Subsystem 3b: in-game !command framework (thin slice) - #9
Conversation
Create RustPlusBot.Features.Commands (refs Abstractions, Persistence, Connections) and its test project; implement CommandOptions with DefaultPrefix and Cooldown defaults; wire both projects into the solution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ttings migration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements Task 8: MuteCommandHandler and UnmuteCommandHandler (internal sealed, primary-constructor), each calling IMuteStore.SetMutedAsync and returning a localized confirmation. Tests written first (TDD); 2 new + 19 prior = 21 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….server key Final-review cleanup: DefaultPrefix was bound/validated but never read (the per-server prefix lives in ServerCommandSettings with a "!" fallback in the store), and command.uptime.ok.server was an unused localization key.
There was a problem hiding this comment.
Pull request overview
Adds a new in-game !command framework that consumes TeamMessageReceivedEvent and dispatches parsed commands with localized replies, plus new persistence for per-server mute/prefix settings and a new live-query seam for server info/time.
Changes:
- Introduces
RustPlusBot.Features.Commands(dispatcher, cooldown, hosted service, localization, and 6 thin-slice commands). - Adds
IRustServerQuery+ DTO snapshots and implements the query seam inConnectionSupervisor. - Persists per-server command settings (mute + prefix) via EF (
ServerCommandSettings, migration,IMuteStore/MuteStore) and gates Discord→game relay on mute.
Reviewed changes
Copilot reviewed 56 out of 57 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/RustPlusBot.Persistence.Tests/Commands/ServerCommandSettingsSchemaTests.cs | Verifies EF schema + cascade behavior for command settings. |
| tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs | Covers MuteStore defaults and mute persistence behavior. |
| tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs | Exercises new IRustServerQuery behavior via ConnectionSupervisor harness. |
| tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs | Extends fake connection to return server info/time snapshots. |
| tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj | Adds new Commands test project wiring. |
| tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs | Tests localization fallback/formatting/normalization. |
| tests/RustPlusBot.Features.Commands.Tests/Hosting/BotUptimeTests.cs | Tests uptime baseline behavior. |
| tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs | Tests handlers using IRustServerQuery (pop/time/wipe/uptime). |
| tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs | Tests mute/unmute handlers + localization. |
| tests/RustPlusBot.Features.Commands.Tests/Formatting/DurationFormatTests.cs | Tests compact duration formatting. |
| tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandLineTests.cs | Tests command line parsing and custom prefix behavior. |
| tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandDispatcherTests.cs | Tests dispatcher behavior incl. mute gate + cooldown + prefix. |
| tests/RustPlusBot.Features.Commands.Tests/Dispatching/CommandCooldownTests.cs | Tests cooldown window semantics. |
| tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs | Ensures DI registrations resolve and expected handlers are present. |
| tests/RustPlusBot.Features.Commands.Tests/CommandOptionsTests.cs | Tests default command options values. |
| tests/RustPlusBot.Features.Chat.Tests/TeamChatInboundProcessorTests.cs | Updates inbound relay tests to include mute gate via scoped IMuteStore. |
| tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs | Registers scoped IMuteStore in test DI to validate scope correctness. |
| src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs | Registers IMuteStore in persistence DI. |
| src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs | Updates model snapshot for ServerCommandSettings. |
| src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.Designer.cs | Adds migration designer for command settings table. |
| src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.cs | Adds migration creating ServerCommandSettings table. |
| src/RustPlusBot.Persistence/Configurations/ServerCommandSettingsConfiguration.cs | EF configuration for command settings entity + cascade FK. |
| src/RustPlusBot.Persistence/Commands/MuteStore.cs | Implements EF-backed mute/prefix reads and mute persistence. |
| src/RustPlusBot.Persistence/Commands/IMuteStore.cs | Defines persistence seam for mute/prefix reads and mute writes. |
| src/RustPlusBot.Persistence/BotDbContext.cs | Adds DbSet + configuration application for command settings. |
| src/RustPlusBot.Host/RustPlusBot.Host.csproj | References Commands feature from the host. |
| src/RustPlusBot.Host/Program.cs | Binds/validates CommandOptions and registers Commands feature. |
| src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs | Implements IRustServerQuery to expose live server info/time snapshots. |
| src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs | Introduces time snapshot DTO decoupled from RustPlusApi types. |
| src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs | Introduces server info snapshot DTO decoupled from RustPlusApi types. |
| src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs | Implements new query calls against RustPlusApi and maps to snapshots. |
| src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs | Adds new read-side live query interface. |
| src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs | Extends socket connection seam with query methods. |
| src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs | Registers IRustServerQuery backed by ConnectionSupervisor. |
| src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj | Adds Commands feature project with test visibility. |
| src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs | Adds commands localizer abstraction. |
| src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs | Implements command localization with EN fallback + normalization. |
| src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs | Adds built-in EN/FR string catalog. |
| src/RustPlusBot.Features.Commands/Hosting/CommandsHostedService.cs | Adds hosted service consuming team-message events and dispatching commands. |
| src/RustPlusBot.Features.Commands/Hosting/BotUptime.cs | Adds uptime baseline singleton for !uptime. |
| src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs | Adds !wipe handler. |
| src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs | Adds !uptime handler. |
| src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs | Adds !unmute handler. |
| src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs | Adds !time handler. |
| src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs | Adds !pop handler. |
| src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs | Adds !mute handler. |
| src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs | Adds compact duration formatter used by replies. |
| src/RustPlusBot.Features.Commands/Dispatching/ICommandHandler.cs | Defines handler contract used by dispatcher. |
| src/RustPlusBot.Features.Commands/Dispatching/CommandLine.cs | Adds command parsing for prefix + args. |
| src/RustPlusBot.Features.Commands/Dispatching/CommandDispatcher.cs | Adds dispatcher (parse/lookup/mute gate/cooldown/reply). |
| src/RustPlusBot.Features.Commands/Dispatching/CommandCooldown.cs | Adds per-(server,command) in-memory cooldown. |
| src/RustPlusBot.Features.Commands/Dispatching/CommandContext.cs | Adds invocation context passed to handlers. |
| src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs | Registers Commands DI graph (handlers, dispatcher, hosted service, etc.). |
| src/RustPlusBot.Features.Commands/CommandOptions.cs | Adds configurable cooldown options. |
| src/RustPlusBot.Features.Chat/Inbound/TeamChatInboundProcessor.cs | Gates Discord→game relay on persisted mute state via scoped store. |
| src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs | Adds domain entity for persisted command settings. |
| RustPlusBot.slnx | Adds Commands feature and test project to solution. |
Files not reviewed (1)
- src/RustPlusBot.Persistence/Migrations/20260616175850_CommandSettings.Designer.cs: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public bool TryConsume(Guid serverId, string name) | ||
| { | ||
| var now = clock.UtcNow; | ||
| var key = (serverId, name); | ||
| var allowed = true; | ||
| _last.AddOrUpdate( | ||
| key, | ||
| now, | ||
| (_, previous) => | ||
| { | ||
| if (now - previous < _cooldown) | ||
| { | ||
| allowed = false; | ||
| return previous; | ||
| } | ||
|
|
||
| return now; | ||
| }); | ||
| return allowed; | ||
| } | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in 0615e22. Replaced the AddOrUpdate + closure-flag pattern with a lock-free TryGetValue + TryAdd/TryUpdate compare-and-swap, so exactly one caller wins the write at window-expiry. Added a parallel test (ConcurrentCallers_ForSameKey_LetExactlyOneThrough, 64 threads) that asserts a single true.
| /// <summary>Gets the command prefix ("!" when unset).</summary> | ||
| /// <param name="guildId">Owning Discord guild snowflake.</param> | ||
| /// <param name="serverId">The Rust server id.</param> | ||
| /// <param name="cancellationToken">A cancellation token.</param> | ||
| /// <returns>The trigger prefix, or "!" when unset.</returns> |
There was a problem hiding this comment.
Correct — this slice ships the prefix as read-only: it's stored per (guild, server) on ServerCommandSettings.Prefix (default !) and read by the dispatcher, but there's no runtime setter/UI to change it yet. That write surface (a #settings control) is deferred to a later slice. I've updated the PR description to say so explicitly rather than imply it's runtime-configurable today.
The closure-captured 'allowed' flag inside ConcurrentDictionary.AddOrUpdate's updateFactory was not an atomic gate: the factory can run concurrently/retry, so two threads racing at window-expiry could both observe the same 'previous' and both return true. Replace with a lock-free TryGetValue + TryAdd/TryUpdate CAS so exactly one caller wins. Addresses Copilot review on PR #9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Adds the in-game
!commandframework — the engine that turns a!-prefixed line in in-game team chat into a parsed, authorized, rate-limited command whose localized reply is sent back to team chat — proven with a thin slice of six commands. NewRustPlusBot.Features.Commandsproject (mirrors Pairing/Connections/Chat), consuming the existingTeamMessageReceivedEventfrom the bus alongside the chat relay.Commands shipped:
!mute,!unmute,!uptime,!pop,!time,!wipe.Behavior
!mutesilences all bot→game output — command replies and the Discord→game relay — persisted per(guild, server);!mute/!unmutestay runnable while muted.(server, command)in-memory cooldown; EN/FR replies localized by guild culture.(guild, server)(entityServerCommandSettings.Prefix, default!) and read by the dispatcher viaIMuteStore.GetPrefixAsync. This slice is read-only: there is no runtime setter/UI to change the prefix yet — a#settingscontrol to edit it is deferred to a later slice. So today every server uses the!default.Two new public seams
IRustServerQuery(read side of the live socket, implemented byConnectionSupervisor, returning our ownServerInfoSnapshot/ServerTimeSnapshotDTOs so RustPlusApi types don't leak).IMuteStore+ServerCommandSettingsentity (FK→RustServercascade,CommandSettingsmigration), shared withFeatures.Chat, which gates its relay on mute.Deferred: team-intel commands (
!online/!team/!afk/…, → 3b-ii), history +!leader(need a team-event tracker), Discord surfaces (#commands,/help,/uptime, → 3c), and the prefix-edit settings surface (the store + column exist; the write path is later).Test Plan
dotnet build -warnaserror→ 0 warnings / 0 errorsjb cleanupcode --profile=ReformatAndReorderclean (pre-push gate passes)has-pending-model-changes)validateScopes: true) — no captive dependency (singleton hosted service + inbound processor open per-event/-message scopes for scoped stores)CommandCooldown.TryConsumeatomic under concurrency (lock-free CAS + parallel test)!pop/!time/!wipe/!uptimein-game and confirm replies;!mutethen confirm both command replies and Discord→game relay go silent;!unmuterestores both🤖 Generated with Claude Code