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();