Skip to content

Commit d2d57a1

Browse files
HandyS11claude
andauthored
Subsystem 4c: Smart Storage Monitors (#storagemonitors channel, pair/validate, live contents embed + Refresh/Rename) (#27)
* feat(abstractions): storage-monitor contents DTOs + paired/triggered events * feat(pairing): route storage-monitor FCM pairings to StorageMonitorPairedEvent Add StorageMonitor switch arm in PairingHandler.HandleEntityAsync and OnStorageMonitorPairing subscription in RustPlusFcmPairingSource, mirroring the SmartAlarm pattern. Includes TDD test (30 total, +1). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): storage-monitor read seam + trigger event + fake support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(domain): SmartStorageMonitor entity * feat(persistence): add SmartStorageMonitor store, EF config, migration, and tests (Task 6) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(storage): item-name lookup (embedded items.json + resolver) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(storage): embed renderer, component ids, rename modal + i18n keys Add StorageMonitorEmbedRenderer (pure), StorageMonitorComponentIds, and StorageMonitorRenameModal; add 19 EN/FR localization keys (storage.* + channel.storagemonitors.name); bump parity test key count to 212. Uses inline FormatRemaining helper — no Features.Commands reference. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): #storagemonitors channel spec + locator Add ServerStorageMonitors channel key (order 6), ChannelSpec (Interactive/PerServer), IStorageMonitorChannelLocator interface, StorageMonitorChannelLocator (CachingChannelLocator), DI registration, and full xUnit test coverage (spec-provider + locator cache tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(storage): channel poster + pairing coordinator Add IStorageMonitorChannelPoster, DiscordStorageMonitorChannelPoster (untested integration shim delegating to DiscordChannelMessenger), and StorageMonitorPairingCoordinator (mirrors SwitchPairingCoordinator: posts an "Add it?" prompt on paired event, persists via IStorageMonitorStore.AddAsync on accept with contents:null render, race-guarded TryAccept/TryDismiss). Five new tests cover: prompt posted for new monitor, already-managed ignored, accept persists and returns true, accept on race returns false, dismiss without pending returns false. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): supervisor primes/relays storage monitors + GetStorageContentsAsync - Subscribe StorageMonitorTriggered in the connected window and fire-and-forget PublishStorageTriggerAsync - Add PrimeDevicesAsync third arm: list IStorageMonitorStore, call PublishStoragePrimeAsync per entity (null → skip) - Add IRustServerQuery.GetStorageContentsAsync; implement on ConnectionSupervisor via GetStorageMonitorInfoAsync - Add IStorageMonitorStore + IAlarmStore registrations to AlarmPrimingTests and SwitchQueryTests harnesses - Add FakeRustSocketSource.EnqueueStorageInfo for race-free priming test setup - Add StorageMonitorPrimingTests: priming event, no-socket null, query-path, trigger relay (69 total, +4) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(4c): Task 11 — StorageMonitorStateRelay (trigger render + connection status unreachable) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(storage): component module (accept/dismiss/refresh/rename) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(storage): hosted service, DI extension, host wiring Add StorageMonitorsHostedService (3 bus loops: paired, triggered, connection-status), AddStorageMonitors() DI extension, IStorageMonitorStore Persistence registration, Program.cs + Host csproj wiring, and registration test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(storage): harden item-name resolver against duplicate parsed ids ToFrozenDictionary throws on a duplicate key; two distinct string keys in items.json can parse to the same int (NumberStyles.Integer allows a leading sign/whitespace). In the static initializer that would surface as a TypeInitializationException and hard-fault the feature on first use. Group by the parsed id (last wins) so a future regenerated/hand-edited items.json degrades instead of crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4418988 commit d2d57a1

61 files changed

