Skip to content

Commit 8d8b656

Browse files
HandyS11claude
andauthored
Subsystem 3d: team presence events (connect/disconnect/death/respawn/AFK) + in-game !afk (#17)
* feat(connections): surface leader DeathNote on TeamInfoSnapshot * feat(abstractions): add PlayerTransition + PlayerStateChangedEvent * chore(players): scaffold Features.Players slice + test project * feat(connections): TeamStateTracker diffs team snapshots into transitions * feat(players): EN/FR player-event localization catalog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(players): renderer + relay to #events and in-game chat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(players): publish + relay PlayerStateChangedEvent from poll loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(players): all transition kinds render end-to-end * feat(connections): AFK hysteresis tracking in TeamStateTracker * feat(connections): IAfkState read seam over the AFK tracker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): in-game !afk command over IAfkState * docs(commands): list !afk in the help catalog Add afk to CommandHelpCatalog.InGame (CommandGroup.TeamIntel, help.afk), add help.afk EN/FR descriptions to CommandLocalizationCatalog, and update CommandHelpCatalogTests.HandlerNames from 12 to 13 entries to keep the bidirectional drift test green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(connections): guard TeamStateTracker with a lock for concurrent !afk reads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d4e4340 commit 8d8b656

40 files changed

Lines changed: 1416 additions & 9 deletions

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
1111
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1212
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
13+
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
1314
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1415
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
1516
</Folder>
@@ -19,6 +20,7 @@
1920
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
2021
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
2122
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
23+
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
2224
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
2325
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
2426
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />

src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ namespace RustPlusBot.Features.Connections.Listening;
33
/// <summary>A point-in-time view of a team, decoupled from RustPlusApi types.</summary>
44
/// <param name="LeaderSteamId">Steam64 id of the current team leader.</param>
55
/// <param name="Members">Status snapshots for all team members.</param>
6-
public sealed record TeamInfoSnapshot(ulong LeaderSteamId, IReadOnlyList<TeamMemberSnapshot> Members);
6+
/// <param name="DeathNote">The leader's death-note map coordinate, if one is set; null otherwise.</param>
7+
public sealed record TeamInfoSnapshot(
8+
ulong LeaderSteamId,
9+
IReadOnlyList<TeamMemberSnapshot> Members,
10+
(float X, float Y)? DeathNote = null);
711

812
/// <summary>A point-in-time view of one team member.</summary>
913
/// <param name="SteamId">Steam64 id of the member.</param>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
3+
namespace RustPlusBot.Abstractions.Events;
4+
5+
/// <summary>Published when a team-info poll detects presence transitions since the previous poll.</summary>
6+
/// <param name="GuildId">The owning guild snowflake.</param>
7+
/// <param name="ServerId">The target server id.</param>
8+
/// <param name="Dimensions">Map dimensions for grid rendering, or null if unavailable.</param>
9+
/// <param name="Transitions">The transitions detected this poll (never empty when published).</param>
10+
public sealed record PlayerStateChangedEvent(
11+
ulong GuildId,
12+
Guid ServerId,
13+
MapDimensions? Dimensions,
14+
IReadOnlyList<PlayerTransition> Transitions);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>One team-member presence transition, with a pre-resolved optional map location.</summary>
4+
/// <param name="Kind">The transition kind.</param>
5+
/// <param name="SteamId">Steam64 id of the member.</param>
6+
/// <param name="Name">In-game display name (empty when the game reports none).</param>
7+
/// <param name="Location">Resolved map coordinate to show, or null when no location applies.</param>
8+
public sealed record PlayerTransition(
9+
PlayerTransitionKind Kind,
10+
ulong SteamId,
11+
string Name,
12+
(float X, float Y)? Location);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>The kind of team-member presence transition detected between two team snapshots.</summary>
4+
public enum PlayerTransitionKind
5+
{
6+
/// <summary>Member came online.</summary>
7+
Connect = 0,
8+
9+
/// <summary>Member went offline.</summary>
10+
Disconnect = 1,
11+
12+
/// <summary>Member died.</summary>
13+
Death = 2,
14+
15+
/// <summary>Member spawned/respawned.</summary>
16+
Respawn = 3,
17+
18+
/// <summary>Member crossed the AFK stillness threshold.</summary>
19+
BecameAfk = 4,
20+
21+
/// <summary>Member moved again after being AFK.</summary>
22+
ReturnedFromAfk = 5,
23+
}

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
3737
services.AddScoped<ICommandHandler, TeamCommandHandler>();
3838
services.AddScoped<ICommandHandler, SteamIdCommandHandler>();
3939
services.AddScoped<ICommandHandler, AliveCommandHandler>();
40+
services.AddScoped<ICommandHandler, AfkCommandHandler>();
4041
services.AddScoped<ICommandHandler, ProxCommandHandler>();
4142
services.AddScoped<ICommandHandler, CargoCommandHandler>();
4243
services.AddScoped<ICommandHandler, HeliCommandHandler>();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Formatting;
3+
using RustPlusBot.Features.Commands.Localization;
4+
using RustPlusBot.Features.Connections.Listening;
5+
6+
namespace RustPlusBot.Features.Commands.Handlers;
7+
8+
/// <summary>!afk — lists team members who have been still (online + alive) past the AFK threshold.</summary>
9+
/// <param name="afk">The live AFK state.</param>
10+
/// <param name="localizer">The reply localizer.</param>
11+
internal sealed class AfkCommandHandler(IAfkState afk, ICommandLocalizer localizer) : ICommandHandler
12+
{
13+
/// <inheritdoc />
14+
public string Name => "afk";
15+
16+
/// <inheritdoc />
17+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
18+
{
19+
ArgumentNullException.ThrowIfNull(context);
20+
var members = await afk.GetAfkMembersAsync(context.GuildId, context.ServerId, cancellationToken)
21+
.ConfigureAwait(false);
22+
if (members is null)
23+
{
24+
return localizer.Get("command.notconnected", context.Culture);
25+
}
26+
27+
if (members.Count == 0)
28+
{
29+
return localizer.Get("command.afk.none", context.Culture);
30+
}
31+
32+
var parts = members
33+
.OrderByDescending(m => m.StillFor)
34+
.Select(m => localizer.Get(
35+
"command.afk.member", context.Culture, m.Name, DurationFormat.Compact(m.StillFor)));
36+
return localizer.Get("command.afk.ok", context.Culture, string.Join(", ", parts));
37+
}
38+
}

src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal static class CommandHelpCatalog
2222
new("team", CommandGroup.TeamIntel, "help.team"),
2323
new("steamid", CommandGroup.TeamIntel, "help.steamid"),
2424
new("alive", CommandGroup.TeamIntel, "help.alive"),
25+
new("afk", CommandGroup.TeamIntel, "help.afk"),
2526
new("prox", CommandGroup.TeamIntel, "help.prox"),
2627
new("uptime", CommandGroup.Bot, "help.uptime"),
2728
];

