Skip to content

Commit a1bace9

Browse files
committed
feat(connections): AFK hysteresis tracking in TeamStateTracker
1 parent 4077f00 commit a1bace9

5 files changed

Lines changed: 177 additions & 45 deletions

File tree

src/RustPlusBot.Features.Connections/ConnectionOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,10 @@ public sealed class ConnectionOptions
3535

3636
/// <summary>How often the rig-timer tick advances rig phases and emits timed boundary events. Default 30s.</summary>
3737
public TimeSpan RigTickInterval { get; set; } = TimeSpan.FromSeconds(30);
38+
39+
/// <summary>How long a member must be still (and online + alive) before being flagged AFK. Default 5m.</summary>
40+
public TimeSpan AfkThreshold { get; set; } = TimeSpan.FromMinutes(5);
41+
42+
/// <summary>Movement tolerance (world units) below which a member is considered still. Default 1.</summary>
43+
public float AfkEpsilon { get; set; } = 1f;
3844
}

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

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ namespace RustPlusBot.Features.Connections.Listening;
77
internal sealed class TeamStateTracker
88
{
99
private Dictionary<ulong, TeamMemberSnapshot>? _baseline;
10+
private readonly Dictionary<ulong, DateTimeOffset> _stillSince = new();
11+
private readonly HashSet<ulong> _afk = new();
1012

1113
/// <summary>Diffs <paramref name="snapshot"/> against the previous one. First non-null call primes silently.</summary>
1214
/// <param name="snapshot">The latest team snapshot, or null when the poll returned no data.</param>
15+
/// <param name="now">Current wall-clock time (from <c>IClock.UtcNow</c>).</param>
16+
/// <param name="afkThreshold">How long a member must be still before being flagged AFK.</param>
17+
/// <param name="afkEpsilon">Movement tolerance (world units) below which a member is considered still.</param>
1318
/// <returns>The transitions since the previous snapshot; empty on prime, null input, or no change.</returns>
14-
public IReadOnlyList<PlayerTransition> Diff(TeamInfoSnapshot? snapshot)
19+
public IReadOnlyList<PlayerTransition> Diff(
20+
TeamInfoSnapshot? snapshot, DateTimeOffset now, TimeSpan afkThreshold, float afkEpsilon)
1521
{
1622
if (snapshot is null)
1723
{
@@ -22,42 +28,91 @@ public IReadOnlyList<PlayerTransition> Diff(TeamInfoSnapshot? snapshot)
2228

2329
if (_baseline is null)
2430
{
25-
_baseline = current; // first poll: silent baseline
31+
_baseline = current;
32+
foreach (var m in current.Values)
33+
{
34+
_stillSince[m.SteamId] = now;
35+
}
36+
2637
return [];
2738
}
2839

2940
var transitions = new List<PlayerTransition>();
30-
foreach (var (id, now) in current)
41+
foreach (var (id, nowMember) in current)
3142
{
3243
if (!_baseline.TryGetValue(id, out var was))
3344
{
34-
continue; // brand-new member: prime silently this poll
45+
_stillSince[id] = now; // prime new member's stillness clock
46+
continue;
3547
}
3648

37-
if (now.IsOnline && !was.IsOnline)
38-
{
39-
transitions.Add(new PlayerTransition(PlayerTransitionKind.Connect, id, now.Name, null));
40-
}
41-
else if (!now.IsOnline && was.IsOnline)
42-
{
43-
transitions.Add(new PlayerTransition(PlayerTransitionKind.Disconnect, id, now.Name, null));
44-
}
49+
AddPresenceTransitions(transitions, id, was, nowMember, snapshot);
50+
UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon);
51+
}
52+
53+
_baseline = current;
54+
return transitions;
55+
}
56+
57+
private static void AddPresenceTransitions(
58+
List<PlayerTransition> transitions, ulong id,
59+
TeamMemberSnapshot was, TeamMemberSnapshot now, TeamInfoSnapshot snapshot)
60+
{
61+
if (now.IsOnline && !was.IsOnline)
62+
{
63+
transitions.Add(new PlayerTransition(PlayerTransitionKind.Connect, id, now.Name, null));
64+
}
65+
else if (!now.IsOnline && was.IsOnline)
66+
{
67+
transitions.Add(new PlayerTransition(PlayerTransitionKind.Disconnect, id, now.Name, null));
68+
}
4569

46-
if (now.LastDeathTimeUtc > was.LastDeathTimeUtc)
70+
if (now.LastDeathTimeUtc > was.LastDeathTimeUtc)
71+
{
72+
transitions.Add(new PlayerTransition(
73+
PlayerTransitionKind.Death, id, now.Name, ResolveDeathLocation(id, snapshot, was)));
74+
}
75+
76+
if (now.LastSpawnTimeUtc > was.LastSpawnTimeUtc)
77+
{
78+
transitions.Add(new PlayerTransition(PlayerTransitionKind.Respawn, id, now.Name, (now.X, now.Y)));
79+
}
80+
}
81+
82+
private void UpdateAfk(
83+
List<PlayerTransition> transitions, ulong id,
84+
TeamMemberSnapshot was, TeamMemberSnapshot now, DateTimeOffset clock, TimeSpan threshold, float epsilon)
85+
{
86+
var eligible = now.IsOnline && now.IsAlive;
87+
if (!eligible)
88+
{
89+
if (_afk.Remove(id))
4790
{
48-
transitions.Add(new PlayerTransition(
49-
PlayerTransitionKind.Death, id, now.Name, ResolveDeathLocation(id, snapshot, was)));
91+
transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null));
5092
}
5193

52-
if (now.LastSpawnTimeUtc > was.LastSpawnTimeUtc)
94+
_stillSince[id] = clock;
95+
return;
96+
}
97+
98+
var moved = Math.Abs(now.X - was.X) > epsilon || Math.Abs(now.Y - was.Y) > epsilon;
99+
if (moved)
100+
{
101+
_stillSince[id] = clock;
102+
if (_afk.Remove(id))
53103
{
54-
transitions.Add(new PlayerTransition(
55-
PlayerTransitionKind.Respawn, id, now.Name, (now.X, now.Y)));
104+
transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null));
56105
}
106+
107+
return;
57108
}
58109