Lines changed: 4869 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1313
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1414
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
15+
<Project Path="src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj" />
1516
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
1617
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1718
<Project Path="src/RustPlusBot.Localization/RustPlusBot.Localization.csproj" />
@@ -24,6 +25,7 @@
2425
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
2526
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
2627
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
28+
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
2729
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
2830
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
2931
<Project Path="tests/RustPlusBot.Features.Alarms.Tests/RustPlusBot.Features.Alarms.Tests.csproj" />

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
6767
ulong entityId,
6868
CancellationToken cancellationToken);
6969

70+
/// <summary>Reads a storage monitor's contents for a (guild, server), or null when there is no live socket.</summary>
71+
/// <param name="guildId">The guild snowflake.</param>
72+
/// <param name="serverId">The server id.</param>
73+
/// <param name="entityId">The storage-monitor entity id.</param>
74+
/// <param name="cancellationToken">A cancellation token.</param>
75+
/// <returns>The contents snapshot, or null when unreachable.</returns>
76+
Task<StorageContentsSnapshot?> GetStorageContentsAsync(
77+
ulong guildId,
78+
Guid serverId,
79+
ulong entityId,
80+
CancellationToken cancellationToken);
81+
7082
/// <summary>Sets a smart switch on/off; returns false when there is no live socket or the call fails.</summary>
7183
/// <param name="guildId">The owning guild snowflake.</param>
7284
/// <param name="serverId">The target server id.</param>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Abstractions.Connections;
2+
3+
/// <summary>A point-in-time read of a storage monitor's contents and protection state.</summary>
4+
/// <param name="Capacity">Total slot count (24 = Tool Cupboard, 48 = large box, 12 = small box), or null if unknown.</param>
5+
/// <param name="HasProtection">For a Tool Cupboard: whether decay protection is active. Null for non-TC monitors.</param>
6+
/// <param name="ProtectionExpiry">When decay protection expires (UTC). Only meaningful when <paramref name="HasProtection"/> is true.</param>
7+
/// <param name="Items">The stacks currently inside the monitor.</param>
8+
public sealed record StorageContentsSnapshot(
9+
int? Capacity,
10+
bool? HasProtection,
11+
DateTimeOffset? ProtectionExpiry,
12+
IReadOnlyList<StorageItemSnapshot> Items);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace RustPlusBot.Abstractions.Connections;
2+
3+
/// <summary>One stack inside a storage monitor: the item id, its quantity, and whether it is a blueprint.</summary>
4+
/// <param name="ItemId">The Rust item id (resolve to a display name via the item-name lookup).</param>
5+
/// <param name="Quantity">The stack quantity.</param>
6+
/// <param name="IsBlueprint">True when the stack is a blueprint rather than the item itself.</param>
7+
public sealed record StorageItemSnapshot(int ItemId, int Quantity, bool IsBlueprint);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>A storage monitor was paired in-game via FCM; the feature offers an "Add it?" prompt.</summary>
4+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
5+
/// <param name="ServerId">The local Rust server id.</param>
6+
/// <param name="EntityId">The in-game storage-monitor entity id.</param>
7+
public sealed record StorageMonitorPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
3+
namespace RustPlusBot.Abstractions.Events;
4+
5+
/// <summary>A managed storage monitor's contents were (re)read — on connect-prime or on an in-game change.</summary>
6+
/// <param name="GuildId">The owning Discord guild snowflake.</param>
7+
/// <param name="ServerId">The local Rust server id.</param>
8+
/// <param name="EntityId">The in-game entity id (the discriminant — the feature filters to ids it manages).</param>
9+
/// <param name="Contents">The contents snapshot carried on the read/broadcast.</param>
10+
public sealed record StorageMonitorTriggeredEvent(
11+
ulong GuildId,
12+
Guid ServerId,
13+
ulong EntityId,
14+
StorageContentsSnapshot Contents);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace RustPlusBot.Domain.StorageMonitors;
2+
3+
/// <summary>A paired Smart Storage Monitor the bot manages, surviving restarts. Guild- and server-scoped.</summary>
4+
public sealed class SmartStorageMonitor
5+
{
6+
/// <summary>Surrogate primary key.</summary>
7+
public Guid Id { get; set; } = Guid.NewGuid();
8+
9+
/// <summary>The owning Discord guild snowflake.</summary>
10+
public ulong GuildId { get; set; }
11+
12+
/// <summary>The server this monitor belongs to (FK to RustServer, cascade delete).</summary>
13+
public Guid ServerId { get; set; }
14+
15+
/// <summary>The in-game storage-monitor entity id.</summary>
16+
public ulong EntityId { get; set; }
17+
18+
/// <summary>User-facing label; defaults to a generated "Storage Monitor &lt;EntityId&gt;" (the FCM event carries no name).</summary>
19+
public string Name { get; set; } = string.Empty;
20+
21+
/// <summary>The Discord message id of this monitor's embed, or null until first posted.</summary>
22+
public ulong? MessageId { get; set; }
23+
24+
/// <summary>The Discord user who accepted (validated) the pairing.</summary>
25+
public ulong PairedByUserId { get; set; }
26+
27+
/// <summary>When the monitor was accepted (UTC).</summary>
28+
public DateTimeOffset CreatedUtc { get; set; }
29+
}

