Skip to content

Commit 70a92c7

Browse files
HandyS11claude
andauthored
Subsystem 3b-ii: in-game team-intel commands (!online/!offline/!team/!alive/!prox/!steamid) (#10)
* docs: add 3b-ii team-intel commands design spec Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: add 3b-ii team-intel commands implementation plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(connections): add TeamInfoSnapshot/TeamMemberSnapshot DTOs * feat(connections): add GetTeamInfoAsync to query/connection seams * feat(connections): map GetTeamInfoAsync in RustPlusSocketSource shim * feat(connections): implement supervisor GetTeamInfoAsync over live sockets * feat(commands): add Distance helper for !prox * feat(commands): add TeamMemberFilter for partial name matching * feat(commands): add EN/FR strings for team-intel commands * feat(commands): add !online and !offline handlers * feat(commands): add !team and !steamid handlers * feat(commands): add !alive handler with per-member survival times * feat(commands): add !prox handler with caller-relative distances * feat(commands): register team-intel handlers in DI * style: apply jb ReformatAndReorder for team-intel changes * feat(commands): clearer !prox reply when caller has no teammates * docs: record 3b-ii completion in implementation plan Plan doc updated for 3b-ii ship. The feature catalog (docs/product/, gitignored since 81275ac) was updated locally to mark the six team-intel commands Done and !afk deferred, but stays untracked per that decision. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 240a349 commit 70a92c7

23 files changed

Lines changed: 2315 additions & 1 deletion

docs/superpowers/plans/2026-06-17-rustplusbot-3b-ii-team-intel.md

Lines changed: 1345 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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.

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
2828
services.AddScoped<ICommandHandler, PopCommandHandler>();
2929
services.AddScoped<ICommandHandler, WipeCommandHandler>();
3030
services.AddScoped<ICommandHandler, TimeCommandHandler>();
31+
services.AddScoped<ICommandHandler, OnlineCommandHandler>();
32+
services.AddScoped<ICommandHandler, OfflineCommandHandler>();
33+
services.AddScoped<ICommandHandler, TeamCommandHandler>();
34+
services.AddScoped<ICommandHandler, SteamIdCommandHandler>();
35+
services.AddScoped<ICommandHandler, AliveCommandHandler>();
36+
services.AddScoped<ICommandHandler, ProxCommandHandler>();
3137

3238
services.AddScoped<CommandDispatcher>();
3339
services.AddHostedService<CommandsHostedService>();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace RustPlusBot.Features.Commands.Formatting;
2+
3+
/// <summary>Computes planar distances between map coordinates for in-game replies.</summary>
4+
internal static class Distance
5+
{
6+
/// <summary>Euclidean distance between two map points, rounded to whole metres.</summary>
7+
/// <param name="x1">First point's horizontal coordinate.</param>
8+
/// <param name="y1">First point's vertical coordinate.</param>
9+
/// <param name="x2">Second point's horizontal coordinate.</param>
10+
/// <param name="y2">Second point's vertical coordinate.</param>
11+
/// <returns>The distance rounded to the nearest whole number.</returns>
12+
public static int Between(float x1, float y1, float x2, float y2)
13+
{
14+
var dx = x2 - x1;
15+
var dy = y2 - y1;
16+
return (int)Math.Round(Math.Sqrt((dx * dx) + (dy * dy)), MidpointRounding.AwayFromZero);
17+
}
18+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
3+
namespace RustPlusBot.Features.Commands.Formatting;
4+
5+
/// <summary>Filters team members by an optional name argument for !prox and !steamid.</summary>
6+
internal static class TeamMemberFilter
7+
{
8+
/// <summary>Returns all members when <paramref name="nameArg"/> is null/whitespace,
9+
/// otherwise those whose name contains it (case-insensitive).</summary>
10+
/// <param name="members">The members to filter.</param>
11+
/// <param name="nameArg">The optional partial name filter.</param>
12+
/// <returns>The matching members (all when no filter is given).</returns>
13+
public static IReadOnlyList<TeamMemberSnapshot> ByName(
14+
IReadOnlyList<TeamMemberSnapshot> members,
15+
string? nameArg)
16+
{
17+
ArgumentNullException.ThrowIfNull(members);
18+
if (string.IsNullOrWhiteSpace(nameArg))
19+
{
20+
return members;
21+
}
22+
23+
return members
24+
.Where(m => m.Name.Contains(nameArg, StringComparison.OrdinalIgnoreCase))
25+
.ToList();
26+
}
27+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using RustPlusBot.Abstractions.Time;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Formatting;
4+
using RustPlusBot.Features.Commands.Localization;
5+
using RustPlusBot.Features.Connections.Listening;
6+
7+
namespace RustPlusBot.Features.Commands.Handlers;
8+
9+
/// <summary>!alive — reports per-member survival times (longest first); dead members shown as "dead".</summary>
10+
/// <param name="query">The live server query.</param>
11+
/// <param name="localizer">The reply localizer.</param>
12+
/// <param name="clock">The clock used to compute survival durations.</param>
13+
internal sealed class AliveCommandHandler(IRustServerQuery query, ICommandLocalizer localizer, IClock clock)
14+
: ICommandHandler
15+
{
16+
/// <inheritdoc />
17+
public string Name => "alive";
18+
19+
/// <inheritdoc />
20+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
21+
{
22+
ArgumentNullException.ThrowIfNull(context);
23+
var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken)
24+
.ConfigureAwait(false);
25+
if (team is null)
26+
{
27+
return localizer.Get("command.notconnected", context.Culture);
28+
}
29+
30+
if (team.Members.Count == 0)
31+
{
32+
return localizer.Get("command.team.none", context.Culture);
33+
}
34+
35+
var now = clock.UtcNow;
36+
var alive = team.Members
37+
.Where(m => m.IsAlive)
38+
.OrderByDescending(m => now - m.LastSpawnTimeUtc)
39+
.Select(m => localizer.Get(
40+
"command.alive.member", context.Culture,
41+
m.Name, DurationFormat.Compact(now - m.LastSpawnTimeUtc)));
42+
var dead = team.Members
43+
.Where(m => !m.IsAlive)
44+
.Select(m => localizer.Get("command.alive.dead", context.Culture, m.Name));
45+
46+
var parts = alive.Concat(dead).ToList();
47+
return localizer.Get("command.alive.ok", context.Culture, string.Join(", ", parts));
48+
}
49+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Localization;
3+
using RustPlusBot.Features.Connections.Listening;
4+
5+
namespace RustPlusBot.Features.Commands.Handlers;
6+
7+
/// <summary>!offline — lists teammates not currently connected.</summary>
8+
/// <param name="query">The live server query.</param>
9+
/// <param name="localizer">The reply localizer.</param>
10+
internal sealed class OfflineCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler
11+
{
12+
/// <inheritdoc />
13+
public string Name => "offline";
14+
15+
/// <inheritdoc />
16+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
17+
{
18+
ArgumentNullException.ThrowIfNull(context);
19+
var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken)
20+
.ConfigureAwait(false);
21+
if (team is null)
22+
{
23+
return localizer.Get("command.notconnected", context.Culture);
24+
}
25+
26+
var names = team.Members.Where(m => !m.IsOnline).Select(m => m.Name).ToList();
27+
if (names.Count == 0)
28+
{
29+
return localizer.Get("command.offline.none", context.Culture);
30+
}
31+
32+
return localizer.Get("command.offline.ok", context.Culture, names.Count, string.Join(", ", names));
33+
}
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Localization;
3+
using RustPlusBot.Features.Connections.Listening;
4+
5+
namespace RustPlusBot.Features.Commands.Handlers;
6+
7+
/// <summary>!online — lists currently-connected teammates.</summary>
8+
/// <param name="query">The live server query.</param>
9+
/// <param name="localizer">The reply localizer.</param>
10+
internal sealed class OnlineCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler
11+
{
12+
/// <inheritdoc />
13+
public string Name => "online";
14+
15+
/// <inheritdoc />
16+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
17+
{
18+
ArgumentNullException.ThrowIfNull(context);
19+
var team = await query.GetTeamInfoAsync(context.GuildId, context.ServerId, cancellationToken)
20+
.ConfigureAwait(false);
21+
if (team is null)
22+
{
23+
return localizer.Get("command.notconnected", context.Culture);
24+
}
25+
26+
var names = team.Members.Where(m => m.IsOnline).Select(m => m.Name).ToList();
27+
if (names.Count == 0)
28+
{
29+
return localizer.Get("command.online.none", context.Culture);
30+
}
31+
32+
return localizer.Get("command.online.ok", context.Culture, names.Count, string.Join(", ", names));
33+
}
34+
}

0 commit comments

Comments
 (0)