|
| 1 | +# Subsystem 3b-ii — Team-intel commands (design) |
| 2 | + |
| 3 | +**Date:** 2026-06-17 |
| 4 | +**Branch:** `feat/team-intel` off `develop` |
| 5 | +**Status:** Approved design, pending implementation plan. |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +3b-ii is the second slice of subsystem 3 (chat + `!commands`). It adds six in-game |
| 10 | +team-intelligence `!commands` that read a single live `GetTeamInfoAsync` snapshot from the |
| 11 | +connected Rust+ socket. It is a **pure additive read-path extension** on top of the 3b command |
| 12 | +framework: no new entities, migrations, events, options, background services, or projects. |
| 13 | + |
| 14 | +**Ships:** `!online`, `!offline`, `!team`, `!alive`, `!prox`, `!steamid`. |
| 15 | + |
| 16 | +**Deferred:** `!afk` ("inactive teammates 5+ min") — it requires tracking each member's position |
| 17 | +across time, which a single snapshot cannot compute. It needs a background position-delta poller |
| 18 | +(its own future slice). `!connections`/`!deaths`/`!leader` remain deferred per the feature catalog |
| 19 | +(team-event history tracker / `PromoteToLeader`). The Discord surfaces (`#commands`, `/help`, |
| 20 | +`/uptime`, `/leader`, `#info` team summary) remain in **3c**. |
| 21 | + |
| 22 | +## Context & confirmed API |
| 23 | + |
| 24 | +Verified against the real `RustPlusApi 2.0.0-beta.1` DLL (decompiled, not guessed): |
| 25 | + |
| 26 | +- `RustPlus.GetTeamInfoAsync(CancellationToken = default)` returns `Task<Response<TeamInfo?>>` |
| 27 | + (`.IsSuccess` / `.Data`, same `Response<T>` shape as `GetInfoAsync`/`GetTimeAsync`). |
| 28 | +- `TeamInfo` (record): `ulong LeaderSteamId`, `IEnumerable<MemberInfo>? Members`, |
| 29 | + `DeathNote? DeathNote`, `IEnumerable<PlayerNote>? Notes`, `IEnumerable<PlayerNote>? LeaderNotes`. |
| 30 | + Only `LeaderSteamId` + `Members` are used this slice; notes/death-note are out of scope. |
| 31 | +- `MemberInfo` (record): `ulong SteamId`, `string? Name`, `float X`, `float Y`, `bool IsOnline`, |
| 32 | + `DateTime LastSpawnTime` (UTC), `bool IsAlive`, `DateTime LastDeathTime` (UTC). |
| 33 | + |
| 34 | +This is the same package and the same `IRustServerQuery` seam already used by `!pop`/`!time`/`!wipe`. |
| 35 | + |
| 36 | +## Architecture (approach A: extend `IRustServerQuery`) |
| 37 | + |
| 38 | +The new team data crosses the Connections → Commands boundary through the **existing query seam**, |
| 39 | +exactly mirroring `GetServerInfoAsync`/`GetTimeAsync`. RustPlusApi types do not leak into Commands — |
| 40 | +a dedicated DTO sits at the boundary. |
| 41 | + |
| 42 | +### Connections-side additions |
| 43 | + |
| 44 | +New public DTOs in `src/RustPlusBot.Features.Connections/Listening/`: |
| 45 | + |
| 46 | +```csharp |
| 47 | +public sealed record TeamInfoSnapshot( |
| 48 | + ulong LeaderSteamId, |
| 49 | + IReadOnlyList<TeamMemberSnapshot> Members); |
| 50 | + |
| 51 | +public sealed record TeamMemberSnapshot( |
| 52 | + ulong SteamId, |
| 53 | + string Name, |
| 54 | + float X, |
| 55 | + float Y, |
| 56 | + bool IsOnline, |
| 57 | + bool IsAlive, |
| 58 | + DateTimeOffset LastSpawnTimeUtc, |
| 59 | + DateTimeOffset LastDeathTimeUtc); |
| 60 | +``` |
| 61 | + |
| 62 | +- **`IRustServerQuery`** (public) gains: |
| 63 | + `Task<TeamInfoSnapshot?> GetTeamInfoAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);` |
| 64 | + — returns `null` when `(guildId, serverId)` has no live socket. |
| 65 | +- **`IRustServerConnection`** (internal) gains: |
| 66 | + `Task<TeamInfoSnapshot?> GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken);` |
| 67 | +- **`RustPlusSocketSource`** (the untested integration shim) implements it: awaits |
| 68 | + `_rustPlus.GetTeamInfoAsync(ct)` under the timeout, returns `null` on `!IsSuccess`/timeout/null data, |
| 69 | + otherwise maps each `MemberInfo`: |
| 70 | + - `Name` → `member.Name ?? string.Empty` (null-safe). |
| 71 | + - `Members` → `teamInfo.Members ?? []` then `.Select(...).ToList()` (null-safe). |
| 72 | + - `DateTime` → `DateTimeOffset` via `new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))` |
| 73 | + (same idiom as the existing wipe-time mapping). |
| 74 | +- **`ConnectionSupervisor`** implements `IRustServerQuery.GetTeamInfoAsync` over its `_liveSockets` |
| 75 | + registry — identical lookup pattern to `GetServerInfoAsync`/`GetTimeAsync` (look up the live |
| 76 | + connection for the key; if none, return `null`; otherwise delegate with the configured timeout). |
| 77 | + |
| 78 | +### Commands-side additions |
| 79 | + |
| 80 | +Six `internal sealed class : ICommandHandler` in `src/RustPlusBot.Features.Commands/Handlers/`, |
| 81 | +each registered `AddScoped<ICommandHandler, …>()` in `CommandServiceCollectionExtensions.AddCommands`. |
| 82 | +Each follows the established handler shape: call `IRustServerQuery.GetTeamInfoAsync`; if `null` → |
| 83 | +`command.notconnected`; otherwise shape **one compact comma-joined line** via `ICommandLocalizer`. |
| 84 | + |
| 85 | +| Command | `Name` | Logic | |
| 86 | +|-----------|------------|-------| |
| 87 | +| `!online` | `online` | Members where `IsOnline`; names comma-joined. Empty → `command.online.none`. | |
| 88 | +| `!offline`| `offline` | Members where `!IsOnline`. Empty → `command.offline.none`. | |
| 89 | +| `!team` | `team` | All member names. Empty → `command.team.none`. | |
| 90 | +| `!steamid`| `steamid` | No arg → `Name SteamId` pairs for all members. Name arg → filter (partial, case-insensitive). | |
| 91 | +| `!alive` | `alive` | `IsAlive` members sorted by survival (`clock.UtcNow − LastSpawnTimeUtc`) descending, then dead members shown as "dead". Uses `IClock` + `DurationFormat.Compact`. | |
| 92 | +| `!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. | |
| 93 | + |
| 94 | +Two new small, pure, testable helpers in `src/RustPlusBot.Features.Commands/Formatting/`: |
| 95 | + |
| 96 | +- `Distance.Between(float x1, float y1, float x2, float y2)` → rounded integer metres. |
| 97 | +- `TeamMemberFilter.ByName(snapshot.Members, arg)` → partial, case-insensitive name match |
| 98 | + (shared by `!prox` and `!steamid` so the match logic is not duplicated). |
| 99 | + |
| 100 | +### Reply formatting & edge cases (consistent across all six) |
| 101 | + |
| 102 | +- **One compact line**, comma-joined. Matches the existing `!pop`/`!time` single-line style and the |
| 103 | + reference bot. Multi-member lists are not capped this slice (Rust truncation only bites on very |
| 104 | + large teams, which is rare; revisit if it becomes a problem). |
| 105 | +- **Not connected** (null snapshot): `command.notconnected` (existing key, reused). |
| 106 | +- **Empty result set**: a per-command localized "none" variant (e.g. `command.online.none`). |
| 107 | +- **`!prox` caller not in snapshot** (own member entry missing): `command.prox.selfunknown`. |
| 108 | +- **`!steamid`/`!prox` name arg matches nothing**: `command.team.nomatch` (takes the arg as `{0}`). |
| 109 | +- All replies localized **EN/FR** via new keys added to both cultures in |
| 110 | + `CommandLocalizationCatalog.Default`. |
| 111 | + |
| 112 | +### Loop/echo safety |
| 113 | + |
| 114 | +Unchanged from 3b and confirmed safe: the dispatcher drops `FromActivePlayer` lines so replies do |
| 115 | +not re-trigger, and command replies are **not** recorded in `RelayDedupBuffer` (the bot's in-game |
| 116 | +answer posts once to Discord `#teamchat`, deliberately). These six handlers add no new outbound path |
| 117 | +beyond the existing `ITeamChatSender` reply, so no new loop surface is introduced. |
| 118 | + |
| 119 | +## Testing |
| 120 | + |
| 121 | +TDD per handler (repo convention). New tests in `tests/RustPlusBot.Features.Commands.Tests/Handlers/`: |
| 122 | + |
| 123 | +- One test class per command covering: happy path, not-connected (null snapshot), empty set, and the |
| 124 | + command-specific edge cases — `!prox` self-unknown, `!prox`/`!steamid` name-no-match, `!alive` |
| 125 | + ordering (longest first) with a mix of alive and dead members. |
| 126 | +- Pure-helper tests for `Distance.Between` and `TeamMemberFilter.ByName`. |
| 127 | +- `IClock` faked with a fixed `UtcNow` so `!alive` survival strings are deterministic. |
| 128 | + |
| 129 | +Connections-side: |
| 130 | + |
| 131 | +- `ServerQueryTests` gains coverage for `ConnectionSupervisor.GetTeamInfoAsync`: mapped snapshot when |
| 132 | + a live socket exists, `null` when none. |
| 133 | +- `RustPlusSocketSource`'s team mapping stays **untested** — integration shim, by design (same as the |
| 134 | + existing `GetServerInfoAsync`/`GetTimeAsync` shims). |
| 135 | + |
| 136 | +### Fakes — critical (the 3a/3b lesson) |
| 137 | + |
| 138 | +Adding `GetTeamInfoAsync` to `IRustServerQuery` and `IRustServerConnection` forces **every** test |
| 139 | +double to implement it, or the whole assembly silently drops its tests. Must update: |
| 140 | + |
| 141 | +1. `tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs` (the |
| 142 | + `IRustServerConnection` fake). |
| 143 | +2. Any hand-rolled `IRustServerQuery` fake used in Commands tests (NSubstitute auto-stubs interfaces, |
| 144 | + but a concrete fake needs the new member). |
| 145 | +3. Any `IRustServerQuery`/`IRustServerConnection` fake referenced in `Features.Chat.Tests`. |
| 146 | + |
| 147 | +After the change, run the **full** suite and read **per-assembly** counts — a low total means an |
| 148 | +assembly failed to build and its tests vanished. |
| 149 | + |
| 150 | +## Verification gate (before claiming done) |
| 151 | + |
| 152 | +- `dotnet build` strict (0 warnings / 0 errors under the repo analyzers). |
| 153 | +- **Full** `dotnet test`, reading per-assembly counts (not just the grand total). |
| 154 | +- `dotnet tool restore` then `dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder` |
| 155 | + (the repo's real format gate; the pre-push hook enforces it; Roslynator does not catch what jb |
| 156 | + reorders). |
| 157 | +- Confirm **no EF model drift** (no new entities/migrations — team info is read-only live data). |
| 158 | + |
| 159 | +## Explicit non-goals (this slice) |
| 160 | + |
| 161 | +- No `!afk` (needs a position-delta poller). |
| 162 | +- No `!connections`/`!deaths` (need a team-event history tracker). |
| 163 | +- No `!leader` / `PromoteToLeader` (deferred per catalog). |
| 164 | +- No new entities, migrations, events, options, background services, or projects. |
| 165 | +- No Discord surfaces (those are 3c). |
| 166 | +- No caching of the team snapshot (one `GetTeamInfoAsync` call per command — fine at human typing |
| 167 | + speed; a cache was considered and rejected as YAGNI, and would be inconsistent with the existing |
| 168 | + non-caching `GetServerInfoAsync`). |
| 169 | +- No member-list length cap / "+N more" truncation (revisit only if real teams overflow). |
| 170 | + |
| 171 | +## After shipping |
| 172 | + |
| 173 | +Flip the `feat/team-intel`-related rows in `docs/product/feature-catalog.md` to ✔️ Done |
| 174 | +(`!online`/`!offline`/`!team`/`!alive`/`!prox`/`!steamid`), and update |
| 175 | +`docs/product/feature-catalog.md` `!afk` to note it is deferred pending a position poller. |
| 176 | +Next after 3b-ii: `!afk` poller slice and/or **3c** (command Discord surfaces), then subsystem 2. |
0 commit comments