src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ internal interface IRustServerConnection : IAsyncDisposable
5656
/// <returns>True/false for on/off, or null on failure/timeout.</returns>
5757
Task<bool?> GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
5858

59+
/// <summary>Reads a storage monitor's contents, or null on failure/timeout. Also primes the socket's interest so triggers fire for it thereafter.</summary>
60+
/// <param name="entityId">The in-game storage-monitor entity id.</param>
61+
/// <param name="timeout">How long to wait for the response.</param>
62+
/// <param name="cancellationToken">A cancellation token.</param>
63+
/// <returns>The contents snapshot, or null on failure/timeout.</returns>
64+
Task<StorageContentsSnapshot?> GetStorageMonitorInfoAsync(ulong entityId,
65+
TimeSpan timeout,
66+
CancellationToken cancellationToken);
67+
5968
/// <summary>Sets a smart switch on/off; returns true on success, false on failure/timeout.</summary>
6069
/// <param name="entityId">The in-game smart-switch entity id.</param>
6170
/// <param name="value">True to turn on, false to turn off.</param>
@@ -111,4 +120,7 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
111120

112121
/// <summary>Raised when a managed smart device's state changes in-game; carries the entity id and new state.</summary>
113122
event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;
123+
124+
/// <summary>Raised when a managed storage monitor's contents change in-game; carries the entity id and the new contents.</summary>
125+
event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;
114126
}

src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using System.Linq;
23
using Microsoft.Extensions.Logging;
34
using RustPlusApi;
45
using RustPlusBot.Abstractions.Connections;
@@ -54,6 +55,11 @@ public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
5455
CancellationToken cancellationToken) =>
5556
Task.FromResult<bool?>(null);
5657

58+
public Task<StorageContentsSnapshot?> GetStorageMonitorInfoAsync(ulong entityId,
59+
TimeSpan timeout,
60+
CancellationToken cancellationToken) =>
61+
Task.FromResult<StorageContentsSnapshot?>(null);
62+
5763
public Task<bool> SetSmartSwitchValueAsync(ulong entityId,
5864
bool value,
5965
TimeSpan timeout,
@@ -94,6 +100,12 @@ public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered
94100
remove { _ = value; }
95101
}
96102

103+
public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered
104+
{
105+
add { _ = value; }
106+
remove { _ = value; }
107+
}
108+
97109
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
98110
}
99111

@@ -118,6 +130,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo
118130
_rustPlus = new RustPlus(connection);
119131
_rustPlus.OnTeamChatReceived += OnTeamChatReceived;
120132
_rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered;
133+
_rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered;
121134
}
122135

123136
public async Task<SocketConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
@@ -348,6 +361,8 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
348361

349362
public event EventHandler<SmartDeviceTrigger>? SmartDeviceTriggered;
350363

