Skip to content

Commit 6fc2a42

Browse files
HandyS11claude
andcommitted
feat(connections): IAfkState read seam over the AFK tracker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a1bace9 commit 6fc2a42

5 files changed

Lines changed: 85 additions & 5 deletions

File tree

src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
2222
services.AddSingleton<IConnectionSupervisor>(sp => sp.GetRequiredService<ConnectionSupervisor>());
2323
services.AddSingleton<ITeamChatSender>(sp => sp.GetRequiredService<ConnectionSupervisor>());
2424
services.AddSingleton<IRustServerQuery>(sp => sp.GetRequiredService<ConnectionSupervisor>());
25+
services.AddSingleton<IAfkState>(sp => sp.GetRequiredService<ConnectionSupervisor>());
2526
services.AddScoped<IServerRemovalService, ServerRemovalService>();
2627

2728
// Contribute this assembly's interaction modules to the Discord layer.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace RustPlusBot.Features.Connections.Listening;
2+
3+
/// <summary>One currently-AFK team member.</summary>
4+
/// <param name="SteamId">Steam64 id.</param>
5+
/// <param name="Name">In-game display name.</param>
6+
/// <param name="StillFor">How long the member has been continuously still.</param>
7+
public sealed record AfkMember(ulong SteamId, string Name, TimeSpan StillFor);
8+
9+
/// <summary>Reads the live AFK state computed by the connection poll loop.</summary>
10+
public interface IAfkState
11+
{
12+
/// <summary>Gets the currently-AFK members, or null when there is no live socket.</summary>
13+
/// <param name="guildId">The owning guild snowflake.</param>
14+
/// <param name="serverId">The target server id.</param>
15+
/// <param name="cancellationToken">A cancellation token.</param>
16+
/// <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);
18+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,23 @@ private void UpdateAfk(
115115
}
116116
}
117117

118+
/// <summary>The members currently flagged AFK and how long each has been still, as of <paramref name="now"/>.</summary>
119+
/// <param name="now">The current wall-clock time used to compute each member's still duration.</param>
120+
public IReadOnlyList<AfkMember> CurrentAfk(DateTimeOffset now)
121+
{
122+
var result = new List<AfkMember>();
123+
foreach (var id in _afk)
124+
{
125+
if (_baseline is not null && _baseline.TryGetValue(id, out var m))
126+
{
127+
var since = _stillSince.TryGetValue(id, out var s) ? s : now;
128+
result.Add(new AfkMember(id, m.Name, now - since));
129+
}
130+
}
131+
132+
return result;
133+
}
134+
118135
private static (float X, float Y)? ResolveDeathLocation(
119136
ulong steamId, TeamInfoSnapshot snapshot, TeamMemberSnapshot previous)
120137
{

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ internal sealed partial class ConnectionSupervisor(
3232
IEventBus eventBus,
3333
IClock clock,
3434
IOptions<ConnectionOptions> options,
35-
ILogger<ConnectionSupervisor> logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAsyncDisposable
35+
ILogger<ConnectionSupervisor> logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, IAsyncDisposable
3636
{
3737
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new();
3838
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -259,6 +259,18 @@ public async Task<TeamChatSendResult> SendAsync(
259259
}
260260
}
261261

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+
262274
[LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")]
263275
private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId);
264276

@@ -398,10 +410,11 @@ void OnTeamMessage(object? sender, TeamChatLine line)
398410
var dims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
399411
var rigs = await GetRigPositionsAsync(key.Server, connection, ct).ConfigureAwait(false);
400412

413+
var tracker = new TeamStateTracker();
401414
connection.TeamMessageReceived += OnTeamMessage;
402-
_liveSockets[key] = new LiveSocket(connection, activeSteamId);
415+
_liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker);
403416
using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
404-
var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, pollCts.Token),
417+
var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, tracker, pollCts.Token),
405418
CancellationToken.None);
406419
try
407420
{
@@ -448,11 +461,11 @@ private async Task PollMarkersAsync(
448461
IRustServerConnection connection,
449462
MapDimensions? dims,
450463
IReadOnlyList<RigPosition> rigs,
464+
TeamStateTracker tracker,
451465
CancellationToken ct)
452466
{
453467
IReadOnlyList<MapMarkerSnapshot>? previous = null;
454468
var rigsInRadius = new HashSet<RigKind>();
455-
var tracker = new TeamStateTracker();
456469
while (!ct.IsCancellationRequested)
457470
{
458471
var anyCh47 = false;
@@ -755,7 +768,7 @@ private readonly record struct Prepared(
755768
ulong SteamId,
756769
string PlayerToken);
757770

758-
private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId);
771+
private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId, TeamStateTracker Tracker);
759772

760773
private sealed class Handle(CancellationTokenSource cts, Task runTask) : IAsyncDisposable
761774
{
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
3+
namespace RustPlusBot.Features.Connections.Tests;
4+
5+
public sealed class AfkStateTests
6+
{
7+
[Fact]
8+
public void CurrentAfk_lists_member_with_still_duration()
9+
{
10+
var t = new TeamStateTracker();
11+
var t0 = DateTimeOffset.UnixEpoch;
12+
var m = new TeamMemberSnapshot(1, "Bob", 0, 0, true, true, t0, t0);
13+
t.Diff(new TeamInfoSnapshot(1, [m]), t0, TimeSpan.FromMinutes(5), 1f);
14+
t.Diff(new TeamInfoSnapshot(1, [m]), t0.AddMinutes(6), TimeSpan.FromMinutes(5), 1f); // BecameAfk
15+
16+
var afk = t.CurrentAfk(t0.AddMinutes(6));
17+
var bob = Assert.Single(afk);
18+
Assert.Equal(1UL, bob.SteamId);
19+
Assert.Equal(TimeSpan.FromMinutes(6), bob.StillFor);
20+
}
21+
22+
[Fact]
23+
public void CurrentAfk_empty_when_nobody_afk()
24+
{
25+
var t = new TeamStateTracker();
26+
var t0 = DateTimeOffset.UnixEpoch;
27+
var m = new TeamMemberSnapshot(1, "Bob", 0, 0, true, true, t0, t0);
28+
t.Diff(new TeamInfoSnapshot(1, [m]), t0, TimeSpan.FromMinutes(5), 1f);
29+
Assert.Empty(t.CurrentAfk(t0.AddMinutes(1)));
30+
}
31+
}

0 commit comments

Comments
 (0)