Skip to content

Commit 492e18b

Browse files
HandyS11claude
andauthored
Push-driven team state via team_changed + consumer-loop resilience (#65)
* fix: keep event consumers alive across transient handler failures Three faults observed live tonight, all the same class: an exception escaping into a consumer's await-foreach ends that subscription for the rest of the process, so the feature it drives goes silently dead until the bot restarts. 1. "Map connection-status loop faulted. InvalidOperationException: GetMap returned no data." — ConnectionSupervisor.GetMonumentsAsync forwarded the connection-level call (documented "Throws on failure") straight through a query seam whose own contract promised "or an empty list". It was the sole query in RustPlusSocketSource without a degrade-to-null/empty catch. It now catches, logs and returns [], and the failure message names the Rust+ error code instead of a bare "returned no data". 2. "ConnectionStatusChanged consumer faulted. TimeoutException" from a Discord REST edit — the Workspace consumer died, leaving the info channels stale. 3. The same shape existed, unguarded, in 21 consumer loops across 11 services. Add EventBusConsumption.ConsumeAsync<TEvent>(handler, onHandlerFailure, ct): it subscribes, guards each handler call, reports failures and keeps consuming; only cancellation of the loop token ends the subscription. Abstractions stays dependency-free (the callback is Action<Exception>, so each service keeps its own logger). Every vulnerable consumer now routes through it; each service's outer try/catch stays as a backstop. ClansHostedService and CommandsHostedService already isolate per event inline and are left alone — migrating them would lose the guild/server ids in their failure logs. Also fixes the unrelated log line that started this: Rust+ sends the token "apartmentcomplex", which no entry in MonumentTokenMap mapped, so apartment complexes rendered with no icon ("No monument icon for token ...; mapped type: null"). MonumentType.ApartmentsComplex and Apartments_Complex.svg were already in RustMapsApi.Assets — only the token table had not caught up. Tests: EventBusConsumptionTests (a failing handler costs its own event only; cancellation still ends the subscription), WorkspaceConsumerResilienceTests and MapHostedServiceTests (both written failing first, reproducing the live exceptions), plus a monuments-seam and a token-map case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connections): use a query-specific log for the monuments seam Copilot review: GetMonumentsAsync (the query seam used by map rendering) reused LogMonumentsFetchFailed, whose message says "rig detection disabled for this connection" — misleading for a non-rig caller. Give the seam its own generic LogMonumentsQueryFailed and keep the rig-specific message on the rig path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(map): stop the status-loop regression test flaking on slow CI AdvancingClock jumped 1 minute per read while MapRefreshInterval is 30 minutes, so MapRefreshThrottle gated every refresh after the first: the counter only re-incremented once ~30 clock reads accumulated. Fast enough locally, but on Windows CI fewer than 30 events were consumed inside the 5s deadline, so calls stuck at 1 and the assert failed. Advance an hour per read (past the interval) so the throttle passes every refresh and each event reaches the counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spec): push-driven team state via team_changed (beta.6) Design for moving team-state detection (connect/disconnect/death/respawn/ AFK) from the 2-5s poll onto RustPlusApi beta.6's OnTeamChanged event, keeping a 30s team poll as an AFK safety tick. Marker/rig polling stays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for team_changed push migration Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(deps): bump RustPlusApi to 2.0.0-beta.6 (team_changed dispatch) * refactor(connections): extract shared TeamInfoMapping.ToSnapshot Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connections): surface team_changed as IRustServerConnection.TeamChanged Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connections): add TeamPollInterval (AFK safety-tick cadence) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connections): drive team state from team_changed push + slow AFK tick * test(connections): cover AFK-during-silence; harden team_changed handler; docs * style(connections): apply ReSharper formatting to new team tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a1e700c commit 492e18b

14 files changed

Lines changed: 1482 additions & 36 deletions

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta2" />
1616
<PackageVersion Include="RustMapsApi" Version="1.0.0-beta.3" />
1717
<PackageVersion Include="RustMapsApi.Assets" Version="1.0.0-beta.3" />
18-
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.5" />
19-
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.5" />
18+
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.6" />
19+
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.6" />
2020
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
2121
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
2222
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />

