From 72ee03c75364005a2a530372e823c7fb94152257 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 20:51:04 +0200 Subject: [PATCH 01/14] feat(connections): surface leader DeathNote on TeamInfoSnapshot --- .../Connections/TeamInfoSnapshot.cs | 6 +++++- .../Listening/RustPlusSocketSource.cs | 5 ++++- .../TeamInfoSnapshotTests.cs | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/RustPlusBot.Features.Connections.Tests/TeamInfoSnapshotTests.cs 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.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/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); + } +} From cd900da972a3b510e18d343b825c5deb40b1ae75 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 20:54:00 +0200 Subject: [PATCH 02/14] feat(abstractions): add PlayerTransition + PlayerStateChangedEvent --- .../Events/PlayerStateChangedEvent.cs | 14 +++++++++++ .../Events/PlayerTransition.cs | 12 ++++++++++ .../Events/PlayerTransitionKind.cs | 23 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 src/RustPlusBot.Abstractions/Events/PlayerStateChangedEvent.cs create mode 100644 src/RustPlusBot.Abstractions/Events/PlayerTransition.cs create mode 100644 src/RustPlusBot.Abstractions/Events/PlayerTransitionKind.cs 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, +} From 23a380f43f83acb75906f631ef02cec50eca32e1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 20:56:26 +0200 Subject: [PATCH 03/14] chore(players): scaffold Features.Players slice + test project --- RustPlusBot.slnx | 2 ++ .../RustPlusBot.Features.Players.csproj | 23 +++++++++++++++++++ .../RustPlusBot.Features.Players.Tests.csproj | 20 ++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj create mode 100644 tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj 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.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/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 @@ + + + + + + + + + + + + + + + + + + + + From 07f3d4dc67b20ec47ecc1c9497580a253cc6d416 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:00:07 +0200 Subject: [PATCH 04/14] feat(connections): TeamStateTracker diffs team snapshots into transitions --- .../Listening/TeamStateTracker.cs | 75 ++++++++++ .../TeamStateTrackerTests.cs | 137 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs new file mode 100644 index 00000000..0d14bddc --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs @@ -0,0 +1,75 @@ +using RustPlusBot.Abstractions.Events; + +namespace RustPlusBot.Features.Connections.Listening; + +/// Diffs successive team snapshots into presence transitions. One instance per connected window. +/// Not thread-safe: the supervisor calls from a single poll loop. +internal sealed class TeamStateTracker +{ + 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. + /// The transitions since the previous snapshot; empty on prime, null input, or no change. + public IReadOnlyList Diff(TeamInfoSnapshot? snapshot) + { + if (snapshot is null) + { + return []; + } + + var current = snapshot.Members.ToDictionary(m => m.SteamId); + + if (_baseline is null) + { + _baseline = current; // first poll: silent baseline + return []; + } + + var transitions = new List(); + foreach (var (id, now) in current) + { + if (!_baseline.TryGetValue(id, out var was)) + { + continue; // brand-new member: prime silently this poll + } + + 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))); + } + } + + _baseline = current; + return transitions; + } + + 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/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs new file mode 100644 index 00000000..f0506f85 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs @@ -0,0 +1,137 @@ +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))); + 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))); + Assert.Empty(tracker.Diff(null)); + // After the null, an offline flip is still detected against the original baseline. + var result = tracker.Diff(Team(1, Member(1, online: false))); + 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))); + var result = tracker.Diff(Team(1, Member(1), Member(2, online: true))); + 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))); + var result = tracker.Diff(Team(1, Member(1, online: true))); + 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))); + var result = tracker.Diff(Team(1, Member(1, online: false))); + 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))); + // 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)))); + 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))); + var result = tracker.Diff(Team(1, Member(1, x: 50, y: 60, spawn: t0.AddMinutes(1)))); + 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])); + Assert.Empty(tracker.Diff(new TeamInfoSnapshot(1, [m]))); + } + + [Fact] + public void Connect_and_disconnect_have_no_location() + { + var tracker = new TeamStateTracker(); + tracker.Diff(Team(1, Member(1, online: false))); + var result = tracker.Diff(Team(1, Member(1, online: true, x: 9, y: 9))); + 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)])); + var snap = new TeamInfoSnapshot(1, [Member(1, x: 1, y: 1, death: t0.AddMinutes(1))], (700f, 800f)); + var death = Assert.Single(tracker.Diff(snap), 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)])); + // ...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), 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)])); + // 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)])); + Assert.DoesNotContain(first, t => t.SteamId == 2); + } +} From f49a0e79bdc8d88568ab945508113fa26d90ddc7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:04:52 +0200 Subject: [PATCH 05/14] feat(players): EN/FR player-event localization catalog Co-Authored-By: Claude Sonnet 4.6 --- .../Rendering/IPlayerLocalizer.cs | 16 +++++ .../Rendering/PlayerLocalizationCatalog.cs | 52 ++++++++++++++++ .../Rendering/PlayerLocalizer.cs | 61 +++++++++++++++++++ .../PlayerLocalizationCatalogTests.cs | 39 ++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs create mode 100644 src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs create mode 100644 src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs create mode 100644 tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs 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/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/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs new file mode 100644 index 00000000..fa109cdd --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs @@ -0,0 +1,39 @@ +using RustPlusBot.Features.Players.Rendering; + +namespace RustPlusBot.Features.Players.Tests; + +public sealed class PlayerLocalizationCatalogTests +{ + private static readonly string[] Keys = + [ + "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); + } +} From 3c4fe4c87905472eff6abeb7bf000e091b7085b5 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:11:15 +0200 Subject: [PATCH 06/14] feat(players): renderer + relay to #events and in-game chat Co-Authored-By: Claude Sonnet 4.6 --- .../Posting/DiscordPlayerChannelPoster.cs | 45 +++++++++++ .../Posting/IPlayerChannelPoster.cs | 13 +++ .../Relaying/PlayerEventRelay.cs | 61 ++++++++++++++ .../Rendering/PlayerEventRenderer.cs | 61 ++++++++++++++ .../PlayerEventRelayTests.cs | 79 +++++++++++++++++++ .../PlayerEventRendererTests.cs | 43 ++++++++++ 6 files changed, 302 insertions(+) create mode 100644 src/RustPlusBot.Features.Players/Posting/DiscordPlayerChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Players/Posting/IPlayerChannelPoster.cs create mode 100644 src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs create mode 100644 src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs create mode 100644 tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs create mode 100644 tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs 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..9d56dd82 --- /dev/null +++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs @@ -0,0 +1,61 @@ +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/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/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs new file mode 100644 index 00000000..6e1123fe --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -0,0 +1,79 @@ +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); + } +} From 5d7d32429ca3d61218862da1b696ad827eed7119 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:16:45 +0200 Subject: [PATCH 07/14] feat(players): publish + relay PlayerStateChangedEvent from poll loop Co-Authored-By: Claude Sonnet 4.6 --- .../Supervisor/ConnectionSupervisor.cs | 10 +++ .../Hosting/PlayersHostedService.cs | 73 +++++++++++++++++++ .../PlayerEventServiceCollectionExtensions.cs | 28 +++++++ src/RustPlusBot.Host/Program.cs | 2 + src/RustPlusBot.Host/RustPlusBot.Host.csproj | 1 + .../PlayerEventRegistrationTests.cs | 37 ++++++++++ 6 files changed, 151 insertions(+) create mode 100644 src/RustPlusBot.Features.Players/Hosting/PlayersHostedService.cs create mode 100644 src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs create mode 100644 tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 7630ed6c..4d549df1 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -449,6 +449,7 @@ private async Task PollMarkersAsync( { IReadOnlyList? previous = null; var rigsInRadius = new HashSet(); + var tracker = new TeamStateTracker(); while (!ct.IsCancellationRequested) { var anyCh47 = false; @@ -475,6 +476,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); + if (transitions.Count > 0) + { + await eventBus.PublishAsync( + new PlayerStateChangedEvent(key.Guild, key.Server, dims, transitions), ct) + .ConfigureAwait(false); + } } catch (OperationCanceledException) { 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.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.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); + } +} From 4077f00eb9b304c9ec0cfa13df9f0e4feaa81c16 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:20:44 +0200 Subject: [PATCH 08/14] test(players): all transition kinds render end-to-end --- .../PlayerEventEndToEndTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs new file mode 100644 index 00000000..566c0166 --- /dev/null +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs @@ -0,0 +1,23 @@ +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")); + } + } +} From a1bace9a8af651ddba1343926f50340858a960de Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:25:32 +0200 Subject: [PATCH 09/14] feat(connections): AFK hysteresis tracking in TeamStateTracker --- .../ConnectionOptions.cs | 6 ++ .../Listening/TeamStateTracker.cs | 95 +++++++++++++++---- .../Supervisor/ConnectionSupervisor.cs | 5 +- .../TeamStateTrackerAfkTests.cs | 68 +++++++++++++ .../TeamStateTrackerTests.cs | 48 +++++----- 5 files changed, 177 insertions(+), 45 deletions(-) create mode 100644 tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs 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/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs index 0d14bddc..896fd51e 100644 --- a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs +++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs @@ -7,11 +7,17 @@ namespace RustPlusBot.Features.Connections.Listening; internal sealed class TeamStateTracker { private Dictionary? _baseline; + private readonly Dictionary _stillSince = new(); + private readonly HashSet _afk = new(); /// 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) + public IReadOnlyList Diff( + TeamInfoSnapshot? snapshot, DateTimeOffset now, TimeSpan afkThreshold, float afkEpsilon) { if (snapshot is null) { @@ -22,42 +28,91 @@ public IReadOnlyList Diff(TeamInfoSnapshot? snapshot) if (_baseline is null) { - _baseline = current; // first poll: silent baseline + _baseline = current; + foreach (var m in current.Values) + { + _stillSince[m.SteamId] = now; + } + return []; } var transitions = new List(); - foreach (var (id, now) in current) + foreach (var (id, nowMember) in current) { if (!_baseline.TryGetValue(id, out var was)) { - continue; // brand-new member: prime silently this poll + _stillSince[id] = now; // prime new member's stillness clock + continue; } - 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)); - } + AddPresenceTransitions(transitions, id, was, nowMember, snapshot); + UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon); + } + + _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) + 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) + { + var eligible = now.IsOnline && now.IsAlive; + if (!eligible) + { + if (_afk.Remove(id)) { - transitions.Add(new PlayerTransition( - PlayerTransitionKind.Death, id, now.Name, ResolveDeathLocation(id, snapshot, was))); + transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null)); } - if (now.LastSpawnTimeUtc > was.LastSpawnTimeUtc) + _stillSince[id] = clock; + return; + } + + var moved = Math.Abs(now.X - was.X) > epsilon || Math.Abs(now.Y - was.Y) > epsilon; + if (moved) + { + _stillSince[id] = clock; + if (_afk.Remove(id)) { - transitions.Add(new PlayerTransition( - PlayerTransitionKind.Respawn, id, now.Name, (now.X, now.Y))); + transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null)); } + + return; } - _baseline = current; - return transitions; + 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))); + } } private static (float X, float Y)? ResolveDeathLocation( diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 4d549df1..02604a44 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,6 +30,7 @@ internal sealed partial class ConnectionSupervisor( IUserDmSender dmSender, ICredentialProtector protector, IEventBus eventBus, + IClock clock, IOptions options, ILogger logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAsyncDisposable { @@ -478,7 +481,7 @@ 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); + var transitions = tracker.Diff(team, clock.UtcNow, _options.AfkThreshold, _options.AfkEpsilon); if (transitions.Count > 0) { await eventBus.PublishAsync( diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs new file mode 100644 index 00000000..de611a81 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs @@ -0,0 +1,68 @@ +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Tests; + +public sealed class TeamStateTrackerAfkTests +{ + private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5); + private const float Eps = 1f; + + private static TeamMemberSnapshot Member(ulong id, float x, float y, bool online = true, bool alive = true) + => new(id, $"P{id}", x, y, online, alive, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch); + + 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_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 off = t.Diff(Team(Member(1, 0, 0, online: false)), t0.AddMinutes(7), Threshold, Eps); + Assert.Contains(off, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk); + } + + [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); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs index f0506f85..64856b04 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs @@ -17,7 +17,7 @@ private static TeamInfoSnapshot Team(ulong leader, params TeamMemberSnapshot[] m public void First_snapshot_primes_silently() { var tracker = new TeamStateTracker(); - var result = tracker.Diff(Team(1, Member(1))); + var result = tracker.Diff(Team(1, Member(1)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); Assert.Empty(result); } @@ -25,10 +25,10 @@ public void First_snapshot_primes_silently() public void Null_snapshot_emits_nothing_and_keeps_baseline() { var tracker = new TeamStateTracker(); - tracker.Diff(Team(1, Member(1, online: true))); - Assert.Empty(tracker.Diff(null)); + 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))); + var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f); Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect); } @@ -36,8 +36,8 @@ public void Null_snapshot_emits_nothing_and_keeps_baseline() public void Brand_new_member_is_primed_silently() { var tracker = new TeamStateTracker(); - tracker.Diff(Team(1, Member(1))); - var result = tracker.Diff(Team(1, Member(1), Member(2, online: true))); + 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); } @@ -45,8 +45,8 @@ public void Brand_new_member_is_primed_silently() public void Connect_detected_on_offline_to_online() { var tracker = new TeamStateTracker(); - tracker.Diff(Team(1, Member(1, online: false))); - var result = tracker.Diff(Team(1, Member(1, online: true))); + 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); } @@ -54,8 +54,8 @@ public void Connect_detected_on_offline_to_online() public void Disconnect_detected_on_online_to_offline() { var tracker = new TeamStateTracker(); - tracker.Diff(Team(1, Member(1, online: true))); - var result = tracker.Diff(Team(1, Member(1, online: false))); + 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); } @@ -64,9 +64,9 @@ 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))); + 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)))); + 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); } @@ -75,8 +75,8 @@ 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))); - var result = tracker.Diff(Team(1, Member(1, x: 50, y: 60, spawn: t0.AddMinutes(1)))); + 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); } @@ -86,16 +86,16 @@ 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])); - Assert.Empty(tracker.Diff(new TeamInfoSnapshot(1, [m]))); + 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))); - var result = tracker.Diff(Team(1, Member(1, online: true, x: 9, y: 9))); + 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); } @@ -104,9 +104,9 @@ public void Leader_death_uses_deathnote_location() { var t0 = DateTimeOffset.UnixEpoch; var tracker = new TeamStateTracker(); - tracker.Diff(new TeamInfoSnapshot(1, [Member(1, death: t0)])); + 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), t => t.Kind == PlayerTransitionKind.Death); + var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f), t => t.Kind == PlayerTransitionKind.Death); Assert.Equal((700f, 800f), death.Location); } @@ -116,10 +116,10 @@ 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)])); + 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), t => t.Kind == PlayerTransitionKind.Death); + 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 } @@ -129,9 +129,9 @@ 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)])); + 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)])); + 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); } } From 6fc2a4268f5b5a4ee406d0b3b26cbe2cb05af13c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:31:22 +0200 Subject: [PATCH 10/14] feat(connections): IAfkState read seam over the AFK tracker Co-Authored-By: Claude Sonnet 4.6 --- .../ConnectionServiceCollectionExtensions.cs | 1 + .../Listening/IAfkState.cs | 18 +++++++++++ .../Listening/TeamStateTracker.cs | 17 ++++++++++ .../Supervisor/ConnectionSupervisor.cs | 23 +++++++++++--- .../AfkStateTests.cs | 31 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 src/RustPlusBot.Features.Connections/Listening/IAfkState.cs create mode 100644 tests/RustPlusBot.Features.Connections.Tests/AfkStateTests.cs 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..b3e4a6a1 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs @@ -0,0 +1,18 @@ +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/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs index 896fd51e..77b0dbe4 100644 --- a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs +++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs @@ -115,6 +115,23 @@ private void UpdateAfk( } } + /// 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) + { + 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) { diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 02604a44..5046c4de 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -32,7 +32,7 @@ internal sealed partial class ConnectionSupervisor( 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); @@ -259,6 +259,18 @@ public async Task SendAsync( } } + /// + 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); + } + [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId); @@ -398,10 +410,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 { @@ -448,11 +461,11 @@ private async Task PollMarkersAsync( IRustServerConnection connection, MapDimensions? dims, IReadOnlyList rigs, + TeamStateTracker tracker, CancellationToken ct) { IReadOnlyList? previous = null; var rigsInRadius = new HashSet(); - var tracker = new TeamStateTracker(); while (!ct.IsCancellationRequested) { var anyCh47 = false; @@ -755,7 +768,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/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))); + } +} From 00f1e10e3c0aab50ed6383afc13c28cc7afd959d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:36:27 +0200 Subject: [PATCH 11/14] feat(commands): in-game !afk command over IAfkState --- .../CommandServiceCollectionExtensions.cs | 1 + .../Handlers/AfkCommandHandler.cs | 38 ++++++++++++++++ .../CommandLocalizationCatalog.cs | 6 +++ .../AfkCommandHandlerTests.cs | 45 +++++++++++++++++++ .../CommandRegistrationTests.cs | 5 ++- 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs 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/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs index 013b944c..723ca690 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}", @@ -111,6 +114,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}", diff --git a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs new file mode 100644 index 00000000..10d54960 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs @@ -0,0 +1,45 @@ +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()); From 98e812f1b8402c59e8ae34f4aacbc7485105ef09 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:40:47 +0200 Subject: [PATCH 12/14] 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 --- src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs | 1 + .../Localization/CommandLocalizationCatalog.cs | 2 ++ .../Help/CommandHelpCatalogTests.cs | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) 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 723ca690..cf74ea3f 100644 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs @@ -80,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.", @@ -164,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/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] From 5b9550566fea840a5ea7f45c7399a5382bcf438f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 21:47:10 +0200 Subject: [PATCH 13/14] fix(connections): guard TeamStateTracker with a lock for concurrent !afk reads Co-Authored-By: Claude Sonnet 4.6 --- .../Listening/TeamStateTracker.cs | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs index 77b0dbe4..ef2d9654 100644 --- a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs +++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs @@ -3,9 +3,9 @@ namespace RustPlusBot.Features.Connections.Listening; /// Diffs successive team snapshots into presence transitions. One instance per connected window. -/// Not thread-safe: the supervisor calls from a single poll loop. internal sealed class TeamStateTracker { + private readonly object _gate = new(); private Dictionary? _baseline; private readonly Dictionary _stillSince = new(); private readonly HashSet _afk = new(); @@ -26,32 +26,35 @@ public IReadOnlyList Diff( var current = snapshot.Members.ToDictionary(m => m.SteamId); - if (_baseline is null) + lock (_gate) { - _baseline = current; - foreach (var m in current.Values) + if (_baseline is null) { - _stillSince[m.SteamId] = now; - } + _baseline = current; + foreach (var m in current.Values) + { + _stillSince[m.SteamId] = now; + } - return []; - } + return []; + } - var transitions = new List(); - foreach (var (id, nowMember) in current) - { - if (!_baseline.TryGetValue(id, out var was)) + var transitions = new List(); + foreach (var (id, nowMember) in current) { - _stillSince[id] = now; // prime new member's stillness clock - continue; + if (!_baseline.TryGetValue(id, out var was)) + { + _stillSince[id] = now; // prime new member's stillness clock + continue; + } + + AddPresenceTransitions(transitions, id, was, nowMember, snapshot); + UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon); } - AddPresenceTransitions(transitions, id, was, nowMember, snapshot); - UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon); + _baseline = current; + return transitions; } - - _baseline = current; - return transitions; } private static void AddPresenceTransitions( @@ -119,17 +122,20 @@ private void UpdateAfk( /// The current wall-clock time used to compute each member's still duration. public IReadOnlyList CurrentAfk(DateTimeOffset now) { - var result = new List(); - foreach (var id in _afk) + lock (_gate) { - if (_baseline is not null && _baseline.TryGetValue(id, out var m)) + var result = new List(); + foreach (var id in _afk) { - var since = _stillSince.TryGetValue(id, out var s) ? s : now; - result.Add(new AfkMember(id, m.Name, now - since)); + 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; + return result; + } } private static (float X, float Y)? ResolveDeathLocation( From 7e41a8286b13b562762401aa98b3c2bafb5bca75 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 18 Jun 2026 22:04:49 +0200 Subject: [PATCH 14/14] fix(players): address Copilot review + ReSharper format gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Listening/IAfkState.cs | 4 +- .../Listening/TeamStateTracker.cs | 70 ++++++++++++++----- .../Supervisor/ConnectionSupervisor.cs | 29 ++++---- .../Relaying/PlayerEventRelay.cs | 3 +- .../AfkCommandHandlerTests.cs | 5 +- .../TeamStateTrackerAfkTests.cs | 68 +++++++++++++++--- .../TeamStateTrackerTests.cs | 45 ++++++++---- .../PlayerEventEndToEndTests.cs | 6 +- .../PlayerEventRelayTests.cs | 3 +- .../PlayerLocalizationCatalogTests.cs | 1 + 10 files changed, 175 insertions(+), 59 deletions(-) diff --git a/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs index b3e4a6a1..629f9dca 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IAfkState.cs @@ -14,5 +14,7 @@ public interface IAfkState /// 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); + Task?> GetAfkMembersAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs index ef2d9654..2930d25a 100644 --- a/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs +++ b/src/RustPlusBot.Features.Connections/Listening/TeamStateTracker.cs @@ -5,10 +5,10 @@ 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 Dictionary? _baseline; private readonly Dictionary _stillSince = new(); - private readonly HashSet _afk = 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. @@ -17,7 +17,10 @@ internal sealed class TeamStateTracker /// 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) + TeamInfoSnapshot? snapshot, + DateTimeOffset now, + TimeSpan afkThreshold, + float afkEpsilon) { if (snapshot is null) { @@ -49,17 +52,22 @@ public IReadOnlyList Diff( } AddPresenceTransitions(transitions, id, was, nowMember, snapshot); - UpdateAfk(transitions, id, was, nowMember, now, afkThreshold, afkEpsilon); + 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) + List transitions, + ulong id, + TeamMemberSnapshot was, + TeamMemberSnapshot now, + TeamInfoSnapshot snapshot) { if (now.IsOnline && !was.IsOnline) { @@ -83,22 +91,31 @@ private static void AddPresenceTransitions( } private void UpdateAfk( - List transitions, ulong id, - TeamMemberSnapshot was, TeamMemberSnapshot now, DateTimeOffset clock, TimeSpan threshold, float epsilon) + List transitions, + ulong id, + TeamMemberSnapshot was, + TeamMemberSnapshot now, + DateTimeOffset clock, + TimeSpan threshold, + float epsilon, + bool diedThisPoll) { - var eligible = now.IsOnline && now.IsAlive; - if (!eligible) + // 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) { - if (_afk.Remove(id)) - { - transitions.Add(new PlayerTransition(PlayerTransitionKind.ReturnedFromAfk, id, now.Name, null)); - } - + _afk.Remove(id); _stillSince[id] = clock; return; } - var moved = Math.Abs(now.X - was.X) > epsilon || Math.Abs(now.Y - was.Y) > epsilon; + // 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; @@ -118,6 +135,23 @@ private void UpdateAfk( } } + /// 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) @@ -139,7 +173,9 @@ public IReadOnlyList CurrentAfk(DateTimeOffset now) } private static (float X, float Y)? ResolveDeathLocation( - ulong steamId, TeamInfoSnapshot snapshot, TeamMemberSnapshot previous) + 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) diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 5046c4de..7e854cd8 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -32,7 +32,8 @@ internal sealed partial class ConnectionSupervisor( IEventBus eventBus, IClock clock, IOptions options, - ILogger logger) : IConnectionSupervisor, ITeamChatSender, IRustServerQuery, IAfkState, 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); @@ -41,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() { @@ -259,18 +274,6 @@ public async Task SendAsync( } } - /// - 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); - } - [LoggerMessage(Level = LogLevel.Error, Message = "Connection loop for server {ServerId} faulted.")] private static partial void LogLoopFaulted(ILogger logger, Exception exception, Guid serverId); diff --git a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs index 9d56dd82..433daf18 100644 --- a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs +++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs @@ -39,7 +39,8 @@ public async Task RelayAsync(PlayerStateChangedEvent evt, CancellationToken canc foreach (var t in evt.Transitions) { await teamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture), cancellationToken) + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture), + cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { diff --git a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs index 10d54960..ab81386f 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs @@ -38,7 +38,10 @@ public async Task Reports_none_when_empty() 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)) }); + .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.Connections.Tests/TeamStateTrackerAfkTests.cs b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs index de611a81..5bc8381e 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerAfkTests.cs @@ -5,11 +5,17 @@ namespace RustPlusBot.Features.Connections.Tests; public sealed class TeamStateTrackerAfkTests { - private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5); 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) - => new(id, $"P{id}", x, y, online, alive, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch); + 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); @@ -18,9 +24,9 @@ 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 + 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 + 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 } @@ -31,20 +37,38 @@ 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 + 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_emits_ReturnedFromAfk() + 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 + 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); - Assert.Contains(off, x => x.Kind == PlayerTransitionKind.ReturnedFromAfk); + // 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] @@ -65,4 +89,30 @@ public void Small_jitter_below_epsilon_still_counts_as_still() 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 index 64856b04..377515cf 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/TeamStateTrackerTests.cs @@ -6,8 +6,13 @@ 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) + 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) @@ -28,7 +33,8 @@ public void Null_snapshot_emits_nothing_and_keeps_baseline() 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); + var result = tracker.Diff(Team(1, Member(1, online: false)), DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), + 1f); Assert.Single(result, t => t.Kind == PlayerTransitionKind.Disconnect); } @@ -37,7 +43,8 @@ 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); + 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); } @@ -46,7 +53,8 @@ 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); + 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); } @@ -55,7 +63,8 @@ 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); + 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); } @@ -66,7 +75,8 @@ public void Death_detected_on_deathtime_advance_even_if_alive_again() 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); + 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); } @@ -76,7 +86,8 @@ 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 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); } @@ -95,7 +106,8 @@ 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); + 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); } @@ -104,9 +116,11 @@ 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); + 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); + var death = Assert.Single(tracker.Diff(snap, DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(5), 1f), + t => t.Kind == PlayerTransitionKind.Death); Assert.Equal((700f, 800f), death.Location); } @@ -116,10 +130,12 @@ 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); + 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); + 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 } @@ -131,7 +147,8 @@ public void Death_with_no_prior_position_and_no_deathnote_has_null_location() // 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); + 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 index 566c0166..1afdaa38 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs @@ -13,8 +13,10 @@ public void All_transition_kinds_render_without_throwing() 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 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/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs index 6e1123fe..3ce62955 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -60,7 +60,8 @@ public async Task Skips_discord_post_when_no_channel_but_still_sends_ingame() await relay.RelayAsync( Evt(new PlayerTransition(PlayerTransitionKind.Disconnect, 1, "Bob", null)), CancellationToken.None); - await _poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _poster.DidNotReceive() + .PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); await _sender.ReceivedWithAnyArgs(1).SendAsync(default, Guid.Empty, default!, default); } diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs index fa109cdd..e78ee5cd 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs @@ -6,6 +6,7 @@ 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",