diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index ea26c15..d4f64b7 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -12,6 +12,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs index e948f30..65ef81a 100644 --- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs +++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs @@ -67,6 +67,18 @@ Task> GetMonumentsAsync( ulong entityId, CancellationToken cancellationToken); + /// Reads a storage monitor's contents for a (guild, server), or null when there is no live socket. + /// The guild snowflake. + /// The server id. + /// The storage-monitor entity id. + /// A cancellation token. + /// The contents snapshot, or null when unreachable. + Task GetStorageContentsAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken); + /// Sets a smart switch on/off; returns false when there is no live socket or the call fails. /// The owning guild snowflake. /// The target server id. diff --git a/src/RustPlusBot.Abstractions/Connections/StorageContentsSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/StorageContentsSnapshot.cs new file mode 100644 index 0000000..77c8c0d --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/StorageContentsSnapshot.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// A point-in-time read of a storage monitor's contents and protection state. +/// Total slot count (24 = Tool Cupboard, 48 = large box, 12 = small box), or null if unknown. +/// For a Tool Cupboard: whether decay protection is active. Null for non-TC monitors. +/// When decay protection expires (UTC). Only meaningful when is true. +/// The stacks currently inside the monitor. +public sealed record StorageContentsSnapshot( + int? Capacity, + bool? HasProtection, + DateTimeOffset? ProtectionExpiry, + IReadOnlyList Items); diff --git a/src/RustPlusBot.Abstractions/Connections/StorageItemSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/StorageItemSnapshot.cs new file mode 100644 index 0000000..1af0093 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/StorageItemSnapshot.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// One stack inside a storage monitor: the item id, its quantity, and whether it is a blueprint. +/// The Rust item id (resolve to a display name via the item-name lookup). +/// The stack quantity. +/// True when the stack is a blueprint rather than the item itself. +public sealed record StorageItemSnapshot(int ItemId, int Quantity, bool IsBlueprint); diff --git a/src/RustPlusBot.Abstractions/Events/StorageMonitorPairedEvent.cs b/src/RustPlusBot.Abstractions/Events/StorageMonitorPairedEvent.cs new file mode 100644 index 0000000..414b8b9 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/StorageMonitorPairedEvent.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Abstractions.Events; + +/// A storage monitor was paired in-game via FCM; the feature offers an "Add it?" prompt. +/// The owning Discord guild snowflake. +/// The local Rust server id. +/// The in-game storage-monitor entity id. +public sealed record StorageMonitorPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId); diff --git a/src/RustPlusBot.Abstractions/Events/StorageMonitorTriggeredEvent.cs b/src/RustPlusBot.Abstractions/Events/StorageMonitorTriggeredEvent.cs new file mode 100644 index 0000000..cba3ea1 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/StorageMonitorTriggeredEvent.cs @@ -0,0 +1,14 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Events; + +/// A managed storage monitor's contents were (re)read — on connect-prime or on an in-game change. +/// The owning Discord guild snowflake. +/// The local Rust server id. +/// The in-game entity id (the discriminant — the feature filters to ids it manages). +/// The contents snapshot carried on the read/broadcast. +public sealed record StorageMonitorTriggeredEvent( + ulong GuildId, + Guid ServerId, + ulong EntityId, + StorageContentsSnapshot Contents); diff --git a/src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs b/src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs new file mode 100644 index 0000000..bf9086f --- /dev/null +++ b/src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs @@ -0,0 +1,29 @@ +namespace RustPlusBot.Domain.StorageMonitors; + +/// A paired Smart Storage Monitor the bot manages, surviving restarts. Guild- and server-scoped. +public sealed class SmartStorageMonitor +{ + /// Surrogate primary key. + public Guid Id { get; set; } = Guid.NewGuid(); + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// The server this monitor belongs to (FK to RustServer, cascade delete). + public Guid ServerId { get; set; } + + /// The in-game storage-monitor entity id. + public ulong EntityId { get; set; } + + /// User-facing label; defaults to a generated "Storage Monitor <EntityId>" (the FCM event carries no name). + public string Name { get; set; } = string.Empty; + + /// The Discord message id of this monitor's embed, or null until first posted. + public ulong? MessageId { get; set; } + + /// The Discord user who accepted (validated) the pairing. + public ulong PairedByUserId { get; set; } + + /// When the monitor was accepted (UTC). + public DateTimeOffset CreatedUtc { get; set; } +} diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 0ddc88b..7f22ead 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -56,6 +56,15 @@ internal interface IRustServerConnection : IAsyncDisposable /// True/false for on/off, or null on failure/timeout. Task GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken); + /// Reads a storage monitor's contents, or null on failure/timeout. Also primes the socket's interest so triggers fire for it thereafter. + /// The in-game storage-monitor entity id. + /// How long to wait for the response. + /// A cancellation token. + /// The contents snapshot, or null on failure/timeout. + Task GetStorageMonitorInfoAsync(ulong entityId, + TimeSpan timeout, + CancellationToken cancellationToken); + /// Sets a smart switch on/off; returns true on success, false on failure/timeout. /// The in-game smart-switch entity id. /// True to turn on, false to turn off. @@ -111,4 +120,7 @@ Task> GetMonumentsAsync(TimeSpan timeout, /// Raised when a managed smart device's state changes in-game; carries the entity id and new state. event EventHandler? SmartDeviceTriggered; + + /// Raised when a managed storage monitor's contents change in-game; carries the entity id and the new contents. + event EventHandler? StorageMonitorTriggered; } diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 8184a07..01f7dea 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Linq; using Microsoft.Extensions.Logging; using RustPlusApi; using RustPlusBot.Abstractions.Connections; @@ -54,6 +55,11 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella CancellationToken cancellationToken) => Task.FromResult(null); + public Task GetStorageMonitorInfoAsync(ulong entityId, + TimeSpan timeout, + CancellationToken cancellationToken) => + Task.FromResult(null); + public Task SetSmartSwitchValueAsync(ulong entityId, bool value, TimeSpan timeout, @@ -94,6 +100,12 @@ public event EventHandler? SmartDeviceTriggered remove { _ = value; } } + public event EventHandler? StorageMonitorTriggered + { + add { _ = value; } + remove { _ = value; } + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } @@ -118,6 +130,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo _rustPlus = new RustPlus(connection); _rustPlus.OnTeamChatReceived += OnTeamChatReceived; _rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered; + _rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered; } public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) @@ -348,6 +361,8 @@ public async Task PromoteToLeaderAsync(ulong steamId, public event EventHandler? SmartDeviceTriggered; + public event EventHandler? StorageMonitorTriggered; + public async Task GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken) @@ -377,6 +392,35 @@ public async Task PromoteToLeaderAsync(ulong steamId, } } + /// + public async Task GetStorageMonitorInfoAsync( + ulong entityId, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + // CONFIRMED (2.0.0-beta.3): GetStorageMonitorInfoAsync(ulong, CancellationToken) returns + // Task>; the read also primes the entity so OnStorageMonitorTriggered + // fires for it thereafter. + var response = await _rustPlus.GetStorageMonitorInfoAsync(entityId, timeoutCts.Token) + .WaitAsync(timeoutCts.Token).ConfigureAwait(false); + return response is { IsSuccess: true, Data: { } info } ? MapContents(info) : null; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } +#pragma warning disable CA1031 // Broad catch: a failed/timed-out storage read returns null; the caller treats null as unreachable. + catch (Exception) +#pragma warning restore CA1031 + { + return null; + } + } + public async Task SetSmartSwitchValueAsync(ulong entityId, bool value, TimeSpan timeout, @@ -558,6 +602,7 @@ public async ValueTask DisposeAsync() { _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; _rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered; + _rustPlus.OnStorageMonitorTriggered -= OnStorageMonitorTriggered; try { // CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1. @@ -575,6 +620,26 @@ public async ValueTask DisposeAsync() private void OnSmartDeviceTriggered(object? sender, RustPlusApi.Data.Events.SmartDeviceEventArg e) => SmartDeviceTriggered?.Invoke(this, new SmartDeviceTrigger(e.Id, e.IsActive)); + private void OnStorageMonitorTriggered(object? sender, RustPlusApi.Data.Events.StorageMonitorEventArg e) => + StorageMonitorTriggered?.Invoke(this, new StorageMonitorTrigger(e.Id, MapContents(e))); + + private static StorageContentsSnapshot MapContents(RustPlusApi.Data.Entities.StorageMonitorInfo info) + { + var items = info.Items is null + ? (IReadOnlyList)[] + : + [ + .. info.Items.Select(i => + new StorageItemSnapshot(i.Id, i.Quantity ?? 0, i.IsItemBlueprint ?? false)) + ]; + + DateTimeOffset? expiry = info.HasProtection == true + ? new DateTimeOffset(DateTime.SpecifyKind(info.ProtectionExpiry, DateTimeKind.Utc)) + : null; + + return new StorageContentsSnapshot(info.Capacity, info.HasProtection, expiry, items); + } + private static void AddMarkers( List into, IReadOnlyDictionary source, diff --git a/src/RustPlusBot.Features.Connections/Listening/StorageMonitorTrigger.cs b/src/RustPlusBot.Features.Connections/Listening/StorageMonitorTrigger.cs new file mode 100644 index 0000000..0707d85 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/StorageMonitorTrigger.cs @@ -0,0 +1,8 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Connections.Listening; + +/// An in-game storage-monitor broadcast: the entity id and its current contents. +/// The in-game entity id. +/// The contents snapshot carried on the broadcast. +internal sealed record StorageMonitorTrigger(ulong EntityId, StorageContentsSnapshot Contents); diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 0604975..614ba61 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -14,6 +14,7 @@ using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.StorageMonitors; using RustPlusBot.Persistence.Switches; namespace RustPlusBot.Features.Connections.Supervisor; @@ -266,6 +267,23 @@ public async Task> GetMonumentsAsync( .ConfigureAwait(false); } + /// + public async Task GetStorageContentsAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return null; + } + + return await live.Connection + .GetStorageMonitorInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); + } + /// public async Task SetSmartSwitchAsync( ulong guildId, @@ -481,9 +499,17 @@ void OnSmartDevice(object? sender, SmartDeviceTrigger trigger) } #pragma warning restore RCS1163 +#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. + void OnStorage(object? sender, StorageMonitorTrigger trigger) + { + _ = PublishStorageTriggerAsync(key, trigger); + } +#pragma warning restore RCS1163 + var tracker = new TeamStateTracker(); connection.TeamMessageReceived += OnTeamMessage; connection.SmartDeviceTriggered += OnSmartDevice; + connection.StorageMonitorTriggered += OnStorage; _liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker); await PrimeDevicesAsync(key, connection, ct).ConfigureAwait(false); using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -510,6 +536,7 @@ void OnSmartDevice(object? sender, SmartDeviceTrigger trigger) _liveSockets.TryRemove(key, out _); connection.TeamMessageReceived -= OnTeamMessage; connection.SmartDeviceTriggered -= OnSmartDevice; + connection.StorageMonitorTriggered -= OnStorage; } } @@ -868,6 +895,48 @@ await PrimeEntityIdsAsync(key, connection, await PrimeEntityIdsAsync(key, connection, (IReadOnlyList)[.. alarms.Select(a => a.EntityId)]).ConfigureAwait(false); + + IReadOnlyList monitors; + try + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + monitors = await store.ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed monitor-list read just skips storage priming for this connection. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeviceListFailed(logger, ex, key.Server); + return; + } + +#pragma warning disable S3267 // Not a projection: each iteration awaits with per-entity best-effort error handling. + foreach (var monitor in monitors) +#pragma warning restore S3267 + { + try + { + await PublishStoragePrimeAsync(key, connection, monitor.EntityId).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a single monitor's prime failure is logged and skipped. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDevicePrimeFailed(logger, ex, monitor.EntityId, key.Server); + } + } } private async Task PrimeEntityIdsAsync( @@ -963,6 +1032,76 @@ await eventBus.PublishAsync( } } + /// Trigger path: contents are carried on the broadcast arg — no re-read. + /// The (guild, server) routing key. + /// The storage trigger carrying the entity id and contents. + private async Task PublishStorageTriggerAsync((ulong Guild, Guid Server) key, StorageMonitorTrigger trigger) + { + if (_disposed) + { + return; + } + + try + { + await eventBus.PublishAsync( + new StorageMonitorTriggeredEvent(key.Guild, key.Server, trigger.EntityId, trigger.Contents), + _shutdown.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a storage publish failure must not crash the socket callback. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDevicePublishFailed(logger, ex, trigger.EntityId, key.Server); + } + } + + /// Prime path: read contents on connect (also primes the socket's interest), then publish. + /// The (guild, server) routing key. + /// The live connection used to read contents. + /// The storage-monitor entity id to prime. + private async Task PublishStoragePrimeAsync( + (ulong Guild, Guid Server) key, + IRustServerConnection connection, + ulong entityId) + { + if (_disposed) + { + return; + } + + try + { + var contents = await connection + .GetStorageMonitorInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token) + .ConfigureAwait(false); + if (contents is null) + { + return; // unreachable read; the relay leaves the embed as-is (or unreachable via status events). + } + + await eventBus.PublishAsync( + new StorageMonitorTriggeredEvent(key.Guild, key.Server, entityId, contents), + _shutdown.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a storage prime failure must not crash the connect path. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDevicePublishFailed(logger, ex, entityId, key.Server); + } + } + [LoggerMessage(Level = LogLevel.Warning, Message = "Marker poll for server {ServerId} failed.")] private static partial void LogMarkerPollFailed(ILogger logger, Exception exception, Guid serverId); diff --git a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs index f91f7f0..ed37378 100644 --- a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs +++ b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs @@ -70,6 +70,7 @@ public RustPlusFcmListener( _fcm.OnServerPairing += OnServerPairing; _fcm.OnSmartSwitchPairing += OnSmartSwitchPairing; _fcm.OnSmartAlarmPairing += OnSmartAlarmPairing; + _fcm.OnStorageMonitorPairing += OnStorageMonitorPairing; } /// @@ -103,6 +104,7 @@ public async ValueTask DisposeAsync() _fcm.OnServerPairing -= OnServerPairing; _fcm.OnSmartSwitchPairing -= OnSmartSwitchPairing; _fcm.OnSmartAlarmPairing -= OnSmartAlarmPairing; + _fcm.OnStorageMonitorPairing -= OnStorageMonitorPairing; await _fcm.DisposeAsync().ConfigureAwait(false); } @@ -169,6 +171,23 @@ private void OnSmartAlarmPairing(object? sender, Notification e) EntityKind: RustPlusBot.Domain.Entities.PairedEntityKind.SmartAlarm)); } + private void OnStorageMonitorPairing(object? sender, Notification e) + { + if (e?.Data is not { } entityId) + { + return; + } + + Dispatch(new PairingNotification( + Kind: PairingKind.Entity, + ServerName: string.Empty, Ip: string.Empty, Port: 0, + PlayerId: e.PlayerId, + PlayerToken: e.PlayerToken.ToString(System.Globalization.CultureInfo.InvariantCulture), + FacepunchServerId: e.ServerId, + EntityId: entityId, + EntityKind: RustPlusBot.Domain.Entities.PairedEntityKind.StorageMonitor)); + } + private void Dispatch(PairingNotification notification) { // Fire-and-forget bridge from synchronous event to async callback. diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs index 16455ad..1343e13 100644 --- a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs +++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs @@ -83,6 +83,12 @@ await eventBus .PublishAsync(new AlarmPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken) .ConfigureAwait(false); break; + case RustPlusBot.Domain.Entities.PairedEntityKind.StorageMonitor: + await eventBus + .PublishAsync(new StorageMonitorPairedEvent(guildId, server.Id, notification.EntityId), + cancellationToken) + .ConfigureAwait(false); + break; default: LogUnroutedEntityKind(logger, notification.EntityKind); break; diff --git a/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs b/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs new file mode 100644 index 0000000..e5ea54f --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.StorageMonitors.Pairing; +using RustPlusBot.Features.StorageMonitors.Relaying; + +namespace RustPlusBot.Features.StorageMonitors.Hosting; + +/// Runs the storage-monitor pairing loop, the triggered relay loop, and the connection-status relay loop. +/// The in-process event bus. +/// Handles paired storage monitors. +/// Re-renders storage monitors on trigger/connection changes. +/// The logger. +internal sealed partial class StorageMonitorsHostedService( + IEventBus eventBus, + StorageMonitorPairingCoordinator coordinator, + StorageMonitorStateRelay relay, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _pairedLoop; + private Task? _statusLoop; + private Task? _triggeredLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None); + _triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None); + _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] + { + _pairedLoop, _triggeredLoop, _statusLoop + }.Where(t => t is not null)) + { + try + { +#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop. + await loop!.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + private async Task ConsumePairedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await coordinator.HandlePairedAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPairedLoopFaulted(logger, ex); + } + } + + private async Task ConsumeTriggeredAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.HandleTriggeredAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogTriggeredLoopFaulted(logger, ex); + } + } + + private async Task ConsumeStatusAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.HandleConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogStatusLoopFaulted(logger, ex); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor pairing loop faulted.")] + private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor triggered relay loop faulted.")] + private static partial void LogTriggeredLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor connection-status relay loop faulted.")] + private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorComponentModule.cs b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorComponentModule.cs new file mode 100644 index 0000000..62dc06d --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorComponentModule.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.StorageMonitors.Pairing; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Persistence.StorageMonitors; + +namespace RustPlusBot.Features.StorageMonitors.Modules; + +/// Thin handler for the #storagemonitors pairing prompt + Refresh/Rename. Any guild member. +/// Creates a short-lived DI scope per interaction. +/// Live socket read (for Refresh). +/// Publishes a triggered event to drive an embed refresh. +public sealed class StorageMonitorComponentModule( + IServiceScopeFactory scopeFactory, + IRustServerQuery query, + IEventBus eventBus) : InteractionModuleBase +{ + private const string InvalidControlMessage = "That control wasn't valid."; + + /// Accepts a pending pairing prompt and starts managing the monitor. + /// The "{serverId}:{entityId}" custom-id tail. + [ComponentInteraction(StorageMonitorComponentIds.AcceptPrefix + "*")] + public async Task AcceptAsync(string tail) + { + if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var coordinator = scope.ServiceProvider.GetRequiredService(); + var accepted = await coordinator + .TryAcceptAsync(Context.Guild.Id, serverId, entityId, Context.User.Id, CancellationToken.None) + .ConfigureAwait(false); + await FollowupAsync(accepted ? "Storage monitor added." : "That monitor is already managed.", + ephemeral: true).ConfigureAwait(false); + } + } + + /// Dismisses a pending pairing prompt and removes the transient prompt message. + /// The "{serverId}:{entityId}" custom-id tail. + [ComponentInteraction(StorageMonitorComponentIds.DismissPrefix + "*")] + public async Task DismissAsync(string tail) + { + if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var coordinator = scope.ServiceProvider.GetRequiredService(); + coordinator.TryDismiss(Context.Guild.Id, serverId, entityId); + } + + await DeletePromptMessageSafeAsync().ConfigureAwait(false); + await RespondAsync("Dismissed.", ephemeral: true).ConfigureAwait(false); + } + + /// Re-reads the monitor's contents and republishes them so the embed refreshes. + /// The "{serverId}:{entityId}" custom-id tail. + [ComponentInteraction(StorageMonitorComponentIds.RefreshPrefix + "*")] + public async Task RefreshAsync(string tail) + { + if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var contents = await query + .GetStorageContentsAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None) + .ConfigureAwait(false); + if (contents is null) + { + await FollowupAsync("Storage monitor is unreachable right now.", ephemeral: true).ConfigureAwait(false); + return; + } + + await eventBus + .PublishAsync(new StorageMonitorTriggeredEvent(Context.Guild.Id, serverId, entityId, contents)) + .ConfigureAwait(false); + await FollowupAsync("Refreshed.", ephemeral: true).ConfigureAwait(false); + } + + /// Opens the rename modal, carrying the target tail in the modal custom id. + /// The "{serverId}:{entityId}" custom-id tail. + [ComponentInteraction(StorageMonitorComponentIds.RenamePrefix + "*")] + public async Task RenamePromptAsync(string tail) + { + if (!TryParse(tail, out _, out _) || Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + await RespondWithModalAsync(StorageMonitorComponentIds.RenameModalPrefix + tail) + .ConfigureAwait(false); + } + + /// Persists the new name, then republishes current contents so the embed refreshes. + /// The "{serverId}:{entityId}" custom-id tail. + /// The submitted rename modal. + [ModalInteraction(StorageMonitorComponentIds.RenameModalPrefix + "*")] + public async Task RenameSubmitAsync(string tail, StorageMonitorRenameModal modal) + { + ArgumentNullException.ThrowIfNull(modal); + if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null) + { + await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false); + return; + } + + var name = string.IsNullOrWhiteSpace(modal.Name) + ? "Storage Monitor " + entityId.ToString(CultureInfo.InvariantCulture) + : modal.Name.Trim(); + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + await store.RenameAsync(Context.Guild.Id, serverId, entityId, name, CancellationToken.None) + .ConfigureAwait(false); + } + + // Re-read (may be null if unreachable) and republish so the relay re-renders the renamed embed. + var contents = await query + .GetStorageContentsAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None) + .ConfigureAwait(false); + if (contents is not null) + { + await eventBus + .PublishAsync(new StorageMonitorTriggeredEvent(Context.Guild.Id, serverId, entityId, contents)) + .ConfigureAwait(false); + } + + await FollowupAsync("Renamed.", ephemeral: true).ConfigureAwait(false); + } + + private static bool TryParse(string tail, out Guid serverId, out ulong entityId) + { + serverId = Guid.Empty; + entityId = 0UL; + if (tail is null) + { + return false; + } + + var parts = tail.Split(':'); + return parts.Length == 2 + && Guid.TryParse(parts[0], out serverId) + && ulong.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out entityId); + } + + private async Task DeletePromptMessageSafeAsync() + { + try + { + // The source message of a component interaction is the prompt that carries the button. + if (Context.Interaction is IComponentInteraction component) + { + await component.Message.DeleteAsync().ConfigureAwait(false); + } + } +#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal. + catch (Exception ex) +#pragma warning restore CA1031 + { + // Ignore: the prompt is transient and harmless if it lingers. + _ = ex; + } + } +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorRenameModal.cs b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorRenameModal.cs new file mode 100644 index 0000000..b85e35d --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorRenameModal.cs @@ -0,0 +1,17 @@ +using Discord; +using Discord.Interactions; +using RustPlusBot.Features.StorageMonitors.Rendering; + +namespace RustPlusBot.Features.StorageMonitors.Modules; + +/// The modal that collects a new storage-monitor name. Handled by the storage-monitor component module. +public sealed class StorageMonitorRenameModal : IModal +{ + /// The new name. + [InputLabel("Storage monitor name")] + [ModalTextInput(StorageMonitorComponentIds.RenameInputId, TextInputStyle.Short, maxLength: 128)] + public string Name { get; set; } = string.Empty; + + /// + public string Title => "Rename storage monitor"; +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Naming/EmbeddedItemNameResolver.cs b/src/RustPlusBot.Features.StorageMonitors/Naming/EmbeddedItemNameResolver.cs new file mode 100644 index 0000000..cbfed0a --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Naming/EmbeddedItemNameResolver.cs @@ -0,0 +1,37 @@ +using System.Collections.Frozen; +using System.Globalization; +using System.Text.Json; + +namespace RustPlusBot.Features.StorageMonitors.Naming; + +/// Resolves item ids from the bundled items.json (Facepunch's published item names). Singleton. +public sealed class EmbeddedItemNameResolver : IItemNameResolver +{ + private static readonly FrozenDictionary Names = Load(); + + /// + public string Resolve(int itemId) => + Names.TryGetValue(itemId, out var name) + ? name + : "Item " + itemId.ToString(CultureInfo.InvariantCulture); + + private static FrozenDictionary Load() + { + var assembly = typeof(EmbeddedItemNameResolver).Assembly; + var resourceName = assembly.GetManifestResourceNames() + .Single(n => n.EndsWith("items.json", StringComparison.Ordinal)); + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException("Embedded items.json not found."); + var raw = JsonSerializer.Deserialize>(stream) + ?? throw new InvalidOperationException("items.json deserialized to null."); + // Group by the parsed id (last wins) before freezing: two distinct string keys can parse to the + // same int (NumberStyles.Integer allows leading sign/whitespace), and ToFrozenDictionary throws on a + // duplicate key — which, in a static initializer, would hard-fault the feature. Degrade, never crash. + return raw + .Select(kvp => (Parsed: int.TryParse(kvp.Key, NumberStyles.Integer, CultureInfo.InvariantCulture, + out var id), Id: id, kvp.Value)) + .Where(x => x.Parsed) + .GroupBy(x => x.Id, x => x.Value) + .ToFrozenDictionary(g => g.Key, g => g.Last()); + } +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Naming/IItemNameResolver.cs b/src/RustPlusBot.Features.StorageMonitors/Naming/IItemNameResolver.cs new file mode 100644 index 0000000..bbe9a01 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Naming/IItemNameResolver.cs @@ -0,0 +1,10 @@ +namespace RustPlusBot.Features.StorageMonitors.Naming; + +/// Resolves a Rust item id to a human-readable display name. +public interface IItemNameResolver +{ + /// Gets the display name for an item id, or a stable "Item {id}" fallback when unknown. + /// The Rust item id. + /// The display name, or "Item {id}" if not in the bundled lookup. + string Resolve(int itemId); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Naming/items.json b/src/RustPlusBot.Features.StorageMonitors/Naming/items.json new file mode 100644 index 0000000..531d7b0 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Naming/items.json @@ -0,0 +1,1207 @@ +{ +"-2139580305":"Auto Turret", +"-2134097299":"Tripod Spot Light", +"-2133781216":"Outbreak Scientist Suit", +"-2124352573":"Acoustic Guitar", +"-2123125470":"Advanced Healing Tea", +"-2115299615":"Small Boat Engine", +"-2110553371":"Bamboo Salvaged Shelves", +"-2107018088":"Shovel Bass", +"-2103694546":"Sunglasses", +"-2100458529":"Raw Snake Meat", +"-2099697608":"Stones", +"-2097376851":"Nailgun Nails", +"-2095813057":"Raw Big Cat Meat", +"-2094954543":"Wood Armor Helmet", +"-2086926071":"Potato", +"-2084071424":"Potato Seed", +"-2073432256":"Skinning Knife", +"-2072273936":"Bandage", +"-2069578888":"M249", +"-2068194497":"Lunar New Year Horse Armor", +"-2067472972":"Sheet Metal Door", +"-2058362263":"Small Candle Set", +"-2049214035":"Pressure Pad", +"-2047081330":"Movember Moustache", +"-2040817543":"Pan Flute", +"-2035449523":"Spoiled Deer Meat", +"-2027988285":"Locomotive", +"-2027793839":"Advent Calendar", +"-2026042603":"Baseball Bat", +"-2025184684":"Shirt", +"-2024549027":"Heavy Frankenstein Legs", +"-2022172587":"Diving Tank", +"-2018158920":"Chicken Coop", +"-2016974826":"Wall Divider Pack", +"-2012470695":"Improvised Balaclava", +"-2002277461":"Road Sign Jacket", +"-2001260025":"Instant Camera", +"-1999722522":"Furnace", +"-1998423571":"Explosives Storage Box", +"-1997698639":"Sunglasses", +"-1997543660":"Horse Saddle", +"-1994909036":"Sheet Metal", +"-1993883724":"High External Legacy Wall", +"-1992717673":"Large Furnace", +"-1989600732":"Hot Air Balloon Armor", +"-1987565603":"Incendiary Bolt", +"-1985799200":"Rug", +"-1982036270":"High Quality Metal Ore", +"-1978999529":"Salvaged Cleaver", +"-1973785141":"Fogger-3000", +"-1966748496":"Mace", +"-1962971928":"Mushroom", +"-1961560162":"Firecracker String", +"-1958316066":"Scientist Suit", +"-1950721390":"Concrete Barricade", +"-1944704288":"Ice Throne", +"-1941646328":"Can of Tuna", +"-1938052175":"Charcoal", +"-1937799374":"Naval Scientist Suit", +"-1923843855":"Half Height Bamboo Shelves", +"-1916473915":"Chinese Lantern", +"-1913996738":"Fish Trophy", +"-1904821376":"Orange Roughy", +"-1903165497":"Bone Helmet", +"-1901993050":"Ornate Frame Medium", +"-1899491405":"Glue", +"-1896395719":"Advanced Blueprint Fragment", +"-1880870149":"Red Keycard", +"-1880231361":"Flatbed Vehicle Module", +"-1878764039":"Small Trout", +"-1878475007":"Satchel Charge", +"-1866909924":"Steering Wheel", +"-1863559151":"Water Barrel", +"-1863063690":"Rocking Chair", +"-1861522751":"Research Table", +"-1850571427":"Military Silencer", +"-1850297170":"Small Spike Trap", +"-1848736516":"Cooked Chicken", +"-1843426638":"MLRS Rocket", +"-1841918730":"High Velocity Rocket", +"-1836526520":"Ornate Frame Small", +"-1832422579":"One Sided Town Sign Post", +"-1827561369":"Propane Explosive Bomb", +"-1824943010":"Jack O Lantern Happy", +"-1824770114":"Sky Lantern - Orange", +"-1819763926":"Wind Turbine", +"-1819233322":"Medium Wooden Sign", +"-1815301988":"Water Pistol", +"-1812555177":"LR-300 Assault Rifle", +"-1811234677":"Beehive Nucleus", +"-1804515496":"Gold Mirror Medium", +"-1802083073":"High Quality Valves", +"-1800345240":"Speargun Spear", +"-1796837031":"Spoiled Crocodile Meat", +"-1790885730":"Wheat Seed", +"-1785248332":"Fish Pie", +"-1785231475":"Surgeon Scrubs", +"-1782127806":"Star Balloon", +"-1780802565":"Salvaged Icepick", +"-1779203452":"Portable Easel", +"-1779183908":"Paper", +"-1779180711":"Water", +"-1778897469":"Button", +"-1778159885":"Heavy Plate Pants", +"-1776128552":"Green Berry Seed", +"-1774190142":"Shutter Frame Standing", +"-1773144852":"Hide Skirt", +"-1772746857":"Heavy Scientist Suit", +"-1770889433":"Sky Lantern - Green", +"-1770281406":"Krieg chainsword", +"-1768880890":"Small Shark", +"-1767794021":"Gas Compression Overdrive", +"-1758372725":"Thompson", +"-1754948969":"Sleeping Bag", +"-1736356576":"Reactive Target", +"-1732475823":"Medium Frankenstein Head", +"-1729415579":"Advanced Anti-Rad Tea", +"-1709878924":"Raw Human Meat", +"-1707425764":"Fishing Tackle", +"-1698937385":"Herring", +"-1696379844":"Hazmat Youtooz", +"-1695367501":"Shorts", +"-1693832478":"Large Flatbed Vehicle Module", +"-1691396643":"HV Pistol Ammo", +"-1685290200":"12 Gauge Buckshot", +"-1679267738":"Graveyard Fence", +"-1677315902":"Pure Healing Tea", +"-1673693549":"Empty Propane Tank", +"-1671551935":"Torpedo", +"-1667224349":"Decorative Baubels", +"-1663759755":"Homemade Landmine", +"-1659598760":"Soda Can Silencer", +"-1654401345":"Medieval Sheet Metal Door", +"-1654233406":"Sardine", +"-1652561344":"Bamboo Barrel", +"-1651220691":"Pookie Bear", +"-1647846966":"Two Sided Ornate Hanging Sign", +"-1647389398":"Frankenstein Mask", +"-1624770297":"Light Frankenstein Torso", +"-1622660759":"Large Present", +"-1622110948":"Bandit Guard Gear", +"-1621539785":"Beach Parasol", +"-1616704051":"Spoiled Snake Meat", +"-1615281216":"Armored Passenger Vehicle Module", +"-1614955425":"Strengthened Glass Window", +"-1607980696":"Workbench Level 3", +"-1593678393":"Ammo Storage Box", +"-1588628467":"Computer Station", +"-1583967946":"Stone Hatchet", +"-1581843485":"Sulfur", +"-1579932985":"Horse Dung", +"-1569700847":"Headset", +"-1559420426":"Double Diving Tank", +"-1557377697":"Empty Tuna Can", +"-1553999294":"Red Boomer", +"-1549739227":"Boots", +"-1541706279":"Wood Frame Medium", +"-1539025626":"Miners Hat", +"-1538109120":"Violet Volcano Firework", +"-1536855921":"Shovel", +"-1535621066":"Stone Fireplace", +"-1530414568":"Cassette Recorder", +"-1528767189":"Ornate Frame Standing", +"-1520560807":"Raw Bear Meat", +"-1519126340":"Drop Box", +"-1518883088":"Night Vision Goggles", +"-1517740219":"Speargun", +"-1513203236":"Honeycomb", +"-1511285251":"Pumpkin Seed", +"-1510616686":"Chandelier", +"-1509851560":"Cooked Deer Meat", +"-1507239837":"HBHF Sensor", +"-1506417026":"Ninja Suit", +"-1506397857":"Salvaged Hammer", +"-1501451746":"Cockpit Vehicle Module", +"-1498613415":"Advanced Cooling Tea", +"-1497205569":"Wood Mirror Small", +"-1488408786":"Pumpkin Pie", +"-1488398114":"Composter", +"-1486461488":"Red Roman Candle", +"-1478855279":"Ice Metal Chest Plate", +"-1478445584":"Tuna Can Lamp", +"-1478212975":"Wolf Headdress", +"-1478094705":"Boogie Board", +"-1476814093":"Pure Warming Tea", +"-1476278729":"Wood Frame Small", +"-1469578201":"Longsword", +"-1467876094":"#50cal", +"-1449152644":"MLRS", +"-1448252298":"Electrical Branch", +"-1444650226":"Gold Mirror Small", +"-1442559428":"Hobo Barrel", +"-1442496789":"Pinata", +"-1442339204":"High External Legacy Gate", +"-1440987069":"Raw Chicken Breast", +"-1440443161":"Clump of Latex Balloons", +"-1433390281":"Sky Lantern - Red", +"-1432674913":"Anti-Radiation Pills", +"-1430299277":"Ornate Frame XL", +"-1429456799":"Prison Cell Wall", +"-1423304443":"Medium Neon Sign", +"-1421257350":"Storage Barrel Horizontal", +"-1417478274":"Motorbike", +"-1416322465":"Walkie Talkie", +"-1408336705":"Sunglasses", +"-1405508498":"Muzzle Boost", +"-1386082991":"Purple ID Tag", +"-1385721419":"Advanced Harvesting Tea", +"-1380144986":"Scrap Mirror Standing", +"-1379835144":"Festive Window Garland", +"-1379036069":"Canbourine", +"-1370759135":"Portrait Picture Frame", +"-1368584029":"Sickle", +"-1367281941":"Waterpipe Shotgun", +"-1366326648":"Spray Can Decal", +"-1364246987":"Snowmobile", +"-1360171080":"Concrete Pickaxe", +"-1344017968":"Wanted Poster", +"-1336109173":"Wood Double Door", +"-1335497659":"Ice Assault Rifle", +"-1334569149":"Hockey Mask", +"-1334255764":"Minicopter", +"-1331212963":"Star Tree Topper", +"-1330640246":"Junkyard Drum Kit", +"-1323101799":"Double Horse Saddle", +"-1322332389":"Ornate Frame XXL", +"-1321651331":"Explosive 5.56 Rifle Ammo", +"-1318837358":"Cooked Big Cat Meat", +"-1316706473":"Camera", +"-1315992997":"Dragon Rocket Launcher", +"-1314079879":"Snake mask", +"-1310391395":"Legacy Furnace", +"-1306288356":"Green Roman Candle", +"-1305326964":"Green Berry Clone", +"-1302129395":"Pickaxe", +"-1294739579":"Light-Up Frame Medium", +"-1293296287":"Small Oil Refinery", +"-1290278434":"Siege Tower", +"-1286302544":"OR Switch", +"-1284169891":"Water Pump", +"-1274093662":"Bath Tub Planter", +"-1273339005":"Bed", +"-1266045928":"Bunny Onesie", +"-1265020883":"Wanted Poster 3", +"-1262185308":"Binoculars", +"-1260229965":"Basic Cooling Tea", +"-1258821205":"Spot Light", +"-1252059217":"Hatchet", +"-1247485104":"Command Block", +"-1244287686":"Shutter Frame XL", +"-1234735557":"Wooden Arrow", +"-1230433643":"Festive Double Doorway Garland", +"-1220928936":"Leather Beanbag Seat", +"-1215753368":"Flame Thrower", +"-1215166612":"A Barrel Costume", +"-1214542497":"HMLMG", +"-1211801774":"Shutter Frame XXL", +"-1211268013":"Basic Horse Shoes", +"-1211166256":"5.56 Rifle Ammo", +"-1199897172":"Metal Vertical embrasure", +"-1199897169":"Metal horizontal embrasure", +"-1196547867":"Electric Furnace", +"-1184406448":"Basic Max Health Tea", +"-1183726687":"Wooden Window Bars", +"-1175656359":"Cultist Deer Torch", +"-1167031859":"Spoiled Wolf Meat", +"-1166712463":"Fluid Splitter", +"-1163943815":"Weapon Rack Light", +"-1163532624":"Jacket", +"-1162759543":"Cooked Horse Meat", +"-1160621614":"Red Industrial Wall Light", +"-1157596551":"Sulfur Ore", +"-1151332840":"Wooden Frontier Bar Doors", +"-1142222427":"Basic Warming Tea", +"-1138208076":"Small Wooden Sign", +"-1137865085":"Machete", +"-1130709577":"Pump Jack", +"-1130350864":"Raw Horse Meat", +"-1127003365":"Piercer Bolt", +"-1123473824":"Multiple Grenade Launcher", +"-1117626326":"Chainlink Fence", +"-1113501606":"Boom Box", +"-1112793865":"Door Key", +"-1108136649":"Tactical Gloves", +"-1104881824":"Bear Skin Rug", +"-1102429027":"Heavy Plate Jacket", +"-1101924344":"Wetsuit", +"-1100422738":"Spinning Wheel", +"-1100168350":"Large Water Catcher", +"-1094453063":"Shutter Frame large", +"-1081599445":"Raw Crocodile Meat", +"-1078639462":"Skull Spikes", +"-1073015016":"Skull Spikes", +"-1063073030":"Wooden Boat Door", +"-1060567807":"Shutter Frame Medium", +"-1056824343":"Diver propulsion vehicle", +"-1050697733":"Scrap Mirror Small", +"-1049881973":"Cowbell", +"-1049172752":"Storage Adaptor", +"-1044468317":"RF Broadcaster", +"-1043618880":"Ghost Costume", +"-1040518150":"Camper Vehicle Module", +"-1039528932":"Small Water Bottle", +"-1039234836":"Paintable Reactive Target", +"-1037472336":"Rose Seed", +"-1036635990":"12 Gauge Incendiary Shell", +"-1035206446":"Clothing Mannequin", +"-1023374709":"Wood Shutters", +"-1023065463":"High Velocity Arrow", +"-1022661119":"Baseball Cap", +"-1021495308":"Metal Spring", +"-1018587433":"Animal Fat", +"-1014934560":"Paintball Overalls", +"-1009359066":"SAM Site", +"-1004426654":"Bunny Ears", +"-1003665711":"Super Serum", +"-1002156085":"Gold Egg", +"-1000573653":"Frog Boots", +"-996920608":"Blueprint", +"-996235148":"Ornate Frame large", +"-996185386":"XL Picture Frame", +"-992286106":"White Berry Seed", +"-989755543":"Burnt Bear Meat", +"-986782031":"Rabbit Mask", +"-985781766":"High Ice Wall", +"-979951147":"Jerry Can Guitar", +"-979302481":"Easter Door Wreath", +"-967648160":"High External Stone Wall", +"-965336208":"Chocolate Bar", +"-963820355":"Survivor's Pie", +"-963819285":"Incapacitate Dart", +"-961457160":"New Year Gong", +"-956706906":"Prison Cell Gate", +"-952411326":"Plank", +"-948291630":"Seismic Sensor", +"-946599131":"Artist Canvas Medium", +"-946599114":"Artist Canvas Standing", +"-946599113":"Artist Canvas Large", +"-946369541":"Low Grade Fuel", +"-945708533":"Knights armour skirt plates", +"-939424778":"Flasher Light", +"-936921910":"Flashbang", +"-935322684":"4 Module Car", +"-932201673":"Scrap", +"-930193596":"Fertilizer", +"-929092070":"Basic Healing Tea", +"-924959988":"Skull Trophy", +"-919882824":"Abyss Vertical Storage Tank", +"-912398867":"Cassette - Medium", +"-907422733":"Large Backpack", +"-904863145":"Semi-Automatic Rifle", +"-903796529":"Asbestos Armor Insert", +"-902423513":"Krieg Hazmat", +"-901370585":"Twitch Rivals Trophy 2023", +"-892718768":"Prisoner Hood", +"-888153050":"Halloween Candy", +"-886280491":"Hemp Clone", +"-885833256":"Vampire Stake", +"-880494890":"Abyss Horizontal Storage Tank", +"-880412831":"Blunderbuss", +"-874908751":"Waterwell NPC Jumpsuit", +"-874650016":"Krieg Large Backpack", +"-870140677":"Snake Venom", +"-869598982":"Small Hunting Trophy", +"-866121090":"2 Module Car", +"-858312878":"Cloth", +"-855748505":"Simple Handmade Sight", +"-854270928":"Dragon Door Knocker", +"-852563019":"M92 Pistol", +"-851988960":"Salmon", +"-851288382":"Blow Pipe", +"-850982208":"Key Lock", +"-849373693":"Frontier Horseshoe Single Item Rack", +"-845557339":"Landscape Picture Frame", +"-842267147":"Snowman Helmet", +"-839576748":"Handcuffs", +"-831725027":"3 Module Car", +"-819720157":"Metal Window Bars", +"-816769770":"Artist Canvas XXL", +"-810326667":"Work Cart", +"-804769727":"Plant Fiber", +"-803263829":"Coffee Can Helmet", +"-800824218":"Meds Storage Box", +"-798662404":"Orchid Clone", +"-798293154":"Laser Detector", +"-797592358":"Abyss Pack", +"-796583652":"Shop Front", +"-784870360":"Electric Heater", +"-781866273":"Oil Filter Silencer", +"-781014061":"Sprinkler", +"-778875547":"Corn Clone", +"-778367295":"L96 Rifle", +"-770304148":"Chinese Lantern White", +"-769647921":"Skull Trophy", +"-765183617":"Double Barrel Shotgun", +"-763071910":"Lumberjack Hoodie", +"-761829530":"Burlap Shoes", +"-759279626":"Mounted Ballista", +"-751151717":"Spoiled Chicken", +"-747743875":"Egg Suit", +"-746647361":"Memory Cell", +"-746030907":"Granola Bar", +"-742865266":"Rocket", +"-739993590":"Twitch Rivals Flag", +"-733625651":"Paddling Pool", +"-727717969":"12 Gauge Slug", +"-724146494":"Spoiled Horse Meat", +"-722629980":"Heavy Scientist Youtooz", +"-722241321":"Small Present", +"-707792719":"Paintball Gun", +"-702051347":"Bandana Mask", +"-700591459":"Can of Beans", +"-699558439":"Road Sign Gloves", +"-697981032":"Inner Tube", +"-695978112":"Smart Alarm", +"-695124222":"Giant Candy Decor", +"-692338819":"Small Rechargeable Battery", +"-691113464":"High External Stone Gate", +"-690968985":"Blocker", +"-690276911":"Glowing Eyes", +"-682687162":"Burnt Human Meat", +"-656349006":"Green Boomer", +"-652889722":"Advanced Crafting Quality Tea", +"-649128577":"Basic Wood Tea", +"-648077743":"Mr Spice Can", +"-635951327":"Wood Frame Large", +"-629028935":"Electric Fuse", +"-626174997":"Taxi Vehicle Module", +"-611118083":"Sunflower", +"-606898372":"#clothingmannequin", +"-602717596":"Red Dog Tags", +"-601133933":"Armoured Triangle Laddder Hatch", +"-596876839":"Spray Can", +"-594596146":"Radiation Dart", +"-593892112":"Wooden Armor Insert", +"-592016202":"Explosives", +"-587989372":"Catfish", +"-586784898":"Mail Box", +"-586342290":"Blueberries", +"-583379016":"Megaphone", +"-582782051":"Snap Trap", +"-582467439":"Coconut Armor Helmet", +"-576866254":"Fabric Beanbag Seat", +"-575744869":"Party Hat", +"-575483084":"Santa Hat", +"-568419968":"Grub", +"-567909622":"Pumpkin", +"-566907190":"RF Pager", +"-563624462":"Splitter", +"-561148628":"Tugboat", +"-560304835":"Space Suit", +"-559599960":"Sandbag Barricade", +"-558880549":"Gingerbread Suit", +"-557539629":"Pure Wood Tea", +"-555122905":"Sofa", +"-551431036":"Wallpaper Flooring", +"-544317637":"Research Paper", +"-544295594":"Guns Storage Box", +"-542577259":"Minnows", +"-541206665":"Advanced Wood Tea", +"-526026171":"Wicker Barrel", +"-520133715":"Yellow Berry Seed", +"-515830359":"Blue Roman Candle", +"-507248640":"Wellipets Hat", +"-502177121":"Door Controller", +"-498301781":"Shutter Frame Small", +"-496584751":"Rad. Removal Tea", +"-493159321":"Medium Quality Spark Plugs", +"-489848205":"Large Candle Set", +"-487356515":"Basic Anti-Rad Tea", +"-484206264":"Blue Keycard", +"-484006286":"Firebomb", +"-482348853":"Mini Crossbow", +"-479314201":"Battering Ram Head", +"-478923685":"Armored Triangle Ladder Hatch", +"-470439097":"Arctic Suit", +"-465682601":"SUPER Stocking", +"-463122489":"Watch Tower", +"-463012608":"Salvaged Ejector Seat", +"-459159118":"Wood Armor Gloves", +"-458565393":"Root Combiner", +"-455286320":"Gray ID Tag", +"-454370658":"Red Volcano Firework", +"-451310088":"Documents", +"-450890885":"Wall Divider Pack", +"-430416124":"Single Plant Pot", +"-427072335":"Knights armour helmet", +"-424687710":"Medieval Barricade", +"-420889602":"Krieg Shotgun", +"-418359052":"Horse Mask", +"-413663149":"Comps Storage Box", +"-411735114":"Cannonball", +"-401905610":"High External Adobe Gate", +"-399173933":"Prototype Hatchet", +"-395377963":"Raw Wolf Meat", +"-389796733":"Light-Up Mirror Small", +"-384243979":"SAM Ammo", +"-380502678":"Medieval Sheet Metal Double Door", +"-379734527":"Pattern Boomer", +"-379403794":"Water Wheel", +"-374457631":"Sedan", +"-369760990":"Small Stash", +"-365097295":"Powered Water Purifier", +"-363689972":"Snowball", +"-357442017":"Pitchfork Bolt", +"-348232115":"SKS", +"-343857907":"Sound Light", +"-335089230":"High External Wooden Gate", +"-334418777":"Advanced Warming Tea", +"-333406828":"Sled", +"-324675402":"Reindeer Antlers", +"-321733511":"Crude Oil", +"-321431890":"Beach Chair", +"-321247698":"Boat Building Plan", +"-316250604":"Wooden Ladder", +"-297099594":"Heavy Frankenstein Head", +"-295829489":"Test Generator", +"-282193997":"Orange ID Tag", +"-282113991":"Simple Light", +"-280812482":"Triangle Planter Box", +"-280223496":"Violet Boomer", +"-277057363":"Salt Water", +"-274709858":"Wood Dart", +"-265876753":"Gun Powder", +"-265292885":"Fluid Combiner", +"-262590403":"Salvaged Axe", +"-258574361":"Dracula Cape", +"-258457936":"Unused Storage Barrel Vertical", +"-253079493":"Scientist Suit", +"-246672609":"Horizontal Weapon Rack", +"-243540612":"Twitch Rivals Desk", +"-242084766":"Cooked Pork", +"-237809779":"Hemp Seed", +"-226151558":"2 Module Car Chassis", +"-218009552":"Homing Missile Launcher", +"-216999575":"Counter", +"-216116642":"Skull Door Knocker", +"-211235948":"Xylobone", +"-209869746":"Decorative Plastic Candy Canes", +"-196667575":"Flashlight", +"-194953424":"Metal Facemask", +"-194509282":"Butcher Knife", +"-193519904":"Single Shallow Wall Shelves", +"-187304968":"Battering Ram", +"-187031121":"Solo Submarine", +"-180129657":"Wood Storage Box", +"-176608084":"Sunglasses", +"-173268138":"Rustigé Egg - Amethyst", +"-173268132":"Rustigé Egg - Blue", +"-173268131":"Rustigé Egg - Purple", +"-173268129":"Rustigé Egg - Red", +"-173268128":"Rustigé Egg - White", +"-173268127":"Rustigé Egg - Cerulean", +"-173268126":"Rustigé Egg - Ivory", +"-173268125":"Rustigé Egg - Green", +"-170436364":"Cooked Snake Meat", +"-158718378":"Small Ramp", +"-156748077":"Skull Trophy", +"-152332823":"Chicken Costume", +"-151838493":"Wood", +"-151387974":"Deluxe Christmas Lights", +"-148794216":"Garage Door", +"-148229307":"Metal Shop Front", +"-144513264":"Pipe Tool", +"-144417939":"Wire Tool", +"-143481979":"Basic Blueprint Fragment", +"-143132326":"Huge Wooden Sign", +"-139037392":"Abyss Assault Rifle", +"-135252633":"Sled", +"-134959124":"Light Frankenstein Head", +"-132516482":"Weapon Lasersight", +"-132247350":"Small Water Catcher", +"-129230242":"Decorative Pinecones", +"-126305173":"Painted Egg", +"-119235651":"Water Jug", +"-113413047":"Diving Mask", +"-110921842":"Locker", +"-105415879":"Frontier Suit", +"-105343718":"Circle Balloon", +"-99886070":"Violet Roman Candle", +"-97956382":"Tool Cupboard", +"-97459906":"Jumpsuit", +"-96256997":"Wide Weapon Rack", +"-92759291":"Wooden Floor Spikes", +"-92315244":"High Caliber Revolver", +"-89874794":"Low Quality Spark Plugs", +"-82758111":"Scrap Mirror Large", +"-78533081":"Burnt Deer Meat", +"-75944661":"Eoka Pistol", +"-73195037":"Legacy Bow", +"-52398594":"Frontier Horns Single Item Rack", +"-48090175":"Snow Jacket", +"-44876289":"Igniter", +"-44066823":"Medium Chassis", +"-44066790":"Large Chassis", +"-44066600":"Small Chassis", +"-41896755":"Workbench Level 2", +"-41440462":"Spas-12 Shotgun", +"-34498533":"Cannon", +"-33009419":"Pure Anti-Rad Tea", +"-25740268":"Skull Spikes", +"-24571537":"Coconut", +"-23994173":"Boonie Hat", +"-22883916":"Dragon Mask", +"-20045316":"Mobile Phone", +"-19360132":"Rose Clone", +"-19318653":"Hammerhead Bolt", +"-17123659":"Smoke Rocket WIP!!!!", +"-10594280":"Sulfur Storage Box", +"-8312704":"Beach Towel", +"-7270019":"Orange Boomer", +"-4031221":"Metal Ore", +"3222790":"Hide Halterneck", +"3380160":"Card Movember Moustache", +"4384538":"Apple Pie", +"14241751":"Fire Arrow", +"15388698":"Stone Barricade", +"20489901":"Purple Sunglasses", +"21402876":"Burlap Gloves", +"22947882":"White ID Tag", +"23352662":"Large Banner Hanging", +"23391694":"Bunny Hat", +"28201841":"M39 Rifle", +"37122747":"Green Keycard", +"39600618":"Microphone Stand", +"42535890":"Medium Animated Neon Sign", +"51984655":"Incendiary Pistol Bullet", +"54265286":"Crocodile Pie", +"54436981":"Fairy Lights", +"60528587":"Roadsign Horse Armor", +"62577426":"Photograph", +"69511070":"Metal Fragments", +"70102328":"Red ID Tag", +"73681876":"Tech Trash", +"81423963":"Yellow ID Tag", +"82772055":"Horse", +"86840834":"NVGM Scientist Suit", +"94971664":"Stone Storage Box", +"95950017":"Metal Pipe", +"97903330":"Pure Crafting Quality Tea", +"98508942":"XXL Picture Frame", +"99588025":"High External Wooden Wall", +"104856514":"Bulb String Lights", +"106959911":"Light Frankenstein Legs", +"110116923":"Ice Metal Facemask", +"120820987":"Chicken Pie", +"121049755":"Tall Picture Frame", +"122783240":"Black Berry Clone", +"140006625":"PTZ CCTV Camera", +"143803535":"F1 Grenade", +"146221721":"Heavy Scientist Plushie", +"158303804":"Obsidian Bone Knife", +"162882477":"#50cal", +"170758448":"Cockpit With Engine Vehicle Module", +"171931394":"Stone Pickaxe", +"174866732":"Variable Zoom Scope", +"176787552":"Rifle Body", +"177226991":"Scarecrow", +"180752235":"Pink ID Tag", +"184516676":"Beehive", +"185586769":"Inner Tube", +"190184021":"Kayak", +"192249897":"Green", +"196700171":"Hide Vest", +"196784377":"Improvised Shield", +"198438816":"Vending Machine", +"200773292":"Hammer", +"204391461":"Coal :(", +"204970153":"Wrapped Gift", +"209218760":"Head Bag", +"210787554":"Engineering Workbench", +"215754713":"Bone Arrow", +"223891266":"T-Shirt", +"236677901":"Prototype Pickaxe", +"237239288":"Pants", +"240752557":"Tall Weapon Rack", +"242421166":"Light-Up Frame Large", +"242933621":"Frontier Mirror Large", +"248643189":"Spoiled Big Cat Meat", +"254522515":"Large Medkit", +"255305250":"Wooden Boat Ladder", +"261913429":"White Volcano Firework", +"263834859":"Basic Scrap Tea", +"268565518":"Storage Vehicle Module", +"271048478":"Rat Mask", +"273172220":"Plumber's Trumpet", +"273951840":"Scarecrow Suit", +"277730763":"Mummy Suit", +"281099360":"Bread Loaf", +"282103175":"Giant Lollipop Decor", +"286193827":"Pickles", +"286648290":"Disco Floor", +"296519935":"Diving Fins", +"301063058":"Wanted Poster 2", +"304481038":"Flare", +"309017792":"Big Cat Pie", +"317398316":"High Quality Metal", +"320438357":"Hunters Pie", +"340210699":"Frontier Mirror Small", +"342438846":"Anchovy", +"343045591":"MLRS Aiming Module", +"349762871":"40mm HE Grenade", +"352130972":"Rotten Apple", +"352321488":"Sunglasses", +"352499047":"Shotgun Trap", +"355877490":"Minigun Ammo Pack", +"359723196":"Chippy Arcade Game", +"362863314":"Heart Balloon", +"363163265":"Hose Tool", +"368008432":"Basic Crafting Quality Tea", +"375473148":"Scrap Transport Helicopter", +"377750553":"Pure Harvesting Tea", +"381595627":"Twitch Rivals Neon Sign", +"385099196":"4 Module Car Chassis", +"385645417":"Paintball", +"390728933":"Yellow Berry Clone", +"392828520":"Cooked Crocodile Meat", +"405904531":"Soccer Ball", +"405905095":"Sail", +"418081930":"Wood Chestplate", +"442289265":"Holosight", +"442886268":"Rocket Launcher", +"443432036":"Fluid Switch & Pump", +"445662288":"Scientist Plushie", +"446206234":"Torch Holder", +"450531685":"Light-Up Mirror Large", +"468313189":"Twitch Rivals Hazmat Suit", +"472505338":"Medieval AR", +"476066818":"Cassette - Long", +"479143914":"Gears", +"479292118":"Large Loot Bag", +"486661382":"Clan Table", +"491263800":"Nomad Suit", +"492357192":"RAND Switch", +"494161326":"RPG Launcher", +"504109620":"Ice Sculpture", +"507284030":"Coconut Armor Pants", +"524678627":"Advanced Scrap Tea", +"528668503":"Flame Turret", +"533993281":"Space LR-300 Assault Rifle", +"537946062":"Flight Recorder Box", +"547862680":"Knights armour cuirass", +"550753330":"Snowball Gun Ammo", +"553270375":"Large Rechargeable Battery", +"553887414":"Skull Fire Pit", +"553967074":"Wallpaper Wall", +"559147458":"Survival Fish Trap", +"567235583":"8x Zoom Scope", +"567871954":"Secretlab Chair", +"571949408":"Clump of Mixed Balloons", +"573676040":"Coffin", +"573926264":"Semi Automatic Body", +"574701440":"Scrap Storage Box", +"576509618":"Portable Boom Box", +"588596902":"Handmade Shell", +"593465182":"Table", +"594041190":"Compass", +"596469572":"RF Transmitter", +"602628465":"Parachute", +"602741290":"Burlap Shirt", +"603811464":"Advanced Max Health Tea", +"605467368":"Incendiary 5.56 Rifle Ammo", +"607400343":"Legacy Wood Shelter", +"607785075":"Armored Ladder Hatch", +"609049394":"Battery - Small", +"610102428":"Industrial Conveyor", +"613961768":"Bota Bag", +"615112838":"Rail Road Planter", +"621915341":"Raw Pork", +"625599716":"Metal Shield", +"634478325":"CCTV Camera", +"640470230":"Ceiling Fluorescent Light", +"642482233":"Sticks", +"647240052":"Triangle Rail Road Planter", +"649912614":"Revolver", +"652793345":"Krieg Storage Crates", +"656371026":"High Quality Carburetor", +"656371027":"Medium Quality Carburetor", +"656371028":"Low Quality Carburetor", +"656829501":"Wall Cabinet", +"657352755":"Beach Table", +"665332906":"Timer", +"671063303":"Riot Helmet", +"671706427":"Reinforced Glass Window", +"674734128":"Festive Doorway Garland", +"678698219":"M4 Shotgun", +"679690962":"Tools Storage Box", +"680234026":"Yellow Perch", +"695450239":"Lunar New Year Spear", +"696029452":"Paper Map", +"696029539":"Hot Air Balloon", +"699075597":"Wooden Cross", +"703057617":"Military Flame Thrower", +"709206314":"Tiger Mask", +"721798950":"Car Radio", +"722955039":"Water Gun", +"723407026":"Wood Mirror Standing", +"734320711":"Orchid", +"738611016":"Paintable Window", +"742745918":"Industrial Splitter", +"755224797":"Vodka Bottle", +"756125481":"Wood Mirror Medium", +"756517185":"Medium Present", +"756890702":"High External Adobe Wall", +"762289806":"Siren Light", +"782422285":"Sofa - Pattern", +"785728077":"Pistol Bullet", +"789333045":"Sunken Combat Knife", +"794356786":"Hide Boots", +"794443127":"Christmas Tree", +"795236088":"Torch", +"795371088":"Pump Shotgun", +"803222026":"Repair Bench", +"803954639":"Blue Berry Seed", +"809199956":"Gravestone", +"809689733":"Mummy Mask", +"809942731":"Scarecrow Wrap", +"813023040":"Cooked Wolf Meat", +"814297925":"Medieval Large Wood Box", +"818733919":"Industrial Door", +"818877484":"Semi-Automatic Pistol", +"821588319":"Bicycle", +"826309791":"Two Sided Town Sign Post", +"829641693":"Anchor", +"830839496":"Red Berry Seed", +"831955134":"Sky Lantern - Purple", +"832133926":"Wood Armor Pants", +"833533164":"Large Wood Box", +"835042040":"Medium Frankenstein Legs", +"838308300":"Burst Module", +"838831151":"Blue Berry Clone", +"839738457":"Scrap Mirror Medium", +"844440409":"Bronze Egg", +"850280505":"Bucket Helmet", +"853471967":"Laser Light", +"854447607":"White Berry", +"858486327":"Green Berry", +"861513346":"Lumberjack Suit", +"866332017":"Large Neon Sign", +"866889860":"Wooden Barricade", +"878301596":"Generic vehicle module", +"882559853":"Spider Webs", +"884424049":"Compound Bow", +"888415708":"RF Receiver", +"895374329":"Passenger Vehicle Module", +"912235912":"Sunflower Clone", +"915408809":"40mm Smoke Grenade", +"920930831":"Blue Industrial Wall Light", +"924598634":"Wheat Clone", +"926800282":"Medium Quality Valves", +"935606207":"Minigun", +"935692442":"Longsleeve T-Shirt", +"936496778":"Floor grill", +"952603248":"Weapon flashlight", +"960673498":"Large Hunting Trophy", +"962186730":"Tin Can Alarm", +"963400638":"Speech Bubble Balloon", +"963906841":"Rock", +"968019378":"Clatter Helmet", +"968421290":"Connected Speaker", +"969768382":"Reinforced Wooden Shield", +"971362526":"Skull Trophy", +"972302244":"Kick Hazmat", +"975983052":"Twitch Rivals Trophy", +"980333378":"Hide Poncho", +"988652725":"Smart Switch", +"989925924":"Raw Fish", +"992944937":"Ore Storage Box", +"996293980":"Human Skull", +"996757362":"Wagon", +"998894949":"Corn Seed", +"999690781":"Geiger Counter", +"1004843240":"Orchid Seed", +"1015352446":"Duo Submarine", +"1023919015":"Food Storage Box", +"1028889957":"Light-Up Mirror Medium", +"1036321299":"Blue Dog Tags", +"1044081720":"Wood Storage Box", +"1046904719":"Abyss Metal Hatchet", +"1052926200":"Mining Quarry", +"1055319033":"40mm Shotgun Round", +"1058261682":"Christmas Lights", +"1065594600":"Pilot Hazmat", +"1072924620":"High Quality Spark Plugs", +"1079279582":"Medical Syringe", +"1081315464":"Nest Hat", +"1081921512":"Card Table", +"1090916276":"Pitchfork", +"1094293920":"Wrapping Paper", +"1099314009":"Barbeque", +"1099611828":"Metal Armor Insert", +"1103488722":"Snowball Gun", +"1104520648":"Chainsaw", +"1107575710":"Arctic Scientist Suit", +"1110385766":"Metal Chest Plate", +"1112162468":"Blue Berry", +"1113514903":"Attack Helicopter", +"1115193056":"Wall Divider Pack", +"1121416193":"Pure Cooling Tea", +"1121925526":"Candy Cane", +"1127417055":"Armoured Ladder Hatch", +"1130729138":"Spoiled Fish Meat", +"1132603396":"Weapon Rack Stand", +"1142993169":"Ceiling Light", +"1145722690":"Catapult", +"1149964039":"Storage Monitor", +"1153652756":"Large Wooden Sign", +"1158340331":"Medium Quality Crankshaft", +"1158340332":"High Quality Crankshaft", +"1158340334":"Low Quality Crankshaft", +"1159991980":"Code Lock", +"1160881421":"Hitch & Trough", +"1168856825":"Metal Detector", +"1168916338":"Bee Grenade", +"1171735914":"AND Switch", +"1174484438":"Mini Fridge", +"1174957864":"Shockbyte Tool Cupboard", +"1176355476":"Concrete Hatchet", +"1177596584":"Elevator", +"1178325727":"Wheat", +"1181207482":"Heavy Plate Helmet", +"1184215560":"Spoiled Produce", +"1186655046":"Fuel Tank Vehicle Module", +"1189981699":"Crate Costume", +"1199391518":"Road Signs", +"1205084994":"Large Photo Frame", +"1205607945":"Two Sided Hanging Sign", +"1221063409":"Armored Double Door", +"1223729384":"Lavender ID Tag", +"1223900335":"Dog Tag", +"1230323789":"SMG Body", +"1230691307":"Captain's Log", +"1234878710":"Telephone", +"1234880403":"Sewing Kit", +"1242482355":"Jack O Lantern Angry", +"1242522330":"Cursed Cauldron", +"1248356124":"Timed Explosive Charge", +"1248383659":"#50cal", +"1254295946":"Armor Storage Box", +"1258768145":"Sunglasses", +"1259919256":"Mixing Table", +"1263920163":"Smoke Grenade", +"1266491000":"Hazmat Suit", +"1268178466":"Green Industrial Wall Light", +"1272194103":"Red Berry", +"1272430949":"Wheelbarrow Piano", +"1272768630":"Spoiled Human Meat", +"1277159544":"Weapon Rack Double Light", +"1285226495":"Bunny Costume", +"1293102274":"XOR Switch", +"1295301598":"Latex Balloon", +"1296788329":"Homing Missile", +"1305578813":"Small Neon Sign", +"1305765685":"Krieg Storage Barrel", +"1307626005":"Storage Barrel Vertical", +"1312679249":"Wood Mirror Large", +"1312843609":"Skull", +"1315082560":"Ox Mask", +"1318558775":"MP5A4", +"1319617282":"Small Loot Bag", +"1324203999":"Champagne Boomer", +"1326180354":"Salvaged Sword", +"1327005675":"Short Ice Wall", +"1330084809":"Low Quality Valves", +"1346158228":"Pumpkin Basket", +"1348294923":"Spoiled Bear Meat", +"1350707894":"Jungle Rock", +"1353298668":"Armored Door", +"1358643074":"Snow Machine", +"1361520181":"Minecart Planter", +"1364514421":"Blue ID Tag", +"1365234594":"Gold Mirror large", +"1366282552":"Leather Gloves", +"1367190888":"Corn", +"1371909803":"Tesla Coil", +"1373240771":"Wooden Barricade Cover", +"1373971859":"Python Revolver", +"1376065505":"Rear Seats Vehicle Module", +"1381010055":"Leather", +"1382263453":"Barbed Wooden Barricade", +"1390353317":"Sheet Metal Double Door", +"1391703481":"Burnt Pork", +"1394042569":"RHIB", +"1397052267":"Supply Signal", +"1400460850":"Saddle bag", +"1401987718":"Duct Tape", +"1409529282":"Door Closer", +"1412103380":"Sunflower Seed", +"1413014235":"Fridge", +"1414245162":"Note", +"1414245519":"Rose", +"1414245522":"Rope", +"1420547167":"Horse Costume", +"1422530437":"Raw Deer Meat", +"1424075905":"Water Bucket", +"1426097945":"Coconut Armor Chestplate", +"1426574435":"Minicopter", +"1428574144":"Hopper", +"1430085198":"Industrial Crafter", +"1443579727":"Hunting Bow", +"1447138977":"Light-Up Frame XXL", +"1451568081":"Chainlink Fence Gate", +"1456143403":"Cooking Workbench", +"1463862472":"Wanted Poster 4", +"1465782238":"Metal Storage Box", +"1467878256":"Pork Pie", +"1478091698":"Muzzle Brake", +"1480022580":"Basic Ore Tea", +"1482871705":"3 Module Car Chassis", +"1488606552":"Retro Tool Cupboard", +"1488979457":"Jackhammer", +"1491189398":"Paddle", +"1491753484":"Medium Frankenstein Torso", +"1494014226":"Discord Trophy", +"1512054436":"Potato Clone", +"1516531815":"Basic Harvesting Tea", +"1516985844":"Netting", +"1521286012":"Double Sign Post", +"1523195708":"Targeting Computer", +"1523403414":"Cassette - Short", +"1524187186":"Workbench Level 1", +"1524980732":"Carvable Pumpkin", +"1525520776":"Building Plan", +"1533551194":"White Berry Clone", +"1534542921":"Chair", +"1536610005":"Cooked Human Meat", +"1538126328":"Industrial Combiner", +"1540934679":"Wooden Spear", +"1542290441":"Single Sign Post", +"1545779598":"Assault Rifle", +"1548091822":"Apple", +"1553078977":"Bleach", +"1556365900":"Molotov Cocktail", +"1557173737":"Sunglasses", +"1559779253":"Engine Vehicle Module", +"1559915778":"Single Horse Saddle", +"1561022037":"Abyss Metal Pickaxe", +"1562867678":"Artist Canvas XL", +"1568388703":"Diesel Fuel", +"1569882109":"Handmade Fishing Rod", +"1572152877":"Mint ID Tag", +"1575635062":"Frankenstein Table", +"1578317134":"Hazmat Plushy", +"1581210395":"Large Planter Box", +"1586884551":"Flight Control Codelock", +"1588298435":"Bolt Action Rifle", +"1588492232":"Drone", +"1601468620":"Blue Jumpsuit", +"1601800933":"Jar of Honey", +"1602646136":"Stone Spear", +"1603174987":"Confetti Cannon", +"1604092540":"Twitch Rivals 2025 Sofa", +"1604837581":"Wooden Shield", +"1608640313":"Tank Top", +"1609921845":"Artist Canvas Small", +"1614528785":"Heavy Frankenstein Torso", +"1619039771":"Digital Clock", +"1621942085":"Outbreak Sprayer", +"1623701499":"Industrial Wall Light", +"1629293099":"Snowman", +"1629564540":"Wallpaper Tool", +"1633553557":"Birthday Candle Hat", +"1638322904":"Incendiary Rocket", +"1643667218":"Large Animated Neon Sign", +"1655650836":"Metal Barricade", +"1655979682":"Empty Can Of Beans", +"1658229558":"Lantern", +"1659114910":"Gas Mask", +"1659447559":"Wooden Horse Armor", +"1660145984":"Yellow Berry", +"1668129151":"Cooked Fish", +"1668858301":"Small Stocking", +"1673224590":"M15 Semi-Automatic Pistol", +"1675639563":"Beenie Hat", +"1680793490":"Boomerang", +"1686524871":"Decorative Gingerbread Men", +"1691223771":"Light-Up Frame Small", +"1696050067":"Modular Car Lift", +"1697996440":"Landscape Photo Frame", +"1711033574":"Bone Club", +"1712070256":"HV 5.56 Rifle Ammo", +"1712261904":"Pure Max Health Tea", +"1714496074":"Candle Hat", +"1714509152":"Ballista", +"1717250161":"Electric Table Lamp", +"1719587208":"Targeting Attachment", +"1719978075":"Bone Fragments", +"1722154847":"Hide Pants", +"1723747470":"Tree Lights", +"1729120840":"Wooden Door", +"1729374708":"Pure Ore Tea", +"1729712564":"Portrait Photo Frame", +"1730664641":"Wallpaper Ceiling", +"1732236518":"Caboose", +"1735402444":"Disco Floor", +"1736620421":"Clothing Storage Box", +"1744298439":"Blue Boomer", +"1746956556":"Bone Armor", +"1751045826":"Hoodie", +"1757265204":"Silver Egg", +"1758333838":"Teal", +"1762167092":"Green ID Tag", +"1768112091":"Tomaha Snowmobile", +"1769475390":"Wood Frame Standing", +"1770475779":"Worm", +"1770744540":"Generic vehicle chassis", +"1771755747":"Black Berry", +"1776460938":"Blood", +"1783512007":"Cactus Flesh", +"1784005657":"Parachute (Deployed)", +"1784406797":"Sousaphone", +"1787198294":"Frontier Mirror Standing", +"1789825282":"Candy Cane Club", +"1796682209":"Custom SMG", +"1801656689":"Light-Up Frame XL", +"1803831286":"Garry's Mod Tool Gun", +"1811780502":"Radioactive Water", +"1814288539":"Bone Knife", +"1819863051":"Sky Lantern", +"1827479659":"Burnt Wolf Meat", +"1831249347":"Scattershot", +"1835946060":"Cable Tunnel", +"1840570710":"Above Ground Pool", +"1840822026":"Beancan Grenade", +"1846605708":"Abyss Torch", +"1849409072":"Silly Horse Mask", +"1849887541":"Small Generator", +"1850456855":"Road Sign Kilt", +"1856217390":"Egg Basket", +"1858828593":"Egg", +"1865253052":"Dracula Mask", +"1869224826":"Motorbike With Sidecar", +"1873004466":"Coconut Armor Gloves", +"1873897110":"Cooked Bear Meat", +"1874610722":"Armored Cockpit Vehicle Module", +"1877339384":"Burlap Headwrap", +"1878053256":"Rowboat", +"1882709339":"Metal Blade", +"1883981798":"Low Quality Pistons", +"1883981800":"High Quality Pistons", +"1883981801":"Medium Quality Pistons", +"1884461210":"Charcoal Storage Box", +"1885488976":"Spooky Speaker", +"1892536031":"Fluorescent Light", +"1895235349":"Disco Ball", +"1898094925":"Pumpkin Plant Clone", +"1899610628":"Medium Loot Bag", +"1903654061":"Small Planter Box", +"1905387657":"Pure Rad. Removal Tea", +"1911552868":"Black Berry Seed", +"1914691295":"Prototype 17", +"1916016738":"Light-Up Mirror Standing", +"1917703890":"Burnt Horse Meat", +"1925646349":"Spoiled Pork Meat", +"1931713481":"Black Raspberries", +"1933140008":"PT Boat", +"1937380239":"Frontier Hatchet", +"1946219319":"Camp Fire", +"1948067030":"Ladder Hatch", +"1950013766":"Light-Up Frame Standing", +"1950721418":"Salvaged Shelves", +"1951603367":"Switch", +"1953903201":"Nailgun", +"1954597876":"Bee Catapult Bomb", +"1965232394":"Crossbow", +"1973165031":"Birthday Cake", +"1973684065":"Burnt Chicken", +"1973949960":"Frontier Bolts Single Item Rack", +"1975934948":"Survey Charge", +"1983621560":"Floor triangle grill", +"1989785143":"High Quality Horse Shoes", +"1991794121":"Trike", +"1992974553":"Burlap Trousers", +"1993693904":"Boat Building Station", +"2005491391":"Extended Magazine", +"2009734114":"Christmas Door Wreath", +"2019042823":"Tarp", +"2021351233":"Advanced Rad. Removal Tea", +"2023888403":"Medium Rechargeable Battery", +"2024467711":"Pure Scrap Tea", +"2036395619":"Scatter Dart", +"2039177180":"Bear Pie", +"2040726127":"Combat Knife", +"2041899972":"Triangle Ladder Hatch", +"2047789913":"Lead Armor Insert", +"2048317869":"Wolf Skull", +"2052270186":"Inner Tube", +"2054391128":"Factory Door", +"2054929933":"Jungle Relic Assault Rifle", +"2055695285":"Frontier Mirror Medium", +"2063916636":"Advanced Ore Tea", +"2068884361":"Small Backpack", +"2070189026":"Large Banner on pole", +"2083256995":"Handmade SMG", +"2087678962":"Search Light", +"2090395347":"Large Solar Panel", +"2100007442":"Audio Alarm", +"2104517339":"Strobe Light", +"2106561762":"Decorative Tinsel", +"2114754781":"Water Purifier", +"2120241887":"Gold Mirror Standing", +"2126889441":"Santa Beard", +"2130820932":"Cancer Research UK Plushie", +"2130820933":"Ronald McDonald House UK Plushie", +"2133269020":"Red Berry Clone" +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Pairing/StorageMonitorPairingCoordinator.cs b/src/RustPlusBot.Features.StorageMonitors/Pairing/StorageMonitorPairingCoordinator.cs new file mode 100644 index 0000000..c4038c1 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Pairing/StorageMonitorPairingCoordinator.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.StorageMonitors; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.StorageMonitors.Pairing; + +/// Turns a into an "Add it?" prompt and, on Accept, a managed storage monitor. +/// Opens scopes for the scoped storage monitor/workspace stores. +/// Resolves the #storagemonitors channel id. +/// Posts/edits storage monitor + prompt messages. +/// Renders the prompt and storage monitor embeds. +internal sealed class StorageMonitorPairingCoordinator( + IServiceScopeFactory scopeFactory, + IStorageMonitorChannelLocator locator, + IStorageMonitorChannelPoster poster, + StorageMonitorEmbedRenderer renderer) +{ + private readonly ConcurrentDictionary<(ulong Guild, Guid Server, ulong Entity), Pending> _pending = new(); + + /// Gets the held default name for a pending pairing, or null. + /// The guild id. + /// The server id. + /// The storage monitor entity id. + /// The held default name, or null when no pending pairing exists. + public string? PendingName(ulong guildId, Guid serverId, ulong entityId) => + _pending.TryGetValue((guildId, serverId, entityId), out var p) ? p.DefaultName : null; + + /// Handles a paired storage monitor: ignore if already managed, else post the prompt and hold pending state. + /// The paired-storage-monitor event. + /// A token to cancel the operation. + /// A task that completes when the prompt has been posted (or the storage monitor was ignored). + public async Task HandlePairedAsync(StorageMonitorPairedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + if (await ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken).ConfigureAwait(false)) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is not { } channel) + { + return; + } + + var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var defaultName = $"Storage Monitor {evt.EntityId}"; + var (embed, components) = renderer.RenderPrompt(evt.ServerId, evt.EntityId, defaultName, culture); + var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken) + .ConfigureAwait(false); + _pending[(evt.GuildId, evt.ServerId, evt.EntityId)] = new Pending(defaultName, messageId); + } + + /// Accepts a pending pairing: persist + replace prompt with the storage monitor embed. Race-guarded. + /// The guild id. + /// The server id. + /// The storage monitor entity id. + /// The id of the user who accepted the pairing. + /// A token to cancel the operation. + /// True when the storage monitor was persisted; false when it was already managed (race). + public async Task TryAcceptAsync( + ulong guildId, + Guid serverId, + ulong entityId, + ulong acceptingUserId, + CancellationToken cancellationToken) + { + if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false)) + { + _pending.TryRemove((guildId, serverId, entityId), out _); + return false; + } + + _pending.TryGetValue((guildId, serverId, entityId), out var pending); + var name = pending?.DefaultName ?? $"Storage Monitor {entityId}"; + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var added = await store.AddAsync(guildId, serverId, entityId, name, acceptingUserId, cancellationToken) + .ConfigureAwait(false); + + var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (channelId is { } channel) + { + var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + + // The storage monitor is freshly accepted; contents are unknown until the next prime/trigger arrives + // moments later. Render with contents: null (unreachable). The supervisor's prime path republishes + // real contents shortly (same pattern as switches). + var (embed, components) = renderer.RenderMonitor(added, contents: null, culture); + var newMessageId = await poster + .EnsureAsync(channel, pending?.MessageId, embed, components, cancellationToken) + .ConfigureAwait(false); + if (newMessageId is { } mid) + { + await store.SetMessageIdAsync(guildId, serverId, entityId, mid, cancellationToken) + .ConfigureAwait(false); + } + } + } + + _pending.TryRemove((guildId, serverId, entityId), out _); + return true; + } + + /// Drops a pending pairing; returns whether one was present. + /// The guild id. + /// The server id. + /// The storage monitor entity id. + /// True when a pending pairing was removed; false when none was held. + public bool TryDismiss(ulong guildId, Guid serverId, ulong entityId) => + _pending.TryRemove((guildId, serverId, entityId), out _); + + private async Task ExistsAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken ct) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.ExistsAsync(guildId, serverId, entityId, ct).ConfigureAwait(false); + } + } + + private async Task GetCultureAsync(ulong guildId, CancellationToken ct) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false); + } + } + + private sealed record Pending(string DefaultName, ulong? MessageId); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs new file mode 100644 index 0000000..1406c6b --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs @@ -0,0 +1,24 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; +using RustPlusBot.Discord.Posting; + +namespace RustPlusBot.Features.StorageMonitors.Posting; + +/// Posts/edits storage monitor embeds in #storagemonitors by message id. Untested integration shim. +/// The Discord socket client. +/// The logger. +internal sealed class DiscordStorageMonitorChannelPoster( + DiscordSocketClient client, + ILogger logger) : IStorageMonitorChannelPoster +{ + /// + public Task EnsureAsync( + ulong channelId, + ulong? messageId, + Embed embed, + MessageComponent components, + CancellationToken cancellationToken) + => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger, + cancellationToken); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs new file mode 100644 index 0000000..399a1e1 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs @@ -0,0 +1,19 @@ +namespace RustPlusBot.Features.StorageMonitors.Posting; + +/// Posts/edits a storage monitor embed in #storagemonitors by message id, self-healing a deleted message. +internal interface IStorageMonitorChannelPoster +{ + /// Edits the message at if present and found; otherwise posts a new one. + /// The #storagemonitors channel id. + /// The known embed message id, or null to post fresh. + /// The embed to show. + /// The control row. + /// A cancellation token. + /// The (possibly new) message id, or null on failure. + Task EnsureAsync( + ulong channelId, + ulong? messageId, + global::Discord.Embed embed, + global::Discord.MessageComponent components, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs new file mode 100644 index 0000000..beddd43 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs @@ -0,0 +1,129 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.StorageMonitors; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.StorageMonitors.Relaying; + +/// Keeps storage monitor embeds in sync: trigger events re-render with the carried contents; a non-Connected server marks them unreachable. +/// Opens scopes for the scoped stores. +/// Resolves the #storagemonitors channel id. +/// Posts/edits storage monitor embeds. +/// Renders storage monitor embeds. +internal sealed class StorageMonitorStateRelay( + IServiceScopeFactory scopeFactory, + IStorageMonitorChannelLocator locator, + IStorageMonitorChannelPoster poster, + StorageMonitorEmbedRenderer renderer) +{ + /// Handles a storage monitor trigger: ignore unmanaged ids; else render the event's contents directly. + /// The storage monitor triggered event. + /// A cancellation token. + /// A task that completes when the embed has been re-rendered (or the id was ignored). + public async Task HandleTriggeredAsync(StorageMonitorTriggeredEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + if (!await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken) + .ConfigureAwait(false)) + { + return; // not a storage monitor this relay manages — ignore. + } + + var monitor = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken) + .ConfigureAwait(false); + if (monitor is null) + { + return; + } + + var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken) + .ConfigureAwait(false); + await RenderAsync(store, monitor, evt.Contents, evt.GuildId, evt.ServerId, culture, cancellationToken) + .ConfigureAwait(false); + } + } + + /// Handles a connection-status change: a non-Connected server marks its storage monitor embeds unreachable. + /// The connection-status change. + /// A cancellation token. + /// A task that completes when every affected embed has been re-rendered. + public async Task HandleConnectionStatusAsync( + ConnectionStatusChangedEvent evt, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var connections = scope.ServiceProvider.GetRequiredService(); + var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (state is { Status: ConnectionStatus.Connected }) + { + // The supervisor's prime path republishes real state on connect; nothing to do here. + return; + } + + var store = scope.ServiceProvider.GetRequiredService(); + var monitors = await store.ListByServerAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (monitors.Count == 0) + { + return; + } + + var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken) + .ConfigureAwait(false); + foreach (var monitor in monitors) + { + await RenderAsync(store, monitor, contents: null, evt.GuildId, evt.ServerId, culture, + cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task RenderAsync( + IStorageMonitorStore store, + SmartStorageMonitor monitor, + StorageContentsSnapshot? contents, + ulong guildId, + Guid serverId, + string culture, + CancellationToken cancellationToken) + { + var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (channelId is not { } channel) + { + return; + } + + var (embed, components) = renderer.RenderMonitor(monitor, contents, culture); + var newMessageId = await poster.EnsureAsync(channel, monitor.MessageId, embed, components, cancellationToken) + .ConfigureAwait(false); + if (newMessageId is { } mid && mid != monitor.MessageId) + { + await store.SetMessageIdAsync(guildId, serverId, monitor.EntityId, mid, cancellationToken) + .ConfigureAwait(false); + } + } + + private static async Task GetCultureAsync( + IServiceProvider provider, + ulong guildId, + CancellationToken cancellationToken) + { + var store = provider.GetRequiredService(); + return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorComponentIds.cs b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorComponentIds.cs new file mode 100644 index 0000000..4a30ef2 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorComponentIds.cs @@ -0,0 +1,23 @@ +namespace RustPlusBot.Features.StorageMonitors.Rendering; + +/// Custom ids for storage-monitor components. Tails encode "{serverId}:{entityId}". +internal static class StorageMonitorComponentIds +{ + /// Pairing-prompt Accept button; tail "{serverId}:{entityId}". + public const string AcceptPrefix = "storage:accept:"; + + /// Pairing-prompt Dismiss button; tail "{serverId}:{entityId}". + public const string DismissPrefix = "storage:dismiss:"; + + /// Refresh button (re-reads contents); tail "{serverId}:{entityId}". + public const string RefreshPrefix = "storage:refresh:"; + + /// Rename button (opens the modal); tail "{serverId}:{entityId}". + public const string RenamePrefix = "storage:rename:"; + + /// Rename modal id; tail "{serverId}:{entityId}". + public const string RenameModalPrefix = "storage:rename:modal:"; + + /// The rename modal's text input id. + public const string RenameInputId = "storage:rename:input"; +} diff --git a/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs new file mode 100644 index 0000000..2f0a5b8 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using System.Text; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Naming; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.StorageMonitors.Rendering; + +/// Renders a Smart Storage Monitor as a Discord embed + control row, and the pairing prompt. Pure. +/// The shared localizer. +/// Resolves item ids to display names. +internal sealed class StorageMonitorEmbedRenderer(ILocalizer localizer, IItemNameResolver names) +{ + /// Renders the monitor embed and its Refresh/Rename buttons. null ⇒ unreachable. + /// The monitor. + /// The current contents, or null when unreachable. + /// The guild culture. + /// The embed and the component row. + public (Embed Embed, MessageComponent Components) RenderMonitor( + SmartStorageMonitor monitor, + StorageContentsSnapshot? contents, + string culture) + { + ArgumentNullException.ThrowIfNull(monitor); + var unreachable = contents is null; + + var description = new StringBuilder(); + if (unreachable) + { + description.Append(localizer.Get("storage.status.unreachable", culture)); + } + else + { + description.AppendLine(localizer.Get(TypeKey(contents!.Capacity), culture)); + AppendProtection(description, contents, culture); + AppendContents(description, contents, culture); + } + + var embed = new EmbedBuilder() + .WithTitle(monitor.Name) + .WithDescription(description.ToString()) + .WithFooter(localizer.Get("storage.embed.footer", culture, monitor.EntityId)) + .Build(); + + var tail = $"{monitor.ServerId}:{monitor.EntityId}"; + var components = new ComponentBuilder() + .WithButton(localizer.Get("storage.button.refresh", culture), + StorageMonitorComponentIds.RefreshPrefix + tail, ButtonStyle.Primary, disabled: unreachable) + .WithButton(localizer.Get("storage.button.rename", culture), + StorageMonitorComponentIds.RenamePrefix + tail, ButtonStyle.Secondary, disabled: unreachable) + .Build(); + + return (embed, components); + } + + /// Renders the transient "New storage monitor detected — Add it?" prompt. + /// The server id. + /// The entity id. + /// The generated default name. + /// The guild culture. + /// The prompt embed and Accept/Dismiss row. + public (Embed Embed, MessageComponent Components) RenderPrompt( + Guid serverId, + ulong entityId, + string defaultName, + string culture) + { + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("storage.prompt.title", culture)) + .WithDescription(localizer.Get("storage.prompt.body", culture, defaultName)) + .Build(); + + var tail = $"{serverId}:{entityId}"; + var components = new ComponentBuilder() + .WithButton(localizer.Get("storage.prompt.accept", culture), + StorageMonitorComponentIds.AcceptPrefix + tail, ButtonStyle.Success) + .WithButton(localizer.Get("storage.prompt.dismiss", culture), + StorageMonitorComponentIds.DismissPrefix + tail, ButtonStyle.Secondary) + .Build(); + + return (embed, components); + } + + private static string TypeKey(int? capacity) => capacity switch + { + 24 => "storage.type.toolcupboard", + 48 => "storage.type.largebox", + 12 => "storage.type.smallbox", + _ => "storage.type.unknown", + }; + + private static string FormatRemaining(TimeSpan span) + { + if (span.TotalDays >= 1) + { + return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalDays}d {span.Hours}h"); + } + + if (span.TotalHours >= 1) + { + return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalHours}h {span.Minutes}m"); + } + + return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m"); + } + + private void AppendProtection(StringBuilder sb, StorageContentsSnapshot contents, string culture) + { + // Protection is only meaningful for a Tool Cupboard (capacity 24). + if (contents.Capacity != 24) + { + return; + } + + if (contents is { HasProtection: true, ProtectionExpiry: { } expiry }) + { + var remaining = expiry - DateTimeOffset.UtcNow; + sb.AppendLine(localizer.Get("storage.protection.on", culture, + FormatRemaining(remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining))); + } + else + { + sb.AppendLine(localizer.Get("storage.protection.off", culture)); + } + } + + private void AppendContents(StringBuilder sb, StorageContentsSnapshot contents, string culture) + { + var capacityText = contents.Capacity?.ToString(CultureInfo.InvariantCulture) ?? "?"; + sb.AppendLine(localizer.Get("storage.slots", culture, contents.Items.Count, capacityText)); + + if (contents.Items.Count == 0) + { + sb.AppendLine(localizer.Get("storage.contents.empty", culture)); + return; + } + + foreach (var item in contents.Items.OrderByDescending(i => i.Quantity)) + { + var name = names.Resolve(item.ItemId); + if (item.IsBlueprint) + { + name = localizer.Get("storage.contents.bp", culture, name); + } + + sb.AppendLine(localizer.Get("storage.contents.line", culture, name, item.Quantity)); + } + } +} diff --git a/src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj b/src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj new file mode 100644 index 0000000..c04677d --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs b/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs new file mode 100644 index 0000000..3d7efe8 --- /dev/null +++ b/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Discord; +using RustPlusBot.Features.StorageMonitors.Hosting; +using RustPlusBot.Features.StorageMonitors.Naming; +using RustPlusBot.Features.StorageMonitors.Pairing; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Relaying; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.StorageMonitors; + +/// DI registration for the Smart Storage Monitors feature. +public static class StorageMonitorServiceCollectionExtensions +{ + /// Registers the item-name resolver, renderer, poster, coordinator, relay, modules, and hosted service. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddStorageMonitors(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRustPlusBotLocalization(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + services.AddSingleton(new InteractionModuleAssembly( + typeof(StorageMonitorServiceCollectionExtensions).Assembly)); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/IStorageMonitorChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IStorageMonitorChannelLocator.cs new file mode 100644 index 0000000..3f8fe23 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/IStorageMonitorChannelLocator.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the per-server #storagemonitors channel (used to post/edit storage-monitor embeds). +public interface IStorageMonitorChannelLocator +{ + /// Gets the Discord channel id of #storagemonitors for (, ), or null. + /// The guild snowflake. + /// The server id. + /// A cancellation token. + /// The Discord channel id, or null if not provisioned. + Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/StorageMonitorChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/StorageMonitorChannelLocator.cs new file mode 100644 index 0000000..d183313 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/StorageMonitorChannelLocator.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Time; + +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the #storagemonitors channel id for a (guild, server). +/// Opens scopes for the scoped workspace store. +/// Drives the cache TTL. +internal sealed class StorageMonitorChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) + : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerStorageMonitors), + IStorageMonitorChannelLocator; diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index fb0f980..bf894ed 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -20,6 +20,8 @@ public IEnumerable GetChannelSpecs() => ChannelPermissionProfile.Interactive, 4), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name", ChannelPermissionProfile.Interactive, 5), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name", + ChannelPermissionProfile.Interactive, 6), ]; /// diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index ef75abd..7d9841c 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -29,6 +29,9 @@ internal static class WorkspaceChannelKeys /// Key for the per-server #alarms channel. public const string ServerAlarms = "alarms"; + + /// Per-server storage-monitors channel key. + public const string ServerStorageMonitors = "storagemonitors"; } /// Stable message keys persisted as ProvisionedMessage.MessageKey. diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index 9cbcd6e..c52bb29 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -64,6 +64,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index fcdf266..172b2ff 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -14,6 +14,7 @@ using RustPlusBot.Features.Map; using RustPlusBot.Features.Pairing; using RustPlusBot.Features.Players; +using RustPlusBot.Features.StorageMonitors; using RustPlusBot.Features.Switches; using RustPlusBot.Features.Workspace; using RustPlusBot.Host.Credentials; @@ -79,6 +80,7 @@ builder.Services.AddMap(); builder.Services.AddSwitches(); builder.Services.AddAlarms(); +builder.Services.AddStorageMonitors(); var host = builder.Build(); diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index 1b9cd69..df5e607 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -26,6 +26,7 @@ + diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 6ccfa30..7745fac 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -90,6 +90,9 @@ configuration + + moniteurs-stockage + interrupteurs @@ -588,6 +591,60 @@ L'interrupteur est injoignable pour le moment. + + Actualiser + + + Renommer + + + {0} (PL) + + + Vide + + + {0} ×{1} + + + Entité {0} + + + Non protégé + + + Protégé — expire dans {0} + + + Ajouter + + + Ajouter **{0}** ? + + + Ignorer + + + Nouveau moniteur de stockage détecté + + + {0} / {1} emplacements + + + ⚠️ Injoignable + + + Grande caisse + + + Petite caisse + + + Armoire à outils + + + Moniteur de stockage + Disponibilité du bot : {0} diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 8875517..b319292 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -90,6 +90,9 @@ setup + + storage-monitors + switches @@ -588,6 +591,60 @@ Switch is unreachable right now. + + Refresh + + + Rename + + + {0} (BP) + + + Empty + + + {0} ×{1} + + + Entity {0} + + + Not protected + + + Protected — expires in {0} + + + Add it + + + Add **{0}**? + + + Dismiss + + + New storage monitor detected + + + {0} / {1} slots + + + ⚠️ Unreachable + + + Large Box + + + Small Box + + + Tool Cupboard + + + Storage Monitor + Bot uptime: {0} diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index f6e0f96..7c3b2d7 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -9,6 +9,7 @@ using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Map; using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.StorageMonitors; using RustPlusBot.Domain.Switches; using RustPlusBot.Domain.Workspace; using RustPlusBot.Persistence.Configurations; @@ -52,6 +53,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Paired and managed Smart Alarms. public DbSet SmartAlarms => Set(); + /// Managed Smart Storage Monitors. + public DbSet SmartStorageMonitors => Set(); + /// Per-guild event subscriptions. public DbSet EventSubscriptions => Set(); @@ -81,6 +85,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .ApplyConfiguration(new PairedEntityConfiguration()) .ApplyConfiguration(new SmartSwitchConfiguration()) .ApplyConfiguration(new SmartAlarmConfiguration()) + .ApplyConfiguration(new SmartStorageMonitorConfiguration()) .ApplyConfiguration(new EventSubscriptionConfiguration()) .ApplyConfiguration(new ProvisionedCategoryConfiguration()) .ApplyConfiguration(new ProvisionedChannelConfiguration()) diff --git a/src/RustPlusBot.Persistence/Configurations/SmartStorageMonitorConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/SmartStorageMonitorConfiguration.cs new file mode 100644 index 0000000..1ad02b1 --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/SmartStorageMonitorConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.StorageMonitors; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class SmartStorageMonitorConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(s => s.Id); + builder.Property(s => s.Name).IsRequired().HasMaxLength(128); + builder.HasIndex(s => new + { + s.GuildId, s.ServerId, s.EntityId + }).IsUnique(); + + // Removing a RustServer cascades to its storage monitors so no orphaned rows linger. + builder.HasOne() + .WithMany() + .HasForeignKey(s => s.ServerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.Designer.cs new file mode 100644 index 0000000..d9360f7 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.Designer.cs @@ -0,0 +1,708 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RustPlusBot.Persistence; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + [DbContext(typeof(BotDbContext))] + [Migration("20260626175614_SmartStorageMonitors")] + partial class SmartStorageMonitors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("ParentId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.HasKey("GuildId", "UserId"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GlobalName") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.HasKey("ServerId"); + + b.ToTable("ServerCommandSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedPlayerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); + + b.ToTable("PlayerCredentials"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("PairedEntities"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("EventSubscriptions"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("ShowGrid") + .HasColumnType("INTEGER"); + + b.Property("ShowMarkers") + .HasColumnType("INTEGER"); + + b.Property("ShowMonuments") + .HasColumnType("INTEGER"); + + b.Property("ShowPlayers") + .HasColumnType("INTEGER"); + + b.Property("ShowRigs") + .HasColumnType("INTEGER"); + + b.Property("ShowVendor") + .HasColumnType("INTEGER"); + + b.HasKey("ServerId"); + + b.ToTable("ServerMapSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("FacepunchServerId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordCategoryId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId") + .IsUnique(); + + b.ToTable("ProvisionedCategories"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChannelKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "ChannelKey") + .IsUnique(); + + b.ToTable("ProvisionedChannels"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "MessageKey") + .IsUnique(); + + b.ToTable("ProvisionedMessages"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.HasOne("Persistord.Core.Entities.ChannelEntity", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithOne() + .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.cs b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.cs new file mode 100644 index 0000000..b427b80 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class SmartStorageMonitors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SmartStorageMonitors", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + ServerId = table.Column(type: "TEXT", nullable: false), + EntityId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + MessageId = table.Column(type: "INTEGER", nullable: true), + PairedByUserId = table.Column(type: "INTEGER", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SmartStorageMonitors", x => x.Id); + table.ForeignKey( + name: "FK_SmartStorageMonitors_RustServers_ServerId", + column: x => x.ServerId, + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SmartStorageMonitors_GuildId_ServerId_EntityId", + table: "SmartStorageMonitors", + columns: new[] { "GuildId", "ServerId", "EntityId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SmartStorageMonitors_ServerId", + table: "SmartStorageMonitors", + column: "ServerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SmartStorageMonitors"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 1afec44..6d298b7 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -424,6 +424,45 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RustServers"); }); + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => { b.Property("Id") @@ -619,6 +658,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => { b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index d8f42c3..614a4d6 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using RustPlusBot.Persistence.Credentials; using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.StorageMonitors; using RustPlusBot.Persistence.Switches; using RustPlusBot.Persistence.Workspace; @@ -39,6 +40,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/RustPlusBot.Persistence/StorageMonitors/IStorageMonitorStore.cs b/src/RustPlusBot.Persistence/StorageMonitors/IStorageMonitorStore.cs new file mode 100644 index 0000000..5e07a1c --- /dev/null +++ b/src/RustPlusBot.Persistence/StorageMonitors/IStorageMonitorStore.cs @@ -0,0 +1,97 @@ +using RustPlusBot.Domain.StorageMonitors; + +namespace RustPlusBot.Persistence.StorageMonitors; + +/// Persists managed Smart Storage Monitors (accepted pairings only; pending pairings stay in-memory). +public interface IStorageMonitorStore +{ + /// Adds a managed storage monitor and returns the persisted row. + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game storage-monitor entity id. + /// The display name. + /// The user who accepted the pairing. + /// A cancellation token. + /// The persisted storage monitor. + Task AddAsync( + ulong guildId, + Guid serverId, + ulong entityId, + string name, + ulong pairedByUserId, + CancellationToken cancellationToken = default); + + /// Gets a storage monitor by identity, or null. + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game storage-monitor entity id. + /// A cancellation token. + /// The storage monitor, or null. + Task GetAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken = default); + + /// Lists every managed storage monitor for a server. + /// Owning Discord guild snowflake. + /// The Rust server id. + /// A cancellation token. + /// The managed storage monitors for the server. + Task> ListByServerAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default); + + /// True when a managed storage monitor with this identity exists. + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game storage-monitor entity id. + /// A cancellation token. + /// True if a matching storage monitor exists. + Task ExistsAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken = default); + + /// Renames a storage monitor (no-op if absent). + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game storage-monitor entity id. + /// The new display name. + /// A cancellation token. + /// A task that completes when the rename has been persisted. + Task RenameAsync( + ulong guildId, + Guid serverId, + ulong entityId, + string name, + CancellationToken cancellationToken = default); + + /// Sets the embed message id (no-op if absent). + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game storage-monitor entity id. + /// The Discord embed message id. + /// A cancellation token. + /// A task that completes when the message id has been persisted. + Task SetMessageIdAsync( + ulong guildId, + Guid serverId, + ulong entityId, + ulong messageId, + CancellationToken cancellationToken = default); + + /// Removes a storage monitor (no-op if absent). + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The in-game storage-monitor entity id. + /// A cancellation token. + /// A task that completes when the storage monitor has been removed. + Task RemoveAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs b/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs new file mode 100644 index 0000000..9de17da --- /dev/null +++ b/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.StorageMonitors; + +namespace RustPlusBot.Persistence.StorageMonitors; + +/// EF-backed . +/// The bot database context. +/// Supplies the creation timestamp. +public sealed class StorageMonitorStore(BotDbContext context, IClock clock) : IStorageMonitorStore +{ + /// + public async Task AddAsync( + ulong guildId, + Guid serverId, + ulong entityId, + string name, + ulong pairedByUserId, + CancellationToken cancellationToken = default) + { + var entity = new SmartStorageMonitor + { + GuildId = guildId, + ServerId = serverId, + EntityId = entityId, + Name = name, + PairedByUserId = pairedByUserId, + CreatedUtc = clock.UtcNow, + }; + context.SmartStorageMonitors.Add(entity); + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return entity; + } + catch (DbUpdateException) + { + // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the + // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by + // detaching the failed insert and returning the row the winner persisted. If no such row exists, + // the failure was not the uniqueness race — let it propagate. + context.Entry(entity).State = EntityState.Detached; + var existing = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + throw; + } + + return existing; + } + } + + /// + public Task GetAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken = default) => + context.SmartStorageMonitors.SingleOrDefaultAsync( + s => s.GuildId == guildId && s.ServerId == serverId && s.EntityId == entityId, cancellationToken); + + /// + public async Task> ListByServerAsync( + ulong guildId, + Guid serverId, + CancellationToken cancellationToken = default) + { + // SQLite cannot ORDER BY a DateTimeOffset column, so order oldest-first on the client side. + var monitors = await context.SmartStorageMonitors + .Where(s => s.GuildId == guildId && s.ServerId == serverId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return [.. monitors.OrderBy(s => s.CreatedUtc)]; + } + + /// + public Task ExistsAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken = default) => + context.SmartStorageMonitors.AnyAsync( + s => s.GuildId == guildId && s.ServerId == serverId && s.EntityId == entityId, cancellationToken); + + /// + public Task RenameAsync( + ulong guildId, + Guid serverId, + ulong entityId, + string name, + CancellationToken cancellationToken = default) => + MutateAsync(guildId, serverId, entityId, s => s.Name = name, cancellationToken); + + /// + public Task SetMessageIdAsync( + ulong guildId, + Guid serverId, + ulong entityId, + ulong messageId, + CancellationToken cancellationToken = default) => + MutateAsync(guildId, serverId, entityId, s => s.MessageId = messageId, cancellationToken); + + /// + public async Task RemoveAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken = default) + { + var entity = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false); + if (entity is null) + { + return; + } + + context.SmartStorageMonitors.Remove(entity); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task MutateAsync( + ulong guildId, + Guid serverId, + ulong entityId, + Action mutate, + CancellationToken cancellationToken) + { + var entity = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false); + if (entity is null) + { + return; + } + + mutate(entity); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs index b5e7870..8a9693d 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -16,6 +16,7 @@ using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.StorageMonitors; using RustPlusBot.Persistence.Switches; namespace RustPlusBot.Features.Connections.Tests; @@ -53,6 +54,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(source); services.AddSingleton(Options.Create(new ConnectionOptions { diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 34d5560..46ddfbd 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -21,6 +21,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource private readonly ConcurrentQueue _connectOutcomes = new(); private readonly ConcurrentQueue _heartbeats = new(); private readonly ConcurrentQueue> _pendingMarkerScript = new(); + private readonly Dictionary _pendingStorageContents = []; private int _createCount; private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); @@ -56,6 +57,14 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p connection.MonumentsResult = _pendingMonuments; _pendingMonuments = []; + // Transfer any pre-staged storage contents so they are in place before the prime loop starts. + foreach (var (entityId, contents) in _pendingStorageContents) + { + connection.StorageContents[entityId] = contents; + } + + _pendingStorageContents.Clear(); + LastConnection = connection; return connection; } @@ -83,6 +92,17 @@ public void EnqueueMarkers(IReadOnlyList markers) => /// The monument list to return from . public void SetMonuments(IReadOnlyList monuments) => _pendingMonuments = monuments; + /// + /// Pre-stages storage contents for a given entity, to be transferred to the NEXT connection created by + /// . Eliminates the setup race when the prime loop reads contents before the test can + /// assign them on . + /// Call this before . + /// + /// The storage-monitor entity id to stage. + /// The contents snapshot to return from . + public void EnqueueStorageInfo(ulong entityId, StorageContentsSnapshot? contents) => + _pendingStorageContents[entityId] = contents; + internal HeartbeatResult NextHeartbeat() { if (_heartbeats.TryDequeue(out var next)) @@ -121,6 +141,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The state returned by per entity id; absent → null. public Dictionary SwitchStates { get; } = []; + /// The contents returned by per entity id; absent → null. + public Dictionary StorageContents { get; } = []; + /// The result returned by . Defaults to true. public bool SetSwitchResult { get; set; } = true; @@ -155,6 +178,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// Raised by . public event EventHandler? SmartDeviceTriggered; + /// Raised by . + public event EventHandler? StorageMonitorTriggered; + public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(outcome); @@ -191,6 +217,13 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella Task.FromResult(SwitchStates.TryGetValue(entityId, out var s) ? s : null); #pragma warning restore RCS1163 +#pragma warning disable RCS1163 // Unused parameters for fake implementation + public Task GetStorageMonitorInfoAsync(ulong entityId, + TimeSpan timeout, + CancellationToken cancellationToken) => + Task.FromResult(StorageContents.TryGetValue(entityId, out var c) ? c : null); +#pragma warning restore RCS1163 + #pragma warning disable RCS1163 // Unused parameters for fake implementation public Task SetSmartSwitchValueAsync(ulong entityId, bool value, @@ -263,5 +296,11 @@ public Task> GetMonumentsAsync(TimeSpan timeout, /// The current active state carried on the trigger arg. public void RaiseSmartDeviceTriggered(ulong entityId, bool isActive) => SmartDeviceTriggered?.Invoke(this, new SmartDeviceTrigger(entityId, isActive)); + + /// Simulates an in-game storage-monitor contents change. + /// The storage-monitor entity id to raise the event for. + /// The contents snapshot carried on the trigger. + public void RaiseStorageMonitorTriggered(ulong entityId, StorageContentsSnapshot contents) => + StorageMonitorTriggered?.Invoke(this, new StorageMonitorTrigger(entityId, contents)); } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs new file mode 100644 index 0000000..41a608d --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs @@ -0,0 +1,229 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord.Notifications; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Connections.Tests.Fakes; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Alarms; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.StorageMonitors; +using RustPlusBot.Persistence.Switches; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class StorageMonitorPrimingTests +{ + private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMemoryEventBus Bus) CreateHarness( + FakeRustSocketSource source) + { + var protector = Substitute.For(); + protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); + var dm = Substitute.For(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + var bus = new InMemoryEventBus(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(protector); + services.AddSingleton(dm); + services.AddSingleton(bus); + + var cs = $"DataSource=storagepriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(cs); + keepAlive.Open(); + using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) + { + seed.Database.Migrate(); + } + + services.AddSingleton(keepAlive); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(source); + services.AddSingleton(Options.Create(new ConnectionOptions + { + ConnectTimeout = TimeSpan.FromSeconds(1), + InitialRetryDelay = TimeSpan.FromMilliseconds(5), + MaxRetryDelay = TimeSpan.FromMilliseconds(20), + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatTimeout = TimeSpan.FromMilliseconds(200), + })); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return (provider, provider.GetRequiredService(), bus); + } + + private static async Task SeedServerWithActiveAndMonitorAsync(ServiceProvider provider, ulong entityId) + { + using var scope = provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + ctx.RustServers.Add(server); + ctx.PlayerCredentials.Add(new PlayerCredential + { + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 1UL, + SteamId = 555UL, + ProtectedPlayerToken = "123", + Status = CredentialStatus.Active, + }); + await ctx.SaveChangesAsync(); + var store = scope.ServiceProvider.GetRequiredService(); + await store.AddAsync(10UL, server.Id, entityId, $"Storage Monitor {entityId}", 1UL); + return server.Id; + } + + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) + { + while (!condition()) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(10, ct); + } + } + + [Fact] + public async Task Priming_publishes_StorageMonitorTriggeredEvent_for_persisted_monitor_on_connect() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndMonitorAsync(provider, entityId: 777UL); + + var contents = new StorageContentsSnapshot(48, null, null, []); + + // Pre-stage the storage contents BEFORE EnsureConnectionAsync so the prime loop sees it. + source.EnqueueStorageInfo(777UL, contents); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Subscribe BEFORE connecting: SubscribeAsync registers the channel eagerly on THIS thread, so the primed + // publish (which fires during EnsureConnectionAsync) is observed. Only the enumeration runs in Task.Run. + var stream = bus.SubscribeAsync(cts.Token); + var received = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Task.Run( + async () => + { + await foreach (var evt in stream) + { + if (evt.EntityId == 777UL) + { + received.TrySetResult(evt); + break; + } + } + }, + cts.Token); + + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + var result = await received.Task.WaitAsync(cts.Token); + Assert.Equal(777UL, result.EntityId); + Assert.Equal(48, result.Contents.Capacity); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task GetStorageContentsAsync_returns_null_when_no_live_socket() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, _) = CreateHarness(source); + await using var disposeProvider = provider; + + var result = await supervisor.GetStorageContentsAsync(10UL, Guid.NewGuid(), 777UL, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetStorageContentsAsync_returns_contents_when_live_socket_exists() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, _) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndMonitorAsync(provider, entityId: 888UL); + + var expectedContents = new StorageContentsSnapshot(24, null, null, + [new StorageItemSnapshot(-151838493, 100, false)]); + source.EnqueueStorageInfo(888UL, expectedContents); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + var result = await supervisor.GetStorageContentsAsync(10UL, serverId, 888UL, cts.Token); + + Assert.NotNull(result); + Assert.Equal(24, result.Capacity); + var item = Assert.Single(result.Items); + Assert.Equal(-151838493, item.ItemId); + Assert.Equal(100, item.Quantity); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task Trigger_publishes_StorageMonitorTriggeredEvent_on_storage_change() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndMonitorAsync(provider, entityId: 999UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Subscribe BEFORE raising the trigger so the channel is registered when the publish fires. + var stream = bus.SubscribeAsync(cts.Token); + var received = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Task.Run( + async () => + { + await foreach (var e in stream) + { + // Skip any prime (null → not published) and match the trigger for entity 999. + if (e is { EntityId: 999UL }) + { + received.TrySetResult(e); + break; + } + } + }, + cts.Token); + + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + var triggerContents = new StorageContentsSnapshot(48, null, null, + [new StorageItemSnapshot(-151838493, 500, false)]); + source.LastConnection!.RaiseStorageMonitorTriggered(999UL, triggerContents); + + var evt = await received.Task.WaitAsync(cts.Token); + Assert.Equal(999UL, evt.EntityId); + Assert.Equal(48, evt.Contents.Capacity); + await supervisor.StopAllAsync(); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorSeamTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorSeamTests.cs new file mode 100644 index 0000000..c1cceb9 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageMonitorSeamTests.cs @@ -0,0 +1,28 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Connections.Tests.Fakes; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class StorageMonitorSeamTests +{ + [Fact] + public void Fake_RaiseStorageMonitorTriggered_DeliversContents() + { + var source = new FakeRustSocketSource(); + var connection = (FakeRustSocketSource.FakeConnection)source.Create("1.2.3.4", 28015, 100UL, "tok"); + + StorageMonitorTrigger? received = null; + connection.StorageMonitorTriggered += (_, t) => received = t; + + var contents = new StorageContentsSnapshot(24, true, DateTimeOffset.UnixEpoch, + [new StorageItemSnapshot(-151838493, 500, false)]); + connection.RaiseStorageMonitorTriggered(777UL, contents); + + Assert.NotNull(received); + Assert.Equal(777UL, received.EntityId); + var item = Assert.Single(received.Contents.Items); + Assert.Equal(-151838493, item.ItemId); + Assert.Equal(500, item.Quantity); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs index 3f125de..9f32c16 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs @@ -13,8 +13,10 @@ using RustPlusBot.Features.Connections.Supervisor; using RustPlusBot.Features.Connections.Tests.Fakes; using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.StorageMonitors; using RustPlusBot.Persistence.Switches; namespace RustPlusBot.Features.Connections.Tests; @@ -51,6 +53,8 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(source); services.AddSingleton(Options.Create(new ConnectionOptions { diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs index 9ab4821..bce5bc8 100644 --- a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs @@ -38,6 +38,10 @@ private static PairingNotification AlarmPairing(Guid fpServer, ulong entityId = new(PairingKind.Entity, string.Empty, string.Empty, 0, 1UL, "t", FacepunchServerId: fpServer, EntityId: entityId, EntityKind: PairedEntityKind.SmartAlarm); + private static PairingNotification StorageMonitorPairing(Guid fpServer, ulong entityId = 77UL) => + new(PairingKind.Entity, string.Empty, string.Empty, 0, 1UL, "t", + FacepunchServerId: fpServer, EntityId: entityId, EntityKind: PairedEntityKind.StorageMonitor); + [Fact] public async Task ServerPairing_CreatesServerCredentialAndFiresEventOnce() { @@ -147,4 +151,24 @@ await bus.Received(1).PublishAsync( Arg.Is(e => e.ServerId == server.Id && e.EntityId == 55UL), Arg.Any()); await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); } + + [Fact] + public async Task EntityPairing_StorageMonitor_PublishesStorageMonitorPairedEvent_NotSwitch() + { + var (context, connection) = TestDb.Create(); + await using var _ = context; + await using var __ = connection; + var bus = Substitute.For(); + var handler = CreateHandler(context, bus); + await handler.HandleAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); + var server = await context.RustServers.SingleAsync(); + bus.ClearReceivedCalls(); + + await handler.HandleAsync(10UL, 1UL, StorageMonitorPairing(FpServer, 77UL), CancellationToken.None); + + await bus.Received(1).PublishAsync( + Arg.Is(e => e.ServerId == server.Id && e.EntityId == 77UL), + Arg.Any()); + await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } } diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/EmbeddedItemNameResolverTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/EmbeddedItemNameResolverTests.cs new file mode 100644 index 0000000..026dfc5 --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/EmbeddedItemNameResolverTests.cs @@ -0,0 +1,20 @@ +using RustPlusBot.Features.StorageMonitors.Naming; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +public sealed class EmbeddedItemNameResolverTests +{ + [Fact] + public void Resolve_KnownId_ReturnsDisplayName() + { + var resolver = new EmbeddedItemNameResolver(); + Assert.Equal("Wood", resolver.Resolve(-151838493)); + } + + [Fact] + public void Resolve_UnknownId_ReturnsFallback() + { + var resolver = new EmbeddedItemNameResolver(); + Assert.Equal("Item 123456789", resolver.Resolve(123456789)); + } +} diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj b/tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj new file mode 100644 index 0000000..21fca2b --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorEmbedRendererTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorEmbedRendererTests.cs new file mode 100644 index 0000000..0a1dd95 --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorEmbedRendererTests.cs @@ -0,0 +1,112 @@ +using Discord; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Naming; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +public sealed class StorageMonitorEmbedRendererTests +{ + private static StorageMonitorEmbedRenderer Create(out IItemNameResolver names) + { + var loc = new ResxLocalizer(); + names = Substitute.For(); + names.Resolve(Arg.Any()).Returns(ci => "Item" + (int)ci[0]); + return new StorageMonitorEmbedRenderer(loc, names); + } + + private static SmartStorageMonitor Sample(string name = "Box") => new() + { + Id = Guid.NewGuid(), ServerId = Guid.NewGuid(), EntityId = 7UL, Name = name, + }; + + [Fact] + public void RenderMonitor_NullContents_ShowsUnreachableAndDisablesButtons() + { + var renderer = Create(out _); + var monitor = Sample("Box"); + + var (embed, components) = renderer.RenderMonitor(monitor, contents: null, culture: "en"); + + Assert.Contains("Unreachable", embed.Description ?? string.Empty, StringComparison.Ordinal); + var buttons = components.Components.OfType() + .SelectMany(r => r.Components) + .OfType() + .ToList(); + Assert.All(buttons, b => Assert.True(b.IsDisabled)); + } + + [Fact] + public void RenderMonitor_ToolCupboardWithProtection_ShowsTypeAndProtectionText() + { + var renderer = Create(out _); + var monitor = Sample("TC"); + var contents = new StorageContentsSnapshot(24, true, DateTimeOffset.UtcNow.AddHours(4), []); + + var (embed, _) = renderer.RenderMonitor(monitor, contents, "en"); + + Assert.Contains("Tool Cupboard", embed.Description ?? string.Empty, StringComparison.Ordinal); + Assert.Contains("Protected", embed.Description ?? string.Empty, StringComparison.Ordinal); + } + + [Fact] + public void RenderMonitor_LargeBoxWithItems_ListsItemsSortedDescAndNoProtection() + { + var renderer = Create(out _); + var monitor = Sample("LargeBox"); + var contents = new StorageContentsSnapshot(48, null, null, + [ + new StorageItemSnapshot(100, 5, false), + new StorageItemSnapshot(200, 50, false), + ]); + + var (embed, _) = renderer.RenderMonitor(monitor, contents, "en"); + + var desc = embed.Description ?? string.Empty; + // Items should appear; IItemNameResolver returns "Item{id}" + Assert.Contains("Item200", desc, StringComparison.Ordinal); + Assert.Contains("Large Box", desc, StringComparison.Ordinal); + // No protection line for a non-TC box + Assert.DoesNotContain("Protected", desc, StringComparison.Ordinal); + Assert.DoesNotContain("protected", desc, StringComparison.Ordinal); + // Item200 (qty 50) should appear before Item100 (qty 5) + Assert.True(desc.IndexOf("Item200", StringComparison.Ordinal) < + desc.IndexOf("Item100", StringComparison.Ordinal)); + } + + [Fact] + public void RenderMonitor_EmptyBox_ShowsEmptyText() + { + var renderer = Create(out _); + var monitor = Sample("EmptyBox"); + var contents = new StorageContentsSnapshot(12, null, null, []); + + var (embed, _) = renderer.RenderMonitor(monitor, contents, "en"); + + var desc = embed.Description ?? string.Empty; + Assert.Contains("Empty", desc, StringComparison.Ordinal); + Assert.Contains("Small Box", desc, StringComparison.Ordinal); + } + + [Fact] + public void RenderPrompt_HasAcceptAndDismissButtons() + { + var renderer = Create(out _); + var serverId = Guid.NewGuid(); + + var (embed, components) = renderer.RenderPrompt(serverId, 42UL, "Storage Monitor 42", "en"); + + Assert.Contains("New storage monitor detected", embed.Title ?? string.Empty, StringComparison.Ordinal); + var buttons = components.Components.OfType() + .SelectMany(r => r.Components) + .OfType() + .ToList(); + Assert.Contains(buttons, b => + b.CustomId == $"{StorageMonitorComponentIds.AcceptPrefix}{serverId}:42"); + Assert.Contains(buttons, b => + b.CustomId == $"{StorageMonitorComponentIds.DismissPrefix}{serverId}:42"); + } +} diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorPairingCoordinatorTests.cs new file mode 100644 index 0000000..51c33a4 --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorPairingCoordinatorTests.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Naming; +using RustPlusBot.Features.StorageMonitors.Pairing; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.StorageMonitors; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +public sealed class StorageMonitorPairingCoordinatorTests +{ + private static Harness Create() + { + var store = Substitute.For(); + var workspace = Substitute.For(); + workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + services.AddScoped(_ => workspace); + var provider = services.BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(777UL); + + var poster = Substitute.For(); + poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(900UL); + + var names = Substitute.For(); + names.Resolve(Arg.Any()).Returns(ci => "Item" + (int)ci[0]); + var renderer = new StorageMonitorEmbedRenderer(new ResxLocalizer(), names); + var coordinator = new StorageMonitorPairingCoordinator(scopeFactory, locator, poster, renderer); + return new Harness(coordinator, store, poster, locator); + } + + [Fact] + public async Task Paired_new_monitor_posts_prompt_with_default_name() + { + var h = Create(); + var serverId = Guid.NewGuid(); + h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(false); + + await h.Coordinator.HandlePairedAsync(new StorageMonitorPairedEvent(10UL, serverId, 42UL), + CancellationToken.None); + + await h.Poster.Received(1).EnsureAsync(777UL, null, Arg.Any(), + Arg.Any(), Arg.Any()); + Assert.Equal("Storage Monitor 42", h.Coordinator.PendingName(10UL, serverId, 42UL)); + } + + [Fact] + public async Task Paired_already_managed_monitor_is_ignored() + { + var h = Create(); + var serverId = Guid.NewGuid(); + h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(true); + + await h.Coordinator.HandlePairedAsync(new StorageMonitorPairedEvent(10UL, serverId, 42UL), + CancellationToken.None); + + await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any()); + Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL)); + } + + [Fact] + public async Task Accept_persists_monitor_and_replaces_prompt() + { + var h = Create(); + var serverId = Guid.NewGuid(); + h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(false); + await h.Coordinator.HandlePairedAsync(new StorageMonitorPairedEvent(10UL, serverId, 42UL), + CancellationToken.None); + h.Store.AddAsync(10UL, serverId, 42UL, "Storage Monitor 42", 5UL, Arg.Any()) + .Returns(new SmartStorageMonitor + { + GuildId = 10UL, ServerId = serverId, EntityId = 42UL, Name = "Storage Monitor 42", + }); + + var ok = await h.Coordinator.TryAcceptAsync(10UL, serverId, 42UL, acceptingUserId: 5UL, + CancellationToken.None); + + Assert.True(ok); + await h.Store.Received(1).AddAsync(10UL, serverId, 42UL, "Storage Monitor 42", 5UL, + Arg.Any()); + Assert.Null(h.Coordinator.PendingName(10UL, serverId, 42UL)); // pending cleared + } + + [Fact] + public async Task Accept_is_noop_when_already_persisted_by_race() + { + var h = Create(); + var serverId = Guid.NewGuid(); + h.Store.ExistsAsync(10UL, serverId, 42UL, Arg.Any()).Returns(true); + + var ok = await h.Coordinator.TryAcceptAsync(10UL, serverId, 42UL, 5UL, CancellationToken.None); + + Assert.False(ok); + await h.Store.DidNotReceive().AddAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public void TryDismiss_no_pending_returns_false() => + Assert.False(Create().Coordinator.TryDismiss(10UL, Guid.NewGuid(), 42UL)); + + private sealed record Harness( + StorageMonitorPairingCoordinator Coordinator, + IStorageMonitorStore Store, + IStorageMonitorChannelPoster Poster, + IStorageMonitorChannelLocator Locator); +} diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorRegistrationTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorRegistrationTests.cs new file mode 100644 index 0000000..6affb83 --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorRegistrationTests.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Naming; +using RustPlusBot.Features.StorageMonitors.Pairing; +using RustPlusBot.Features.StorageMonitors.Relaying; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +public sealed class StorageMonitorRegistrationTests +{ + [Fact] + public void AddStorageMonitors_registers_core_services() + { + var services = new ServiceCollection(); + services.AddStorageMonitors(); + + Assert.Contains(services, d => d.ServiceType == typeof(StorageMonitorPairingCoordinator)); + Assert.Contains(services, d => d.ServiceType == typeof(StorageMonitorStateRelay)); + Assert.Contains(services, d => d.ServiceType == typeof(StorageMonitorEmbedRenderer)); + Assert.Contains(services, d => d.ServiceType == typeof(IItemNameResolver)); + Assert.Contains(services, d => d.ServiceType == typeof(ILocalizer)); + } +} diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs new file mode 100644 index 0000000..96365c0 --- /dev/null +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.StorageMonitors; +using RustPlusBot.Features.StorageMonitors.Naming; +using RustPlusBot.Features.StorageMonitors.Posting; +using RustPlusBot.Features.StorageMonitors.Relaying; +using RustPlusBot.Features.StorageMonitors.Rendering; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.StorageMonitors; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.StorageMonitors.Tests; + +public sealed class StorageMonitorStateRelayTests +{ + private const ulong Guild = 10UL; + private static readonly Guid Server = Guid.NewGuid(); + + private static Harness Create() + { + var store = Substitute.For(); + var connections = Substitute.For(); + var workspace = Substitute.For(); + workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); + + var services = new ServiceCollection(); + services.AddScoped(_ => store); + services.AddScoped(_ => connections); + services.AddScoped(_ => workspace); + var provider = services.BuildServiceProvider(); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(555UL); + + var poster = Substitute.For(); + poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns((ulong?)900UL); + + var names = Substitute.For(); + names.Resolve(Arg.Any()).Returns(ci => "Item" + (int)ci[0]); + var renderer = new StorageMonitorEmbedRenderer(new ResxLocalizer(), names); + + var relay = new StorageMonitorStateRelay( + provider.GetRequiredService(), locator, poster, renderer); + + return new Harness(relay, store, poster, connections); + } + + [Fact] + public async Task HandleTriggeredAsync_UnmanagedEntity_DoesNothing() + { + var h = Create(); + h.Store.ExistsAsync(Guild, Server, 7UL, Arg.Any()).Returns(false); + + await h.Relay.HandleTriggeredAsync( + new StorageMonitorTriggeredEvent(Guild, Server, 7UL, + new StorageContentsSnapshot(24, null, null, [])), default); + + await h.Poster.DidNotReceive().EnsureAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleTriggeredAsync_ManagedEntity_PostsEmbed() + { + var h = Create(); + h.Store.ExistsAsync(Guild, Server, 7UL, Arg.Any()).Returns(true); + h.Store.GetAsync(Guild, Server, 7UL, Arg.Any()) + .Returns(new SmartStorageMonitor + { + GuildId = Guild, + ServerId = Server, + EntityId = 7UL, + Name = "Tool Cupboard", + MessageId = null, + }); + + await h.Relay.HandleTriggeredAsync( + new StorageMonitorTriggeredEvent(Guild, Server, 7UL, + new StorageContentsSnapshot(48, null, null, [new StorageItemSnapshot(100, 5, false)])), default); + + await h.Poster.Received(1).EnsureAsync(555UL, Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task HandleConnectionStatusAsync_NotConnected_PostsUnreachable() + { + var h = Create(); + h.Connections.GetStateAsync(Guild, Server, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = Guild, RustServerId = Server, Status = ConnectionStatus.Unreachable, + }); + h.Store.ListByServerAsync(Guild, Server, Arg.Any()) + .Returns( + [ + new SmartStorageMonitor + { + GuildId = Guild, + ServerId = Server, + EntityId = 7UL, + Name = "TC", + MessageId = null, + }, + ]); + + await h.Relay.HandleConnectionStatusAsync( + new ConnectionStatusChangedEvent(Guild, Server), CancellationToken.None); + + await h.Poster.Received(1).EnsureAsync(555UL, Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task HandleConnectionStatusAsync_Connected_DoesNothing() + { + var h = Create(); + h.Connections.GetStateAsync(Guild, Server, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = Guild, RustServerId = Server, Status = ConnectionStatus.Connected, + }); + + await h.Relay.HandleConnectionStatusAsync( + new ConnectionStatusChangedEvent(Guild, Server), CancellationToken.None); + + await h.Poster.DidNotReceive().EnsureAsync(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Any()); + } + + private sealed record Harness( + StorageMonitorStateRelay Relay, + IStorageMonitorStore Store, + IStorageMonitorChannelPoster Poster, + IConnectionStore Connections); +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs new file mode 100644 index 0000000..25867a1 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Locating; + +public sealed class StorageMonitorChannelLocatorTests +{ + private static (StorageMonitorChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock + Clock) + CreateLocator() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var cs = $"DataSource=storagemonitor-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(cs); + keepAlive.Open(); + using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) + { + seed.Database.Migrate(); + } + + var services = new ServiceCollection(); + services.AddSingleton(keepAlive); + services.AddSingleton(clock); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + var locator = new StorageMonitorChannelLocator(provider.GetRequiredService(), clock); + return (locator, provider, cs, clock); + } + + private static async Task SeedAsync(string connectionString) + { + await using var context = + new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options); + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + context.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 10UL, + RustServerId = server.Id, + ChannelKey = WorkspaceChannelKeys.ServerStorageMonitors, + DiscordChannelId = 888UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + + // A row with a null RustServerId (global scope) must be skipped by the locator. + context.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 10UL, + RustServerId = null, + ChannelKey = WorkspaceChannelKeys.ServerStorageMonitors, + DiscordChannelId = 777UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + return server.Id; + } + + [Fact] + public async Task GetChannelIdAsync_returns_provisioned_storagemonitors_channel() + { + var (locator, provider, cs, _) = CreateLocator(); + await using var _p = provider; + var serverId = await SeedAsync(cs); + + var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None); + + Assert.Equal(888UL, channelId); + } + + [Fact] + public async Task GetChannelIdAsync_returns_null_when_not_provisioned() + { + var (locator, provider, _, _) = CreateLocator(); + await using var _p = provider; + + Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Cache_refreshes_after_ttl_expires() + { + var (locator, provider, cs, clock) = CreateLocator(); + await using var _p = provider; + + // Cold load with empty DB — cache built at UnixEpoch, no rows. + var firstResult = await locator.GetChannelIdAsync(20UL, Guid.NewGuid(), CancellationToken.None); + Assert.Null(firstResult); + + // Insert a server + channel into the DB after the first load. + await using var insertCtx = + new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options); + var server = new RustServer + { + GuildId = 20UL, Name = "T", Ip = "2.2.2.2", Port = 28015 + }; + insertCtx.RustServers.Add(server); + await insertCtx.SaveChangesAsync(); + insertCtx.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 20UL, + RustServerId = server.Id, + ChannelKey = WorkspaceChannelKeys.ServerStorageMonitors, + DiscordChannelId = 999UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await insertCtx.SaveChangesAsync(); + + // Clock still at UnixEpoch — within the 30 s TTL, cache must NOT be reloaded. + var withinTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); + Assert.Null(withinTtlResult); + + // Advance the clock past the 30 s TTL — next call must rebuild the cache. + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(31)); + + var afterTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); + Assert.Equal(999UL, afterTtlResult); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs index 9e002ff..822069f 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs @@ -18,4 +18,17 @@ public void Contributes_teamchat_as_interactive_per_server_channel() Assert.Equal(ChannelPermissionProfile.Interactive, teamchat.Permissions); Assert.Equal("channel.teamchat.name", teamchat.NameKey); } + + [Fact] + public void Contributes_storagemonitors_as_interactive_per_server_channel() + { + var provider = new ServerWorkspaceSpecProvider(); + + var storagemonitors = provider.GetChannelSpecs() + .Single(c => c.Key == WorkspaceChannelKeys.ServerStorageMonitors); + + Assert.Equal(WorkspaceScope.PerServer, storagemonitors.Scope); + Assert.Equal(ChannelPermissionProfile.Interactive, storagemonitors.Permissions); + Assert.Equal("channel.storagemonitors.name", storagemonitors.NameKey); + } } diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 0a3b6f6..843f8ba 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -42,6 +42,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(193, EnglishKeys().Count); + Assert.Equal(212, EnglishKeys().Count); } } diff --git a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs new file mode 100644 index 0000000..72ed090 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.StorageMonitors; + +namespace RustPlusBot.Persistence.Tests.StorageMonitors; + +public sealed class SmartStorageMonitorSchemaTests +{ + [Fact] + public async Task SmartStorageMonitor_round_trips_through_sqlite() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + var entity = new SmartStorageMonitor + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 777UL, + Name = "Storage Monitor 777", + PairedByUserId = 5UL, + CreatedUtc = DateTimeOffset.UnixEpoch, + }; + context.Set().Add(entity); + await context.SaveChangesAsync(); + + var loaded = await context.Set().SingleAsync(); + Assert.Equal(777UL, loaded.EntityId); + Assert.Equal("Storage Monitor 777", loaded.Name); + Assert.Null(loaded.MessageId); + } + + [Fact] + public async Task SmartStorageMonitor_cascades_when_server_removed() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + context.Set().Add(new SmartStorageMonitor + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 777UL, + Name = "Storage Monitor 777", + CreatedUtc = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.Set().ToListAsync()); + } + + [Fact] + public async Task SmartStorageMonitor_unique_index_rejects_duplicate_entity() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = connection; + await using var __ = context; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + context.Set().Add(new SmartStorageMonitor + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 777UL, + Name = "A", + CreatedUtc = DateTimeOffset.UnixEpoch, + }); + context.Set().Add(new SmartStorageMonitor + { + GuildId = 10UL, + ServerId = server.Id, + EntityId = 777UL, + Name = "B", + CreatedUtc = DateTimeOffset.UnixEpoch, + }); + + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs new file mode 100644 index 0000000..210f3b4 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs @@ -0,0 +1,145 @@ +using Microsoft.Data.Sqlite; +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.StorageMonitors; + +namespace RustPlusBot.Persistence.Tests.StorageMonitors; + +public sealed class StorageMonitorStoreTests +{ + private static (StorageMonitorStore Store, BotDbContext Context, SqliteConnection Conn) Create() + { + var (context, connection) = SqliteContextFixture.Create(); + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + return (new StorageMonitorStore(context, clock), context, connection); + } + + private static async Task SeedServerAsync(BotDbContext context, string ip = "1.1.1.1", string name = "S") + { + var server = new RustServer + { + GuildId = 10UL, Name = name, Ip = ip, Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + + [Fact] + public async Task Add_then_Get_round_trips() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var added = await store.AddAsync(10UL, serverId, 777UL, "Box", pairedByUserId: 5UL); + var loaded = await store.GetAsync(10UL, serverId, 777UL); + + Assert.NotNull(loaded); + Assert.Equal(added.Id, loaded.Id); + Assert.Equal("Box", loaded.Name); + Assert.Equal(5UL, loaded.PairedByUserId); + Assert.Equal(DateTimeOffset.UnixEpoch, loaded.CreatedUtc); + } + + [Fact] + public async Task Add_is_idempotent_when_monitor_already_exists() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + var first = await store.AddAsync(10UL, serverId, 777UL, "Box", pairedByUserId: 5UL); + // Simulates the concurrent double-accept race: a second AddAsync for the same identity must not throw, + // and must return the row the first accept persisted (the unique index rejects the duplicate insert). + var second = await store.AddAsync(10UL, serverId, 777UL, "Box again", pairedByUserId: 6UL); + + Assert.Equal(first.Id, second.Id); + Assert.Equal("Box", second.Name); + Assert.Equal(5UL, second.PairedByUserId); + Assert.Single(await store.ListByServerAsync(10UL, serverId)); + } + + [Fact] + public async Task Exists_reflects_presence() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + Assert.False(await store.ExistsAsync(10UL, serverId, 777UL)); + await store.AddAsync(10UL, serverId, 777UL, "Box", 5UL); + Assert.True(await store.ExistsAsync(10UL, serverId, 777UL)); + } + + [Fact] + public async Task ListByServer_returns_only_that_server_ordered_by_CreatedUtc() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverA = await SeedServerAsync(context); + var serverB = await SeedServerAsync(context, ip: "2.2.2.2", name: "T"); + + await store.AddAsync(10UL, serverA, 1UL, "A1", 5UL); + await store.AddAsync(10UL, serverA, 2UL, "A2", 5UL); + await store.AddAsync(10UL, serverB, 3UL, "B1", 5UL); + + var listA = await store.ListByServerAsync(10UL, serverA); + Assert.Equal(2, listA.Count); + Assert.All(listA, s => Assert.Equal(serverA, s.ServerId)); + } + + [Fact] + public async Task Rename_and_SetMessageId_mutate_the_row() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 777UL, "Box", 5UL); + + await store.RenameAsync(10UL, serverId, 777UL, "Loot Room"); + await store.SetMessageIdAsync(10UL, serverId, 777UL, 999UL); + + var loaded = await store.GetAsync(10UL, serverId, 777UL); + Assert.NotNull(loaded); + Assert.Equal("Loot Room", loaded.Name); + Assert.Equal(999UL, loaded.MessageId); + } + + [Fact] + public async Task Remove_deletes_the_row() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + await store.AddAsync(10UL, serverId, 777UL, "Box", 5UL); + + await store.RemoveAsync(10UL, serverId, 777UL); + + Assert.Null(await store.GetAsync(10UL, serverId, 777UL)); + } + + [Fact] + public async Task Mutators_are_noops_when_monitor_absent() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + var serverId = await SeedServerAsync(context); + + // Should not throw. + await store.RenameAsync(10UL, serverId, 777UL, "x"); + await store.SetMessageIdAsync(10UL, serverId, 777UL, 1UL); + await store.RemoveAsync(10UL, serverId, 777UL); + + Assert.Null(await store.GetAsync(10UL, serverId, 777UL)); + } +}