From 4aa697372086f39a0a9c964c1b233e2ed9df3133 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 16:15:23 +0200 Subject: [PATCH 01/14] feat(2a): add map-marker snapshot, dimensions, and MapMarkersChangedEvent Co-Authored-By: Claude Opus 4.8 --- .../Connections/MapDimensions.cs | 7 +++++ .../Connections/MapMarkerSnapshot.cs | 9 +++++++ .../Connections/MarkerKind.cs | 20 ++++++++++++++ .../Events/MapMarkersChangedEvent.cs | 16 +++++++++++ .../Connections/MapMarkerSnapshotTests.cs | 27 +++++++++++++++++++ 5 files changed, 79 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Connections/MapDimensions.cs create mode 100644 src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs create mode 100644 src/RustPlusBot.Abstractions/Connections/MarkerKind.cs create mode 100644 src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs create mode 100644 tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs diff --git a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs new file mode 100644 index 00000000..ada92dd4 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// The static-per-wipe map size needed to convert world coordinates to a grid reference. +/// Map image width. +/// Map image height. +/// The ocean margin around the playable area. +public sealed record MapDimensions(uint Width, uint Height, int OceanMargin); diff --git a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs new file mode 100644 index 00000000..3e4f66d3 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs @@ -0,0 +1,9 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// One map marker observed in a GetMapMarkers poll. +/// The stable marker id (used to diff polls). +/// The classified marker kind. +/// World X coordinate. +/// World Y coordinate. +/// The marker name, if any. +public sealed record MapMarkerSnapshot(ulong Id, MarkerKind Kind, float X, float Y, string? Name); diff --git a/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs b/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs new file mode 100644 index 00000000..e543020a --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Features.Connections.Listening; + +/// The subset of Rust map-marker types this bot reasons about; everything else is . +public enum MarkerKind +{ + /// A marker type the bot does not classify (player, vending, explosion, generic radius, vendor). + Other = 0, + + /// A cargo ship. + CargoShip = 1, + + /// A patrol helicopter. + PatrolHelicopter = 2, + + /// A Chinook (CH-47). + Chinook = 3, + + /// A locked crate. + Crate = 4, +} diff --git a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs new file mode 100644 index 00000000..1d0a239a --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs @@ -0,0 +1,16 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Abstractions.Events; + +/// Published when a marker poll detects markers that appeared or disappeared since the previous poll. +/// The owning guild snowflake. +/// The target server id. +/// Map dimensions for grid-reference rendering, or null if unavailable. +/// Markers present now that were absent in the previous poll. +/// Markers absent now that were present in the previous poll. +public sealed record MapMarkersChangedEvent( + ulong GuildId, + Guid ServerId, + MapDimensions? Dimensions, + IReadOnlyList Added, + IReadOnlyList Removed); diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs new file mode 100644 index 00000000..e9fd66bb --- /dev/null +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs @@ -0,0 +1,27 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Abstractions.Tests.Connections; + +public sealed class MapMarkerSnapshotTests +{ + [Fact] + public void Marker_carries_kind_and_coordinates() + { + var marker = new MapMarkerSnapshot(42UL, MarkerKind.CargoShip, 1234f, 5678f, "Cargo"); + + Assert.Equal(42UL, marker.Id); + Assert.Equal(MarkerKind.CargoShip, marker.Kind); + Assert.Equal(1234f, marker.X); + Assert.Equal(5678f, marker.Y); + Assert.Equal("Cargo", marker.Name); + } + + [Fact] + public void Dimensions_carry_size_and_margin() + { + var dims = new MapDimensions(4000u, 4000u, 500); + + Assert.Equal(4000u, dims.Width); + Assert.Equal(500, dims.OceanMargin); + } +} From 81b34ffc6e33cc224f586f4104d9d6fa32eb51ef Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 16:34:57 +0200 Subject: [PATCH 02/14] feat(2a): add GetMapMarkers/GetMapDimensions to the connection seam + fake --- .../Listening/IRustServerConnection.cs | 12 +++ .../Listening/RustPlusSocketSource.cs | 82 +++++++++++++++++++ .../Fakes/FakeRustSocketSource.cs | 17 ++++ 3 files changed, 111 insertions(+) diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 4e9ef5c3..da239a9f 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -47,6 +47,18 @@ internal interface IRustServerConnection : IAsyncDisposable /// True if the promotion succeeded; false on failure/timeout. Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken); + /// Polls the current map markers (all kinds; the caller diffs by id). Throws on failure. + /// How long to wait for the response. + /// A cancellation token. + /// The current markers (every kind, unclassified ones as ). + Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + + /// Gets the static map dimensions for grid-reference rendering, or null on failure/timeout. + /// How long to wait for the response. + /// A cancellation token. + /// The map dimensions, or null on failure/timeout. + Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// Raised for every in-game team chat line received on this socket. event EventHandler? TeamMessageReceived; } diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 9045c9c8..450d5256 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -48,6 +48,12 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(false); + public Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult(null); + public event EventHandler? TeamMessageReceived { add { _ = value; } @@ -302,6 +308,82 @@ public async Task PromoteToLeaderAsync(ulong steamId, } } + public async Task> GetMapMarkersAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + // CONFIRMED (2.0.0-beta.1): GetMapMarkersAsync returns Task>. + // MapMarkers has typed dictionaries CargoShipMarkers/PatrolHelicopterMarkers/Ch47Markers + // (Dictionary); each marker exposes Nullable Id, Nullable X/Y. + // No flat list, no Type field, NO crate bucket (the game stopped sending crate markers) → core-3. + var response = await _rustPlus.GetMapMarkersAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!response.IsSuccess || response.Data is null) + { + throw new InvalidOperationException("GetMapMarkers returned no data."); + } + + var data = response.Data; + var markers = new List(); + AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip); + AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter); + AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook); + return markers; + } + + private static void AddMarkers( + List into, + IReadOnlyDictionary source, + MarkerKind kind) + where TMarker : RustPlusApi.Data.Markers.Marker + { + foreach (var (id, marker) in source) + { + into.Add(new MapMarkerSnapshot(id, kind, marker.X ?? 0f, marker.Y ?? 0f, Name: null)); + } + } + + public async Task GetMapDimensionsAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + // CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task>. + // ServerMap has Nullable Width/Height, Nullable OceanMargin, JpgImage, Monuments. + // 2a uses dims only; if any dim is null, treat the whole thing as unavailable (return null). + var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!response.IsSuccess || response.Data is null) + { + return null; + } + + var map = response.Data; + if (map.Width is not { } width || map.Height is not { } height || map.OceanMargin is not { } margin) + { + return null; + } + + return new MapDimensions(width, height, margin); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } +#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return null; + } + } + public async ValueTask DisposeAsync() { _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 61a8baa7..8f96a6be 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -81,6 +81,15 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The Steam ID passed to the most recent call. public ulong LastPromotedSteamId { get; private set; } + /// The markers returned by . Defaults to empty (nothing on the map). + public IReadOnlyList MarkersResult { get; set; } = []; + + /// When true, throws (simulates a failed poll). + public bool MarkersThrow { get; set; } + + /// The dimensions returned by . Defaults to a non-null snapshot. + public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500); + /// Raised when a team chat message arrives on this connection. public event EventHandler? TeamMessageReceived; @@ -113,6 +122,14 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella return Task.FromResult(PromoteResult); } + public Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + MarkersThrow + ? Task.FromException>(new InvalidOperationException("poll failed")) + : Task.FromResult(MarkersResult); + + public Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult(DimensionsResult); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// Raises to simulate an inbound team chat line. From ff052e0e2b40b7234daa1c091807afb88a23dd45 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 16:45:27 +0200 Subject: [PATCH 03/14] feat(2a): poll map markers in the connected window and publish diffs Adds MarkerPollInterval to ConnectionOptions (default 10s) and a PollMarkersAsync loop to ConnectionSupervisor: fetches map dimensions once on connect, polls markers on the configured interval, diffs each result against the previous snapshot, and publishes MapMarkersChangedEvent on the event bus. The first poll is a silent baseline; failed polls retain the previous snapshot without producing a spurious diff. Three integration tests verify the silent baseline, the added-marker publish, and the failed-poll snapshot-retention contract. Co-Authored-By: Claude Sonnet 4.6 --- .../ConnectionOptions.cs | 3 + .../Listening/IRustServerConnection.cs | 3 +- .../Listening/RustPlusSocketSource.cs | 30 +-- .../Supervisor/ConnectionSupervisor.cs | 64 +++++++ .../ConnectionSupervisorTests.cs | 179 +++++++++++++++++- .../Fakes/FakeRustSocketSource.cs | 6 +- 6 files changed, 267 insertions(+), 18 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs index 671c2933..134daf48 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs @@ -17,4 +17,7 @@ public sealed class ConnectionOptions /// How long a single heartbeat may take before the socket is considered unreachable. public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// How often to poll map markers for live-event detection. Default 10s. + public TimeSpan MarkerPollInterval { get; init; } = TimeSpan.FromSeconds(10); } diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index da239a9f..7f0b58a5 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -51,7 +51,8 @@ internal interface IRustServerConnection : IAsyncDisposable /// How long to wait for the response. /// A cancellation token. /// The current markers (every kind, unclassified ones as ). - Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + Task> GetMapMarkersAsync(TimeSpan timeout, + CancellationToken cancellationToken = default); /// Gets the static map dimensions for grid-reference rendering, or null on failure/timeout. /// How long to wait for the response. diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 450d5256..7b20dd6e 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -48,10 +48,12 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) => Task.FromResult(false); - public Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + public Task> GetMapMarkersAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) => Task.FromResult>([]); - public Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + public Task GetMapDimensionsAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) => Task.FromResult(null); public event EventHandler? TeamMessageReceived @@ -333,18 +335,6 @@ public async Task> GetMapMarkersAsync( return markers; } - private static void AddMarkers( - List into, - IReadOnlyDictionary source, - MarkerKind kind) - where TMarker : RustPlusApi.Data.Markers.Marker - { - foreach (var (id, marker) in source) - { - into.Add(new MapMarkerSnapshot(id, kind, marker.X ?? 0f, marker.Y ?? 0f, Name: null)); - } - } - public async Task GetMapDimensionsAsync( TimeSpan timeout, CancellationToken cancellationToken = default) @@ -401,6 +391,18 @@ public async ValueTask DisposeAsync() } } + private static void AddMarkers( + List into, + IReadOnlyDictionary source, + MarkerKind kind) + where TMarker : RustPlusApi.Data.Markers.Marker + { + foreach (var (id, marker) in source) + { + into.Add(new MapMarkerSnapshot(id, kind, marker.X ?? 0f, marker.Y ?? 0f, Name: null)); + } + } + private void OnTeamChatReceived(object? sender, RustPlusApi.Data.Events.TeamMessageEventArg e) => TeamMessageReceived?.Invoke(this, new TeamChatLine(e.SteamId, e.Name, e.Message)); diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 9963954b..2987eccc 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -350,8 +350,12 @@ void OnTeamMessage(object? sender, TeamChatLine line) } #pragma warning restore RCS1163 + var dims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + connection.TeamMessageReceived += OnTeamMessage; _liveSockets[key] = new LiveSocket(connection, activeSteamId); + using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, pollCts.Token), CancellationToken.None); try { while (!ct.IsCancellationRequested) @@ -375,11 +379,68 @@ await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, cred } finally { + await pollCts.CancelAsync().ConfigureAwait(false); + try + { +#pragma warning disable VSTHRD003 // Suppress: markerPoll is owned by this connected window and explicitly joined on exit. + await markerPoll.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on stop. + } + _liveSockets.TryRemove(key, out _); connection.TeamMessageReceived -= OnTeamMessage; } } + private async Task PollMarkersAsync( + (ulong Guild, Guid Server) key, + IRustServerConnection connection, + MapDimensions? dims, + CancellationToken ct) + { + IReadOnlyList? previous = null; + while (!ct.IsCancellationRequested) + { + try + { + var current = await connection.GetMapMarkersAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + if (previous is null) + { + previous = current; // first poll: silent baseline + } + else + { + var added = current.Where(c => previous.All(p => p.Id != c.Id)).ToList(); + var removed = previous.Where(p => current.All(c => c.Id != p.Id)).ToList(); + previous = current; + if (added.Count > 0 || removed.Count > 0) + { + await eventBus.PublishAsync( + new MapMarkersChangedEvent(key.Guild, key.Server, dims, added, removed), ct) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + return; // stopping + } +#pragma warning disable CA1031 // Broad catch: a failed poll is logged and skipped; the previous snapshot is retained. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogMarkerPollFailed(logger, ex, key.Server); + // previous is retained, so a transient failure does not produce a spurious despawn/respawn diff. + } + + await Task.Delay(_options.MarkerPollInterval, ct).ConfigureAwait(false); + } + } + private async Task PrepareAsync((ulong Guild, Guid Server) key, CancellationToken ct) { var scope = scopeFactory.CreateAsyncScope(); @@ -508,6 +569,9 @@ private async Task PublishTeamMessageAsync((ulong Guild, Guid Server) key, ulong } } + [LoggerMessage(Level = LogLevel.Warning, Message = "Marker poll for server {ServerId} failed.")] + private static partial void LogMarkerPollFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Relaying a message to team chat for server {ServerId} failed.")] private static partial void LogSendFailed(ILogger logger, Exception exception, Guid serverId); diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 7eb1bc11..f369ae2c 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -64,13 +64,17 @@ private static Harness CreateHarness(FakeRustSocketSource source) MaxRetryDelay = TimeSpan.FromMilliseconds(20), HeartbeatInterval = TimeSpan.FromMilliseconds(20), HeartbeatTimeout = TimeSpan.FromMilliseconds(200), + MarkerPollInterval = TimeSpan.FromMilliseconds(20), })); services.AddSingleton(); var provider = services.BuildServiceProvider(); return new Harness { - Provider = provider, Dm = dm, Supervisor = provider.GetRequiredService() + Provider = provider, + Dm = dm, + Supervisor = provider.GetRequiredService(), + Bus = provider.GetRequiredService(), }; } @@ -243,11 +247,184 @@ public async Task StartAll_StartsAConnectionPerConnectableServer() Assert.True(source.CreateCount >= 1); } + [Fact] + public async Task First_marker_poll_is_a_silent_baseline() + { + // Arrange: the connection always returns an empty marker list. + // After two full poll cycles the first (silent baseline) plus one repeated poll should + // produce no MapMarkersChangedEvent because there is no diff to report. + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + var subTask = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + // FakeConnection defaults to empty MarkersResult — baseline poll sees nothing. + await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + // Wait two full poll cycles so the baseline and one follow-up are definitely consumed. + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + + // The first poll establishes a silent baseline; repeated empty polls produce no event. + Assert.Empty(captured); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try { await subTask; } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Marker_added_on_a_later_poll_publishes_changed_event() + { + // Arrange: first poll sees nothing (baseline); second poll sees a CargoShip → one event. + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + var subTask = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + // Wait for the fake connection to exist; start with empty markers (baseline poll sees nothing). + await WaitUntilAsync(() => source.LastConnection is not null, cts.Token); + source.LastConnection!.MarkersResult = []; + await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + // Wait one poll cycle for the baseline to be established, then add a CargoShip. + await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); + source.LastConnection!.MarkersResult = + [new MapMarkerSnapshot(2UL, MarkerKind.CargoShip, 1f, 1f, "Cargo A")]; + + // Wait until the event arrives (up to 30s deadline). + var deadline = DateTimeOffset.UtcNow.AddSeconds(30); + while (captured.IsEmpty && DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(15, cts.Token); + } + + Assert.Single(captured); + var evt = captured.TryPeek(out var e) ? e : null; + Assert.NotNull(evt); + Assert.Single(evt!.Added); + Assert.Equal(MarkerKind.CargoShip, evt.Added[0].Kind); + Assert.Empty(evt.Removed); + // Dimensions should ride along from the connect-time fetch. + Assert.NotNull(evt.Dimensions); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try { await subTask; } + catch (OperationCanceledException) + { + /* expected */ + } + } + + [Fact] + public async Task Failed_marker_poll_retains_previous_snapshot() + { + // Arrange: baseline is empty. The second poll adds a CargoShip (event published). + // The third poll throws (no event). The fourth returns the same CargoShip (no spurious diff). + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + var subTask = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + + await WaitUntilAsync(() => source.LastConnection is not null, cts.Token); + source.LastConnection!.MarkersResult = []; + await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + // Let baseline settle. + await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); + + // Poll 2: add CargoShip → expect exactly one event. + var cargo = new MapMarkerSnapshot(3UL, MarkerKind.CargoShip, 2f, 2f, null); + source.LastConnection!.MarkersResult = [cargo]; + + var deadline = DateTimeOffset.UtcNow.AddSeconds(30); + while (captured.IsEmpty && DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(15, cts.Token); + } + + Assert.Single(captured); + + // Poll 3: make the poll throw. + source.LastConnection!.MarkersThrow = true; + // Give it time to attempt the failing poll. + await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); + + // Poll 4: recover; same CargoShip → snapshot unchanged, no new event. + source.LastConnection!.MarkersThrow = false; + source.LastConnection!.MarkersResult = [cargo]; + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + + // Still only one event total — the failed poll did not corrupt the snapshot. + Assert.Single(captured); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try { await subTask; } + catch (OperationCanceledException) + { + /* expected */ + } + } + + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) + { + while (!condition()) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(10, ct); + } + } + private sealed class Harness : IAsyncDisposable { public required ServiceProvider Provider { get; init; } public required IUserDmSender Dm { get; init; } public required ConnectionSupervisor Supervisor { get; init; } + public required IEventBus Bus { get; init; } public async ValueTask DisposeAsync() { diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 8f96a6be..f8ec10d0 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -122,12 +122,14 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella return Task.FromResult(PromoteResult); } - public Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + public Task> GetMapMarkersAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) => MarkersThrow ? Task.FromException>(new InvalidOperationException("poll failed")) : Task.FromResult(MarkersResult); - public Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + public Task GetMapDimensionsAsync(TimeSpan timeout, + CancellationToken cancellationToken = default) => Task.FromResult(DimensionsResult); public ValueTask DisposeAsync() => ValueTask.CompletedTask; From ad250c9a12fec821e67ad38a47a4c0304495f5b1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 16:55:28 +0200 Subject: [PATCH 04/14] fix(2a): strengthen marker-poll baseline test + deterministic fake script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FakeRustSocketSource: add source-level EnqueueMarkers that pre-stages marker lists and transfers them to FakeConnection at Create time, eliminating the setup race between test code and the poll loop. - FakeConnection: add per-connection ConcurrentQueue> with "hold last" behaviour mirroring NextHeartbeat; EnqueueMarkers on the connection available for post-connect injection. Existing MarkersResult fallback preserved for Task-2 / no-script callers. - First_marker_poll_is_a_silent_baseline: rewritten to script poll 1+2 with a CargoShip (baseline + no-change) and poll 3 with CargoShip+Heli; waits for the definite heli-added event as the race-free signal proving both baseline suppression and diff correctness. - Marker_added_on_a_later_poll_publishes_changed_event: scripted via source queue before EnsureConnectionAsync; asserts exact MapDimensions(4000,4000, 500) from the FakeConnection default. - Failed_marker_poll_retains_previous_snapshot: polls 1+2 scripted via source queue; MarkersThrow used post-event for the throw/recover cycle; hold-last means recovery sees the same CargoShip → no spurious event. - ConnectionOptions.MarkerPollInterval: init → set for IOptions binding. Co-Authored-By: Claude Sonnet 4.6 --- .../ConnectionOptions.cs | 2 +- .../ConnectionSupervisorTests.cs | 110 ++++++++++-------- .../Fakes/FakeRustSocketSource.cs | 61 +++++++++- 3 files changed, 115 insertions(+), 58 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs index 134daf48..5c02f68d 100644 --- a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs +++ b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs @@ -19,5 +19,5 @@ public sealed class ConnectionOptions public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10); /// How often to poll map markers for live-event detection. Default 10s. - public TimeSpan MarkerPollInterval { get; init; } = TimeSpan.FromSeconds(10); + public TimeSpan MarkerPollInterval { get; set; } = TimeSpan.FromSeconds(10); } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index f369ae2c..1edf5c05 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -250,9 +250,16 @@ public async Task StartAll_StartsAConnectionPerConnectableServer() [Fact] public async Task First_marker_poll_is_a_silent_baseline() { - // Arrange: the connection always returns an empty marker list. - // After two full poll cycles the first (silent baseline) plus one repeated poll should - // produce no MapMarkersChangedEvent because there is no diff to report. + // Contract: the FIRST poll after connect must not publish any event even when markers are + // present on that first poll (the baseline records them silently). Only LATER changes alert. + // + // Script: + // Poll 1 → [CargoShip] (baseline — must NOT fire an event) + // Poll 2 → [CargoShip] (no change — still no event) + // Poll 3 → [CargoShip, PatrolHelicopter] (heli added — ONE event, Added=[heli]) + // + // Waiting for the definite heli-event signal proves both that baseline suppression held AND + // that the diff works, without any fixed-sleep assertion. var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); @@ -269,16 +276,25 @@ public async Task First_marker_poll_is_a_silent_baseline() } }, CancellationToken.None); - await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + // Script the three polls before EnsureConnectionAsync so the script is in place before the + // poll loop starts — no race between test setup and the supervisor's background poll task. + var cargo = new MapMarkerSnapshot(1UL, MarkerKind.CargoShip, 1f, 1f, null); + var heli = new MapMarkerSnapshot(2UL, MarkerKind.PatrolHelicopter, 2f, 2f, null); + source.EnqueueMarkers([cargo]); // poll 1: baseline (silent) + source.EnqueueMarkers([cargo]); // poll 2: no change + source.EnqueueMarkers([cargo, heli]); // poll 3: heli added → one event - // FakeConnection defaults to empty MarkersResult — baseline poll sees nothing. - await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); - // Wait two full poll cycles so the baseline and one follow-up are definitely consumed. - await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + // Wait for the heli-added event — its arrival is the definite signal that at least three + // poll cycles have completed and proves the baseline CargoShip never triggered an event. + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); - // The first poll establishes a silent baseline; repeated empty polls produce no event. - Assert.Empty(captured); + Assert.Single(captured); + Assert.True(captured.TryPeek(out var evt)); + Assert.Single(evt!.Added); + Assert.Equal(MarkerKind.PatrolHelicopter, evt.Added[0].Kind); + Assert.Empty(evt.Removed); await h.Supervisor.StopAllAsync(); await cts.CancelAsync(); @@ -292,7 +308,12 @@ public async Task First_marker_poll_is_a_silent_baseline() [Fact] public async Task Marker_added_on_a_later_poll_publishes_changed_event() { - // Arrange: first poll sees nothing (baseline); second poll sees a CargoShip → one event. + // Contract: first poll is a silent baseline; a new marker on a subsequent poll fires exactly + // one MapMarkersChangedEvent with the correct Added entry and the connect-time dimensions. + // + // Script: + // Poll 1 → [] (baseline — no event) + // Poll 2 → [CargoShip] (added → one event) var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); @@ -309,33 +330,26 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() } }, CancellationToken.None); - await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + // FakeConnection default DimensionsResult is new(4000u, 4000u, 500); assert those exact values. + var expectedDims = new MapDimensions(4000u, 4000u, 500); - // Wait for the fake connection to exist; start with empty markers (baseline poll sees nothing). - await WaitUntilAsync(() => source.LastConnection is not null, cts.Token); - source.LastConnection!.MarkersResult = []; - await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); + // Script polls before EnsureConnectionAsync so the marker script is in the connection before + // the poll loop can start — eliminates any setup race. + source.EnqueueMarkers([]); // poll 1: baseline + source.EnqueueMarkers([new MapMarkerSnapshot(2UL, MarkerKind.CargoShip, 1f, 1f, "Cargo A")]); // poll 2 - // Wait one poll cycle for the baseline to be established, then add a CargoShip. - await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); - source.LastConnection!.MarkersResult = - [new MapMarkerSnapshot(2UL, MarkerKind.CargoShip, 1f, 1f, "Cargo A")]; + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); - // Wait until the event arrives (up to 30s deadline). - var deadline = DateTimeOffset.UtcNow.AddSeconds(30); - while (captured.IsEmpty && DateTimeOffset.UtcNow < deadline) - { - await Task.Delay(15, cts.Token); - } + // Wait for the definite signal: the CargoShip-added event. + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); Assert.Single(captured); - var evt = captured.TryPeek(out var e) ? e : null; + Assert.True(captured.TryPeek(out var evt)); Assert.NotNull(evt); Assert.Single(evt!.Added); Assert.Equal(MarkerKind.CargoShip, evt.Added[0].Kind); Assert.Empty(evt.Removed); - // Dimensions should ride along from the connect-time fetch. - Assert.NotNull(evt.Dimensions); + Assert.Equal(expectedDims, evt.Dimensions); await h.Supervisor.StopAllAsync(); await cts.CancelAsync(); @@ -349,8 +363,13 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() [Fact] public async Task Failed_marker_poll_retains_previous_snapshot() { - // Arrange: baseline is empty. The second poll adds a CargoShip (event published). - // The third poll throws (no event). The fourth returns the same CargoShip (no spurious diff). + // Contract: a thrown poll does not corrupt the previous snapshot. + // + // Script: + // Poll 1 → [] (baseline — no event) + // Poll 2 → [CargoShip] (added → exactly one event; queue empties, hold-last = CargoShip) + // Poll 3 → throws (MarkersThrow = true; no event, snapshot retained) + // Poll 4 → [CargoShip] (same as held-last → no spurious diff, still exactly one event total) var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); @@ -367,35 +386,24 @@ public async Task Failed_marker_poll_retains_previous_snapshot() } }, CancellationToken.None); - await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); - - await WaitUntilAsync(() => source.LastConnection is not null, cts.Token); - source.LastConnection!.MarkersResult = []; - await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token); - - // Let baseline settle. - await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); - - // Poll 2: add CargoShip → expect exactly one event. var cargo = new MapMarkerSnapshot(3UL, MarkerKind.CargoShip, 2f, 2f, null); - source.LastConnection!.MarkersResult = [cargo]; + // Script polls before EnsureConnectionAsync to eliminate the setup race. + source.EnqueueMarkers([]); // poll 1: baseline + source.EnqueueMarkers([cargo]); // poll 2: CargoShip added; queue empties → hold-last = [cargo] - var deadline = DateTimeOffset.UtcNow.AddSeconds(30); - while (captured.IsEmpty && DateTimeOffset.UtcNow < deadline) - { - await Task.Delay(15, cts.Token); - } + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + // Wait for the one cargo-added event — definite signal that poll 2 completed. + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); Assert.Single(captured); - // Poll 3: make the poll throw. + // Poll 3: make the next poll throw; the poll loop catches the exception and retains the snapshot. source.LastConnection!.MarkersThrow = true; - // Give it time to attempt the failing poll. + // Give enough time for at least one failing poll to be attempted. await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); - // Poll 4: recover; same CargoShip → snapshot unchanged, no new event. + // Poll 4: recover — hold-last still returns [cargo], so snapshot is unchanged, no new event. source.LastConnection!.MarkersThrow = false; - source.LastConnection!.MarkersResult = [cargo]; await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); // Still only one event total — the failed poll did not corrupt the snapshot. diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index f8ec10d0..494a1efe 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -19,6 +19,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource { private readonly ConcurrentQueue _connectOutcomes = new(); private readonly ConcurrentQueue _heartbeats = new(); + private readonly ConcurrentQueue> _pendingMarkerScript = new(); private int _createCount; private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); @@ -42,6 +43,12 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p LastSteamId = steamId; var outcome = _connectOutcomes.TryDequeue(out var next) ? next : SocketConnectOutcome.Connected; var connection = new FakeConnection(outcome, this); + // Transfer any pre-staged marker script so it is in place before the poll loop starts. + while (_pendingMarkerScript.TryDequeue(out var markers)) + { + connection.EnqueueMarkers(markers); + } + LastConnection = connection; return connection; } @@ -50,6 +57,16 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p public void EnqueueHeartbeat(HeartbeatResult result) => _heartbeats.Enqueue(result); + /// + /// Pre-stages a scripted marker list for the NEXT connection created by . + /// All items enqueued here are transferred to the new at creation + /// time, before the supervisor can start the poll loop, eliminating the setup race. Call this + /// before so the script is in place when polls begin. + /// + /// The marker list to deliver on the corresponding poll. + public void EnqueueMarkers(IReadOnlyList markers) => + _pendingMarkerScript.Enqueue(markers); + internal HeartbeatResult NextHeartbeat() { if (_heartbeats.TryDequeue(out var next)) @@ -63,6 +80,10 @@ internal HeartbeatResult NextHeartbeat() internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocketSource source) : IRustServerConnection { + private readonly ConcurrentQueue> _markerScript = new(); + private IReadOnlyList _lastMarkers = []; + private bool _markerScriptStarted; + /// Gets the messages sent via . public List SentMessages { get; } = []; @@ -81,10 +102,14 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The Steam ID passed to the most recent call. public ulong LastPromotedSteamId { get; private set; } - /// The markers returned by . Defaults to empty (nothing on the map). + /// + /// The fallback markers returned by when no scripted results remain. + /// Defaults to empty (nothing on the map). Callers that do not use see + /// this value on every poll, matching the original Task-2 behavior. + /// public IReadOnlyList MarkersResult { get; set; } = []; - /// When true, throws (simulates a failed poll). + /// When true, throws regardless of any enqueued script. public bool MarkersThrow { get; set; } /// The dimensions returned by . Defaults to a non-null snapshot. @@ -123,10 +148,25 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella } public Task> GetMapMarkersAsync(TimeSpan timeout, - CancellationToken cancellationToken = default) => - MarkersThrow - ? Task.FromException>(new InvalidOperationException("poll failed")) - : Task.FromResult(MarkersResult); + CancellationToken cancellationToken = default) + { + if (MarkersThrow) + { + return Task.FromException>( + new InvalidOperationException("poll failed")); + } + + if (_markerScript.TryDequeue(out var scripted)) + { + _markerScriptStarted = true; + _lastMarkers = scripted; + return Task.FromResult(_lastMarkers); + } + + // Once any scripted result has been dequeued, hold the last one (mirroring NextHeartbeat). + // If the script was never started, fall back to MarkersResult so Task-2 callers are unaffected. + return Task.FromResult(_markerScriptStarted ? _lastMarkers : MarkersResult); + } public Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => @@ -134,6 +174,15 @@ public Task> GetMapMarkersAsync(TimeSpan timeou public ValueTask DisposeAsync() => ValueTask.CompletedTask; + /// + /// Enqueues a scripted marker list to be returned by the next call. + /// Once the queue empties the last dequeued list is held and returned on every subsequent poll, + /// mirroring the heartbeat "hold last" pattern. Enqueued results take priority over + /// ; if nothing has been enqueued, is used. + /// + /// The marker list to return for the next poll. + public void EnqueueMarkers(IReadOnlyList markers) => _markerScript.Enqueue(markers); + /// Raises to simulate an inbound team chat line. /// The team chat line to raise. public void RaiseTeamMessage(TeamChatLine line) => TeamMessageReceived?.Invoke(this, line); From b2f4927700cf3eac3a452779f7f98d2b85a4aed0 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:02:45 +0200 Subject: [PATCH 05/14] feat(2a): scaffold Features.Events project + GridReference helper --- RustPlusBot.slnx | 2 + .../Formatting/GridReference.cs | 49 +++++++++++++++++++ .../RustPlusBot.Features.Events.csproj | 22 +++++++++ .../Formatting/GridReferenceTests.cs | 35 +++++++++++++ .../RustPlusBot.Features.Events.Tests.csproj | 20 ++++++++ 5 files changed, 128 insertions(+) create mode 100644 src/RustPlusBot.Features.Events/Formatting/GridReference.cs create mode 100644 src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj create mode 100644 tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index d539fc99..2df619b4 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -8,6 +8,7 @@ + @@ -18,6 +19,7 @@ + diff --git a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs new file mode 100644 index 00000000..df4fbcfa --- /dev/null +++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Events.Formatting; + +/// Converts world coordinates to a Rust map grid reference (e.g. "D7"), rustplusplus-compatible. +public static class GridReference +{ + private const float GridDiameter = 146.25f; + + /// Formats a grid reference, or raw rounded coordinates when is null. + /// World X coordinate. + /// World Y coordinate. + /// Map dimensions, or null when unavailable. + /// A grid reference like "D7", or "(x, y)" when dimensions are unavailable. + public static string From(float x, float y, MapDimensions? dims) + { + if (dims is null) + { + return string.Create( + CultureInfo.InvariantCulture, + $"({Math.Round(x)}, {Math.Round(y)})"); + } + + var mapSize = dims.Width; + var columns = (int)Math.Ceiling(mapSize / GridDiameter); + var col = (int)Math.Floor(Math.Clamp(x, 0f, mapSize - 1) / GridDiameter); + // Rows are numbered from the TOP; world Y increases upward, so invert. + var rowFromBottom = (int)Math.Floor(Math.Clamp(y, 0f, mapSize - 1) / GridDiameter); + var row = Math.Max(0, columns - rowFromBottom - 1); + + return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); + } + + private static string ColumnLetters(int index) + { + // 0->A .. 25->Z, 26->AA .. (spreadsheet-style, matching rustplusplus past Z). + var sb = new StringBuilder(); + var n = index; + do + { + sb.Insert(0, (char)('A' + (n % 26))); + n = (n / 26) - 1; + } while (n >= 0); + + return sb.ToString(); + } +} diff --git a/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj b/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj new file mode 100644 index 00000000..4f55cb20 --- /dev/null +++ b/src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs new file mode 100644 index 00000000..1b01b5d6 --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs @@ -0,0 +1,35 @@ +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; + +namespace RustPlusBot.Features.Events.Tests.Formatting; + +public sealed class GridReferenceTests +{ + [Fact] + public void Origin_is_top_left_cell() + { + // x near 0, y near top => column A, top row. + var dims = new MapDimensions(4000u, 4000u, 500); + var grid = GridReference.From(10f, 3990f, dims); + + Assert.StartsWith("A", grid, StringComparison.Ordinal); + } + + [Fact] + public void Null_dimensions_fall_back_to_raw_coordinates() + { + var grid = GridReference.From(1234.6f, 5678.4f, dims: null); + + Assert.Equal("(1235, 5678)", grid); + } + + [Theory] + [InlineData(0f, 4000f, "A")] + [InlineData(150f, 4000f, "B")] + public void Column_letter_advances_every_cell(float x, float y, string expectedColumnPrefix) + { + var dims = new MapDimensions(4000u, 4000u, 500); + var grid = GridReference.From(x, y, dims); + Assert.StartsWith(expectedColumnPrefix, grid, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj b/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj new file mode 100644 index 00000000..a0c1d48b --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + From 2c66d25a91938a1c48222968f1f5b7bd1514fdc4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:08:48 +0200 Subject: [PATCH 06/14] feat(2a): add MarkerEventClassifier mapping deltas to domain events Co-Authored-By: Claude Opus 4.8 --- .../Classifying/MapEventKind.cs | 20 +++++ .../Classifying/MarkerEventClassifier.cs | 51 +++++++++++ .../Classifying/RustMapEvent.cs | 11 +++ .../Classifying/MarkerEventClassifierTests.cs | 90 +++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs create mode 100644 src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs create mode 100644 src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs diff --git a/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs b/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs new file mode 100644 index 00000000..76c4588d --- /dev/null +++ b/src/RustPlusBot.Features.Events/Classifying/MapEventKind.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Features.Events.Classifying; + +/// A classified live map event. +public enum MapEventKind +{ + /// A cargo ship entered the map. + CargoEntered = 0, + + /// A cargo ship left the map. + CargoLeft = 1, + + /// A patrol helicopter entered the map. + HeliEntered = 2, + + /// A patrol helicopter left the map. + HeliLeft = 3, + + /// A Chinook spawned. + ChinookSpawned = 4, +} diff --git a/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs b/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs new file mode 100644 index 00000000..05fd53dd --- /dev/null +++ b/src/RustPlusBot.Features.Events/Classifying/MarkerEventClassifier.cs @@ -0,0 +1,51 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Events.Classifying; + +/// Maps raw marker add/remove deltas to domain map events. +/// Stamps each produced event. +internal sealed class MarkerEventClassifier(IClock clock) +{ + /// Classifies one marker-change delta into zero or more domain events. + /// The marker-change delta. + /// The classified events, in delta order (added first, then removed). + public IReadOnlyList Classify(MapMarkersChangedEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + var now = clock.UtcNow; + var events = new List(); + + foreach (var m in evt.Added) + { + MapEventKind? kind = m.Kind switch + { + MarkerKind.CargoShip => MapEventKind.CargoEntered, + MarkerKind.PatrolHelicopter => MapEventKind.HeliEntered, + MarkerKind.Chinook => MapEventKind.ChinookSpawned, + _ => null, // Crate/Other → no event (the game no longer sends crate markers). + }; + if (kind is { } k) + { + events.Add(new RustMapEvent(k, m.X, m.Y, evt.Dimensions, now)); + } + } + + foreach (var m in evt.Removed) + { + MapEventKind? kind = m.Kind switch + { + MarkerKind.CargoShip => MapEventKind.CargoLeft, + MarkerKind.PatrolHelicopter => MapEventKind.HeliLeft, + _ => null, // Chinook/Crate removal is silent. + }; + if (kind is { } k) + { + events.Add(new RustMapEvent(k, m.X, m.Y, evt.Dimensions, now)); + } + } + + return events; + } +} diff --git a/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs b/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs new file mode 100644 index 00000000..a28796ea --- /dev/null +++ b/src/RustPlusBot.Features.Events/Classifying/RustMapEvent.cs @@ -0,0 +1,11 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Events.Classifying; + +/// A classified live map event with its location and time. +/// The event kind. +/// World X coordinate. +/// World Y coordinate. +/// Map dimensions for grid rendering, or null. +/// When the event was observed. +public sealed record RustMapEvent(MapEventKind Kind, float X, float Y, MapDimensions? Dimensions, DateTimeOffset AtUtc); diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs new file mode 100644 index 00000000..df33392d --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs @@ -0,0 +1,90 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; + +namespace RustPlusBot.Features.Events.Tests.Classifying; + +public sealed class MarkerEventClassifierTests +{ + private static readonly Guid Server = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); + + private static MarkerEventClassifier Build() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return new MarkerEventClassifier(clock); + } + + private static MapMarkersChangedEvent Evt( + IReadOnlyList added, + IReadOnlyList removed) => + new(1UL, Server, new MapDimensions(4000u, 4000u, 500), added, removed); + + [Fact] + public void Cargo_added_is_CargoEntered() + { + var result = Build().Classify(Evt( + [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], [])); + + var e = Assert.Single(result); + Assert.Equal(MapEventKind.CargoEntered, e.Kind); + Assert.Equal(Now, e.AtUtc); + Assert.NotNull(e.Dimensions); + } + + [Fact] + public void Cargo_removed_is_CargoLeft() + { + var result = Build().Classify(Evt([], + [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)])); + Assert.Equal(MapEventKind.CargoLeft, Assert.Single(result).Kind); + } + + [Fact] + public void Heli_added_and_removed_map_to_entered_and_left() + { + Assert.Equal(MapEventKind.HeliEntered, Assert.Single(Build().Classify( + Evt([new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 0f, 0f, null)], []))).Kind); + Assert.Equal(MapEventKind.HeliLeft, Assert.Single(Build().Classify( + Evt([], [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 0f, 0f, null)]))).Kind); + } + + [Fact] + public void Chinook_added_is_spawned_but_removal_is_silent() + { + Assert.Equal(MapEventKind.ChinookSpawned, Assert.Single(Build().Classify( + Evt([new MapMarkerSnapshot(3, MarkerKind.Chinook, 0f, 0f, null)], []))).Kind); + Assert.Empty(Build().Classify( + Evt([], [new MapMarkerSnapshot(3, MarkerKind.Chinook, 0f, 0f, null)]))); + } + + [Fact] + public void Crate_and_other_markers_produce_nothing() + { + // The game no longer sends crate markers; MarkerKind.Crate (and Other) classify to nothing. + Assert.Empty(Build().Classify(Evt( + [ + new MapMarkerSnapshot(4, MarkerKind.Crate, 0f, 0f, null), + new MapMarkerSnapshot(5, MarkerKind.Other, 0f, 0f, null) + ], + [ + new MapMarkerSnapshot(6, MarkerKind.Crate, 0f, 0f, null), + new MapMarkerSnapshot(7, MarkerKind.Other, 0f, 0f, null) + ]))); + } + + [Fact] + public void Multiple_deltas_produce_multiple_events() + { + var result = Build().Classify(Evt( + [ + new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null), + new MapMarkerSnapshot(3, MarkerKind.Chinook, 0f, 0f, null) + ], + [new MapMarkerSnapshot(2, MarkerKind.PatrolHelicopter, 0f, 0f, null)])); + Assert.Equal(3, result.Count); + } +} From a8677860511a1e19199add7c37e0137d0b8e8249 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:14:49 +0200 Subject: [PATCH 07/14] feat(2a): add in-memory EventStateStore + IEventState read-seam --- .../State/ActiveMarker.cs | 18 ++++ .../State/EventStateStore.cs | 93 +++++++++++++++++++ .../State/IEventState.cs | 21 +++++ .../State/EventStateStoreTests.cs | 91 ++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 src/RustPlusBot.Features.Events/State/ActiveMarker.cs create mode 100644 src/RustPlusBot.Features.Events/State/EventStateStore.cs create mode 100644 src/RustPlusBot.Features.Events/State/IEventState.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs diff --git a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs new file mode 100644 index 00000000..c3782bd4 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs @@ -0,0 +1,18 @@ +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Events.State; + +/// A marker currently present on a server's map. +/// The marker id. +/// The marker kind. +/// World X coordinate. +/// World Y coordinate. +/// Map dimensions for grid rendering, or null. +/// When the marker was first seen. +public sealed record ActiveMarker( + ulong Id, + MarkerKind Kind, + float X, + float Y, + MapDimensions? Dimensions, + DateTimeOffset SeenAtUtc); diff --git a/src/RustPlusBot.Features.Events/State/EventStateStore.cs b/src/RustPlusBot.Features.Events/State/EventStateStore.cs new file mode 100644 index 00000000..ddfa0cb8 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/EventStateStore.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; + +namespace RustPlusBot.Features.Events.State; + +/// In-memory per-(guild, server) active markers and a bounded recent-event ring. Cleared on disconnect. +/// Stamps when markers become active. +internal sealed class EventStateStore(IClock clock) : IEventState +{ + private const int RecentCapacity = 10; + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ServerState> _byServer = new(); + + /// + public IReadOnlyList GetActiveMarkers(ulong guildId, Guid serverId, MarkerKind kind) + { + if (!_byServer.TryGetValue((guildId, serverId), out var state)) + { + return []; + } + + lock (state.Gate) + { + return state.Active.Values + .Where(m => m.Kind == kind) + .OrderByDescending(m => m.SeenAtUtc) + .ToList(); + } + } + + /// + public IReadOnlyList GetRecentEvents(ulong guildId, Guid serverId) + { + if (!_byServer.TryGetValue((guildId, serverId), out var state)) + { + return []; + } + + lock (state.Gate) + { + return state.Recent.ToList(); // already newest-first + } + } + + /// Applies one marker-change delta and its classified events. + /// The raw marker delta (drives the active set). + /// The classified events (pushed onto the recent ring). + public void Apply(MapMarkersChangedEvent delta, IReadOnlyList events) + { + ArgumentNullException.ThrowIfNull(delta); + ArgumentNullException.ThrowIfNull(events); + var state = _byServer.GetOrAdd((delta.GuildId, delta.ServerId), static _ => new ServerState()); + var now = clock.UtcNow; + lock (state.Gate) + { + foreach (var m in delta.Added) + { + state.Active[m.Id] = new ActiveMarker(m.Id, m.Kind, m.X, m.Y, delta.Dimensions, now); + } + + foreach (var m in delta.Removed) + { + state.Active.Remove(m.Id); + } + + foreach (var e in events) + { + state.Recent.Insert(0, e); + } + + if (state.Recent.Count > RecentCapacity) + { + state.Recent.RemoveRange(RecentCapacity, state.Recent.Count - RecentCapacity); + } + } + } + + /// Clears all state for a server (called when its connection drops). + /// The owning guild snowflake. + /// The target server id. + public void Clear(ulong guildId, Guid serverId) => _byServer.TryRemove((guildId, serverId), out _); + + private sealed class ServerState + { + public object Gate { get; } = new(); + + public Dictionary Active { get; } = []; + + public List Recent { get; } = []; + } +} diff --git a/src/RustPlusBot.Features.Events/State/IEventState.cs b/src/RustPlusBot.Features.Events/State/IEventState.cs new file mode 100644 index 00000000..69881dd8 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/IEventState.cs @@ -0,0 +1,21 @@ +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; + +namespace RustPlusBot.Features.Events.State; + +/// Read access to current map markers and recent events (consumed by in-game command handlers). +public interface IEventState +{ + /// Gets the currently-active markers of a kind for a server (empty if none/unknown). + /// The owning guild snowflake. + /// The target server id. + /// The marker kind to filter by. + /// The active markers of that kind, newest-first. + IReadOnlyList GetActiveMarkers(ulong guildId, Guid serverId, MarkerKind kind); + + /// Gets the recent events for a server, newest-first (empty if none/unknown). + /// The owning guild snowflake. + /// The target server id. + /// The recent events, newest-first. + IReadOnlyList GetRecentEvents(ulong guildId, Guid serverId); +} diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs new file mode 100644 index 00000000..72acb94c --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs @@ -0,0 +1,91 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Events.Tests.State; + +public sealed class EventStateStoreTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); + + private static EventStateStore Build() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return new EventStateStore(clock); + } + + private static MapMarkersChangedEvent Delta( + IReadOnlyList added, + IReadOnlyList removed) => + new(Guild, Server, null, added, removed); + + private static MapMarkersChangedEvent DeltaWithDims( + IReadOnlyList added, + IReadOnlyList removed, + MapDimensions? dims) => + new(Guild, Server, dims, added, removed); + + [Fact] + public void Added_marker_becomes_active_and_carries_dimensions() + { + var store = Build(); + var dims = new MapDimensions(4000u, 4000u, 500); + store.Apply( + DeltaWithDims([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], [], dims), + [new RustMapEvent(MapEventKind.CargoEntered, 10f, 20f, dims, Now)]); + + var active = store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip); + Assert.Single(active); + Assert.Equal(10f, active[0].X); + Assert.Equal(dims, active[0].Dimensions); + Assert.Equal(Now, active[0].SeenAtUtc); + } + + [Fact] + public void Removed_marker_leaves_active_even_when_unalerted() + { + var store = Build(); + // A Crate marker produces NO classified event (core-3), but is still tracked in the active set... + store.Apply(Delta([new MapMarkerSnapshot(4, MarkerKind.Crate, 0f, 0f, null)], []), []); + Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.Crate)); + // ...and its (un-alerted) removal must still clear it. + store.Apply(Delta([], [new MapMarkerSnapshot(4, MarkerKind.Crate, 0f, 0f, null)]), []); + + Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.Crate)); + } + + [Fact] + public void Recent_events_are_newest_first_and_bounded_to_ten() + { + var store = Build(); + for (var i = 0; i < 12; i++) + { + store.Apply( + Delta([new MapMarkerSnapshot((ulong)i, MarkerKind.CargoShip, i, 0f, null)], []), + [new RustMapEvent(MapEventKind.CargoEntered, i, 0f, null, Now.AddMinutes(i))]); + } + + var recent = store.GetRecentEvents(Guild, Server); + Assert.Equal(10, recent.Count); + Assert.Equal(Now.AddMinutes(11), recent[0].AtUtc); // newest first + } + + [Fact] + public void Clear_drops_active_and_recent_for_that_server() + { + var store = Build(); + store.Apply(Delta([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], []), + [new RustMapEvent(MapEventKind.CargoEntered, 0f, 0f, null, Now)]); + + store.Clear(Guild, Server); + + Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + Assert.Empty(store.GetRecentEvents(Guild, Server)); + } +} From 0b43641e23c29a19c24f6f51ce115c669f81673c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:20:58 +0200 Subject: [PATCH 08/14] feat(2a): add EventEmbedRenderer + EN/FR event localization Implements the pure event rendering layer: EventLocalizationCatalog with EN/FR strings for cargo, helicopter, and Chinook events; IEventLocalizer/EventLocalizer for localization; EventEmbedRenderer to build Discord embeds with grid references. Co-Authored-By: Claude Opus 4.8 --- .../Rendering/EventEmbedRenderer.cs | 35 +++++++++++ .../Rendering/EventLocalizationCatalog.cs | 34 +++++++++++ .../Rendering/EventLocalizer.cs | 61 +++++++++++++++++++ .../Rendering/IEventLocalizer.cs | 16 +++++ .../Rendering/EventEmbedRendererTests.cs | 37 +++++++++++ .../Rendering/EventLocalizerTests.cs | 28 +++++++++ 6 files changed, 211 insertions(+) create mode 100644 src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs create mode 100644 src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs create mode 100644 src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs create mode 100644 src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs new file mode 100644 index 00000000..5d107f45 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -0,0 +1,35 @@ +using Discord; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Formatting; + +namespace RustPlusBot.Features.Events.Rendering; + +/// Renders one as a Discord embed. +/// The reply localizer. +internal sealed class EventEmbedRenderer(IEventLocalizer localizer) +{ + /// Renders the event for a guild culture. + /// The event to render. + /// The guild culture ("en"/"fr"). + /// The built embed. + public Embed Render(RustMapEvent evt, string culture) + { + ArgumentNullException.ThrowIfNull(evt); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var key = evt.Kind switch + { + MapEventKind.CargoEntered => "event.cargo.entered", + MapEventKind.CargoLeft => "event.cargo.left", + MapEventKind.HeliEntered => "event.heli.entered", + MapEventKind.HeliLeft => "event.heli.left", + MapEventKind.ChinookSpawned => "event.chinook.spawned", + _ => "event.chinook.spawned", + }; + + return new EmbedBuilder() + .WithAuthor(localizer.Get("event.title", culture)) + .WithDescription(localizer.Get(key, culture, grid)) + .WithTimestamp(evt.AtUtc) + .Build(); + } +} diff --git a/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs b/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs new file mode 100644 index 00000000..54981cd4 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs @@ -0,0 +1,34 @@ +namespace RustPlusBot.Features.Events.Rendering; + +/// The in-memory string catalog for live events: culture -> (key -> value). English is the fallback. +internal sealed class EventLocalizationCatalog +{ + /// culture -> key -> value. + public required IReadOnlyDictionary> Strings { get; init; } + + /// The built-in EN/FR catalog. + public static EventLocalizationCatalog Default { get; } = new() + { + Strings = new Dictionary>(StringComparer.Ordinal) + { + ["en"] = new Dictionary(StringComparer.Ordinal) + { + ["event.cargo.entered"] = "🚢 Cargo Ship entered at {0}", + ["event.cargo.left"] = "🚢 Cargo Ship left ({0})", + ["event.heli.entered"] = "🚁 Patrol Helicopter entered at {0}", + ["event.heli.left"] = "🚁 Patrol Helicopter left ({0})", + ["event.chinook.spawned"] = "🚁 Chinook spawned at {0}", + ["event.title"] = "Live event", + }, + ["fr"] = new Dictionary(StringComparer.Ordinal) + { + ["event.cargo.entered"] = "🚢 Cargo Ship arrivé en {0}", + ["event.cargo.left"] = "🚢 Cargo Ship parti ({0})", + ["event.heli.entered"] = "🚁 Hélicoptère de patrouille arrivé en {0}", + ["event.heli.left"] = "🚁 Hélicoptère de patrouille parti ({0})", + ["event.chinook.spawned"] = "🚁 Chinook apparu en {0}", + ["event.title"] = "Événement", + }, + }, + }; +} diff --git a/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs b/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs new file mode 100644 index 00000000..6027a4a5 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs @@ -0,0 +1,61 @@ +using System.Globalization; + +namespace RustPlusBot.Features.Events.Rendering; + +/// Dictionary-backed with English fallback and region normalization. +/// Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor. +/// The string catalog. +internal sealed class EventLocalizer(EventLocalizationCatalog catalog) : IEventLocalizer +{ + private const string FallbackCulture = "en"; + + /// + public string Get(string key, string culture) + { + var normalized = Normalize(culture); + if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value)) + { + return value; + } + + if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) && + fallback.TryGetValue(key, out var fallbackValue)) + { + return fallbackValue; + } + + return key; + } + + /// + public string Get(string key, string culture, params object[] args) + { + var format = Get(key, culture); + var provider = ResolveFormatProvider(Normalize(culture)); + return string.Format(provider, format, args); + } + + private static string Normalize(string culture) + { + if (string.IsNullOrWhiteSpace(culture)) + { + return FallbackCulture; + } + + var dash = culture.IndexOf('-', StringComparison.Ordinal); + var primary = dash >= 0 ? culture[..dash] : culture; + return primary.ToLowerInvariant(); + } + + private static CultureInfo ResolveFormatProvider(string culture) + { + try + { + return CultureInfo.GetCultureInfo(culture); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } +} diff --git a/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs b/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs new file mode 100644 index 00000000..efdd6210 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs @@ -0,0 +1,16 @@ +namespace RustPlusBot.Features.Events.Rendering; + +/// Resolves localized live-event strings by key and culture, falling back to English. +internal interface IEventLocalizer +{ + /// Gets the localized string for a key, or the key itself if not found. + /// The string key. + /// The BCP-47 culture tag (e.g. "en", "fr"). + string Get(string key, string culture); + + /// Gets the localized, format-applied string. + /// The string key. + /// The BCP-47 culture tag. + /// Format arguments. + string Get(string key, string culture, params object[] args); +} diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs new file mode 100644 index 00000000..9b5d12d9 --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs @@ -0,0 +1,37 @@ +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Rendering; + +namespace RustPlusBot.Features.Events.Tests.Rendering; + +public sealed class EventEmbedRendererTests +{ + private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); + + private static EventEmbedRenderer Build() => + new(new EventLocalizer(EventLocalizationCatalog.Default)); + + [Fact] + public void Cargo_entered_renders_english_with_grid() + { + var dims = new MapDimensions(4000u, 4000u, 500); + var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, dims, Now), "en"); + + Assert.NotNull(embed.Description); + Assert.Contains("Cargo Ship entered", embed.Description, StringComparison.Ordinal); + } + + [Fact] + public void Chinook_spawned_renders_french() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.ChinookSpawned, 0f, 0f, null, Now), "fr"); + Assert.Contains("Chinook apparu", embed.Description, StringComparison.Ordinal); + } + + [Fact] + public void Null_dimensions_render_raw_coordinates() + { + var embed = Build().Render(new RustMapEvent(MapEventKind.HeliEntered, 1234f, 5678f, null, Now), "en"); + Assert.Contains("(1234, 5678)", embed.Description, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs new file mode 100644 index 00000000..cc3bd7a6 --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs @@ -0,0 +1,28 @@ +using RustPlusBot.Features.Events.Rendering; + +namespace RustPlusBot.Features.Events.Tests.Rendering; + +public sealed class EventLocalizerTests +{ + private static readonly EventLocalizer Sut = new(EventLocalizationCatalog.Default); + + [Fact] + public void ReturnsFrench_WhenCultureFr() => + Assert.Equal("🚁 Chinook apparu en {0}", Sut.Get("event.chinook.spawned", "fr")); + + [Fact] + public void ReturnsEnglish_WhenCultureEn() => + Assert.Equal("🚁 Chinook spawned at {0}", Sut.Get("event.chinook.spawned", "en")); + + [Fact] + public void FallsBackToEnglish_WhenCultureUnknown() => + Assert.Equal("🚁 Chinook spawned at {0}", Sut.Get("event.chinook.spawned", "xx")); + + [Fact] + public void ReturnsKey_WhenKeyUnknown() => + Assert.Equal("unknown.key", Sut.Get("unknown.key", "en")); + + [Fact] + public void NormalizesRegion() => + Assert.Equal("🚁 Chinook apparu en {0}", Sut.Get("event.chinook.spawned", "fr-FR")); +} From eb45027da2d9e0a92d5f79175f779a0583275d93 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:39:12 +0200 Subject: [PATCH 09/14] feat(2a): EventRelay, #events poster/locator, hosted service, DI + Workspace spec Co-Authored-By: Claude Sonnet 4.6 --- .../EventServiceCollectionExtensions.cs | 33 +++++ .../Hosting/EventsHostedService.cs | 126 ++++++++++++++++++ .../Posting/DiscordEventChannelPoster.cs | 37 +++++ .../Posting/IEventChannelPoster.cs | 14 ++ .../Relaying/EventRelay.cs | 65 +++++++++ .../Localization/LocalizationCatalog.cs | 2 + .../Locating/EventChannelLocator.cs | 75 +++++++++++ .../Locating/IEventChannelLocator.cs | 12 ++ .../Specs/ServerWorkspaceSpecProvider.cs | 2 + .../WorkspaceKeys.cs | 3 + .../WorkspaceServiceCollectionExtensions.cs | 3 +- .../EventRegistrationTests.cs | 46 +++++++ .../Relaying/EventRelayTests.cs | 98 ++++++++++++++ .../RustPlusBot.Features.Events.Tests.csproj | 1 + .../Locating/EventChannelLocatorTests.cs | 126 ++++++++++++++++++ 15 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs create mode 100644 src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs create mode 100644 src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Events/Posting/IEventChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Events/Relaying/EventRelay.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/IEventChannelLocator.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs create mode 100644 tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs diff --git a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs new file mode 100644 index 00000000..659b16be --- /dev/null +++ b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Hosting; +using RustPlusBot.Features.Events.Posting; +using RustPlusBot.Features.Events.Relaying; +using RustPlusBot.Features.Events.Rendering; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Events; + +/// DI registration for the live-events feature. +public static class EventServiceCollectionExtensions +{ + /// Registers the classifier, state store, renderer, relay, poster, and hosted service. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddEvents(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(EventLocalizationCatalog.Default); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs b/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs new file mode 100644 index 00000000..617704b2 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Hosting/EventsHostedService.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Events.Relaying; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Events.Hosting; + +/// Runs the marker-change relay loop and the disconnect-clear loop. +/// The in-process event bus. +/// Relays marker deltas into Discord #events. +/// Cleared on disconnect. +/// Opens scopes to read connection state. +/// The logger. +internal sealed partial class EventsHostedService( + IEventBus eventBus, + EventRelay relay, + EventStateStore store, + IServiceScopeFactory scopeFactory, + ILogger logger) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _disconnectLoop; + private Task? _relayLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _relayLoop = Task.Run(() => ConsumeMarkerEventsAsync(_cts.Token), CancellationToken.None); + _disconnectLoop = Task.Run(() => ConsumeConnectionStatusEventsAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] + { + _relayLoop, _disconnectLoop + }.Where(t => t is not null)) + { + try + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks — these are our own loop tasks, joined on stop. + await loop!.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + private async Task ConsumeMarkerEventsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await relay.RelayAsync(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 + { + LogRelayLoopFaulted(logger, ex); + } + } + + private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await ClearIfDisconnectedAsync(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 + { + LogDisconnectLoopFaulted(logger, ex); + } + } + + private async Task ClearIfDisconnectedAsync(ConnectionStatusChangedEvent evt, CancellationToken cancellationToken) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var connectionStore = scope.ServiceProvider.GetRequiredService(); + var state = await connectionStore.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (state is null || state.Status != ConnectionStatus.Connected) + { + store.Clear(evt.GuildId, evt.ServerId); + } + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Event relay loop faulted.")] + private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Disconnect-clear loop faulted.")] + private static partial void LogDisconnectLoopFaulted(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs new file mode 100644 index 00000000..bdc29810 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs @@ -0,0 +1,37 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Events.Posting; + +/// Posts event embeds to Discord text channels via the gateway client. +/// The Discord socket client. +/// The logger. +internal sealed partial class DiscordEventChannelPoster( + DiscordSocketClient client, + ILogger logger) + : IEventChannelPoster +{ + /// + public async Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken) + { + try + { + if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel) + { + return; + } + + await channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Posting an event embed to channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); +} diff --git a/src/RustPlusBot.Features.Events/Posting/IEventChannelPoster.cs b/src/RustPlusBot.Features.Events/Posting/IEventChannelPoster.cs new file mode 100644 index 00000000..38d7285b --- /dev/null +++ b/src/RustPlusBot.Features.Events/Posting/IEventChannelPoster.cs @@ -0,0 +1,14 @@ +using Discord; + +namespace RustPlusBot.Features.Events.Posting; + +/// Posts an event embed to a Discord channel. +internal interface IEventChannelPoster +{ + /// Posts to the channel. + /// The target Discord channel id. + /// The embed to post. + /// A cancellation token. + /// A task that completes when the embed has been posted. + Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs new file mode 100644 index 00000000..de148ca1 --- /dev/null +++ b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Posting; +using RustPlusBot.Features.Events.Rendering; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Events.Relaying; + +/// Classifies one marker delta, updates state, and posts one embed per event to #events. +/// Classifies raw marker deltas into domain events. +/// Tracks active markers and recent events per server. +/// Renders events as Discord embeds. +/// Resolves the #events Discord channel id. +/// Posts embeds to the Discord channel. +/// Opens scopes to read guild culture. +internal sealed class EventRelay( + MarkerEventClassifier classifier, + EventStateStore state, + EventEmbedRenderer renderer, + IEventChannelLocator locator, + IEventChannelPoster poster, + IServiceScopeFactory scopeFactory) +{ + /// Handles one . + /// The marker delta. + /// A cancellation token. + /// A task that completes when the delta has been processed. + public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var events = classifier.Classify(evt); + state.Apply(evt, events); + if (events.Count == 0) + { + return; + } + + var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); + if (channelId is null) + { + return; + } + + var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + foreach (var e in events) + { + var embed = renderer.Render(e, culture); + await poster.PostAsync(channelId.Value, embed, cancellationToken).ConfigureAwait(false); + } + } + + private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index 91a52a1c..d8c11779 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -19,6 +19,7 @@ internal sealed class LocalizationCatalog ["channel.settings.name"] = "settings", ["channel.info.name"] = "info", ["channel.teamchat.name"] = "teamchat", + ["channel.events.name"] = "events", ["information.title"] = "RustPlusBot", ["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.", ["information.servers"] = "Servers registered: {0}", @@ -52,6 +53,7 @@ internal sealed class LocalizationCatalog ["channel.settings.name"] = "parametres", ["channel.info.name"] = "info", ["channel.teamchat.name"] = "tchat-equipe", + ["channel.events.name"] = "evenements", ["information.title"] = "RustPlusBot", ["information.body"] = "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", diff --git a/src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs new file mode 100644 index 00000000..f2f04357 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/EventChannelLocator.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Locating; + +/// +/// Caches the small set of provisioned #events channels (rebuilt when the cache goes stale) and resolves +/// the game-to-Discord direction only (there is no Discord→game path for events). +/// +/// Opens scopes for the scoped workspace store. +/// Drives the cache TTL. +internal sealed class EventChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) + : IEventChannelLocator, IDisposable +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); + private readonly SemaphoreSlim _refreshGate = new(1, 1); + + private DateTimeOffset _builtAt = DateTimeOffset.MinValue; + + private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new(); + + /// + public void Dispose() => _refreshGate.Dispose(); + + /// + public async Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + await EnsureFreshAsync(cancellationToken).ConfigureAwait(false); + return _byServer.TryGetValue((guildId, serverId), out var id) ? id : null; + } + + private async Task EnsureFreshAsync(CancellationToken cancellationToken) + { + if (clock.UtcNow - _builtAt < CacheTtl) + { + return; + } + + await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (clock.UtcNow - _builtAt < CacheTtl) + { + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var rows = await store.GetChannelsByKeyAsync(WorkspaceChannelKeys.ServerEvents, cancellationToken) + .ConfigureAwait(false); + + var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>(); + foreach (var row in rows) + { + if (row.RustServerId is not { } serverId) + { + continue; + } + + byServer[(row.GuildId, serverId)] = row.DiscordChannelId; + } + + _byServer = byServer; + _builtAt = clock.UtcNow; + } + } + finally + { + _refreshGate.Release(); + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/IEventChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IEventChannelLocator.cs new file mode 100644 index 00000000..dbce6096 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/IEventChannelLocator.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the per-server #events channel (game-to-Discord direction only). +public interface IEventChannelLocator +{ + /// Gets the Discord channel id of the #events 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/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index 53c01f25..6511fb9f 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -12,6 +12,8 @@ public IEnumerable GetChannelSpecs() => ChannelPermissionProfile.ReadOnly, 0), new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerTeamChat, "channel.teamchat.name", ChannelPermissionProfile.Interactive, 1), + new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerEvents, "channel.events.name", + ChannelPermissionProfile.ReadOnly, 2), ]; /// diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index 4cc29159..1c7419af 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -17,6 +17,9 @@ internal static class WorkspaceChannelKeys /// Key for the per-server #teamchat channel. public const string ServerTeamChat = "teamchat"; + + /// Key for the per-server #events channel. + public const string ServerEvents = "events"; } /// Stable message keys persisted as ProvisionedMessage.MessageKey. diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index b2b5b61f..16f4e286 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -57,8 +57,9 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // Contribute this assembly's interaction modules to the Discord layer. services.AddSingleton(new InteractionModuleAssembly(typeof(WorkspaceServiceCollectionExtensions).Assembly)); - // Channel locator (singleton with TTL cache; IClock + IServiceScopeFactory provided by the host). + // Channel locators (singleton with TTL cache; IClock + IServiceScopeFactory provided by the host). services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); diff --git a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs new file mode 100644 index 00000000..17fcc3fa --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs @@ -0,0 +1,46 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Events.Hosting; +using RustPlusBot.Features.Events.Relaying; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Events.Tests; + +public sealed class EventRegistrationTests +{ + [Fact] + public void Services_resolve() + { + using var provider = BuildProvider(); + + Assert.Contains(provider.GetServices(), h => h is EventsHostedService); + + var eventState = provider.GetRequiredService(); + var eventStateStore = provider.GetRequiredService(); + Assert.Same(eventStateStore, eventState); + + Assert.NotNull(provider.GetRequiredService()); + } + + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + services.AddEvents(); + + return services.BuildServiceProvider(validateScopes: true); + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs new file mode 100644 index 00000000..263158ed --- /dev/null +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -0,0 +1,98 @@ +using Discord; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Posting; +using RustPlusBot.Features.Events.Relaying; +using RustPlusBot.Features.Events.Rendering; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Events.Tests.Relaying; + +public sealed class EventRelayTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + + private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Poster) + CreateRelay(ulong? channelId = 999UL) + { + var clock = Substitute.For(); + clock.UtcNow.Returns(new DateTimeOffset(2026, 6, 17, 12, 0, 0, TimeSpan.Zero)); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(Guild, Server, Arg.Any()).Returns(channelId); + + var poster = Substitute.For(); + + var store = new EventStateStore(clock); + + var workspaceStore = Substitute.For(); + workspaceStore.GetCultureAsync(Guild, Arg.Any()).Returns("en"); + + var services = new ServiceCollection(); + services.AddScoped(_ => workspaceStore); + var provider = services.BuildServiceProvider(); + + var relay = new EventRelay( + new MarkerEventClassifier(clock), + store, + new EventEmbedRenderer(new EventLocalizer(EventLocalizationCatalog.Default)), + locator, + poster, + provider.GetRequiredService()); + + return (relay, store, poster); + } + + [Fact] + public async Task Posts_one_embed_per_classified_event_and_updates_state() + { + var (relay, store, poster) = CreateRelay(); + + await relay.RelayAsync( + new MapMarkersChangedEvent(Guild, Server, null, + [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], + []), + CancellationToken.None); + + await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); + Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + } + + [Fact] + public async Task When_channel_missing_updates_state_but_does_not_post() + { + var (relay, store, poster) = CreateRelay(channelId: null); + + await relay.RelayAsync( + new MapMarkersChangedEvent(Guild, Server, null, + [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], + []), + CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + // State must still be updated so !cargo reflects the active cargo even without #events. + Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + } + + [Fact] + public async Task Empty_classification_posts_nothing() + { + var (relay, _, poster) = CreateRelay(); + + // Other markers produce no domain events from the classifier. + await relay.RelayAsync( + new MapMarkersChangedEvent(Guild, Server, null, + [new MapMarkerSnapshot(2, MarkerKind.Other, 0f, 0f, null)], + []), + CancellationToken.None); + + await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj b/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj index a0c1d48b..c722dbf0 100644 --- a/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj +++ b/tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj @@ -2,6 +2,7 @@ + diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs new file mode 100644 index 00000000..6d7bbe68 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs @@ -0,0 +1,126 @@ +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 EventChannelLocatorTests +{ + private static (EventChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock) + CreateLocator() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var cs = $"DataSource=event-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 EventChannelLocator(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.ServerEvents, + DiscordChannelId = 888UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + return server.Id; + } + + [Fact] + public async Task GetChannelIdAsync_returns_provisioned_events_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.ServerEvents, + 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); + } +} From bfe8b6c015558b39e81f26a4f55e5e4db48ef266 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:45:41 +0200 Subject: [PATCH 10/14] feat(2a): register Events feature + MarkerPollInterval option in the host --- src/RustPlusBot.Host/Program.cs | 3 +++ src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + 2 files changed, 4 insertions(+) diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index fa2c633d..514035de 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -9,6 +9,7 @@ using RustPlusBot.Features.Chat; using RustPlusBot.Features.Commands; using RustPlusBot.Features.Connections; +using RustPlusBot.Features.Events; using RustPlusBot.Features.Pairing; using RustPlusBot.Features.Workspace; using RustPlusBot.Host.Credentials; @@ -50,6 +51,7 @@ .Validate(static o => o.HeartbeatTimeout > TimeSpan.Zero, "Connections:HeartbeatTimeout must be positive.") .Validate(static o => o.HeartbeatTimeout < o.HeartbeatInterval, "Connections:HeartbeatTimeout must be less than HeartbeatInterval.") + .Validate(static o => o.MarkerPollInterval > TimeSpan.Zero, "Connections:MarkerPollInterval must be positive.") .ValidateOnStart(); builder.Services.AddConnections(); builder.Services.AddChat(); @@ -58,6 +60,7 @@ .Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.") .ValidateOnStart(); builder.Services.AddCommands(); +builder.Services.AddEvents(); var host = builder.Build(); diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index ac632af6..59c9b2a3 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -23,5 +23,6 @@ + From 235b250c2b28fa39a07bf22be2a0c7cff438dd1a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:53:36 +0200 Subject: [PATCH 11/14] feat(2a): add !cargo/!heli/!chinook/!events in-game command handlers Co-Authored-By: Claude Sonnet 4.6 --- .../CommandServiceCollectionExtensions.cs | 4 + .../Handlers/CargoCommandHandler.cs | 36 +++++++++ .../Handlers/ChinookCommandHandler.cs | 36 +++++++++ .../Handlers/EventsCommandHandler.cs | 45 +++++++++++ .../Handlers/HeliCommandHandler.cs | 36 +++++++++ .../CommandLocalizationCatalog.cs | 26 +++++++ .../RustPlusBot.Features.Commands.csproj | 1 + .../CommandRegistrationTests.cs | 9 ++- .../Handlers/EventHandlersTests.cs | 78 +++++++++++++++++++ 9 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs create mode 100644 src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index c57a0bc6..0f83eb0b 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -38,6 +38,10 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHostedService(); diff --git a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs new file mode 100644 index 00000000..5ee0e7ca --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs @@ -0,0 +1,36 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !cargo — reports the cargo ship's current grid position, if any. +/// The live event state. +/// The reply localizer. +/// For "how long ago". +internal sealed class CargoCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock) + : ICommandHandler +{ + /// + public string Name => "cargo"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.CargoShip); + if (markers.Count == 0) + { + return Task.FromResult(localizer.Get("command.cargo.none", context.Culture)); + } + + var m = markers[0]; + var grid = GridReference.From(m.X, m.Y, m.Dimensions); + var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); + return Task.FromResult(localizer.Get("command.cargo.ok", context.Culture, grid, ago)); + } +} diff --git a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs new file mode 100644 index 00000000..8a7150a5 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs @@ -0,0 +1,36 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !chinook — reports the Chinook helicopter's current grid position, if any. +/// The live event state. +/// The reply localizer. +/// For "how long ago". +internal sealed class ChinookCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock) + : ICommandHandler +{ + /// + public string Name => "chinook"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.Chinook); + if (markers.Count == 0) + { + return Task.FromResult(localizer.Get("command.chinook.none", context.Culture)); + } + + var m = markers[0]; + var grid = GridReference.From(m.X, m.Y, m.Dimensions); + var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); + return Task.FromResult(localizer.Get("command.chinook.ok", context.Culture, grid, ago)); + } +} diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs new file mode 100644 index 00000000..73d29a86 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -0,0 +1,45 @@ +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !events — lists the most recent live events. +/// The live event state. +/// The reply localizer. +internal sealed class EventsCommandHandler(IEventState state, ICommandLocalizer localizer) : ICommandHandler +{ + /// + public string Name => "events"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var events = state.GetRecentEvents(context.GuildId, context.ServerId); + if (events.Count == 0) + { + return Task.FromResult(localizer.Get("command.events.none", context.Culture)); + } + + var parts = events.Select(e => + { + var grid = GridReference.From(e.X, e.Y, e.Dimensions); + var key = e.Kind switch + { + MapEventKind.CargoEntered => "command.event.cargoentered", + MapEventKind.CargoLeft => "command.event.cargoleft", + MapEventKind.HeliEntered => "command.event.helientered", + MapEventKind.HeliLeft => "command.event.helileft", + MapEventKind.ChinookSpawned => "command.event.chinookspawned", + _ => "command.event.chinookspawned", + }; + return localizer.Get(key, context.Culture, grid); + }); + + return Task.FromResult( + localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts))); + } +} diff --git a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs new file mode 100644 index 00000000..ecc2c269 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs @@ -0,0 +1,36 @@ +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !heli — reports the patrol helicopter's current grid position, if any. +/// The live event state. +/// The reply localizer. +/// For "how long ago". +internal sealed class HeliCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock) + : ICommandHandler +{ + /// + public string Name => "heli"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.PatrolHelicopter); + if (markers.Count == 0) + { + return Task.FromResult(localizer.Get("command.heli.none", context.Culture)); + } + + var m = markers[0]; + var grid = GridReference.From(m.X, m.Y, m.Dimensions); + var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); + return Task.FromResult(localizer.Get("command.heli.ok", context.Culture, grid, ago)); + } +} diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 345fb547..ffb906d1 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -38,6 +38,19 @@ internal sealed class CommandLocalizationCatalog ["command.prox.member"] = "{0} {1}m", ["command.prox.selfunknown"] = "Can't locate you.", ["command.prox.alone"] = "No teammates nearby.", + ["command.cargo.ok"] = "Cargo Ship at {0} ({1} ago)", + ["command.cargo.none"] = "No cargo ship on the map.", + ["command.heli.ok"] = "Patrol Helicopter at {0} ({1} ago)", + ["command.heli.none"] = "No patrol helicopter on the map.", + ["command.chinook.ok"] = "Chinook at {0} ({1} ago)", + ["command.chinook.none"] = "No chinook on the map.", + ["command.events.ok"] = "Recent: {0}", + ["command.events.none"] = "No recent events.", + ["command.event.cargoentered"] = "cargo in {0}", + ["command.event.cargoleft"] = "cargo left {0}", + ["command.event.helientered"] = "heli in {0}", + ["command.event.helileft"] = "heli left {0}", + ["command.event.chinookspawned"] = "chinook in {0}", ["command.server.none"] = "No server is set up yet.", ["command.server.specify"] = "Multiple servers are set up — choose one with the server option.", ["command.server.unknown"] = "That server isn't set up.", @@ -99,6 +112,19 @@ internal sealed class CommandLocalizationCatalog ["command.prox.member"] = "{0} {1}m", ["command.prox.selfunknown"] = "Impossible de vous localiser.", ["command.prox.alone"] = "Aucun coéquipier à proximité.", + ["command.cargo.ok"] = "Cargo en {0} (il y a {1})", + ["command.cargo.none"] = "Aucun cargo sur la carte.", + ["command.heli.ok"] = "Hélicoptère en {0} (il y a {1})", + ["command.heli.none"] = "Aucun hélicoptère sur la carte.", + ["command.chinook.ok"] = "Chinook en {0} (il y a {1})", + ["command.chinook.none"] = "Aucun chinook sur la carte.", + ["command.events.ok"] = "Récent : {0}", + ["command.events.none"] = "Aucun événement récent.", + ["command.event.cargoentered"] = "cargo en {0}", + ["command.event.cargoleft"] = "cargo parti de {0}", + ["command.event.helientered"] = "heli en {0}", + ["command.event.helileft"] = "heli parti de {0}", + ["command.event.chinookspawned"] = "chinook en {0}", ["command.server.none"] = "Aucun serveur n'est encore configuré.", ["command.server.specify"] = "Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.", diff --git a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj index 9793f6f3..915dea82 100644 --- a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj +++ b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 4ae52d83..4cdbd6a9 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -9,6 +9,7 @@ using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Commands.Servers; using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.State; using RustPlusBot.Persistence.Commands; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; @@ -26,6 +27,7 @@ public void Dispatcher_and_handlers_resolve() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); + services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); @@ -44,7 +46,7 @@ public void Dispatcher_and_handlers_resolve() using var scope = provider.CreateScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); var handlers = scope.ServiceProvider.GetServices().ToList(); - Assert.Equal(12, handlers.Count); + Assert.Equal(16, handlers.Count); Assert.Contains(handlers, h => h.Name == "mute"); Assert.Contains(handlers, h => h.Name == "pop"); Assert.Contains(handlers, h => h.Name == "time"); @@ -55,6 +57,10 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "steamid"); Assert.Contains(handlers, h => h.Name == "alive"); Assert.Contains(handlers, h => h.Name == "prox"); + Assert.Contains(handlers, h => h.Name == "cargo"); + Assert.Contains(handlers, h => h.Name == "heli"); + Assert.Contains(handlers, h => h.Name == "chinook"); + Assert.Contains(handlers, h => h.Name == "events"); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } @@ -68,6 +74,7 @@ public void Commands_contribute_an_interaction_module_assembly() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); + services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs new file mode 100644 index 00000000..44cbd930 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -0,0 +1,78 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Handlers; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Features.Events.Classifying; +using RustPlusBot.Features.Events.State; + +namespace RustPlusBot.Features.Commands.Tests.Handlers; + +public sealed class EventHandlersTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 5, 0, TimeSpan.Zero); + + private static (IClock Clock, ICommandLocalizer Loc) Deps() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + return (clock, new CommandLocalizer(CommandLocalizationCatalog.Default)); + } + + private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []); + + [Fact] + public async Task Cargo_with_active_marker_reports_grid() + { + var (clock, loc) = Deps(); + var state = Substitute.For(); + var dims = new MapDimensions(4000u, 4000u, 500); + state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns( + [new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5))]); + var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); + + Assert.NotNull(reply); + Assert.Contains("Cargo Ship at", reply, StringComparison.Ordinal); + // Dimensions present → a grid ref (e.g. "A0"), NOT raw "(10, 3990)" coords. + Assert.DoesNotContain("(10, 3990)", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task Cargo_without_marker_reports_none() + { + var (clock, loc) = Deps(); + var state = Substitute.For(); + state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns([]); + var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Equal("No cargo ship on the map.", reply); + } + + [Fact] + public async Task Events_lists_recent_or_reports_none() + { + var (_, loc) = Deps(); + var state = Substitute.For(); + state.GetRecentEvents(Guild, Server).Returns([]); + Assert.Equal("No recent events.", + await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None)); + + state.GetRecentEvents(Guild, Server).Returns( + [new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, new MapDimensions(4000u, 4000u, 500), Now)]); + var reply = await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None); + Assert.Contains("Recent:", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task Handlers_expose_expected_names() + { + var (clock, loc) = Deps(); + var state = Substitute.For(); + Assert.Equal("cargo", new CargoCommandHandler(state, loc, clock).Name); + Assert.Equal("heli", new HeliCommandHandler(state, loc, clock).Name); + Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock).Name); + Assert.Equal("events", new EventsCommandHandler(state, loc).Name); + } +} From dc347bad6cf02b877647f922c6b7ef7418f91345 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 17:58:19 +0200 Subject: [PATCH 12/14] docs(2a): document the #events channel Co-Authored-By: Claude Sonnet 4.6 --- docs/development/running-locally.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/development/running-locally.md b/docs/development/running-locally.md index 0f1440b9..8186814f 100644 --- a/docs/development/running-locally.md +++ b/docs/development/running-locally.md @@ -63,3 +63,8 @@ overwritten on every startup, so a normal run after the reset leaves only the cu 5. In Development (`Workspace:EnableDangerCommands = true`), use `/workspace simulate-server name: ip: port:` to create a server category, and `/workspace reset` to delete the whole workspace. + +Each provisioned server category also includes an `#events` channel, which receives live alerts for +**cargo ship, patrol helicopter, and chinook** arrivals and departures. No new Discord gateway intent +is required — event markers are detected by polling the Rust+ socket, not the Discord gateway; only the +existing **Send Messages** and **Embed Links** permissions on the provisioned channel are used. From 6d9b9a2385e811973420f4508194c42762908865 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 18:04:19 +0200 Subject: [PATCH 13/14] fix(2a): derive grid row count from Height, not Width (final review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GridReference computed the row count from Width and never read Height/ OceanMargin — correct only because Rust maps are square. Derive each axis from its own dimension so the grid math is correct by construction. Behavior-preserving for square maps; GridReference tests unchanged. Co-Authored-By: Claude Opus 4.8 --- .../Formatting/GridReference.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs index df4fbcfa..45dc933b 100644 --- a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs +++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs @@ -23,12 +23,13 @@ public static string From(float x, float y, MapDimensions? dims) $"({Math.Round(x)}, {Math.Round(y)})"); } - var mapSize = dims.Width; - var columns = (int)Math.Ceiling(mapSize / GridDiameter); - var col = (int)Math.Floor(Math.Clamp(x, 0f, mapSize - 1) / GridDiameter); + // Columns run along X (Width); rows along Y (Height). Rust maps are square in practice, but + // derive each axis from its own dimension so the math is correct by construction, not by accident. + var col = (int)Math.Floor(Math.Clamp(x, 0f, dims.Width - 1) / GridDiameter); + var rowCount = (int)Math.Ceiling(dims.Height / GridDiameter); // Rows are numbered from the TOP; world Y increases upward, so invert. - var rowFromBottom = (int)Math.Floor(Math.Clamp(y, 0f, mapSize - 1) / GridDiameter); - var row = Math.Max(0, columns - rowFromBottom - 1); + var rowFromBottom = (int)Math.Floor(Math.Clamp(y, 0f, dims.Height - 1) / GridDiameter); + var row = Math.Max(0, rowCount - rowFromBottom - 1); return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); } From b3e8ba97e355febdf778c6ec0b45a8e1fa62fb3a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 18:32:29 +0200 Subject: [PATCH 14/14] fix(2a): address Copilot review on PR #13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventEmbedRenderer + EventsCommandHandler: throw ArgumentOutOfRangeException on an unknown MapEventKind instead of silently falling back to the chinook key (fail-fast if a future kind is added). - IRustServerConnection.GetMapMarkersAsync: correct the stale "all kinds / MarkerKind.Other" XML doc — the mapped facade only surfaces cargo/heli/chinook. - RustPlusSocketSource.AddMarkers: skip markers with null X/Y instead of substituting (0,0), which would diff as a phantom spawn/despawn at the origin. - DiscordEventChannelPoster: forward the CancellationToken via RequestOptions.CancelToken (and rethrow OperationCanceledException so a shutdown cancel isn't logged as a post failure). - running-locally.md: chinook only alerts on spawn (cargo/heli on enter+leave) — reword the #events description to match actual behavior. - EventStateStoreTests: rename the un-alerted-removal test for clarity (behavior was already correct). Co-Authored-By: Claude Opus 4.8 --- docs/development/running-locally.md | 9 +++++---- .../Handlers/EventsCommandHandler.cs | 3 ++- .../Listening/IRustServerConnection.cs | 4 ++-- .../Listening/RustPlusSocketSource.cs | 9 ++++++++- .../Posting/DiscordEventChannelPoster.cs | 12 ++++++++++-- .../Rendering/EventEmbedRenderer.cs | 3 ++- .../State/EventStateStoreTests.cs | 2 +- 7 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/development/running-locally.md b/docs/development/running-locally.md index 8186814f..ac666abf 100644 --- a/docs/development/running-locally.md +++ b/docs/development/running-locally.md @@ -64,7 +64,8 @@ overwritten on every startup, so a normal run after the reset leaves only the cu `/workspace simulate-server name: ip: port:` to create a server category, and `/workspace reset` to delete the whole workspace. -Each provisioned server category also includes an `#events` channel, which receives live alerts for -**cargo ship, patrol helicopter, and chinook** arrivals and departures. No new Discord gateway intent -is required — event markers are detected by polling the Rust+ socket, not the Discord gateway; only the -existing **Send Messages** and **Embed Links** permissions on the provisioned channel are used. +Each provisioned server category also includes an `#events` channel, which receives live alerts when a +**cargo ship** or **patrol helicopter** enters or leaves the map and when a **chinook** spawns. No new +Discord gateway intent is required — event markers are detected by polling the Rust+ socket, not the +Discord gateway; only the existing **Send Messages** and **Embed Links** permissions on the provisioned +channel are used. diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs index 73d29a86..2777cc9e 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -15,6 +15,7 @@ internal sealed class EventsCommandHandler(IEventState state, ICommandLocalizer public string Name => "events"; /// + /// A recent event has an unsupported . public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); @@ -34,7 +35,7 @@ internal sealed class EventsCommandHandler(IEventState state, ICommandLocalizer MapEventKind.HeliEntered => "command.event.helientered", MapEventKind.HeliLeft => "command.event.helileft", MapEventKind.ChinookSpawned => "command.event.chinookspawned", - _ => "command.event.chinookspawned", + _ => throw new ArgumentOutOfRangeException(nameof(e), e.Kind, "Unsupported map event kind."), }; return localizer.Get(key, context.Culture, grid); }); diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 7f0b58a5..1b6092ae 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -47,10 +47,10 @@ internal interface IRustServerConnection : IAsyncDisposable /// True if the promotion succeeded; false on failure/timeout. Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken); - /// Polls the current map markers (all kinds; the caller diffs by id). Throws on failure. + /// Polls the current map markers the bot tracks (cargo ship, patrol helicopter, chinook), for diffing by id. Throws on failure. /// How long to wait for the response. /// A cancellation token. - /// The current markers (every kind, unclassified ones as ). + /// The current cargo-ship / patrol-helicopter / chinook markers. Other marker types are not surfaced (the mapped RustPlusApi facade exposes no others the bot reasons about; crates are no longer sent by the game). Task> GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default); diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 7b20dd6e..c6035aaa 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -399,7 +399,14 @@ private static void AddMarkers( { foreach (var (id, marker) in source) { - into.Add(new MapMarkerSnapshot(id, kind, marker.X ?? 0f, marker.Y ?? 0f, Name: null)); + // Skip markers with incomplete coordinates rather than placing a phantom at the origin, + // which would otherwise diff as a spurious spawn/despawn at (0, 0). + if (marker.X is not { } x || marker.Y is not { } y) + { + continue; + } + + into.Add(new MapMarkerSnapshot(id, kind, x, y, Name: null)); } } diff --git a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs index bdc29810..d32e93cf 100644 --- a/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs +++ b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs @@ -17,12 +17,20 @@ public async Task PostAsync(ulong channelId, Embed embed, CancellationToken canc { try { - if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel) + var options = new RequestOptions + { + CancelToken = cancellationToken + }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel) { return; } - await channel.SendMessageAsync(embed: embed).ConfigureAwait(false); + await channel.SendMessageAsync(embed: embed, options: options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // Cancellation (shutdown) is not a post failure; let the relay loop unwind cleanly. } #pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay. catch (Exception ex) diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs index 5d107f45..67faccbb 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -12,6 +12,7 @@ internal sealed class EventEmbedRenderer(IEventLocalizer localizer) /// The event to render. /// The guild culture ("en"/"fr"). /// The built embed. + /// The event kind is not a supported . public Embed Render(RustMapEvent evt, string culture) { ArgumentNullException.ThrowIfNull(evt); @@ -23,7 +24,7 @@ public Embed Render(RustMapEvent evt, string culture) MapEventKind.HeliEntered => "event.heli.entered", MapEventKind.HeliLeft => "event.heli.left", MapEventKind.ChinookSpawned => "event.chinook.spawned", - _ => "event.chinook.spawned", + _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported map event kind."), }; return new EmbedBuilder() diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs index 72acb94c..f57c07fe 100644 --- a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs @@ -48,7 +48,7 @@ public void Added_marker_becomes_active_and_carries_dimensions() } [Fact] - public void Removed_marker_leaves_active_even_when_unalerted() + public void Removed_marker_is_cleared_from_active_even_when_unalerted() { var store = Build(); // A Crate marker produces NO classified event (core-3), but is still tracked in the active set...