Skip to content

Commit 026b585

Browse files
HandyS11claude
andauthored
Subsystem 2a-ii: oil-rig activation detection (small/large) via CH47 + 3-state machine + #events/in-game alerts + /small //large (#14)
* feat(events): add MonumentSnapshot, RigKind, RigEventKind, RigStateChangedEvent Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(connections): add adaptive-poll + rig-window options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): add GetMonumentsAsync to the socket seam * feat(connections): detect oil-rig activation from CH47 radius + adaptive poll cadence Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(events): add RigStateStore timed three-state machine Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(events): rig embed + in-game-line rendering + EN/FR strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(events): broadcast all events in-game + relay rig boundary events Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(events): rig-event relay loop + background rig-timer tick Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add !small / !large rig-status handlers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add /small and /large slash commands * chore: address Copilot review on PR #14 - FakeRustSocketSource: reset _pendingMonuments after transfer in Create() so SetMonuments applies to the NEXT connection only (no cross-connection leak / test order-dependence). - RigStateStore.Apply: fail-fast ArgumentException when Kind != Activated (the timed kinds come from Advance, not Apply); add a Theory covering both. - ConnectionSupervisor: dedicated LogMonumentsFetchFailed message threading the real server id (was misattributed to LogMarkerPollFailed with Guid.Empty). - Remove genuinely-unused usings (NSubstitute + Connections.Listening in RigStateStoreTests; Connections.Listening in RigRenderingTests). Kept the usings Copilot flagged that are actually in use (M.E.DependencyInjection for IServiceScopeFactory in the tick test; Classifying for RustMapEvent/MapEventKind). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d4712b8 commit 026b585

35 files changed

