diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index dbcf73ed..d3157bc0 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -10,6 +10,7 @@
+
@@ -19,6 +20,7 @@
+
diff --git a/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs
index e3b34742..9f2ca8dc 100644
--- a/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs
+++ b/src/RustPlusBot.Abstractions/Connections/TeamInfoSnapshot.cs
@@ -3,7 +3,11 @@ namespace RustPlusBot.Features.Connections.Listening;
/// A point-in-time view of a team, decoupled from RustPlusApi types.
/// Steam64 id of the current team leader.
/// Status snapshots for all team members.
-public sealed record TeamInfoSnapshot(ulong LeaderSteamId, IReadOnlyList Members);
+/// The leader's death-note map coordinate, if one is set; null otherwise.
+public sealed record TeamInfoSnapshot(
+ ulong LeaderSteamId,
+ IReadOnlyList Members,
+ (float X, float Y)? DeathNote = null);
/// A point-in-time view of one team member.
/// Steam64 id of the member.
diff --git a/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs
new file mode 100644
index 00000000..7e0b7504
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs
@@ -0,0 +1,14 @@
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Abstractions.Events;
+
+/// Published when a team-info poll detects presence transitions since the previous poll.
+/// The owning guild snowflake.
+/// The target server id.
+/// Map dimensions for grid rendering, or null if unavailable.
+/// The transitions detected this poll (never empty when published).
+public sealed record PlayerStateChangedEvent(
+ ulong GuildId,
+ Guid ServerId,
+ MapDimensions? Dimensions,
+ IReadOnlyList Transitions);
diff --git a/src/RustPlusBot.Abstractions/Events/PlayerTransition.cs b/src/RustPlusBot.Abstractions/Events/PlayerTransition.cs
new file mode 100644
index 00000000..5f99d0a7
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/PlayerTransition.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Abstractions.Events;
+
+/// One team-member presence transition, with a pre-resolved optional map location.
+/// The transition kind.
+/// Steam64 id of the member.
+/// In-game display name (empty when the game reports none).
+/// Resolved map coordinate to show, or null when no location applies.
+public sealed record PlayerTransition(
+ PlayerTransitionKind Kind,
+ ulong SteamId,
+ string Name,
+ (float X, float Y)? Location);
diff --git a/src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs b/src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs
new file mode 100644
index 00000000..4931d095
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs
@@ -0,0 +1,23 @@
+namespace RustPlusBot.Abstractions.Events;
+
+/// The kind of team-member presence transition detected between two team snapshots.
+public enum PlayerTransitionKind
+{
+ /// Member came online.
+ Connect = 0,
+
+ /// Member went offline.
+ Disconnect = 1,
+
+ /// Member died.
+ Death = 2,
+
+ /// Member spawned/respawned.
+ Respawn = 3,
+
+ /// Member crossed the AFK stillness threshold.
+ BecameAfk = 4,
+
+ /// Member moved again after being AFK.
+ ReturnedFromAfk = 5,
+}
diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs
index dc315283..207eb838 100644
--- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs
@@ -37,6 +37,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs
new file mode 100644
index 00000000..2fcd2ccb
--- /dev/null
+++ b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs
@@ -0,0 +1,38 @@
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Formatting;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Commands.Handlers;
+
+/// !afk — lists team members who have been still (online + alive) past the AFK threshold.
+/// The live AFK state.
+/// The reply localizer.
+internal sealed class AfkCommandHandler(IAfkState afk, ICommandLocalizer localizer) : ICommandHandler
+{
+ ///
+ public string Name => "afk";
+
+ ///
+ public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ var members = await afk.GetAfkMembersAsync(context.GuildId, context.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (members is null)
+ {
+ return localizer.Get("command.notconnected", context.Culture);
+ }
+
+ if (members.Count == 0)
+ {
+ return localizer.Get("command.afk.none", context.Culture);
+ }
+
+ var parts = members
+ .OrderByDescending(m => m.StillFor)
+ .Select(m => localizer.Get(
+ "command.afk.member", context.Culture, m.Name, DurationFormat.Compact(m.StillFor)));
+ return localizer.Get("command.afk.ok", context.Culture, string.Join(", ", parts));
+ }
+}
diff --git a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
index 99f72afa..94e2a263 100644
--- a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
+++ b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
@@ -22,6 +22,7 @@ internal static class CommandHelpCatalog
new("team", CommandGroup.TeamIntel, "help.team"),
new("steamid", CommandGroup.TeamIntel, "help.steamid"),
new("alive", CommandGroup.TeamIntel, "help.alive"),
+ new("afk", CommandGroup.TeamIntel, "help.afk"),
new("prox", CommandGroup.TeamIntel, "help.prox"),
new("uptime", CommandGroup.Bot, "help.uptime"),
];
diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs
index 013b944c..cf74ea3f 100644
--- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs
@@ -31,6 +31,9 @@ internal sealed class CommandLocalizationCatalog
["command.team.none"] = "No team members.",
["command.team.nomatch"] = "No teammate matches '{0}'.",
["command.steamid.ok"] = "{0}",
+ ["command.afk.ok"] = "AFK: {0}",
+ ["command.afk.none"] = "Nobody is AFK.",
+ ["command.afk.member"] = "{0} ({1})",
["command.alive.ok"] = "Alive: {0}",
["command.alive.dead"] = "{0} dead",
["command.alive.member"] = "{0} {1}",
@@ -77,6 +80,7 @@ internal sealed class CommandLocalizationCatalog
["help.team"] = "List all team members.",
["help.steamid"] = "Show teammates' Steam IDs.",
["help.alive"] = "Show how long each teammate has survived.",
+ ["help.afk"] = "List teammates who are AFK.",
["help.prox"] = "Show distance to each teammate.",
["help.uptime"] = "Show how long the bot has been running.",
["help.slash.help"] = "Show this help message.",
@@ -111,6 +115,9 @@ internal sealed class CommandLocalizationCatalog
["command.team.none"] = "Aucun membre d'équipe.",
["command.team.nomatch"] = "Aucun coéquipier ne correspond à « {0} ».",
["command.steamid.ok"] = "{0}",
+ ["command.afk.ok"] = "AFK : {0}",
+ ["command.afk.none"] = "Personne n'est AFK.",
+ ["command.afk.member"] = "{0} ({1})",
["command.alive.ok"] = "En vie : {0}",
["command.alive.dead"] = "{0} mort",
["command.alive.member"] = "{0} {1}",
@@ -158,6 +165,7 @@ internal sealed class CommandLocalizationCatalog
["help.team"] = "Lister tous les membres de l'équipe.",
["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.",
["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.",
+ ["help.afk"] = "Lister les coéquipiers qui sont AFK.",
["help.prox"] = "Afficher la distance à chaque coéquipier.",
["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.",
["help.slash.help"] = "Afficher ce message d'aide.",
diff --git a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs
index 1fbdabcf..661a3499 100644
--- a/src/RustPlusBot.Features.Connections/ConnectionOptions.cs
+++ b/src/RustPlusBot.Features.Connections/ConnectionOptions.cs
@@ -35,4 +35,10 @@ public sealed class ConnectionOptions
/// How often the rig-timer tick advances rig phases and emits timed boundary events. Default 30s.
public TimeSpan RigTickInterval { get; set; } = TimeSpan.FromSeconds(30);
+
+ /// How long a member must be still (and online + alive) before being flagged AFK. Default 5m.
+ public TimeSpan AfkThreshold { get; set; } = TimeSpan.FromMinutes(5);
+
+ /// Movement tolerance (world units) below which a member is considered still. Default 1.
+ public float AfkEpsilon { get; set; } = 1f;
}
diff --git a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
index 31828268..6dc974bd 100644
--- a/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs
@@ -22,6 +22,7 @@ public static IServiceCollection AddConnections(this IServiceCollection services
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
services.AddScoped();
// Contribute this assembly's interaction modules to the Discord layer.
diff --git a/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs
new file mode 100644
index 00000000..629f9dca
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs
@@ -0,0 +1,20 @@
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// One currently-AFK team member.
+/// Steam64 id.
+/// In-game display name.
+/// How long the member has been continuously still.
+public sealed record AfkMember(ulong SteamId, string Name, TimeSpan StillFor);
+
+/// Reads the live AFK state computed by the connection poll loop.
+public interface IAfkState
+{
+ /// Gets the currently-AFK members, or null when there is no live socket.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// The currently-AFK members, or null when there is no live socket.
+ Task?> GetAfkMembersAsync(ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index 1cbf54c5..4d4bed85 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -264,7 +264,10 @@ public async Task GetInfoAsync(TimeSpan timeout, CancellationTo
new DateTimeOffset(DateTime.SpecifyKind(m.LastSpawnTime, DateTimeKind.Utc)),
new DateTimeOffset(DateTime.SpecifyKind(m.LastDeathTime, DateTimeKind.Utc))))
.ToList();
- return new TeamInfoSnapshot(response.Data.LeaderSteamId, members);
+ var deathNote = response.Data.DeathNote is { } dn
+ ? ((float X, float Y)?)(dn.X, dn.Y)
+ : null;
+ return new TeamInfoSnapshot(response.Data.LeaderSteamId, members, deathNote);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs
new file mode 100644
index 00000000..2930d25a
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs
@@ -0,0 +1,189 @@
+using RustPlusBot.Abstractions.Events;
+
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// Diffs successive team snapshots into presence transitions. One instance per connected window.
+internal sealed class TeamStateTracker
+{
+ private readonly HashSet _afk = new();
+ private readonly object _gate = new();
+ private readonly Dictionary _stillSince = new();
+ private Dictionary? _baseline;
+
+ /// Diffs against the previous one. First non-null call primes silently.
+ /// The latest team snapshot, or null when the poll returned no data.
+ /// Current wall-clock time (from IClock.UtcNow).
+ /// How long a member must be still before being flagged AFK.
+ /// Movement tolerance (world units) below which a member is considered still.
+ /// The transitions since the previous snapshot; empty on prime, null input, or no change.
+ public IReadOnlyList Diff(
+ TeamInfoSnapshot? snapshot,
+ DateTimeOffset now,
+ TimeSpan afkThreshold,
+ float afkEpsilon)
+ {
+ if (snapshot is null)
+ {
+ return [];
+ }
+
+ var current = snapshot.Members.ToDictionary(m => m.SteamId);
+
+ lock (_gate)
+ {
+ if (_baseline is null)
+ {
+ _baseline = current;
+ foreach (var m in current.Values)
+ {
+ _stillSince[m.SteamId] = now;
+ }
+
+ return [];
+ }
+
+ var transitions = new List();
+ foreach (var (id, nowMember) in current)
+ {
+ if (!_baseline.TryGetValue(id, out var was))
+ {
+ _stillSince[id] = now; // prime new member's stillness clock
+ continue;
+ }
+
+ AddPresenceTransitions(transitions, id, was, nowMember, snapshot);
+ var diedThisPoll = nowMember.LastDeathTimeUtc > was.LastDeathTimeUtc;
+ UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon, diedThisPoll);
+ }
+
+ PruneDepartedMembers(current);
+ _baseline = current;
+ return transitions;
+ }
+ }
+
+ private static void AddPresenceTransitions(
+ List transitions,
+ ulong id,
+ TeamMemberSnapshot was,
+ TeamMemberSnapshot now,
+ TeamInfoSnapshot snapshot)
+ {
+ if (now.IsOnline && !was.IsOnline)
+ {
+ transitions.Add(new PlayerTransition(PlayerTransitionKind.Connect, id, now.Name, null));
+ }
+ else if (!now.IsOnline && was.IsOnline)
+ {
+ transitions.Add(new PlayerTransition(PlayerTransitionKind.Disconnect, id, now.Name, null));
+ }
+
+ if (now.LastDeathTimeUtc > was.LastDeathTimeUtc)
+ {
+ transitions.Add(new PlayerTransition(
+ PlayerTransitionKind.Death, id, now.Name, ResolveDeathLocation(id, snapshot, was)));
+ }
+
+ if (now.LastSpawnTimeUtc > was.LastSpawnTimeUtc)
+ {
+ transitions.Add(new PlayerTransition(PlayerTransitionKind.Respawn, id, now.Name, (now.X, now.Y)));
+ }
+ }
+
+ private void UpdateAfk(
+ List transitions,
+ ulong id,
+ TeamMemberSnapshot was,
+ TeamMemberSnapshot now,
+ DateTimeOffset clock,
+ TimeSpan threshold,
+ float epsilon,
+ bool diedThisPoll)
+ {
+ // A member who goes offline, is dead, or died this poll (even if a slow poll already shows them
+ // respawned) can no longer be AFK. Clear the AFK latch SILENTLY — the disconnect/death transition
+ // already speaks for them, and a "back" line alongside "disconnected"/"died" would contradict it —
+ // and reset the stillness clock so AFK must be re-earned after the state change.
+ if (!now.IsOnline || !now.IsAlive || diedThisPoll)
+ {
+ _afk.Remove(id);
+ _stillSince[id] = clock;
+ return;
+ }
+
+ // Squared-distance check so epsilon is a true movement radius (per-axis would treat diagonal
+ // movement of dx=dy=0.8, epsilon=1 — distance ≈ 1.13 — as still).
+ var dx = now.X - was.X;
+ var dy = now.Y - was.Y;
+ var moved = (dx * dx) + (dy * dy) > epsilon * epsilon;
+ if (moved)
+ {
+ _stillSince[id] = clock;
+ if (_afk.Remove(id))
+ {
+ transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null));
+ }
+
+ return;
+ }
+
+ var since = _stillSince.TryGetValue(id, out var s) ? s : clock;
+ _stillSince.TryAdd(id, since);
+ if (clock - since >= threshold && _afk.Add(id))
+ {
+ transitions.Add(new PlayerTransition(PlayerTransitionKind.BecameAfk, id, now.Name, (now.X, now.Y)));
+ }
+ }
+
+ /// Drops per-member AFK/stillness state for ids no longer present in the team snapshot.
+ /// The members in the latest snapshot, keyed by Steam id.
+ private void PruneDepartedMembers(Dictionary current)
+ {
+ if (_stillSince.Count == 0 && _afk.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var id in _stillSince.Keys.Where(id => !current.ContainsKey(id)).ToList())
+ {
+ _stillSince.Remove(id);
+ }
+
+ _afk.RemoveWhere(id => !current.ContainsKey(id));
+ }
+
+ /// The members currently flagged AFK and how long each has been still, as of .
+ /// The current wall-clock time used to compute each member's still duration.
+ public IReadOnlyList CurrentAfk(DateTimeOffset now)
+ {
+ lock (_gate)
+ {
+ var result = new List();
+ foreach (var id in _afk)
+ {
+ if (_baseline is not null && _baseline.TryGetValue(id, out var m))
+ {
+ var since = _stillSince.TryGetValue(id, out var s) ? s : now;
+ result.Add(new AfkMember(id, m.Name, now - since));
+ }
+ }
+
+ return result;
+ }
+ }
+
+ private static (float X, float Y)? ResolveDeathLocation(
+ ulong steamId,
+ TeamInfoSnapshot snapshot,
+ TeamMemberSnapshot previous)
+ {
+ // Leader: the single DeathNote is the true death spot (player respawns elsewhere).
+ if (steamId == snapshot.LeaderSteamId && snapshot.DeathNote is { } note)
+ {
+ return note;
+ }
+
+ // Everyone else: the previous poll's position (captured while alive) ≈ where they died.
+ return (previous.X, previous.Y);
+ }
+}
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index 7630ed6c..7e854cd8 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Options;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Abstractions.Time;
using RustPlusBot.Discord.Notifications;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
@@ -20,6 +21,7 @@ namespace RustPlusBot.Features.Connections.Supervisor;
/// DMs an owner when their credential is rejected.
/// Unprotects stored tokens before connecting.
/// Publishes ConnectionStatusChangedEvent on state changes.
+/// Wall-clock source used for AFK hysteresis timestamps.
/// Timeouts/backoff/heartbeat settings.
/// The logger.
internal sealed partial class ConnectionSupervisor(
@@ -28,8 +30,10 @@ internal sealed partial class ConnectionSupervisor(
IUserDmSender dmSender,
ICredentialProtector protector,
IEventBus eventBus,
+ IClock clock,
IOptions options,
- ILogger logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAsyncDisposable
+ ILogger logger)
+ : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, IAsyncDisposable
{
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Handle> _connections = new();
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -38,6 +42,20 @@ internal sealed partial class ConnectionSupervisor(
private readonly CancellationTokenSource _shutdown = new();
private bool _disposed;
+ ///
+ public Task?> GetAfkMembersAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken)
+ {
+ if (_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return Task.FromResult?>(live.Tracker.CurrentAfk(clock.UtcNow));
+ }
+
+ return Task.FromResult?>(null);
+ }
+
///
public async ValueTask DisposeAsync()
{
@@ -395,10 +413,11 @@ void OnTeamMessage(object? sender, TeamChatLine line)
var dims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
var rigs = await GetRigPositionsAsync(key.Server, connection, ct).ConfigureAwait(false);
+ var tracker = new TeamStateTracker();
connection.TeamMessageReceived += OnTeamMessage;
- _liveSockets[key] = new LiveSocket(connection, activeSteamId);
+ _liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker);
using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, pollCts.Token),
+ var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, rigs, tracker, pollCts.Token),
CancellationToken.None);
try
{
@@ -445,6 +464,7 @@ private async Task PollMarkersAsync(
IRustServerConnection connection,
MapDimensions? dims,
IReadOnlyList rigs,
+ TeamStateTracker tracker,
CancellationToken ct)
{
IReadOnlyList? previous = null;
@@ -475,6 +495,15 @@ await eventBus.PublishAsync(
}
await DetectRigActivationsAsync(key, current, rigs, dims, rigsInRadius, ct).ConfigureAwait(false);
+
+ var team = await connection.GetTeamInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
+ var transitions = tracker.Diff(team, clock.UtcNow, _options.AfkThreshold, _options.AfkEpsilon);
+ if (transitions.Count > 0)
+ {
+ await eventBus.PublishAsync(
+ new PlayerStateChangedEvent(key.Guild, key.Server, dims, transitions), ct)
+ .ConfigureAwait(false);
+ }
}
catch (OperationCanceledException)
{
@@ -742,7 +771,7 @@ private readonly record struct Prepared(
ulong SteamId,
string PlayerToken);
- private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId);
+ private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId, TeamStateTracker Tracker);
private sealed class Handle(CancellationTokenSource cts, Task runTask) : IAsyncDisposable
{
diff --git a/src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs b/src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs
new file mode 100644
index 00000000..9de6ec17
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs
@@ -0,0 +1,73 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Players.Relaying;
+
+namespace RustPlusBot.Features.Players.Hosting;
+
+/// Consumes and relays each to #events + in-game chat.
+/// The in-process event bus.
+/// Relays player transitions.
+/// The logger.
+internal sealed partial class PlayersHostedService(
+ IEventBus eventBus,
+ PlayerEventRelay relay,
+ ILogger logger) : IHostedService, IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _loop;
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _loop = Task.Run(() => ConsumeAsync(_cts.Token), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ if (_loop is not null)
+ {
+ try
+ {
+#pragma warning disable VSTHRD003 // Our own loop task, joined on stop.
+ await _loop.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ }
+ }
+
+ private async Task ConsumeAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await relay.RelayAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogRelayLoopFaulted(logger, ex);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Player relay loop faulted.")]
+ private static partial void LogRelayLoopFaulted(ILogger logger, Exception exception);
+}
diff --git a/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs
new file mode 100644
index 00000000..304e8408
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Features.Players.Hosting;
+using RustPlusBot.Features.Players.Posting;
+using RustPlusBot.Features.Players.Relaying;
+using RustPlusBot.Features.Players.Rendering;
+
+namespace RustPlusBot.Features.Players;
+
+/// DI registration for the team-presence-events feature.
+public static class PlayerEventServiceCollectionExtensions
+{
+ /// Registers the localizer, renderer, poster, relay, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddPlayers(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddSingleton(PlayerLocalizationCatalog.Default);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
new file mode 100644
index 00000000..c6bbbb3b
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs
@@ -0,0 +1,45 @@
+using Discord;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+
+namespace RustPlusBot.Features.Players.Posting;
+
+/// Posts player-event embeds to Discord text channels via the gateway client.
+/// The Discord socket client.
+/// The logger.
+internal sealed partial class DiscordPlayerChannelPoster(
+ DiscordSocketClient client,
+ ILogger logger)
+ : IPlayerChannelPoster
+{
+ ///
+ public async Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var options = new RequestOptions
+ {
+ CancelToken = cancellationToken
+ };
+ if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel)
+ {
+ return;
+ }
+
+ await channel.SendMessageAsync(embed: embed, options: options).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // Cancellation (shutdown) is not a post failure; let the relay loop unwind cleanly.
+ }
+#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the relay.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogPostFailed(logger, ex, channelId);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Posting a player-event embed to channel {ChannelId} failed.")]
+ private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId);
+}
diff --git a/src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs b/src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs
new file mode 100644
index 00000000..51f931ed
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs
@@ -0,0 +1,13 @@
+using Discord;
+
+namespace RustPlusBot.Features.Players.Posting;
+
+/// Posts a player-event embed to a Discord channel.
+internal interface IPlayerChannelPoster
+{
+ /// Posts to the channel.
+ /// The Discord channel snowflake.
+ /// The embed to post.
+ /// A cancellation token.
+ Task PostAsync(ulong channelId, Embed embed, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs
new file mode 100644
index 00000000..433daf18
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Players.Posting;
+using RustPlusBot.Features.Players.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Players.Relaying;
+
+/// Posts every player transition to #events AND in-game team chat.
+/// Renders transitions as embeds and in-game lines.
+/// Resolves the #events Discord channel id.
+/// Posts embeds to the Discord channel.
+/// Broadcasts the in-game team-chat line.
+/// Opens scopes to read guild culture.
+internal sealed class PlayerEventRelay(
+ PlayerEventRenderer renderer,
+ IEventChannelLocator locator,
+ IPlayerChannelPoster poster,
+ ITeamChatSender teamChatSender,
+ IServiceScopeFactory scopeFactory)
+{
+ /// Handles one : posts embeds and broadcasts in-game.
+ /// The player state changed event.
+ /// A cancellation token.
+ public async Task RelayAsync(PlayerStateChangedEvent evt, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ if (evt.Transitions.Count == 0)
+ {
+ return;
+ }
+
+ var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
+ var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+
+ foreach (var t in evt.Transitions)
+ {
+ await teamChatSender
+ .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture),
+ cancellationToken)
+ .ConfigureAwait(false);
+ if (channelId is { } id)
+ {
+ await poster.PostAsync(id, renderer.Render(t, evt.Dimensions, culture), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs
new file mode 100644
index 00000000..db06fb3b
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs
@@ -0,0 +1,16 @@
+namespace RustPlusBot.Features.Players.Rendering;
+
+/// Resolves localized player-event strings by key and culture, falling back to English.
+internal interface IPlayerLocalizer
+{
+ /// Gets the localized string for a key, or the key itself if not found.
+ /// The string key.
+ /// The BCP-47 culture tag (e.g. "en", "fr").
+ string Get(string key, string culture);
+
+ /// Gets the localized, format-applied string.
+ /// The string key.
+ /// The BCP-47 culture tag.
+ /// Format arguments.
+ string Get(string key, string culture, params object[] args);
+}
diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs
new file mode 100644
index 00000000..ae86a78f
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs
@@ -0,0 +1,61 @@
+using Discord;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Events.Formatting;
+
+namespace RustPlusBot.Features.Players.Rendering;
+
+/// Renders one as a Discord embed or an in-game line.
+/// The reply localizer.
+internal sealed class PlayerEventRenderer(IPlayerLocalizer localizer)
+{
+ /// Renders the transition for a guild culture as a Discord embed.
+ /// The player transition to render.
+ /// The map dimensions, or null if unavailable.
+ /// The BCP-47 culture tag.
+ public Embed Render(PlayerTransition transition, MapDimensions? dims, string culture)
+ {
+ ArgumentNullException.ThrowIfNull(transition);
+ return new EmbedBuilder()
+ .WithAuthor(localizer.Get("player.title", culture))
+ .WithDescription(Describe(transition, dims, culture, suffix: string.Empty))
+ .WithCurrentTimestamp()
+ .Build();
+ }
+
+ /// Renders the in-game team-chat line for the transition.
+ /// The player transition to render.
+ /// The map dimensions, or null if unavailable.
+ /// The BCP-47 culture tag.
+ public string RenderLine(PlayerTransition transition, MapDimensions? dims, string culture)
+ {
+ ArgumentNullException.ThrowIfNull(transition);
+ return Describe(transition, dims, culture, suffix: ".line");
+ }
+
+ private string Describe(PlayerTransition t, MapDimensions? dims, string culture, string suffix)
+ {
+ switch (t.Kind)
+ {
+ case PlayerTransitionKind.Connect:
+ return localizer.Get("player.connect" + suffix, culture, t.Name);
+ case PlayerTransitionKind.Disconnect:
+ return localizer.Get("player.disconnect" + suffix, culture, t.Name);
+ case PlayerTransitionKind.Respawn:
+ return localizer.Get("player.respawn" + suffix, culture, t.Name, Grid(t, dims));
+ case PlayerTransitionKind.ReturnedFromAfk:
+ return localizer.Get("player.afk.back" + suffix, culture, t.Name);
+ case PlayerTransitionKind.BecameAfk:
+ return localizer.Get("player.afk" + suffix, culture, t.Name, Grid(t, dims));
+ case PlayerTransitionKind.Death:
+ return t.Location is null
+ ? localizer.Get("player.death.unknown" + suffix, culture, t.Name)
+ : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims));
+ default:
+ throw new ArgumentOutOfRangeException(nameof(t), t.Kind, "Unsupported transition kind.");
+ }
+ }
+
+ private static string Grid(PlayerTransition t, MapDimensions? dims)
+ => t.Location is { } loc ? GridReference.From(loc.X, loc.Y, dims) : string.Empty;
+}
diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs
new file mode 100644
index 00000000..9b946dda
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs
@@ -0,0 +1,52 @@
+namespace RustPlusBot.Features.Players.Rendering;
+
+/// The in-memory string catalog for player events: culture -> (key -> value). English is the fallback.
+internal sealed class PlayerLocalizationCatalog
+{
+ /// culture -> key -> value.
+ public required IReadOnlyDictionary> Strings { get; init; }
+
+ /// The built-in EN/FR catalog.
+ public static PlayerLocalizationCatalog Default { get; } = new()
+ {
+ Strings = new Dictionary>(StringComparer.Ordinal)
+ {
+ ["en"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["player.title"] = "Team event",
+ ["player.connect"] = "🟢 {0} connected",
+ ["player.connect.line"] = "{0} connected",
+ ["player.disconnect"] = "🔴 {0} disconnected",
+ ["player.disconnect.line"] = "{0} disconnected",
+ ["player.death"] = "💀 {0} died at {1}",
+ ["player.death.line"] = "{0} died at {1}",
+ ["player.death.unknown"] = "💀 {0} died",
+ ["player.death.unknown.line"] = "{0} died",
+ ["player.respawn"] = "✨ {0} respawned at {1}",
+ ["player.respawn.line"] = "{0} respawned at {1}",
+ ["player.afk"] = "💤 {0} is AFK ({1})",
+ ["player.afk.line"] = "{0} is AFK ({1})",
+ ["player.afk.back"] = "👋 {0} is back",
+ ["player.afk.back.line"] = "{0} is back",
+ },
+ ["fr"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["player.title"] = "Événement d'équipe",
+ ["player.connect"] = "🟢 {0} s'est connecté",
+ ["player.connect.line"] = "{0} s'est connecté",
+ ["player.disconnect"] = "🔴 {0} s'est déconnecté",
+ ["player.disconnect.line"] = "{0} s'est déconnecté",
+ ["player.death"] = "💀 {0} est mort en {1}",
+ ["player.death.line"] = "{0} est mort en {1}",
+ ["player.death.unknown"] = "💀 {0} est mort",
+ ["player.death.unknown.line"] = "{0} est mort",
+ ["player.respawn"] = "✨ {0} a réapparu en {1}",
+ ["player.respawn.line"] = "{0} a réapparu en {1}",
+ ["player.afk"] = "💤 {0} est AFK ({1})",
+ ["player.afk.line"] = "{0} est AFK ({1})",
+ ["player.afk.back"] = "👋 {0} est de retour",
+ ["player.afk.back.line"] = "{0} est de retour",
+ },
+ },
+ };
+}
diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs
new file mode 100644
index 00000000..ddafa269
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs
@@ -0,0 +1,61 @@
+using System.Globalization;
+
+namespace RustPlusBot.Features.Players.Rendering;
+
+/// Dictionary-backed with English fallback and region normalization.
+/// Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor.
+/// The string catalog.
+internal sealed class PlayerLocalizer(PlayerLocalizationCatalog catalog) : IPlayerLocalizer
+{
+ private const string FallbackCulture = "en";
+
+ ///
+ public string Get(string key, string culture)
+ {
+ var normalized = Normalize(culture);
+ if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
+ {
+ return value;
+ }
+
+ if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
+ fallback.TryGetValue(key, out var fallbackValue))
+ {
+ return fallbackValue;
+ }
+
+ return key;
+ }
+
+ ///
+ public string Get(string key, string culture, params object[] args)
+ {
+ var format = Get(key, culture);
+ var provider = ResolveFormatProvider(Normalize(culture));
+ return string.Format(provider, format, args);
+ }
+
+ private static string Normalize(string culture)
+ {
+ if (string.IsNullOrWhiteSpace(culture))
+ {
+ return FallbackCulture;
+ }
+
+ var dash = culture.IndexOf('-', StringComparison.Ordinal);
+ var primary = dash >= 0 ? culture[..dash] : culture;
+ return primary.ToLowerInvariant();
+ }
+
+ private static CultureInfo ResolveFormatProvider(string culture)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(culture);
+ }
+ catch (CultureNotFoundException)
+ {
+ return CultureInfo.InvariantCulture;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj b/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj
new file mode 100644
index 00000000..41aa50f2
--- /dev/null
+++ b/src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index be97c698..e2ce6899 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -11,6 +11,7 @@
using RustPlusBot.Features.Connections;
using RustPlusBot.Features.Events;
using RustPlusBot.Features.Map;
+using RustPlusBot.Features.Players;
using RustPlusBot.Features.Pairing;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Host.Credentials;
@@ -68,6 +69,7 @@
.ValidateOnStart();
builder.Services.AddCommands();
builder.Services.AddEvents();
+builder.Services.AddPlayers();
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection("Map"))
.Validate(static o => o.MapRefreshInterval > TimeSpan.Zero, "Map:MapRefreshInterval must be positive.")
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index b2456d02..313203a3 100644
--- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj
+++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
@@ -25,5 +25,6 @@
+
diff --git a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs
new file mode 100644
index 00000000..ab81386f
--- /dev/null
+++ b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs
@@ -0,0 +1,48 @@
+using NSubstitute;
+using RustPlusBot.Features.Commands.Dispatching;
+using RustPlusBot.Features.Commands.Handlers;
+using RustPlusBot.Features.Commands.Localization;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Commands.Tests;
+
+public sealed class AfkCommandHandlerTests
+{
+ private readonly IAfkState _afk = Substitute.For();
+ private readonly ICommandLocalizer _localizer = new CommandLocalizer(CommandLocalizationCatalog.Default);
+
+ private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 99, "Caller", []);
+
+ [Fact]
+ public async Task Name_is_afk() => Assert.Equal("afk", new AfkCommandHandler(_afk, _localizer).Name);
+
+ [Fact]
+ public async Task Reports_not_connected_when_state_null()
+ {
+ _afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((IReadOnlyList?)null);
+ var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
+ Assert.Equal(_localizer.Get("command.notconnected", "en"), reply);
+ }
+
+ [Fact]
+ public async Task Reports_none_when_empty()
+ {
+ _afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns([]);
+ var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
+ Assert.Equal(_localizer.Get("command.afk.none", "en"), reply);
+ }
+
+ [Fact]
+ public async Task Lists_afk_members_with_durations()
+ {
+ _afk.GetAfkMembersAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new List
+ {
+ new(1, "Bob", TimeSpan.FromMinutes(6))
+ });
+ var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
+ Assert.Contains("Bob", reply, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
index 8f2166a9..00474a63 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs
@@ -29,6 +29,7 @@ public void Dispatcher_and_handlers_resolve()
services.AddSingleton(Substitute.For());
services.AddSingleton(_ => Substitute.For());
services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
@@ -47,7 +48,7 @@ public void Dispatcher_and_handlers_resolve()
using var scope = provider.CreateScope();
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
var handlers = scope.ServiceProvider.GetServices().ToList();
- Assert.Equal(18, handlers.Count);
+ Assert.Equal(19, handlers.Count);
Assert.Contains(handlers, h => h.Name == "mute");
Assert.Contains(handlers, h => h.Name == "pop");
Assert.Contains(handlers, h => h.Name == "time");
@@ -64,6 +65,7 @@ public void Dispatcher_and_handlers_resolve()
Assert.Contains(handlers, h => h.Name == "events");
Assert.Contains(handlers, h => h.Name == "small");
Assert.Contains(handlers, h => h.Name == "large");
+ Assert.Contains(handlers, h => h.Name == "afk");
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
Assert.NotNull(scope.ServiceProvider.GetRequiredService());
}
@@ -79,6 +81,7 @@ public void Commands_contribute_an_interaction_module_assembly()
services.AddSingleton(Substitute.For());
services.AddSingleton(_ => Substitute.For());
services.AddSingleton(_ => Substitute.For());
+ services.AddSingleton(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
services.AddScoped(_ => Substitute.For());
diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs
index 8478c445..e19fa6ca 100644
--- a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs
+++ b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs
@@ -5,11 +5,11 @@ namespace RustPlusBot.Features.Commands.Tests.Help;
public sealed class CommandHelpCatalogTests
{
- /// The 12 registered in-game handler names (see AddCommands / CommandRegistrationTests).
+ /// The 13 registered in-game handler names (see AddCommands / CommandRegistrationTests).
private static readonly string[] HandlerNames =
[
"mute", "unmute", "uptime", "pop", "wipe", "time",
- "online", "offline", "team", "steamid", "alive", "prox",
+ "online", "offline", "team", "steamid", "alive", "afk", "prox",
];
[Fact]
diff --git a/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs
new file mode 100644
index 00000000..6545985f
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs
@@ -0,0 +1,31 @@
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class AfkStateTests
+{
+ [Fact]
+ public void CurrentAfk_lists_member_with_still_duration()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ var m = new TeamMemberSnapshot(1, "Bob", 0, 0, true, true, t0, t0);
+ t.Diff(new TeamInfoSnapshot(1, [m]), t0, TimeSpan.FromMinutes(5), 1f);
+ t.Diff(new TeamInfoSnapshot(1, [m]), t0.AddMinutes(6), TimeSpan.FromMinutes(5), 1f); // BecameAfk
+
+ var afk = t.CurrentAfk(t0.AddMinutes(6));
+ var bob = Assert.Single(afk);
+ Assert.Equal(1UL, bob.SteamId);
+ Assert.Equal(TimeSpan.FromMinutes(6), bob.StillFor);
+ }
+
+ [Fact]
+ public void CurrentAfk_empty_when_nobody_afk()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ var m = new TeamMemberSnapshot(1, "Bob", 0, 0, true, true, t0, t0);
+ t.Diff(new TeamInfoSnapshot(1, [m]), t0, TimeSpan.FromMinutes(5), 1f);
+ Assert.Empty(t.CurrentAfk(t0.AddMinutes(1)));
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs
new file mode 100644
index 00000000..b96fa7cd
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs
@@ -0,0 +1,20 @@
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class TeamInfoSnapshotTests
+{
+ [Fact]
+ public void DeathNote_defaults_to_null()
+ {
+ var snap = new TeamInfoSnapshot(123UL, []);
+ Assert.Null(snap.DeathNote);
+ }
+
+ [Fact]
+ public void DeathNote_carries_coordinate_when_set()
+ {
+ var snap = new TeamInfoSnapshot(123UL, [], (100f, 200f));
+ Assert.Equal((100f, 200f), snap.DeathNote);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs
new file mode 100644
index 00000000..5bc8381e
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs
@@ -0,0 +1,118 @@
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class TeamStateTrackerAfkTests
+{
+ private const float Eps = 1f;
+ private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
+
+ private static TeamMemberSnapshot Member(
+ ulong id,
+ float x,
+ float y,
+ bool online = true,
+ bool alive = true,
+ DateTimeOffset death = default)
+ => new(id, $"P{id}", x, y, online, alive, DateTimeOffset.UnixEpoch, death);
+
+ private static TeamInfoSnapshot Team(params TeamMemberSnapshot[] m) => new(1, m);
+
+ [Fact]
+ public void Still_for_threshold_emits_one_BecameAfk()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps); // prime
+ Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(2), Threshold, Eps)); // still, < threshold
+ var afk = t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // crossed
+ Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk && x.Location == (0f, 0f));
+ Assert.Empty(t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(9), Threshold, Eps)); // latched, no repeat
+ }
+
+ [Fact]
+ public void Moving_after_afk_emits_ReturnedFromAfk()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
+ t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
+ var back = t.Diff(Team(Member(1, 50, 50)), t0.AddMinutes(7), Threshold, Eps);
+ Assert.Single(back, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk && x.Location == null);
+ }
+
+ [Fact]
+ public void Going_offline_while_afk_clears_silently_without_ReturnedFromAfk()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
+ t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
+ var off = t.Diff(Team(Member(1, 0, 0, online: false)), t0.AddMinutes(7), Threshold, Eps);
+ // The Disconnect transition speaks for them; no contradictory "is back" line.
+ Assert.DoesNotContain(off, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk);
+ Assert.Contains(off, x => x.Kind == PlayerTransitionKind.Disconnect);
+ Assert.Empty(t.CurrentAfk(t0.AddMinutes(7)));
+ }
+
+ [Fact]
+ public void Dying_while_afk_clears_silently_even_if_already_respawned()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
+ t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // BecameAfk
+ // Slow poll: died and already respawned (IsAlive true), but LastDeathTime advanced.
+ var died = t.Diff(
+ Team(Member(1, 0, 0, alive: true, death: t0.AddMinutes(7))), t0.AddMinutes(8), Threshold, Eps);
+ Assert.DoesNotContain(died, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk);
+ Assert.Contains(died, x => x.Kind == PlayerTransitionKind.Death);
+ Assert.Empty(t.CurrentAfk(t0.AddMinutes(8)));
+ }
+
+ [Fact]
+ public void Dead_member_is_never_afk()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0, alive: false)), t0, Threshold, Eps);
+ Assert.Empty(t.Diff(Team(Member(1, 0, 0, alive: false)), t0.AddMinutes(10), Threshold, Eps));
+ }
+
+ [Fact]
+ public void Small_jitter_below_epsilon_still_counts_as_still()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
+ var afk = t.Diff(Team(Member(1, 0.5f, 0.5f)), t0.AddMinutes(6), Threshold, Eps); // < 1 unit move
+ Assert.Single(afk, x => x.Kind == PlayerTransitionKind.BecameAfk);
+ }
+
+ [Fact]
+ public void Diagonal_move_beyond_epsilon_radius_counts_as_moved()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0)), t0, Threshold, Eps);
+ // dx=dy=0.8 → distance ≈ 1.13 > epsilon 1; per-axis would wrongly call this "still".
+ var afk = t.Diff(Team(Member(1, 0.8f, 0.8f)), t0.AddMinutes(6), Threshold, Eps);
+ Assert.DoesNotContain(afk, x => x.Kind == PlayerTransitionKind.BecameAfk);
+ }
+
+ [Fact]
+ public void Departed_member_state_is_pruned()
+ {
+ var t = new TeamStateTracker();
+ var t0 = DateTimeOffset.UnixEpoch;
+ t.Diff(Team(Member(1, 0, 0), Member(2, 0, 0)), t0, Threshold, Eps);
+ t.Diff(Team(Member(1, 0, 0), Member(2, 0, 0)), t0.AddMinutes(6), Threshold, Eps); // both BecameAfk
+ Assert.Equal(2, t.CurrentAfk(t0.AddMinutes(6)).Count);
+ // Member 2 leaves the team (absent from the snapshot entirely).
+ t.Diff(Team(Member(1, 0, 0)), t0.AddMinutes(7), Threshold, Eps);
+ var afk = t.CurrentAfk(t0.AddMinutes(7));
+ Assert.Single(afk);
+ Assert.Equal(1UL, afk[0].SteamId);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs
new file mode 100644
index 00000000..377515cf
--- /dev/null
+++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs
@@ -0,0 +1,154 @@
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+
+namespace RustPlusBot.Features.Connections.Tests;
+
+public sealed class TeamStateTrackerTests
+{
+ private static TeamMemberSnapshot Member(
+ ulong id,
+ bool online = true,
+ bool alive = true,
+ float x = 0,
+ float y = 0,
+ DateTimeOffset spawn = default,
+ DateTimeOffset death = default)
+ => new(id, $"P{id}", x, y, online, alive, spawn, death);
+
+ private static TeamInfoSnapshot Team(ulong leader, params TeamMemberSnapshot[] members)
+ => new(leader, members);
+
+ [Fact]
+ public void First_snapshot_primes_silently()
+ {
+ var tracker = new TeamStateTracker();
+ var result = tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void Null_snapshot_emits_nothing_and_keeps_baseline()
+ {
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ Assert.Empty(tracker.Diff(null, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f));
+ // After the null, an offline flip is still detected against the original baseline.
+ var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5),
+ 1f);
+ Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect);
+ }
+
+ [Fact]
+ public void Brand_new_member_is_primed_silently()
+ {
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ var result = tracker.Diff(Team(1, Member(1), Member(2, online: true)), DateTimeOffset.UnixEpoch,
+ TimeSpan.FromMinutes(5), 1f);
+ Assert.DoesNotContain(result, t => t.SteamId == 2);
+ }
+
+ [Fact]
+ public void Connect_detected_on_offline_to_online()
+ {
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ var result = tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5),
+ 1f);
+ Assert.Single(result, t => t.Kind == PlayerTransitionKind.Connect && t.SteamId == 1);
+ }
+
+ [Fact]
+ public void Disconnect_detected_on_online_to_offline()
+ {
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1, online: true)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5),
+ 1f);
+ Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect && t.SteamId == 1);
+ }
+
+ [Fact]
+ public void Death_detected_on_deathtime_advance_even_if_alive_again()
+ {
+ var t0 = DateTimeOffset.UnixEpoch;
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1, alive: true, death: t0)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ // Died and already respawned: IsAlive true both times, but LastDeathTime advanced.
+ var result = tracker.Diff(Team(1, Member(1, alive: true, death: t0.AddMinutes(1))), DateTimeOffset.UnixEpoch,
+ TimeSpan.FromMinutes(5), 1f);
+ Assert.Contains(result, t => t.Kind == PlayerTransitionKind.Death && t.SteamId == 1);
+ }
+
+ [Fact]
+ public void Respawn_detected_on_spawntime_advance_with_current_location()
+ {
+ var t0 = DateTimeOffset.UnixEpoch;
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1, spawn: t0)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ var result = tracker.Diff(Team(1, Member(1, x: 50, y: 60, spawn: t0.AddMinutes(1))), DateTimeOffset.UnixEpoch,
+ TimeSpan.FromMinutes(5), 1f);
+ var respawn = Assert.Single(result, t => t.Kind == PlayerTransitionKind.Respawn);
+ Assert.Equal((50f, 60f), respawn.Location);
+ }
+
+ [Fact]
+ public void Unchanged_snapshot_emits_nothing()
+ {
+ var tracker = new TeamStateTracker();
+ var m = Member(1, spawn: DateTimeOffset.UnixEpoch, death: DateTimeOffset.UnixEpoch);
+ tracker.Diff(new TeamInfoSnapshot(1, [m]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ Assert.Empty(tracker.Diff(new TeamInfoSnapshot(1, [m]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f));
+ }
+
+ [Fact]
+ public void Connect_and_disconnect_have_no_location()
+ {
+ var tracker = new TeamStateTracker();
+ tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ var result = tracker.Diff(Team(1, Member(1, online: true, x: 9, y: 9)), DateTimeOffset.UnixEpoch,
+ TimeSpan.FromMinutes(5), 1f);
+ Assert.Null(Assert.Single(result).Location);
+ }
+
+ [Fact]
+ public void Leader_death_uses_deathnote_location()
+ {
+ var t0 = DateTimeOffset.UnixEpoch;
+ var tracker = new TeamStateTracker();
+ tracker.Diff(new TeamInfoSnapshot(1, [Member(1, death: t0)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5),
+ 1f);
+ var snap = new TeamInfoSnapshot(1, [Member(1, x: 1, y: 1, death: t0.AddMinutes(1))], (700f, 800f));
+ var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f),
+ t => t.Kind == PlayerTransitionKind.Death);
+ Assert.Equal((700f, 800f), death.Location);
+ }
+
+ [Fact]
+ public void Nonleader_death_uses_previous_poll_position_not_current()
+ {
+ var t0 = DateTimeOffset.UnixEpoch;
+ var tracker = new TeamStateTracker();
+ // Member 2 is alive at (10,10) on the baseline poll...
+ tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, x: 10, y: 10, death: t0)]), DateTimeOffset.UnixEpoch,
+ TimeSpan.FromMinutes(5), 1f);
+ // ...then dies and respawns at (999,999); death must report the PRE-death (10,10).
+ var snap = new TeamInfoSnapshot(1, [Member(1), Member(2, x: 999, y: 999, death: t0.AddMinutes(1))], (5f, 5f));
+ var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f),
+ t => t.Kind == PlayerTransitionKind.Death);
+ Assert.Equal((10f, 10f), death.Location); // leader DeathNote (5,5) must NOT apply to a non-leader
+ }
+
+ [Fact]
+ public void Death_with_no_prior_position_and_no_deathnote_has_null_location()
+ {
+ var t0 = DateTimeOffset.UnixEpoch;
+ var tracker = new TeamStateTracker();
+ // Prime member 1 only.
+ tracker.Diff(new TeamInfoSnapshot(1, [Member(1)]), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f);
+ // Member 2 appears already-dead-advanced in the same poll it is first seen → primed silently, no death.
+ var first = tracker.Diff(new TeamInfoSnapshot(1, [Member(1), Member(2, death: t0)]), DateTimeOffset.UnixEpoch,
+ TimeSpan.FromMinutes(5), 1f);
+ Assert.DoesNotContain(first, t => t.SteamId == 2);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs
new file mode 100644
index 00000000..1afdaa38
--- /dev/null
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs
@@ -0,0 +1,25 @@
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Players.Rendering;
+
+namespace RustPlusBot.Features.Players.Tests;
+
+public sealed class PlayerEventEndToEndTests
+{
+ [Fact]
+ public void All_transition_kinds_render_without_throwing()
+ {
+ var renderer = new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default));
+ var dims = new MapDimensions(3000, 3000, 0);
+ foreach (PlayerTransitionKind kind in Enum.GetValues())
+ {
+ var loc = kind is PlayerTransitionKind.Death or PlayerTransitionKind.Respawn
+ or PlayerTransitionKind.BecameAfk
+ ? ((float, float)?)(10f, 10f)
+ : null;
+ var t = new PlayerTransition(kind, 1, "Bob", loc);
+ Assert.NotNull(renderer.Render(t, dims, "en"));
+ Assert.NotEmpty(renderer.RenderLine(t, dims, "fr"));
+ }
+ }
+}
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs
new file mode 100644
index 00000000..54d07746
--- /dev/null
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs
@@ -0,0 +1,37 @@
+using Discord.WebSocket;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Players;
+using RustPlusBot.Features.Players.Hosting;
+using RustPlusBot.Features.Players.Relaying;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Players.Tests;
+
+public sealed class PlayerEventRegistrationTests
+{
+ [Fact]
+ public void Services_resolve()
+ {
+ using var provider = BuildProvider();
+ Assert.Contains(provider.GetServices(), h => h is PlayersHostedService);
+ Assert.NotNull(provider.GetRequiredService());
+ }
+
+ private static ServiceProvider BuildProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddSingleton();
+ services.AddSingleton(new DiscordSocketClient());
+ services.AddSingleton(Substitute.For());
+ services.AddScoped(_ => Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddPlayers();
+ return services.BuildServiceProvider(validateScopes: true);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs
new file mode 100644
index 00000000..3ce62955
--- /dev/null
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs
@@ -0,0 +1,80 @@
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Players.Posting;
+using RustPlusBot.Features.Players.Relaying;
+using RustPlusBot.Features.Players.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.Players.Tests;
+
+public sealed class PlayerEventRelayTests
+{
+ private readonly IEventChannelLocator _locator = Substitute.For();
+ private readonly IPlayerChannelPoster _poster = Substitute.For();
+ private readonly ITeamChatSender _sender = Substitute.For();
+ private readonly IWorkspaceStore _workspace = Substitute.For();
+
+ private PlayerEventRelay BuildRelay()
+ {
+ var scopeFactory = BuildScopeFactory(_workspace);
+ _workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en");
+ return new PlayerEventRelay(
+ new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default)),
+ _locator, _poster, _sender, scopeFactory);
+ }
+
+ private static IServiceScopeFactory BuildScopeFactory(IWorkspaceStore workspace)
+ {
+ var services = new ServiceCollection();
+ services.AddScoped(_ => workspace);
+ return services.BuildServiceProvider().GetRequiredService();
+ }
+
+ private static PlayerStateChangedEvent Evt(params PlayerTransition[] ts)
+ => new(1UL, Guid.NewGuid(), new MapDimensions(3000, 3000, 0), ts);
+
+ [Fact]
+ public async Task Sends_ingame_line_for_each_transition()
+ {
+ var relay = BuildRelay();
+ _locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((ulong?)null);
+
+ await relay.RelayAsync(
+ Evt(new PlayerTransition(PlayerTransitionKind.Connect, 1, "Bob", null)), CancellationToken.None);
+
+ await _sender.Received(1).SendAsync(
+ Arg.Any(), Arg.Any(), Arg.Is(s => s.Contains("Bob")), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Skips_discord_post_when_no_channel_but_still_sends_ingame()
+ {
+ var relay = BuildRelay();
+ _locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((ulong?)null);
+
+ await relay.RelayAsync(
+ Evt(new PlayerTransition(PlayerTransitionKind.Disconnect, 1, "Bob", null)), CancellationToken.None);
+
+ await _poster.DidNotReceive()
+ .PostAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ await _sender.ReceivedWithAnyArgs(1).SendAsync(default, Guid.Empty, default!, default);
+ }
+
+ [Fact]
+ public async Task Posts_embed_when_channel_resolves()
+ {
+ var relay = BuildRelay();
+ _locator.GetChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((ulong?)555);
+
+ await relay.RelayAsync(
+ Evt(new PlayerTransition(PlayerTransitionKind.Death, 1, "Bob", (10f, 10f))), CancellationToken.None);
+
+ await _poster.Received(1).PostAsync(555UL, Arg.Any(), Arg.Any());
+ }
+}
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs
new file mode 100644
index 00000000..547772f9
--- /dev/null
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs
@@ -0,0 +1,43 @@
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Players.Rendering;
+
+namespace RustPlusBot.Features.Players.Tests;
+
+public sealed class PlayerEventRendererTests
+{
+ private static readonly PlayerEventRenderer Renderer = new(new PlayerLocalizer(PlayerLocalizationCatalog.Default));
+ private static readonly MapDimensions Dims = new(3000, 3000, 0);
+
+ [Fact]
+ public void Connect_line_names_member_without_location()
+ {
+ var line = Renderer.RenderLine(
+ new PlayerTransition(PlayerTransitionKind.Connect, 1, "Bob", null), Dims, "en");
+ Assert.Equal("Bob connected", line);
+ }
+
+ [Fact]
+ public void Death_with_location_includes_grid()
+ {
+ var line = Renderer.RenderLine(
+ new PlayerTransition(PlayerTransitionKind.Death, 1, "Bob", (0f, 2999f)), Dims, "en");
+ Assert.StartsWith("Bob died at A", line, StringComparison.Ordinal); // top-left ≈ A0/A1
+ }
+
+ [Fact]
+ public void Death_without_location_uses_unknown_string()
+ {
+ var line = Renderer.RenderLine(
+ new PlayerTransition(PlayerTransitionKind.Death, 1, "Bob", null), Dims, "en");
+ Assert.Equal("Bob died", line);
+ }
+
+ [Fact]
+ public void Afk_back_has_no_location()
+ {
+ var line = Renderer.RenderLine(
+ new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, 1, "Bob", null), Dims, "en");
+ Assert.Equal("Bob is back", line);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs
new file mode 100644
index 00000000..e78ee5cd
--- /dev/null
+++ b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs
@@ -0,0 +1,40 @@
+using RustPlusBot.Features.Players.Rendering;
+
+namespace RustPlusBot.Features.Players.Tests;
+
+public sealed class PlayerLocalizationCatalogTests
+{
+ private static readonly string[] Keys =
+ [
+ "player.title",
+ "player.connect", "player.connect.line",
+ "player.disconnect", "player.disconnect.line",
+ "player.death", "player.death.line",
+ "player.death.unknown", "player.death.unknown.line",
+ "player.respawn", "player.respawn.line",
+ "player.afk", "player.afk.line",
+ "player.afk.back", "player.afk.back.line",
+ ];
+
+ [Theory]
+ [InlineData("en")]
+ [InlineData("fr")]
+ public void Every_key_present_for_culture(string culture)
+ {
+ var catalog = PlayerLocalizationCatalog.Default;
+ Assert.True(catalog.Strings.TryGetValue(culture, out var map));
+ foreach (var key in Keys)
+ {
+ Assert.True(map!.ContainsKey(key), $"missing {key} for {culture}");
+ }
+ }
+
+ [Fact]
+ public void Localizer_formats_with_args_and_falls_back_to_english()
+ {
+ var localizer = new PlayerLocalizer(PlayerLocalizationCatalog.Default);
+ Assert.Contains("Bob", localizer.Get("player.connect", "en", "Bob"), StringComparison.Ordinal);
+ // Unknown culture falls back to English string (not the raw key).
+ Assert.Contains("Bob", localizer.Get("player.connect", "de", "Bob"), StringComparison.Ordinal);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj b/tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj
new file mode 100644
index 00000000..b0ec8f25
--- /dev/null
+++ b/tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+