59-
_baseline = current;
60-
return transitions;
110+
var since = _stillSince.TryGetValue(id, out var s) ? s : clock;
111+
_stillSince.TryAdd(id, since);
112+
if (clock - since >= threshold && _afk.Add(id))
113+
{
114+
transitions.Add(new PlayerTransition(PlayerTransitionKind.BecameAfk, id, now.Name, (now.X, now.Y)));
115+
}
61116
}
62117

63118
private static (float X, float Y)? ResolveDeathLocation(

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Extensions.Options;
66
using RustPlusBot.Abstractions.Credentials;
77
using RustPlusBot.Abstractions.Events;
8+
using RustPlusBot.Abstractions.Time;
89
using RustPlusBot.Discord.Notifications;
910
using RustPlusBot.Domain.Connections;
1011
using RustPlusBot.Domain.Credentials;
@@ -20,6 +21,7 @@ namespace RustPlusBot.Features.Connections.Supervisor;
2021
/// <param name="dmSender">DMs an owner when their credential is rejected.</param>
2122
/// <param name="protector">Unprotects stored tokens before connecting.</param>
2223
/// <param name="eventBus">Publishes ConnectionStatusChangedEvent on state changes.</param>
24+
/// <param name="clock">Wall-clock source used for AFK hysteresis timestamps.</param>
2325
/// <param name="options">Timeouts/backoff/heartbeat settings.</param>
2426
/// <param name="logger">The logger.</param>
2527
internal sealed partial class ConnectionSupervisor(
@@ -28,6 +30,7 @@ internal sealed partial class ConnectionSupervisor(
2830
IUserDmSender dmSender,
2931
ICredentialProtector protector,
3032
IEventBus eventBus,
33+
IClock clock,
3134
IOptions<ConnectionOptions> options,
3235
ILogger<ConnectionSupervisor> logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAsyncDisposable
3336
{
@@ -478,7 +481,7 @@ await eventBus.PublishAsync(
478481
await DetectRigActivationsAsync(key, current, rigs, dims, rigsInRadius, ct).ConfigureAwait(false);
479482

480483
var team = await connection.GetTeamInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
481-
var transitions = tracker.Diff(team);
484+
var transitions = tracker.Diff(team, clock.UtcNow, _options.AfkThreshold, _options.AfkEpsilon);
482485
if (transitions.Count > 0)
483486
{
484487
await eventBus.PublishAsync(
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using RustPlusBot.Abstractions.Events;
2+
using RustPlusBot.Features.Connections.Listening;
3+
4+
namespace RustPlusBot.Features.Connections.Tests;
5+
6+
public sealed class TeamStateTrackerAfkTests
7+
{
8+
private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
9+
private const float Eps = 1f;
10+
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);
13+
14+
private static TeamInfoSnapshot Team(params TeamMemberSnapshot[] m) => new(1, m);
15+
16+
[Fact]
17+
public void Still_for_threshold_emits_one_BecameAfk()
18+
{
19+
var t = new TeamStateTracker();
20+
var t0 = DateTimeOffset.UnixEpoch;
21+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); // prime
22+
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
24+
Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk && x.Location == (0f, 0f));
25+
Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(9), Threshold, Eps)); // latched, no repeat
26+
}
27+
28+
[Fact]
29+
public void Moving_after_afk_emits_ReturnedFromAfk()
30+
{
31+
var t = new TeamStateTracker();
32+
var t0 = DateTimeOffset.UnixEpoch;
33+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
34+
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
35+
var back = t.Diff(Team(Member(1, 50, 50)), t0.AddMinutes(7), Threshold, Eps);
36+
Assert.Single(back, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk && x.Location == null);
37+
}
38+
39+
[Fact]
40+
public void Going_offline_while_afk_emits_ReturnedFromAfk()
41+
{
42+
var t = new TeamStateTracker();
43+
var t0 = DateTimeOffset.UnixEpoch;
44+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
45+
t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
46+
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);
48+
}
49+
50+
[Fact]
51+
public void Dead_member_is_never_afk()
52+
{
53+
var t = new TeamStateTracker();
54+
var t0 = DateTimeOffset.UnixEpoch;
55+
t.Diff(Team(Member(1, 0, 0, alive: false)), t0, Threshold, Eps);
56+
Assert.Empty(t.Diff(Team(Member(1, 0, 0, alive: false)), t0.AddMinutes(10), Threshold, Eps));
57+
}
58+
59+
[Fact]
60+
public void Small_jitter_below_epsilon_still_counts_as_still()
61+
{
62+
var t = new TeamStateTracker();
63+
var t0 = DateTimeOffset.UnixEpoch;
64+
t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
65+
var afk = t.Diff(Team(Member(1, 0.5f, 0.5f)), t0.AddMinutes(6), Threshold, Eps); // < 1 unit move
66+
Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk);
67+
}
68+
}

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

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,45 +17,45 @@ private static TeamInfoSnapshot Team(ulong leader, params TeamMemberSnapshot[] m
1717
public void First_snapshot_primes_silently()
1818
{
1919
var tracker = new TeamStateTracker();
20-
var result = tracker.Diff(Team(1, Member(1)));
20+
var result = tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
2121
Assert.Empty(result);
2222
}
2323

2424
[Fact]
2525
public void Null_snapshot_emits_nothing_and_keeps_baseline()
2626
{
2727
var tracker = new TeamStateTracker();
28-
tracker.Diff(Team(1, Member(1, online: true)));
29-
Assert.Empty(tracker.Diff(null));
28+
tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
29+
Assert.Empty(tracker.Diff(null, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f));
3030
// After the null, an offline flip is still detected against the original baseline.
31-
var result = tracker.Diff(Team(1, Member(1, online: false)));
31+
var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
3232
Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect);
3333
}
3434

