Skip to content

Commit 7e41a82

Browse files
HandyS11claude
andcommitted
fix(players): address Copilot review + ReSharper format gate
- AFK: clear the latch SILENTLY when a member goes offline, is dead, or died this poll (detected via LastDeathTimeUtc advancing even on a slow poll where IsAlive already flipped back), instead of emitting a contradictory ReturnedFromAfk alongside the disconnect/death line. - AFK movement: use a squared-distance check so AfkEpsilon is a true movement radius (per-axis treated diagonal moves as still). - Prune per-member stillness/AFK state for ids absent from the snapshot (members who leave the team) so the maps don't grow unbounded. - Add player.title to the localization-catalog key-coverage test. - Run jb cleanupcode (ReformatAndReorder) — fixes the Linux CI format gate that the original push skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5b95505 commit 7e41a82

10 files changed

Lines changed: 175 additions & 59 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,7 @@ public interface IAfkState
1414
/// <param name="serverId">The target server id.</param>
1515
/// <param name="cancellationToken">A cancellation token.</param>
1616
/// <returns>The currently-AFK members, or null when there is no live socket.</returns>
17-
Task<IReadOnlyList<AfkMember>?> GetAfkMembersAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
17+
Task<IReadOnlyList<AfkMember>?> GetAfkMembersAsync(ulong guildId,
18+
Guid serverId,
19+
CancellationToken cancellationToken);
1820
}

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

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ namespace RustPlusBot.Features.Connections.Listening;
55
/// <summary>Diffs successive team snapshots into presence transitions. One instance per connected window.</summary>
66
internal sealed class TeamStateTracker
77
{
8+
private readonly HashSet<ulong> _afk = new();
89
private readonly object _gate = new();
9-
private Dictionary<ulong, TeamMemberSnapshot>? _baseline;
1010
private readonly Dictionary<ulong, DateTimeOffset> _stillSince = new();
11-
private readonly HashSet<ulong> _afk = new();
11+
private Dictionary<ulong, TeamMemberSnapshot>? _baseline;
1212

1313
/// <summary>Diffs <paramref name="snapshot"/> against the previous one. First non-null call primes silently.</summary>
1414
/// <param name="snapshot">The latest team snapshot, or null when the poll returned no data.</param>
@@ -17,7 +17,10 @@ internal sealed class TeamStateTracker
1717
/// <param name="afkEpsilon">Movement tolerance (world units) below which a member is considered still.</param>
1818
/// <returns>The transitions since the previous snapshot; empty on prime, null input, or no change.</returns>
1919
public IReadOnlyList<PlayerTransition> Diff(
20-
TeamInfoSnapshot? snapshot, DateTimeOffset now, TimeSpan afkThreshold, float afkEpsilon)
20+
TeamInfoSnapshot? snapshot,
21+
DateTimeOffset now,
22+
TimeSpan afkThreshold,
23+
float afkEpsilon)
2124
{
2225
if (snapshot is null)
2326
{
@@ -49,17 +52,22 @@ public IReadOnlyList<PlayerTransition> Diff(
4952
}
5053

5154
AddPresenceTransitions(transitions, id, was, nowMember, snapshot);
52-
UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon);
55+
var diedThisPoll = nowMember.LastDeathTimeUtc > was.LastDeathTimeUtc;
56+
UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon, diedThisPoll);
5357
}
5458

59+
PruneDepartedMembers(current);
5560
_baseline = current;
5661
return transitions;
5762
}
5863
}
5964