docs/superpowers/plans/2026-07-24-team-changed-push.md

Lines changed: 862 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Push-driven team state via `team_changed` (RustPlusApi beta.6)
2+
3+
**Date:** 2026-07-24
4+
**Status:** Approved design
5+
**Branch:** `fix/consumer-loop-resilience` (or a dedicated follow-up branch)
6+
7+
## 1. Problem & motivation
8+
9+
Team-state detection (connect / disconnect / death / respawn / AFK) is currently
10+
derived by **polling** `GetTeamInfoAsync` inside `PollMarkersAsync`
11+
(`ConnectionSupervisor.cs:761`), on the marker cadence of every 2–5 s
12+
(`MarkerPollInterval` = 5 s, `MarkerPollFastInterval` = 2 s).
13+
14+
Two problems motivate a change:
15+
16+
1. **Efficiency / latency.** A `getTeamInfo` request every 2–5 s per connected
17+
server is wasteful, and presence/death events lag by up to one poll interval.
18+
2. **Log noise.** RustPlusApi `beta.5` does not dispatch the `team_changed`
19+
broadcast — its `ParseNotification` falls through to
20+
`Logger.LogUnknownBroadcast`, producing recurring
21+
`WRN Unknown broadcast received: RustPlusContracts.AppBroadcast`. `team_changed`
22+
is the *only* unhandled broadcast field in `beta.5`, so every one of these
23+
warnings is a dropped team update.
24+
25+
RustPlusApi **beta.6** now parses `team_changed` and raises a new
26+
`OnTeamChanged` event carrying `TeamChangedEventArg { PlayerId, TeamInfo }`,
27+
where `TeamInfo` is produced by the **same `ToTeamInfo()` mapping** the polled
28+
`GetTeamInfoAsync` already uses. Full field parity (members with
29+
`X/Y/IsOnline/IsAlive/LastSpawnTime/LastDeathTime`, `LeaderSteamId`, `DeathNote`).
30+
31+
## 2. Scope
32+
33+
**In scope:** upgrade `beta.5 → beta.6`; move team-state detection from the fast
34+
poll to the `OnTeamChanged` push event; retain a low-frequency team poll purely as
35+
an AFK safety tick.
36+
37+
**Explicitly out of scope:** marker/rig polling. The Rust+ protocol has **no
38+
broadcast** for cargo-ship / patrol-heli / chinook / travelling-vendor markers;
39+
the server only answers `getMapMarkers` on request. `PollMarkersAsync`'s marker
40+
and rig-activation work is unchanged. This is therefore a *partial* migration —
41+
"move team state to push", not "replace the polling system" wholesale.
42+
43+
## 3. Architecture
44+
45+
### 3.1 Connection layer — `IRustServerConnection` / `RustPlusSocketSource`
46+
47+
- Add to the interface:
48+
`event EventHandler<TeamInfoSnapshot>? TeamChanged;`
49+
- `RustPlusSocketSource`:
50+
- Subscribe `_rustPlus.OnTeamChanged += OnTeamChanged` in the ctor event block
51+
(near `RustPlusSocketSource.cs:172`); unsubscribe in `DisposeAsync`
52+
(near line 768), symmetrically with the existing handlers.
53+
- Extract the inline `TeamInfo → TeamInfoSnapshot` mapping currently in
54+
`GetTeamInfoAsync` (lines 339–353) into a private static
55+
`ToSnapshot(RustPlusApi.Data.TeamInfo)` helper. Both `GetTeamInfoAsync` and
56+
the new handler reuse it:
57+
`private void OnTeamChanged(object? s, TeamChangedEventArg e) => TeamChanged?.Invoke(this, ToSnapshot(e.TeamInfo));`
58+
- Because the event and the poll share one mapping, there is zero behavioural
59+
drift between pushed and polled team snapshots.
60+
- The test fake `IRustServerConnection` gains the `TeamChanged` event plus a test
61+
hook to raise it.
62+
63+
### 3.2 Supervisor layer — `ConnectionSupervisor`
64+
65+
- **Push handler.** Wire an `OnTeamChanged` handler in `RunAsync` alongside the
66+
existing `OnTeamMessage` / `OnClanChanged` wiring (~line 609). On each event it
67+
calls `tracker.Diff(snapshot, clock.UtcNow, _options.AfkThreshold,
68+
_options.AfkEpsilon)` and, when transitions are non-empty, fire-and-forget
69+
publishes `PlayerStateChangedEvent` using the same `_ = PublishAsync(...)`
70+
error-isolation pattern as the other handlers. Subscribe on setup, unsubscribe
71+
in the `finally` (lines 655–659 block).
72+
- **Slow team poll (AFK tick + self-heal).** Split the team block (lines 761–768)
73+
out of `PollMarkersAsync` into a new `PollTeamAsync` loop that runs the *same*
74+
`Diff` + publish at a new `TeamPollInterval` (default 30 s). Its first iteration
75+
runs immediately (to prime the baseline promptly), then it waits
76+
`TeamPollInterval` between iterations. Launched as a fourth `Task.Run` next to
77+
`markerPoll` / `reachabilityPoll` (line 624) and joined in the same
78+
`Task.WhenAll` on exit (line 646).
79+
- **`PollMarkersAsync`** loses its `tracker` parameter and the team block; markers
80+
and rig-activation detection are otherwise untouched.
81+
- **Shared `dims`.** `PlayerStateChangedEvent` needs `MapDimensions?`. `dims` stays
82+
resolved lazily *off the critical connect path* (preserving the non-blocking
83+
behaviour the comment at lines 731–735 protects), stored in a per-connected-window
84+
holder that both the push handler and `PollTeamAsync` read. Events arriving before
85+
`dims` resolves publish `dims: null`, which `PlayerStateChangedEvent` already
86+
tolerates (nullable → renders without a grid reference).
87+
88+
### 3.3 Options — `ConnectionOptions`
89+
90+
- Add `public TimeSpan TeamPollInterval { get; set; } = TimeSpan.FromSeconds(30);`
91+
92+
## 4. Concurrency & correctness
93+
94+
- `TeamStateTracker.Diff` is now invoked from two threads: the RustPlusApi dispatch
95+
thread (push) and the `PollTeamAsync` thread (tick). `TeamStateTracker` is already
96+
guarded by `_gate` and swaps `_baseline` atomically, so concurrent calls serialize
97+
and never corrupt shared state. Ordering is NOT guaranteed, though:
98+
`PollTeamAsync` captures its snapshot before an `await` spanning a network
99+
round-trip, so a concurrent push can commit a newer baseline first and the poll's
100+
stale snapshot may then emit a rare, self-healing spurious presence transition
101+
(corrected on the next tick). This is the accepted cost of the slow-poll
102+
(Approach B) design; AFK timing is unaffected.
103+
- **No change to the AFK model.** `UpdateAfk` still flags `BecameAfk` when
104+
`Clock - stillSince >= Threshold`, evaluated whenever `Diff` runs. The slow poll
105+
exists solely to guarantee `Diff` runs periodically, so "became AFK" cannot stall
106+
during broadcast silence (a stationary player in a quiet/solo team emits no
107+
`team_changed`). Worst-case detection latency for BecameAfk is
108+
`AfkThreshold + TeamPollInterval`; presence/death/respawn/returned-from-AFK remain
109+
instant via push.
110+
- **Baseline priming.** Whichever of {first broadcast, first `PollTeamAsync`
111+
iteration} runs first primes the tracker silently (`Diff` primes on first non-null
112+
call); the other diffs against it. No spurious connect-event flood on connect.
113+
114+
## 5. Testing
115+
116+
- `RustPlusSocketSource`: raising `OnTeamChanged` surfaces a correctly-mapped
117+
`TeamInfoSnapshot` — member fields, leader death note, offline and dead members.
118+
- `ConnectionSupervisor`: a `TeamChanged` event carrying a moved / disconnected /
119+
died member publishes the expected `PlayerStateChangedEvent`; and the fast marker
120+
cadence no longer issues any `getTeamInfo` request.
121+
- AFK-during-silence: with no broadcasts delivered, a stationary online player is
122+
still flagged `BecameAfk` from the slow poll within
123+
`AfkThreshold + TeamPollInterval`.
124+
- Regression: existing marker/rig and status-loop tests remain green (the marker
125+
loop is untouched apart from removing the team block).
126+
127+
## 6. Rollout
128+
129+
- Bump `RustPlusApi` and `RustPlusApi.Fcm` to `2.0.0-beta.6` in
130+
`Directory.Packages.props`.
131+
- The `Unknown broadcast received` warning disappears once beta.6 dispatches
132+
`team_changed`; no code change is required to silence it.
133+
134+
## 7. Out-of-scope follow-up (noted, not included here)
135+
136+
The related investigation found that `RustPlusSocketSource.GetMapMarkersAsync`
137+
throws a bare `"GetMapMarkers returned no data."` that discards the server's
138+
`response.Error` code/message, making the `Marker poll ... failed` warning
139+
non-diagnostic. That is a separate, independent fix and is **not** part of this
140+
design.