364+
public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;
365+
351366
public async Task<bool?> GetSmartDeviceInfoAsync(ulong entityId,
352367
TimeSpan timeout,
353368
CancellationToken cancellationToken)
@@ -377,6 +392,35 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
377392
}
378393
}
379394

395+
/// <inheritdoc />
396+
public async Task<StorageContentsSnapshot?> GetStorageMonitorInfoAsync(
397+
ulong entityId,
398+
TimeSpan timeout,
399+
CancellationToken cancellationToken)
400+
{
401+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
402+
timeoutCts.CancelAfter(timeout);
403+
try
404+
{
405+
// CONFIRMED (2.0.0-beta.3): GetStorageMonitorInfoAsync(ulong, CancellationToken) returns
406+
// Task<Response<StorageMonitorInfo?>>; the read also primes the entity so OnStorageMonitorTriggered
407+
// fires for it thereafter.
408+
var response = await _rustPlus.GetStorageMonitorInfoAsync(entityId, timeoutCts.Token)
409+
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
410+
return response is { IsSuccess: true, Data: { } info } ? MapContents(info) : null;
411+
}
412+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
413+
{
414+
return null;
415+
}
416+
#pragma warning disable CA1031 // Broad catch: a failed/timed-out storage read returns null; the caller treats null as unreachable.
417+
catch (Exception)
418+
#pragma warning restore CA1031
419+
{
420+
return null;
421+
}
422+
}
423+
380424
public async Task<bool> SetSmartSwitchValueAsync(ulong entityId,
381425
bool value,
382426
TimeSpan timeout,
@@ -558,6 +602,7 @@ public async ValueTask DisposeAsync()
558602
{
559603
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
560604
_rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered;
605+
_rustPlus.OnStorageMonitorTriggered -= OnStorageMonitorTriggered;
561606
try
562607
{
563608
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
@@ -575,6 +620,26 @@ public async ValueTask DisposeAsync()
575620
private void OnSmartDeviceTriggered(object? sender, RustPlusApi.Data.Events.SmartDeviceEventArg e) =>
576621
SmartDeviceTriggered?.Invoke(this, new SmartDeviceTrigger(e.Id, e.IsActive));
577622

623+
private void OnStorageMonitorTriggered(object? sender, RustPlusApi.Data.Events.StorageMonitorEventArg e) =>
624+
StorageMonitorTriggered?.Invoke(this, new StorageMonitorTrigger(e.Id, MapContents(e)));
625+
626+
private static StorageContentsSnapshot MapContents(RustPlusApi.Data.Entities.StorageMonitorInfo info)
627+
{
628+
var items = info.Items is null
629+
? (IReadOnlyList<StorageItemSnapshot>)[]
630+
:
631+
[
632+
.. info.Items.Select(i =>
633+
new StorageItemSnapshot(i.Id, i.Quantity ?? 0, i.IsItemBlueprint ?? false))
634+
];
635+
636+
DateTimeOffset? expiry = info.HasProtection == true
637+
? new DateTimeOffset(DateTime.SpecifyKind(info.ProtectionExpiry, DateTimeKind.Utc))
638+
: null;
639+
640+
return new StorageContentsSnapshot(info.Capacity, info.HasProtection, expiry, items);
641+
}
642+
578643
private static void AddMarkers<TMarker>(
579644
List<MapMarkerSnapshot> into,
580645
IReadOnlyDictionary<ulong, TMarker> source,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
3+
namespace RustPlusBot.Features.Connections.Listening;
4+
5+
/// <summary>An in-game storage-monitor broadcast: the entity id and its current contents.</summary>
6+
/// <param name="EntityId">The in-game entity id.</param>
7+
/// <param name="Contents">The contents snapshot carried on the broadcast.</param>
8+
internal sealed record StorageMonitorTrigger(ulong EntityId, StorageContentsSnapshot Contents);

0 commit comments

Comments
 (0)