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/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/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
similarity index 73%
rename from src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs
rename to src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
index e52c939d..fd9a3266 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerQuery.cs
+++ b/src/RustPlusBot.Abstractions/Connections/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/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.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/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/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/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/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/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..51847196
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs
@@ -0,0 +1,130 @@
+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)
+ {
+ // 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))
+ {
+ 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)
+ {
+ // 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] ||
+ !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);
+
+ // 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);
+
+ 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/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/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index 300fa528..9045c9c8 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,34 @@ 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/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..67828c76 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,26 @@ 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 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,
+ 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/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.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);
+ }
}
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"));
+ }
+ }
+}
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);
+ }
+}
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);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
index 2caa9846..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,6 +105,14 @@ public Task SendTeamMessageAsync(string message, CancellationToken cancellationT
return Task.CompletedTask;
}
+#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())
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs
index dae247cb..01a1445a 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,165 @@ 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_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()
+ {
+ 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);
+
+ var teamLabel = Loc.Get("server.info.team.label", "en");
+ Assert.DoesNotContain(payload.Embed!.Fields, f => f.Name == teamLabel);
+ }
}
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();