src/RustPlusBot.Features.Connections/ConnectionOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ public sealed class ConnectionOptions
4242
/// <summary>Movement tolerance (world units) below which a member is considered still. Default 1.</summary>
4343
public float AfkEpsilon { get; set; } = 1f;
4444

45+
/// <summary>
46+
/// How often to poll team info as an AFK safety tick and self-heal, now that live team changes arrive
47+
/// via the pushed <c>team_changed</c> event. Presence/death events are instant; this only bounds how long
48+
/// a still player in a broadcast-silent team can take to be flagged AFK (worst case AfkThreshold + this).
49+
/// Default 30s.
50+
/// </summary>
51+
public TimeSpan TeamPollInterval { get; set; } = TimeSpan.FromSeconds(30);
52+
4553
/// <summary>How often to poll managed devices for reachability changes while connected. Default 5m.</summary>
4654
public TimeSpan ReachabilityPollInterval { get; set; } = TimeSpan.FromMinutes(5);
4755

src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,11 @@ Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
169169

170170
/// <summary>Raised when a managed storage monitor's contents change in-game; carries the entity id and the new contents.</summary>
171171
event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;
172+
173+
/// <summary>
174+
/// Raised when the server pushes a <c>team_changed</c> broadcast (member join/leave, online/offline,
175+
/// death/respawn, movement, leader change). Carries the full team snapshot — the same shape a
176+
/// <see cref="GetTeamInfoAsync"/> poll returns.
177+
/// </summary>
178+
event EventHandler<TeamInfoSnapshot>? TeamChanged;
172179
}