Lines changed: 1338 additions & 26 deletions
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>One named monument observed in a <c>GetMap</c> response.</summary>
4+
/// <param name="Token">The monument token (e.g. <c>oilrig_1</c>, <c>large_oil_rig</c>).</param>
5+
/// <param name="X">World X coordinate.</param>
6+
/// <param name="Y">World Y coordinate.</param>
7+
public sealed record MonumentSnapshot(string Token, float X, float Y);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>The rig lifecycle boundary an event marks.</summary>
4+
public enum RigEventKind
5+
{
6+
/// <summary>A CH47 reached the rig — the combat (unlocking) phase began.</summary>
7+
Activated = 0,
8+
9+
/// <summary>The combat phase ended — the crate is now lootable.</summary>
10+
CrateLootable = 1,
11+
12+
/// <summary>The dormant window ended — the crate respawned and the rig is armed again.</summary>
13+
Respawned = 2,
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Which oil rig a rig event refers to.</summary>
4+
public enum RigKind
5+
{
6+
/// <summary>The small oil rig (monument token <c>oilrig_1</c>).</summary>
7+
Small = 0,
8+
9+
/// <summary>The large oil rig (monument token <c>large_oil_rig</c>).</summary>
10+
Large = 1,
11+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
3+
namespace RustPlusBot.Abstractions.Events;
4+
5+
/// <summary>Published when an oil rig crosses a lifecycle boundary (activated / crate lootable / respawned).</summary>
6+
/// <param name="GuildId">The owning guild snowflake.</param>
7+
/// <param name="ServerId">The target server id.</param>
8+
/// <param name="Rig">Which rig.</param>
9+
/// <param name="Kind">The boundary that was crossed.</param>
10+
/// <param name="X">The rig monument's world X coordinate.</param>
11+
/// <param name="Y">The rig monument's world Y coordinate.</param>
12+
/// <param name="Dimensions">Map dimensions for grid-reference rendering, or null if unavailable.</param>
13+
public sealed record RigStateChangedEvent(
14+
ulong GuildId,
15+
Guid ServerId,
16+
RigKind Rig,
17+
RigEventKind Kind,
18+
float X,
19+
float Y,
20+
MapDimensions? Dimensions);

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
4242
services.AddScoped<ICommandHandler, HeliCommandHandler>();
4343
services.AddScoped<ICommandHandler, ChinookCommandHandler>();
4444
services.AddScoped<ICommandHandler, EventsCommandHandler>();
45+
services.AddScoped<ICommandHandler, SmallCommandHandler>();
46+
services.AddScoped<ICommandHandler, LargeCommandHandler>();
4547

4648
services.AddScoped<CommandDispatcher>();
4749
services.AddHostedService<CommandsHostedService>();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using RustPlusBot.Abstractions.Events;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Localization;
4+
using RustPlusBot.Features.Events.State;
5+
6+
namespace RustPlusBot.Features.Commands.Handlers;
7+
8+
/// <summary>!large — reports the large oil rig's status.</summary>
9+
/// <param name="rigState">The rig state reader.</param>
10+
/// <param name="localizer">The reply localizer.</param>
11+
internal sealed class LargeCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler
12+
{
13+
/// <inheritdoc />
14+
public string Name => "large";
15+
16+
/// <inheritdoc />
17+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
18+
{
19+
ArgumentNullException.ThrowIfNull(context);
20+
return Task.FromResult<string?>(RigReply.For(rigState, context, RigKind.Large, "command.large", localizer));
21+
}
22+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using RustPlusBot.Abstractions.Events;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Formatting;
4+
using RustPlusBot.Features.Commands.Localization;
5+
using RustPlusBot.Features.Events.State;
6+
7+
namespace RustPlusBot.Features.Commands.Handlers;
8+
9+
/// <summary>Shared rig-status reply formatting for the !small/!large handlers.</summary>
10+
internal static class RigReply
11+
{
12+
/// <summary>Formats the localized rig-status reply for a rig.</summary>
13+
/// <param name="rigState">The rig state reader.</param>
14+
/// <param name="context">The command context.</param>
15+
/// <param name="rig">Which rig.</param>
16+
/// <param name="prefix">The localization key prefix ("command.small" / "command.large").</param>
17+
/// <param name="localizer">The reply localizer.</param>
18+
/// <returns>The localized reply.</returns>
19+
public static string For(
20+
IRigState rigState,
21+
CommandContext context,
22+
RigKind rig,
23+
string prefix,
24+
ICommandLocalizer localizer)
25+
{
26+
var state = rigState.Get(context.GuildId, context.ServerId, rig);
27+
return state.Status switch
28+
{
29+
RigStatus.Online => localizer.Get($"{prefix}.online", context.Culture),
30+
RigStatus.Active => localizer.Get($"{prefix}.active", context.Culture,
31+
DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)),
32+
RigStatus.Offline => localizer.Get($"{prefix}.offline", context.Culture,
33+
DurationFormat.Compact(state.Remaining ?? TimeSpan.Zero)),
34+
_ => localizer.Get($"{prefix}.online", context.Culture),
35+
};
36+
}
37+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using RustPlusBot.Abstractions.Events;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Localization;
4+
using RustPlusBot.Features.Events.State;
5+
6+
namespace RustPlusBot.Features.Commands.Handlers;
7+
8+
/// <summary>!small — reports the small oil rig's status.</summary>
9+
/// <param name="rigState">The rig state reader.</param>
10+
/// <param name="localizer">The reply localizer.</param>
11+
internal sealed class SmallCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler
12+
{
13+
/// <inheritdoc />
14+
public string Name => "small";
15+
16+
/// <inheritdoc />
17+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
18+
{
19+
ArgumentNullException.ThrowIfNull(context);
20+
return Task.FromResult<string?>(RigReply.For(rigState, context, RigKind.Small, "command.small", localizer));
21+
}
22+
}

src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ internal sealed class CommandLocalizationCatalog
5151
["command.event.helientered"] = "heli in {0}",
5252
["command.event.helileft"] = "heli left {0}",
5353
["command.event.chinookspawned"] = "chinook in {0}",
54+
["command.small.online"] = "Small Oil Rig: crate ready, waiting for activation.",
55+
["command.small.active"] = "Small Oil Rig: combat phase — crate lootable in {0}.",
56+
["command.small.offline"] = "Small Oil Rig: looted / dormant — respawns in {0}.",
57+
["command.large.online"] = "Large Oil Rig: crate ready, waiting for activation.",
58+
["command.large.active"] = "Large Oil Rig: combat phase — crate lootable in {0}.",
59+
["command.large.offline"] = "Large Oil Rig: looted / dormant — respawns in {0}.",
5460
["command.server.none"] = "No server is set up yet.",
5561
["command.server.specify"] = "Multiple servers are set up — choose one with the server option.",
5662
["command.server.unknown"] = "That server isn't set up.",
@@ -125,6 +131,12 @@ internal sealed class CommandLocalizationCatalog
125131
["command.event.helientered"] = "heli en {0}",
126132
["command.event.helileft"] = "heli parti de {0}",
127133
["command.event.chinookspawned"] = "chinook en {0}",
134+
["command.small.online"] = "Petite plateforme : caisse prête, en attente d'activation.",
135+
["command.small.active"] = "Petite plateforme : phase de combat — caisse lootable dans {0}.",
136+
["command.small.offline"] = "Petite plateforme : pillée / dormante — réapparaît dans {0}.",
137+
["command.large.online"] = "Grande plateforme : caisse prête, en attente d'activation.",
138+
["command.large.active"] = "Grande plateforme : phase de combat — caisse lootable dans {0}.",
139+
["command.large.offline"] = "Grande plateforme : pillée / dormante — réapparaît dans {0}.",
128140
["command.server.none"] = "Aucun serveur n'est encore configuré.",
129141
["command.server.specify"] =
130142
"Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.",

src/RustPlusBot.Features.Commands/Modules/ServerCommandModule.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ public Task AliveAsync(
7070
[Autocomplete(typeof(ServerAutocompleteHandler))]
7171
string? server = null) => RunAsync("alive", server);
7272

73+
/// <summary>Shows the small oil rig's status.</summary>
74+
/// <param name="server">The target server (only needed if more than one is registered).</param>
75+
[SlashCommand("small", "Show the small oil rig status")]
76+
public Task SmallAsync(
77+
[Summary("server", "Which server (only needed if more than one)")]
78+
[Autocomplete(typeof(ServerAutocompleteHandler))]
79+
string? server = null) => RunAsync("small", server);
80+
81+
/// <summary>Shows the large oil rig's status.</summary>
82+
/// <param name="server">The target server (only needed if more than one is registered).</param>
83+
[SlashCommand("large", "Show the large oil rig status")]
84+
public Task LargeAsync(
85+
[Summary("server", "Which server (only needed if more than one)")]
86+
[Autocomplete(typeof(ServerAutocompleteHandler))]
87+
string? server = null) => RunAsync("large", server);
88+
7389
private async Task RunAsync(string commandName, string? server)
7490
{
7591
if (Context.Guild is null)

0 commit comments

Comments
 (0)