Skip to content

Commit d4712b8

Browse files
HandyS11claude
andauthored
Subsystem 2a: live events poller + #events alerts + event !commands (#13)
* feat(2a): add map-marker snapshot, dimensions, and MapMarkersChangedEvent Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(2a): add GetMapMarkers/GetMapDimensions to the connection seam + fake * 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 <noreply@anthropic.com> * fix(2a): strengthen marker-poll baseline test + deterministic fake script - 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<IReadOnlyList< MapMarkerSnapshot>> 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 <noreply@anthropic.com> * feat(2a): scaffold Features.Events project + GridReference helper * feat(2a): add MarkerEventClassifier mapping deltas to domain events Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(2a): add in-memory EventStateStore + IEventState read-seam * 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 <noreply@anthropic.com> * feat(2a): EventRelay, #events poster/locator, hosted service, DI + Workspace spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(2a): register Events feature + MarkerPollInterval option in the host * feat(2a): add !cargo/!heli/!chinook/!events in-game command handlers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(2a): document the #events channel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(2a): derive grid row count from Height, not Width (final review) 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 <noreply@anthropic.com> * fix(2a): address Copilot review on PR #13 - 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b83fe3d commit d4712b8

56 files changed

Lines changed: 2171 additions & 3 deletions

File tree

Some content is hidden

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

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
99
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
1010
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
11+
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1112
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1213
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
1314
</Folder>
@@ -18,6 +19,7 @@
1819
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
1920
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
2021
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
22+
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
2123
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
2224
</Folder>
2325
</Solution>