src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered
138138
remove { _ = value; }
139139
}
140140

141+
public event EventHandler<TeamInfoSnapshot>? TeamChanged
142+
{
143+
add { _ = value; }
144+
remove { _ = value; }
145+
}
146+
141147
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
142148
}
143149

@@ -170,6 +176,7 @@ public RustPlusServerConnection(string ip,
170176
_rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered;
171177
_rustPlus.OnClanChatReceived += OnClanChatReceived;
172178
_rustPlus.OnClanChanged += OnClanChanged;
179+
_rustPlus.OnTeamChanged += OnTeamChanged;
173180
}
174181

175182
/// <inheritdoc />
@@ -336,21 +343,7 @@ public async Task<HeartbeatResult> GetInfoAsync(TimeSpan timeout, CancellationTo
336343
return null;
337344
}
338345

339-
var members = (response.Data.Members ?? [])
340-
.Select(m => new TeamMemberSnapshot(
341-
m.SteamId,
342-
m.Name ?? string.Empty,
343-
m.X,
344-
m.Y,
345-
m.IsOnline,
346-
m.IsAlive,
347-
new DateTimeOffset(DateTime.SpecifyKind(m.LastSpawnTime, DateTimeKind.Utc)),
348-
new DateTimeOffset(DateTime.SpecifyKind(m.LastDeathTime, DateTimeKind.Utc))))
349-
.ToList();
350-
var deathNote = response.Data.DeathNote is { } dn
351-
? ((float X, float Y)?)(dn.X, dn.Y)
352-
: null;
353-
return new TeamInfoSnapshot(response.Data.LeaderSteamId, members, deathNote);
346+
return TeamInfoMapping.ToSnapshot(response.Data);
354347
}
355348
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
356349
{
@@ -466,6 +459,8 @@ public async Task<bool> PromoteToLeaderAsync(ulong steamId,
466459

467460
public event EventHandler<StorageMonitorTrigger>? StorageMonitorTriggered;
468461

462+
public event EventHandler<TeamInfoSnapshot>? TeamChanged;
463+
469464
public async Task<DeviceReading> GetSmartDeviceInfoAsync(ulong entityId,
470465
SmartDeviceKind kind,
471466
TimeSpan timeout,
@@ -766,6 +761,7 @@ public async ValueTask DisposeAsync()
766761
_rustPlus.OnStorageMonitorTriggered -= OnStorageMonitorTriggered;
767762
_rustPlus.OnClanChatReceived -= OnClanChatReceived;
768763
_rustPlus.OnClanChanged -= OnClanChanged;
764+
_rustPlus.OnTeamChanged -= OnTeamChanged;
769765
try
770766
{
771767
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
@@ -835,6 +831,14 @@ private void OnClanChanged(object? sender, ClanChangedEventArg e) =>
835831
ClanChanged?.Invoke(this,
836832
e.ClanInfo is { } info ? ClanProbeResult.From(ClanMapping.ToSnapshot(info)) : ClanProbeResult.NoClan);
837833

834+
private void OnTeamChanged(object? sender, RustPlusApi.Data.Events.TeamChangedEventArg e)
835+
{
836+
if (e.TeamInfo is { } teamInfo)
837+
{
838+
TeamChanged?.Invoke(this, TeamInfoMapping.ToSnapshot(teamInfo));
839+
}
840+
}
841+
838842
[LoggerMessage(Level = LogLevel.Warning, Message = "Rust+ socket connect failed.")]
839843
private static partial void LogConnectFailed(ILogger logger, Exception ex);
840844

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using RustPlusBot.Abstractions.Connections;
2+
3+
namespace RustPlusBot.Features.Connections.Listening;
4+
5+
/// <summary>Maps a RustPlusApi <see cref="RustPlusApi.Data.TeamInfo"/> to the bot's decoupled
6+
/// <see cref="TeamInfoSnapshot"/>. Shared by the polled read and the pushed <c>team_changed</c> event so
7+
/// the two paths can never drift.</summary>
8+
internal static class TeamInfoMapping
9+
{
10+
/// <summary>Projects a RustPlusApi team-info model onto a <see cref="TeamInfoSnapshot"/>.</summary>
11+
/// <param name="teamInfo">The RustPlusApi team info (from a poll response or a team_changed broadcast).</param>
12+
/// <returns>The decoupled snapshot the tracker and events consume.</returns>
13+
public static TeamInfoSnapshot ToSnapshot(RustPlusApi.Data.TeamInfo teamInfo)
14+
{
15+
// CONFIRMED (2.0.0-beta.6): MemberInfo exposes SteamId/Name?/X/Y/IsOnline/IsAlive plus
16+
// LastSpawnTime/LastDeathTime as Unspecified-kind DateTimes that are actually UTC.
17+
var members = (teamInfo.Members ?? [])
18+
.Select(m => new TeamMemberSnapshot(
19+
m.SteamId,
20+
m.Name ?? string.Empty,
21+
m.X,
22+
m.Y,
23+
m.IsOnline,
24+
m.IsAlive,
25+
new DateTimeOffset(DateTime.SpecifyKind(m.LastSpawnTime, DateTimeKind.Utc)),
26+
new DateTimeOffset(DateTime.SpecifyKind(m.LastDeathTime, DateTimeKind.Utc))))
27+
.ToList();
28+
var deathNote = teamInfo.DeathNote is { } dn
29+
? ((float X, float Y)?)(dn.X, dn.Y)
30+
: null;
31+
return new TeamInfoSnapshot(teamInfo.LeaderSteamId, members, deathNote);
32+
}
33+
}

0 commit comments

Comments
 (0)