Skip to content

Commit 235b250

Browse files
HandyS11claude
andcommitted
feat(2a): add !cargo/!heli/!chinook/!events in-game command handlers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bfe8b6c commit 235b250

9 files changed

Lines changed: 270 additions & 1 deletion

File tree

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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
19+
{
20+
ArgumentNullException.ThrowIfNull(context);
21+
var events = state.GetRecentEvents(context.GuildId, context.ServerId);
22+
if (events.Count == 0)
23+
{
24+
return Task.FromResult<string?>(localizer.Get("command.events.none", context.Culture));
25+
}
26+
27+
var parts = events.Select(e =>
28+
{
29+
var grid = GridReference.From(e.X, e.Y, e.Dimensions);
30+
var key = e.Kind switch
31+
{
32+
MapEventKind.CargoEntered => "command.event.cargoentered",
33+
MapEventKind.CargoLeft => "command.event.cargoleft",
34+
MapEventKind.HeliEntered => "command.event.helientered",
35+
MapEventKind.HeliLeft => "command.event.helileft",
36+
MapEventKind.ChinookSpawned => "command.event.chinookspawned",
37+
_ => "command.event.chinookspawned",
38+
};
39+
return localizer.Get(key, context.Culture, grid);
40+
});
41+
42+
return Task.FromResult<string?>(
43+
localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)));
44+
}
45+
}
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>!heli — reports the patrol 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 HeliCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock)
16+
: ICommandHandler
17+
{
18+
/// <inheritdoc />
19+
public string Name => "heli";
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.PatrolHelicopter);
26+
if (markers.Count == 0)
27+
{
28+
return Task.FromResult<string?>(localizer.Get("command.heli.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.heli.ok", context.Culture, grid, ago));
35+
}
36+
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ internal sealed class CommandLocalizationCatalog
3838
["command.prox.member"] = "{0} {1}m",
3939
["command.prox.selfunknown"] = "Can't locate you.",
4040
["command.prox.alone"] = "No teammates nearby.",
41+
["command.cargo.ok"] = "Cargo Ship at {0} ({1} ago)",
42+
["command.cargo.none"] = "No cargo ship on the map.",
43+
["command.heli.ok"] = "Patrol Helicopter at {0} ({1} ago)",
44+
["command.heli.none"] = "No patrol helicopter on the map.",
45+
["command.chinook.ok"] = "Chinook at {0} ({1} ago)",
46+
["command.chinook.none"] = "No chinook on the map.",
47+
["command.events.ok"] = "Recent: {0}",
48+
["command.events.none"] = "No recent events.",
49+
["command.event.cargoentered"] = "cargo in {0}",
50+
["command.event.cargoleft"] = "cargo left {0}",
51+
["command.event.helientered"] = "heli in {0}",
52+
["command.event.helileft"] = "heli left {0}",
53+
["command.event.chinookspawned"] = "chinook in {0}",
4154
["command.server.none"] = "No server is set up yet.",
4255
["command.server.specify"] = "Multiple servers are set up — choose one with the server option.",
4356
["command.server.unknown"] = "That server isn't set up.",
@@ -99,6 +112,19 @@ internal sealed class CommandLocalizationCatalog
99112
["command.prox.member"] = "{0} {1}m",
100113
["command.prox.selfunknown"] = "Impossible de vous localiser.",
101114
["command.prox.alone"] = "Aucun coéquipier à proximité.",
115+
["command.cargo.ok"] = "Cargo en {0} (il y a {1})",
116+
["command.cargo.none"] = "Aucun cargo sur la carte.",
117+
["command.heli.ok"] = "Hélicoptère en {0} (il y a {1})",
118+
["command.heli.none"] = "Aucun hélicoptère sur la carte.",
119+
["command.chinook.ok"] = "Chinook en {0} (il y a {1})",
120+
["command.chinook.none"] = "Aucun chinook sur la carte.",
121+
["command.events.ok"] = "Récent : {0}",
122+
["command.events.none"] = "Aucun événement récent.",
123+
["command.event.cargoentered"] = "cargo en {0}",
124+
["command.event.cargoleft"] = "cargo parti de {0}",
125+
["command.event.helientered"] = "heli en {0}",
126+
["command.event.helileft"] = "heli parti de {0}",
127+
["command.event.chinookspawned"] = "chinook en {0}",
102128
["command.server.none"] = "Aucun serveur n'est encore configuré.",
103129
["command.server.specify"] =
104130
"Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.",

src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<ProjectReference Include="..\RustPlusBot.Abstractions\RustPlusBot.Abstractions.csproj" />
1010
<ProjectReference Include="..\RustPlusBot.Persistence\RustPlusBot.Persistence.csproj" />
1111
<ProjectReference Include="..\RustPlusBot.Features.Connections\RustPlusBot.Features.Connections.csproj" />
12+
<ProjectReference Include="..\RustPlusBot.Features.Events\RustPlusBot.Features.Events.csproj" />
1213
<ProjectReference Include="..\RustPlusBot.Discord\RustPlusBot.Discord.csproj" />
1314
</ItemGroup>
1415

tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using RustPlusBot.Features.Commands.Localization;
1010
using RustPlusBot.Features.Commands.Servers;
1111
using RustPlusBot.Features.Connections.Listening;
12+
using RustPlusBot.Features.Events.State;
1213
using RustPlusBot.Persistence.Commands;
1314
using RustPlusBot.Persistence.Servers;
1415
using RustPlusBot.Persistence.Workspace;
@@ -26,6 +27,7 @@ public void Dispatcher_and_handlers_resolve()
2627
services.AddSingleton<IEventBus>(Substitute.For<IEventBus>());
2728
services.AddSingleton<ITeamChatSender>(Substitute.For<ITeamChatSender>());
2829
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
30+
services.AddSingleton<IEventState>(_ => Substitute.For<IEventState>());
2931
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
3032
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
3133
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
@@ -44,7 +46,7 @@ public void Dispatcher_and_handlers_resolve()
4446
using var scope = provider.CreateScope();
4547
Assert.NotNull(scope.ServiceProvider.GetRequiredService<CommandDispatcher>());
4648
var handlers = scope.ServiceProvider.GetServices<ICommandHandler>().ToList();
47-
Assert.Equal(12, handlers.Count);
49+
Assert.Equal(16, handlers.Count);
4850
Assert.Contains(handlers, h => h.Name == "mute");
4951
Assert.Contains(handlers, h => h.Name == "pop");
5052
Assert.Contains(handlers, h => h.Name == "time");
@@ -55,6 +57,10 @@ public void Dispatcher_and_handlers_resolve()
5557
Assert.Contains(handlers, h => h.Name == "steamid");
5658
Assert.Contains(handlers, h => h.Name == "alive");
5759
Assert.Contains(handlers, h => h.Name == "prox");
60+
Assert.Contains(handlers, h => h.Name == "cargo");
61+
Assert.Contains(handlers, h => h.Name == "heli");
62+
Assert.Contains(handlers, h => h.Name == "chinook");
63+
Assert.Contains(handlers, h => h.Name == "events");
5864
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerResolver>());
5965
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerQueryService>());
6066
}
@@ -68,6 +74,7 @@ public void Commands_contribute_an_interaction_module_assembly()
6874
services.AddSingleton<IEventBus>(Substitute.For<IEventBus>());
6975
services.AddSingleton<ITeamChatSender>(Substitute.For<ITeamChatSender>());
7076
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
77+
services.AddSingleton<IEventState>(_ => Substitute.For<IEventState>());
7178
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
7279
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
7380
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using NSubstitute;
2+
using RustPlusBot.Abstractions.Time;
3+
using RustPlusBot.Features.Commands.Dispatching;
4+
using RustPlusBot.Features.Commands.Handlers;
5+
using RustPlusBot.Features.Commands.Localization;
6+
using RustPlusBot.Features.Connections.Listening;
7+
using RustPlusBot.Features.Events.Classifying;
8+
using RustPlusBot.Features.Events.State;
9+
10+
namespace RustPlusBot.Features.Commands.Tests.Handlers;
11+
12+
public sealed class EventHandlersTests
13+
{
14+
private const ulong Guild = 1UL;
15+
private static readonly Guid Server = Guid.NewGuid();
16+
private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 5, 0, TimeSpan.Zero);
17+
18+
private static (IClock Clock, ICommandLocalizer Loc) Deps()
19+
{
20+
var clock = Substitute.For<IClock>();
21+
clock.UtcNow.Returns(Now);
22+
return (clock, new CommandLocalizer(CommandLocalizationCatalog.Default));
23+
}
24+
25+
private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []);
26+
27+
[Fact]
28+
public async Task Cargo_with_active_marker_reports_grid()
29+
{
30+
var (clock, loc) = Deps();
31+
var state = Substitute.For<IEventState>();
32+
var dims = new MapDimensions(4000u, 4000u, 500);
33+
state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns(
34+
[new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5))]);
35+
var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None);
36+
37+
Assert.NotNull(reply);
38+
Assert.Contains("Cargo Ship at", reply, StringComparison.Ordinal);
39+
// Dimensions present → a grid ref (e.g. "A0"), NOT raw "(10, 3990)" coords.
40+
Assert.DoesNotContain("(10, 3990)", reply, StringComparison.Ordinal);
41+
}
42+
43+
[Fact]
44+
public async Task Cargo_without_marker_reports_none()
45+
{
46+
var (clock, loc) = Deps();
47+
var state = Substitute.For<IEventState>();
48+
state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns([]);
49+
var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None);
50+
Assert.Equal("No cargo ship on the map.", reply);
51+
}
52+
53+
[Fact]
54+
public async Task Events_lists_recent_or_reports_none()
55+
{
56+
var (_, loc) = Deps();
57+
var state = Substitute.For<IEventState>();
58+
state.GetRecentEvents(Guild, Server).Returns([]);
59+
Assert.Equal("No recent events.",
60+
await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None));
61+
62+
state.GetRecentEvents(Guild, Server).Returns(
63+
[new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, new MapDimensions(4000u, 4000u, 500), Now)]);
64+
var reply = await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None);
65+
Assert.Contains("Recent:", reply, StringComparison.Ordinal);
66+
}
67+
68+
[Fact]
69+
public async Task Handlers_expose_expected_names()
70+
{
71+
var (clock, loc) = Deps();
72+
var state = Substitute.For<IEventState>();
73+
Assert.Equal("cargo", new CargoCommandHandler(state, loc, clock).Name);
74+
Assert.Equal("heli", new HeliCommandHandler(state, loc, clock).Name);
75+
Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock).Name);
76+
Assert.Equal("events", new EventsCommandHandler(state, loc).Name);
77+
}
78+
}

0 commit comments

Comments
 (0)