From 4361ddef85e875edd91c5b45f1720fb45824f152 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 17:03:47 +0200 Subject: [PATCH 1/2] Fix per-device reachability: alarm reads, reachable storage renders, sweep contents refresh Live-run diagnosis (devices always 'unreachable' despite working triggers): 1. Alarms were primed/swept via GetSmartSwitchInfoAsync; RustPlusApi beta.3 type-checks the server-reported entity type and throws for alarms, so every alarm read surfaced as NoResponse on each connect. Reads are now kind-aware (new SmartDeviceKind threaded through IRustServerConnection): alarms go through GetAlarmInfoAsync. 2. A storage monitor that (re)became Reachable was re-rendered with null contents, which displays the unreachable banner and disables Refresh/Rename. The prime's contents render raced this on a second bus loop and reliably lost. StorageMonitorStateRelay now re-reads contents via IRustServerQuery before rendering a Reachable device. 3. The 5-minute reachability sweep read storage contents and discarded them; it now republishes them as StorageMonitorTriggeredEvent so embeds keep tracking in-game changes even when no EntityChanged broadcast arrives. Device triggers are deliberately NOT republished (an active alarm would re-ping every sweep). Library-side defects (mapper type-check, capacity-based broadcast routing) are documented in RustPlusApi docs/development/beta4-entity-info-fixes.md. Co-Authored-By: Claude Fable 5 --- .../Connections/SmartDeviceKind.cs | 14 ++ .../Listening/IRustServerConnection.cs | 6 +- .../Listening/RustPlusSocketSource.cs | 18 ++- .../Supervisor/ConnectionSupervisor.cs | 47 ++++-- .../Relaying/StorageMonitorStateRelay.cs | 14 +- .../AlarmPrimingTests.cs | 42 +++++ .../Fakes/FakeRustSocketSource.cs | 9 ++ .../StorageSweepTests.cs | 147 ++++++++++++++++++ .../StorageMonitorsHostedServiceTests.cs | 3 +- .../StorageMonitorStateRelayTests.cs | 63 +++++++- 10 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Connections/SmartDeviceKind.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs diff --git a/src/RustPlusBot.Abstractions/Connections/SmartDeviceKind.cs b/src/RustPlusBot.Abstractions/Connections/SmartDeviceKind.cs new file mode 100644 index 00000000..4b65d48a --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/SmartDeviceKind.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// +/// The kind of binary-state smart device behind an entity id. The Rust+ API validates the entity type +/// on reads, so callers must ask for the kind they actually paired (a switch read against an alarm fails). +/// +public enum SmartDeviceKind +{ + /// A smart switch. + Switch = 0, + + /// A smart alarm. + Alarm = 1, +} diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 2ecd2aab..d775e8f4 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -51,10 +51,14 @@ internal interface IRustServerConnection : IAsyncDisposable /// Reads a smart device's on/off state and reachability. Also primes the socket's interest in the entity (so triggers fire for it thereafter). /// The in-game entity id (switch or alarm). + /// The paired device kind; the Rust+ API validates the entity type on reads, so an alarm must be read as an alarm. /// How long to wait for the response. /// A cancellation token. /// A with the active state (non-null only when ) and the reachability outcome. - Task GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken); + Task GetSmartDeviceInfoAsync(ulong entityId, + SmartDeviceKind kind, + TimeSpan timeout, + CancellationToken cancellationToken); /// Reads a storage monitor's contents and reachability. Also primes the socket's interest so triggers fire for it thereafter. /// The in-game storage-monitor entity id. diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index a63f5fec..f5361aba 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -51,6 +51,7 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella Task.FromResult(false); public Task GetSmartDeviceInfoAsync(ulong entityId, + SmartDeviceKind kind, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(new DeviceReading(null, DeviceReachability.NoResponse)); @@ -364,6 +365,7 @@ public async Task PromoteToLeaderAsync(ulong steamId, public event EventHandler? StorageMonitorTriggered; public async Task GetSmartDeviceInfoAsync(ulong entityId, + SmartDeviceKind kind, TimeSpan timeout, CancellationToken cancellationToken) { @@ -371,13 +373,19 @@ public async Task GetSmartDeviceInfoAsync(ulong entityId, timeoutCts.CancelAfter(timeout); try { - // CONFIRMED (2.0.0-beta.3): GetSmartSwitchInfoAsync(ulong, CancellationToken) returns - // Task of Response of SmartDeviceInfo; Response.IsSuccess and Response.Data are the accessors and - // SmartDeviceInfo.IsActive is a bool. The call also primes the socket's interest in this + // CONFIRMED (2.0.0-beta.3): GetSmartSwitchInfoAsync/GetAlarmInfoAsync(ulong, CancellationToken) + // return Task of Response of SmartDeviceInfo; Response.IsSuccess and Response.Data are the accessors + // and SmartDeviceInfo.IsActive is a bool. The call also primes the socket's interest in this // entity, so OnSmartDeviceTriggered fires for it thereafter. + // CONFIRMED: both mappers throw InvalidOperationException when the server-reported entity type + // does not match the method (AppEntityInfoToModel type checks), so the paired kind must pick + // the matching read — a switch read against an alarm surfaces as NoResponse otherwise. // CONFIRMED: Response.Error is ErrorMessage? (null on success), so response.Error?.Code is correct. - var response = await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token) - .WaitAsync(timeoutCts.Token).ConfigureAwait(false); + var response = kind == SmartDeviceKind.Alarm + ? await _rustPlus.GetAlarmInfoAsync(entityId, timeoutCts.Token) + .WaitAsync(timeoutCts.Token).ConfigureAwait(false) + : await _rustPlus.GetSmartSwitchInfoAsync(entityId, timeoutCts.Token) + .WaitAsync(timeoutCts.Token).ConfigureAwait(false); var reachability = ReachabilityMapping.FromResponse(response.IsSuccess, response.Error?.Code); var isActive = response is { IsSuccess: true, Data: { } info } ? info.IsActive : (bool?)null; return new DeviceReading(isActive, reachability); diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index fcb11a8e..e71031c0 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -264,7 +264,7 @@ public async Task> GetMonumentsAsync( } var reading = await live.Connection - .GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken) + .GetSmartDeviceInfoAsync(entityId, SmartDeviceKind.Switch, _options.HeartbeatTimeout, cancellationToken) .ConfigureAwait(false); return reading.IsActive; } @@ -691,6 +691,15 @@ await eventBus.PublishAsync( } } + /// + /// Reads every managed device's reachability for one sweep cycle. Side effect: reachable storage + /// monitors get their just-read contents republished as , + /// giving embeds a periodic contents refresh independent of broadcasts. + /// + /// The (guild, server) routing key. + /// The live connection used to read device state. + /// A cancellation token. + /// The reachability snapshot keyed by entity id. private async Task> ReadAllReachabilityAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, @@ -707,9 +716,12 @@ private async Task> ReadAllReachabilityAsy var monitors = await scope.ServiceProvider.GetRequiredService() .ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false); - foreach (var entityId in switches.Select(s => s.EntityId).Concat(alarms.Select(a => a.EntityId))) + var devices = switches.Select(s => (s.EntityId, SmartDeviceKind.Switch)) + .Concat(alarms.Select(a => (a.EntityId, SmartDeviceKind.Alarm))); + foreach (var (entityId, kind) in devices) { - var reading = await connection.GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, ct) + var reading = await connection + .GetSmartDeviceInfoAsync(entityId, kind, _options.HeartbeatTimeout, ct) .ConfigureAwait(false); result[entityId] = reading.Reachability; } @@ -722,6 +734,18 @@ private async Task> ReadAllReachabilityAsy .GetStorageMonitorInfoAsync(monitor.EntityId, _options.HeartbeatTimeout, ct) .ConfigureAwait(false); result[monitor.EntityId] = reading.Reachability; + + // The read already carries the contents — republish them so embeds keep tracking in-game + // changes even when no EntityChanged broadcast arrives (broadcasts alone are unreliable + // for storage monitors). Storage renders have no ping/relay side effects, so a periodic + // republish is safe; device (switch/alarm) triggers must NOT be republished here — an + // active alarm would re-ping on every sweep. + if (reading is { Reachability: DeviceReachability.Reachable, Contents: { } contents }) + { + await eventBus.PublishAsync( + new StorageMonitorTriggeredEvent(key.Guild, key.Server, monitor.EntityId, contents), ct) + .ConfigureAwait(false); + } } } @@ -969,7 +993,8 @@ private async Task PrimeDevicesAsync( } await PrimeEntityIdsAsync(key, connection, - (IReadOnlyList)[.. switches.Select(sw => sw.EntityId)]).ConfigureAwait(false); + (IReadOnlyList)[.. switches.Select(sw => sw.EntityId)], + SmartDeviceKind.Switch).ConfigureAwait(false); IReadOnlyList alarms; try @@ -994,7 +1019,8 @@ await PrimeEntityIdsAsync(key, connection, } await PrimeEntityIdsAsync(key, connection, - (IReadOnlyList)[.. alarms.Select(a => a.EntityId)]).ConfigureAwait(false); + (IReadOnlyList)[.. alarms.Select(a => a.EntityId)], + SmartDeviceKind.Alarm).ConfigureAwait(false); IReadOnlyList monitors; try @@ -1042,7 +1068,8 @@ await PrimeEntityIdsAsync(key, connection, private async Task PrimeEntityIdsAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, - IReadOnlyList entityIds) + IReadOnlyList entityIds, + SmartDeviceKind kind) { #pragma warning disable S3267 // Not a projection: each iteration awaits with per-entity best-effort error handling. foreach (var entityId in entityIds) @@ -1050,7 +1077,7 @@ private async Task PrimeEntityIdsAsync( { try { - await PublishDevicePrimeAsync(key, connection, entityId).ConfigureAwait(false); + await PublishDevicePrimeAsync(key, connection, entityId, kind).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -1100,10 +1127,12 @@ await eventBus.PublishAsync( /// The (guild, server) routing key. /// The live connection used to read device state. /// The entity id of the device to prime. + /// The paired device kind used for the type-checked Rust+ read. private async Task PublishDevicePrimeAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, - ulong entityId) + ulong entityId, + SmartDeviceKind kind) { if (_disposed) { @@ -1113,7 +1142,7 @@ private async Task PublishDevicePrimeAsync( try { var reading = await connection - .GetSmartDeviceInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token) + .GetSmartDeviceInfoAsync(entityId, kind, _options.HeartbeatTimeout, _shutdown.Token) .ConfigureAwait(false); await eventBus.PublishAsync( new DeviceReachabilityChangedEvent(key.Guild, key.Server, entityId, reading.Reachability), diff --git a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs index c4378cd1..fafbea55 100644 --- a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs +++ b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs @@ -17,11 +17,13 @@ namespace RustPlusBot.Features.StorageMonitors.Relaying; /// Resolves the #storagemonitors channel id. /// Posts/edits storage monitor embeds. /// Renders storage monitor embeds. +/// Reads live storage contents when a device becomes reachable. internal sealed class StorageMonitorStateRelay( IServiceScopeFactory scopeFactory, IStorageMonitorChannelLocator locator, IStorageMonitorChannelPoster poster, - StorageMonitorEmbedRenderer renderer) + StorageMonitorEmbedRenderer renderer, + IRustServerQuery query) { /// Handles a storage monitor trigger: ignore unmanaged ids; else render the event's contents directly. /// The storage monitor triggered event. @@ -54,7 +56,9 @@ await RenderAsync(store, monitor, evt.Contents, evt.GuildId, evt.ServerId, cultu } } - /// Handles a per-device reachability change: ignore foreign entities, else persist + re-render. + /// Handles a per-device reachability change: ignore foreign entities, else persist + re-render. + /// A device that (re)became is re-read so the embed shows its + /// contents instead of a stale unreachable banner (the prime races this handler on a second bus loop). /// The device-reachability-changed event. /// A cancellation token. /// A task that completes when the embed has been re-rendered (or the id was ignored). @@ -83,9 +87,13 @@ await store.SetReachabilityAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.Re return; } + var contents = evt.Reachability == DeviceReachability.Reachable + ? await query.GetStorageContentsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken) + .ConfigureAwait(false) + : null; var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken) .ConfigureAwait(false); - await RenderAsync(store, monitor, contents: null, evt.GuildId, evt.ServerId, culture, cancellationToken) + await RenderAsync(store, monitor, contents, evt.GuildId, evt.ServerId, culture, cancellationToken) .ConfigureAwait(false); } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs index 8a9693df..e2788636 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -3,6 +3,7 @@ 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; @@ -132,4 +133,45 @@ public async Task Priming_publishes_state_for_persisted_alarm_on_connect() Assert.False(evt.IsActive); // absent in SwitchStates → null → defaulted off await supervisor.StopAllAsync(); } + + [Fact] + public async Task Priming_reads_alarm_with_alarm_kind() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndAlarmAsync(provider, entityId: 77UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Wait for the prime publish so the device read has definitely happened before asserting. + var stream = bus.SubscribeAsync(cts.Token); + var received = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Task.Run( + async () => + { + await foreach (var evt in stream) + { + if (evt.EntityId == 77UL) + { + received.TrySetResult(evt); + break; + } + } + }, + cts.Token); + + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await received.Task.WaitAsync(cts.Token); + await supervisor.StopAllAsync(); + + // The Rust+ API type-checks entity reads: a switch read against an alarm entity fails and the + // device would be persisted NoResponse forever. The prime must read the alarm AS an alarm. + var connection = Assert.IsType(source.LastConnection); + lock (connection.DeviceReadCalls) + { + Assert.Contains((77UL, SmartDeviceKind.Alarm), connection.DeviceReadCalls); + } + } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 90a24082..e8576a1b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -163,6 +163,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The state returned by per entity id; absent → null. public Dictionary SwitchStates { get; } = []; + /// Records (entityId, kind) for every call, in call order. + public List<(ulong EntityId, SmartDeviceKind Kind)> DeviceReadCalls { get; } = []; + /// The contents returned by per entity id; absent → null. public Dictionary StorageContents { get; } = []; @@ -242,9 +245,15 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella #pragma warning disable RCS1163 // Unused parameters for fake implementation public Task GetSmartDeviceInfoAsync(ulong entityId, + SmartDeviceKind kind, TimeSpan timeout, CancellationToken cancellationToken) { + lock (DeviceReadCalls) + { + DeviceReadCalls.Add((entityId, kind)); + } + var reachability = DeviceReachabilityOverrides.TryGetValue(entityId, out var r) ? r : DeviceReachability.Reachable; diff --git a/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs new file mode 100644 index 00000000..f1385e79 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs @@ -0,0 +1,147 @@ +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 StorageSweepTests +{ + 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=storagesweep-{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), + ReachabilityPollInterval = TimeSpan.FromMilliseconds(25), + })); + 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, $"Monitor {entityId}", 1UL); + return server.Id; + } + + [Fact] + public async Task Sweep_republishes_contents_for_reachable_storage_monitor() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndMonitorAsync(provider, entityId: 88UL); + source.EnqueueStorageInfo(88UL, + new StorageContentsSnapshot(48, null, null, [new StorageItemSnapshot(100, 5, false)])); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Subscribe BEFORE connecting so the prime publish is observed. The prime publishes the first + // contents event; the periodic reachability sweep must republish contents so embeds keep tracking + // in-game changes even when no broadcast arrives (broadcasts alone are unreliable for storage). + var stream = bus.SubscribeAsync(cts.Token); + var second = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Task.Run( + async () => + { + var seen = 0; + await foreach (var evt in stream) + { + if (evt.EntityId != 88UL) + { + continue; + } + + seen++; + if (seen >= 2) + { + second.TrySetResult(evt); + break; + } + } + }, + cts.Token); + + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + var evt = await second.Task.WaitAsync(cts.Token); + Assert.Equal(88UL, evt.EntityId); + Assert.NotNull(evt.Contents); + Assert.Single(evt.Contents.Items); + await supervisor.StopAllAsync(); + } +} diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs index f0de3ec6..3587eaaa 100644 --- a/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/Hosting/StorageMonitorsHostedServiceTests.cs @@ -47,7 +47,8 @@ private static Harness Create() 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(scopeFactory, relayLocator, relayPoster, renderer); + var relay = new StorageMonitorStateRelay(scopeFactory, relayLocator, relayPoster, renderer, + Substitute.For()); // Coordinator collaborators var pairingLocator = Substitute.For(); diff --git a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs index 50eef43f..78e41d4d 100644 --- a/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs +++ b/tests/RustPlusBot.Features.StorageMonitors.Tests/StorageMonitorStateRelayTests.cs @@ -45,10 +45,11 @@ private static Harness Create() names.Resolve(Arg.Any()).Returns(ci => "Item" + (int)ci[0]); var renderer = new StorageMonitorEmbedRenderer(new ResxLocalizer(), names); + var query = Substitute.For(); var relay = new StorageMonitorStateRelay( - provider.GetRequiredService(), locator, poster, renderer); + provider.GetRequiredService(), locator, poster, renderer, query); - return new Harness(relay, store, poster, connections); + return new Harness(relay, store, poster, connections, query); } [Fact] @@ -181,9 +182,65 @@ await h.Poster.Received(1).EnsureAsync(555UL, 900UL, Arg.Any(), Arg.Any()); } + [Fact] + public async Task HandleReachabilityChangedAsync_Reachable_RendersFetchedContents() + { + var h = Create(); + h.Store.ExistsAsync(Guild, Server, 42UL, Arg.Any()).Returns(true); + h.Store.GetAsync(Guild, Server, 42UL, Arg.Any()) + .Returns(new SmartStorageMonitor + { + GuildId = Guild, + ServerId = Server, + EntityId = 42UL, + Name = "TC", + MessageId = 900UL, + Reachability = DeviceReachability.Reachable, + }); + h.Query.GetStorageContentsAsync(Guild, Server, 42UL, Arg.Any()) + .Returns(new StorageContentsSnapshot(48, null, null, [new StorageItemSnapshot(100, 5, false)])); + + await h.Relay.HandleReachabilityChangedAsync( + new DeviceReachabilityChangedEvent(Guild, Server, 42UL, DeviceReachability.Reachable), + CancellationToken.None); + + // A device that became reachable must show its live contents, not the unreachable banner + // (a null-contents render here would also disable the Refresh/Rename buttons). + await h.Poster.Received(1).EnsureAsync(555UL, 900UL, + Arg.Is(e => e.Description.Contains("Item100")), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleReachabilityChangedAsync_NotReachable_DoesNotQueryContents() + { + var h = Create(); + h.Store.ExistsAsync(Guild, Server, 42UL, Arg.Any()).Returns(true); + h.Store.GetAsync(Guild, Server, 42UL, Arg.Any()) + .Returns(new SmartStorageMonitor + { + GuildId = Guild, + ServerId = Server, + EntityId = 42UL, + Name = "TC", + MessageId = 900UL, + Reachability = DeviceReachability.NoResponse, + }); + + await h.Relay.HandleReachabilityChangedAsync( + new DeviceReachabilityChangedEvent(Guild, Server, 42UL, DeviceReachability.NoResponse), + CancellationToken.None); + + await h.Query.DidNotReceive().GetStorageContentsAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()); + await h.Poster.Received(1).EnsureAsync(555UL, 900UL, Arg.Any(), + Arg.Any(), Arg.Any()); + } + private sealed record Harness( StorageMonitorStateRelay Relay, IStorageMonitorStore Store, IStorageMonitorChannelPoster Poster, - IConnectionStore Connections); + IConnectionStore Connections, + IRustServerQuery Query); } From bde1edc17c2baf12b49a039f647891ce9d9d554a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 5 Jul 2026 17:37:56 +0200 Subject: [PATCH 2/2] Alarm embed freshness: live timestamp, silent observed-state sync, Refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Last triggered <1m ago" text was baked into the embed at render time and froze until the next re-render. Three changes keep alarm embeds honest: 1. The last-triggered line now renders Discord's native relative timestamp (), which clients keep updating live — no server-side re-renders needed. CompactDuration and the renderer's IClock dependency are gone. 2. New SmartDeviceStateObservedEvent: a poll/prime/refresh OBSERVED this state, as opposed to a broadcast trigger. AlarmStateRelay syncs silently — persists drifted LastIsActive (leaving LastTriggeredUtc untouched) and re-renders; never pings or relays; no-ops (no Discord edit) when unchanged. Published by the 5-minute reachability sweep (which already read the state and discarded it) and by the connect prime for alarms — the latter also fixes a latent wart where reconnecting while an alarm was active re-pinged @everyone. 3. Refresh button on the alarm embed (mirrors the storage monitor one): re-reads the alarm live via the new kind-aware IRustServerQuery.GetSmartAlarmReadingAsync and republishes reachability + observed state through the same relay pipelines. Localization: alarm.button.refresh (en/fr), key parity 260 → 261. Co-Authored-By: Claude Fable 5 --- .../Connections/IRustServerQuery.cs | 11 + .../Events/SmartDeviceStateObservedEvent.cs | 12 ++ .../Hosting/AlarmsHostedService.cs | 33 ++- .../Modules/AlarmComponentModule.cs | 41 +++- .../Relaying/AlarmStateRelay.cs | 33 +++ .../Rendering/AlarmComponentIds.cs | 3 + .../Rendering/AlarmEmbedRenderer.cs | 34 +--- .../Supervisor/ConnectionSupervisor.cs | 49 ++++- src/RustPlusBot.Localization/Strings.fr.resx | 3 + src/RustPlusBot.Localization/Strings.resx | 3 + .../AlarmEmbedRendererTests.cs | 35 +--- .../AlarmPairingCoordinatorTests.cs | 2 +- .../AlarmRefresherTests.cs | 2 +- .../AlarmStateRelayTests.cs | 73 +++++++ .../Hosting/AlarmsHostedServiceTests.cs | 39 +++- .../AlarmPrimingTests.cs | 14 +- .../AlarmSweepTests.cs | 189 ++++++++++++++++++ .../Fakes/FakeRustSocketSource.cs | 20 ++ .../StringsResourceParityTests.cs | 2 +- 19 files changed, 530 insertions(+), 68 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Events/SmartDeviceStateObservedEvent.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs index 81f546a5..bd4b8cb9 100644 --- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs +++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs @@ -67,6 +67,17 @@ Task> GetMonumentsAsync( ulong entityId, CancellationToken cancellationToken); + /// Reads a smart alarm's live state and reachability (kind-aware Rust+ read). + /// The owning guild snowflake. + /// The target server id. + /// The in-game smart-alarm entity id. + /// A cancellation token. + /// The reading; with a null state when there is no live socket. + Task GetSmartAlarmReadingAsync(ulong guildId, + Guid serverId, + 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. diff --git a/src/RustPlusBot.Abstractions/Events/SmartDeviceStateObservedEvent.cs b/src/RustPlusBot.Abstractions/Events/SmartDeviceStateObservedEvent.cs new file mode 100644 index 00000000..d310ece7 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/SmartDeviceStateObservedEvent.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Abstractions.Events; + +/// +/// A managed smart device's on/off state as observed by a read (connect prime, periodic sweep, +/// or manual refresh) — as opposed to , which reports an in-game +/// broadcast. Consumers correct drifted state silently: an observation must never ping or relay. +/// +/// The owning Discord guild snowflake. +/// The local Rust server id. +/// The in-game entity id (the discriminant — features filter to the ids they manage). +/// The observed on/off state. +public sealed record SmartDeviceStateObservedEvent(ulong GuildId, Guid ServerId, ulong EntityId, bool IsActive); diff --git a/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs b/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs index 42bdc915..fbb0c640 100644 --- a/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs +++ b/src/RustPlusBot.Features.Alarms/Hosting/AlarmsHostedService.cs @@ -6,10 +6,10 @@ namespace RustPlusBot.Features.Alarms.Hosting; -/// Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, and the per-device reachability loop. +/// Runs the alarm-pairing loop, the alarm-triggered relay loop, the connection-status relay loop, the per-device reachability loop, and the observed-state sync loop. /// The in-process event bus. /// Handles paired alarms. -/// Re-renders alarms on trigger/connection/reachability changes. +/// Re-renders alarms on trigger/connection/reachability/observed-state changes. /// The logger. internal sealed partial class AlarmsHostedService( IEventBus eventBus, @@ -18,6 +18,7 @@ internal sealed partial class AlarmsHostedService( ILogger logger) : IHostedService, IDisposable { private readonly CancellationTokenSource _cts = new(); + private Task? _observedLoop; private Task? _pairedLoop; private Task? _reachabilityLoop; private Task? _statusLoop; @@ -33,6 +34,7 @@ public Task StartAsync(CancellationToken cancellationToken) _triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None); _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None); _reachabilityLoop = Task.Run(() => ConsumeReachabilityChangedAsync(_cts.Token), CancellationToken.None); + _observedLoop = Task.Run(() => ConsumeStateObservedAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -42,7 +44,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop + _pairedLoop, _triggeredLoop, _statusLoop, _reachabilityLoop, _observedLoop }.Where(t => t is not null)) { try @@ -146,6 +148,28 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio } } + private async Task ConsumeStateObservedAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.HandleStateObservedAsync(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 + { + LogObservedLoopFaulted(logger, ex); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Alarm pairing loop faulted.")] private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception); @@ -157,4 +181,7 @@ private async Task ConsumeReachabilityChangedAsync(CancellationToken cancellatio [LoggerMessage(Level = LogLevel.Error, Message = "Alarm reachability relay loop faulted.")] private static partial void LogReachabilityLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Alarm observed-state sync loop faulted.")] + private static partial void LogObservedLoopFaulted(ILogger logger, Exception exception); } diff --git a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs index ccfa43db..7e8b91c8 100644 --- a/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs +++ b/src/RustPlusBot.Features.Alarms/Modules/AlarmComponentModule.cs @@ -2,6 +2,8 @@ using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Alarms.Pairing; using RustPlusBot.Features.Alarms.Relaying; using RustPlusBot.Features.Alarms.Rendering; @@ -12,9 +14,13 @@ namespace RustPlusBot.Features.Alarms.Modules; /// Thin handler for the #alarms pairing prompt + control buttons + rename modal. Any guild member. /// Creates a short-lived DI scope per interaction. /// Re-renders the alarm embed on demand. +/// Live socket read (for Refresh). +/// Publishes reachability/observed-state events to drive an embed refresh. public sealed class AlarmComponentModule( IServiceScopeFactory scopeFactory, - IAlarmRefresher refresher) : InteractionModuleBase + IAlarmRefresher refresher, + IRustServerQuery query, + IEventBus eventBus) : InteractionModuleBase { private const string InvalidControlMessage = "That control wasn't valid."; @@ -136,6 +142,39 @@ await refresher.RefreshAsync(Context.Guild.Id, serverId, entityId, unreachable: await FollowupAsync("Updated.", ephemeral: true).ConfigureAwait(false); } + /// Re-reads the alarm's live state and republishes it so the embed refreshes. + /// The "{serverId}:{entityId}" custom-id tail. + [ComponentInteraction(AlarmComponentIds.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 reading = await query + .GetSmartAlarmReadingAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None) + .ConfigureAwait(false); + + // Persist/render stays in the relay pipelines — this handler only reads and publishes. + await eventBus + .PublishAsync(new DeviceReachabilityChangedEvent(Context.Guild.Id, serverId, entityId, + reading.Reachability)) + .ConfigureAwait(false); + if (reading is { Reachability: DeviceReachability.Reachable, IsActive: { } isActive }) + { + await eventBus + .PublishAsync(new SmartDeviceStateObservedEvent(Context.Guild.Id, serverId, entityId, isActive)) + .ConfigureAwait(false); + await FollowupAsync("Refreshed.", ephemeral: true).ConfigureAwait(false); + return; + } + + await FollowupAsync("Alarm is unreachable right now.", ephemeral: true).ConfigureAwait(false); + } + /// Opens the rename modal, carrying the target tail in the modal custom id. /// The "{serverId}:{entityId}" custom-id tail. [ComponentInteraction(AlarmComponentIds.RenamePrefix + "*")] diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs index 8cbf7770..b406f446 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs @@ -135,6 +135,39 @@ public async Task HandleConnectionStatusAsync(ConnectionStatusChangedEvent evt, } } + /// + /// Handles an observed (prime/sweep/refresh) state reading: if it drifted from the persisted state, + /// persists it and re-renders the embed. Never pings or relays — only real triggers notify. The + /// last-triggered timestamp is left untouched (an observation cannot tell when the change happened). + /// + /// The observed-state event. + /// A cancellation token. + /// A task that completes when any drift has been persisted and re-rendered. + public async Task HandleStateObservedAsync( + SmartDeviceStateObservedEvent evt, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var alarm = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken) + .ConfigureAwait(false); + if (alarm is null || alarm.LastIsActive == evt.IsActive) + { + return; // foreign entity, or no drift — nothing to do (no Discord edit in steady state). + } + + await store.UpdateStateAsync(evt.GuildId, evt.ServerId, evt.EntityId, evt.IsActive, + triggeredUtc: null, cancellationToken) + .ConfigureAwait(false); + } + + await refresher.RefreshAsync(evt.GuildId, evt.ServerId, evt.EntityId, unreachable: false, cancellationToken) + .ConfigureAwait(false); + } + /// /// Handles a per-device reachability change: if the entity belongs to a managed alarm, persists the new /// reachability and triggers a refresh. Foreign entities are silently ignored. diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs index 7246a643..70fc2606 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmComponentIds.cs @@ -15,6 +15,9 @@ internal static class AlarmComponentIds /// Relay to team chat toggle button; tail "{serverId}:{entityId}". public const string RelayTogglePrefix = "alarm:relay:"; + /// Refresh button (re-reads live state); tail "{serverId}:{entityId}". + public const string RefreshPrefix = "alarm:refresh:"; + /// Rename button (opens the modal); tail "{serverId}:{entityId}". public const string RenamePrefix = "alarm:rename:"; diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs index 5b277353..3ee1592d 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs @@ -1,7 +1,6 @@ using System.Globalization; using Discord; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Alarms; using RustPlusBot.Localization; @@ -9,8 +8,7 @@ namespace RustPlusBot.Features.Alarms.Rendering; /// Renders a Smart Alarm as a Discord embed + control row, and the pairing-prompt embed + row. Pure. /// The alarm localizer. -/// The clock used to compute relative trigger times. -internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock) +internal sealed class AlarmEmbedRenderer(ILocalizer localizer) { /// Renders the alarm embed and its control buttons. /// The alarm to render. @@ -54,8 +52,11 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock) var blocked = alarm.Reachability is DeviceReachability.Removed or DeviceReachability.NoPrivilege; var disableButtons = unreachable || blocked; + // is Discord's native relative timestamp — clients render "x minutes ago" and keep + // it updating live, so the embed never shows a stale duration between re-renders. var triggered = alarm.LastTriggeredUtc is { } t - ? localizer.Get("alarm.embed.lasttriggered", culture, CompactDuration(clock.UtcNow - t)) + ? localizer.Get("alarm.embed.lasttriggered", culture, + string.Create(CultureInfo.InvariantCulture, $"")) : localizer.Get("alarm.embed.nevertriggered", culture); var embed = new EmbedBuilder() @@ -72,6 +73,8 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock) alarm.PingEveryone ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: disableButtons) .WithButton(localizer.Get(relayKey, culture), AlarmComponentIds.RelayTogglePrefix + tail, alarm.RelayToTeamChat ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: disableButtons) + .WithButton(localizer.Get("alarm.button.refresh", culture), AlarmComponentIds.RefreshPrefix + tail, + ButtonStyle.Primary, disabled: disableButtons) .WithButton(localizer.Get("alarm.button.rename", culture), AlarmComponentIds.RenamePrefix + tail, ButtonStyle.Secondary, disabled: disableButtons) .Build(); @@ -106,27 +109,4 @@ internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock) return (embed, components); } - - /// Formats a duration compactly: "5m", "2h 10m", "3d 4h", "<1m". - /// The duration to format. - /// A compact human-readable duration string. - private static string CompactDuration(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"); - } - - if (span.TotalMinutes >= 1) - { - return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m"); - } - - return "<1m"; - } } diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index e71031c0..8eb065c3 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -269,6 +269,23 @@ public async Task> GetMonumentsAsync( return reading.IsActive; } + /// + public async Task GetSmartAlarmReadingAsync( + ulong guildId, + Guid serverId, + ulong entityId, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return new DeviceReading(null, DeviceReachability.NoResponse); + } + + return await live.Connection + .GetSmartDeviceInfoAsync(entityId, SmartDeviceKind.Alarm, _options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); + } + /// public async Task GetStorageContentsAsync( ulong guildId, @@ -724,6 +741,16 @@ private async Task> ReadAllReachabilityAsy .GetSmartDeviceInfoAsync(entityId, kind, _options.HeartbeatTimeout, ct) .ConfigureAwait(false); result[entityId] = reading.Reachability; + + // Republish the state the read already carries as an OBSERVED event so drifted alarm + // embeds self-correct (the consumer is silent: no ping/relay, no edit when unchanged). + // Deliberately alarms only: switch embeds sync via actuation replies and broadcasts. + if (kind == SmartDeviceKind.Alarm && reading is { IsActive: { } isActive }) + { + await eventBus.PublishAsync( + new SmartDeviceStateObservedEvent(key.Guild, key.Server, entityId, isActive), ct) + .ConfigureAwait(false); + } } #pragma warning disable S3267 // Not a projection: each iteration awaits with per-monitor best-effort error handling. @@ -1150,10 +1177,24 @@ await eventBus.PublishAsync( .ConfigureAwait(false); if (reading.Reachability == DeviceReachability.Reachable) { - await eventBus.PublishAsync( - new SmartDeviceTriggeredEvent(key.Guild, key.Server, entityId, reading.IsActive ?? false), - _shutdown.Token) - .ConfigureAwait(false); + // A prime is an OBSERVATION for alarms: a triggered event would re-ping @everyone on every + // reconnect while the alarm is active. Switch embeds keep the triggered path (no ping semantics). + if (kind == SmartDeviceKind.Alarm) + { + await eventBus.PublishAsync( + new SmartDeviceStateObservedEvent(key.Guild, key.Server, entityId, + reading.IsActive ?? false), + _shutdown.Token) + .ConfigureAwait(false); + } + else + { + await eventBus.PublishAsync( + new SmartDeviceTriggeredEvent(key.Guild, key.Server, entityId, + reading.IsActive ?? false), + _shutdown.Token) + .ConfigureAwait(false); + } } } catch (OperationCanceledException) diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 3cea6676..9a19fc77 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -24,6 +24,9 @@ Relais tchat équipe : activé + + Actualiser + Renommer diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 3f86f03f..a59d1ccb 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -24,6 +24,9 @@ Relay to team chat: on + + Refresh + Rename diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs index 2f32c8cf..c21a66c9 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs @@ -12,13 +12,7 @@ public sealed class AlarmEmbedRendererTests { private static readonly DateTimeOffset _fixedNow = new(2025, 6, 1, 12, 0, 0, TimeSpan.Zero); - private static AlarmEmbedRenderer Create(DateTimeOffset? now = null) - { - var clock = Substitute.For(); - clock.UtcNow.Returns(now ?? _fixedNow); - var localizer = new ResxLocalizer(); - return new AlarmEmbedRenderer(localizer, clock); - } + private static AlarmEmbedRenderer Create() => new(new ResxLocalizer()); private static SmartAlarm Sample( string name = "Fire Alarm", @@ -81,33 +75,26 @@ public void RenderAlarm_never_triggered_shows_never_triggered_text() } [Fact] - public void RenderAlarm_triggered_recently_shows_last_triggered_ago() + public void RenderAlarm_triggered_shows_native_discord_relative_timestamp() { var triggered = _fixedNow.AddMinutes(-5); var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: triggered), unreachable: false, "en"); + // Discord renders as a live-updating "x minutes ago" — the embed never goes stale. Assert.Contains("Last triggered", embed.Description ?? string.Empty, StringComparison.Ordinal); - Assert.Contains("ago", embed.Description ?? string.Empty, StringComparison.Ordinal); - Assert.Contains("5m", embed.Description ?? string.Empty, StringComparison.Ordinal); + Assert.Contains($"", embed.Description ?? string.Empty, + StringComparison.Ordinal); } [Fact] - public void RenderAlarm_triggered_hours_ago_shows_hours_format() + public void RenderAlarm_has_refresh_button_with_identity_tail() { - var triggered = _fixedNow.AddHours(-2).AddMinutes(-10); - var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: triggered), unreachable: false, "en"); - - Assert.Contains("2h", embed.Description ?? string.Empty, StringComparison.Ordinal); - Assert.Contains("10m", embed.Description ?? string.Empty, StringComparison.Ordinal); - } - - [Fact] - public void RenderAlarm_triggered_days_ago_shows_days_format() - { - var triggered = _fixedNow.AddDays(-3); - var (embed, _) = Create().RenderAlarm(Sample(lastTriggeredUtc: triggered), unreachable: false, "en"); + var alarm = Sample(); + var (_, components) = Create().RenderAlarm(alarm, unreachable: false, "en"); + var buttons = Buttons(components); - Assert.Contains("3d", embed.Description ?? string.Empty, StringComparison.Ordinal); + Assert.Contains(buttons, b => + b.CustomId == AlarmComponentIds.RefreshPrefix + $"{alarm.ServerId}:{alarm.EntityId}"); } // ── Buttons — ping ──────────────────────────────────────────────────────── diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs index f982c102..4f32444a 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs @@ -39,7 +39,7 @@ private static Harness Create() var clock = Substitute.For(); clock.UtcNow.Returns(new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero)); var localizer = new ResxLocalizer(); - var renderer = new AlarmEmbedRenderer(localizer, clock); + var renderer = new AlarmEmbedRenderer(localizer); var coordinator = new AlarmPairingCoordinator(scopeFactory, locator, poster, renderer); return new Harness(coordinator, store, poster, locator); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs index 99bafe7a..011d2881 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs @@ -40,7 +40,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL var clock = Substitute.For(); clock.UtcNow.Returns(_fixedNow); var localizer = new ResxLocalizer(); - var renderer = new AlarmEmbedRenderer(localizer, clock); + var renderer = new AlarmEmbedRenderer(localizer); if (alarm is not null) { diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs index ef8a5bec..d93de7ab 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -70,6 +70,79 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL return new Harness(relay, store, refresher, poster, teamChatSender, connections); } + // ────────────────────────────────────────────────────────────────────────── + // Observed state (prime/sweep/refresh) — silent sync + // ────────────────────────────────────────────────────────────────────────── + + /// An observed state that drifted persists (without a trigger timestamp) and refreshes — but never notifies. + [Fact] + public async Task Observed_drifted_state_persists_and_refreshes_without_notifying() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Perimeter", + LastIsActive = false, + PingEveryone = true, + RelayToTeamChat = true, + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleStateObservedAsync( + new SmartDeviceStateObservedEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.Store.Received(1).UpdateStateAsync( + 10UL, serverId, 42UL, true, null, Arg.Any()); + await h.Refresher.Received(1) + .RefreshAsync(10UL, serverId, 42UL, unreachable: false, Arg.Any()); + await h.Poster.DidNotReceive() + .SendEveryonePingAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await h.TeamChatSender.DidNotReceive() + .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// An observed state equal to the persisted one is a no-op (no store write, no Discord edit). + [Fact] + public async Task Observed_unchanged_state_does_nothing() + { + var serverId = Guid.NewGuid(); + var alarm = new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Perimeter", + LastIsActive = true, + }; + var h = Create(alarm: alarm); + + await h.Relay.HandleStateObservedAsync( + new SmartDeviceStateObservedEvent(10UL, serverId, 42UL, IsActive: true), CancellationToken.None); + + await h.Store.DidNotReceive().UpdateStateAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + await h.Refresher.DidNotReceive().RefreshAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + /// An observed state for an entity that is not a managed alarm is ignored. + [Fact] + public async Task Observed_foreign_entity_is_ignored() + { + var h = Create(alarm: null); + + await h.Relay.HandleStateObservedAsync( + new SmartDeviceStateObservedEvent(10UL, Guid.NewGuid(), 99UL, IsActive: true), CancellationToken.None); + + await h.Store.DidNotReceive().UpdateStateAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + await h.Refresher.DidNotReceive().RefreshAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + // ────────────────────────────────────────────────────────────────────────── // Triggered → active // ────────────────────────────────────────────────────────────────────────── diff --git a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs index b371ea55..f771156c 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/Hosting/AlarmsHostedServiceTests.cs @@ -51,7 +51,7 @@ private static Harness Create() teamChatSender.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(TeamChatSendResult.Sent); - var alarmRenderer = new AlarmEmbedRenderer(new ResxLocalizer(), clock); + var alarmRenderer = new AlarmEmbedRenderer(new ResxLocalizer()); var relay = new AlarmStateRelay( scopeFactory, refresher, @@ -134,6 +134,43 @@ await h.Refresher.Received().RefreshAsync( await h.Service.StopAsync(default); } + [Fact] + public async Task SmartDeviceStateObservedEvent_drifted_alarm_routes_to_relay_and_syncs_silently() + { + var h = Create(); + await h.Service.StartAsync(default); + + var serverId = Guid.NewGuid(); + h.Store.GetAsync(10UL, serverId, 42UL, Arg.Any()) + .Returns(new SmartAlarm + { + GuildId = 10UL, + ServerId = serverId, + EntityId = 42UL, + Name = "Perimeter", + LastIsActive = false, + }); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(20); + while (DateTimeOffset.UtcNow < deadline + && !h.Refresher.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == nameof(IAlarmRefresher.RefreshAsync))) + { + await h.Bus.PublishAsync(new SmartDeviceStateObservedEvent(10UL, serverId, 42UL, IsActive: true)); + await Task.Delay(20); + } + + // Observed sync: state persisted WITHOUT a trigger timestamp, embed refreshed, no ping. + await h.Store.Received().UpdateStateAsync( + 10UL, serverId, 42UL, true, null, Arg.Any()); + await h.Refresher.Received().RefreshAsync( + 10UL, serverId, 42UL, unreachable: false, Arg.Any()); + await h.Poster.DidNotReceive() + .SendEveryonePingAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await h.Service.StopAsync(default); + } + [Fact] public async Task ConnectionStatusChangedEvent_non_connected_routes_to_relay_and_refreshes_all_alarms() { diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs index e2788636..601c1ec1 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs @@ -97,7 +97,7 @@ private static async Task SeedServerWithActiveAndAlarmAsync(ServiceProvide } [Fact] - public async Task Priming_publishes_state_for_persisted_alarm_on_connect() + public async Task Priming_publishes_observed_state_for_persisted_alarm_on_connect() { var source = new FakeRustSocketSource(); var (provider, supervisor, bus) = CreateHarness(source); @@ -108,9 +108,12 @@ public async Task Priming_publishes_state_for_persisted_alarm_on_connect() // 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); + // An alarm prime is an OBSERVATION, not a trigger — a triggered event here would re-ping @everyone on + // every reconnect while the alarm is active. + var stream = bus.SubscribeAsync(cts.Token); var received = - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + new TaskCompletionSource(TaskCreationOptions + .RunContinuationsAsynchronously); _ = Task.Run( async () => { @@ -145,9 +148,10 @@ public async Task Priming_reads_alarm_with_alarm_kind() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // Wait for the prime publish so the device read has definitely happened before asserting. - var stream = bus.SubscribeAsync(cts.Token); + var stream = bus.SubscribeAsync(cts.Token); var received = - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + new TaskCompletionSource(TaskCreationOptions + .RunContinuationsAsynchronously); _ = Task.Run( async () => { diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs new file mode 100644 index 00000000..ff0310a6 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs @@ -0,0 +1,189 @@ +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 AlarmSweepTests +{ + 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=alarmsweep-{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), + ReachabilityPollInterval = TimeSpan.FromMilliseconds(25), + })); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return (provider, provider.GetRequiredService(), bus); + } + + private static async Task SeedServerWithActiveAndAlarmAsync(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, $"Alarm {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 GetSmartAlarmReading_reads_with_alarm_kind_when_connected() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, _) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndAlarmAsync(provider, entityId: 77UL); + source.StageDeviceState(77UL, isActive: true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + var reading = await supervisor.GetSmartAlarmReadingAsync(10UL, serverId, 77UL, cts.Token); + + Assert.Equal(DeviceReachability.Reachable, reading.Reachability); + Assert.True(reading.IsActive); + var connection = Assert.IsType(source.LastConnection); + lock (connection.DeviceReadCalls) + { + // The Rust+ API type-checks reads: the refresh path must read the alarm AS an alarm. + Assert.Contains((77UL, SmartDeviceKind.Alarm), connection.DeviceReadCalls); + } + + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task GetSmartAlarmReading_returns_noresponse_when_no_live_socket() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, _) = CreateHarness(source); + await using var disposeProvider = provider; + + var reading = await supervisor.GetSmartAlarmReadingAsync( + 10UL, Guid.NewGuid(), 77UL, CancellationToken.None); + + Assert.Null(reading.IsActive); + Assert.Equal(DeviceReachability.NoResponse, reading.Reachability); + } + + [Fact] + public async Task Sweep_publishes_observed_state_for_reachable_alarm() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor, bus) = CreateHarness(source); + await using var disposeProvider = provider; + var serverId = await SeedServerWithActiveAndAlarmAsync(provider, entityId: 77UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Subscribe BEFORE connecting. The periodic sweep must publish the alarm state it already reads + // as an OBSERVED event so drifted embeds self-correct without ping/relay side effects. + var stream = bus.SubscribeAsync(cts.Token); + var received = + new TaskCompletionSource(TaskCreationOptions + .RunContinuationsAsynchronously); + _ = Task.Run( + async () => + { + await foreach (var evt in stream) + { + if (evt.EntityId == 77UL && evt.IsActive) + { + received.TrySetResult(evt); + break; + } + } + }, + cts.Token); + + // The alarm reads as active; staged so it is in place before the connection is created. + source.StageDeviceState(77UL, isActive: true); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + var evt = await received.Task.WaitAsync(cts.Token); + Assert.Equal(77UL, evt.EntityId); + Assert.True(evt.IsActive); + await supervisor.StopAllAsync(); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index e8576a1b..e7929194 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 Dictionary _pendingDeviceReachabilityOverrides = []; + private readonly Dictionary _pendingDeviceStates = []; private readonly ConcurrentQueue> _pendingMarkerScript = new(); private readonly Dictionary _pendingStorageContents = []; private int _createCount; @@ -74,6 +75,14 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p _pendingDeviceReachabilityOverrides.Clear(); + // Transfer any pre-staged device states so they are in place before the prime/sweep loops read them. + foreach (var (entityId, state) in _pendingDeviceStates) + { + connection.SwitchStates[entityId] = state; + } + + _pendingDeviceStates.Clear(); + LastConnection = connection; return connection; } @@ -125,6 +134,17 @@ public void EnqueueStorageInfo(ulong entityId, StorageContentsSnapshot? contents public void StageDeviceReachability(ulong entityId, DeviceReachability reachability) => _pendingDeviceReachabilityOverrides[entityId] = reachability; + /// + /// Pre-stages the on/off state returned by for a given + /// entity, to be transferred to the NEXT connection created by . Eliminates the setup + /// race when the prime/sweep loops read state before the test can assign + /// . Call this before . + /// + /// The entity id to stage. + /// The on/off state to return for this entity. + public void StageDeviceState(ulong entityId, bool? isActive) => + _pendingDeviceStates[entityId] = isActive; + internal HeartbeatResult NextHeartbeat() { if (_heartbeats.TryDequeue(out var next)) diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 2c31458c..3ba0dd76 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(260, EnglishKeys().Count); + Assert.Equal(261, EnglishKeys().Count); } }