docs/development/running-locally.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ overwritten on every startup, so a normal run after the reset leaves only the cu
6363
5. In Development (`Workspace:EnableDangerCommands = true`), use
6464
`/workspace simulate-server name:<n> ip:<host> port:<port>` to create a server category, and
6565
`/workspace reset` to delete the whole workspace.
66+
67+
Each provisioned server category also includes an `#events` channel, which receives live alerts when a
68+
**cargo ship** or **patrol helicopter** enters or leaves the map and when a **chinook** spawns. No new
69+
Discord gateway intent is required — event markers are detected by polling the Rust+ socket, not the
70+
Discord gateway; only the existing **Send Messages** and **Embed Links** permissions on the provisioned
71+
channel are used.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace RustPlusBot.Features.Connections.Listening;
2+
3+
/// <summary>The static-per-wipe map size needed to convert world coordinates to a grid reference.</summary>
4+
/// <param name="Width">Map image width.</param>
5+
/// <param name="Height">Map image height.</param>
6+
/// <param name="OceanMargin">The ocean margin around the playable area.</param>
7+
public sealed record MapDimensions(uint Width, uint Height, int OceanMargin);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace RustPlusBot.Features.Connections.Listening;
2+
3+
/// <summary>One map marker observed in a <c>GetMapMarkers</c> poll.</summary>
4+
/// <param name="Id">The stable marker id (used to diff polls).</param>
5+
/// <param name="Kind">The classified marker kind.</param>
6+
/// <param name="X">World X coordinate.</param>
7+
/// <param name="Y">World Y coordinate.</param>
8+
/// <param name="Name">The marker name, if any.</param>
9+
public sealed record MapMarkerSnapshot(ulong Id, MarkerKind Kind, float X, float Y, string? Name);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace RustPlusBot.Features.Connections.Listening;
2+
3+
/// <summary>The subset of Rust map-marker types this bot reasons about; everything else is <see cref="Other"/>.</summary>
4+
public enum MarkerKind
5+
{
6+
/// <summary>A marker type the bot does not classify (player, vending, explosion, generic radius, vendor).</summary>
7+
Other = 0,
8+
9+
/// <summary>A cargo ship.</summary>
10+
CargoShip = 1,
11+
12+
/// <summary>A patrol helicopter.</summary>
13+
PatrolHelicopter = 2,
14+
15+
/// <summary>A Chinook (CH-47).</summary>
16+
Chinook = 3,
17+
18+
/// <summary>A locked crate.</summary>
19+
Crate = 4,
20+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
3+
namespace RustPlusBot.Abstractions.Events;
4+
5+
/// <summary>Published when a marker poll detects markers that appeared or disappeared since the previous poll.</summary>
6+
/// <param name="GuildId">The owning guild snowflake.</param>
7+
/// <param name="ServerId">The target server id.</param>
8+
/// <param name="Dimensions">Map dimensions for grid-reference rendering, or null if unavailable.</param>
9+
/// <param name="Added">Markers present now that were absent in the previous poll.</param>
10+
/// <param name="Removed">Markers absent now that were present in the previous poll.</param>
11+
public sealed record MapMarkersChangedEvent(
12+
ulong GuildId,
13+
Guid ServerId,
14+
MapDimensions? Dimensions,
15+
IReadOnlyList<MapMarkerSnapshot> Added,
16+
IReadOnlyList<MapMarkerSnapshot> Removed);

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
3838
services.AddScoped<ICommandHandler, SteamIdCommandHandler>();
3939
services.AddScoped<ICommandHandler, AliveCommandHandler>();
4040
services.AddScoped<ICommandHandler, ProxCommandHandler>();
41+
services.AddScoped<ICommandHandler, CargoCommandHandler>();
42+
services.AddScoped<ICommandHandler, HeliCommandHandler>();
43+
services.AddScoped<ICommandHandler, ChinookCommandHandler>();
44+
services.AddScoped<ICommandHandler, EventsCommandHandler>();
4145

4246
services.AddScoped<CommandDispatcher>();
4347
services.AddHostedService<CommandsHostedService>();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using RustPlusBot.Abstractions.Time;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Formatting;
4+
using RustPlusBot.Features.Commands.Localization;
5+
using RustPlusBot.Features.Connections.Listening;
6+
using RustPlusBot.Features.Events.Formatting;
7+
using RustPlusBot.Features.Events.State;
8+
9+
namespace RustPlusBot.Features.Commands.Handlers;
10+
11+
/// <summary>!cargo — reports the cargo ship's current grid position, if any.</summary>
12+
/// <param name="state">The live event state.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
/// <param name="clock">For "how long ago".</param>
15+
internal sealed class CargoCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock)
16+
: ICommandHandler
17+
{
18+
/// <inheritdoc />
19+
public string Name => "cargo";
20+
21+
/// <inheritdoc />
22+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
23+
{
24+
ArgumentNullException.ThrowIfNull(context);
25+
var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.CargoShip);
26+
if (markers.Count == 0)
27+
{
28+
return Task.FromResult<string?>(localizer.Get("command.cargo.none", context.Culture));
29+
}
30+
31+
var m = markers[0];
32+
var grid = GridReference.From(m.X, m.Y, m.Dimensions);
33+
var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc);
34+
return Task.FromResult<string?>(localizer.Get("command.cargo.ok", context.Culture, grid, ago));
35+
}
36+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using RustPlusBot.Abstractions.Time;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Formatting;
4+
using RustPlusBot.Features.Commands.Localization;
5+
using RustPlusBot.Features.Connections.Listening;
6+
using RustPlusBot.Features.Events.Formatting;
7+
using RustPlusBot.Features.Events.State;
8+
9+
namespace RustPlusBot.Features.Commands.Handlers;
10+
11+
/// <summary>!chinook — reports the Chinook helicopter's current grid position, if any.</summary>
12+
/// <param name="state">The live event state.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
/// <param name="clock">For "how long ago".</param>
15+
internal sealed class ChinookCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock)
16+
: ICommandHandler
17+
{
18+
/// <inheritdoc />
19+
public string Name => "chinook";
20+
21+
/// <inheritdoc />
22+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
23+
{
24+
ArgumentNullException.ThrowIfNull(context);
25+
var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.Chinook);
26+
if (markers.Count == 0)
27+
{
28+
return Task.FromResult<string?>(localizer.Get("command.chinook.none", context.Culture));
29+
}
30+
31+
var m = markers[0];
32+
var grid = GridReference.From(m.X, m.Y, m.Dimensions);
33+
var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc);
34+
return Task.FromResult<string?>(localizer.Get("command.chinook.ok", context.Culture, grid, ago));
35+
}
36+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Localization;
3+
using RustPlusBot.Features.Events.Classifying;
4+
using RustPlusBot.Features.Events.Formatting;
5+
using RustPlusBot.Features.Events.State;
6+
7+
namespace RustPlusBot.Features.Commands.Handlers;
8+
9+
/// <summary>!events — lists the most recent live events.</summary>
10+
/// <param name="state">The live event state.</param>
11+
/// <param name="localizer">The reply localizer.</param>
12+
internal sealed class EventsCommandHandler(IEventState state, ICommandLocalizer localizer) : ICommandHandler
13+
{
14+
/// <inheritdoc />
15+
public string Name => "events";
16+
17+
/// <inheritdoc />
18+
/// <exception cref="ArgumentOutOfRangeException">A recent event has an unsupported <see cref="MapEventKind"/>.</exception>
19+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
20+
{
21+
ArgumentNullException.ThrowIfNull(context);
22+
var events = state.GetRecentEvents(context.GuildId, context.ServerId);
23+
if (events.Count == 0)
24+
{
25+
return Task.FromResult<string?>(localizer.Get("command.events.none", context.Culture));
26+
}
27+
28+
var parts = events.Select(e =>
29+
{
30+
var grid = GridReference.From(e.X, e.Y, e.Dimensions);
31+
var key = e.Kind switch
32+
{
33+
MapEventKind.CargoEntered => "command.event.cargoentered",
34+
MapEventKind.CargoLeft => "command.event.cargoleft",
35+
MapEventKind.HeliEntered => "command.event.helientered",
36+
MapEventKind.HeliLeft => "command.event.helileft",
37+
MapEventKind.ChinookSpawned => "command.event.chinookspawned",
38+
_ => throw new ArgumentOutOfRangeException(nameof(e), e.Kind, "Unsupported map event kind."),
39+
};
40+
return localizer.Get(key, context.Culture, grid);
41+
});
42+
43+
return Task.FromResult<string?>(
44+
localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)));
45+
}
46+
}

0 commit comments

Comments
 (0)