6065
private static void AddPresenceTransitions(
61-
List<PlayerTransition> transitions, ulong id,
62-
TeamMemberSnapshot was, TeamMemberSnapshot now, TeamInfoSnapshot snapshot)
66+
List<PlayerTransition> transitions,
67+
ulong id,
68+
TeamMemberSnapshot was,
69+
TeamMemberSnapshot now,
70+
TeamInfoSnapshot snapshot)
6371
{
6472
if (now.IsOnline && !was.IsOnline)
6573
{
@@ -83,22 +91,31 @@ private static void AddPresenceTransitions(
8391
}
8492

8593
private void UpdateAfk(
86-
List<PlayerTransition> transitions, ulong id,
87-
TeamMemberSnapshot was, TeamMemberSnapshot now, DateTimeOffset clock, TimeSpan threshold, float epsilon)
94+
List<PlayerTransition> transitions,
95+
ulong id,
96+
TeamMemberSnapshot was,
97+
TeamMemberSnapshot now,
98+
DateTimeOffset clock,
99+
TimeSpan threshold,
100+
float epsilon,
101+
bool diedThisPoll)
88102
{
89-
var eligible = now.IsOnline && now.IsAlive;
90-
if (!eligible)
103+
// A member who goes offline, is dead, or died this poll (even if a slow poll already shows them
104+
// respawned) can no longer be AFK. Clear the AFK latch SILENTLY — the disconnect/death transition
105+
// already speaks for them, and a "back" line alongside "disconnected"/"died" would contradict it —
106+
// and reset the stillness clock so AFK must be re-earned after the state change.
107+
if (!now.IsOnline || !now.IsAlive || diedThisPoll)
91108
{
92-
if (_afk.Remove(id))
93-
{
94-
transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null));
95-
}
96-
109+
_afk.Remove(id);
97110
_stillSince[id] = clock;
98111
return;
99112
}
100113

101-
var moved = Math.Abs(now.X - was.X) > epsilon || Math.Abs(now.Y - was.Y) > epsilon;
114+
// Squared-distance check so epsilon is a true movement radius (per-axis would treat diagonal
115+
// movement of dx=dy=0.8, epsilon=1 — distance ≈ 1.13 — as still).
116+
var dx = now.X - was.X;
117+
var dy = now.Y - was.Y;
118+
var moved = (dx * dx) + (dy * dy) > epsilon * epsilon;
102119
if (moved)
103120
{
104121
_stillSince[id] = clock;
@@ -118,6 +135,23 @@ private void UpdateAfk(
118135
}
119136
}
120137

138+
/// <summary>Drops per-member AFK/stillness state for ids no longer present in the team snapshot.</summary>
139+
/// <param name="current">The members in the latest snapshot, keyed by Steam id.</param>
140+
private void PruneDepartedMembers(Dictionary<ulong, TeamMemberSnapshot> current)
141+
{
142+
if (_stillSince.Count == 0 && _afk.Count == 0)
143+
{
144+
return;
145+
}
146+
147+
foreach (var id in _stillSince.Keys.Where(id => !current.ContainsKey(id)).ToList())
148+
{
149+
_stillSince.Remove(id);
150+
}
151+
152+
_afk.RemoveWhere(id => !current.ContainsKey(id));
153+
}
154+
121155
/// <summary>The members currently flagged AFK and how long each has been still, as of <paramref name="now"/>.</summary>
122156
/// <param name="now">The current wall-clock time used to compute each member's still duration.</param>
123157
public IReadOnlyList<AfkMember> CurrentAfk(DateTimeOffset now)
@@ -139,7 +173,9 @@ public IReadOnlyList<AfkMember> CurrentAfk(DateTimeOffset now)
139173
}
140174