src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ internal sealed class CommandLocalizationCatalog
3131
["command.team.none"] = "No team members.",
3232
["command.team.nomatch"] = "No teammate matches '{0}'.",
3333
["command.steamid.ok"] = "{0}",
34+
["command.afk.ok"] = "AFK: {0}",
35+
["command.afk.none"] = "Nobody is AFK.",
36+
["command.afk.member"] = "{0} ({1})",
3437
["command.alive.ok"] = "Alive: {0}",
3538
["command.alive.dead"] = "{0} dead",
3639
["command.alive.member"] = "{0} {1}",
@@ -77,6 +80,7 @@ internal sealed class CommandLocalizationCatalog
7780
["help.team"] = "List all team members.",
7881
["help.steamid"] = "Show teammates' Steam IDs.",
7982
["help.alive"] = "Show how long each teammate has survived.",
83+
["help.afk"] = "List teammates who are AFK.",
8084
["help.prox"] = "Show distance to each teammate.",
8185
["help.uptime"] = "Show how long the bot has been running.",
8286
["help.slash.help"] = "Show this help message.",
@@ -111,6 +115,9 @@ internal sealed class CommandLocalizationCatalog
111115
["command.team.none"] = "Aucun membre d'équipe.",
112116
["command.team.nomatch"] = "Aucun coéquipier ne correspond à « {0} ».",
113117
["command.steamid.ok"] = "{0}",
118+
["command.afk.ok"] = "AFK : {0}",
119+
["command.afk.none"] = "Personne n'est AFK.",
120+
["command.afk.member"] = "{0} ({1})",
114121
["command.alive.ok"] = "En vie : {0}",
115122
["command.alive.dead"] = "{0} mort",
116123
["command.alive.member"] = "{0} {1}",
@@ -158,6 +165,7 @@ internal sealed class CommandLocalizationCatalog
158165
["help.team"] = "Lister tous les membres de l'équipe.",
159166
["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.",
160167
["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.",
168+
["help.afk"] = "Lister les coéquipiers qui sont AFK.",
161169
["help.prox"] = "Afficher la distance à chaque coéquipier.",
162170
["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.",
163171
["help.slash.help"] = "Afficher ce message d'aide.",

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
}

0 commit comments

Comments
 (0)