3535
[Fact]
3636
public void Brand_new_member_is_primed_silently()
3737
{
3838
var tracker = new TeamStateTracker();
39-
tracker.Diff(Team(1, Member(1)));
40-
var result = tracker.Diff(Team(1, Member(1), Member(2, online: true)));
39+
tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
40+
var result = tracker.Diff(Team(1, Member(1), Member(2, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
4141
Assert.DoesNotContain(result, t => t.SteamId == 2);
4242
}
4343

4444
[Fact]
4545
public void Connect_detected_on_offline_to_online()
4646
{
4747
var tracker = new TeamStateTracker();
48-
tracker.Diff(Team(1, Member(1, online: false)));
49-
var result = tracker.Diff(Team(1, Member(1, online: true)));
48+
tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
49+
var result = tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
5050
Assert.Single(result, t => t.Kind == PlayerTransitionKind.Connect && t.SteamId == 1);
5151
}
5252

5353
[Fact]
5454
public void Disconnect_detected_on_online_to_offline()
5555
{
5656
var tracker = new TeamStateTracker();
57-
tracker.Diff(Team(1, Member(1, online: true)));
58-
var result = tracker.Diff(Team(1, Member(1, online: false)));
57+
tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
58+
var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
5959
Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect && t.SteamId == 1);
6060
}
6161

@@ -64,9 +64,9 @@ public void Death_detected_on_deathtime_advance_even_if_alive_again()
6464
{
6565
var t0 = DateTimeOffset.UnixEpoch;
6666
var tracker = new TeamStateTracker();
67-
tracker.Diff(Team(1, Member(1, alive: true, death: t0)));
67+
tracker.Diff(Team(1, Member(1, alive: true, death: t0)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
6868
// Died and already respawned: IsAlive true both times, but LastDeathTime advanced.
69-
var result = tracker.Diff(Team(1, Member(1, alive: true, death: t0.AddMinutes(1))));
69+
var result = tracker.Diff(Team(1, Member(1, alive: true, death: t0.AddMinutes(1))), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
7070
Assert.Contains(result, t => t.Kind == PlayerTransitionKind.Death && t.SteamId == 1);
7171
}
7272

@@ -75,8 +75,8 @@ public void Respawn_detected_on_spawntime_advance_with_current_location()
7575
{
7676
var t0 = DateTimeOffset.UnixEpoch;
7777
var tracker = new TeamStateTracker();
78-
tracker.Diff(Team(1, Member(1, spawn: t0)));
79-
var result = tracker.Diff(Team(1, Member(1, x: 50, y: 60, spawn: t0.AddMinutes(1))));
78+
tracker.Diff(Team(1, Member(1, spawn: t0)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
79+
var result = tracker.Diff(Team(1, Member(1, x: 50, y: 60, spawn: t0.AddMinutes(1))), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
8080
var respawn = Assert.Single(result, t => t.Kind == PlayerTransitionKind.Respawn);
8181
Assert.Equal((50f, 60f), respawn.Location);
8282
}
@@ -86,16 +86,16 @@ public void Unchanged_snapshot_emits_nothing()
8686
{
8787
var tracker = new TeamStateTracker();
8888
var m = Member(1, spawn: DateTimeOffset.UnixEpoch, death: DateTimeOffset.UnixEpoch);
89-
tracker.Diff(new TeamInfoSnapshot(1, [m]));
90-
Assert.Empty(tracker.Diff(new TeamInfoSnapshot(1, [m])));
89+
tracker.Diff(new TeamInfoSnapshot(1, [m]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
90+
Assert.Empty(tracker.Diff(new TeamInfoSnapshot(1, [m]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f));
9191
}
9292

9393
[Fact]
9494
public void Connect_and_disconnect_have_no_location()
9595
{
9696
var tracker = new TeamStateTracker();
97-
tracker.Diff(Team(1, Member(1, online: false)));
98-
var result = tracker.Diff(Team(1, Member(1, online: true, x: 9, y: 9)));
97+
tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
98+
var result = tracker.Diff(Team(1, Member(1, online: true, x: 9, y: 9)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
9999
Assert.Null(Assert.Single(result).Location);
100100
}
101101

@@ -104,9 +104,9 @@ public void Leader_death_uses_deathnote_location()
104104
{
105105
var t0 = DateTimeOffset.UnixEpoch;
106106
var tracker = new TeamStateTracker();
107-
tracker.Diff(new TeamInfoSnapshot(1, [Member(1, death: t0)]));
107+
tracker.Diff(new TeamInfoSnapshot(1, [Member(1, death: t0)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
108108
var snap = new TeamInfoSnapshot(1, [Member(1, x: 1, y: 1, death: t0.AddMinutes(1))], (700f, 800f));
109-
var death = Assert.Single(tracker.Diff(snap), t => t.Kind == PlayerTransitionKind.Death);
109+
var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f), t => t.Kind == PlayerTransitionKind.Death);
110110
Assert.Equal((700f, 800f), death.Location);
111111
}
112112

@@ -116,10 +116,10 @@ public void Nonleader_death_uses_previous_poll_position_not_current()
116116
var t0 = DateTimeOffset.UnixEpoch;
117117
var tracker = new TeamStateTracker();
118118
// Member 2 is alive at (10,10) on the baseline poll...
119-
tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, x: 10, y: 10, death: t0)]));
119+
tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, x: 10, y: 10, death: t0)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
120120
// ...then dies and respawns at (999,999); death must report the PRE-death (10,10).
121121
var snap = new TeamInfoSnapshot(1, [Member(1), Member(2, x: 999, y: 999, death: t0.AddMinutes(1))], (5f, 5f));
122-
var death = Assert.Single(tracker.Diff(snap), t => t.Kind == PlayerTransitionKind.Death);
122+
var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f), t => t.Kind == PlayerTransitionKind.Death);
123123
Assert.Equal((10f, 10f), death.Location); // leader DeathNote (5,5) must NOT apply to a non-leader
124124
}
125125

@@ -129,9 +129,9 @@ public void Death_with_no_prior_position_and_no_deathnote_has_null_location()
129129
var t0 = DateTimeOffset.UnixEpoch;
130130
var tracker = new TeamStateTracker();
131131
// Prime member 1 only.
132-
tracker.Diff(new TeamInfoSnapshot(1, [Member(1)]));
132+
tracker.Diff(new TeamInfoSnapshot(1, [Member(1)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
133133
// Member 2 appears already-dead-advanced in the same poll it is first seen → primed silently, no death.
134-
var first = tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, death: t0)]));
134+
var first = tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, death: t0)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
135135
Assert.DoesNotContain(first, t => t.SteamId == 2);
136136
}
137137
}

0 commit comments

Comments
 (0)