141175
private static (float X, float Y)? ResolveDeathLocation(
142-
ulong steamId, TeamInfoSnapshot snapshot, TeamMemberSnapshot previous)
176+
ulong steamId,
177+
TeamInfoSnapshot snapshot,
178+
TeamMemberSnapshot previous)
143179
{
144180
// Leader: the single DeathNote is the true death spot (player respawns elsewhere).
145181
if (steamId == snapshot.LeaderSteamId && snapshot.DeathNote is { } note)

src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ internal sealed partial class ConnectionSupervisor(
3232
IEventBus eventBus,
3333
IClock clock,
3434
IOptions<ConnectionOptions> options,
35-
ILogger<ConnectionSupervisor> logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, IAsyncDisposable
35+
ILogger<ConnectionSupervisor> logger)
36+
: IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, IAsyncDisposable
3637
{
3738
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new();
3839
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -41,6 +42,20 @@ internal sealed partial class ConnectionSupervisor(
4142
private readonly CancellationTokenSource _shutdown = new();
4243
private bool _disposed;
4344

45+
/// <inheritdoc />
46+
public Task<IReadOnlyList<AfkMember>?> GetAfkMembersAsync(
47+
ulong guildId,
48+
Guid serverId,
49+
CancellationToken cancellationToken)
50+
{
51+
if (_liveSockets.TryGetValue((guildId, serverId), out var live))
52+
{
53+
return Task.FromResult<IReadOnlyList<AfkMember>?>(live.Tracker.CurrentAfk(clock.UtcNow));
54+
}
55+
56+
return Task.FromResult<IReadOnlyList<AfkMember>?>(null);
57+
}
58+
4459
/// <inheritdoc />
4560
public async ValueTask DisposeAsync()
4661
{
@@ -259,18 +274,6 @@ public async Task<TeamChatSendResult> SendAsync(
259274
}
260275
}
261276

262-
/// <inheritdoc />
263-
public Task<IReadOnlyList<AfkMember>?> GetAfkMembersAsync(
264-
ulong guildId, Guid serverId, CancellationToken cancellationToken)
265-
{
266-
if (_liveSockets.TryGetValue((guildId, serverId), out var live))
267-
{
268-
return Task.FromResult<IReadOnlyList<AfkMember>?>(live.Tracker.CurrentAfk(clock.UtcNow));
269-
}
270-
271-
return Task.FromResult<IReadOnlyList<AfkMember>?>(null);
272-
}
273-
274277
[LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")]
275278
private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId);
276279

src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public async Task RelayAsync(PlayerStateChangedEvent evt, CancellationToken canc
3939
foreach (var t in evt.Transitions)
4040
{
4141
await teamChatSender
42-
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture), cancellationToken)
42+
.SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture),
43+
cancellationToken)
4344
.ConfigureAwait(false);
4445
if (channelId is { } id)
4546
{

tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ public async Task Reports_none_when_empty()
3838
public async Task Lists_afk_members_with_durations()
3939
{
4040
_afk.GetAfkMembersAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
41-
.Returns(new List<AfkMember> { new(1, "Bob", TimeSpan.FromMinutes(6)) });
41+
.Returns(new List<AfkMember>
42+
{
43+
new(1, "Bob", TimeSpan.FromMinutes(6))
44+
});
4245
var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
4346
Assert.Contains("Bob", reply, StringComparison.Ordinal);
4447
}

tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ namespace RustPlusBot.Features.Connections.Tests;
55

66
public sealed class TeamStateTrackerAfkTests
77
{
8-
private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
98
private const float Eps = 1f;
9+
private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
1010

11-
private static TeamMemberSnapshot Member(ulong id, float x, float y, bool online = true, bool alive = true)
12-
=> new(id, $"P{id}", x, y, online, alive, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch);
11+
private static TeamMemberSnapshot Member(
12+
ulong id,
13+
float x,
14+
float y,
15+
bool online = true,
16+
bool alive = true,
17+
DateTimeOffset death = default)
18+
=> new(id, $"P{id}", x, y, online, alive, DateTimeOffset.UnixEpoch, death);
1319

1420
private static TeamInfoSnapshot Team(params TeamMemberSnapshot[] m) => new(1, m);
1521

@@ -18,9 +24,9 @@ public void Still_for_threshold_emits_one_BecameAfk()
1824
{
1925
var t = new TeamStateTracker();
2026
var t0 = DateTimeOffset.UnixEpoch;
21-
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); // prime
27+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); // prime
2228
Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(2), Threshold, Eps)); // still, < threshold
23-
var afk = t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // crossed
29+
var afk = t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // crossed
2430
Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk && x.Location == (0f, 0f));
2531
Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(9), Threshold, Eps)); // latched, no repeat
2632
}
@@ -31,20 +37,38 @@ public void Moving_after_afk_emits_ReturnedFromAfk()
3137
var t = new TeamStateTracker();
3238
var t0 = DateTimeOffset.UnixEpoch;
3339
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
34-
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
40+
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
3541
var back = t.Diff(Team(Member(1, 50, 50)), t0.AddMinutes(7), Threshold, Eps);
3642
Assert.Single(back, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk && x.Location == null);
3743
}
3844

