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/docs/development/running-locally.md b/docs/development/running-locally.md
index 0f1440b9..ac666abf 100644
--- a/docs/development/running-locally.md
+++ b/docs/development/running-locally.md
@@ -63,3 +63,9 @@ 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 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.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/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..2777cc9e
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs
@@ -0,0 +1,46 @@
+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";
+
+ ///
+ /// A recent event has an unsupported .
+ 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",
+ _ => throw new ArgumentOutOfRangeException(nameof(e), e.Kind, "Unsupported map event kind."),
+ };
+ 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/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs
index 671c2933..5c02f68d 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; set; } = TimeSpan.FromSeconds(10);
}
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
index 4e9ef5c3..1b6092ae 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -47,6 +47,19 @@ 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 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 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);
+
+ /// 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..c6035aaa 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -48,6 +48,14 @@ 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 +310,70 @@ 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;
+ }
+
+ 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;
@@ -319,6 +391,25 @@ 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)
+ {
+ // 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));
+ }
+ }
+
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/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/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/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs
new file mode 100644
index 00000000..45dc933b
--- /dev/null
+++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs
@@ -0,0 +1,50 @@
+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)})");
+ }
+
+ // 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, dims.Height - 1) / GridDiameter);
+ var row = Math.Max(0, rowCount - 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/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..d32e93cf
--- /dev/null
+++ b/src/RustPlusBot.Features.Events/Posting/DiscordEventChannelPoster.cs
@@ -0,0 +1,45 @@
+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
+ {
+ var options = new RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
+ {
+ return;
+ }
+
+ 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)
+#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.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs
new file mode 100644
index 00000000..67faccbb
--- /dev/null
+++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs
@@ -0,0 +1,36 @@
+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.
+ /// The event kind is not a supported .
+ 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",
+ _ => throw new ArgumentOutOfRangeException(nameof(evt), evt.Kind, "Unsupported map event kind."),
+ };
+
+ 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/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/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/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/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 @@
+
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);
+ }
+}
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);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs
index 7eb1bc11..1edf5c05 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,192 @@ public async Task StartAll_StartsAConnectionPerConnectableServer()
Assert.True(source.CreateCount >= 1);
}
+ [Fact]
+ public async Task First_marker_poll_is_a_silent_baseline()
+ {
+ // 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));
+ 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);
+
+ // 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
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId, 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);
+
+ 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();
+ try { await subTask; }
+ catch (OperationCanceledException)
+ {
+ /* expected */
+ }
+ }
+
+ [Fact]
+ public async Task Marker_added_on_a_later_poll_publishes_changed_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));
+ 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);
+
+ // FakeConnection default DimensionsResult is new(4000u, 4000u, 500); assert those exact values.
+ var expectedDims = new MapDimensions(4000u, 4000u, 500);
+
+ // 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
+
+ await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
+
+ // Wait for the definite signal: the CargoShip-added event.
+ await WaitUntilAsync(() => !captured.IsEmpty, cts.Token);
+
+ Assert.Single(captured);
+ 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);
+ Assert.Equal(expectedDims, 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()
+ {
+ // 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));
+ 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);
+
+ var cargo = new MapMarkerSnapshot(3UL, MarkerKind.CargoShip, 2f, 2f, null);
+ // 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]
+
+ 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 next poll throw; the poll loop catches the exception and retains the snapshot.
+ source.LastConnection!.MarkersThrow = true;
+ // Give enough time for at least one failing poll to be attempted.
+ await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token);
+
+ // Poll 4: recover — hold-last still returns [cargo], so snapshot is unchanged, no new event.
+ source.LastConnection!.MarkersThrow = false;
+ 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 61a8baa7..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,6 +102,19 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
/// The Steam ID passed to the most recent call.
public ulong LastPromotedSteamId { get; private set; }
+ ///
+ /// 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 regardless of any enqueued script.
+ 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,8 +147,42 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
return Task.FromResult(PromoteResult);
}
+ public Task> GetMapMarkersAsync(TimeSpan timeout,
+ 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) =>
+ Task.FromResult(DimensionsResult);
+
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);
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);
+ }
+}
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/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/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