From 7f96de21168291c436308478afd6d98547d3759f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 12:41:55 +0200 Subject: [PATCH 01/12] docs(3c): spec for command Discord surfaces (/help, /uptime, /leader, #info team summary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subsystem 3c — third slice of subsystem 3. Surfaces the in-game command framework into Discord: /help (in-game + slash command listing), /uptime (bot process), /leader (member-select promote), and a team summary field on the per-server #info embed. Slash modules + help catalog live in the existing Features.Commands project (Approach A); the #info edit is local to Workspace's ServerInfoMessageRenderer. The #commands channel relay is deferred to 3c-ii. Co-Authored-By: Claude Opus 4.8 --- ...-rustplusbot-3c-command-surfaces-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md diff --git a/docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md b/docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md new file mode 100644 index 00000000..fa18b550 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md @@ -0,0 +1,215 @@ +# RustPlusBot 3c — Command Discord Surfaces — Design + +**Date:** 2026-06-17 +**Subsystem:** 3c (third slice of subsystem 3: chat + `!commands`) +**Predecessors:** 3a chat bridge (PR #8, `8132240`), 3b command framework (PR #9, `240a349`), 3b-ii team-intel (PR #10, `70a92c7`). +**Branch to use:** `feat/command-surfaces` off `develop`. + +## Summary + +3c surfaces the in-game command framework into Discord and adds three slash +commands. It ships **four** of the five surfaces the catalog tagged to 3c: + +- **`/help`** — ephemeral embed listing the in-game `!commands` (grouped, with the + server's prefix) and the slash commands; per-guild culture (EN/FR). +- **`/uptime`** — ephemeral bot-process uptime. +- **`/leader`** — transfers in-game team leadership: opens a select of live team + members and promotes the chosen one (ManageGuild-gated). +- **`#info` team summary** — a "Online N/M · leader X" field added to the existing + per-server `#info` embed, shown only while Connected. + +**Explicitly deferred to a future 3c-ii:** the **`#commands` channel** (a +per-server Discord channel that relays typed messages into the in-game command +pipeline). It needs a new channel spec plus a Discord→in-game routing path through +the dispatcher, and a second privileged-intent message listener alongside +`#teamchat`; it is the heaviest piece and is cleanly separable. **`#activity`** +log remains out of subsystem 3 (3/4 in the catalog). **Per-connection uptime** is +deferred (no "connected since" is stored today). + +## Decisions (locked in brainstorming) + +| Topic | Decision | +| --- | --- | +| Scope this slice | `/help`, `/uptime`, `/leader`, `#info` team summary. `#commands` channel → 3c-ii. | +| Where new code lives | **Approach A** — slash modules + help catalog in the existing `Features.Commands` project; `#info` edit local to Workspace's renderer. No new project. | +| `/leader` target selection | Pick a teammate from a select menu populated by `GetTeamInfoAsync`; promote via `PromoteToLeaderAsync(steamId)`. ManageGuild-gated. | +| `#info` summary content | Online/total + leader name; rendered only when `Status == Connected`; refreshed on the **existing** triggers (`ConnectionStatusChangedEvent` / reconcile) — **no new polling timer**. | +| `/help` content | In-game `!commands` (grouped, with prefix) **and** the slash commands; EN/FR. | +| `/help` metadata source | A curated **`CommandHelpCatalog`** (name + group + localized description key), unit-tested against the live `ICommandHandler` registry to catch drift. | +| Server targeting (slash) | Auto-use the single server if a guild has exactly one; if multiple → `/leader` shows a server picker first, `/help` falls back to default `!` with a one-line note. | +| Localization | Per-guild culture (EN/FR), via the existing `CommandLocalizer` (Commands surfaces) and Workspace `Localizer` (the `#info` field). | + +## Architecture + +### New in `Features.Commands` + +- **`Modules/CommandSurfaceModule.cs`** — `InteractionModuleBase` + hosting `/help`, `/uptime`, `/leader`. Resolves scoped services through + `IServiceScopeFactory` per interaction (the repo's module idiom, per + `SetupModule`/`SettingsComponentModule`). +- **`Modules/LeaderComponentModule.cs`** — wildcard component module handling the + `/leader` select callbacks. Custom-id prefixes: + - `leader-server:` — server-pick step (only used in multi-server guilds), value = serverId. + - `leader-promote:{serverId}` — member-pick step, value = target SteamId. + ManageGuild is **re-checked** on the component callback (Discord does not re-gate + components). +- **`Help/CommandHelpCatalog.cs`** — a curated manifest: ordered entries of + `(string Name, CommandGroup Group, string DescriptionKey)`. `CommandGroup` is an + enum `Control | Server | TeamIntel | Bot`. Plus a small `HelpEmbedRenderer` (pure, + testable) that builds the embed from `(catalog, prefix, culture, multiServerNote)`. +- **`CommandLocalizationCatalog`** gains EN/FR keys: per-command help descriptions, + group headings, `/help` title + multi-server prefix note, `/uptime` reply, + `/leader` not-connected / no-server / empty-team / success / failure replies. +- **`CommandServiceCollectionExtensions`** registers a new + `InteractionModuleAssembly(thisAssembly)` singleton so `DiscordBotService` + discovers the modules (mirrors Pairing/Connections/Workspace). + +### Extended seam (Connections) + +- **`IRustServerQuery.PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken)`** + → `Task` (true = promoted; false = no live socket or API non-success). +- **`IRustServerConnection.PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken)`** + → `Task` (internal seam). +- **`ConnectionSupervisor`** implements the query method over `_liveSockets` + (mirrors `GetTeamInfoAsync`: `TryGetValue` → delegate with `HeartbeatTimeout` → + false when no socket). +- **`RustPlusSocketSource`** (the one untested integration shim) wraps + `RustPlus.PromoteToLeaderAsync(ulong, CancellationToken)` → payload-free + `Response` → `response.IsSuccess`. Broad-catch → false; never surface a + token/secret. **VERIFIED against the 2.0.0-beta.1 DLL XML:** + `M:RustPlusApi.RustPlus.PromoteToLeaderAsync(System.UInt64,System.Threading.CancellationToken)` + returns a payload-free `RustPlusApi.Data.Response` with `.IsSuccess`. +- **`FakeRustSocketSource`** (test double) gains `PromoteToLeaderAsync` or the + Connections suite silently drops tests (the 3a `FakeWorkspaceStore` / 3b-ii lesson: + always run the full suite and read per-assembly counts). + +### `/leader` promotion logic (testable) + +The promote decision is extracted into an internal `LeaderService` (or equivalent) +in `Features.Commands`, taking `(guildId, serverId, steamId, culture)` and the +`IRustServerQuery`, returning a localized result + outcome — so the +fetch-members / promote logic is covered at the service level. The interaction +modules stay thin (untested — repo convention: `InteractionModuleBase` types are +not unit-tested here). + +### Edited (Workspace) + +- **`ServerInfoMessageRenderer`** — when `status == ConnectionStatus.Connected`, + call `IRustServerQuery.GetTeamInfoAsync`. On a non-null snapshot add a field + "Online: {online}/{total} · leader {leaderName}". `online` = members where + `IsOnline`; `total` = member count; leader name = the member whose `SteamId == + LeaderSteamId`, falling back to the SteamId string if absent. On a null snapshot + (race: status Connected but the query fails) **omit the field silently**. + (Workspace already references `Connections` and depends on `IConnectionStore`; + it gains an `IRustServerQuery` dependency on the renderer.) +- **Workspace `LocalizationCatalog`** gains EN/FR keys: the team field label and the + "leader" word / `online of total` format. + +### Dependency direction + +`Features.Commands` already references `Connections` + `Workspace`; Workspace +already references `Connections`. Nothing new is inverted. No new project. + +### Not added + +No new entity, migration, event, options, or background service. + +## Per-surface behavior + +### `/help` (ephemeral; any guild member) + +1. Resolve guild culture (`IWorkspaceStore.GetCultureAsync`). +2. Resolve target prefix: if the guild has exactly one registered server, read that + server's prefix (`IMuteStore.GetPrefixAsync`). If multiple servers, use the + default `!` and include a one-line note that prefixes are configured per server. + If zero servers, use the default `!` with no note (nothing to disambiguate). +3. Render an embed via `HelpEmbedRenderer`: one group per `CommandGroup` + (Control / Server / Team intel / Bot) each listing + `{prefix}{name} — {localized description}`, plus a "Slash commands" group for + `/help`, `/uptime`, `/leader`. + +### `/uptime` (ephemeral) + +Reports `BotUptime.Elapsed` via `DurationFormat.Compact`, localized. (No +per-connection uptime this slice.) + +### `/leader` (ManageGuild; defers ephemeral) + +1. Determine target server: + - 0 servers → ephemeral "No server is set up yet." + - 1 server → use it. + - >1 servers → respond with a **server select** (`leader-server:`); on pick, + proceed as if that server were chosen. +2. For the chosen server, call `IRustServerQuery.GetTeamInfoAsync`. + - No live socket → ephemeral "Not connected to this server." + - Empty team → ephemeral "No team members to promote." +3. Respond with a **member select** (`leader-promote:{serverId}`): options labeled by + member name, value = SteamId, capped at `SelectMenuBuilder.MaxOptionCount` (25); + the current leader is shown marked as default. +4. `LeaderComponentModule` handles the member pick → re-check ManageGuild → + `IRustServerQuery.PromoteToLeaderAsync(guild, server, steamId)`: + - true → ephemeral success (member name). + - false → ephemeral "Couldn't promote — the server may be unreachable." + +### `#info` team summary + +In `ServerInfoMessageRenderer`, when `Status == Connected`, fetch `GetTeamInfoAsync` +and add the team field as described above; omit on null. Refreshed by the existing +`#info` triggers only. + +## Error handling & edge cases + +- **No live socket** (`/leader`, `#info` field): query returns null / false → + `/leader` ephemeral "Not connected"; `#info` omits the field. +- **No registered servers** (`/leader`): ephemeral "No server is set up yet." +- **Empty team** (`/leader`): "No team members to promote." +- **Promote fails** (socket dropped between select and click, or API non-success): + ephemeral "Couldn't promote — the server may be unreachable." +- **Stale component interaction**: re-resolve live data on the callback; if the + socket is gone, the same not-connected path applies. ManageGuild re-checked. +- **Secrets:** no tokens in any reply or log (existing rule; `RustPlusSocketSource` + broad-catches to false rather than throwing with any value). +- **Fault isolation:** module exceptions are handled by the existing global Discord + interaction error handling; nothing crashes the gateway. + +## Testing + +- **`CommandHelpCatalog` drift guard:** every registered `ICommandHandler.Name` has a + catalog entry, and every entry's `DescriptionKey` resolves in both EN and FR. +- **`HelpEmbedRenderer`:** pure render from `(catalog, prefix, culture)` → assert + grouping order, prefix substitution, the slash-commands group, and the + multi-server note path. +- **`LeaderService`:** covers no-socket / empty-team / promote-success / + promote-failure against a fake `IRustServerQuery`. +- **`ConnectionSupervisor.PromoteToLeaderAsync`:** tested via `FakeRustSocketSource` + (add the new method to the fake). `RustPlusSocketSource` mapping is the one + untested integration shim, by design. +- **`ServerInfoMessageRenderer`:** add cases for connected-with-team, + connected-but-query-null (field omitted), and not-connected (field omitted). +- Interaction modules (`CommandSurfaceModule`, `LeaderComponentModule`) stay thin and + untested (repo convention). + +## Out of scope (this slice) + +- `#commands` channel + Discord→in-game command relay → **3c-ii**. +- `#activity` log → subsystem 3/4. +- Per-connection uptime (needs a "connected since" on `ConnectionState`). +- `!afk` poller slice (separate deferred work from 3b-ii). +- Role/permission gating of commands → subsystem 9. + +## Execution notes / gotchas (carry-forward) + +- Run the **full** test suite and read per-assembly counts after touching shared + seams (`IRustServerQuery`, `FakeRustSocketSource`) — a missing fake method silently + drops a whole assembly's tests. +- NSubstitute on internal interfaces needs `DynamicProxyGenAssembly2` + `InternalsVisibleTo` in the project under test (already present in Connections; + add to Commands if mocking an internal there). +- `dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` (ReSharper) is + the real format gate — run it (after `dotnet tool restore`) before pushing; the + pre-push hook enforces it and it reorders members Roslynator never flags. +- `Discord.ConnectionState` collides with `Domain.Connections.ConnectionState` — alias + if both are named in one file. +- `SelectMenuBuilder` hard-caps at 25 options — `.Take(SelectMenuBuilder.MaxOptionCount)` + the member list (as `#info`'s swap select already does). +- Roslynator: `// TODO` comments are errors (`S1135`); use `` instead. From b127f65bfe5a2f27c60d49977fd5fa241f173559 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 12:51:50 +0200 Subject: [PATCH 02/12] docs(3c): implementation plan for command Discord surfaces 7-task subagent-driven TDD plan: PromoteToLeaderAsync seam, CommandHelpCatalog + drift guard, HelpEmbedRenderer, LeaderService, the /help+/uptime+/leader modules, and the #info team summary field. Verified signatures against the codebase (IRustServerQuery, ServerInfoMessageRenderer, FakeRustSocketSource, RustPlusApi PromoteToLeaderAsync) and the Discord project/package ref gap. Co-Authored-By: Claude Opus 4.8 --- ...6-06-17-rustplusbot-3c-command-surfaces.md | 1507 +++++++++++++++++ 1 file changed, 1507 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md diff --git a/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md b/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md new file mode 100644 index 00000000..9afc59a5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md @@ -0,0 +1,1507 @@ +# RustPlusBot 3c — Command Discord Surfaces — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface the in-game command framework into Discord — `/help`, `/uptime`, `/leader`, and a team summary on the per-server `#info` embed. + +**Architecture:** Slash modules + a help catalog live in the existing `RustPlusBot.Features.Commands` project (Approach A); they reuse `BotUptime`, `ICommandLocalizer`, the `ICommandHandler` registry, and `IRustServerQuery`. `/leader` adds a `PromoteToLeaderAsync` method to the `IRustServerQuery`/`IRustServerConnection` seam (supervisor + untested `RustPlusSocketSource` shim). The `#info` team summary is a local edit to Workspace's `ServerInfoMessageRenderer`. No new project, entity, migration, event, options, or background service. + +**Tech Stack:** .NET 10, C# 13, Discord.Net 3.20 (`Discord.Interactions`), RustPlusApi 2.0.0-beta.1, xUnit + NSubstitute, EF Core/SQLite (test harness only), strict Roslynator analyzers, ReSharper `jb cleanupcode` format gate. + +## Global Constraints + +- **Branch:** `feat/command-surfaces` off `develop`. +- **No secrets in replies or logs** — `PromoteToLeaderAsync` broad-catches to `false`; never throw with a token value. +- **Localization:** all user-facing copy is EN + FR via the existing per-feature catalogs (`CommandLocalizationCatalog` for Commands; `LocalizationCatalog` for Workspace). Ephemeral confirm/result text is acceptable in English only where it mirrors prior slices' convention, but reply embeds (`/help`, `/uptime`, `/leader`, `#info` field) ARE localized. +- **Interaction modules (`InteractionModuleBase`) are NOT unit-tested** — push logic into testable services/renderers; keep modules thin. +- **Run the FULL test suite and read per-assembly counts** after touching shared seams (`IRustServerQuery`, `FakeRustSocketSource`, `IRustServerConnection`) — a missing fake method silently drops a whole assembly's tests. +- **NSubstitute on internal interfaces** needs `` in the project under test (Connections already has it; check Commands). +- **`SelectMenuBuilder` hard-caps at 25 options** — `.Take(SelectMenuBuilder.MaxOptionCount)` any member/server list. +- **`Discord.ConnectionState` collides** with `Domain.Connections.ConnectionState` — alias (`using DomainConnectionState = …`) if both are named in one file. +- **Roslynator:** `// TODO` comments are errors (`S1135`) — use ``. An interface impl must repeat `= default` on a `CancellationToken` (`S1006`). Prefer concrete `Dictionary<>` where `CA1859` flags an interface field. +- **Before pushing:** `dotnet tool restore` then `dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` (the real format gate; the pre-push hook enforces it). It reorders members Roslynator never flags. +- **Build must be 0 warnings / 0 errors** under strict analyzers; **no EF model drift** (this slice adds none). + +--- + +## File Structure + +**Connections (extend the existing seam):** +- Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` — add `PromoteToLeaderAsync`. +- Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` — add `PromoteToLeaderAsync`. +- Modify `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` — implement the query method over `_liveSockets`. +- Modify `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` — implement on both `RejectedConnection` (returns false) and `RustPlusServerConnection` (wraps the real API). +- Modify `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` — add the method to `FakeConnection`. +- Modify `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` — add promote tests. + +**Commands (new surfaces):** +- Create `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs` — the grouping enum. +- Create `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` — curated manifest. +- Create `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs` — pure embed builder. +- Create `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs` — testable promote/fetch logic. +- Create `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs` — `/help`, `/uptime`, `/leader`. +- Create `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs` — select callbacks. +- Modify `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` — new EN/FR keys. +- Modify `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` — register catalog, renderer, service, module assembly. +- Modify `src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj` — add a `RustPlusBot.Discord` project reference and a `Discord.Net` package reference (needed for `InteractionModuleBase`/`SocketInteractionContext`/`InteractionModuleAssembly`). The `DynamicProxyGenAssembly2` + test-project InternalsVisibleTo are already present — leave them. +- Create test files under `tests/RustPlusBot.Features.Commands.Tests/Help/` and `…/Leader/`. +- Modify `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` — assert new registrations resolve. + +**Workspace (`#info` summary):** +- Modify `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` — add the team field. +- Modify `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs` — new EN/FR keys. +- Modify `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` — new ctor arg + team-field cases. + +--- + +## Task 1: `PromoteToLeaderAsync` on the connection seam + +**Files:** +- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` +- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` +- Modify: `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` +- Modify: `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` +- Modify: `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` +- Test: `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` + +**Interfaces:** +- Produces: + - `IRustServerQuery.PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken)` → `Task` (true = promoted; false = no live socket or API non-success). + - `IRustServerConnection.PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken)` → `Task`. + +- [ ] **Step 1: Add the fake method (so the suite still builds) and write the failing supervisor tests** + +In `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs`, add to `FakeConnection` (after `GetTeamInfoAsync`): + +```csharp +/// The result returned by . Defaults to true. +public bool PromoteResult { get; set; } = true; + +/// The Steam ID passed to the most recent call. +public ulong LastPromotedSteamId { get; private set; } + +public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) +{ + LastPromotedSteamId = steamId; + return Task.FromResult(PromoteResult); +} +``` + +In `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs`, add (mirrors `GetTeamInfo_*`): + +```csharp +[Fact] +public async Task PromoteToLeader_ReturnsTrueAndForwardsSteamId_WhenConnected() +{ + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + source.LastConnection!.PromoteResult = true; + var promoted = await supervisor.PromoteToLeaderAsync(10UL, serverId, 999UL, cts.Token); + + Assert.True(promoted); + Assert.Equal(999UL, source.LastConnection.LastPromotedSteamId); + await supervisor.StopAllAsync(); +} + +[Fact] +public async Task PromoteToLeader_ReturnsFalse_WhenApiNonSuccess() +{ + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + source.LastConnection!.PromoteResult = false; + var promoted = await supervisor.PromoteToLeaderAsync(10UL, serverId, 999UL, cts.Token); + + Assert.False(promoted); + await supervisor.StopAllAsync(); +} + +[Fact] +public async Task PromoteToLeader_ReturnsFalse_WhenNoLiveSocket() +{ + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + + var promoted = await supervisor.PromoteToLeaderAsync(10UL, Guid.NewGuid(), 999UL, CancellationToken.None); + + Assert.False(promoted); +} +``` + +- [ ] **Step 2: Run the new tests to verify they fail to compile / fail** + +Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests --filter "FullyQualifiedName~PromoteToLeader"` +Expected: COMPILE ERROR — `IRustServerConnection`/`IRustServerQuery`/`ConnectionSupervisor` have no `PromoteToLeaderAsync`. + +- [ ] **Step 3: Add `PromoteToLeaderAsync` to `IRustServerConnection`** + +In `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs`, after `SendTeamMessageAsync`: + +```csharp +/// Promotes a team member to team leader; returns true on success. +/// Steam64 id of the member to promote. +/// How long to wait for the response. +/// A cancellation token. +/// True if the promotion succeeded; false on failure/timeout. +Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken); +``` + +- [ ] **Step 4: Add `PromoteToLeaderAsync` to `IRustServerQuery`** + +In `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs`, after `GetTeamInfoAsync`: + +```csharp +/// Promotes a team member to team leader; returns false when there is no live socket or the API fails. +/// The owning guild snowflake. +/// The target server id. +/// Steam64 id of the member to promote. +/// A cancellation token. +/// True if promoted; false when no live socket or the API fails. +Task PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken); +``` + +- [ ] **Step 5: Implement on `ConnectionSupervisor`** + +In `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs`, after `GetTeamInfoAsync` (before `SendAsync`): + +```csharp +/// +public async Task PromoteToLeaderAsync( + ulong guildId, + Guid serverId, + ulong steamId, + CancellationToken cancellationToken) +{ + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return false; + } + + return await live.Connection.PromoteToLeaderAsync(steamId, _options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); +} +``` + +- [ ] **Step 6: Implement on the `RejectedConnection` and `RustPlusServerConnection` shim classes** + +In `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs`, add to `RejectedConnection` (after `SendTeamMessageAsync`): + +```csharp +public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(false); +``` + +And add to `RustPlusServerConnection` (after `SendTeamMessageAsync`, before `DisposeAsync`): + +```csharp +public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) +{ + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + // CONFIRMED (2.0.0-beta.1): PromoteToLeaderAsync(ulong, CancellationToken) returns a payload-free + // Task; Response.IsSuccess indicates the outcome. + var response = await _rustPlus.PromoteToLeaderAsync(steamId, timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + return response.IsSuccess; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } +#pragma warning disable CA1031 // Broad catch: any promote failure maps to false; never surface a token/secret. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return false; + } +} +``` + +- [ ] **Step 7: Run the new tests to verify they pass** + +Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests --filter "FullyQualifiedName~PromoteToLeader"` +Expected: PASS (3 tests). + +- [ ] **Step 8: Run the FULL Connections suite + confirm count** + +Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests` +Expected: PASS, count = 26 (23 prior + 3 new). If the assembly's total looks low, a fake method is missing — fix before continuing. + +- [ ] **Step 9: Commit** + +```bash +git add src/RustPlusBot.Features.Connections tests/RustPlusBot.Features.Connections.Tests +git commit -m "feat(3c): add PromoteToLeaderAsync to the connection query seam + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: `CommandGroup` + `CommandHelpCatalog` with a drift/i18n guard + +**Files:** +- Create: `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs` +- Create: `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` +- Modify: `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` +- Test: `tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs` + +**Interfaces:** +- Consumes: the existing `ICommandHandler.Name` registry (12 handlers: `mute`, `unmute`, `uptime`, `pop`, `wipe`, `time`, `online`, `offline`, `team`, `steamid`, `alive`, `prox`). +- Produces: + - `enum CommandGroup { Control, Server, TeamIntel, Bot }`. + - `sealed record CommandHelpEntry(string Name, CommandGroup Group, string DescriptionKey)`. + - `static class CommandHelpCatalog` with `IReadOnlyList InGame` and `IReadOnlyList Slash`. + +- [ ] **Step 1: Write the failing catalog tests** + +Create `tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs`: + +```csharp +using RustPlusBot.Features.Commands.Help; +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Help; + +public sealed class CommandHelpCatalogTests +{ + // The 12 registered in-game handler names (see AddCommands / CommandRegistrationTests). + private static readonly string[] HandlerNames = + [ + "mute", "unmute", "uptime", "pop", "wipe", "time", + "online", "offline", "team", "steamid", "alive", "prox", + ]; + + [Fact] + public void EveryRegisteredHandlerHasAnInGameEntry() + { + var entryNames = CommandHelpCatalog.InGame.Select(e => e.Name).ToHashSet(StringComparer.Ordinal); + foreach (var name in HandlerNames) + { + Assert.Contains(name, entryNames); + } + } + + [Fact] + public void InGameCatalogHasNoEntryWithoutAHandler() + { + var handlerNames = HandlerNames.ToHashSet(StringComparer.Ordinal); + foreach (var entry in CommandHelpCatalog.InGame) + { + Assert.Contains(entry.Name, handlerNames); + } + } + + [Fact] + public void EveryDescriptionKeyResolvesInEnglishAndFrench() + { + var loc = new CommandLocalizer(CommandLocalizationCatalog.Default); + foreach (var entry in CommandHelpCatalog.InGame.Concat(CommandHelpCatalog.Slash)) + { + // Get returns the key itself when unresolved; a resolved value must differ from the key. + Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "en")); + Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "fr")); + } + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~CommandHelpCatalogTests"` +Expected: COMPILE ERROR — `CommandHelpCatalog`/`CommandGroup` do not exist. + +- [ ] **Step 3: Create `CommandGroup`** + +Create `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs`: + +```csharp +namespace RustPlusBot.Features.Commands.Help; + +/// The display grouping for a command in the /help listing. +internal enum CommandGroup +{ + /// Bot control commands (mute/unmute). + Control, + + /// Server-state commands (pop/time/wipe). + Server, + + /// Team-intel commands (online/offline/team/steamid/alive/prox). + TeamIntel, + + /// Bot-meta commands (uptime). + Bot, +} +``` + +- [ ] **Step 4: Create `CommandHelpCatalog`** + +Create `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs`: + +```csharp +namespace RustPlusBot.Features.Commands.Help; + +/// One entry in the help listing. +/// The command name without prefix (in-game) or slash command name. +/// The display group. +/// The localization key for the description. +internal sealed record CommandHelpEntry(string Name, CommandGroup Group, string DescriptionKey); + +/// The curated help manifest. Tested against the live handler registry to catch drift. +internal static class CommandHelpCatalog +{ + /// The in-game !commands, in display order. + public static IReadOnlyList InGame { get; } = + [ + new("mute", CommandGroup.Control, "help.mute"), + new("unmute", CommandGroup.Control, "help.unmute"), + new("pop", CommandGroup.Server, "help.pop"), + new("time", CommandGroup.Server, "help.time"), + new("wipe", CommandGroup.Server, "help.wipe"), + new("online", CommandGroup.TeamIntel, "help.online"), + new("offline", CommandGroup.TeamIntel, "help.offline"), + new("team", CommandGroup.TeamIntel, "help.team"), + new("steamid", CommandGroup.TeamIntel, "help.steamid"), + new("alive", CommandGroup.TeamIntel, "help.alive"), + new("prox", CommandGroup.TeamIntel, "help.prox"), + new("uptime", CommandGroup.Bot, "help.uptime"), + ]; + + /// The Discord slash commands, in display order. + public static IReadOnlyList Slash { get; } = + [ + new("help", CommandGroup.Bot, "help.slash.help"), + new("uptime", CommandGroup.Bot, "help.slash.uptime"), + new("leader", CommandGroup.Bot, "help.slash.leader"), + ]; +} +``` + +- [ ] **Step 5: Add the localization keys** + +In `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs`, add to the `["en"]` dictionary (after `command.prox.alone`): + +```csharp +["help.title"] = "Commands", +["help.group.control"] = "Control", +["help.group.server"] = "Server", +["help.group.teamintel"] = "Team", +["help.group.bot"] = "Bot", +["help.group.slash"] = "Slash commands", +["help.prefixnote"] = "Prefixes are configured per server; showing the default.", +["help.mute"] = "Silence all bot output to the game.", +["help.unmute"] = "Resume bot output to the game.", +["help.pop"] = "Show the server population.", +["help.time"] = "Show the in-game time.", +["help.wipe"] = "Show how long ago the server wiped.", +["help.online"] = "List online teammates.", +["help.offline"] = "List offline teammates.", +["help.team"] = "List all team members.", +["help.steamid"] = "Show teammates' Steam IDs.", +["help.alive"] = "Show how long each teammate has survived.", +["help.prox"] = "Show distance to each teammate.", +["help.uptime"] = "Show how long the bot has been running.", +["help.slash.help"] = "Show this help message.", +["help.slash.uptime"] = "Show the bot's uptime.", +["help.slash.leader"] = "Transfer in-game team leadership.", +["uptime.ok"] = "Bot uptime: {0}", +["leader.noserver"] = "No server is set up yet.", +["leader.notconnected"] = "Not connected to this server.", +["leader.noteam"] = "No team members to promote.", +["leader.pickserver"] = "Choose a server:", +["leader.pickmember"] = "Choose who to promote to team leader:", +["leader.success"] = "Promoted {0} to team leader.", +["leader.failure"] = "Couldn't promote — the server may be unreachable.", +``` + +And add the matching `["fr"]` entries (after `command.prox.alone`): + +```csharp +["help.title"] = "Commandes", +["help.group.control"] = "Contrôle", +["help.group.server"] = "Serveur", +["help.group.teamintel"] = "Équipe", +["help.group.bot"] = "Bot", +["help.group.slash"] = "Commandes slash", +["help.prefixnote"] = "Les préfixes sont configurés par serveur ; affichage du préfixe par défaut.", +["help.mute"] = "Couper toute sortie du bot vers le jeu.", +["help.unmute"] = "Réactiver la sortie du bot vers le jeu.", +["help.pop"] = "Afficher la population du serveur.", +["help.time"] = "Afficher l'heure en jeu.", +["help.wipe"] = "Afficher depuis combien de temps le serveur a été wipe.", +["help.online"] = "Lister les coéquipiers en ligne.", +["help.offline"] = "Lister les coéquipiers hors ligne.", +["help.team"] = "Lister tous les membres de l'équipe.", +["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.", +["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.", +["help.prox"] = "Afficher la distance à chaque coéquipier.", +["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.", +["help.slash.help"] = "Afficher ce message d'aide.", +["help.slash.uptime"] = "Afficher la disponibilité du bot.", +["help.slash.leader"] = "Transférer le rôle de chef d'équipe en jeu.", +["uptime.ok"] = "Disponibilité du bot : {0}", +["leader.noserver"] = "Aucun serveur n'est encore configuré.", +["leader.notconnected"] = "Non connecté à ce serveur.", +["leader.noteam"] = "Aucun membre d'équipe à promouvoir.", +["leader.pickserver"] = "Choisissez un serveur :", +["leader.pickmember"] = "Choisissez qui promouvoir chef d'équipe :", +["leader.success"] = "{0} promu chef d'équipe.", +["leader.failure"] = "Impossible de promouvoir — le serveur est peut-être injoignable.", +``` + +- [ ] **Step 6: Run the catalog tests to verify they pass** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~CommandHelpCatalogTests"` +Expected: PASS (3 tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/RustPlusBot.Features.Commands/Help src/RustPlusBot.Features.Commands/Localization tests/RustPlusBot.Features.Commands.Tests/Help +git commit -m "feat(3c): add command help catalog + help/leader/uptime localization keys + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: `HelpEmbedRenderer` (pure) + +**Files:** +- Create: `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs` +- Test: `tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs` + +**Interfaces:** +- Consumes: `CommandHelpCatalog`, `ICommandLocalizer`. +- Produces: `HelpEmbedRenderer(ICommandLocalizer localizer)` with + `Discord.Embed Render(string prefix, string culture, bool showPrefixNote)`. + +- [ ] **Step 1: Write the failing renderer tests** + +Create `tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs`: + +```csharp +using RustPlusBot.Features.Commands.Help; +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Help; + +public sealed class HelpEmbedRendererTests +{ + private static readonly HelpEmbedRenderer Renderer = + new(new CommandLocalizer(CommandLocalizationCatalog.Default)); + + [Fact] + public void Render_PrefixesInGameCommands() + { + var embed = Renderer.Render("$", "en", showPrefixNote: false); + + var body = string.Concat(embed.Fields.Select(f => f.Value)); + Assert.Contains("$pop", body, StringComparison.Ordinal); + Assert.Contains("$online", body, StringComparison.Ordinal); + } + + [Fact] + public void Render_IncludesSlashCommandsGroup() + { + var embed = Renderer.Render("!", "en", showPrefixNote: false); + + var body = string.Concat(embed.Fields.Select(f => f.Value)); + Assert.Contains("/leader", body, StringComparison.Ordinal); + Assert.Contains("/uptime", body, StringComparison.Ordinal); + } + + [Fact] + public void Render_AddsPrefixNote_WhenRequested() + { + var withNote = Renderer.Render("!", "en", showPrefixNote: true); + var withoutNote = Renderer.Render("!", "en", showPrefixNote: false); + + Assert.NotNull(withNote.Description); + Assert.Contains("per server", withNote.Description, StringComparison.OrdinalIgnoreCase); + Assert.True(string.IsNullOrEmpty(withoutNote.Description)); + } + + [Fact] + public void Render_LocalizesToFrench() + { + var embed = Renderer.Render("!", "fr", showPrefixNote: false); + + var body = string.Concat(embed.Fields.Select(f => f.Value)); + Assert.Contains("Afficher la population", body, StringComparison.Ordinal); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~HelpEmbedRendererTests"` +Expected: COMPILE ERROR — `HelpEmbedRenderer` does not exist. + +- [ ] **Step 3: Create `HelpEmbedRenderer`** + +Create `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs`: + +```csharp +using System.Globalization; +using Discord; +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Help; + +/// Builds the /help embed from the catalog, the server prefix, and the guild culture. +/// Resolves the title, group headings, and per-command descriptions. +internal sealed class HelpEmbedRenderer(ICommandLocalizer localizer) +{ + private static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder = + [ + (CommandGroup.Control, "help.group.control"), + (CommandGroup.Server, "help.group.server"), + (CommandGroup.TeamIntel, "help.group.teamintel"), + (CommandGroup.Bot, "help.group.bot"), + ]; + + /// Renders the help embed. + /// The in-game command prefix to display (e.g. "!"). + /// The guild culture ("en"/"fr"). + /// True to add the multi-server prefix note to the description. + /// The built embed. + public Embed Render(string prefix, string culture, bool showPrefixNote) + { + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("help.title", culture)) + .WithColor(Color.Blue); + + if (showPrefixNote) + { + embed.WithDescription(localizer.Get("help.prefixnote", culture)); + } + + foreach (var (group, headingKey) in GroupOrder) + { + var lines = CommandHelpCatalog.InGame + .Where(e => e.Group == group) + .Select(e => string.Create(CultureInfo.InvariantCulture, + $"`{prefix}{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}")) + .ToList(); + if (lines.Count > 0) + { + embed.AddField(localizer.Get(headingKey, culture), string.Join('\n', lines)); + } + } + + var slashLines = CommandHelpCatalog.Slash + .Select(e => string.Create(CultureInfo.InvariantCulture, + $"`/{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}")) + .ToList(); + embed.AddField(localizer.Get("help.group.slash", culture), string.Join('\n', slashLines)); + + return embed.Build(); + } +} +``` + +- [ ] **Step 4: Run the renderer tests to verify they pass** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~HelpEmbedRendererTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs +git commit -m "feat(3c): add HelpEmbedRenderer + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: `LeaderService` (testable promote/fetch logic) + +**Files:** +- Create: `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs` +- Test: `tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs` + +**Interfaces:** +- Consumes: `IRustServerQuery` (`GetTeamInfoAsync`, `PromoteToLeaderAsync`), `ICommandLocalizer`, `TeamInfoSnapshot`/`TeamMemberSnapshot` (from `RustPlusBot.Features.Connections.Listening`). +- Produces: + - `sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader)`. + - `sealed record LeaderTeamResult(IReadOnlyList Members, string? ErrorMessage)` — `ErrorMessage` non-null means stop and show it; otherwise render the select from `Members`. + - `LeaderService(IRustServerQuery query, ICommandLocalizer localizer)` with: + - `Task GetMembersAsync(ulong guildId, Guid serverId, string culture, CancellationToken)`. + - `Task PromoteAsync(ulong guildId, Guid serverId, ulong steamId, string memberName, string culture, CancellationToken)` → the localized result message. + +- [ ] **Step 1: Write the failing service tests** + +Create `tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs`: + +```csharp +using NSubstitute; +using RustPlusBot.Features.Commands.Leader; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Commands.Tests.Leader; + +public sealed class LeaderServiceTests +{ + private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); + private static readonly Guid Server = Guid.NewGuid(); + + [Fact] + public async Task GetMembers_ReturnsError_WhenNotConnected() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1UL, Server, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var service = new LeaderService(query, Loc); + + var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); + + Assert.Empty(result.Members); + Assert.Equal("Not connected to this server.", result.ErrorMessage); + } + + [Fact] + public async Task GetMembers_ReturnsError_WhenTeamEmpty() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1UL, Server, Arg.Any()) + .Returns(new TeamInfoSnapshot(0UL, [])); + var service = new LeaderService(query, Loc); + + var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); + + Assert.Empty(result.Members); + Assert.Equal("No team members to promote.", result.ErrorMessage); + } + + [Fact] + public async Task GetMembers_MapsMembersAndMarksLeader() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1UL, Server, Arg.Any()) + .Returns(new TeamInfoSnapshot( + 10UL, + [ + new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + new TeamMemberSnapshot(20UL, "bob", 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + ])); + var service = new LeaderService(query, Loc); + + var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); + + Assert.Null(result.ErrorMessage); + Assert.Equal(2, result.Members.Count); + Assert.Contains(result.Members, m => m is { SteamId: 10UL, Name: "alice", IsLeader: true }); + Assert.Contains(result.Members, m => m is { SteamId: 20UL, Name: "bob", IsLeader: false }); + } + + [Fact] + public async Task Promote_ReturnsSuccessMessage_WhenPromoted() + { + var query = Substitute.For(); + query.PromoteToLeaderAsync(1UL, Server, 20UL, Arg.Any()).Returns(true); + var service = new LeaderService(query, Loc); + + var message = await service.PromoteAsync(1UL, Server, 20UL, "bob", "en", CancellationToken.None); + + Assert.Equal("Promoted bob to team leader.", message); + } + + [Fact] + public async Task Promote_ReturnsFailureMessage_WhenApiFails() + { + var query = Substitute.For(); + query.PromoteToLeaderAsync(1UL, Server, 20UL, Arg.Any()).Returns(false); + var service = new LeaderService(query, Loc); + + var message = await service.PromoteAsync(1UL, Server, 20UL, "bob", "en", CancellationToken.None); + + Assert.Equal("Couldn't promote — the server may be unreachable.", message); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~LeaderServiceTests"` +Expected: COMPILE ERROR — `LeaderService` does not exist. + +- [ ] **Step 3: Create `LeaderService`** + +Create `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs`: + +```csharp +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Commands.Leader; + +/// One selectable team member for the /leader promote select. +/// The member's Steam64 id. +/// The member's in-game name. +/// True if this member is the current team leader. +internal sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader); + +/// The members to offer, or an error message to show instead. +/// The selectable members (empty when is set). +/// A localized message to show instead of a select, or null to proceed. +internal sealed record LeaderTeamResult(IReadOnlyList Members, string? ErrorMessage); + +/// The testable logic behind /leader: fetch live members, then promote one. +/// The live-server query seam. +/// Resolves the result/error messages. +internal sealed class LeaderService(IRustServerQuery query, ICommandLocalizer localizer) +{ + /// Fetches the current team for the promote select, or an error message. + /// The owning guild snowflake. + /// The target server id. + /// The guild culture. + /// A cancellation token. + /// The members to offer, or an error message. + public async Task GetMembersAsync( + ulong guildId, + Guid serverId, + string culture, + CancellationToken cancellationToken) + { + var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (team is null) + { + return new LeaderTeamResult([], localizer.Get("leader.notconnected", culture)); + } + + if (team.Members.Count == 0) + { + return new LeaderTeamResult([], localizer.Get("leader.noteam", culture)); + } + + var members = team.Members + .Select(m => new LeaderMemberOption(m.SteamId, m.Name, m.SteamId == team.LeaderSteamId)) + .ToList(); + return new LeaderTeamResult(members, null); + } + + /// Promotes a member and returns the localized result message. + /// The owning guild snowflake. + /// The target server id. + /// The Steam64 id to promote. + /// The member's name (for the success message). + /// The guild culture. + /// A cancellation token. + /// The localized result message. + public async Task PromoteAsync( + ulong guildId, + Guid serverId, + ulong steamId, + string memberName, + string culture, + CancellationToken cancellationToken) + { + var promoted = await query.PromoteToLeaderAsync(guildId, serverId, steamId, cancellationToken) + .ConfigureAwait(false); + return promoted + ? localizer.Get("leader.success", culture, memberName) + : localizer.Get("leader.failure", culture); + } +} +``` + +- [ ] **Step 4: Run the service tests to verify they pass** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~LeaderServiceTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/RustPlusBot.Features.Commands/Leader tests/RustPlusBot.Features.Commands.Tests/Leader +git commit -m "feat(3c): add LeaderService (fetch members + promote) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: Slash + component modules and DI registration + +**Files:** +- Create: `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs` +- Create: `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs` +- Modify: `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` +- Modify: `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` + +**Interfaces:** +- Consumes: `HelpEmbedRenderer`, `LeaderService`, `BotUptime`, `ICommandLocalizer`, `IServerService`, `IMuteStore`, `IWorkspaceStore`, `IServiceScopeFactory`, `RustPlusBot.Discord.InteractionModuleAssembly`, `DurationFormat`. +- Produces (custom-id contracts used across the two modules): + - server-pick: `leader-server:` + serverId value carried as the select option value. + - member-pick: `leader-promote:{serverId}` with the SteamId as the option value. + +**Note on module registration:** `AddCommands` must register `HelpEmbedRenderer`, `LeaderService` (scoped — they use scoped stores/seams resolved per interaction via `IServiceScopeFactory` inside the module, OR register them and resolve from the scope), and a new `InteractionModuleAssembly(thisAssembly)` singleton so `DiscordBotService` discovers the modules. Follow `SetupModule`'s scope-per-interaction idiom: the module ctor takes only `IServiceScopeFactory`, and resolves `HelpEmbedRenderer`/`LeaderService`/stores from a fresh `CreateAsyncScope()` per call. + +- [ ] **Step 1: Add the module-discovery + registration assertions to `CommandRegistrationTests`** + +In `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs`, add `using RustPlusBot.Discord;` and a new test: + +```csharp +[Fact] +public void Commands_contribute_an_interaction_module_assembly() +{ + var services = new ServiceCollection(); + services.AddLogging(); + 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.AddOptions(); + services.AddCommands(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + var assemblies = provider.GetServices(); + Assert.Contains(assemblies, a => a.Assembly == typeof(CommandServiceCollectionExtensions).Assembly); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~Commands_contribute_an_interaction_module_assembly"` +Expected: FAIL — no `InteractionModuleAssembly` is registered for the Commands assembly. + +- [ ] **Step 2b: Add the Discord project + package references to the Commands csproj** + +The Commands project does not yet reference `RustPlusBot.Discord` (where `InteractionModuleAssembly` lives) or the `Discord.Net` package (where `InteractionModuleBase`/`SocketInteractionContext`/`Discord.Interactions` live). Edit `src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj` so the reference groups read: + +```xml + + + + + + + + + + +``` + +(`Discord.Net` version comes from `Directory.Packages.props` central management — no version attribute here, matching Connections.) + +- [ ] **Step 3: Register the surfaces in `AddCommands`** + +In `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs`, add `using RustPlusBot.Discord;`, `using RustPlusBot.Features.Commands.Help;`, `using RustPlusBot.Features.Commands.Leader;`, and append before `return services;`: + +```csharp +// Discord surfaces (3c): help renderer, leader service, and the interaction modules. +services.AddScoped(); +services.AddScoped(); +services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly)); +``` + +- [ ] **Step 4: Run the registration test to verify it passes** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~Commands_contribute_an_interaction_module_assembly"` +Expected: PASS. + +- [ ] **Step 5: Create `CommandSurfaceModule`** + +Create `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs`: + +```csharp +using System.Globalization; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Help; +using RustPlusBot.Features.Commands.Hosting; +using RustPlusBot.Features.Commands.Leader; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Persistence.Commands; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Commands.Modules; + +/// The /help, /uptime, and /leader slash commands. +/// Creates a short-lived DI scope per interaction. +public sealed class CommandSurfaceModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// The custom-id prefix for the /leader server-pick select. + public const string LeaderServerSelectId = "leader-server"; + + /// The custom-id prefix for the /leader member-pick select (suffixed with the server id). + public const string LeaderPromotePrefix = "leader-promote:"; + + /// Lists the available in-game and slash commands. + [SlashCommand("help", "Show the available commands")] + public async Task HelpAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var servers = scope.ServiceProvider.GetRequiredService(); + var muteStore = scope.ServiceProvider.GetRequiredService(); + var renderer = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); + + string prefix = "!"; + var showNote = false; + if (known.Count == 1) + { + prefix = await muteStore.GetPrefixAsync(Context.Guild.Id, known[0].Id).ConfigureAwait(false); + } + else if (known.Count > 1) + { + showNote = true; + } + + var embed = renderer.Render(prefix, culture, showNote); + await FollowupAsync(embed: embed, ephemeral: true).ConfigureAwait(false); + } + } + + /// Reports the bot process uptime. + [SlashCommand("uptime", "Show how long the bot has been running")] + public async Task UptimeAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + var uptime = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var text = localizer.Get("uptime.ok", culture, DurationFormat.Compact(uptime.Elapsed)); + await RespondAsync(text, ephemeral: true).ConfigureAwait(false); + } + } + + /// Transfers in-game team leadership (ManageGuild). Opens a server or member select. + [SlashCommand("leader", "Transfer in-game team leadership")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task LeaderAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var servers = scope.ServiceProvider.GetRequiredService(); + var leader = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); + if (known.Count == 0) + { + await FollowupAsync(localizer.Get("leader.noserver", culture), ephemeral: true).ConfigureAwait(false); + return; + } + + if (known.Count > 1) + { + var serverSelect = new SelectMenuBuilder() + .WithCustomId(LeaderServerSelectId) + .WithPlaceholder(localizer.Get("leader.pickserver", culture)); + foreach (var server in known.Take(SelectMenuBuilder.MaxOptionCount)) + { + serverSelect.AddOption(server.Name, server.Id.ToString()); + } + + var components = new ComponentBuilder().WithSelectMenu(serverSelect).Build(); + await FollowupAsync(localizer.Get("leader.pickserver", culture), components: components, + ephemeral: true).ConfigureAwait(false); + return; + } + + await ShowMemberSelectAsync(scope.ServiceProvider, leader, localizer, Context.Guild.Id, known[0].Id, + culture).ConfigureAwait(false); + } + } + + private async Task ShowMemberSelectAsync( + IServiceProvider provider, + LeaderService leader, + ICommandLocalizer localizer, + ulong guildId, + Guid serverId, + string culture) + { + _ = provider; + var result = await leader.GetMembersAsync(guildId, serverId, culture, CancellationToken.None) + .ConfigureAwait(false); + if (result.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var select = new SelectMenuBuilder() + .WithCustomId(string.Create(CultureInfo.InvariantCulture, $"{LeaderPromotePrefix}{serverId}")) + .WithPlaceholder(localizer.Get("leader.pickmember", culture)); + foreach (var member in result.Members.Take(SelectMenuBuilder.MaxOptionCount)) + { + select.AddOption(member.Name, member.SteamId.ToString(CultureInfo.InvariantCulture), + isDefault: member.IsLeader); + } + + var components = new ComponentBuilder().WithSelectMenu(select).Build(); + await FollowupAsync(localizer.Get("leader.pickmember", culture), components: components, ephemeral: true) + .ConfigureAwait(false); + } +} +``` + +- [ ] **Step 6: Create `LeaderComponentModule`** + +Create `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs`: + +```csharp +using System.Globalization; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Commands.Leader; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Commands.Modules; + +/// Handles the /leader server-pick and member-pick select callbacks. +/// Creates a short-lived DI scope per interaction. +public sealed class LeaderComponentModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Handles the server-pick step (multi-server guilds): re-shows a member select for the chosen server. + /// The selected server id (one value). + [ComponentInteraction(CommandSurfaceModule.LeaderServerSelectId, ignoreGroupNames: true)] + public async Task OnServerSelectedAsync(string[] values) + { + if (!await EnsureManageGuildAsync().ConfigureAwait(false) || + values is not [var raw] || !Guid.TryParse(raw, out var serverId)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var leader = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var result = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) + .ConfigureAwait(false); + if (result.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var select = new SelectMenuBuilder() + .WithCustomId(string.Create(CultureInfo.InvariantCulture, + $"{CommandSurfaceModule.LeaderPromotePrefix}{serverId}")) + .WithPlaceholder(localizer.Get("leader.pickmember", culture)); + foreach (var member in result.Members.Take(SelectMenuBuilder.MaxOptionCount)) + { + select.AddOption(member.Name, member.SteamId.ToString(CultureInfo.InvariantCulture), + isDefault: member.IsLeader); + } + + var components = new ComponentBuilder().WithSelectMenu(select).Build(); + await FollowupAsync(localizer.Get("leader.pickmember", culture), components: components, ephemeral: true) + .ConfigureAwait(false); + } + } + + /// Handles the member-pick step: promotes the chosen member. + /// The server id from the wildcard custom-id. + /// The selected member's Steam id (one value). + [ComponentInteraction($"{CommandSurfaceModule.LeaderPromotePrefix}*", ignoreGroupNames: true)] + public async Task OnMemberSelectedAsync(string serverIdRaw, string[] values) + { + if (!await EnsureManageGuildAsync().ConfigureAwait(false) || + !Guid.TryParse(serverIdRaw, out var serverId) || + values is not [var rawSteamId] || + !ulong.TryParse(rawSteamId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var steamId)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var leader = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + + // Re-resolve the member name for the success message; fall back to the SteamId. + var team = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) + .ConfigureAwait(false); + var name = team.Members.FirstOrDefault(m => m.SteamId == steamId)?.Name + ?? steamId.ToString(CultureInfo.InvariantCulture); + + var message = await leader + .PromoteAsync(Context.Guild.Id, serverId, steamId, name, culture, CancellationToken.None) + .ConfigureAwait(false); + await FollowupAsync(message, ephemeral: true).ConfigureAwait(false); + } + } + + private async Task EnsureManageGuildAsync() + { + // Discord does not re-gate component callbacks, so re-check ManageGuild here. + if (Context.User is SocketGuildUser user && user.GuildPermissions.ManageGuild) + { + return true; + } + + await RespondAsync("You need the Manage Server permission.", ephemeral: true).ConfigureAwait(false); + return false; + } +} +``` + +- [ ] **Step 7: Run the full Commands suite + confirm it builds and passes** + +Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests` +Expected: PASS. Count = prior 70 + Task 2 (3) + Task 3 (4) + Task 4 (5) + Task 5 (1) = 83. Confirm the per-assembly total; a low count means a module failed to compile. + +- [ ] **Step 8: Build the whole solution to confirm `DiscordBotService` discovery wiring compiles** + +Run: `dotnet build RustPlusBot.slnx` +Expected: Build succeeded, 0 warnings, 0 errors. + +- [ ] **Step 9: Commit** + +```bash +git add src/RustPlusBot.Features.Commands tests/RustPlusBot.Features.Commands.Tests +git commit -m "feat(3c): add /help, /uptime, /leader slash + component modules + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 6: `#info` team summary field + +**Files:** +- Modify: `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` +- Modify: `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs` +- Test: `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` + +**Interfaces:** +- Consumes: `IRustServerQuery.GetTeamInfoAsync` (added to the renderer ctor), `TeamInfoSnapshot`/`TeamMemberSnapshot`. +- Produces: a new embed field "Online: {online}/{total} · leader {name}" rendered only when `Status == Connected` and the team query returns non-null. + +- [ ] **Step 1: Add the localization keys** + +In `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs`, add to `["en"]` (after `server.info.players.label`): + +```csharp +["server.info.team.label"] = "Team", +["server.info.team.value"] = "{0}/{1} online · leader {2}", +``` + +And to `["fr"]`: + +```csharp +["server.info.team.label"] = "Équipe", +["server.info.team.value"] = "{0}/{1} en ligne · chef {2}", +``` + +- [ ] **Step 2: Update the existing renderer tests for the new ctor arg, then add the team-field cases** + +In `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs`: + +Add usings: + +```csharp +using RustPlusBot.Features.Connections.Listening; +``` + +Every `new ServerInfoMessageRenderer(servers, connections, Loc)` becomes +`new ServerInfoMessageRenderer(servers, connections, query, Loc)`. Add a query +substitute to each `ServerInfo_*` test that returns null by default (field omitted), +e.g. at the top of each such test body: + +```csharp +var query = Substitute.For(); +query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); +``` + +(Add `using RustPlusBot.Features.Connections.Listening;` is above; NSubstitute is +already imported.) Then add two new tests: + +```csharp +[Fact] +public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary() +{ + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, PlayerCount = 5, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, + Status = CredentialStatus.Active }, + }); + var query = Substitute.For(); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) + .Returns(new TeamInfoSnapshot( + 10UL, + [ + new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + new TeamMemberSnapshot(20UL, "bob", 0f, 0f, false, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + ])); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("1/2 online", body, StringComparison.Ordinal); + Assert.Contains("alice", body, StringComparison.Ordinal); +} + +[Fact] +public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() +{ + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, PlayerCount = 5, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, + Status = CredentialStatus.Active }, + }); + var query = Substitute.For(); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.DoesNotContain("online", string.Concat(payload.Embed!.Fields.Select(f => f.Value)), + StringComparison.OrdinalIgnoreCase); +} +``` + +- [ ] **Step 2b: Run to verify failure** + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests --filter "FullyQualifiedName~RendererTests"` +Expected: COMPILE ERROR — `ServerInfoMessageRenderer` has no 4-arg ctor. + +- [ ] **Step 3: Add the `IRustServerQuery` dependency and render the field** + +In `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs`: + +Add `using RustPlusBot.Features.Connections.Listening;`. Change the primary ctor to: + +```csharp +internal sealed class ServerInfoMessageRenderer( + IServerService servers, + IConnectionStore connections, + IRustServerQuery query, + ILocalizer localizer) : IMessageRenderer +``` + +(update the `` doc comments to add `Live team query.`). + +Then, inside `RenderAsync`, after the `if (status == ConnectionStatus.Connected && state?.PlayerCount is int count)` block (after its closing brace, before `var eligible = …`), add: + +```csharp +if (status == ConnectionStatus.Connected) +{ + var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (team is not null) + { + var online = team.Members.Count(m => m.IsOnline); + var leaderName = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId)?.Name + ?? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture); + embed.AddField( + localizer.Get("server.info.team.label", context.Culture), + localizer.Get("server.info.team.value", context.Culture, + online.ToString(CultureInfo.InvariantCulture), + team.Members.Count.ToString(CultureInfo.InvariantCulture), + leaderName)); + } +} +``` + +- [ ] **Step 4: Run the renderer tests to verify they pass** + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests --filter "FullyQualifiedName~RendererTests"` +Expected: PASS. The two new tests pass, and the 5 existing `ServerInfo_*` tests still pass (their query substitute returns null → field omitted → prior assertions unaffected). + +- [ ] **Step 5: Run the FULL Workspace suite + confirm count** + +Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests` +Expected: PASS, count = prior + 2. If low, a renderer ctor site was missed. + +- [ ] **Step 6: Commit** + +```bash +git add src/RustPlusBot.Features.Workspace tests/RustPlusBot.Features.Workspace.Tests +git commit -m "feat(3c): add team summary field to #info embed + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 7: Whole-feature verification, format gate, and plan commit + +**Files:** none (verification + the gitignored plan doc). + +- [ ] **Step 1: Run the entire test suite and read per-assembly counts** + +Run: `dotnet test RustPlusBot.slnx` +Expected: ALL pass. Confirm each assembly's count is at or above its prior baseline (Connections +3, Commands +13, Workspace +2; others unchanged). A drop in any assembly = a missing fake/ctor. + +- [ ] **Step 2: Confirm no EF model drift (this slice adds none)** + +Run: `git diff --name-only develop... | grep -iE "Migrations|ModelSnapshot|DbContext|Domain/.*\.cs|Persistence/.*Configuration"` +Expected: NO output (3c touches no entities, migrations, or EF configuration). If the `has-pending-model-changes` tooling is available and works, also run it; if it errors with "Unable to retrieve project metadata" (a known tooling quirk), rely on the diff check. + +- [ ] **Step 3: Run the ReSharper format gate** + +Run: `dotnet tool restore && dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` +Then: `git status --short` +Expected: jb may reorder members in the new files. Review the changes are cosmetic (member ordering, brace/collection-initializer wrapping), then stage and amend/commit them. + +- [ ] **Step 4: Rebuild to confirm 0/0 after formatting** + +Run: `dotnet build RustPlusBot.slnx` +Expected: Build succeeded, 0 warnings, 0 errors. + +- [ ] **Step 5: Commit any formatting changes and the plan doc** + +```bash +git add -A +git add -f docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md +git commit -m "chore(3c): apply jb formatting + commit plan + +Co-Authored-By: Claude Opus 4.8 " +``` + +- [ ] **Step 6: Push and open the PR** + +```bash +git push -u origin feat/command-surfaces +gh pr create --base develop --title "Subsystem 3c: command Discord surfaces (/help, /uptime, /leader, #info team summary)" --body "$(cat <<'EOF' +Third slice of subsystem 3. Surfaces the in-game command framework into Discord. + +- `/help` — ephemeral, lists in-game `!commands` (grouped, with the server's prefix) and the slash commands; EN/FR; rendered from a drift-guarded `CommandHelpCatalog`. +- `/uptime` — ephemeral bot-process uptime. +- `/leader` — ManageGuild; server-pick (multi-server) → member-select → `PromoteToLeaderAsync`. +- `#info` team summary — "online/total · leader" field, shown only while Connected, on the existing refresh path. + +New seam method `PromoteToLeaderAsync` on `IRustServerQuery`/`IRustServerConnection`/`ConnectionSupervisor`/`RustPlusSocketSource`. No new project/entity/migration/event/timer. + +The `#commands` channel relay is deferred to a future 3c-ii. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- `/help` (in-game + slash, grouped, prefix, EN/FR, catalog-driven, drift guard) → Tasks 2, 3, 5. ✓ +- `/uptime` (bot process) → Task 5 (reuses `BotUptime`). ✓ +- `/leader` (ManageGuild, member select, server pick when >1, promote) → Tasks 1, 4, 5. ✓ +- `#info` team summary (online/total + leader, only Connected, existing refresh) → Task 6. ✓ +- `PromoteToLeaderAsync` seam (query/connection/supervisor/shim/fake) → Task 1. ✓ +- Server targeting (auto-one / pick-multi / default-prefix-note) → Task 5 (`HelpAsync`/`LeaderAsync`) + Task 3 (note rendering). ✓ +- No new project/entity/migration/event/options/service → respected throughout; verified in Task 7 Step 2. ✓ +- Module assembly registration → Task 5 Steps 1–4. ✓ +- Error/edge cases (no socket, no servers, empty team, promote fail, stale component re-gate) → Tasks 4 + 5 (`EnsureManageGuildAsync`, `GetMembersAsync` error paths). ✓ + +**2. Placeholder scan:** No "TBD"/"add error handling"/"similar to". Every code step shows full code. The `_ = provider;` discard in `ShowMemberSelectAsync` is intentional (the helper resolves nothing further; it could be simplified during implementation but is shown complete and compiling). ✓ + +**3. Type consistency:** +- `PromoteToLeaderAsync` query signature `(ulong, Guid, ulong, CancellationToken)→Task` and connection signature `(ulong, TimeSpan, CancellationToken)→Task` are consistent across Task 1 (definition), Task 4 (`LeaderService` consumes the query form), and the fake. ✓ +- Custom-ids: `LeaderServerSelectId = "leader-server"` and `LeaderPromotePrefix = "leader-promote:"` defined in Task 5 `CommandSurfaceModule` and consumed in `LeaderComponentModule` (`$"{…LeaderPromotePrefix}*"`). ✓ +- Localization keys used in `HelpEmbedRenderer`/`LeaderService`/`CommandSurfaceModule`/renderer all match the keys added in Tasks 2 and 6 (`help.*`, `uptime.ok`, `leader.*`, `server.info.team.*`). ✓ +- `HelpEmbedRenderer.Render(string, string, bool)` signature consistent between Task 3 definition, its tests, and the `HelpAsync` call site. ✓ +- `LeaderTeamResult`/`LeaderMemberOption` records consistent between Task 4 and the module consumers. ✓ From b4180910e6ff2cdf0549846b1bb8f250ee3d2f4b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 12:54:56 +0200 Subject: [PATCH 03/12] feat(3c): add PromoteToLeaderAsync to the connection query seam Co-Authored-By: Claude Opus 4.8 --- .../Listening/IRustServerConnection.cs | 7 +++ .../Listening/IRustServerQuery.cs | 8 +++ .../Listening/RustPlusSocketSource.cs | 28 ++++++++++ .../Supervisor/ConnectionSupervisor.cs | 16 ++++++ .../Fakes/FakeRustSocketSource.cs | 14 +++++ .../ServerQueryTests.cs | 51 +++++++++++++++++++ 6 files changed, 124 insertions(+) diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index 4e413859..4e9ef5c3 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -40,6 +40,13 @@ internal interface IRustServerConnection : IAsyncDisposable /// Unlike the probe methods, this surfaces send failures to the caller (the supervisor maps them to a failed send result). Task SendTeamMessageAsync(string message, CancellationToken cancellationToken); + /// Promotes a team member to team leader; returns true on success. + /// Steam64 id of the member to promote. + /// How long to wait for the response. + /// A cancellation token. + /// True if the promotion succeeded; false on failure/timeout. + Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken); + /// Raised for every in-game team chat line received on this socket. event EventHandler? TeamMessageReceived; } diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs index e52c939d..fd9a3266 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs @@ -23,4 +23,12 @@ public interface IRustServerQuery /// A cancellation token. /// A team snapshot, or null when there is no live socket. Task GetTeamInfoAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + + /// Promotes a team member to team leader; returns false when there is no live socket or the API fails. + /// The owning guild snowflake. + /// The target server id. + /// Steam64 id of the member to promote. + /// A cancellation token. + /// True if promoted; false when no live socket or the API fails. + Task PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 300fa528..80a4d47c 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -45,6 +45,9 @@ public Task GetInfoAsync(TimeSpan timeout, CancellationToken ca public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) => Task.CompletedTask; + public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(false); + public event EventHandler? TeamMessageReceived { add { _ = value; } @@ -271,6 +274,31 @@ public async Task SendTeamMessageAsync(string message, CancellationToken cancell await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); } + public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + // CONFIRMED (2.0.0-beta.1): PromoteToLeaderAsync(ulong, CancellationToken) returns a payload-free + // Task; Response.IsSuccess indicates the outcome. + var response = await _rustPlus.PromoteToLeaderAsync(steamId, timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + return response.IsSuccess; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } +#pragma warning disable CA1031 // Broad catch: any promote failure maps to false; never surface a token/secret. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return false; + } + } + public async ValueTask DisposeAsync() { _rustPlus.OnTeamChatReceived -= OnTeamChatReceived; diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 073d3713..9963954b 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -168,6 +168,22 @@ public async Task StopAllAsync() .ConfigureAwait(false); } + /// + public async Task PromoteToLeaderAsync( + ulong guildId, + Guid serverId, + ulong steamId, + CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return false; + } + + return await live.Connection.PromoteToLeaderAsync(steamId, _options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); + } + /// public async Task SendAsync( ulong guildId, diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 2caa9846..6fe68a07 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -99,6 +99,20 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT return Task.CompletedTask; } + /// The result returned by . Defaults to true. + public bool PromoteResult { get; set; } = true; + + /// The Steam ID passed to the most recent call. + public ulong LastPromotedSteamId { get; private set; } + +#pragma warning disable RCS1163 // Unused parameters for fake implementation + public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) +#pragma warning restore RCS1163 + { + LastPromotedSteamId = steamId; + return Task.FromResult(PromoteResult); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// Raises to simulate an inbound team chat line. diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs index 1d0fd252..86058180 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -193,6 +193,57 @@ public async Task GetTeamInfo_ReturnsNull_WhenNoLiveSocket() Assert.Null(snapshot); } + [Fact] + public async Task PromoteToLeader_ReturnsTrueAndForwardsSteamId_WhenConnected() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + source.LastConnection!.PromoteResult = true; + var promoted = await supervisor.PromoteToLeaderAsync(10UL, serverId, 999UL, cts.Token); + + Assert.True(promoted); + Assert.Equal(999UL, source.LastConnection.LastPromotedSteamId); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task PromoteToLeader_ReturnsFalse_WhenApiNonSuccess() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + source.LastConnection!.PromoteResult = false; + var promoted = await supervisor.PromoteToLeaderAsync(10UL, serverId, 999UL, cts.Token); + + Assert.False(promoted); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task PromoteToLeader_ReturnsFalse_WhenNoLiveSocket() + { + var source = new FakeRustSocketSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + + var promoted = await supervisor.PromoteToLeaderAsync(10UL, Guid.NewGuid(), 999UL, CancellationToken.None); + + Assert.False(promoted); + } + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) { while (!condition()) From 98e6de17bf42058f3ad2efff42075cca51107f2f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 12:58:28 +0200 Subject: [PATCH 04/12] feat(3c): add command help catalog + help/leader/uptime localization keys Co-Authored-By: Claude Opus 4.8 --- .../Help/CommandGroup.cs | 17 ++++++ .../Help/CommandHelpCatalog.cs | 36 +++++++++++ .../CommandLocalizationCatalog.cs | 60 +++++++++++++++++++ .../Help/CommandHelpCatalogTests.cs | 45 ++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Help/CommandGroup.cs create mode 100644 src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs diff --git a/src/RustPlusBot.Features.Commands/Help/CommandGroup.cs b/src/RustPlusBot.Features.Commands/Help/CommandGroup.cs new file mode 100644 index 00000000..eb38943e --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Help/CommandGroup.cs @@ -0,0 +1,17 @@ +namespace RustPlusBot.Features.Commands.Help; + +/// The display grouping for a command in the /help listing. +internal enum CommandGroup +{ + /// Bot control commands (mute/unmute). + Control = 0, + + /// Server-state commands (pop/time/wipe). + Server = 1, + + /// Team-intel commands (online/offline/team/steamid/alive/prox). + TeamIntel = 2, + + /// Bot-meta commands (uptime). + Bot = 3, +} diff --git a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs new file mode 100644 index 00000000..99f72afa --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs @@ -0,0 +1,36 @@ +namespace RustPlusBot.Features.Commands.Help; + +/// One entry in the help listing. +/// The command name without prefix (in-game) or slash command name. +/// The display group. +/// The localization key for the description. +internal sealed record CommandHelpEntry(string Name, CommandGroup Group, string DescriptionKey); + +/// The curated help manifest. Tested against the live handler registry to catch drift. +internal static class CommandHelpCatalog +{ + /// The in-game !commands, in display order. + public static IReadOnlyList InGame { get; } = + [ + new("mute", CommandGroup.Control, "help.mute"), + new("unmute", CommandGroup.Control, "help.unmute"), + new("pop", CommandGroup.Server, "help.pop"), + new("time", CommandGroup.Server, "help.time"), + new("wipe", CommandGroup.Server, "help.wipe"), + new("online", CommandGroup.TeamIntel, "help.online"), + new("offline", CommandGroup.TeamIntel, "help.offline"), + new("team", CommandGroup.TeamIntel, "help.team"), + new("steamid", CommandGroup.TeamIntel, "help.steamid"), + new("alive", CommandGroup.TeamIntel, "help.alive"), + new("prox", CommandGroup.TeamIntel, "help.prox"), + new("uptime", CommandGroup.Bot, "help.uptime"), + ]; + + /// The Discord slash commands, in display order. + public static IReadOnlyList Slash { get; } = + [ + new("help", CommandGroup.Bot, "help.slash.help"), + new("uptime", CommandGroup.Bot, "help.slash.uptime"), + new("leader", CommandGroup.Bot, "help.slash.leader"), + ]; +} diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 149d2672..80e5c703 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -38,6 +38,36 @@ internal sealed class CommandLocalizationCatalog ["command.prox.member"] = "{0} {1}m", ["command.prox.selfunknown"] = "Can't locate you.", ["command.prox.alone"] = "No teammates nearby.", + ["help.title"] = "Commands", + ["help.group.control"] = "Control", + ["help.group.server"] = "Server", + ["help.group.teamintel"] = "Team", + ["help.group.bot"] = "Bot", + ["help.group.slash"] = "Slash commands", + ["help.prefixnote"] = "Prefixes are configured per server; showing the default.", + ["help.mute"] = "Silence all bot output to the game.", + ["help.unmute"] = "Resume bot output to the game.", + ["help.pop"] = "Show the server population.", + ["help.time"] = "Show the in-game time.", + ["help.wipe"] = "Show how long ago the server wiped.", + ["help.online"] = "List online teammates.", + ["help.offline"] = "List offline teammates.", + ["help.team"] = "List all team members.", + ["help.steamid"] = "Show teammates' Steam IDs.", + ["help.alive"] = "Show how long each teammate has survived.", + ["help.prox"] = "Show distance to each teammate.", + ["help.uptime"] = "Show how long the bot has been running.", + ["help.slash.help"] = "Show this help message.", + ["help.slash.uptime"] = "Show the bot's uptime.", + ["help.slash.leader"] = "Transfer in-game team leadership.", + ["uptime.ok"] = "Bot uptime: {0}", + ["leader.noserver"] = "No server is set up yet.", + ["leader.notconnected"] = "Not connected to this server.", + ["leader.noteam"] = "No team members to promote.", + ["leader.pickserver"] = "Choose a server:", + ["leader.pickmember"] = "Choose who to promote to team leader:", + ["leader.success"] = "Promoted {0} to team leader.", + ["leader.failure"] = "Couldn't promote — the server may be unreachable.", }, ["fr"] = new Dictionary(StringComparer.Ordinal) { @@ -66,6 +96,36 @@ internal sealed class CommandLocalizationCatalog ["command.prox.member"] = "{0} {1}m", ["command.prox.selfunknown"] = "Impossible de vous localiser.", ["command.prox.alone"] = "Aucun coéquipier à proximité.", + ["help.title"] = "Commandes", + ["help.group.control"] = "Contrôle", + ["help.group.server"] = "Serveur", + ["help.group.teamintel"] = "Équipe", + ["help.group.bot"] = "Bot", + ["help.group.slash"] = "Commandes slash", + ["help.prefixnote"] = "Les préfixes sont configurés par serveur ; affichage du préfixe par défaut.", + ["help.mute"] = "Couper toute sortie du bot vers le jeu.", + ["help.unmute"] = "Réactiver la sortie du bot vers le jeu.", + ["help.pop"] = "Afficher la population du serveur.", + ["help.time"] = "Afficher l'heure en jeu.", + ["help.wipe"] = "Afficher depuis combien de temps le serveur a été wipe.", + ["help.online"] = "Lister les coéquipiers en ligne.", + ["help.offline"] = "Lister les coéquipiers hors ligne.", + ["help.team"] = "Lister tous les membres de l'équipe.", + ["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.", + ["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.", + ["help.prox"] = "Afficher la distance à chaque coéquipier.", + ["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.", + ["help.slash.help"] = "Afficher ce message d'aide.", + ["help.slash.uptime"] = "Afficher la disponibilité du bot.", + ["help.slash.leader"] = "Transférer le rôle de chef d'équipe en jeu.", + ["uptime.ok"] = "Disponibilité du bot : {0}", + ["leader.noserver"] = "Aucun serveur n'est encore configuré.", + ["leader.notconnected"] = "Non connecté à ce serveur.", + ["leader.noteam"] = "Aucun membre d'équipe à promouvoir.", + ["leader.pickserver"] = "Choisissez un serveur :", + ["leader.pickmember"] = "Choisissez qui promouvoir chef d'équipe :", + ["leader.success"] = "{0} promu chef d'équipe.", + ["leader.failure"] = "Impossible de promouvoir — le serveur est peut-être injoignable.", }, }, }; diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs new file mode 100644 index 00000000..8478c445 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs @@ -0,0 +1,45 @@ +using RustPlusBot.Features.Commands.Help; +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Help; + +public sealed class CommandHelpCatalogTests +{ + /// The 12 registered in-game handler names (see AddCommands / CommandRegistrationTests). + private static readonly string[] HandlerNames = + [ + "mute", "unmute", "uptime", "pop", "wipe", "time", + "online", "offline", "team", "steamid", "alive", "prox", + ]; + + [Fact] + public void EveryRegisteredHandlerHasAnInGameEntry() + { + var entryNames = CommandHelpCatalog.InGame.Select(e => e.Name).ToHashSet(StringComparer.Ordinal); + foreach (var name in HandlerNames) + { + Assert.Contains(name, entryNames); + } + } + + [Fact] + public void InGameCatalogHasNoEntryWithoutAHandler() + { + var handlerNames = HandlerNames.ToHashSet(StringComparer.Ordinal); + foreach (var entry in CommandHelpCatalog.InGame) + { + Assert.Contains(entry.Name, handlerNames); + } + } + + [Fact] + public void EveryDescriptionKeyResolvesInEnglishAndFrench() + { + var loc = new CommandLocalizer(CommandLocalizationCatalog.Default); + foreach (var entry in CommandHelpCatalog.InGame.Concat(CommandHelpCatalog.Slash)) + { + Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "en")); + Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "fr")); + } + } +} From be59bb6f08bcf2bbccb7d32380dd8d2b6b0fe1e6 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:01:41 +0200 Subject: [PATCH 05/12] feat(3c): add HelpEmbedRenderer Co-Authored-By: Claude Opus 4.8 --- .../Help/HelpEmbedRenderer.cs | 56 +++++++++++++++++++ .../Help/HelpEmbedRendererTests.cs | 50 +++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs diff --git a/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs b/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs new file mode 100644 index 00000000..587a2de0 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Help; + +/// Builds the /help embed from the catalog, the server prefix, and the guild culture. +/// Resolves the title, group headings, and per-command descriptions. +internal sealed class HelpEmbedRenderer(ICommandLocalizer localizer) +{ + private static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder = + [ + (CommandGroup.Control, "help.group.control"), + (CommandGroup.Server, "help.group.server"), + (CommandGroup.TeamIntel, "help.group.teamintel"), + (CommandGroup.Bot, "help.group.bot"), + ]; + + /// Renders the help embed. + /// The in-game command prefix to display (e.g. "!"). + /// The guild culture ("en"/"fr"). + /// True to add the multi-server prefix note to the description. + /// The built embed. + public Embed Render(string prefix, string culture, bool showPrefixNote) + { + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("help.title", culture)) + .WithColor(Color.Blue); + + if (showPrefixNote) + { + embed.WithDescription(localizer.Get("help.prefixnote", culture)); + } + + foreach (var (group, headingKey) in GroupOrder) + { + var lines = CommandHelpCatalog.InGame + .Where(e => e.Group == group) + .Select(e => string.Create(CultureInfo.InvariantCulture, + $"`{prefix}{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}")) + .ToList(); + if (lines.Count > 0) + { + embed.AddField(localizer.Get(headingKey, culture), string.Join('\n', lines)); + } + } + + var slashLines = CommandHelpCatalog.Slash + .Select(e => string.Create(CultureInfo.InvariantCulture, + $"`/{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}")) + .ToList(); + embed.AddField(localizer.Get("help.group.slash", culture), string.Join('\n', slashLines)); + + return embed.Build(); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs new file mode 100644 index 00000000..3b88a8ca --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs @@ -0,0 +1,50 @@ +using RustPlusBot.Features.Commands.Help; +using RustPlusBot.Features.Commands.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Help; + +public sealed class HelpEmbedRendererTests +{ + private static readonly HelpEmbedRenderer Renderer = + new(new CommandLocalizer(CommandLocalizationCatalog.Default)); + + [Fact] + public void Render_PrefixesInGameCommands() + { + var embed = Renderer.Render("$", "en", showPrefixNote: false); + + var body = string.Concat(embed.Fields.Select(f => f.Value)); + Assert.Contains("$pop", body, StringComparison.Ordinal); + Assert.Contains("$online", body, StringComparison.Ordinal); + } + + [Fact] + public void Render_IncludesSlashCommandsGroup() + { + var embed = Renderer.Render("!", "en", showPrefixNote: false); + + var body = string.Concat(embed.Fields.Select(f => f.Value)); + Assert.Contains("/leader", body, StringComparison.Ordinal); + Assert.Contains("/uptime", body, StringComparison.Ordinal); + } + + [Fact] + public void Render_AddsPrefixNote_WhenRequested() + { + var withNote = Renderer.Render("!", "en", showPrefixNote: true); + var withoutNote = Renderer.Render("!", "en", showPrefixNote: false); + + Assert.NotNull(withNote.Description); + Assert.Contains("per server", withNote.Description, StringComparison.OrdinalIgnoreCase); + Assert.True(string.IsNullOrEmpty(withoutNote.Description)); + } + + [Fact] + public void Render_LocalizesToFrench() + { + var embed = Renderer.Render("!", "fr", showPrefixNote: false); + + var body = string.Concat(embed.Fields.Select(f => f.Value)); + Assert.Contains("Afficher la population", body, StringComparison.Ordinal); + } +} From 3aa1124612a74dec1236c370a8723c0a5368eb69 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:04:33 +0200 Subject: [PATCH 06/12] feat(3c): add LeaderService (fetch members + promote) Co-Authored-By: Claude Opus 4.8 --- .../Leader/LeaderService.cs | 73 ++++++++++++++++ .../Leader/LeaderServiceTests.cs | 87 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Leader/LeaderService.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs diff --git a/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs b/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs new file mode 100644 index 00000000..a32d25d9 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs @@ -0,0 +1,73 @@ +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Commands.Leader; + +/// One selectable team member for the /leader promote select. +/// The member's Steam64 id. +/// The member's in-game name. +/// True if this member is the current team leader. +internal sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader); + +/// The members to offer, or an error message to show instead. +/// The selectable members (empty when is set). +/// A localized message to show instead of a select, or null to proceed. +internal sealed record LeaderTeamResult(IReadOnlyList Members, string? ErrorMessage); + +/// The testable logic behind /leader: fetch live members, then promote one. +/// The live-server query seam. +/// Resolves the result/error messages. +internal sealed class LeaderService(IRustServerQuery query, ICommandLocalizer localizer) +{ + /// Fetches the current team for the promote select, or an error message. + /// The owning guild snowflake. + /// The target server id. + /// The guild culture. + /// A cancellation token. + /// The members to offer, or an error message. + public async Task GetMembersAsync( + ulong guildId, + Guid serverId, + string culture, + CancellationToken cancellationToken) + { + var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (team is null) + { + return new LeaderTeamResult([], localizer.Get("leader.notconnected", culture)); + } + + if (team.Members.Count == 0) + { + return new LeaderTeamResult([], localizer.Get("leader.noteam", culture)); + } + + var members = team.Members + .Select(m => new LeaderMemberOption(m.SteamId, m.Name, m.SteamId == team.LeaderSteamId)) + .ToList(); + return new LeaderTeamResult(members, null); + } + + /// Promotes a member and returns the localized result message. + /// The owning guild snowflake. + /// The target server id. + /// The Steam64 id to promote. + /// The member's name (for the success message). + /// The guild culture. + /// A cancellation token. + /// The localized result message. + public async Task PromoteAsync( + ulong guildId, + Guid serverId, + ulong steamId, + string memberName, + string culture, + CancellationToken cancellationToken) + { + var promoted = await query.PromoteToLeaderAsync(guildId, serverId, steamId, cancellationToken) + .ConfigureAwait(false); + return promoted + ? localizer.Get("leader.success", culture, memberName) + : localizer.Get("leader.failure", culture); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs new file mode 100644 index 00000000..77d2c472 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs @@ -0,0 +1,87 @@ +using NSubstitute; +using RustPlusBot.Features.Commands.Leader; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Commands.Tests.Leader; + +public sealed class LeaderServiceTests +{ + private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); + private static readonly Guid Server = Guid.NewGuid(); + + [Fact] + public async Task GetMembers_ReturnsError_WhenNotConnected() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1UL, Server, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var service = new LeaderService(query, Loc); + + var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); + + Assert.Empty(result.Members); + Assert.Equal("Not connected to this server.", result.ErrorMessage); + } + + [Fact] + public async Task GetMembers_ReturnsError_WhenTeamEmpty() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1UL, Server, Arg.Any()) + .Returns(new TeamInfoSnapshot(0UL, [])); + var service = new LeaderService(query, Loc); + + var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); + + Assert.Empty(result.Members); + Assert.Equal("No team members to promote.", result.ErrorMessage); + } + + [Fact] + public async Task GetMembers_MapsMembersAndMarksLeader() + { + var query = Substitute.For(); + query.GetTeamInfoAsync(1UL, Server, Arg.Any()) + .Returns(new TeamInfoSnapshot( + 10UL, + [ + new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + new TeamMemberSnapshot(20UL, "bob", 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + ])); + var service = new LeaderService(query, Loc); + + var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); + + Assert.Null(result.ErrorMessage); + Assert.Equal(2, result.Members.Count); + Assert.Contains(result.Members, m => m is { SteamId: 10UL, Name: "alice", IsLeader: true }); + Assert.Contains(result.Members, m => m is { SteamId: 20UL, Name: "bob", IsLeader: false }); + } + + [Fact] + public async Task Promote_ReturnsSuccessMessage_WhenPromoted() + { + var query = Substitute.For(); + query.PromoteToLeaderAsync(1UL, Server, 20UL, Arg.Any()).Returns(true); + var service = new LeaderService(query, Loc); + + var message = await service.PromoteAsync(1UL, Server, 20UL, "bob", "en", CancellationToken.None); + + Assert.Equal("Promoted bob to team leader.", message); + } + + [Fact] + public async Task Promote_ReturnsFailureMessage_WhenApiFails() + { + var query = Substitute.For(); + query.PromoteToLeaderAsync(1UL, Server, 20UL, Arg.Any()).Returns(false); + var service = new LeaderService(query, Loc); + + var message = await service.PromoteAsync(1UL, Server, 20UL, "bob", "en", CancellationToken.None); + + Assert.Equal("Couldn't promote — the server may be unreachable.", message); + } +} From 479f5fa7f4ad65b083eb1f6c0a52e970d05fc987 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:10:39 +0200 Subject: [PATCH 07/12] feat(3c): add /help, /uptime, /leader slash + component modules Co-Authored-By: Claude Opus 4.8 --- .../CommandServiceCollectionExtensions.cs | 8 + .../Modules/CommandSurfaceModule.cs | 167 ++++++++++++++++++ .../Modules/LeaderComponentModule.cs | 109 ++++++++++++ .../RustPlusBot.Features.Commands.csproj | 5 + .../CommandRegistrationTests.cs | 21 +++ 5 files changed, 310 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs create mode 100644 src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index 7ed3e526..2b4d542b 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -1,7 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Discord; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; +using RustPlusBot.Features.Commands.Help; using RustPlusBot.Features.Commands.Hosting; +using RustPlusBot.Features.Commands.Leader; using RustPlusBot.Features.Commands.Localization; namespace RustPlusBot.Features.Commands; @@ -38,6 +41,11 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddScoped(); services.AddHostedService(); + // Discord surfaces (3c): help renderer, leader service, and the interaction modules. + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly)); + return services; } } diff --git a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs new file mode 100644 index 00000000..99369fd9 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Help; +using RustPlusBot.Features.Commands.Hosting; +using RustPlusBot.Features.Commands.Leader; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Persistence.Commands; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Commands.Modules; + +/// The /help, /uptime, and /leader slash commands. +/// Creates a short-lived DI scope per interaction. +public sealed class CommandSurfaceModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// The custom-id prefix for the /leader server-pick select. + public const string LeaderServerSelectId = "leader-server"; + + /// The custom-id prefix for the /leader member-pick select (suffixed with the server id). + public const string LeaderPromotePrefix = "leader-promote:"; + + /// Lists the available in-game and slash commands. + [SlashCommand("help", "Show the available commands")] + public async Task HelpAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var servers = scope.ServiceProvider.GetRequiredService(); + var muteStore = scope.ServiceProvider.GetRequiredService(); + var renderer = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); + + string prefix = "!"; + var showNote = false; + if (known.Count == 1) + { + prefix = await muteStore.GetPrefixAsync(Context.Guild.Id, known[0].Id).ConfigureAwait(false); + } + else if (known.Count > 1) + { + showNote = true; + } + + var embed = renderer.Render(prefix, culture, showNote); + await FollowupAsync(ephemeral: true, embed: embed).ConfigureAwait(false); + } + } + + /// Reports the bot process uptime. + [SlashCommand("uptime", "Show how long the bot has been running")] + public async Task UptimeAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + var uptime = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var text = localizer.Get("uptime.ok", culture, DurationFormat.Compact(uptime.Elapsed)); + await RespondAsync(text, ephemeral: true).ConfigureAwait(false); + } + } + + /// Transfers in-game team leadership (ManageGuild). Opens a server or member select. + [SlashCommand("leader", "Transfer in-game team leadership")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task LeaderAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var servers = scope.ServiceProvider.GetRequiredService(); + var leader = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); + if (known.Count == 0) + { + await FollowupAsync(localizer.Get("leader.noserver", culture), ephemeral: true).ConfigureAwait(false); + return; + } + + if (known.Count > 1) + { + var serverSelect = new SelectMenuBuilder() + .WithCustomId(LeaderServerSelectId) + .WithPlaceholder(localizer.Get("leader.pickserver", culture)); + foreach (var server in known.Take(SelectMenuBuilder.MaxOptionCount)) + { + serverSelect.AddOption(server.Name, server.Id.ToString()); + } + + var components = new ComponentBuilder().WithSelectMenu(serverSelect).Build(); + await FollowupAsync(localizer.Get("leader.pickserver", culture), ephemeral: true, + components: components).ConfigureAwait(false); + return; + } + + await ShowMemberSelectAsync(scope.ServiceProvider, leader, localizer, Context.Guild.Id, known[0].Id, + culture).ConfigureAwait(false); + } + } + + private async Task ShowMemberSelectAsync( + IServiceProvider provider, + LeaderService leader, + ICommandLocalizer localizer, + ulong guildId, + Guid serverId, + string culture) + { + _ = provider; + var result = await leader.GetMembersAsync(guildId, serverId, culture, CancellationToken.None) + .ConfigureAwait(false); + if (result.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var select = new SelectMenuBuilder() + .WithCustomId(string.Create(CultureInfo.InvariantCulture, $"{LeaderPromotePrefix}{serverId}")) + .WithPlaceholder(localizer.Get("leader.pickmember", culture)); + foreach (var member in result.Members.Take(SelectMenuBuilder.MaxOptionCount)) + { + select.AddOption(member.Name, member.SteamId.ToString(CultureInfo.InvariantCulture), + isDefault: member.IsLeader); + } + + var components = new ComponentBuilder().WithSelectMenu(select).Build(); + await FollowupAsync(localizer.Get("leader.pickmember", culture), ephemeral: true, components: components) + .ConfigureAwait(false); + } +} diff --git a/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs new file mode 100644 index 00000000..a26faa89 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs @@ -0,0 +1,109 @@ +using System.Globalization; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Commands.Leader; +using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Commands.Modules; + +/// Handles the /leader server-pick and member-pick select callbacks. +/// Creates a short-lived DI scope per interaction. +public sealed class LeaderComponentModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Handles the server-pick step (multi-server guilds): re-shows a member select for the chosen server. + /// The selected server id (one value). + [ComponentInteraction(CommandSurfaceModule.LeaderServerSelectId, ignoreGroupNames: true)] + public async Task OnServerSelectedAsync(string[] values) + { + if (!await EnsureManageGuildAsync().ConfigureAwait(false) || + values is not [var raw] || !Guid.TryParse(raw, out var serverId)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var leader = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + var result = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) + .ConfigureAwait(false); + if (result.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var select = new SelectMenuBuilder() + .WithCustomId(string.Create(CultureInfo.InvariantCulture, + $"{CommandSurfaceModule.LeaderPromotePrefix}{serverId}")) + .WithPlaceholder(localizer.Get("leader.pickmember", culture)); + foreach (var member in result.Members.Take(SelectMenuBuilder.MaxOptionCount)) + { + select.AddOption(member.Name, member.SteamId.ToString(CultureInfo.InvariantCulture), + isDefault: member.IsLeader); + } + + var components = new ComponentBuilder().WithSelectMenu(select).Build(); + await FollowupAsync(localizer.Get("leader.pickmember", culture), ephemeral: true, components: components) + .ConfigureAwait(false); + } + } + + /// Handles the member-pick step: promotes the chosen member. + /// The server id from the wildcard custom-id. + /// The selected member's Steam id (one value). + [ComponentInteraction($"{CommandSurfaceModule.LeaderPromotePrefix}*", ignoreGroupNames: true)] + public async Task OnMemberSelectedAsync(string serverIdRaw, string[] values) + { + if (!await EnsureManageGuildAsync().ConfigureAwait(false) || + !Guid.TryParse(serverIdRaw, out var serverId) || + values is not [var rawSteamId] || + !ulong.TryParse(rawSteamId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var steamId)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var workspace = scope.ServiceProvider.GetRequiredService(); + var leader = scope.ServiceProvider.GetRequiredService(); + + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + + // Re-resolve the member name for the success message; fall back to the SteamId. + var team = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) + .ConfigureAwait(false); + var name = team.Members.FirstOrDefault(m => m.SteamId == steamId)?.Name + ?? steamId.ToString(CultureInfo.InvariantCulture); + + var message = await leader + .PromoteAsync(Context.Guild.Id, serverId, steamId, name, culture, CancellationToken.None) + .ConfigureAwait(false); + await FollowupAsync(message, ephemeral: true).ConfigureAwait(false); + } + } + + private async Task EnsureManageGuildAsync() + { + // Discord does not re-gate component callbacks, so re-check ManageGuild here. + if (Context.User is SocketGuildUser user && user.GuildPermissions.ManageGuild) + { + return true; + } + + await RespondAsync("You need the Manage Server permission.", ephemeral: true).ConfigureAwait(false); + return false; + } +} diff --git a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj index 083fe58d..9793f6f3 100644 --- a/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj +++ b/src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj @@ -9,6 +9,11 @@ + + + + + diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 4fd9f1ae..726abe8c 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -3,6 +3,7 @@ using NSubstitute; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; +using RustPlusBot.Discord; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Localization; @@ -51,4 +52,24 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "alive"); Assert.Contains(handlers, h => h.Name == "prox"); } + + [Fact] + public void Commands_contribute_an_interaction_module_assembly() + { + var services = new ServiceCollection(); + services.AddLogging(); + 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.AddOptions(); + services.AddCommands(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + var assemblies = provider.GetServices(); + Assert.Contains(assemblies, a => a.Assembly == typeof(CommandServiceCollectionExtensions).Assembly); + } } From 5e37bbafcf48f167180bc593a66001707361bb1c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:21:04 +0200 Subject: [PATCH 08/12] feat(3c): add team summary field to #info embed Co-Authored-By: Claude Opus 4.8 --- .../Connections}/IRustServerQuery.cs | 0 .../Connections}/ServerInfoSnapshot.cs | 0 .../Connections}/ServerTimeSnapshot.cs | 0 .../Connections}/TeamInfoSnapshot.cs | 0 .../Localization/LocalizationCatalog.cs | 4 + .../Messages/ServerInfoMessageRenderer.cs | 20 ++++ .../Messages/RendererTests.cs | 98 ++++++++++++++++++- .../WorkspaceRegistrationTests.cs | 3 + 8 files changed, 120 insertions(+), 5 deletions(-) rename src/{RustPlusBot.Features.Connections/Listening => RustPlusBot.Abstractions/Connections}/IRustServerQuery.cs (100%) rename src/{RustPlusBot.Features.Connections/Listening => RustPlusBot.Abstractions/Connections}/ServerInfoSnapshot.cs (100%) rename src/{RustPlusBot.Features.Connections/Listening => RustPlusBot.Abstractions/Connections}/ServerTimeSnapshot.cs (100%) rename src/{RustPlusBot.Features.Connections/Listening => RustPlusBot.Abstractions/Connections}/TeamInfoSnapshot.cs (100%) diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs similarity index 100% rename from src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs rename to src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs diff --git a/src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/ServerInfoSnapshot.cs similarity index 100% rename from src/RustPlusBot.Features.Connections/Listening/ServerInfoSnapshot.cs rename to src/RustPlusBot.Abstractions/Connections/ServerInfoSnapshot.cs diff --git a/src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/ServerTimeSnapshot.cs similarity index 100% rename from src/RustPlusBot.Features.Connections/Listening/ServerTimeSnapshot.cs rename to src/RustPlusBot.Abstractions/Connections/ServerTimeSnapshot.cs diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamInfoSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs similarity index 100% rename from src/RustPlusBot.Features.Connections/Listening/TeamInfoSnapshot.cs rename to src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index 827177b4..91a52a1c 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -39,6 +39,8 @@ internal sealed class LocalizationCatalog ["server.info.status.nocredentials"] = "No working credentials", ["server.info.player.label"] = "Active player", ["server.info.players.label"] = "Players online", + ["server.info.team.label"] = "Team", + ["server.info.team.value"] = "{0}/{1} online · leader {2}", ["server.info.swap.placeholder"] = "Switch active player", ["server.info.none"] = "—", }, @@ -71,6 +73,8 @@ internal sealed class LocalizationCatalog ["server.info.status.nocredentials"] = "Aucun identifiant valide", ["server.info.player.label"] = "Joueur actif", ["server.info.players.label"] = "Joueurs en ligne", + ["server.info.team.label"] = "Équipe", + ["server.info.team.value"] = "{0}/{1} en ligne · chef {2}", ["server.info.swap.placeholder"] = "Changer le joueur actif", ["server.info.none"] = "—", }, diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index c49185ad..19ca191c 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -2,6 +2,7 @@ using Discord; using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; @@ -13,10 +14,12 @@ namespace RustPlusBot.Features.Workspace.Messages; /// Renders a server's #info embed with live connection status and the ManageGuild swap select. /// Server lookup. /// Live connection state + pool. +/// Live team query. /// String resolution. internal sealed class ServerInfoMessageRenderer( IServerService servers, IConnectionStore connections, + IRustServerQuery query, ILocalizer localizer) : IMessageRenderer { /// @@ -61,6 +64,23 @@ public async ValueTask RenderAsync(MessageRenderContext context, count.ToString(CultureInfo.InvariantCulture)); } + if (status == ConnectionStatus.Connected) + { + var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + if (team is not null) + { + var online = team.Members.Count(m => m.IsOnline); + var leaderName = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId)?.Name + ?? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture); + embed.AddField( + localizer.Get("server.info.team.label", context.Culture), + localizer.Get("server.info.team.value", context.Culture, + online.ToString(CultureInfo.InvariantCulture), + team.Members.Count.ToString(CultureInfo.InvariantCulture), + leaderName)); + } + } + var eligible = pool .Where(c => c.Status != CredentialStatus.Invalid) .Take(SelectMenuBuilder.MaxOptionCount) // Discord hard-limits a select menu to 25 options. diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index dae247cb..a975e598 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -3,6 +3,7 @@ using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Registry; @@ -97,8 +98,11 @@ public async Task ServerInfo_Connected_ShowsStatusActivePlayerAndCount_AndSwapSe Status = CredentialStatus.Active }, }); + var query = Substitute.For(); + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); @@ -133,8 +137,11 @@ public async Task ServerInfo_NoCredentials_ShowsNoCredentialsAndNoCount() }); connections.ListPoolAsync(1, serverId, Arg.Any()) .Returns(new List()); + var query = Substitute.For(); + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); @@ -161,8 +168,11 @@ public async Task ServerInfo_NullState_DefaultsToNoCredentials() .Returns((DomainConnectionState?)null); connections.ListPoolAsync(1, serverId, Arg.Any()) .Returns(new List()); + var query = Substitute.For(); + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); @@ -217,7 +227,10 @@ public async Task ServerInfo_HasRemoveServerButton() }); connections.ListPoolAsync(1, serverId, Arg.Any()) .Returns(new List()); - var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + var query = Substitute.For(); + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); @@ -264,7 +277,10 @@ public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows() Status = CredentialStatus.Active }, }); - var renderer = new ServerInfoMessageRenderer(servers, connections, Loc); + var query = Substitute.For(); + query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); @@ -275,4 +291,76 @@ public async Task ServerInfo_SwapSelectAndRemoveButton_AreInSeparateActionRows() Assert.Contains(rows, r => r.Components.OfType().Any()); Assert.Contains(rows, r => r.Components.OfType().Any()); } + + [Fact] + public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, PlayerCount = 5, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, + Status = CredentialStatus.Active }, + }); + var query = Substitute.For(); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) + .Returns(new TeamInfoSnapshot( + 10UL, + [ + new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + new TeamMemberSnapshot(20UL, "bob", 0f, 0f, false, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + ])); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("1/2 online", body, StringComparison.Ordinal); + Assert.Contains("alice", body, StringComparison.Ordinal); + } + + [Fact] + public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, PlayerCount = 5, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, + Status = CredentialStatus.Active }, + }); + var query = Substitute.For(); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) + .Returns((TeamInfoSnapshot?)null); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.DoesNotContain("online", string.Concat(payload.Embed!.Fields.Select(f => f.Value)), + StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs index 159c113c..e1fcaeb6 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs @@ -1,7 +1,9 @@ using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Teardown; @@ -18,6 +20,7 @@ public void ReconcilerAndTeardown_ResolveFromScope() services.AddSingleton(new DiscordSocketClient()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(Substitute.For()); services.AddLogging(); services.AddBotPersistence("DataSource=:memory:"); services.AddWorkspace(); From 35f2631495ec3e60b3d4679b387baf28468c453f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:25:38 +0200 Subject: [PATCH 09/12] fix(3c): tighten #info null-team test + document IRustServerQuery DI dependency Co-Authored-By: Claude Opus 4.8 --- .../WorkspaceServiceCollectionExtensions.cs | 2 ++ .../Messages/RendererTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index d2e1405e..b2b5b61f 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -40,6 +40,8 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + // ServerInfoMessageRenderer requires IRustServerQuery, which is registered by AddConnections — + // the host must compose both AddWorkspace and AddConnections. services.AddScoped(); // Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index a975e598..166e48e4 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -360,7 +360,7 @@ public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - Assert.DoesNotContain("online", string.Concat(payload.Embed!.Fields.Select(f => f.Value)), - StringComparison.OrdinalIgnoreCase); + var teamLabel = Loc.Get("server.info.team.label", "en"); + Assert.DoesNotContain(payload.Embed!.Fields, f => f.Name == teamLabel); } } From c4609314845848a7478924233bdaf7547654f87f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:28:08 +0200 Subject: [PATCH 10/12] chore(3c): apply jb ReformatAndReorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic only — multi-line parameter/call-chain wrapping in the 3c files that Roslynator does not flag (the repo's format gate). Build 0/0, full suite 249 green. Co-Authored-By: Claude Opus 4.8 --- ...6-06-17-rustplusbot-3c-command-surfaces.md | 17 ++++++ .../Listening/RustPlusSocketSource.cs | 7 ++- .../Fakes/FakeRustSocketSource.cs | 12 ++--- .../Messages/RendererTests.cs | 54 +++++++++++++++---- 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md b/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md index 9afc59a5..b1af8b67 100644 --- a/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md +++ b/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md @@ -27,6 +27,7 @@ ## File Structure **Connections (extend the existing seam):** + - Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` — add `PromoteToLeaderAsync`. - Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` — add `PromoteToLeaderAsync`. - Modify `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` — implement the query method over `_liveSockets`. @@ -35,6 +36,7 @@ - Modify `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` — add promote tests. **Commands (new surfaces):** + - Create `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs` — the grouping enum. - Create `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` — curated manifest. - Create `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs` — pure embed builder. @@ -48,6 +50,7 @@ - Modify `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` — assert new registrations resolve. **Workspace (`#info` summary):** + - Modify `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` — add the team field. - Modify `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs` — new EN/FR keys. - Modify `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` — new ctor arg + team-field cases. @@ -57,6 +60,7 @@ ## Task 1: `PromoteToLeaderAsync` on the connection seam **Files:** + - Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` - Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` - Modify: `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` @@ -65,6 +69,7 @@ - Test: `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` **Interfaces:** + - Produces: - `IRustServerQuery.PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken)` → `Task` (true = promoted; false = no live socket or API non-success). - `IRustServerConnection.PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken)` → `Task`. @@ -258,12 +263,14 @@ Co-Authored-By: Claude Opus 4.8 " ## Task 2: `CommandGroup` + `CommandHelpCatalog` with a drift/i18n guard **Files:** + - Create: `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs` - Create: `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` - Modify: `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` - Test: `tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs` **Interfaces:** + - Consumes: the existing `ICommandHandler.Name` registry (12 handlers: `mute`, `unmute`, `uptime`, `pop`, `wipe`, `time`, `online`, `offline`, `team`, `steamid`, `alive`, `prox`). - Produces: - `enum CommandGroup { Control, Server, TeamIntel, Bot }`. @@ -486,10 +493,12 @@ Co-Authored-By: Claude Opus 4.8 " ## Task 3: `HelpEmbedRenderer` (pure) **Files:** + - Create: `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs` - Test: `tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs` **Interfaces:** + - Consumes: `CommandHelpCatalog`, `ICommandLocalizer`. - Produces: `HelpEmbedRenderer(ICommandLocalizer localizer)` with `Discord.Embed Render(string prefix, string culture, bool showPrefixNote)`. @@ -638,10 +647,12 @@ Co-Authored-By: Claude Opus 4.8 " ## Task 4: `LeaderService` (testable promote/fetch logic) **Files:** + - Create: `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs` - Test: `tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs` **Interfaces:** + - Consumes: `IRustServerQuery` (`GetTeamInfoAsync`, `PromoteToLeaderAsync`), `ICommandLocalizer`, `TeamInfoSnapshot`/`TeamMemberSnapshot` (from `RustPlusBot.Features.Connections.Listening`). - Produces: - `sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader)`. @@ -848,12 +859,14 @@ Co-Authored-By: Claude Opus 4.8 " ## Task 5: Slash + component modules and DI registration **Files:** + - Create: `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs` - Create: `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs` - Modify: `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` - Modify: `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` **Interfaces:** + - Consumes: `HelpEmbedRenderer`, `LeaderService`, `BotUptime`, `ICommandLocalizer`, `IServerService`, `IMuteStore`, `IWorkspaceStore`, `IServiceScopeFactory`, `RustPlusBot.Discord.InteractionModuleAssembly`, `DurationFormat`. - Produces (custom-id contracts used across the two modules): - server-pick: `leader-server:` + serverId value carried as the select option value. @@ -1241,11 +1254,13 @@ Co-Authored-By: Claude Opus 4.8 " ## Task 6: `#info` team summary field **Files:** + - Modify: `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` - Modify: `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs` - Test: `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` **Interfaces:** + - Consumes: `IRustServerQuery.GetTeamInfoAsync` (added to the renderer ctor), `TeamInfoSnapshot`/`TeamMemberSnapshot`. - Produces: a new embed field "Online: {online}/{total} · leader {name}" rendered only when `Status == Connected` and the team query returns non-null. @@ -1487,6 +1502,7 @@ EOF ## Self-Review **1. Spec coverage:** + - `/help` (in-game + slash, grouped, prefix, EN/FR, catalog-driven, drift guard) → Tasks 2, 3, 5. ✓ - `/uptime` (bot process) → Task 5 (reuses `BotUptime`). ✓ - `/leader` (ManageGuild, member select, server pick when >1, promote) → Tasks 1, 4, 5. ✓ @@ -1500,6 +1516,7 @@ EOF **2. Placeholder scan:** No "TBD"/"add error handling"/"similar to". Every code step shows full code. The `_ = provider;` discard in `ShowMemberSelectAsync` is intentional (the helper resolves nothing further; it could be simplified during implementation but is shown complete and compiling). ✓ **3. Type consistency:** + - `PromoteToLeaderAsync` query signature `(ulong, Guid, ulong, CancellationToken)→Task` and connection signature `(ulong, TimeSpan, CancellationToken)→Task` are consistent across Task 1 (definition), Task 4 (`LeaderService` consumes the query form), and the fake. ✓ - Custom-ids: `LeaderServerSelectId = "leader-server"` and `LeaderPromotePrefix = "leader-promote:"` defined in Task 5 `CommandSurfaceModule` and consumed in `LeaderComponentModule` (`$"{…LeaderPromotePrefix}*"`). ✓ - Localization keys used in `HelpEmbedRenderer`/`LeaderService`/`CommandSurfaceModule`/renderer all match the keys added in Tasks 2 and 6 (`help.*`, `uptime.ok`, `leader.*`, `server.info.team.*`). ✓ diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 80a4d47c..9045c9c8 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -274,7 +274,9 @@ public async Task SendTeamMessageAsync(string message, CancellationToken cancell await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false); } - public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) + public async Task PromoteToLeaderAsync(ulong steamId, + TimeSpan timeout, + CancellationToken cancellationToken) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeout); @@ -282,7 +284,8 @@ public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Ca { // CONFIRMED (2.0.0-beta.1): PromoteToLeaderAsync(ulong, CancellationToken) returns a payload-free // Task; Response.IsSuccess indicates the outcome. - var response = await _rustPlus.PromoteToLeaderAsync(steamId, timeoutCts.Token).WaitAsync(timeoutCts.Token) + var response = await _rustPlus.PromoteToLeaderAsync(steamId, timeoutCts.Token) + .WaitAsync(timeoutCts.Token) .ConfigureAwait(false); return response.IsSuccess; } diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 6fe68a07..61a8baa7 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -75,6 +75,12 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// The snapshot returned by . Defaults to a non-null empty snapshot. public TeamInfoSnapshot? TeamResult { get; set; } = new(0UL, []); + /// The result returned by . Defaults to true. + public bool PromoteResult { get; set; } = true; + + /// The Steam ID passed to the most recent call. + public ulong LastPromotedSteamId { get; private set; } + /// Raised when a team chat message arrives on this connection. public event EventHandler? TeamMessageReceived; @@ -99,12 +105,6 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT return Task.CompletedTask; } - /// The result returned by . Defaults to true. - public bool PromoteResult { get; set; } = true; - - /// The Steam ID passed to the most recent call. - public ulong LastPromotedSteamId { get; private set; } - #pragma warning disable RCS1163 // Unused parameters for fake implementation public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) #pragma warning restore RCS1163 diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 166e48e4..0e3b153d 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -299,19 +299,36 @@ public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary() var credId = Guid.NewGuid(); var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); var connections = Substitute.For(); connections.GetStateAsync(1, serverId, Arg.Any()) .Returns(new DomainConnectionState { - RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, PlayerCount = 5, + RustServerId = serverId, + GuildId = 1, + ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, + PlayerCount = 5, }); connections.ListPoolAsync(1, serverId, Arg.Any()) .Returns(new List { - new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, - Status = CredentialStatus.Active }, + new() + { + Id = credId, + GuildId = 1, + RustServerId = serverId, + OwnerUserId = 7, + SteamId = 5UL, + Status = CredentialStatus.Active + }, }); var query = Substitute.For(); query.GetTeamInfoAsync(1, serverId, Arg.Any()) @@ -339,19 +356,36 @@ public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() var credId = Guid.NewGuid(); var servers = Substitute.For(); servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); var connections = Substitute.For(); connections.GetStateAsync(1, serverId, Arg.Any()) .Returns(new DomainConnectionState { - RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, PlayerCount = 5, + RustServerId = serverId, + GuildId = 1, + ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, + PlayerCount = 5, }); connections.ListPoolAsync(1, serverId, Arg.Any()) .Returns(new List { - new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, - Status = CredentialStatus.Active }, + new() + { + Id = credId, + GuildId = 1, + RustServerId = serverId, + OwnerUserId = 7, + SteamId = 5UL, + Status = CredentialStatus.Active + }, }); var query = Substitute.For(); query.GetTeamInfoAsync(1, serverId, Arg.Any()) From f9ff9a3756621f2e704a2a68c315bef29a2297cb Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:38:02 +0200 Subject: [PATCH 11/12] chore: stop tracking superpowers spec/plan docs (kept local only) docs/superpowers/ is gitignored; these spec/plan files were force-added past the ignore. Untrack them (3c here + the 3b-ii docs carried from PR #10) so they stay local working docs and never ship in a PR. Files remain on disk. Co-Authored-By: Claude Opus 4.8 --- ...2026-06-17-rustplusbot-3b-ii-team-intel.md | 1345 --------------- ...6-06-17-rustplusbot-3c-command-surfaces.md | 1524 ----------------- ...-17-rustplusbot-3b-ii-team-intel-design.md | 176 -- ...-rustplusbot-3c-command-surfaces-design.md | 215 --- 4 files changed, 3260 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-17-rustplusbot-3b-ii-team-intel.md delete mode 100644 docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md delete mode 100644 docs/superpowers/specs/2026-06-17-rustplusbot-3b-ii-team-intel-design.md delete mode 100644 docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md diff --git a/docs/superpowers/plans/2026-06-17-rustplusbot-3b-ii-team-intel.md b/docs/superpowers/plans/2026-06-17-rustplusbot-3b-ii-team-intel.md deleted file mode 100644 index 357f1482..00000000 --- a/docs/superpowers/plans/2026-06-17-rustplusbot-3b-ii-team-intel.md +++ /dev/null @@ -1,1345 +0,0 @@ -# 3b-ii Team-intel Commands Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add six in-game team-intelligence `!commands` (`!online`, `!offline`, `!team`, `!alive`, `!prox`, `!steamid`) that read a single live `GetTeamInfoAsync` snapshot from the connected Rust+ socket. - -**Architecture:** Extend the existing `IRustServerQuery` seam (the path `!pop`/`!time`/`!wipe` already use) with `GetTeamInfoAsync` returning a new boundary DTO `TeamInfoSnapshot`, implemented on `ConnectionSupervisor` over its `_liveSockets` registry and on the untested `RustPlusSocketSource` shim. Six new `ICommandHandler` classes consume it, each producing one compact comma-joined localized line. No new entities, migrations, events, options, background services, or projects. - -**Tech Stack:** .NET 10, C#, Discord.Net, RustPlusApi 2.0.0-beta.1, xUnit + NSubstitute, EF Core / SQLite (read-only here — no schema change). Strict Roslynator analyzers; jb `ReformatAndReorder` is the format gate. - -**Spec:** `docs/superpowers/specs/2026-06-17-rustplusbot-3b-ii-team-intel-design.md` - -**Branch:** `feat/team-intel` (already created off `develop`; the spec is already committed there). - ---- - -## File Structure - -**Connections (the seam):** - -- Create `src/RustPlusBot.Features.Connections/Listening/TeamInfoSnapshot.cs` — boundary DTOs `TeamInfoSnapshot` + `TeamMemberSnapshot`. -- Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` — add `GetTeamInfoAsync` (public). -- Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` — add `GetTeamInfoAsync` (internal). -- Modify `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` — implement on both `RejectedConnection` and `RustPlusServerConnection`. -- Modify `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` — implement `IRustServerQuery.GetTeamInfoAsync`. - -**Commands (helpers + handlers):** - -- Create `src/RustPlusBot.Features.Commands/Formatting/Distance.cs` — pure distance helper. -- Create `src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs` — shared partial name match. -- Create `src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs` -- Create `src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs` -- Create `src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs` -- Create `src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs` -- Create `src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs` -- Create `src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs` -- Modify `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` — add EN/FR keys. -- Modify `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` — register the six handlers. - -**Tests / fakes:** - -- Modify `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` — add `TeamResult` + `GetTeamInfoAsync`. -- Modify `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` — supervisor `GetTeamInfoAsync` coverage. -- Create `tests/RustPlusBot.Features.Commands.Tests/Formatting/DistanceTests.cs` -- Create `tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs` -- Create `tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs` — all six handlers. - ---- - -## Task 1: Boundary DTOs (`TeamInfoSnapshot`, `TeamMemberSnapshot`) - -**Files:** - -- Create: `src/RustPlusBot.Features.Connections/Listening/TeamInfoSnapshot.cs` - -- [ ] **Step 1: Create the DTO file** - -```csharp -namespace RustPlusBot.Features.Connections.Listening; - -/// A point-in-time view of a team, decoupled from RustPlusApi types. -/// Steam64 id of the current team leader. -/// Status snapshots for all team members. -public sealed record TeamInfoSnapshot(ulong LeaderSteamId, IReadOnlyList Members); - -/// A point-in-time view of one team member. -/// Steam64 id of the member. -/// In-game display name (empty when the game reports none). -/// Horizontal map coordinate (west to east). -/// Vertical map coordinate (south to north). -/// Whether the member is currently connected. -/// Whether the member is currently alive. -/// UTC time of the member's last spawn. -/// UTC time of the member's last death. -public sealed record TeamMemberSnapshot( - ulong SteamId, - string Name, - float X, - float Y, - bool IsOnline, - bool IsAlive, - DateTimeOffset LastSpawnTimeUtc, - DateTimeOffset LastDeathTimeUtc); -``` - -- [ ] **Step 2: Build the Connections project** - -Run: `dotnet build src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj` -Expected: build succeeds, 0 warnings / 0 errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/RustPlusBot.Features.Connections/Listening/TeamInfoSnapshot.cs -git commit -m "feat(connections): add TeamInfoSnapshot/TeamMemberSnapshot DTOs" -``` - ---- - -## Task 2: Extend the seam interfaces - -**Files:** - -- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` -- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` - -- [ ] **Step 1: Add `GetTeamInfoAsync` to the public `IRustServerQuery`** - -In `IRustServerQuery.cs`, after the existing `GetTimeAsync` method (before the closing brace), add: - -```csharp - /// Gets a team snapshot, or null when there is no live socket. - /// The owning guild snowflake. - /// The target server id. - /// A cancellation token. - /// A team snapshot, or null when there is no live socket. - Task GetTeamInfoAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); -``` - -- [ ] **Step 2: Add `GetTeamInfoAsync` to the internal `IRustServerConnection`** - -In `IRustServerConnection.cs`, after the existing `GetTimeAsync` method (the one returning `Task`), add: - -```csharp - /// Gets a team snapshot, or null on failure/timeout. - /// How long to wait for the response. - /// A cancellation token. - /// A team snapshot, or null on failure/timeout. - Task GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken); -``` - -- [ ] **Step 3: Build the Connections project (expect it to FAIL)** - -Run: `dotnet build src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj` -Expected: FAIL — `RustPlusSocketSource` (`RejectedConnection` + `RustPlusServerConnection`) and `ConnectionSupervisor` do not yet implement the new members. This confirms the interface change is wired in. Tasks 3 and 4 fix the build. - -- [ ] **Step 3: Commit** - -```bash -git add src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs -git commit -m "feat(connections): add GetTeamInfoAsync to query/connection seams" -``` - ---- - -## Task 3: Implement `GetTeamInfoAsync` on `RustPlusSocketSource` (untested shim) - -**Files:** - -- Modify: `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` - -This is the integration shim — by design it has no unit tests. Mapping verified against the real 2.0.0-beta.1 DLL: `RustPlus.GetTeamInfoAsync(CancellationToken)` returns `Task>`; `TeamInfo.Members` is `IEnumerable?`; each `MemberInfo` has `SteamId`/`Name?`/`X`/`Y`/`IsOnline`/`IsAlive`/`LastSpawnTime` (DateTime, UTC)/`LastDeathTime` (DateTime, UTC). - -- [ ] **Step 1: Add the no-op override on `RejectedConnection`** - -In `RustPlusSocketSource.cs`, inside the `RejectedConnection` class, after its `GetTimeAsync` method, add: - -```csharp - public Task GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => - Task.FromResult(null); -``` - -- [ ] **Step 2: Add the real mapping on `RustPlusServerConnection`** - -In `RustPlusSocketSource.cs`, inside the `RustPlusServerConnection` class, immediately after the existing `GetTimeAsync` method (the one ending at the `LogQueryFailed`-returning catch), add: - -```csharp - public async Task GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout); - try - { - // CONFIRMED (2.0.0-beta.1): GetTeamInfoAsync returns Task>; Response.IsSuccess/.Data. - // TeamInfo.LeaderSteamId (ulong), TeamInfo.Members (IEnumerable?). - // MemberInfo: SteamId/Name?/X/Y/IsOnline/IsAlive/LastSpawnTime(DateTime,UTC)/LastDeathTime(DateTime,UTC). - var response = await _rustPlus.GetTeamInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) - .ConfigureAwait(false); - if (!response.IsSuccess || response.Data is null) - { - return null; - } - - var members = (response.Data.Members ?? []) - .Select(m => new TeamMemberSnapshot( - m.SteamId, - m.Name ?? string.Empty, - m.X, - m.Y, - m.IsOnline, - m.IsAlive, - new DateTimeOffset(DateTime.SpecifyKind(m.LastSpawnTime, DateTimeKind.Utc)), - new DateTimeOffset(DateTime.SpecifyKind(m.LastDeathTime, DateTimeKind.Utc)))) - .ToList(); - return new TeamInfoSnapshot(response.Data.LeaderSteamId, members); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return null; - } -#pragma warning disable CA1031 // Broad catch: any team-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; - } - } -``` - -Note: this requires the using `RustPlusApi.Data` types to be reachable. `MemberInfo`/`TeamInfo` live in namespace `RustPlusApi.Data`; the file already has `using RustPlusApi;`. The `m =>` lambda references only `m`'s members so no extra `using` is needed (the element type is inferred). If the compiler complains it cannot find the member type, add `using RustPlusApi.Data;` at the top of the file. - -- [ ] **Step 3: Build the Connections project** - -Run: `dotnet build src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj` -Expected: still FAILS, but now only on `ConnectionSupervisor` (missing `IRustServerQuery.GetTeamInfoAsync`). `RustPlusSocketSource` errors are gone. (Task 4 finishes the build.) - -- [ ] **Step 4: Commit** - -```bash -git add src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs -git commit -m "feat(connections): map GetTeamInfoAsync in RustPlusSocketSource shim" -``` - ---- - -## Task 4: Implement `GetTeamInfoAsync` on `ConnectionSupervisor` - -**Files:** - -- Modify: `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` -- Test: `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` -- Test: `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` - -- [ ] **Step 1: Add `TeamResult` + `GetTeamInfoAsync` to the fake connection** - -In `FakeRustSocketSource.cs`, inside `FakeConnection`, after the `TimeResult` property add a `TeamResult` property (default a non-null empty snapshot, mirroring the other two defaults): - -```csharp - /// The snapshot returned by . Defaults to a non-null empty snapshot. - public TeamInfoSnapshot? TeamResult { get; set; } = new(0UL, []); -``` - -Then, after the existing `GetTimeAsync` method in `FakeConnection`, add: - -```csharp - public Task GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) => - Task.FromResult(TeamResult); -``` - -- [ ] **Step 2: Write the failing supervisor tests** - -In `ServerQueryTests.cs`, add two tests (after `GetTime_ReturnsNull_WhenNoLiveSocket`, before the private `WaitUntilAsync`): - -```csharp - [Fact] - public async Task GetTeamInfo_ReturnsSnapshot_WhenConnected() - { - var source = new FakeRustSocketSource(); - var (provider, supervisor) = CreateHarness(source); - await using var _ = provider; - var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); - await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); - - source.LastConnection!.TeamResult = new TeamInfoSnapshot( - 555UL, - [new TeamMemberSnapshot(555UL, "alice", 1f, 2f, true, true, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch)]); - var snapshot = await supervisor.GetTeamInfoAsync(10UL, serverId, cts.Token); - - Assert.NotNull(snapshot); - Assert.Equal(555UL, snapshot.LeaderSteamId); - Assert.Single(snapshot.Members); - Assert.Equal("alice", snapshot.Members[0].Name); - await supervisor.StopAllAsync(); - } - - [Fact] - public async Task GetTeamInfo_ReturnsNull_WhenNoLiveSocket() - { - var source = new FakeRustSocketSource(); - var (provider, supervisor) = CreateHarness(source); - await using var _ = provider; - - var snapshot = await supervisor.GetTeamInfoAsync(10UL, Guid.NewGuid(), CancellationToken.None); - - Assert.Null(snapshot); - } -``` - -- [ ] **Step 3: Run the tests to verify they fail to compile** - -Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj` -Expected: build FAIL — `ConnectionSupervisor` has no `GetTeamInfoAsync`. - -- [ ] **Step 4: Implement it on the supervisor** - -In `ConnectionSupervisor.cs`, immediately after the existing `GetTimeAsync` method (ends at line ~154), add: - -```csharp - /// - public async Task GetTeamInfoAsync( - ulong guildId, - Guid serverId, - CancellationToken cancellationToken) - { - if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) - { - return null; - } - - return await live.Connection.GetTeamInfoAsync(_options.HeartbeatTimeout, cancellationToken) - .ConfigureAwait(false); - } -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj` -Expected: PASS — all Connections tests green (read the count; should be the prior count + 2). - -- [ ] **Step 6: Commit** - -```bash -git add src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs -git commit -m "feat(connections): implement supervisor GetTeamInfoAsync over live sockets" -``` - ---- - -## Task 5: `Distance` helper - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Formatting/Distance.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Formatting/DistanceTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -using RustPlusBot.Features.Commands.Formatting; - -namespace RustPlusBot.Features.Commands.Tests.Formatting; - -public sealed class DistanceTests -{ - [Fact] - public void Between_ReturnsEuclideanDistance_Rounded() - { - Assert.Equal(5, Distance.Between(0f, 0f, 3f, 4f)); // 3-4-5 triangle - } - - [Fact] - public void Between_ReturnsZero_ForSamePoint() - { - Assert.Equal(0, Distance.Between(10f, 10f, 10f, 10f)); - } - - [Fact] - public void Between_RoundsToNearestInteger() - { - Assert.Equal(1, Distance.Between(0f, 0f, 1f, 0f)); - Assert.Equal(141, Distance.Between(0f, 0f, 100f, 100f)); // 141.42 -> 141 - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~DistanceTests"` -Expected: build FAIL — `Distance` does not exist. - -- [ ] **Step 3: Write the implementation** - -```csharp -namespace RustPlusBot.Features.Commands.Formatting; - -/// Computes planar distances between map coordinates for in-game replies. -internal static class Distance -{ - /// Euclidean distance between two map points, rounded to whole metres. - /// First point's horizontal coordinate. - /// First point's vertical coordinate. - /// Second point's horizontal coordinate. - /// Second point's vertical coordinate. - /// The distance rounded to the nearest whole number. - public static int Between(float x1, float y1, float x2, float y2) - { - var dx = x2 - x1; - var dy = y2 - y1; - return (int)Math.Round(Math.Sqrt((dx * dx) + (dy * dy)), MidpointRounding.AwayFromZero); - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~DistanceTests"` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Formatting/Distance.cs tests/RustPlusBot.Features.Commands.Tests/Formatting/DistanceTests.cs -git commit -m "feat(commands): add Distance helper for !prox" -``` - ---- - -## Task 6: `TeamMemberFilter` helper (shared partial name match) - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Tests.Formatting; - -public sealed class TeamMemberFilterTests -{ - private static readonly IReadOnlyList Members = - [ - Member(1UL, "Alice"), - Member(2UL, "Bob"), - Member(3UL, "Bobby"), - ]; - - private static TeamMemberSnapshot Member(ulong id, string name) => - new(id, name, 0f, 0f, true, true, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch); - - [Fact] - public void ByName_NullArg_ReturnsAll() - { - Assert.Equal(3, TeamMemberFilter.ByName(Members, null).Count); - } - - [Fact] - public void ByName_EmptyArg_ReturnsAll() - { - Assert.Equal(3, TeamMemberFilter.ByName(Members, " ").Count); - } - - [Fact] - public void ByName_PartialCaseInsensitive_MatchesSubstring() - { - var result = TeamMemberFilter.ByName(Members, "bob"); - Assert.Equal(2, result.Count); // Bob + Bobby - Assert.Contains(result, m => m.Name == "Bob"); - Assert.Contains(result, m => m.Name == "Bobby"); - } - - [Fact] - public void ByName_NoMatch_ReturnsEmpty() - { - Assert.Empty(TeamMemberFilter.ByName(Members, "zed")); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamMemberFilterTests"` -Expected: build FAIL — `TeamMemberFilter` does not exist. - -- [ ] **Step 3: Write the implementation** - -```csharp -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Formatting; - -/// Filters team members by an optional name argument for !prox and !steamid. -internal static class TeamMemberFilter -{ - /// Returns all members when is null/whitespace, - /// otherwise those whose name contains it (case-insensitive). - /// The members to filter. - /// The optional partial name filter. - /// The matching members (all when no filter is given). - public static IReadOnlyList ByName( - IReadOnlyList members, - string? nameArg) - { - ArgumentNullException.ThrowIfNull(members); - if (string.IsNullOrWhiteSpace(nameArg)) - { - return members; - } - - return members - .Where(m => m.Name.Contains(nameArg, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamMemberFilterTests"` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Formatting/TeamMemberFilter.cs tests/RustPlusBot.Features.Commands.Tests/Formatting/TeamMemberFilterTests.cs -git commit -m "feat(commands): add TeamMemberFilter for partial name matching" -``` - ---- - -## Task 7: Localization keys - -**Files:** - -- Modify: `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` - -All six handlers depend on these keys. Add them now so handler tests can assert exact strings. - -- [ ] **Step 1: Add the EN keys** - -In `CommandLocalizationCatalog.cs`, inside the `["en"]` dictionary, after the `["command.wipe.unknown"]` line, add: - -```csharp - ["command.online.ok"] = "Online ({0}): {1}", - ["command.online.none"] = "No one is online.", - ["command.offline.ok"] = "Offline ({0}): {1}", - ["command.offline.none"] = "Everyone is online.", - ["command.team.ok"] = "Team ({0}): {1}", - ["command.team.none"] = "No team members.", - ["command.team.nomatch"] = "No teammate matches '{0}'.", - ["command.steamid.ok"] = "{0}", - ["command.alive.ok"] = "Alive: {0}", - ["command.alive.dead"] = "{0} dead", - ["command.alive.member"] = "{0} {1}", - ["command.prox.ok"] = "Prox: {0}", - ["command.prox.member"] = "{0} {1}m", - ["command.prox.selfunknown"] = "Can't locate you.", -``` - -- [ ] **Step 2: Add the FR keys** - -Inside the `["fr"]` dictionary, after the `["command.wipe.unknown"]` line, add: - -```csharp - ["command.online.ok"] = "En ligne ({0}) : {1}", - ["command.online.none"] = "Personne n'est en ligne.", - ["command.offline.ok"] = "Hors ligne ({0}) : {1}", - ["command.offline.none"] = "Tout le monde est en ligne.", - ["command.team.ok"] = "Équipe ({0}) : {1}", - ["command.team.none"] = "Aucun membre d'équipe.", - ["command.team.nomatch"] = "Aucun coéquipier ne correspond à « {0} ».", - ["command.steamid.ok"] = "{0}", - ["command.alive.ok"] = "En vie : {0}", - ["command.alive.dead"] = "{0} mort", - ["command.alive.member"] = "{0} {1}", - ["command.prox.ok"] = "Prox : {0}", - ["command.prox.member"] = "{0} {1}m", - ["command.prox.selfunknown"] = "Impossible de vous localiser.", -``` - -- [ ] **Step 3: Build the Commands project** - -Run: `dotnet build src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj` -Expected: build succeeds, 0/0. - -- [ ] **Step 4: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs -git commit -m "feat(commands): add EN/FR strings for team-intel commands" -``` - ---- - -## Task 8: `!online` and `!offline` handlers - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs` -- Create: `src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs` - -- [ ] **Step 1: Create the shared handler test file with the first tests** - -Create `tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs`: - -```csharp -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; - -namespace RustPlusBot.Features.Commands.Tests.Handlers; - -public sealed class TeamIntelHandlersTests -{ - private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default); - - // Caller alice = steamId 7, present in every snapshot at (0,0). - private static CommandContext Ctx(params string[] args) => new(1, ServerId, "en", 7UL, "alice", args); - private static readonly Guid ServerId = Guid.NewGuid(); - - private static TeamMemberSnapshot Member( - ulong id, string name, bool online = true, bool alive = true, - float x = 0f, float y = 0f, DateTimeOffset? spawn = null) => - new(id, name, x, y, online, alive, spawn ?? DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch); - - private static IRustServerQuery QueryReturning(TeamInfoSnapshot? snapshot) - { - var query = Substitute.For(); - query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(snapshot); - return query; - } - - private sealed class TestClock : IClock - { - public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch; - } - - [Fact] - public async Task Online_ListsOnlineMembers() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice", online: true), - Member(8UL, "bob", online: true), - Member(9UL, "carl", online: false), - ])); - var reply = await new OnlineCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Online (2): alice, bob", reply); - } - - [Fact] - public async Task Online_None_WhenNobodyOnline() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, [Member(8UL, "bob", online: false)])); - var reply = await new OnlineCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("No one is online.", reply); - } - - [Fact] - public async Task Online_NotConnected_WhenNull() - { - var reply = await new OnlineCommandHandler(QueryReturning(null), Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Not connected to the server.", reply); - } - - [Fact] - public async Task Offline_ListsOfflineMembers() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice", online: true), - Member(9UL, "carl", online: false), - ])); - var reply = await new OfflineCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Offline (1): carl", reply); - } - - [Fact] - public async Task Offline_None_WhenEveryoneOnline() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, [Member(7UL, "alice", online: true)])); - var reply = await new OfflineCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Everyone is online.", reply); - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: build FAIL — `OnlineCommandHandler`/`OfflineCommandHandler` do not exist. - -- [ ] **Step 3: Create `OnlineCommandHandler`** - -```csharp -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Handlers; - -/// !online — lists currently-connected teammates. -/// The live server query. -/// The reply localizer. -internal sealed class OnlineCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler -{ - /// - public string Name => "online"; - - /// - public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken) - .ConfigureAwait(false); - if (team is null) - { - return localizer.Get("command.notconnected", context.Culture); - } - - var names = team.Members.Where(m => m.IsOnline).Select(m => m.Name).ToList(); - if (names.Count == 0) - { - return localizer.Get("command.online.none", context.Culture); - } - - return localizer.Get("command.online.ok", context.Culture, names.Count, string.Join(", ", names)); - } -} -``` - -- [ ] **Step 4: Create `OfflineCommandHandler`** - -```csharp -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Handlers; - -/// !offline — lists teammates not currently connected. -/// The live server query. -/// The reply localizer. -internal sealed class OfflineCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler -{ - /// - public string Name => "offline"; - - /// - public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken) - .ConfigureAwait(false); - if (team is null) - { - return localizer.Get("command.notconnected", context.Culture); - } - - var names = team.Members.Where(m => !m.IsOnline).Select(m => m.Name).ToList(); - if (names.Count == 0) - { - return localizer.Get("command.offline.none", context.Culture); - } - - return localizer.Get("command.offline.ok", context.Culture, names.Count, string.Join(", ", names)); - } -} -``` - -- [ ] **Step 5: Run to verify the tests pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: PASS (5 tests so far). - -- [ ] **Step 6: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs -git commit -m "feat(commands): add !online and !offline handlers" -``` - ---- - -## Task 9: `!team` and `!steamid` handlers - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs` -- Create: `src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs` - -- [ ] **Step 1: Add the failing tests** - -Append these tests inside the `TeamIntelHandlersTests` class: - -```csharp - [Fact] - public async Task Team_ListsAllMembers() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice"), - Member(8UL, "bob", online: false), - ])); - var reply = await new TeamCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Team (2): alice, bob", reply); - } - - [Fact] - public async Task Team_None_WhenNoMembers() - { - var reply = await new TeamCommandHandler(QueryReturning(new TeamInfoSnapshot(0UL, [])), Loc) - .ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("No team members.", reply); - } - - [Fact] - public async Task SteamId_NoArg_ListsAll() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice"), - Member(8UL, "bob"), - ])); - var reply = await new SteamIdCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("alice 7, bob 8", reply); - } - - [Fact] - public async Task SteamId_NameArg_FiltersToOne() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice"), - Member(8UL, "bob"), - ])); - var reply = await new SteamIdCommandHandler(query, Loc).ExecuteAsync(Ctx("bob"), CancellationToken.None); - Assert.Equal("bob 8", reply); - } - - [Fact] - public async Task SteamId_NameArg_NoMatch_ReportsNoMatch() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, [Member(7UL, "alice")])); - var reply = await new SteamIdCommandHandler(query, Loc).ExecuteAsync(Ctx("zed"), CancellationToken.None); - Assert.Equal("No teammate matches 'zed'.", reply); - } - - [Fact] - public async Task SteamId_None_WhenNoMembers() - { - var reply = await new SteamIdCommandHandler(QueryReturning(new TeamInfoSnapshot(0UL, [])), Loc) - .ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("No team members.", reply); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: build FAIL — handlers do not exist. - -- [ ] **Step 3: Create `TeamCommandHandler`** - -```csharp -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Handlers; - -/// !team — lists every team member's name. -/// The live server query. -/// The reply localizer. -internal sealed class TeamCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler -{ - /// - public string Name => "team"; - - /// - public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken) - .ConfigureAwait(false); - if (team is null) - { - return localizer.Get("command.notconnected", context.Culture); - } - - if (team.Members.Count == 0) - { - return localizer.Get("command.team.none", context.Culture); - } - - var names = team.Members.Select(m => m.Name).ToList(); - return localizer.Get("command.team.ok", context.Culture, names.Count, string.Join(", ", names)); - } -} -``` - -- [ ] **Step 4: Create `SteamIdCommandHandler`** - -```csharp -using System.Globalization; -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Handlers; - -/// !steamid [name] — reports teammates' Steam ids (all, or filtered by partial name). -/// The live server query. -/// The reply localizer. -internal sealed class SteamIdCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler -{ - /// - public string Name => "steamid"; - - /// - public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken) - .ConfigureAwait(false); - if (team is null) - { - return localizer.Get("command.notconnected", context.Culture); - } - - if (team.Members.Count == 0) - { - return localizer.Get("command.team.none", context.Culture); - } - - var nameArg = context.Args.Count > 0 ? context.Args[0] : null; - var matches = TeamMemberFilter.ByName(team.Members, nameArg); - if (matches.Count == 0) - { - return localizer.Get("command.team.nomatch", context.Culture, nameArg ?? string.Empty); - } - - var pairs = matches.Select(m => - string.Create(CultureInfo.InvariantCulture, $"{m.Name} {m.SteamId}")); - return localizer.Get("command.steamid.ok", context.Culture, string.Join(", ", pairs)); - } -} -``` - -- [ ] **Step 5: Run to verify the tests pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: PASS (11 tests so far). - -- [ ] **Step 6: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs -git commit -m "feat(commands): add !team and !steamid handlers" -``` - ---- - -## Task 10: `!alive` handler - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs` - -`!alive` shows alive members sorted by survival (now − LastSpawnTimeUtc) descending, each as `" "` via `DurationFormat.Compact`, then dead members as `" dead"`. Uses `IClock`. - -- [ ] **Step 1: Add the failing tests** - -Append inside `TeamIntelHandlersTests`: - -```csharp - [Fact] - public async Task Alive_SortsBySurvivalDesc_ThenDeadLast() - { - var clock = new TestClock { UtcNow = DateTimeOffset.UnixEpoch.AddHours(3) }; - // carl spawned at epoch -> 3h survival; alice spawned at +2h -> 1h survival; bob dead. - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice", alive: true, spawn: DateTimeOffset.UnixEpoch.AddHours(2)), - Member(8UL, "bob", alive: false), - Member(9UL, "carl", alive: true, spawn: DateTimeOffset.UnixEpoch), - ])); - var reply = await new AliveCommandHandler(query, Loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Alive: carl 3h 0m, alice 1h 0m, bob dead", reply); - } - - [Fact] - public async Task Alive_None_WhenNoMembers() - { - var reply = await new AliveCommandHandler(QueryReturning(new TeamInfoSnapshot(0UL, [])), Loc, new TestClock()) - .ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("No team members.", reply); - } - - [Fact] - public async Task Alive_NotConnected_WhenNull() - { - var reply = await new AliveCommandHandler(QueryReturning(null), Loc, new TestClock()) - .ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Not connected to the server.", reply); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: build FAIL — `AliveCommandHandler` does not exist. - -- [ ] **Step 3: Create `AliveCommandHandler`** - -```csharp -using RustPlusBot.Abstractions.Time; -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Handlers; - -/// !alive — reports per-member survival times (longest first); dead members shown as "dead". -/// The live server query. -/// The reply localizer. -/// The clock used to compute survival durations. -internal sealed class AliveCommandHandler(IRustServerQuery query, ICommandLocalizer localizer, IClock clock) - : ICommandHandler -{ - /// - public string Name => "alive"; - - /// - public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken) - .ConfigureAwait(false); - if (team is null) - { - return localizer.Get("command.notconnected", context.Culture); - } - - if (team.Members.Count == 0) - { - return localizer.Get("command.team.none", context.Culture); - } - - var now = clock.UtcNow; - var alive = team.Members - .Where(m => m.IsAlive) - .OrderByDescending(m => now - m.LastSpawnTimeUtc) - .Select(m => localizer.Get( - "command.alive.member", context.Culture, - m.Name, DurationFormat.Compact(now - m.LastSpawnTimeUtc))); - var dead = team.Members - .Where(m => !m.IsAlive) - .Select(m => localizer.Get("command.alive.dead", context.Culture, m.Name)); - - var parts = alive.Concat(dead).ToList(); - return localizer.Get("command.alive.ok", context.Culture, string.Join(", ", parts)); - } -} -``` - -- [ ] **Step 4: Run to verify the tests pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: PASS (14 tests so far). - -- [ ] **Step 5: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs -git commit -m "feat(commands): add !alive handler with per-member survival times" -``` - ---- - -## Task 11: `!prox` handler - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs` - -`!prox [name]` finds the caller's own position in the snapshot (match `SenderSteamId`), then reports the distance to each OTHER member (`" m"`), optionally filtered to one by partial name. If the caller is not in the snapshot → `command.prox.selfunknown`. If a name arg matches nobody → `command.team.nomatch`. - -- [ ] **Step 1: Add the failing tests** - -Append inside `TeamIntelHandlersTests`: - -```csharp - [Fact] - public async Task Prox_ListsDistancesToOtherMembers() - { - // caller alice (steamId 7) at (0,0); bob at (3,4) -> 5m; carl at (0,10) -> 10m. - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice", x: 0f, y: 0f), - Member(8UL, "bob", x: 3f, y: 4f), - Member(9UL, "carl", x: 0f, y: 10f), - ])); - var reply = await new ProxCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Prox: bob 5m, carl 10m", reply); - } - - [Fact] - public async Task Prox_NameArg_FiltersToOne() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice", x: 0f, y: 0f), - Member(8UL, "bob", x: 3f, y: 4f), - ])); - var reply = await new ProxCommandHandler(query, Loc).ExecuteAsync(Ctx("bob"), CancellationToken.None); - Assert.Equal("Prox: bob 5m", reply); - } - - [Fact] - public async Task Prox_NameArg_NoMatch_ReportsNoMatch() - { - var query = QueryReturning(new TeamInfoSnapshot(7UL, - [ - Member(7UL, "alice", x: 0f, y: 0f), - Member(8UL, "bob", x: 3f, y: 4f), - ])); - var reply = await new ProxCommandHandler(query, Loc).ExecuteAsync(Ctx("zed"), CancellationToken.None); - Assert.Equal("No teammate matches 'zed'.", reply); - } - - [Fact] - public async Task Prox_SelfUnknown_WhenCallerNotInSnapshot() - { - var query = QueryReturning(new TeamInfoSnapshot(8UL, [Member(8UL, "bob", x: 3f, y: 4f)])); - var reply = await new ProxCommandHandler(query, Loc).ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("Can't locate you.", reply); - } - - [Fact] - public async Task Prox_None_WhenNoMembers() - { - var reply = await new ProxCommandHandler(QueryReturning(new TeamInfoSnapshot(0UL, [])), Loc) - .ExecuteAsync(Ctx(), CancellationToken.None); - Assert.Equal("No team members.", reply); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: build FAIL — `ProxCommandHandler` does not exist. - -- [ ] **Step 3: Create `ProxCommandHandler`** - -```csharp -using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Handlers; - -/// !prox [name] — distance from the caller to each other teammate (optionally one). -/// The live server query. -/// The reply localizer. -internal sealed class ProxCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler -{ - /// - public string Name => "prox"; - - /// - public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken) - .ConfigureAwait(false); - if (team is null) - { - return localizer.Get("command.notconnected", context.Culture); - } - - if (team.Members.Count == 0) - { - return localizer.Get("command.team.none", context.Culture); - } - - var self = team.Members.FirstOrDefault(m => m.SteamId == context.SenderSteamId); - if (self is null) - { - return localizer.Get("command.prox.selfunknown", context.Culture); - } - - var others = team.Members.Where(m => m.SteamId != context.SenderSteamId).ToList(); - var nameArg = context.Args.Count > 0 ? context.Args[0] : null; - var matches = TeamMemberFilter.ByName(others, nameArg); - if (matches.Count == 0) - { - return localizer.Get("command.team.nomatch", context.Culture, nameArg ?? string.Empty); - } - - var parts = matches.Select(m => localizer.Get( - "command.prox.member", context.Culture, m.Name, Distance.Between(self.X, self.Y, m.X, m.Y))); - return localizer.Get("command.prox.ok", context.Culture, string.Join(", ", parts)); - } -} -``` - -- [ ] **Step 4: Run to verify the tests pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj --filter "FullyQualifiedName~TeamIntelHandlersTests"` -Expected: PASS (19 tests so far). - -- [ ] **Step 5: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs -git commit -m "feat(commands): add !prox handler with caller-relative distances" -``` - ---- - -## Task 12: Register the six handlers - -**Files:** - -- Modify: `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` (verify, may already assert resolvability) - -- [ ] **Step 1: Register the handlers** - -In `AddCommands`, after the existing `services.AddScoped();` line, add: - -```csharp - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); -``` - -- [ ] **Step 2: Update the registration test count** - -`CommandRegistrationTests.Dispatcher_and_handlers_resolve` asserts the exact handler count, which WILL break when six handlers are added. In `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs`, change: - -```csharp - Assert.Equal(6, handlers.Count); -``` - -to: - -```csharp - Assert.Equal(12, handlers.Count); -``` - -and add assertions for the new names after the existing `Assert.Contains(handlers, h => h.Name == "time");` line: - -```csharp - Assert.Contains(handlers, h => h.Name == "online"); - Assert.Contains(handlers, h => h.Name == "offline"); - Assert.Contains(handlers, h => h.Name == "team"); - Assert.Contains(handlers, h => h.Name == "steamid"); - Assert.Contains(handlers, h => h.Name == "alive"); - Assert.Contains(handlers, h => h.Name == "prox"); -``` - -Note: this test already registers a substitute `IRustServerQuery` (line 25), so the new handlers' constructor dependency resolves without further wiring. - -- [ ] **Step 3: Run the Commands test suite** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj` -Expected: PASS — all Commands tests (prior 43 + the new ~26 = ~69; read the count). - -- [ ] **Step 4: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs -git commit -m "feat(commands): register team-intel handlers in DI" -``` - ---- - -## Task 13: Full-suite verification + format gate - -**Files:** none (verification only). - -- [ ] **Step 1: Build the whole solution strict** - -Run: `dotnet build RustPlusBot.slnx` -Expected: 0 warnings / 0 errors. - -- [ ] **Step 2: Run the FULL test suite and read per-assembly counts** - -Run: `dotnet test RustPlusBot.slnx` -Expected: all assemblies green. **Read each assembly's count** — a sudden drop means a fake/interface broke that assembly's build (the 3a/3b lesson). Confirm Connections gained 2 and Commands gained the team-intel tests; no other assembly's count fell. - -- [ ] **Step 3: Run the format gate** - -Run: `dotnet tool restore && dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` -Expected: completes. If it reorders/reformats files, review the diff (it commonly reorders members and wraps collection initializers that Roslynator does not flag). - -- [ ] **Step 4: Re-build and re-test after cleanup** - -Run: `dotnet build RustPlusBot.slnx && dotnet test RustPlusBot.slnx` -Expected: still 0/0 and all green. - -- [ ] **Step 5: Confirm no EF model drift** - -Run: `dotnet ef migrations has-pending-model-changes --project src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj --startup-project src/RustPlusBot/RustPlusBot.csproj` -Expected: "No changes have been made..." (no migration needed — team info is read-only live data). If the project uses a different EF invocation, fall back to confirming no new `Migrations/` files and that the build's model-drift check (if any) passes. - -- [ ] **Step 6: Commit any jb-cleanup changes** - -```bash -git add -A -git commit -m "style: apply jb ReformatAndReorder for team-intel changes" -``` - -(Skip this commit if Step 3 produced no changes.) - ---- - -## Task 14: Update the feature catalog - -**Files:** - -- Modify: `docs/product/feature-catalog.md` (TRACKED — normal `git add`, no `-f`). - -- [ ] **Step 1: Flip the shipped rows to Done** - -In `docs/product/feature-catalog.md`, change the decision marker on the `!online`/`!offline`/`!team`/`!alive`/`!prox`/`!steamid` rows from `✅ Adopt` to `✔️ Done`. Update the `3b-ii` cluster row's status to reflect it is shipped (e.g. `⏸ Planned` → `✔️ Done`). For `!afk`, note it is deferred pending a position-tracking poller. - -- [ ] **Step 2: Commit** - -```bash -git add docs/product/feature-catalog.md -git commit -m "docs: mark 3b-ii team-intel commands done in feature catalog" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** all six commands (Tasks 8–11), the seam extension + DTO (Tasks 1–4), the untested shim mapping (Task 3), both helpers (Tasks 5–6), EN/FR localization with every edge-case key (Task 7), registration (Task 12), the full verification gate incl. per-assembly counts + jb + no-drift (Task 13), and catalog update (Task 14). `!afk` is explicitly deferred (mentioned in Task 14). No new entities/migrations/events/options — consistent with the spec's non-goals. -- **Type consistency:** `TeamInfoSnapshot(ulong LeaderSteamId, IReadOnlyList Members)` and `TeamMemberSnapshot(SteamId, Name, X, Y, IsOnline, IsAlive, LastSpawnTimeUtc, LastDeathTimeUtc)` are used identically across the shim, fake, supervisor test, and every handler/test. `IRustServerQuery.GetTeamInfoAsync(ulong, Guid, CancellationToken)` and `IRustServerConnection.GetTeamInfoAsync(TimeSpan, CancellationToken)` match their consumers. Localization keys defined in Task 7 are exactly the keys read in Tasks 8–11. -- **Ordering note:** `!alive` sorts by `now - LastSpawnTimeUtc` descending; the test fixes the clock so durations are deterministic and `DurationFormat.Compact` renders `"3h 0m"`/`"1h 0m"` (matches the existing `Uptime`/`Wipe` format). -- **Distance rounding:** `Distance.Between` rounds away-from-zero; the test's `141` (from 141.42) and `5` (3-4-5) confirm the contract the handlers depend on. diff --git a/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md b/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md deleted file mode 100644 index b1af8b67..00000000 --- a/docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md +++ /dev/null @@ -1,1524 +0,0 @@ -# RustPlusBot 3c — Command Discord Surfaces — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Surface the in-game command framework into Discord — `/help`, `/uptime`, `/leader`, and a team summary on the per-server `#info` embed. - -**Architecture:** Slash modules + a help catalog live in the existing `RustPlusBot.Features.Commands` project (Approach A); they reuse `BotUptime`, `ICommandLocalizer`, the `ICommandHandler` registry, and `IRustServerQuery`. `/leader` adds a `PromoteToLeaderAsync` method to the `IRustServerQuery`/`IRustServerConnection` seam (supervisor + untested `RustPlusSocketSource` shim). The `#info` team summary is a local edit to Workspace's `ServerInfoMessageRenderer`. No new project, entity, migration, event, options, or background service. - -**Tech Stack:** .NET 10, C# 13, Discord.Net 3.20 (`Discord.Interactions`), RustPlusApi 2.0.0-beta.1, xUnit + NSubstitute, EF Core/SQLite (test harness only), strict Roslynator analyzers, ReSharper `jb cleanupcode` format gate. - -## Global Constraints - -- **Branch:** `feat/command-surfaces` off `develop`. -- **No secrets in replies or logs** — `PromoteToLeaderAsync` broad-catches to `false`; never throw with a token value. -- **Localization:** all user-facing copy is EN + FR via the existing per-feature catalogs (`CommandLocalizationCatalog` for Commands; `LocalizationCatalog` for Workspace). Ephemeral confirm/result text is acceptable in English only where it mirrors prior slices' convention, but reply embeds (`/help`, `/uptime`, `/leader`, `#info` field) ARE localized. -- **Interaction modules (`InteractionModuleBase`) are NOT unit-tested** — push logic into testable services/renderers; keep modules thin. -- **Run the FULL test suite and read per-assembly counts** after touching shared seams (`IRustServerQuery`, `FakeRustSocketSource`, `IRustServerConnection`) — a missing fake method silently drops a whole assembly's tests. -- **NSubstitute on internal interfaces** needs `` in the project under test (Connections already has it; check Commands). -- **`SelectMenuBuilder` hard-caps at 25 options** — `.Take(SelectMenuBuilder.MaxOptionCount)` any member/server list. -- **`Discord.ConnectionState` collides** with `Domain.Connections.ConnectionState` — alias (`using DomainConnectionState = …`) if both are named in one file. -- **Roslynator:** `// TODO` comments are errors (`S1135`) — use ``. An interface impl must repeat `= default` on a `CancellationToken` (`S1006`). Prefer concrete `Dictionary<>` where `CA1859` flags an interface field. -- **Before pushing:** `dotnet tool restore` then `dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` (the real format gate; the pre-push hook enforces it). It reorders members Roslynator never flags. -- **Build must be 0 warnings / 0 errors** under strict analyzers; **no EF model drift** (this slice adds none). - ---- - -## File Structure - -**Connections (extend the existing seam):** - -- Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` — add `PromoteToLeaderAsync`. -- Modify `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` — add `PromoteToLeaderAsync`. -- Modify `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` — implement the query method over `_liveSockets`. -- Modify `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` — implement on both `RejectedConnection` (returns false) and `RustPlusServerConnection` (wraps the real API). -- Modify `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` — add the method to `FakeConnection`. -- Modify `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` — add promote tests. - -**Commands (new surfaces):** - -- Create `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs` — the grouping enum. -- Create `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` — curated manifest. -- Create `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs` — pure embed builder. -- Create `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs` — testable promote/fetch logic. -- Create `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs` — `/help`, `/uptime`, `/leader`. -- Create `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs` — select callbacks. -- Modify `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` — new EN/FR keys. -- Modify `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` — register catalog, renderer, service, module assembly. -- Modify `src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj` — add a `RustPlusBot.Discord` project reference and a `Discord.Net` package reference (needed for `InteractionModuleBase`/`SocketInteractionContext`/`InteractionModuleAssembly`). The `DynamicProxyGenAssembly2` + test-project InternalsVisibleTo are already present — leave them. -- Create test files under `tests/RustPlusBot.Features.Commands.Tests/Help/` and `…/Leader/`. -- Modify `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` — assert new registrations resolve. - -**Workspace (`#info` summary):** - -- Modify `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` — add the team field. -- Modify `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs` — new EN/FR keys. -- Modify `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` — new ctor arg + team-field cases. - ---- - -## Task 1: `PromoteToLeaderAsync` on the connection seam - -**Files:** - -- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs` -- Modify: `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs` -- Modify: `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs` -- Modify: `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs` -- Modify: `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` -- Test: `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs` - -**Interfaces:** - -- Produces: - - `IRustServerQuery.PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken)` → `Task` (true = promoted; false = no live socket or API non-success). - - `IRustServerConnection.PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken)` → `Task`. - -- [ ] **Step 1: Add the fake method (so the suite still builds) and write the failing supervisor tests** - -In `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs`, add to `FakeConnection` (after `GetTeamInfoAsync`): - -```csharp -/// The result returned by . Defaults to true. -public bool PromoteResult { get; set; } = true; - -/// The Steam ID passed to the most recent call. -public ulong LastPromotedSteamId { get; private set; } - -public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) -{ - LastPromotedSteamId = steamId; - return Task.FromResult(PromoteResult); -} -``` - -In `tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs`, add (mirrors `GetTeamInfo_*`): - -```csharp -[Fact] -public async Task PromoteToLeader_ReturnsTrueAndForwardsSteamId_WhenConnected() -{ - var source = new FakeRustSocketSource(); - var (provider, supervisor) = CreateHarness(source); - await using var _ = provider; - var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); - await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); - - source.LastConnection!.PromoteResult = true; - var promoted = await supervisor.PromoteToLeaderAsync(10UL, serverId, 999UL, cts.Token); - - Assert.True(promoted); - Assert.Equal(999UL, source.LastConnection.LastPromotedSteamId); - await supervisor.StopAllAsync(); -} - -[Fact] -public async Task PromoteToLeader_ReturnsFalse_WhenApiNonSuccess() -{ - var source = new FakeRustSocketSource(); - var (provider, supervisor) = CreateHarness(source); - await using var _ = provider; - var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); - await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); - - source.LastConnection!.PromoteResult = false; - var promoted = await supervisor.PromoteToLeaderAsync(10UL, serverId, 999UL, cts.Token); - - Assert.False(promoted); - await supervisor.StopAllAsync(); -} - -[Fact] -public async Task PromoteToLeader_ReturnsFalse_WhenNoLiveSocket() -{ - var source = new FakeRustSocketSource(); - var (provider, supervisor) = CreateHarness(source); - await using var _ = provider; - - var promoted = await supervisor.PromoteToLeaderAsync(10UL, Guid.NewGuid(), 999UL, CancellationToken.None); - - Assert.False(promoted); -} -``` - -- [ ] **Step 2: Run the new tests to verify they fail to compile / fail** - -Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests --filter "FullyQualifiedName~PromoteToLeader"` -Expected: COMPILE ERROR — `IRustServerConnection`/`IRustServerQuery`/`ConnectionSupervisor` have no `PromoteToLeaderAsync`. - -- [ ] **Step 3: Add `PromoteToLeaderAsync` to `IRustServerConnection`** - -In `src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs`, after `SendTeamMessageAsync`: - -```csharp -/// Promotes a team member to team leader; returns true on success. -/// Steam64 id of the member to promote. -/// How long to wait for the response. -/// A cancellation token. -/// True if the promotion succeeded; false on failure/timeout. -Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken); -``` - -- [ ] **Step 4: Add `PromoteToLeaderAsync` to `IRustServerQuery`** - -In `src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs`, after `GetTeamInfoAsync`: - -```csharp -/// Promotes a team member to team leader; returns false when there is no live socket or the API fails. -/// The owning guild snowflake. -/// The target server id. -/// Steam64 id of the member to promote. -/// A cancellation token. -/// True if promoted; false when no live socket or the API fails. -Task PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken); -``` - -- [ ] **Step 5: Implement on `ConnectionSupervisor`** - -In `src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs`, after `GetTeamInfoAsync` (before `SendAsync`): - -```csharp -/// -public async Task PromoteToLeaderAsync( - ulong guildId, - Guid serverId, - ulong steamId, - CancellationToken cancellationToken) -{ - if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) - { - return false; - } - - return await live.Connection.PromoteToLeaderAsync(steamId, _options.HeartbeatTimeout, cancellationToken) - .ConfigureAwait(false); -} -``` - -- [ ] **Step 6: Implement on the `RejectedConnection` and `RustPlusServerConnection` shim classes** - -In `src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs`, add to `RejectedConnection` (after `SendTeamMessageAsync`): - -```csharp -public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) => - Task.FromResult(false); -``` - -And add to `RustPlusServerConnection` (after `SendTeamMessageAsync`, before `DisposeAsync`): - -```csharp -public async Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken cancellationToken) -{ - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout); - try - { - // CONFIRMED (2.0.0-beta.1): PromoteToLeaderAsync(ulong, CancellationToken) returns a payload-free - // Task; Response.IsSuccess indicates the outcome. - var response = await _rustPlus.PromoteToLeaderAsync(steamId, timeoutCts.Token).WaitAsync(timeoutCts.Token) - .ConfigureAwait(false); - return response.IsSuccess; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return false; - } -#pragma warning disable CA1031 // Broad catch: any promote failure maps to false; never surface a token/secret. - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) -#pragma warning restore CA1031 - { - LogQueryFailed(_logger, ex); - return false; - } -} -``` - -- [ ] **Step 7: Run the new tests to verify they pass** - -Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests --filter "FullyQualifiedName~PromoteToLeader"` -Expected: PASS (3 tests). - -- [ ] **Step 8: Run the FULL Connections suite + confirm count** - -Run: `dotnet test tests/RustPlusBot.Features.Connections.Tests` -Expected: PASS, count = 26 (23 prior + 3 new). If the assembly's total looks low, a fake method is missing — fix before continuing. - -- [ ] **Step 9: Commit** - -```bash -git add src/RustPlusBot.Features.Connections tests/RustPlusBot.Features.Connections.Tests -git commit -m "feat(3c): add PromoteToLeaderAsync to the connection query seam - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 2: `CommandGroup` + `CommandHelpCatalog` with a drift/i18n guard - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs` -- Create: `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs` -- Modify: `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs` - -**Interfaces:** - -- Consumes: the existing `ICommandHandler.Name` registry (12 handlers: `mute`, `unmute`, `uptime`, `pop`, `wipe`, `time`, `online`, `offline`, `team`, `steamid`, `alive`, `prox`). -- Produces: - - `enum CommandGroup { Control, Server, TeamIntel, Bot }`. - - `sealed record CommandHelpEntry(string Name, CommandGroup Group, string DescriptionKey)`. - - `static class CommandHelpCatalog` with `IReadOnlyList InGame` and `IReadOnlyList Slash`. - -- [ ] **Step 1: Write the failing catalog tests** - -Create `tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs`: - -```csharp -using RustPlusBot.Features.Commands.Help; -using RustPlusBot.Features.Commands.Localization; - -namespace RustPlusBot.Features.Commands.Tests.Help; - -public sealed class CommandHelpCatalogTests -{ - // The 12 registered in-game handler names (see AddCommands / CommandRegistrationTests). - private static readonly string[] HandlerNames = - [ - "mute", "unmute", "uptime", "pop", "wipe", "time", - "online", "offline", "team", "steamid", "alive", "prox", - ]; - - [Fact] - public void EveryRegisteredHandlerHasAnInGameEntry() - { - var entryNames = CommandHelpCatalog.InGame.Select(e => e.Name).ToHashSet(StringComparer.Ordinal); - foreach (var name in HandlerNames) - { - Assert.Contains(name, entryNames); - } - } - - [Fact] - public void InGameCatalogHasNoEntryWithoutAHandler() - { - var handlerNames = HandlerNames.ToHashSet(StringComparer.Ordinal); - foreach (var entry in CommandHelpCatalog.InGame) - { - Assert.Contains(entry.Name, handlerNames); - } - } - - [Fact] - public void EveryDescriptionKeyResolvesInEnglishAndFrench() - { - var loc = new CommandLocalizer(CommandLocalizationCatalog.Default); - foreach (var entry in CommandHelpCatalog.InGame.Concat(CommandHelpCatalog.Slash)) - { - // Get returns the key itself when unresolved; a resolved value must differ from the key. - Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "en")); - Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "fr")); - } - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~CommandHelpCatalogTests"` -Expected: COMPILE ERROR — `CommandHelpCatalog`/`CommandGroup` do not exist. - -- [ ] **Step 3: Create `CommandGroup`** - -Create `src/RustPlusBot.Features.Commands/Help/CommandGroup.cs`: - -```csharp -namespace RustPlusBot.Features.Commands.Help; - -/// The display grouping for a command in the /help listing. -internal enum CommandGroup -{ - /// Bot control commands (mute/unmute). - Control, - - /// Server-state commands (pop/time/wipe). - Server, - - /// Team-intel commands (online/offline/team/steamid/alive/prox). - TeamIntel, - - /// Bot-meta commands (uptime). - Bot, -} -``` - -- [ ] **Step 4: Create `CommandHelpCatalog`** - -Create `src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs`: - -```csharp -namespace RustPlusBot.Features.Commands.Help; - -/// One entry in the help listing. -/// The command name without prefix (in-game) or slash command name. -/// The display group. -/// The localization key for the description. -internal sealed record CommandHelpEntry(string Name, CommandGroup Group, string DescriptionKey); - -/// The curated help manifest. Tested against the live handler registry to catch drift. -internal static class CommandHelpCatalog -{ - /// The in-game !commands, in display order. - public static IReadOnlyList InGame { get; } = - [ - new("mute", CommandGroup.Control, "help.mute"), - new("unmute", CommandGroup.Control, "help.unmute"), - new("pop", CommandGroup.Server, "help.pop"), - new("time", CommandGroup.Server, "help.time"), - new("wipe", CommandGroup.Server, "help.wipe"), - new("online", CommandGroup.TeamIntel, "help.online"), - new("offline", CommandGroup.TeamIntel, "help.offline"), - new("team", CommandGroup.TeamIntel, "help.team"), - new("steamid", CommandGroup.TeamIntel, "help.steamid"), - new("alive", CommandGroup.TeamIntel, "help.alive"), - new("prox", CommandGroup.TeamIntel, "help.prox"), - new("uptime", CommandGroup.Bot, "help.uptime"), - ]; - - /// The Discord slash commands, in display order. - public static IReadOnlyList Slash { get; } = - [ - new("help", CommandGroup.Bot, "help.slash.help"), - new("uptime", CommandGroup.Bot, "help.slash.uptime"), - new("leader", CommandGroup.Bot, "help.slash.leader"), - ]; -} -``` - -- [ ] **Step 5: Add the localization keys** - -In `src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs`, add to the `["en"]` dictionary (after `command.prox.alone`): - -```csharp -["help.title"] = "Commands", -["help.group.control"] = "Control", -["help.group.server"] = "Server", -["help.group.teamintel"] = "Team", -["help.group.bot"] = "Bot", -["help.group.slash"] = "Slash commands", -["help.prefixnote"] = "Prefixes are configured per server; showing the default.", -["help.mute"] = "Silence all bot output to the game.", -["help.unmute"] = "Resume bot output to the game.", -["help.pop"] = "Show the server population.", -["help.time"] = "Show the in-game time.", -["help.wipe"] = "Show how long ago the server wiped.", -["help.online"] = "List online teammates.", -["help.offline"] = "List offline teammates.", -["help.team"] = "List all team members.", -["help.steamid"] = "Show teammates' Steam IDs.", -["help.alive"] = "Show how long each teammate has survived.", -["help.prox"] = "Show distance to each teammate.", -["help.uptime"] = "Show how long the bot has been running.", -["help.slash.help"] = "Show this help message.", -["help.slash.uptime"] = "Show the bot's uptime.", -["help.slash.leader"] = "Transfer in-game team leadership.", -["uptime.ok"] = "Bot uptime: {0}", -["leader.noserver"] = "No server is set up yet.", -["leader.notconnected"] = "Not connected to this server.", -["leader.noteam"] = "No team members to promote.", -["leader.pickserver"] = "Choose a server:", -["leader.pickmember"] = "Choose who to promote to team leader:", -["leader.success"] = "Promoted {0} to team leader.", -["leader.failure"] = "Couldn't promote — the server may be unreachable.", -``` - -And add the matching `["fr"]` entries (after `command.prox.alone`): - -```csharp -["help.title"] = "Commandes", -["help.group.control"] = "Contrôle", -["help.group.server"] = "Serveur", -["help.group.teamintel"] = "Équipe", -["help.group.bot"] = "Bot", -["help.group.slash"] = "Commandes slash", -["help.prefixnote"] = "Les préfixes sont configurés par serveur ; affichage du préfixe par défaut.", -["help.mute"] = "Couper toute sortie du bot vers le jeu.", -["help.unmute"] = "Réactiver la sortie du bot vers le jeu.", -["help.pop"] = "Afficher la population du serveur.", -["help.time"] = "Afficher l'heure en jeu.", -["help.wipe"] = "Afficher depuis combien de temps le serveur a été wipe.", -["help.online"] = "Lister les coéquipiers en ligne.", -["help.offline"] = "Lister les coéquipiers hors ligne.", -["help.team"] = "Lister tous les membres de l'équipe.", -["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.", -["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.", -["help.prox"] = "Afficher la distance à chaque coéquipier.", -["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.", -["help.slash.help"] = "Afficher ce message d'aide.", -["help.slash.uptime"] = "Afficher la disponibilité du bot.", -["help.slash.leader"] = "Transférer le rôle de chef d'équipe en jeu.", -["uptime.ok"] = "Disponibilité du bot : {0}", -["leader.noserver"] = "Aucun serveur n'est encore configuré.", -["leader.notconnected"] = "Non connecté à ce serveur.", -["leader.noteam"] = "Aucun membre d'équipe à promouvoir.", -["leader.pickserver"] = "Choisissez un serveur :", -["leader.pickmember"] = "Choisissez qui promouvoir chef d'équipe :", -["leader.success"] = "{0} promu chef d'équipe.", -["leader.failure"] = "Impossible de promouvoir — le serveur est peut-être injoignable.", -``` - -- [ ] **Step 6: Run the catalog tests to verify they pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~CommandHelpCatalogTests"` -Expected: PASS (3 tests). - -- [ ] **Step 7: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Help src/RustPlusBot.Features.Commands/Localization tests/RustPlusBot.Features.Commands.Tests/Help -git commit -m "feat(3c): add command help catalog + help/leader/uptime localization keys - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 3: `HelpEmbedRenderer` (pure) - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs` - -**Interfaces:** - -- Consumes: `CommandHelpCatalog`, `ICommandLocalizer`. -- Produces: `HelpEmbedRenderer(ICommandLocalizer localizer)` with - `Discord.Embed Render(string prefix, string culture, bool showPrefixNote)`. - -- [ ] **Step 1: Write the failing renderer tests** - -Create `tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs`: - -```csharp -using RustPlusBot.Features.Commands.Help; -using RustPlusBot.Features.Commands.Localization; - -namespace RustPlusBot.Features.Commands.Tests.Help; - -public sealed class HelpEmbedRendererTests -{ - private static readonly HelpEmbedRenderer Renderer = - new(new CommandLocalizer(CommandLocalizationCatalog.Default)); - - [Fact] - public void Render_PrefixesInGameCommands() - { - var embed = Renderer.Render("$", "en", showPrefixNote: false); - - var body = string.Concat(embed.Fields.Select(f => f.Value)); - Assert.Contains("$pop", body, StringComparison.Ordinal); - Assert.Contains("$online", body, StringComparison.Ordinal); - } - - [Fact] - public void Render_IncludesSlashCommandsGroup() - { - var embed = Renderer.Render("!", "en", showPrefixNote: false); - - var body = string.Concat(embed.Fields.Select(f => f.Value)); - Assert.Contains("/leader", body, StringComparison.Ordinal); - Assert.Contains("/uptime", body, StringComparison.Ordinal); - } - - [Fact] - public void Render_AddsPrefixNote_WhenRequested() - { - var withNote = Renderer.Render("!", "en", showPrefixNote: true); - var withoutNote = Renderer.Render("!", "en", showPrefixNote: false); - - Assert.NotNull(withNote.Description); - Assert.Contains("per server", withNote.Description, StringComparison.OrdinalIgnoreCase); - Assert.True(string.IsNullOrEmpty(withoutNote.Description)); - } - - [Fact] - public void Render_LocalizesToFrench() - { - var embed = Renderer.Render("!", "fr", showPrefixNote: false); - - var body = string.Concat(embed.Fields.Select(f => f.Value)); - Assert.Contains("Afficher la population", body, StringComparison.Ordinal); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~HelpEmbedRendererTests"` -Expected: COMPILE ERROR — `HelpEmbedRenderer` does not exist. - -- [ ] **Step 3: Create `HelpEmbedRenderer`** - -Create `src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs`: - -```csharp -using System.Globalization; -using Discord; -using RustPlusBot.Features.Commands.Localization; - -namespace RustPlusBot.Features.Commands.Help; - -/// Builds the /help embed from the catalog, the server prefix, and the guild culture. -/// Resolves the title, group headings, and per-command descriptions. -internal sealed class HelpEmbedRenderer(ICommandLocalizer localizer) -{ - private static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder = - [ - (CommandGroup.Control, "help.group.control"), - (CommandGroup.Server, "help.group.server"), - (CommandGroup.TeamIntel, "help.group.teamintel"), - (CommandGroup.Bot, "help.group.bot"), - ]; - - /// Renders the help embed. - /// The in-game command prefix to display (e.g. "!"). - /// The guild culture ("en"/"fr"). - /// True to add the multi-server prefix note to the description. - /// The built embed. - public Embed Render(string prefix, string culture, bool showPrefixNote) - { - var embed = new EmbedBuilder() - .WithTitle(localizer.Get("help.title", culture)) - .WithColor(Color.Blue); - - if (showPrefixNote) - { - embed.WithDescription(localizer.Get("help.prefixnote", culture)); - } - - foreach (var (group, headingKey) in GroupOrder) - { - var lines = CommandHelpCatalog.InGame - .Where(e => e.Group == group) - .Select(e => string.Create(CultureInfo.InvariantCulture, - $"`{prefix}{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}")) - .ToList(); - if (lines.Count > 0) - { - embed.AddField(localizer.Get(headingKey, culture), string.Join('\n', lines)); - } - } - - var slashLines = CommandHelpCatalog.Slash - .Select(e => string.Create(CultureInfo.InvariantCulture, - $"`/{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}")) - .ToList(); - embed.AddField(localizer.Get("help.group.slash", culture), string.Join('\n', slashLines)); - - return embed.Build(); - } -} -``` - -- [ ] **Step 4: Run the renderer tests to verify they pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~HelpEmbedRendererTests"` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs -git commit -m "feat(3c): add HelpEmbedRenderer - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 4: `LeaderService` (testable promote/fetch logic) - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs` -- Test: `tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs` - -**Interfaces:** - -- Consumes: `IRustServerQuery` (`GetTeamInfoAsync`, `PromoteToLeaderAsync`), `ICommandLocalizer`, `TeamInfoSnapshot`/`TeamMemberSnapshot` (from `RustPlusBot.Features.Connections.Listening`). -- Produces: - - `sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader)`. - - `sealed record LeaderTeamResult(IReadOnlyList Members, string? ErrorMessage)` — `ErrorMessage` non-null means stop and show it; otherwise render the select from `Members`. - - `LeaderService(IRustServerQuery query, ICommandLocalizer localizer)` with: - - `Task GetMembersAsync(ulong guildId, Guid serverId, string culture, CancellationToken)`. - - `Task PromoteAsync(ulong guildId, Guid serverId, ulong steamId, string memberName, string culture, CancellationToken)` → the localized result message. - -- [ ] **Step 1: Write the failing service tests** - -Create `tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs`: - -```csharp -using NSubstitute; -using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Tests.Leader; - -public sealed class LeaderServiceTests -{ - private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); - private static readonly Guid Server = Guid.NewGuid(); - - [Fact] - public async Task GetMembers_ReturnsError_WhenNotConnected() - { - var query = Substitute.For(); - query.GetTeamInfoAsync(1UL, Server, Arg.Any()) - .Returns((TeamInfoSnapshot?)null); - var service = new LeaderService(query, Loc); - - var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); - - Assert.Empty(result.Members); - Assert.Equal("Not connected to this server.", result.ErrorMessage); - } - - [Fact] - public async Task GetMembers_ReturnsError_WhenTeamEmpty() - { - var query = Substitute.For(); - query.GetTeamInfoAsync(1UL, Server, Arg.Any()) - .Returns(new TeamInfoSnapshot(0UL, [])); - var service = new LeaderService(query, Loc); - - var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); - - Assert.Empty(result.Members); - Assert.Equal("No team members to promote.", result.ErrorMessage); - } - - [Fact] - public async Task GetMembers_MapsMembersAndMarksLeader() - { - var query = Substitute.For(); - query.GetTeamInfoAsync(1UL, Server, Arg.Any()) - .Returns(new TeamInfoSnapshot( - 10UL, - [ - new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - new TeamMemberSnapshot(20UL, "bob", 0f, 0f, true, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - ])); - var service = new LeaderService(query, Loc); - - var result = await service.GetMembersAsync(1UL, Server, "en", CancellationToken.None); - - Assert.Null(result.ErrorMessage); - Assert.Equal(2, result.Members.Count); - Assert.Contains(result.Members, m => m is { SteamId: 10UL, Name: "alice", IsLeader: true }); - Assert.Contains(result.Members, m => m is { SteamId: 20UL, Name: "bob", IsLeader: false }); - } - - [Fact] - public async Task Promote_ReturnsSuccessMessage_WhenPromoted() - { - var query = Substitute.For(); - query.PromoteToLeaderAsync(1UL, Server, 20UL, Arg.Any()).Returns(true); - var service = new LeaderService(query, Loc); - - var message = await service.PromoteAsync(1UL, Server, 20UL, "bob", "en", CancellationToken.None); - - Assert.Equal("Promoted bob to team leader.", message); - } - - [Fact] - public async Task Promote_ReturnsFailureMessage_WhenApiFails() - { - var query = Substitute.For(); - query.PromoteToLeaderAsync(1UL, Server, 20UL, Arg.Any()).Returns(false); - var service = new LeaderService(query, Loc); - - var message = await service.PromoteAsync(1UL, Server, 20UL, "bob", "en", CancellationToken.None); - - Assert.Equal("Couldn't promote — the server may be unreachable.", message); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~LeaderServiceTests"` -Expected: COMPILE ERROR — `LeaderService` does not exist. - -- [ ] **Step 3: Create `LeaderService`** - -Create `src/RustPlusBot.Features.Commands/Leader/LeaderService.cs`: - -```csharp -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Features.Connections.Listening; - -namespace RustPlusBot.Features.Commands.Leader; - -/// One selectable team member for the /leader promote select. -/// The member's Steam64 id. -/// The member's in-game name. -/// True if this member is the current team leader. -internal sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader); - -/// The members to offer, or an error message to show instead. -/// The selectable members (empty when is set). -/// A localized message to show instead of a select, or null to proceed. -internal sealed record LeaderTeamResult(IReadOnlyList Members, string? ErrorMessage); - -/// The testable logic behind /leader: fetch live members, then promote one. -/// The live-server query seam. -/// Resolves the result/error messages. -internal sealed class LeaderService(IRustServerQuery query, ICommandLocalizer localizer) -{ - /// Fetches the current team for the promote select, or an error message. - /// The owning guild snowflake. - /// The target server id. - /// The guild culture. - /// A cancellation token. - /// The members to offer, or an error message. - public async Task GetMembersAsync( - ulong guildId, - Guid serverId, - string culture, - CancellationToken cancellationToken) - { - var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (team is null) - { - return new LeaderTeamResult([], localizer.Get("leader.notconnected", culture)); - } - - if (team.Members.Count == 0) - { - return new LeaderTeamResult([], localizer.Get("leader.noteam", culture)); - } - - var members = team.Members - .Select(m => new LeaderMemberOption(m.SteamId, m.Name, m.SteamId == team.LeaderSteamId)) - .ToList(); - return new LeaderTeamResult(members, null); - } - - /// Promotes a member and returns the localized result message. - /// The owning guild snowflake. - /// The target server id. - /// The Steam64 id to promote. - /// The member's name (for the success message). - /// The guild culture. - /// A cancellation token. - /// The localized result message. - public async Task PromoteAsync( - ulong guildId, - Guid serverId, - ulong steamId, - string memberName, - string culture, - CancellationToken cancellationToken) - { - var promoted = await query.PromoteToLeaderAsync(guildId, serverId, steamId, cancellationToken) - .ConfigureAwait(false); - return promoted - ? localizer.Get("leader.success", culture, memberName) - : localizer.Get("leader.failure", culture); - } -} -``` - -- [ ] **Step 4: Run the service tests to verify they pass** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~LeaderServiceTests"` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/RustPlusBot.Features.Commands/Leader tests/RustPlusBot.Features.Commands.Tests/Leader -git commit -m "feat(3c): add LeaderService (fetch members + promote) - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 5: Slash + component modules and DI registration - -**Files:** - -- Create: `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs` -- Create: `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs` -- Modify: `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs` -- Modify: `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs` - -**Interfaces:** - -- Consumes: `HelpEmbedRenderer`, `LeaderService`, `BotUptime`, `ICommandLocalizer`, `IServerService`, `IMuteStore`, `IWorkspaceStore`, `IServiceScopeFactory`, `RustPlusBot.Discord.InteractionModuleAssembly`, `DurationFormat`. -- Produces (custom-id contracts used across the two modules): - - server-pick: `leader-server:` + serverId value carried as the select option value. - - member-pick: `leader-promote:{serverId}` with the SteamId as the option value. - -**Note on module registration:** `AddCommands` must register `HelpEmbedRenderer`, `LeaderService` (scoped — they use scoped stores/seams resolved per interaction via `IServiceScopeFactory` inside the module, OR register them and resolve from the scope), and a new `InteractionModuleAssembly(thisAssembly)` singleton so `DiscordBotService` discovers the modules. Follow `SetupModule`'s scope-per-interaction idiom: the module ctor takes only `IServiceScopeFactory`, and resolves `HelpEmbedRenderer`/`LeaderService`/stores from a fresh `CreateAsyncScope()` per call. - -- [ ] **Step 1: Add the module-discovery + registration assertions to `CommandRegistrationTests`** - -In `tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs`, add `using RustPlusBot.Discord;` and a new test: - -```csharp -[Fact] -public void Commands_contribute_an_interaction_module_assembly() -{ - var services = new ServiceCollection(); - services.AddLogging(); - 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.AddOptions(); - services.AddCommands(); - - using var provider = services.BuildServiceProvider(validateScopes: true); - - var assemblies = provider.GetServices(); - Assert.Contains(assemblies, a => a.Assembly == typeof(CommandServiceCollectionExtensions).Assembly); -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~Commands_contribute_an_interaction_module_assembly"` -Expected: FAIL — no `InteractionModuleAssembly` is registered for the Commands assembly. - -- [ ] **Step 2b: Add the Discord project + package references to the Commands csproj** - -The Commands project does not yet reference `RustPlusBot.Discord` (where `InteractionModuleAssembly` lives) or the `Discord.Net` package (where `InteractionModuleBase`/`SocketInteractionContext`/`Discord.Interactions` live). Edit `src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj` so the reference groups read: - -```xml - - - - - - - - - - -``` - -(`Discord.Net` version comes from `Directory.Packages.props` central management — no version attribute here, matching Connections.) - -- [ ] **Step 3: Register the surfaces in `AddCommands`** - -In `src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs`, add `using RustPlusBot.Discord;`, `using RustPlusBot.Features.Commands.Help;`, `using RustPlusBot.Features.Commands.Leader;`, and append before `return services;`: - -```csharp -// Discord surfaces (3c): help renderer, leader service, and the interaction modules. -services.AddScoped(); -services.AddScoped(); -services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly)); -``` - -- [ ] **Step 4: Run the registration test to verify it passes** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests --filter "FullyQualifiedName~Commands_contribute_an_interaction_module_assembly"` -Expected: PASS. - -- [ ] **Step 5: Create `CommandSurfaceModule`** - -Create `src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs`: - -```csharp -using System.Globalization; -using Discord; -using Discord.Interactions; -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Help; -using RustPlusBot.Features.Commands.Hosting; -using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Persistence.Commands; -using RustPlusBot.Persistence.Servers; -using RustPlusBot.Persistence.Workspace; - -namespace RustPlusBot.Features.Commands.Modules; - -/// The /help, /uptime, and /leader slash commands. -/// Creates a short-lived DI scope per interaction. -public sealed class CommandSurfaceModule(IServiceScopeFactory scopeFactory) - : InteractionModuleBase -{ - /// The custom-id prefix for the /leader server-pick select. - public const string LeaderServerSelectId = "leader-server"; - - /// The custom-id prefix for the /leader member-pick select (suffixed with the server id). - public const string LeaderPromotePrefix = "leader-promote:"; - - /// Lists the available in-game and slash commands. - [SlashCommand("help", "Show the available commands")] - public async Task HelpAsync() - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - await DeferAsync(ephemeral: true).ConfigureAwait(false); - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var workspace = scope.ServiceProvider.GetRequiredService(); - var servers = scope.ServiceProvider.GetRequiredService(); - var muteStore = scope.ServiceProvider.GetRequiredService(); - var renderer = scope.ServiceProvider.GetRequiredService(); - - var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); - - string prefix = "!"; - var showNote = false; - if (known.Count == 1) - { - prefix = await muteStore.GetPrefixAsync(Context.Guild.Id, known[0].Id).ConfigureAwait(false); - } - else if (known.Count > 1) - { - showNote = true; - } - - var embed = renderer.Render(prefix, culture, showNote); - await FollowupAsync(embed: embed, ephemeral: true).ConfigureAwait(false); - } - } - - /// Reports the bot process uptime. - [SlashCommand("uptime", "Show how long the bot has been running")] - public async Task UptimeAsync() - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var workspace = scope.ServiceProvider.GetRequiredService(); - var localizer = scope.ServiceProvider.GetRequiredService(); - var uptime = scope.ServiceProvider.GetRequiredService(); - - var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - var text = localizer.Get("uptime.ok", culture, DurationFormat.Compact(uptime.Elapsed)); - await RespondAsync(text, ephemeral: true).ConfigureAwait(false); - } - } - - /// Transfers in-game team leadership (ManageGuild). Opens a server or member select. - [SlashCommand("leader", "Transfer in-game team leadership")] - [RequireUserPermission(GuildPermission.ManageGuild)] - public async Task LeaderAsync() - { - if (Context.Guild is null) - { - await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); - return; - } - - await DeferAsync(ephemeral: true).ConfigureAwait(false); - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var workspace = scope.ServiceProvider.GetRequiredService(); - var servers = scope.ServiceProvider.GetRequiredService(); - var leader = scope.ServiceProvider.GetRequiredService(); - var localizer = scope.ServiceProvider.GetRequiredService(); - - var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); - if (known.Count == 0) - { - await FollowupAsync(localizer.Get("leader.noserver", culture), ephemeral: true).ConfigureAwait(false); - return; - } - - if (known.Count > 1) - { - var serverSelect = new SelectMenuBuilder() - .WithCustomId(LeaderServerSelectId) - .WithPlaceholder(localizer.Get("leader.pickserver", culture)); - foreach (var server in known.Take(SelectMenuBuilder.MaxOptionCount)) - { - serverSelect.AddOption(server.Name, server.Id.ToString()); - } - - var components = new ComponentBuilder().WithSelectMenu(serverSelect).Build(); - await FollowupAsync(localizer.Get("leader.pickserver", culture), components: components, - ephemeral: true).ConfigureAwait(false); - return; - } - - await ShowMemberSelectAsync(scope.ServiceProvider, leader, localizer, Context.Guild.Id, known[0].Id, - culture).ConfigureAwait(false); - } - } - - private async Task ShowMemberSelectAsync( - IServiceProvider provider, - LeaderService leader, - ICommandLocalizer localizer, - ulong guildId, - Guid serverId, - string culture) - { - _ = provider; - var result = await leader.GetMembersAsync(guildId, serverId, culture, CancellationToken.None) - .ConfigureAwait(false); - if (result.ErrorMessage is { } error) - { - await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); - return; - } - - var select = new SelectMenuBuilder() - .WithCustomId(string.Create(CultureInfo.InvariantCulture, $"{LeaderPromotePrefix}{serverId}")) - .WithPlaceholder(localizer.Get("leader.pickmember", culture)); - foreach (var member in result.Members.Take(SelectMenuBuilder.MaxOptionCount)) - { - select.AddOption(member.Name, member.SteamId.ToString(CultureInfo.InvariantCulture), - isDefault: member.IsLeader); - } - - var components = new ComponentBuilder().WithSelectMenu(select).Build(); - await FollowupAsync(localizer.Get("leader.pickmember", culture), components: components, ephemeral: true) - .ConfigureAwait(false); - } -} -``` - -- [ ] **Step 6: Create `LeaderComponentModule`** - -Create `src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs`: - -```csharp -using System.Globalization; -using Discord; -using Discord.Interactions; -using Discord.WebSocket; -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; -using RustPlusBot.Persistence.Servers; -using RustPlusBot.Persistence.Workspace; - -namespace RustPlusBot.Features.Commands.Modules; - -/// Handles the /leader server-pick and member-pick select callbacks. -/// Creates a short-lived DI scope per interaction. -public sealed class LeaderComponentModule(IServiceScopeFactory scopeFactory) - : InteractionModuleBase -{ - /// Handles the server-pick step (multi-server guilds): re-shows a member select for the chosen server. - /// The selected server id (one value). - [ComponentInteraction(CommandSurfaceModule.LeaderServerSelectId, ignoreGroupNames: true)] - public async Task OnServerSelectedAsync(string[] values) - { - if (!await EnsureManageGuildAsync().ConfigureAwait(false) || - values is not [var raw] || !Guid.TryParse(raw, out var serverId)) - { - return; - } - - await DeferAsync(ephemeral: true).ConfigureAwait(false); - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var workspace = scope.ServiceProvider.GetRequiredService(); - var leader = scope.ServiceProvider.GetRequiredService(); - var localizer = scope.ServiceProvider.GetRequiredService(); - - var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - var result = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) - .ConfigureAwait(false); - if (result.ErrorMessage is { } error) - { - await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); - return; - } - - var select = new SelectMenuBuilder() - .WithCustomId(string.Create(CultureInfo.InvariantCulture, - $"{CommandSurfaceModule.LeaderPromotePrefix}{serverId}")) - .WithPlaceholder(localizer.Get("leader.pickmember", culture)); - foreach (var member in result.Members.Take(SelectMenuBuilder.MaxOptionCount)) - { - select.AddOption(member.Name, member.SteamId.ToString(CultureInfo.InvariantCulture), - isDefault: member.IsLeader); - } - - var components = new ComponentBuilder().WithSelectMenu(select).Build(); - await FollowupAsync(localizer.Get("leader.pickmember", culture), components: components, ephemeral: true) - .ConfigureAwait(false); - } - } - - /// Handles the member-pick step: promotes the chosen member. - /// The server id from the wildcard custom-id. - /// The selected member's Steam id (one value). - [ComponentInteraction($"{CommandSurfaceModule.LeaderPromotePrefix}*", ignoreGroupNames: true)] - public async Task OnMemberSelectedAsync(string serverIdRaw, string[] values) - { - if (!await EnsureManageGuildAsync().ConfigureAwait(false) || - !Guid.TryParse(serverIdRaw, out var serverId) || - values is not [var rawSteamId] || - !ulong.TryParse(rawSteamId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var steamId)) - { - return; - } - - await DeferAsync(ephemeral: true).ConfigureAwait(false); - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var workspace = scope.ServiceProvider.GetRequiredService(); - var leader = scope.ServiceProvider.GetRequiredService(); - - var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - - // Re-resolve the member name for the success message; fall back to the SteamId. - var team = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) - .ConfigureAwait(false); - var name = team.Members.FirstOrDefault(m => m.SteamId == steamId)?.Name - ?? steamId.ToString(CultureInfo.InvariantCulture); - - var message = await leader - .PromoteAsync(Context.Guild.Id, serverId, steamId, name, culture, CancellationToken.None) - .ConfigureAwait(false); - await FollowupAsync(message, ephemeral: true).ConfigureAwait(false); - } - } - - private async Task EnsureManageGuildAsync() - { - // Discord does not re-gate component callbacks, so re-check ManageGuild here. - if (Context.User is SocketGuildUser user && user.GuildPermissions.ManageGuild) - { - return true; - } - - await RespondAsync("You need the Manage Server permission.", ephemeral: true).ConfigureAwait(false); - return false; - } -} -``` - -- [ ] **Step 7: Run the full Commands suite + confirm it builds and passes** - -Run: `dotnet test tests/RustPlusBot.Features.Commands.Tests` -Expected: PASS. Count = prior 70 + Task 2 (3) + Task 3 (4) + Task 4 (5) + Task 5 (1) = 83. Confirm the per-assembly total; a low count means a module failed to compile. - -- [ ] **Step 8: Build the whole solution to confirm `DiscordBotService` discovery wiring compiles** - -Run: `dotnet build RustPlusBot.slnx` -Expected: Build succeeded, 0 warnings, 0 errors. - -- [ ] **Step 9: Commit** - -```bash -git add src/RustPlusBot.Features.Commands tests/RustPlusBot.Features.Commands.Tests -git commit -m "feat(3c): add /help, /uptime, /leader slash + component modules - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 6: `#info` team summary field - -**Files:** - -- Modify: `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs` -- Modify: `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs` -- Test: `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs` - -**Interfaces:** - -- Consumes: `IRustServerQuery.GetTeamInfoAsync` (added to the renderer ctor), `TeamInfoSnapshot`/`TeamMemberSnapshot`. -- Produces: a new embed field "Online: {online}/{total} · leader {name}" rendered only when `Status == Connected` and the team query returns non-null. - -- [ ] **Step 1: Add the localization keys** - -In `src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs`, add to `["en"]` (after `server.info.players.label`): - -```csharp -["server.info.team.label"] = "Team", -["server.info.team.value"] = "{0}/{1} online · leader {2}", -``` - -And to `["fr"]`: - -```csharp -["server.info.team.label"] = "Équipe", -["server.info.team.value"] = "{0}/{1} en ligne · chef {2}", -``` - -- [ ] **Step 2: Update the existing renderer tests for the new ctor arg, then add the team-field cases** - -In `tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs`: - -Add usings: - -```csharp -using RustPlusBot.Features.Connections.Listening; -``` - -Every `new ServerInfoMessageRenderer(servers, connections, Loc)` becomes -`new ServerInfoMessageRenderer(servers, connections, query, Loc)`. Add a query -substitute to each `ServerInfo_*` test that returns null by default (field omitted), -e.g. at the top of each such test body: - -```csharp -var query = Substitute.For(); -query.GetTeamInfoAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((TeamInfoSnapshot?)null); -``` - -(Add `using RustPlusBot.Features.Connections.Listening;` is above; NSubstitute is -already imported.) Then add two new tests: - -```csharp -[Fact] -public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary() -{ - var serverId = Guid.NewGuid(); - var credId = Guid.NewGuid(); - var servers = Substitute.For(); - servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); - var connections = Substitute.For(); - connections.GetStateAsync(1, serverId, Arg.Any()) - .Returns(new DomainConnectionState - { - RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, PlayerCount = 5, - }); - connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns(new List - { - new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, - Status = CredentialStatus.Active }, - }); - var query = Substitute.For(); - query.GetTeamInfoAsync(1, serverId, Arg.Any()) - .Returns(new TeamInfoSnapshot( - 10UL, - [ - new TeamMemberSnapshot(10UL, "alice", 0f, 0f, true, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - new TeamMemberSnapshot(20UL, "bob", 0f, 0f, false, true, - DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), - ])); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); - - var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - - var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); - Assert.Contains("1/2 online", body, StringComparison.Ordinal); - Assert.Contains("alice", body, StringComparison.Ordinal); -} - -[Fact] -public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() -{ - var serverId = Guid.NewGuid(); - var credId = Guid.NewGuid(); - var servers = Substitute.For(); - servers.GetAsync(1, serverId, Arg.Any()) - .Returns(new RustServer { Id = serverId, GuildId = 1, Name = "S", Ip = "1.2.3.4", Port = 28015 }); - var connections = Substitute.For(); - connections.GetStateAsync(1, serverId, Arg.Any()) - .Returns(new DomainConnectionState - { - RustServerId = serverId, GuildId = 1, ActiveCredentialId = credId, - Status = ConnectionStatus.Connected, PlayerCount = 5, - }); - connections.ListPoolAsync(1, serverId, Arg.Any()) - .Returns(new List - { - new() { Id = credId, GuildId = 1, RustServerId = serverId, OwnerUserId = 7, SteamId = 5UL, - Status = CredentialStatus.Active }, - }); - var query = Substitute.For(); - query.GetTeamInfoAsync(1, serverId, Arg.Any()) - .Returns((TeamInfoSnapshot?)null); - var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); - - var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - - Assert.DoesNotContain("online", string.Concat(payload.Embed!.Fields.Select(f => f.Value)), - StringComparison.OrdinalIgnoreCase); -} -``` - -- [ ] **Step 2b: Run to verify failure** - -Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests --filter "FullyQualifiedName~RendererTests"` -Expected: COMPILE ERROR — `ServerInfoMessageRenderer` has no 4-arg ctor. - -- [ ] **Step 3: Add the `IRustServerQuery` dependency and render the field** - -In `src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs`: - -Add `using RustPlusBot.Features.Connections.Listening;`. Change the primary ctor to: - -```csharp -internal sealed class ServerInfoMessageRenderer( - IServerService servers, - IConnectionStore connections, - IRustServerQuery query, - ILocalizer localizer) : IMessageRenderer -``` - -(update the `` doc comments to add `Live team query.`). - -Then, inside `RenderAsync`, after the `if (status == ConnectionStatus.Connected && state?.PlayerCount is int count)` block (after its closing brace, before `var eligible = …`), add: - -```csharp -if (status == ConnectionStatus.Connected) -{ - var team = await query.GetTeamInfoAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); - if (team is not null) - { - var online = team.Members.Count(m => m.IsOnline); - var leaderName = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId)?.Name - ?? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture); - embed.AddField( - localizer.Get("server.info.team.label", context.Culture), - localizer.Get("server.info.team.value", context.Culture, - online.ToString(CultureInfo.InvariantCulture), - team.Members.Count.ToString(CultureInfo.InvariantCulture), - leaderName)); - } -} -``` - -- [ ] **Step 4: Run the renderer tests to verify they pass** - -Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests --filter "FullyQualifiedName~RendererTests"` -Expected: PASS. The two new tests pass, and the 5 existing `ServerInfo_*` tests still pass (their query substitute returns null → field omitted → prior assertions unaffected). - -- [ ] **Step 5: Run the FULL Workspace suite + confirm count** - -Run: `dotnet test tests/RustPlusBot.Features.Workspace.Tests` -Expected: PASS, count = prior + 2. If low, a renderer ctor site was missed. - -- [ ] **Step 6: Commit** - -```bash -git add src/RustPlusBot.Features.Workspace tests/RustPlusBot.Features.Workspace.Tests -git commit -m "feat(3c): add team summary field to #info embed - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 7: Whole-feature verification, format gate, and plan commit - -**Files:** none (verification + the gitignored plan doc). - -- [ ] **Step 1: Run the entire test suite and read per-assembly counts** - -Run: `dotnet test RustPlusBot.slnx` -Expected: ALL pass. Confirm each assembly's count is at or above its prior baseline (Connections +3, Commands +13, Workspace +2; others unchanged). A drop in any assembly = a missing fake/ctor. - -- [ ] **Step 2: Confirm no EF model drift (this slice adds none)** - -Run: `git diff --name-only develop... | grep -iE "Migrations|ModelSnapshot|DbContext|Domain/.*\.cs|Persistence/.*Configuration"` -Expected: NO output (3c touches no entities, migrations, or EF configuration). If the `has-pending-model-changes` tooling is available and works, also run it; if it errors with "Unable to retrieve project metadata" (a known tooling quirk), rely on the diff check. - -- [ ] **Step 3: Run the ReSharper format gate** - -Run: `dotnet tool restore && dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` -Then: `git status --short` -Expected: jb may reorder members in the new files. Review the changes are cosmetic (member ordering, brace/collection-initializer wrapping), then stage and amend/commit them. - -- [ ] **Step 4: Rebuild to confirm 0/0 after formatting** - -Run: `dotnet build RustPlusBot.slnx` -Expected: Build succeeded, 0 warnings, 0 errors. - -- [ ] **Step 5: Commit any formatting changes and the plan doc** - -```bash -git add -A -git add -f docs/superpowers/plans/2026-06-17-rustplusbot-3c-command-surfaces.md -git commit -m "chore(3c): apply jb formatting + commit plan - -Co-Authored-By: Claude Opus 4.8 " -``` - -- [ ] **Step 6: Push and open the PR** - -```bash -git push -u origin feat/command-surfaces -gh pr create --base develop --title "Subsystem 3c: command Discord surfaces (/help, /uptime, /leader, #info team summary)" --body "$(cat <<'EOF' -Third slice of subsystem 3. Surfaces the in-game command framework into Discord. - -- `/help` — ephemeral, lists in-game `!commands` (grouped, with the server's prefix) and the slash commands; EN/FR; rendered from a drift-guarded `CommandHelpCatalog`. -- `/uptime` — ephemeral bot-process uptime. -- `/leader` — ManageGuild; server-pick (multi-server) → member-select → `PromoteToLeaderAsync`. -- `#info` team summary — "online/total · leader" field, shown only while Connected, on the existing refresh path. - -New seam method `PromoteToLeaderAsync` on `IRustServerQuery`/`IRustServerConnection`/`ConnectionSupervisor`/`RustPlusSocketSource`. No new project/entity/migration/event/timer. - -The `#commands` channel relay is deferred to a future 3c-ii. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**1. Spec coverage:** - -- `/help` (in-game + slash, grouped, prefix, EN/FR, catalog-driven, drift guard) → Tasks 2, 3, 5. ✓ -- `/uptime` (bot process) → Task 5 (reuses `BotUptime`). ✓ -- `/leader` (ManageGuild, member select, server pick when >1, promote) → Tasks 1, 4, 5. ✓ -- `#info` team summary (online/total + leader, only Connected, existing refresh) → Task 6. ✓ -- `PromoteToLeaderAsync` seam (query/connection/supervisor/shim/fake) → Task 1. ✓ -- Server targeting (auto-one / pick-multi / default-prefix-note) → Task 5 (`HelpAsync`/`LeaderAsync`) + Task 3 (note rendering). ✓ -- No new project/entity/migration/event/options/service → respected throughout; verified in Task 7 Step 2. ✓ -- Module assembly registration → Task 5 Steps 1–4. ✓ -- Error/edge cases (no socket, no servers, empty team, promote fail, stale component re-gate) → Tasks 4 + 5 (`EnsureManageGuildAsync`, `GetMembersAsync` error paths). ✓ - -**2. Placeholder scan:** No "TBD"/"add error handling"/"similar to". Every code step shows full code. The `_ = provider;` discard in `ShowMemberSelectAsync` is intentional (the helper resolves nothing further; it could be simplified during implementation but is shown complete and compiling). ✓ - -**3. Type consistency:** - -- `PromoteToLeaderAsync` query signature `(ulong, Guid, ulong, CancellationToken)→Task` and connection signature `(ulong, TimeSpan, CancellationToken)→Task` are consistent across Task 1 (definition), Task 4 (`LeaderService` consumes the query form), and the fake. ✓ -- Custom-ids: `LeaderServerSelectId = "leader-server"` and `LeaderPromotePrefix = "leader-promote:"` defined in Task 5 `CommandSurfaceModule` and consumed in `LeaderComponentModule` (`$"{…LeaderPromotePrefix}*"`). ✓ -- Localization keys used in `HelpEmbedRenderer`/`LeaderService`/`CommandSurfaceModule`/renderer all match the keys added in Tasks 2 and 6 (`help.*`, `uptime.ok`, `leader.*`, `server.info.team.*`). ✓ -- `HelpEmbedRenderer.Render(string, string, bool)` signature consistent between Task 3 definition, its tests, and the `HelpAsync` call site. ✓ -- `LeaderTeamResult`/`LeaderMemberOption` records consistent between Task 4 and the module consumers. ✓ diff --git a/docs/superpowers/specs/2026-06-17-rustplusbot-3b-ii-team-intel-design.md b/docs/superpowers/specs/2026-06-17-rustplusbot-3b-ii-team-intel-design.md deleted file mode 100644 index 0f3e950f..00000000 --- a/docs/superpowers/specs/2026-06-17-rustplusbot-3b-ii-team-intel-design.md +++ /dev/null @@ -1,176 +0,0 @@ -# Subsystem 3b-ii — Team-intel commands (design) - -**Date:** 2026-06-17 -**Branch:** `feat/team-intel` off `develop` -**Status:** Approved design, pending implementation plan. - -## Summary - -3b-ii is the second slice of subsystem 3 (chat + `!commands`). It adds six in-game -team-intelligence `!commands` that read a single live `GetTeamInfoAsync` snapshot from the -connected Rust+ socket. It is a **pure additive read-path extension** on top of the 3b command -framework: no new entities, migrations, events, options, background services, or projects. - -**Ships:** `!online`, `!offline`, `!team`, `!alive`, `!prox`, `!steamid`. - -**Deferred:** `!afk` ("inactive teammates 5+ min") — it requires tracking each member's position -across time, which a single snapshot cannot compute. It needs a background position-delta poller -(its own future slice). `!connections`/`!deaths`/`!leader` remain deferred per the feature catalog -(team-event history tracker / `PromoteToLeader`). The Discord surfaces (`#commands`, `/help`, -`/uptime`, `/leader`, `#info` team summary) remain in **3c**. - -## Context & confirmed API - -Verified against the real `RustPlusApi 2.0.0-beta.1` DLL (decompiled, not guessed): - -- `RustPlus.GetTeamInfoAsync(CancellationToken = default)` returns `Task>` - (`.IsSuccess` / `.Data`, same `Response` shape as `GetInfoAsync`/`GetTimeAsync`). -- `TeamInfo` (record): `ulong LeaderSteamId`, `IEnumerable? Members`, - `DeathNote? DeathNote`, `IEnumerable? Notes`, `IEnumerable? LeaderNotes`. - Only `LeaderSteamId` + `Members` are used this slice; notes/death-note are out of scope. -- `MemberInfo` (record): `ulong SteamId`, `string? Name`, `float X`, `float Y`, `bool IsOnline`, - `DateTime LastSpawnTime` (UTC), `bool IsAlive`, `DateTime LastDeathTime` (UTC). - -This is the same package and the same `IRustServerQuery` seam already used by `!pop`/`!time`/`!wipe`. - -## Architecture (approach A: extend `IRustServerQuery`) - -The new team data crosses the Connections → Commands boundary through the **existing query seam**, -exactly mirroring `GetServerInfoAsync`/`GetTimeAsync`. RustPlusApi types do not leak into Commands — -a dedicated DTO sits at the boundary. - -### Connections-side additions - -New public DTOs in `src/RustPlusBot.Features.Connections/Listening/`: - -```csharp -public sealed record TeamInfoSnapshot( - ulong LeaderSteamId, - IReadOnlyList Members); - -public sealed record TeamMemberSnapshot( - ulong SteamId, - string Name, - float X, - float Y, - bool IsOnline, - bool IsAlive, - DateTimeOffset LastSpawnTimeUtc, - DateTimeOffset LastDeathTimeUtc); -``` - -- **`IRustServerQuery`** (public) gains: - `Task GetTeamInfoAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);` - — returns `null` when `(guildId, serverId)` has no live socket. -- **`IRustServerConnection`** (internal) gains: - `Task GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken);` -- **`RustPlusSocketSource`** (the untested integration shim) implements it: awaits - `_rustPlus.GetTeamInfoAsync(ct)` under the timeout, returns `null` on `!IsSuccess`/timeout/null data, - otherwise maps each `MemberInfo`: - - `Name` → `member.Name ?? string.Empty` (null-safe). - - `Members` → `teamInfo.Members ?? []` then `.Select(...).ToList()` (null-safe). - - `DateTime` → `DateTimeOffset` via `new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))` - (same idiom as the existing wipe-time mapping). -- **`ConnectionSupervisor`** implements `IRustServerQuery.GetTeamInfoAsync` over its `_liveSockets` - registry — identical lookup pattern to `GetServerInfoAsync`/`GetTimeAsync` (look up the live - connection for the key; if none, return `null`; otherwise delegate with the configured timeout). - -### Commands-side additions - -Six `internal sealed class : ICommandHandler` in `src/RustPlusBot.Features.Commands/Handlers/`, -each registered `AddScoped()` in `CommandServiceCollectionExtensions.AddCommands`. -Each follows the established handler shape: call `IRustServerQuery.GetTeamInfoAsync`; if `null` → -`command.notconnected`; otherwise shape **one compact comma-joined line** via `ICommandLocalizer`. - -| Command | `Name` | Logic | -|-----------|------------|-------| -| `!online` | `online` | Members where `IsOnline`; names comma-joined. Empty → `command.online.none`. | -| `!offline`| `offline` | Members where `!IsOnline`. Empty → `command.offline.none`. | -| `!team` | `team` | All member names. Empty → `command.team.none`. | -| `!steamid`| `steamid` | No arg → `Name SteamId` pairs for all members. Name arg → filter (partial, case-insensitive). | -| `!alive` | `alive` | `IsAlive` members sorted by survival (`clock.UtcNow − LastSpawnTimeUtc`) descending, then dead members shown as "dead". Uses `IClock` + `DurationFormat.Compact`. | -| `!prox` | `prox` | Caller's own position from the snapshot (match `context.SenderSteamId`); distance to each **other** member via `√(Δx²+Δy²)`, rounded to whole metres. Name arg filters to one. | - -Two new small, pure, testable helpers in `src/RustPlusBot.Features.Commands/Formatting/`: - -- `Distance.Between(float x1, float y1, float x2, float y2)` → rounded integer metres. -- `TeamMemberFilter.ByName(snapshot.Members, arg)` → partial, case-insensitive name match - (shared by `!prox` and `!steamid` so the match logic is not duplicated). - -### Reply formatting & edge cases (consistent across all six) - -- **One compact line**, comma-joined. Matches the existing `!pop`/`!time` single-line style and the - reference bot. Multi-member lists are not capped this slice (Rust truncation only bites on very - large teams, which is rare; revisit if it becomes a problem). -- **Not connected** (null snapshot): `command.notconnected` (existing key, reused). -- **Empty result set**: a per-command localized "none" variant (e.g. `command.online.none`). -- **`!prox` caller not in snapshot** (own member entry missing): `command.prox.selfunknown`. -- **`!steamid`/`!prox` name arg matches nothing**: `command.team.nomatch` (takes the arg as `{0}`). -- All replies localized **EN/FR** via new keys added to both cultures in - `CommandLocalizationCatalog.Default`. - -### Loop/echo safety - -Unchanged from 3b and confirmed safe: the dispatcher drops `FromActivePlayer` lines so replies do -not re-trigger, and command replies are **not** recorded in `RelayDedupBuffer` (the bot's in-game -answer posts once to Discord `#teamchat`, deliberately). These six handlers add no new outbound path -beyond the existing `ITeamChatSender` reply, so no new loop surface is introduced. - -## Testing - -TDD per handler (repo convention). New tests in `tests/RustPlusBot.Features.Commands.Tests/Handlers/`: - -- One test class per command covering: happy path, not-connected (null snapshot), empty set, and the - command-specific edge cases — `!prox` self-unknown, `!prox`/`!steamid` name-no-match, `!alive` - ordering (longest first) with a mix of alive and dead members. -- Pure-helper tests for `Distance.Between` and `TeamMemberFilter.ByName`. -- `IClock` faked with a fixed `UtcNow` so `!alive` survival strings are deterministic. - -Connections-side: - -- `ServerQueryTests` gains coverage for `ConnectionSupervisor.GetTeamInfoAsync`: mapped snapshot when - a live socket exists, `null` when none. -- `RustPlusSocketSource`'s team mapping stays **untested** — integration shim, by design (same as the - existing `GetServerInfoAsync`/`GetTimeAsync` shims). - -### Fakes — critical (the 3a/3b lesson) - -Adding `GetTeamInfoAsync` to `IRustServerQuery` and `IRustServerConnection` forces **every** test -double to implement it, or the whole assembly silently drops its tests. Must update: - -1. `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` (the - `IRustServerConnection` fake). -2. Any hand-rolled `IRustServerQuery` fake used in Commands tests (NSubstitute auto-stubs interfaces, - but a concrete fake needs the new member). -3. Any `IRustServerQuery`/`IRustServerConnection` fake referenced in `Features.Chat.Tests`. - -After the change, run the **full** suite and read **per-assembly** counts — a low total means an -assembly failed to build and its tests vanished. - -## Verification gate (before claiming done) - -- `dotnet build` strict (0 warnings / 0 errors under the repo analyzers). -- **Full** `dotnet test`, reading per-assembly counts (not just the grand total). -- `dotnet tool restore` then `dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` - (the repo's real format gate; the pre-push hook enforces it; Roslynator does not catch what jb - reorders). -- Confirm **no EF model drift** (no new entities/migrations — team info is read-only live data). - -## Explicit non-goals (this slice) - -- No `!afk` (needs a position-delta poller). -- No `!connections`/`!deaths` (need a team-event history tracker). -- No `!leader` / `PromoteToLeader` (deferred per catalog). -- No new entities, migrations, events, options, background services, or projects. -- No Discord surfaces (those are 3c). -- No caching of the team snapshot (one `GetTeamInfoAsync` call per command — fine at human typing - speed; a cache was considered and rejected as YAGNI, and would be inconsistent with the existing - non-caching `GetServerInfoAsync`). -- No member-list length cap / "+N more" truncation (revisit only if real teams overflow). - -## After shipping - -Flip the `feat/team-intel`-related rows in `docs/product/feature-catalog.md` to ✔️ Done -(`!online`/`!offline`/`!team`/`!alive`/`!prox`/`!steamid`), and update -`docs/product/feature-catalog.md` `!afk` to note it is deferred pending a position poller. -Next after 3b-ii: `!afk` poller slice and/or **3c** (command Discord surfaces), then subsystem 2. diff --git a/docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md b/docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md deleted file mode 100644 index fa18b550..00000000 --- a/docs/superpowers/specs/2026-06-17-rustplusbot-3c-command-surfaces-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# RustPlusBot 3c — Command Discord Surfaces — Design - -**Date:** 2026-06-17 -**Subsystem:** 3c (third slice of subsystem 3: chat + `!commands`) -**Predecessors:** 3a chat bridge (PR #8, `8132240`), 3b command framework (PR #9, `240a349`), 3b-ii team-intel (PR #10, `70a92c7`). -**Branch to use:** `feat/command-surfaces` off `develop`. - -## Summary - -3c surfaces the in-game command framework into Discord and adds three slash -commands. It ships **four** of the five surfaces the catalog tagged to 3c: - -- **`/help`** — ephemeral embed listing the in-game `!commands` (grouped, with the - server's prefix) and the slash commands; per-guild culture (EN/FR). -- **`/uptime`** — ephemeral bot-process uptime. -- **`/leader`** — transfers in-game team leadership: opens a select of live team - members and promotes the chosen one (ManageGuild-gated). -- **`#info` team summary** — a "Online N/M · leader X" field added to the existing - per-server `#info` embed, shown only while Connected. - -**Explicitly deferred to a future 3c-ii:** the **`#commands` channel** (a -per-server Discord channel that relays typed messages into the in-game command -pipeline). It needs a new channel spec plus a Discord→in-game routing path through -the dispatcher, and a second privileged-intent message listener alongside -`#teamchat`; it is the heaviest piece and is cleanly separable. **`#activity`** -log remains out of subsystem 3 (3/4 in the catalog). **Per-connection uptime** is -deferred (no "connected since" is stored today). - -## Decisions (locked in brainstorming) - -| Topic | Decision | -| --- | --- | -| Scope this slice | `/help`, `/uptime`, `/leader`, `#info` team summary. `#commands` channel → 3c-ii. | -| Where new code lives | **Approach A** — slash modules + help catalog in the existing `Features.Commands` project; `#info` edit local to Workspace's renderer. No new project. | -| `/leader` target selection | Pick a teammate from a select menu populated by `GetTeamInfoAsync`; promote via `PromoteToLeaderAsync(steamId)`. ManageGuild-gated. | -| `#info` summary content | Online/total + leader name; rendered only when `Status == Connected`; refreshed on the **existing** triggers (`ConnectionStatusChangedEvent` / reconcile) — **no new polling timer**. | -| `/help` content | In-game `!commands` (grouped, with prefix) **and** the slash commands; EN/FR. | -| `/help` metadata source | A curated **`CommandHelpCatalog`** (name + group + localized description key), unit-tested against the live `ICommandHandler` registry to catch drift. | -| Server targeting (slash) | Auto-use the single server if a guild has exactly one; if multiple → `/leader` shows a server picker first, `/help` falls back to default `!` with a one-line note. | -| Localization | Per-guild culture (EN/FR), via the existing `CommandLocalizer` (Commands surfaces) and Workspace `Localizer` (the `#info` field). | - -## Architecture - -### New in `Features.Commands` - -- **`Modules/CommandSurfaceModule.cs`** — `InteractionModuleBase` - hosting `/help`, `/uptime`, `/leader`. Resolves scoped services through - `IServiceScopeFactory` per interaction (the repo's module idiom, per - `SetupModule`/`SettingsComponentModule`). -- **`Modules/LeaderComponentModule.cs`** — wildcard component module handling the - `/leader` select callbacks. Custom-id prefixes: - - `leader-server:` — server-pick step (only used in multi-server guilds), value = serverId. - - `leader-promote:{serverId}` — member-pick step, value = target SteamId. - ManageGuild is **re-checked** on the component callback (Discord does not re-gate - components). -- **`Help/CommandHelpCatalog.cs`** — a curated manifest: ordered entries of - `(string Name, CommandGroup Group, string DescriptionKey)`. `CommandGroup` is an - enum `Control | Server | TeamIntel | Bot`. Plus a small `HelpEmbedRenderer` (pure, - testable) that builds the embed from `(catalog, prefix, culture, multiServerNote)`. -- **`CommandLocalizationCatalog`** gains EN/FR keys: per-command help descriptions, - group headings, `/help` title + multi-server prefix note, `/uptime` reply, - `/leader` not-connected / no-server / empty-team / success / failure replies. -- **`CommandServiceCollectionExtensions`** registers a new - `InteractionModuleAssembly(thisAssembly)` singleton so `DiscordBotService` - discovers the modules (mirrors Pairing/Connections/Workspace). - -### Extended seam (Connections) - -- **`IRustServerQuery.PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken)`** - → `Task` (true = promoted; false = no live socket or API non-success). -- **`IRustServerConnection.PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, CancellationToken)`** - → `Task` (internal seam). -- **`ConnectionSupervisor`** implements the query method over `_liveSockets` - (mirrors `GetTeamInfoAsync`: `TryGetValue` → delegate with `HeartbeatTimeout` → - false when no socket). -- **`RustPlusSocketSource`** (the one untested integration shim) wraps - `RustPlus.PromoteToLeaderAsync(ulong, CancellationToken)` → payload-free - `Response` → `response.IsSuccess`. Broad-catch → false; never surface a - token/secret. **VERIFIED against the 2.0.0-beta.1 DLL XML:** - `M:RustPlusApi.RustPlus.PromoteToLeaderAsync(System.UInt64,System.Threading.CancellationToken)` - returns a payload-free `RustPlusApi.Data.Response` with `.IsSuccess`. -- **`FakeRustSocketSource`** (test double) gains `PromoteToLeaderAsync` or the - Connections suite silently drops tests (the 3a `FakeWorkspaceStore` / 3b-ii lesson: - always run the full suite and read per-assembly counts). - -### `/leader` promotion logic (testable) - -The promote decision is extracted into an internal `LeaderService` (or equivalent) -in `Features.Commands`, taking `(guildId, serverId, steamId, culture)` and the -`IRustServerQuery`, returning a localized result + outcome — so the -fetch-members / promote logic is covered at the service level. The interaction -modules stay thin (untested — repo convention: `InteractionModuleBase` types are -not unit-tested here). - -### Edited (Workspace) - -- **`ServerInfoMessageRenderer`** — when `status == ConnectionStatus.Connected`, - call `IRustServerQuery.GetTeamInfoAsync`. On a non-null snapshot add a field - "Online: {online}/{total} · leader {leaderName}". `online` = members where - `IsOnline`; `total` = member count; leader name = the member whose `SteamId == - LeaderSteamId`, falling back to the SteamId string if absent. On a null snapshot - (race: status Connected but the query fails) **omit the field silently**. - (Workspace already references `Connections` and depends on `IConnectionStore`; - it gains an `IRustServerQuery` dependency on the renderer.) -- **Workspace `LocalizationCatalog`** gains EN/FR keys: the team field label and the - "leader" word / `online of total` format. - -### Dependency direction - -`Features.Commands` already references `Connections` + `Workspace`; Workspace -already references `Connections`. Nothing new is inverted. No new project. - -### Not added - -No new entity, migration, event, options, or background service. - -## Per-surface behavior - -### `/help` (ephemeral; any guild member) - -1. Resolve guild culture (`IWorkspaceStore.GetCultureAsync`). -2. Resolve target prefix: if the guild has exactly one registered server, read that - server's prefix (`IMuteStore.GetPrefixAsync`). If multiple servers, use the - default `!` and include a one-line note that prefixes are configured per server. - If zero servers, use the default `!` with no note (nothing to disambiguate). -3. Render an embed via `HelpEmbedRenderer`: one group per `CommandGroup` - (Control / Server / Team intel / Bot) each listing - `{prefix}{name} — {localized description}`, plus a "Slash commands" group for - `/help`, `/uptime`, `/leader`. - -### `/uptime` (ephemeral) - -Reports `BotUptime.Elapsed` via `DurationFormat.Compact`, localized. (No -per-connection uptime this slice.) - -### `/leader` (ManageGuild; defers ephemeral) - -1. Determine target server: - - 0 servers → ephemeral "No server is set up yet." - - 1 server → use it. - - >1 servers → respond with a **server select** (`leader-server:`); on pick, - proceed as if that server were chosen. -2. For the chosen server, call `IRustServerQuery.GetTeamInfoAsync`. - - No live socket → ephemeral "Not connected to this server." - - Empty team → ephemeral "No team members to promote." -3. Respond with a **member select** (`leader-promote:{serverId}`): options labeled by - member name, value = SteamId, capped at `SelectMenuBuilder.MaxOptionCount` (25); - the current leader is shown marked as default. -4. `LeaderComponentModule` handles the member pick → re-check ManageGuild → - `IRustServerQuery.PromoteToLeaderAsync(guild, server, steamId)`: - - true → ephemeral success (member name). - - false → ephemeral "Couldn't promote — the server may be unreachable." - -### `#info` team summary - -In `ServerInfoMessageRenderer`, when `Status == Connected`, fetch `GetTeamInfoAsync` -and add the team field as described above; omit on null. Refreshed by the existing -`#info` triggers only. - -## Error handling & edge cases - -- **No live socket** (`/leader`, `#info` field): query returns null / false → - `/leader` ephemeral "Not connected"; `#info` omits the field. -- **No registered servers** (`/leader`): ephemeral "No server is set up yet." -- **Empty team** (`/leader`): "No team members to promote." -- **Promote fails** (socket dropped between select and click, or API non-success): - ephemeral "Couldn't promote — the server may be unreachable." -- **Stale component interaction**: re-resolve live data on the callback; if the - socket is gone, the same not-connected path applies. ManageGuild re-checked. -- **Secrets:** no tokens in any reply or log (existing rule; `RustPlusSocketSource` - broad-catches to false rather than throwing with any value). -- **Fault isolation:** module exceptions are handled by the existing global Discord - interaction error handling; nothing crashes the gateway. - -## Testing - -- **`CommandHelpCatalog` drift guard:** every registered `ICommandHandler.Name` has a - catalog entry, and every entry's `DescriptionKey` resolves in both EN and FR. -- **`HelpEmbedRenderer`:** pure render from `(catalog, prefix, culture)` → assert - grouping order, prefix substitution, the slash-commands group, and the - multi-server note path. -- **`LeaderService`:** covers no-socket / empty-team / promote-success / - promote-failure against a fake `IRustServerQuery`. -- **`ConnectionSupervisor.PromoteToLeaderAsync`:** tested via `FakeRustSocketSource` - (add the new method to the fake). `RustPlusSocketSource` mapping is the one - untested integration shim, by design. -- **`ServerInfoMessageRenderer`:** add cases for connected-with-team, - connected-but-query-null (field omitted), and not-connected (field omitted). -- Interaction modules (`CommandSurfaceModule`, `LeaderComponentModule`) stay thin and - untested (repo convention). - -## Out of scope (this slice) - -- `#commands` channel + Discord→in-game command relay → **3c-ii**. -- `#activity` log → subsystem 3/4. -- Per-connection uptime (needs a "connected since" on `ConnectionState`). -- `!afk` poller slice (separate deferred work from 3b-ii). -- Role/permission gating of commands → subsystem 9. - -## Execution notes / gotchas (carry-forward) - -- Run the **full** test suite and read per-assembly counts after touching shared - seams (`IRustServerQuery`, `FakeRustSocketSource`) — a missing fake method silently - drops a whole assembly's tests. -- NSubstitute on internal interfaces needs `DynamicProxyGenAssembly2` - `InternalsVisibleTo` in the project under test (already present in Connections; - add to Commands if mocking an internal there). -- `dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` (ReSharper) is - the real format gate — run it (after `dotnet tool restore`) before pushing; the - pre-push hook enforces it and it reorders members Roslynator never flags. -- `Discord.ConnectionState` collides with `Domain.Connections.ConnectionState` — alias - if both are named in one file. -- `SelectMenuBuilder` hard-caps at 25 options — `.Take(SelectMenuBuilder.MaxOptionCount)` - the member list (as `#info`'s swap select already does). -- Roslynator: `// TODO` comments are errors (`S1135`); use `` instead. From a7fd0040bd403628b2ccc18e4d04a3ebccb1babc Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 17 Jun 2026 13:42:23 +0200 Subject: [PATCH 12/12] fix(3c): address Copilot review on PR #11 - LeaderComponentModule.OnMemberSelectedAsync now surfaces the GetMembersAsync error (e.g. not-connected if the socket dropped between select and click) instead of falling through to a generic promote failure. - LeaderComponentModule guards Context.Guild == null on both component handlers (forged/DM payloads), matching the repo pattern (SettingsComponentModule etc.). - ServerInfoMessageRenderer #info team summary treats an empty/whitespace leader display name as missing and falls back to the SteamId (the API can report a member with no name); +1 renderer test. Build 0/0, full suite 250 green. Co-Authored-By: Claude Opus 4.8 --- .../Modules/LeaderComponentModule.cs | 23 +++++++- .../Messages/ServerInfoMessageRenderer.cs | 7 ++- .../Messages/RendererTests.cs | 55 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs index a26faa89..51847196 100644 --- a/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs @@ -20,6 +20,13 @@ public sealed class LeaderComponentModule(IServiceScopeFactory scopeFactory) [ComponentInteraction(CommandSurfaceModule.LeaderServerSelectId, ignoreGroupNames: true)] public async Task OnServerSelectedAsync(string[] values) { + // Component payloads can be forged, so guard against a null guild before dereferencing it. + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + if (!await EnsureManageGuildAsync().ConfigureAwait(false) || values is not [var raw] || !Guid.TryParse(raw, out var serverId)) { @@ -65,6 +72,13 @@ await FollowupAsync(localizer.Get("leader.pickmember", culture), ephemeral: true [ComponentInteraction($"{CommandSurfaceModule.LeaderPromotePrefix}*", ignoreGroupNames: true)] public async Task OnMemberSelectedAsync(string serverIdRaw, string[] values) { + // Component payloads can be forged, so guard against a null guild before dereferencing it. + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + if (!await EnsureManageGuildAsync().ConfigureAwait(false) || !Guid.TryParse(serverIdRaw, out var serverId) || values is not [var rawSteamId] || @@ -82,9 +96,16 @@ values is not [var rawSteamId] || var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); - // Re-resolve the member name for the success message; fall back to the SteamId. + // The socket may have dropped between selection and click; surface that rather than a generic + // promote failure (GetMembersAsync also gives us the member name for the success message). var team = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) .ConfigureAwait(false); + if (team.ErrorMessage is { } error) + { + await FollowupAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + var name = team.Members.FirstOrDefault(m => m.SteamId == steamId)?.Name ?? steamId.ToString(CultureInfo.InvariantCulture); diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index 19ca191c..67828c76 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -70,8 +70,11 @@ public async ValueTask RenderAsync(MessageRenderContext context, if (team is not null) { var online = team.Members.Count(m => m.IsOnline); - var leaderName = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId)?.Name - ?? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture); + var leaderEntry = team.Members.FirstOrDefault(m => m.SteamId == team.LeaderSteamId); + // The API can report a member with no display name, so treat an empty name as missing. + var leaderName = string.IsNullOrWhiteSpace(leaderEntry?.Name) + ? team.LeaderSteamId.ToString(CultureInfo.InvariantCulture) + : leaderEntry.Name; embed.AddField( localizer.Get("server.info.team.label", context.Culture), localizer.Get("server.info.team.value", context.Culture, diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 0e3b153d..01a1445a 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -349,6 +349,61 @@ public async Task ServerInfo_Connected_WithTeam_ShowsTeamSummary() Assert.Contains("alice", body, StringComparison.Ordinal); } + [Fact] + public async Task ServerInfo_Connected_LeaderHasEmptyName_FallsBackToSteamId() + { + var serverId = Guid.NewGuid(); + var credId = Guid.NewGuid(); + var servers = Substitute.For(); + servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "S", + Ip = "1.2.3.4", + Port = 28015 + }); + var connections = Substitute.For(); + connections.GetStateAsync(1, serverId, Arg.Any()) + .Returns(new DomainConnectionState + { + RustServerId = serverId, + GuildId = 1, + ActiveCredentialId = credId, + Status = ConnectionStatus.Connected, + PlayerCount = 1, + }); + connections.ListPoolAsync(1, serverId, Arg.Any()) + .Returns(new List + { + new() + { + Id = credId, + GuildId = 1, + RustServerId = serverId, + OwnerUserId = 7, + SteamId = 5UL, + Status = CredentialStatus.Active + }, + }); + var query = Substitute.For(); + query.GetTeamInfoAsync(1, serverId, Arg.Any()) + .Returns(new TeamInfoSnapshot( + 10UL, + [ + // Leader is present but the API reported no display name. + new TeamMemberSnapshot(10UL, string.Empty, 0f, 0f, true, true, + DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch), + ])); + var renderer = new ServerInfoMessageRenderer(servers, connections, query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("leader 10", body, StringComparison.Ordinal); + } + [Fact] public async Task ServerInfo_Connected_NullTeam_OmitsTeamSummary() {