3945
[Fact]
40-
public void Going_offline_while_afk_emits_ReturnedFromAfk()
46+
public void Going_offline_while_afk_clears_silently_without_ReturnedFromAfk()
4147
{
4248
var t = new TeamStateTracker();
4349
var t0 = DateTimeOffset.UnixEpoch;
4450
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
45-
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
51+
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
4652
var off = t.Diff(Team(Member(1, 0, 0, online: false)), t0.AddMinutes(7), Threshold, Eps);
47-
Assert.Contains(off, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk);
53+
// The Disconnect transition speaks for them; no contradictory "is back" line.
54+
Assert.DoesNotContain(off, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk);
55+
Assert.Contains(off, x => x.Kind == PlayerTransitionKind.Disconnect);
56+
Assert.Empty(t.CurrentAfk(t0.AddMinutes(7)));
57+
}
58+
59+
[Fact]
60+
public void Dying_while_afk_clears_silently_even_if_already_respawned()
61+
{
62+
var t = new TeamStateTracker();
63+
var t0 = DateTimeOffset.UnixEpoch;
64+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
65+
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
66+
// Slow poll: died and already respawned (IsAlive true), but LastDeathTime advanced.
67+
var died = t.Diff(
68+
Team(Member(1, 0, 0, alive: true, death: t0.AddMinutes(7))), t0.AddMinutes(8), Threshold, Eps);
69+
Assert.DoesNotContain(died, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk);
70+
Assert.Contains(died, x => x.Kind == PlayerTransitionKind.Death);
71+
Assert.Empty(t.CurrentAfk(t0.AddMinutes(8)));
4872
}
4973

5074
[Fact]
@@ -65,4 +89,30 @@ public void Small_jitter_below_epsilon_still_counts_as_still()
6589
var afk = t.Diff(Team(Member(1, 0.5f, 0.5f)), t0.AddMinutes(6), Threshold, Eps); // < 1 unit move
6690
Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk);
6791
}
92+
93+
[Fact]
94+
public void Diagonal_move_beyond_epsilon_radius_counts_as_moved()
95+
{
96+
var t = new TeamStateTracker();
97+
var t0 = DateTimeOffset.UnixEpoch;
98+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
99+
// dx=dy=0.8 → distance ≈ 1.13 > epsilon 1; per-axis would wrongly call this "still".
100+
var afk = t.Diff(Team(Member(1, 0.8f, 0.8f)), t0.AddMinutes(6), Threshold, Eps);
101+
Assert.DoesNotContain(afk, x => x.Kind == PlayerTransitionKind.BecameAfk);
102+
}
103+
104+
[Fact]
105+
public void Departed_member_state_is_pruned()
106+
{
107+
var t = new TeamStateTracker();
108+
var t0 = DateTimeOffset.UnixEpoch;
109+
t.Diff(Team(Member(1, 0, 0), Member(2, 0, 0)), t0, Threshold, Eps);
110+
t.Diff(Team(Member(1, 0, 0), Member(2, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // both BecameAfk
111+
Assert.Equal(2, t.CurrentAfk(t0.AddMinutes(6)).Count);
112+
// Member 2 leaves the team (absent from the snapshot entirely).
113+
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(7), Threshold, Eps);
114+
var afk = t.CurrentAfk(t0.AddMinutes(7));
115+
Assert.Single(afk);
116+
Assert.Equal(1UL, afk[0].SteamId);
117+
}
68118
}

0 commit comments

Comments
 (0)