From 5910271366b8d38fca29caa367ced66209d75151 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 1 Oct 2026 12:54:49 -0400 Subject: [PATCH] feature: report the live player roster and kills from player management --- .../src/PlayerManagement.csproj | 1 + .../src/PlayerManagementPlugin.cs | 119 ++- .../src/PlayerManagement.csproj | 1 + .../src/PlayerManagementPlugin.cs | 143 ++- .../test/FiveStack.Tests.csproj | 1 + .../test/PlayerRosterTests.cs | 840 ++++++++++++++++++ .../test/SanctionSyncLoopTests.cs | 23 +- .../test/SanctionsClientTests.cs | 16 + .../PlayerManagement/PlayerSanction.cs | 28 + .../FiveStack.Utilities/PlayerRoster.cs | 411 +++++++++ .../FiveStack.Utilities/SanctionSyncLoop.cs | 61 +- .../FiveStack.Utilities/SanctionsClient.cs | 6 +- 12 files changed, 1630 insertions(+), 20 deletions(-) create mode 100644 apps/player-management-sw/test/PlayerRosterTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PlayerRoster.cs diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index fa0c2621..9f2385b9 100644 --- a/apps/player-management-css/src/PlayerManagement.csproj +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -15,6 +15,7 @@ + diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index c7b9efad..0ab09c2c 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -4,6 +4,7 @@ using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Translations; using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Cvars; using CounterStrikeSharp.API.ValveConstants.Protobuf; using FiveStack.Entities.PlayerManagement; using FiveStack.Enums; @@ -31,6 +32,7 @@ public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig(() => _roster.MapEnded(DateTimeOffset.UtcNow)); + RegisterListener(_ => _roster.MapStarted(DateTimeOffset.UtcNow)); + + RegisterListener(hibernating => + { + if (hibernating) + { + _roster.Hibernating(DateTimeOffset.UtcNow); + } + }); + AddCommandListener("say", OnChat, HookMode.Pre); AddCommandListener("say_team", OnChat, HookMode.Pre); @@ -99,6 +112,7 @@ public override void Load(bool hotReload) _loop = new SanctionSyncLoop( _book, _access, + _roster, new SanctionsClient(), Config.Settings, ModuleVersion, @@ -106,6 +120,19 @@ public override void Load(bool hotReload) message => Logger.LogWarning("{message}", message), message => Logger.LogInformation("{message}", message) ); + + // So a hot reload reports the players already here, not an unknown + // roster. Before a map has loaded there may be no list to read, and the + // roster is then left unknown until the first tick. + try + { + _loop.Observe(Observed(Humans()), DateTimeOffset.UtcNow); + } + catch (Exception error) + { + Logger.LogDebug(error, "unable to read the players present on load"); + } + _loop.Start(); } @@ -192,8 +219,7 @@ private void OnTick() { List humans = Humans(); - _loop?.Observe(humans.Select(player => SteamIdOf(player).ToString())); - + ObserveRoster(humans); Enforce(humans); } catch (Exception error) @@ -299,6 +325,68 @@ private void Enforce(List humans) } } + [GameEventHandler] + public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) + { + try + { + CCSPlayerController? victim = @event.Userid; + + if (victim == null || !IsHuman(victim)) + { + return HookResult.Continue; + } + + CCSPlayerController? attacker = @event.Attacker; + + bool kill = + attacker != null + && IsHuman(attacker) + && PlayerRoster.CountsAsKill( + attacker.Slot, + victim.Slot, + attacker.TeamNum, + victim.TeamNum, + TeammatesAreEnemies() + ); + + _roster.Died(VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null); + } + catch (Exception error) + { + Logger.LogError(error, "unable to count a death"); + } + + return HookResult.Continue; + } + + // Kept apart from the enforcement that follows it on the tick, which must + // run whatever happens to the roster. + private void ObserveRoster(List humans) + { + try + { + _loop?.Observe(Observed(humans), DateTimeOffset.UtcNow); + } + catch (Exception error) + { + Logger.LogError(error, "unable to observe the players present"); + } + } + + // Unreadable is taken as teams as usual, where a team kill earns nothing. + private static bool TeammatesAreEnemies() + { + try + { + return ConVar.Find("mp_teammates_are_enemies")?.GetPrimitiveValue() ?? false; + } + catch (Exception) + { + return false; + } + } + private HookResult OnChat(CCSPlayerController? player, CommandInfo info) { if (player == null || !IsHuman(player)) @@ -341,6 +429,27 @@ private static List Humans() return Utilities.GetPlayers().Where(IsHuman).ToList(); } + // A controller lingers through its disconnect, so only a connected one is + // on the roster. + private static List Observed(List humans) + { + return humans + .Select(player => + { + bool verified = + player.AuthorizedSteamID != null + && player.Connected == PlayerConnectedState.Connected; + + return new ObservedPlayer( + SteamIdOf(player).ToString(), + player.PlayerName, + verified ? player.IpAddress : null, + verified + ); + }) + .ToList(); + } + private static bool IsHuman(CCSPlayerController player) { return player.IsValid && !player.IsBot && !player.IsHLTV && SteamIdOf(player) != 0; @@ -353,4 +462,10 @@ private static ulong SteamIdOf(CCSPlayerController player) { return player.AuthorizedSteamID?.SteamId64 ?? player.SteamID; } + + // Unlike a sanction, a kill credited to a claimed id is something gained. + private static string? VerifiedSteamIdOf(CCSPlayerController player) + { + return player.AuthorizedSteamID?.SteamId64.ToString(); + } } diff --git a/apps/player-management-sw/src/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj index db864cd9..a4f4af31 100644 --- a/apps/player-management-sw/src/PlayerManagement.csproj +++ b/apps/player-management-sw/src/PlayerManagement.csproj @@ -24,6 +24,7 @@ + diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 04cea327..fe8c45ae 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -8,6 +8,8 @@ using SwiftlyS2.Shared; using SwiftlyS2.Shared.Commands; using SwiftlyS2.Shared.Events; +using SwiftlyS2.Shared.GameEventDefinitions; +using SwiftlyS2.Shared.GameEvents; using SwiftlyS2.Shared.Misc; using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.Plugins; @@ -38,6 +40,7 @@ public partial class PlayerManagementPlugin : BasePlugin private readonly SanctionBook _book = new(); private readonly ServerAccessBook _access = new(); + private readonly PlayerRoster _roster = new(); // What was last applied to each player present, so changes are announced // once, and whose mute bit this plugin set, so it only ever lifts its own @@ -50,8 +53,12 @@ public partial class PlayerManagementPlugin : BasePlugin private EventDelegates.OnClientPutInServer? _putInServerHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; private EventDelegates.OnClientDisconnected? _disconnectHandler; + private EventDelegates.OnMapUnload? _mapUnloadHandler; + private EventDelegates.OnMapLoad? _mapLoadHandler; + private EventDelegates.OnWorldUpdate? _worldUpdateHandler; private Guid _chatHookId; private long _lastEnforceMs; + private long _lastWorldUpdateMs; public PlayerManagementPlugin(ISwiftlyCore core) : base(core) { } @@ -123,6 +130,15 @@ public override void Load(bool hotReload) }; Core.Event.OnClientDisconnected += _disconnectHandler; + _mapUnloadHandler = _ => _roster.MapEnded(DateTimeOffset.UtcNow); + Core.Event.OnMapUnload += _mapUnloadHandler; + + _mapLoadHandler = _ => _roster.MapStarted(DateTimeOffset.UtcNow); + Core.Event.OnMapLoad += _mapLoadHandler; + + _worldUpdateHandler = OnWorldUpdate; + Core.Event.OnWorldUpdate += _worldUpdateHandler; + _chatHookId = Core.Command.HookClientChat((playerId, text, teamonly) => OnChat(playerId)); InstallConnectGate(); @@ -130,6 +146,7 @@ public override void Load(bool hotReload) _loop = new SanctionSyncLoop( _book, _access, + _roster, new SanctionsClient(), Settings, ModuleVersion, @@ -137,6 +154,19 @@ public override void Load(bool hotReload) message => _logger.LogWarning("{message}", message), message => _logger.LogInformation("{message}", message) ); + + // So a hot reload reports the players already here, not an unknown + // roster. Before a map has loaded there may be no list to read, and the + // roster is then left unknown until the first tick. + try + { + _loop.Observe(Observed(Humans()), DateTimeOffset.UtcNow); + } + catch (Exception error) + { + _logger.LogDebug(error, "unable to read the players present on load"); + } + _loop.Start(); } @@ -167,6 +197,21 @@ public override void Unload() Core.Event.OnClientDisconnected -= _disconnectHandler; } + if (_mapUnloadHandler != null) + { + Core.Event.OnMapUnload -= _mapUnloadHandler; + } + + if (_mapLoadHandler != null) + { + Core.Event.OnMapLoad -= _mapLoadHandler; + } + + if (_worldUpdateHandler != null) + { + Core.Event.OnWorldUpdate -= _worldUpdateHandler; + } + if (_chatHookId != Guid.Empty) { Core.Command.UnhookClientChat(_chatHookId); @@ -258,8 +303,7 @@ private void OnTick() { List humans = Humans(); - _loop?.Observe(humans.Select(player => SteamIdOf(player).ToString())); - + ObserveRoster(humans); Enforce(humans); } catch (Exception error) @@ -375,6 +419,83 @@ private void Enforce(List humans) } } + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerDeath(EventPlayerDeath @event) + { + try + { + IPlayer? victim = @event.UserIdPlayer; + + if (victim == null || !IsHuman(victim)) + { + return HookResult.Continue; + } + + IPlayer? attacker = @event.AttackerPlayer; + + bool kill = + attacker != null + && IsHuman(attacker) + && PlayerRoster.CountsAsKill( + attacker.PlayerID, + victim.PlayerID, + attacker.Controller.TeamNum, + victim.Controller.TeamNum, + TeammatesAreEnemies() + ); + + _roster.Died(VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null); + } + catch (Exception error) + { + _logger.LogError(error, "unable to count a death"); + } + + return HookResult.Continue; + } + + // Kept apart from the enforcement that follows it on the tick, which must + // run whatever happens to the roster. + private void ObserveRoster(List humans) + { + try + { + _loop?.Observe(Observed(humans), DateTimeOffset.UtcNow); + } + catch (Exception error) + { + _logger.LogError(error, "unable to observe the players present"); + } + } + + // Unreadable is taken as teams as usual, where a team kill earns nothing. + private bool TeammatesAreEnemies() + { + try + { + return Core.ConVar.Find("mp_teammates_are_enemies")?.Value ?? false; + } + catch (Exception) + { + return false; + } + } + + // Still called while the server hibernates, when OnTick is not, which is + // how the roster tells an empty server from one loading a map. + private void OnWorldUpdate() + { + long nowMs = Environment.TickCount64; + + if (nowMs - _lastWorldUpdateMs < EnforceEveryMs) + { + return; + } + + _lastWorldUpdateMs = nowMs; + _roster.WorldUpdated(DateTimeOffset.UtcNow); + } + private HookResult OnChat(int playerId) { IPlayer? player = Core.PlayerManager.GetPlayer(playerId); @@ -422,6 +543,18 @@ private List Humans() return Core.PlayerManager.GetAllPlayers().Where(IsHuman).ToList(); } + private static List Observed(List humans) + { + return humans + .Select(player => new ObservedPlayer( + SteamIdOf(player).ToString(), + player.Name, + player.IsAuthorized ? player.IPAddress : null, + player.IsAuthorized + )) + .ToList(); + } + private static bool IsHuman(IPlayer player) { return player.IsValid && !player.IsFakeClient && SteamIdOf(player) != 0; @@ -435,6 +568,12 @@ private static ulong SteamIdOf(IPlayer player) return player.IsAuthorized ? player.SteamID : player.UnauthorizedSteamID; } + // Unlike a sanction, a kill credited to a claimed id is something gained. + private static string? VerifiedSteamIdOf(IPlayer player) + { + return player.IsAuthorized ? player.SteamID.ToString() : null; + } + private PlayerManagementSettings Settings() { PlayerManagementSettings file = diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj index a14d306b..e5d22eb7 100644 --- a/apps/player-management-sw/test/FiveStack.Tests.csproj +++ b/apps/player-management-sw/test/FiveStack.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs new file mode 100644 index 00000000..db60c72b --- /dev/null +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -0,0 +1,840 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FiveStack.Entities.PlayerManagement; +using FiveStack.Utilities; +using Xunit; + +public class PlayerRosterTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); + + private sealed record Reported(string SteamId, string Name, string? Ip, int Kills, int Deaths); + + private sealed class Panel : HttpMessageHandler + { + public readonly List Bodies = new(); + public Func> Answer = () => Task.FromResult(Ok()); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + using JsonDocument body = JsonDocument.Parse( + await request.Content!.ReadAsStringAsync(cancellationToken) + ); + + Bodies.Add(body.RootElement.Clone()); + + return await Answer(); + } + + public static HttpResponseMessage Ok(bool? recorded = null) + { + string json = + recorded == null + ? "{\"sanctions\":[]}" + : $"{{\"sanctions\":[],\"roster_recorded\":{(recorded.Value ? "true" : "false")}}}"; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + + public static HttpResponseMessage Down() + { + return new HttpResponseMessage(HttpStatusCode.BadGateway); + } + + public List? Players(int sync) + { + JsonElement players = Bodies[sync].GetProperty("players"); + + if (players.ValueKind == JsonValueKind.Null) + { + return null; + } + + return players + .EnumerateArray() + .Select(player => new Reported( + player.GetProperty("steam_id").GetString()!, + player.GetProperty("name").GetString()!, + player.GetProperty("ip").GetString(), + player.GetProperty("kills").GetInt32(), + player.GetProperty("deaths").GetInt32() + )) + .OrderBy(player => player.SteamId) + .ToList(); + } + + public List? SteamIds(int sync) + { + return Players(sync)?.Select(player => player.SteamId).ToList(); + } + + public Dictionary Conns(int sync) + { + return Bodies[sync] + .GetProperty("players") + .EnumerateArray() + .ToDictionary( + player => player.GetProperty("steam_id").GetString()!, + player => player.GetProperty("conn").GetString()! + ); + } + + public List<(string SteamId, int Kills, int Deaths)> Departed(int sync) + { + return Bodies[sync] + .GetProperty("departed") + .EnumerateArray() + .Select(player => + ( + player.GetProperty("steam_id").GetString()!, + player.GetProperty("kills").GetInt32(), + player.GetProperty("deaths").GetInt32() + ) + ) + .ToList(); + } + + public List DepartedConns(int sync) + { + return Bodies[sync] + .GetProperty("departed") + .EnumerateArray() + .Select(player => player.GetProperty("conn").GetString()!) + .ToList(); + } + } + + private sealed class FailingRoster : PlayerRoster + { + public int ReportFailures; + public bool ObserveFails; + + public override RosterReport Report(DateTimeOffset now) + { + if (ReportFailures > 0) + { + ReportFailures--; + throw new InvalidOperationException("roster failed"); + } + + return base.Report(now); + } + + public override DateTimeOffset? Observe( + IEnumerable players, + DateTimeOffset now + ) + { + if (ObserveFails) + { + throw new InvalidOperationException("roster failed"); + } + + return base.Observe(players, now); + } + } + + private static ( + SanctionSyncLoop Loop, + PlayerRoster Roster, + SanctionBook Book, + Panel Panel + ) Loop(PlayerRoster? roster = null) + { + Panel panel = new(); + SanctionBook book = new(); + roster ??= new PlayerRoster(); + + SanctionSyncLoop loop = new( + book, + new ServerAccessBook(), + roster, + new SanctionsClient(new HttpClient(panel)), + () => + new PlayerManagementSettings + { + API_DOMAIN = "https://api.example.com", + SERVER_ID = "11111111-1111-1111-1111-111111111111", + SERVER_API_PASSWORD = "secret", + }, + "0.0.9", + "swiftlys2", + _ => { }, + _ => { } + ); + + return (loop, roster, book, panel); + } + + private static ObservedPlayer Human( + string steamId, + string? name = null, + string? ip = "203.0.113.24:27005", + bool verified = true + ) + { + return new ObservedPlayer(steamId, name ?? $"player {steamId}", ip, verified); + } + + private static DateTimeOffset At(double seconds) + { + return Start.AddSeconds(seconds); + } + + // The panel closes every session on [], so a plugin that has not looked yet + // must not claim the server is empty. + [Fact] + public async Task TheRosterIsUnknownUntilTheFirstObservation() + { + var (loop, _, _, panel) = Loop(); + + await loop.Tick(Start); + + Assert.Equal(JsonValueKind.Null, panel.Bodies[0].GetProperty("players").ValueKind); + + loop.Observe([Human("1")], At(1)); + await loop.Tick(At(1)); + Assert.Single(panel.Bodies); + + await loop.Tick(At(1) + PlayerRoster.Debounce); + + Assert.Equal(2, panel.Bodies.Count); + Assert.Equal(["1"], panel.SteamIds(1)); + } + + [Fact] + public async Task ABurstOfChangesIsOneSync() + { + var (loop, _, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + loop.Observe([Human("1"), Human("2")], At(1)); + loop.Observe([Human("1"), Human("2"), Human("3")], At(2)); + await loop.Tick(At(2)); + Assert.Single(panel.Bodies); + + await loop.Tick(At(1) + PlayerRoster.Debounce); + Assert.Equal(["1", "2", "3"], panel.SteamIds(1)); + + loop.Observe([Human("1"), Human("2"), Human("3")], At(3)); + await loop.Tick(At(3) + PlayerRoster.Debounce); + + Assert.Equal(2, panel.Bodies.Count); + } + + [Fact] + public async Task ANameChangeSyncs() + { + var (loop, _, _, panel) = Loop(); + loop.Observe([Human("1", "nyx")], Start); + await loop.Tick(Start); + + loop.Observe([Human("1", "nyx2")], At(1)); + await loop.Tick(At(1) + PlayerRoster.Debounce); + + Assert.Equal("nyx2", panel.Players(1)![0].Name); + } + + // The join's own sync is what gets a banned player kicked; the roster + // change rides along with it rather than waiting out the debounce. + [Fact] + public async Task AJoinIsNotHeldBackByTheDebounce() + { + var (loop, _, book, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + book.Joined("2"); + loop.Request(); + loop.Observe([Human("1"), Human("2")], At(1)); + await loop.Tick(At(1)); + + Assert.Equal(["1", "2"], panel.SteamIds(1)); + + await loop.Tick(At(1) + PlayerRoster.Debounce); + Assert.Equal(2, panel.Bodies.Count); + } + + [Fact] + public async Task OnlyVerifiedPlayersAreOnTheRoster() + { + var (loop, _, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2", verified: false)], Start); + + await loop.Tick(Start); + + Assert.Equal(["1"], panel.SteamIds(0)); + } + + [Fact] + public async Task ASilentServerIsEmptyOnceTheTicksStop() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + roster.Died(null, "1"); + await loop.Tick(Start); + + await loop.Tick(Start + PlayerRoster.Silence); + Assert.Single(panel.Bodies); + + DateTimeOffset silent = Start + PlayerRoster.Silence + TimeSpan.FromSeconds(1); + await loop.Tick(silent); + await loop.Tick(silent + PlayerRoster.Debounce); + + Assert.Equal(2, panel.Bodies.Count); + Assert.Empty(panel.Players(1)!); + Assert.Equal([("1", 1, 0)], panel.Departed(1)); + } + + [Fact] + public async Task AWorldStillUpdatingWithoutTicksIsHibernating() + { + var (loop, roster, _, panel) = Loop(); + roster.WorldUpdated(Start); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + for (int second = 1; second <= 5; second++) + { + roster.WorldUpdated(At(second)); + } + + await loop.Tick(At(5)); + Assert.Single(panel.Bodies); + + roster.WorldUpdated(At(6)); + await loop.Tick(At(6)); + await loop.Tick(At(6) + PlayerRoster.Debounce); + + Assert.Empty(panel.Players(1)!); + Assert.Equal([("1", 0, 0)], panel.Departed(1)); + } + + // SwiftlyS2 hears of a map change only once the new map has loaded, and + // until then nothing ticks or updates at all. + [Fact] + public async Task AWorldThatStopsWithItsTicksIsLoadingAMap() + { + var (loop, roster, _, panel) = Loop(); + roster.WorldUpdated(Start); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + await loop.Tick(Start + SanctionSyncLoop.Interval); + Assert.Equal(["1"], panel.SteamIds(1)); + + DateTimeOffset gone = Start + PlayerRoster.HoldCap + TimeSpan.FromSeconds(1); + await loop.Tick(gone); + + Assert.Empty(panel.Players(2)!); + Assert.Equal([("1", 0, 0)], panel.Departed(2)); + } + + [Fact] + public async Task TheHoldEndsAsSoonAsEveryoneIsBack() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + Dictionary conns = panel.Conns(0); + + roster.MapEnded(At(1)); + await loop.Tick(Start + SanctionSyncLoop.Interval); + + Assert.Null(panel.Players(1)); + Assert.Empty(panel.Departed(1)); + + roster.MapStarted(At(40)); + loop.Observe([Human("1")], At(41)); + loop.Observe([Human("1"), Human("2")], At(50)); + await loop.Tick(At(50) + PlayerRoster.Debounce); + + Assert.Equal(["1", "2"], panel.SteamIds(2)); + Assert.Equal(conns, panel.Conns(2)); + Assert.Empty(panel.Departed(2)); + } + + // The first tick of a new map routinely finds nobody back yet. Settling on + // it dropped everyone still loading and the live roster flickered on every + // map change. + [Fact] + public async Task AnEmptyFirstObservationDropsNobodyStillLoading() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + Dictionary conns = panel.Conns(0); + + roster.MapEnded(At(1)); + roster.MapStarted(At(2)); + + for (int second = 3; second <= 20; second++) + { + loop.Observe([], At(second)); + await loop.Tick(At(second)); + } + + loop.Observe([Human("1")], At(21)); + loop.Observe([Human("1"), Human("2")], At(25)); + await loop.Tick(At(25) + PlayerRoster.Debounce); + Assert.Single(panel.Bodies); + + loop.Observe([Human("1"), Human("2")], Start + SanctionSyncLoop.Interval); + await loop.Tick(Start + SanctionSyncLoop.Interval); + + Assert.Equal(conns, panel.Conns(1)); + Assert.Empty(panel.Departed(1)); + } + + [Fact] + public async Task WhoeverHasNotComeBackWhenTheHoldSettlesHasLeft() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + roster.MapEnded(At(1)); + roster.MapStarted(At(5)); + loop.Observe([Human("1")], At(6)); + + DateTimeOffset settled = At(6) + PlayerRoster.Settle; + loop.Observe([Human("1")], settled - TimeSpan.FromSeconds(1)); + await loop.Tick(settled - TimeSpan.FromSeconds(1)); + Assert.Null(panel.Players(1)); + + loop.Observe([Human("1")], settled); + await loop.Tick(settled + PlayerRoster.Debounce); + + Assert.Equal(["1"], panel.SteamIds(2)); + Assert.Equal([("2", 0, 0)], panel.Departed(2)); + } + + [Fact] + public async Task AHoldThatNeverSettlesEndsAfterTheCap() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + roster.MapEnded(At(1)); + DateTimeOffset capped = At(1) + PlayerRoster.HoldCap; + + for (DateTimeOffset now = At(2); now < capped; now += TimeSpan.FromSeconds(1)) + { + loop.Observe([Human("1"), Human("2")], now); + } + + loop.Observe([Human("1"), Human("2")], capped); + await loop.Tick(capped + PlayerRoster.Debounce); + + Assert.Equal(["1", "2"], panel.SteamIds(panel.Bodies.Count - 1)); + } + + // A server can hibernate straight through a map change, and the ticks that + // would settle the hold never come. + [Fact] + public async Task AHoldTheTicksNeverReturnToEndsEmpty() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + roster.MapEnded(At(1)); + roster.MapStarted(At(2)); + + DateTimeOffset capped = At(1) + PlayerRoster.HoldCap; + await loop.Tick(capped); + await loop.Tick(capped + PlayerRoster.Debounce); + + Assert.Empty(panel.Players(panel.Bodies.Count - 1)!); + Assert.Equal([("1", 0, 0)], panel.Departed(panel.Bodies.Count - 1)); + } + + [Fact] + public async Task HibernatingEmptiesTheRosterWithoutWaitingForSilence() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + roster.Hibernating(At(1)); + await loop.Tick(At(1) + PlayerRoster.Debounce); + + Assert.Empty(panel.Players(1)!); + Assert.Equal([("1", 0, 0)], panel.Departed(1)); + } + + [Fact] + public async Task CountersBelongToTheConnectionAndStartOverOnRejoin() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + roster.Died("2", "1"); + roster.Died("2", "1"); + roster.Died("1", null); + roster.Died(null, "bot"); + await loop.Tick(Start); + + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 2, 1), + new Reported("2", "player 2", "203.0.113.24", 0, 2), + ], + panel.Players(0) + ); + + loop.Observe([Human("2")], At(1)); + loop.Observe([Human("1"), Human("2")], At(2)); + roster.Died("2", "1"); + await loop.Tick(At(1) + PlayerRoster.Debounce); + + Assert.Equal(1, panel.Players(1)![0].Kills); + Assert.Equal([("1", 2, 1)], panel.Departed(1)); + Assert.Equal([panel.Conns(0)["1"]], panel.DepartedConns(1)); + Assert.NotEqual(panel.Conns(0)["1"], panel.Conns(1)["1"]); + Assert.Equal(panel.Conns(0)["2"], panel.Conns(1)["2"]); + } + + [Fact] + public void EveryConnectionGetsItsOwnId() + { + PlayerRoster first = new(); + PlayerRoster reloaded = new(); + first.Observe([Human("1"), Human("2")], Start); + reloaded.Observe([Human("1")], Start); + + List conns = first + .Report(Start) + .Players!.Concat(reloaded.Report(Start).Players!) + .Select(player => player.conn) + .ToList(); + + Assert.Equal(3, conns.Distinct().Count()); + Assert.All(conns, conn => Assert.Matches("^[0-9a-f]{32}$", conn)); + } + + [Fact] + public async Task DeparturesAreKeptUntilASyncSucceeds() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + roster.Died(null, "1"); + loop.Observe([], At(1)); + panel.Answer = () => Task.FromResult(Panel.Down()); + + await loop.Tick(At(1) + PlayerRoster.Debounce); + await loop.Tick(At(1) + PlayerRoster.Debounce + SanctionSyncLoop.Interval); + + Assert.Equal([("1", 1, 0)], panel.Departed(1)); + Assert.Equal([("1", 1, 0)], panel.Departed(2)); + + panel.Answer = () => Task.FromResult(Panel.Ok()); + await loop.Tick(At(1) + PlayerRoster.Debounce + SanctionSyncLoop.Interval * 2); + await loop.Tick(At(1) + PlayerRoster.Debounce + SanctionSyncLoop.Interval * 3); + + Assert.Equal([("1", 1, 0)], panel.Departed(3)); + Assert.Empty(panel.Departed(4)); + } + + [Fact] + public async Task DeparturesThePanelDidNotRecordAreSentAgain() + { + var (loop, _, _, panel) = Loop(); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + loop.Observe([], At(1)); + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: false)); + await loop.Tick(At(1) + PlayerRoster.Debounce); + + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); + await loop.Tick(At(1) + PlayerRoster.Debounce + SanctionSyncLoop.Interval); + await loop.Tick(At(1) + PlayerRoster.Debounce + SanctionSyncLoop.Interval * 2); + + Assert.Equal([("1", 0, 0)], panel.Departed(1)); + Assert.Equal([("1", 0, 0)], panel.Departed(2)); + Assert.Empty(panel.Departed(3)); + } + + [Fact] + public async Task ASyncWithoutARosterDoesNotSettleDepartures() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + loop.Observe([Human("1")], At(1)); + roster.MapEnded(At(2)); + await loop.Tick(At(1) + PlayerRoster.Debounce); + + Assert.Null(panel.Players(1)); + Assert.Equal([("2", 0, 0)], panel.Departed(1)); + + roster.MapStarted(At(3)); + loop.Observe([Human("1")], At(4)); + await loop.Tick(At(4) + PlayerRoster.Debounce); + + DateTimeOffset next = At(4) + PlayerRoster.Debounce + SanctionSyncLoop.Interval; + loop.Observe([Human("1")], next); + await loop.Tick(next); + + Assert.Equal(["1"], panel.SteamIds(2)); + Assert.Equal([("2", 0, 0)], panel.Departed(2)); + Assert.Empty(panel.Departed(3)); + } + + [Fact] + public async Task ADepartureWhileASyncIsInFlightWaitsForTheNext() + { + var (loop, _, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + TaskCompletionSource answer = new(); + panel.Answer = () => answer.Task; + + loop.Observe([Human("2")], At(1)); + Task inFlight = loop.Tick(At(1) + PlayerRoster.Debounce); + + loop.Observe([], At(3)); + answer.SetResult(Panel.Ok()); + await inFlight; + + panel.Answer = () => Task.FromResult(Panel.Ok()); + await loop.Tick(At(3) + PlayerRoster.Debounce); + + Assert.Equal([("1", 0, 0)], panel.Departed(1)); + Assert.Equal([("2", 0, 0)], panel.Departed(2)); + } + + // Each connection settles against its own session, so two of them are + // never folded into one. + [Fact] + public void EachConnectionDepartsOnItsOwn() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + roster.Died(null, "1"); + roster.Observe([], At(1)); + roster.Observe([Human("1")], At(2)); + roster.Died(null, "1"); + roster.Died(null, "1"); + roster.Died("1", null); + roster.Observe([], At(3)); + + RosterReport report = roster.Report(At(4)); + + Assert.Equal( + [("1", 1, 0), ("1", 2, 1)], + report.Departed.Select(departed => (departed.steam_id, departed.kills, departed.deaths)) + ); + Assert.NotEqual(report.Departed[0].conn, report.Departed[1].conn); + } + + [Fact] + public void DeparturesGoOutOldestFirstAndAtMostSixtyFourAtATime() + { + PlayerRoster roster = new(); + List present = Enumerable + .Range(1, 70) + .Select(id => Human(id.ToString())) + .ToList(); + roster.Observe(present, Start); + + for (int left = 1; left <= 70; left++) + { + roster.Observe(present.Skip(left), At(left)); + } + + RosterReport first = roster.Report(At(71)); + + Assert.Equal( + Enumerable.Range(1, PlayerRoster.MaxDepartures).Select(id => id.ToString()), + first.Departed.Select(departed => departed.steam_id) + ); + + roster.Delivered(first, true); + RosterReport second = roster.Report(At(72)); + + Assert.Equal( + Enumerable.Range(65, 6).Select(id => id.ToString()), + second.Departed.Select(departed => departed.steam_id) + ); + } + + [Fact] + public void DeparturesAreDroppedOnceTheyAreTenMinutesOld() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + roster.Observe([], At(1)); + roster.Observe([Human("2")], At(2)); + roster.Observe([], At(3)); + + Assert.Equal(2, roster.Report(At(1) + PlayerRoster.DepartedTtl).Departed.Count); + + RosterReport later = roster.Report(At(2) + PlayerRoster.DepartedTtl); + + Assert.Equal("2", Assert.Single(later.Departed).steam_id); + } + + [Fact] + public async Task ARosterThatFailsToReportDoesNotStopTheSyncs() + { + FailingRoster roster = new() { ReportFailures = 1 }; + var (loop, _, _, panel) = Loop(roster); + + await loop.Tick(Start); + + Assert.Empty(panel.Bodies); + Assert.Equal("roster failed", loop.Status().LastError); + + loop.Request(); + await loop.Tick(At(1)); + + Assert.Single(panel.Bodies); + } + + [Fact] + public async Task ARosterThatFailsToObserveStillLeavesThePlayersToBeAskedAbout() + { + FailingRoster roster = new() { ObserveFails = true }; + var (loop, _, _, panel) = Loop(roster); + + Assert.Throws(() => loop.Observe([Human("1")], Start)); + + await loop.Tick(Start); + + Assert.Equal( + ["1"], + panel + .Bodies[0] + .GetProperty("steam_ids") + .EnumerateArray() + .Select(id => id.GetString()!) + .ToList() + ); + } + + [Fact] + public void ARosterSerializesAsTheApiExpects() + { + PlayerSanctionsRequest unknown = new() { steam_ids = ["1"] }; + PlayerSanctionsRequest empty = new() { players = [] }; + PlayerSanctionsRequest known = new() + { + players = + [ + new RosterPlayer + { + steam_id = "76561198000000001", + conn = "c1", + name = "nyx", + ip = null, + kills = 12, + deaths = 9, + }, + ], + departed = + [ + new DepartedPlayer + { + steam_id = "76561198000000002", + conn = "c2", + kills = 3, + deaths = 4, + }, + ], + }; + + Assert.Contains( + "\"players\":null", + JsonSerializer.Serialize(unknown, SanctionsClient.Json) + ); + Assert.Contains("\"departed\":[]", JsonSerializer.Serialize(unknown, SanctionsClient.Json)); + Assert.Contains("\"players\":[]", JsonSerializer.Serialize(empty, SanctionsClient.Json)); + Assert.Contains( + "\"players\":[{\"steam_id\":\"76561198000000001\",\"conn\":\"c1\",\"name\":\"nyx\",\"ip\":null,\"kills\":12,\"deaths\":9}]", + JsonSerializer.Serialize(known, SanctionsClient.Json) + ); + Assert.Contains( + "\"departed\":[{\"steam_id\":\"76561198000000002\",\"conn\":\"c2\",\"kills\":3,\"deaths\":4}]", + JsonSerializer.Serialize(known, SanctionsClient.Json) + ); + } + + [Theory] + [InlineData("nyx", "nyx")] + [InlineData(" \u0002[red]nyx[/]\u0001 ", "nyx")] + [InlineData("[re[red]d]nyx", "nyx")] + [InlineData(null, "")] + public void NamesLoseTheirChatFormatting(string? raw, string expected) + { + Assert.Equal(expected, PlayerRoster.Name(raw)); + } + + [Fact] + public void NamesAreCappedWithoutSplittingACharacter() + { + Assert.Equal(new string('a', 64), PlayerRoster.Name(new string('a', 80))); + + string name = PlayerRoster.Name(new string('a', 63) + "\U0001F600" + "b"); + + Assert.Equal(new string('a', 63), name); + } + + [Theory] + [InlineData("203.0.113.24:27005", "203.0.113.24")] + [InlineData("203.0.113.24", "203.0.113.24")] + [InlineData("[2001:db8::1]:27015", "2001:db8::1")] + [InlineData("2001:db8::1", "2001:db8::1")] + [InlineData("[::ffff:203.0.113.24]:27005", "203.0.113.24")] + [InlineData("127.0.0.1:27005", null)] + [InlineData("[::1]:27015", null)] + [InlineData("loopback", null)] + [InlineData("0.0.0.0", null)] + [InlineData("", null)] + [InlineData(null, null)] + public void AddressesLoseTheirPort(string? raw, string? expected) + { + Assert.Equal(expected, PlayerRoster.Address(raw)); + } + + [Theory] + [InlineData(1, 2, 2, 3, false, true)] + [InlineData(1, 1, 2, 2, false, false)] + [InlineData(1, 1, 2, 2, true, false)] + [InlineData(1, 2, 2, 2, false, false)] + [InlineData(1, 2, 2, 2, true, true)] + public void AKillIsAnotherPlayerOnAnotherTeam( + int attacker, + int victim, + int attackerTeam, + int victimTeam, + bool teammatesAreEnemies, + bool expected + ) + { + Assert.Equal( + expected, + PlayerRoster.CountsAsKill( + attacker, + victim, + attackerTeam, + victimTeam, + teammatesAreEnemies + ) + ); + } +} diff --git a/apps/player-management-sw/test/SanctionSyncLoopTests.cs b/apps/player-management-sw/test/SanctionSyncLoopTests.cs index 79074860..dd60d61a 100644 --- a/apps/player-management-sw/test/SanctionSyncLoopTests.cs +++ b/apps/player-management-sw/test/SanctionSyncLoopTests.cs @@ -90,6 +90,11 @@ private static HttpResponseMessage Ok(string json) } } + private static ObservedPlayer Human(string steamId, bool verified = true) + { + return new ObservedPlayer(steamId, $"player {steamId}", null, verified); + } + private static PlayerManagementSettings Connected() { return new PlayerManagementSettings @@ -128,6 +133,7 @@ List Warnings SanctionSyncLoop loop = new( book, access, + new PlayerRoster(), new SanctionsClient(new HttpClient(panel)), settings ?? Connected, "0.0.9", @@ -181,7 +187,7 @@ public async Task ARequestSyncsWithoutWaitingTheInterval() public async Task ItAsksAboutThePlayersPresentAndThoseJoining() { var (loop, book, panel, _) = Loop(); - loop.Observe(["1", "2", "1"]); + loop.Observe([Human("1"), Human("2"), Human("1")], Start); book.Joined("3"); await loop.Tick(Start); @@ -189,6 +195,19 @@ public async Task ItAsksAboutThePlayersPresentAndThoseJoining() Assert.Equal(["1", "2", "3"], panel.Asked[0]); } + // A ban has to reach a player before Steam has verified them, so the + // roster's verified-only rule must not narrow who is asked about. + [Fact] + public async Task AnUnverifiedPlayerIsStillAskedAbout() + { + var (loop, _, panel, _) = Loop(); + loop.Observe([Human("1"), Human("2", verified: false)], Start); + + await loop.Tick(Start); + + Assert.Equal(["1", "2"], panel.Asked[0]); + } + // The bug this guards: a player banned and kicked here keeps that ban in // the cache, is unbanned while away, and must not be kicked again on the // way back in by the stale copy. @@ -308,7 +327,7 @@ public async Task TheSameAccessVersionIsNotFetchedAgain() public async Task TheDeniedPlayersSurfaceToThePlugin() { var (loop, _, access, panel, _) = WithAccess(); - loop.Observe(["1", "2"]); + loop.Observe([Human("1"), Human("2")], Start); panel.Answer = _ => Task.FromResult(Panel.Sanctions(access: Panel.Access("v1", "2"))); await loop.Tick(Start); diff --git a/apps/player-management-sw/test/SanctionsClientTests.cs b/apps/player-management-sw/test/SanctionsClientTests.cs index 2ddeda8d..feacadfe 100644 --- a/apps/player-management-sw/test/SanctionsClientTests.cs +++ b/apps/player-management-sw/test/SanctionsClientTests.cs @@ -59,6 +59,22 @@ private static PlayerSanctionsRequest Request() }; } + [Theory] + [InlineData("{\"sanctions\":[],\"roster_recorded\":true}", true)] + [InlineData("{\"sanctions\":[],\"roster_recorded\":false}", false)] + [InlineData("{\"sanctions\":[]}", null)] + public async Task ItReadsWhetherThePanelRecordedTheRoster(string answer, bool? recorded) + { + StubHandler handler = new(_ => Json(HttpStatusCode.OK, answer)); + + SanctionSync result = await new SanctionsClient(new HttpClient(handler)).Sync( + Settings, + Request() + ); + + Assert.Equal(recorded, result.RosterRecorded); + } + [Fact] public async Task ItPostsThePlayersToTheServersRouteWithTheApiPassword() { diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs index d68b079d..739818d7 100644 --- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs @@ -13,6 +13,31 @@ public class PlayerSanctionsRequest public List steam_ids { get; set; } = new(); public string plugin_version { get; set; } = ""; public string plugin_runtime { get; set; } = ""; + + // Null, not empty, while the roster is unknown or a map change is under + // way: the panel leaves its sessions alone for null and closes them for []. + public List? players { get; set; } + public List departed { get; set; } = new(); +} + +// Kills and deaths are running totals for the connection named by conn, which +// is new each time a player joins, so the panel never has to guess at one. +public class RosterPlayer +{ + public string steam_id { get; set; } = ""; + public string conn { get; set; } = ""; + public string name { get; set; } = ""; + public string? ip { get; set; } + public int kills { get; set; } + public int deaths { get; set; } +} + +public class DepartedPlayer +{ + public string steam_id { get; set; } = ""; + public string conn { get; set; } = ""; + public int kills { get; set; } + public int deaths { get; set; } } public class PlayerSanctionsResponse @@ -21,4 +46,7 @@ public class PlayerSanctionsResponse // Absent from panels that predate access lists. public ServerAccessSync? access { get; set; } + + // Absent from panels that predate the roster. + public bool? roster_recorded { get; set; } } diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs new file mode 100644 index 00000000..f90887c2 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -0,0 +1,411 @@ +using System.Net; +using FiveStack.Entities.PlayerManagement; + +namespace FiveStack.Utilities; + +public sealed record ObservedPlayer(string SteamId, string Name, string? Ip, bool Verified); + +public sealed record RosterReport(List? Players, List Departed); + +// Who is on the server, as the game thread last saw it, never as the connect +// and disconnect hooks tell it: a changelevel puts every client through a +// reconnect, and a disconnect hook can still list the leaving player or fail +// to resolve them at all. +public class PlayerRoster +{ + public static readonly TimeSpan Debounce = TimeSpan.FromSeconds(1.5); + + // A hibernating server stops ticking, and it only hibernates once it is + // empty, so a roster that has not been observed for this long is empty. + public static readonly TimeSpan Silence = TimeSpan.FromSeconds(5); + + // Clients drift back in over the first minute of a new map, and a player + // still loading would otherwise be reported as having left. + public static readonly TimeSpan Settle = TimeSpan.FromSeconds(45); + + public static readonly TimeSpan HoldCap = TimeSpan.FromMinutes(3); + + public static readonly TimeSpan DepartedTtl = TimeSpan.FromMinutes(10); + + public const int MaxDepartures = 64; + + public const int MaxNameLength = 64; + + private enum eRosterState + { + Unknown, + Hold, + Known, + } + + private sealed class Entry + { + public readonly string Conn = Guid.NewGuid().ToString("N"); + public string Name = ""; + public string? Ip; + public int Kills; + public int Deaths; + } + + private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); + + private readonly object _lock = new(); + private readonly Dictionary _current = new(); + private readonly List _departed = new(); + private Dictionary _latest = new(); + private HashSet _held = new(); + private Dictionary? _sent; + private eRosterState _state = eRosterState.Unknown; + private DateTimeOffset? _observedAt; + private DateTimeOffset? _worldAt; + private DateTimeOffset? _dueAt; + private DateTimeOffset _holdSince; + private bool _mapStarted; + private DateTimeOffset? _releaseAt; + + // Returns when a sync is due if this observation is what made one due. + public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) + { + Dictionary verified = new(); + + foreach (ObservedPlayer player in players) + { + if (player.Verified) + { + verified.TryAdd(player.SteamId, player); + } + } + + lock (_lock) + { + DateTimeOffset? before = _dueAt; + + _observedAt = now; + _latest = verified; + + if (_state == eRosterState.Hold) + { + if (_mapStarted) + { + _releaseAt ??= now + Settle; + } + + Admit(); + } + else + { + _state = eRosterState.Known; + Reconcile(now); + } + + Advance(now); + + return before == null ? _dueAt : null; + } + } + + // Ticks stop both while a server hibernates and while it loads a map, and + // SwiftlyS2 only announces a map change once the load is over. A world that + // keeps updating without ticking is hibernating; one that has stopped as + // well is loading. + public void WorldUpdated(DateTimeOffset now) + { + lock (_lock) + { + _worldAt = now; + } + } + + public void MapEnded(DateTimeOffset now) + { + lock (_lock) + { + Hold(now); + _mapStarted = false; + _releaseAt = null; + } + } + + public void MapStarted(DateTimeOffset now) + { + lock (_lock) + { + Hold(now); + _mapStarted = true; + _releaseAt = null; + } + } + + // Trusted even through a hold: the server only hibernates once nobody is + // connected, and the ticks that would settle the hold have stopped. + public void Hibernating(DateTimeOffset now) + { + lock (_lock) + { + _state = eRosterState.Known; + _latest = new(); + Reconcile(now); + Advance(now); + } + } + + // Only players on the roster are counted, which keeps out bots and any + // claimed id Steam has not verified. + public void Died(string? victim, string? killer) + { + lock (_lock) + { + if (victim != null && _current.TryGetValue(victim, out Entry? dead)) + { + dead.Deaths++; + } + + if (killer != null && _current.TryGetValue(killer, out Entry? scorer)) + { + scorer.Kills++; + } + } + } + + public DateTimeOffset? DueAt(DateTimeOffset now) + { + lock (_lock) + { + Advance(now); + + return _dueAt; + } + } + + public virtual RosterReport Report(DateTimeOffset now) + { + lock (_lock) + { + Advance(now); + + List? players = + _state == eRosterState.Known + ? _current + .Select(entry => new RosterPlayer + { + steam_id = entry.Key, + conn = entry.Value.Conn, + name = entry.Value.Name, + ip = entry.Value.Ip, + kills = entry.Value.Kills, + deaths = entry.Value.Deaths, + }) + .ToList() + : null; + + _sent = players?.ToDictionary(player => player.conn, player => player.name); + _dueAt = null; + + List departed = _departed + .Take(MaxDepartures) + .Select(departure => new DepartedPlayer + { + steam_id = departure.Player.steam_id, + conn = departure.Player.conn, + kills = departure.Player.kills, + deaths = departure.Player.deaths, + }) + .ToList(); + + return new RosterReport(players, departed); + } + } + + // A panel told the roster is unknown may ignore the departures that came + // with it, and one that could not record them says so. A panel that says + // nothing predates the roster and has taken them. + public void Delivered(RosterReport report, bool? recorded) + { + if (report.Players == null || recorded == false || report.Departed.Count == 0) + { + return; + } + + HashSet conns = report.Departed.Select(departure => departure.conn).ToHashSet(); + + lock (_lock) + { + _departed.RemoveAll(departure => conns.Contains(departure.Player.conn)); + } + } + + public static string Name(string? raw) + { + string name = ChatUtility.StripFormatting(raw ?? "").Trim(); + + if (name.Length <= MaxNameLength) + { + return name; + } + + int cut = char.IsHighSurrogate(name[MaxNameLength - 1]) ? MaxNameLength - 1 : MaxNameLength; + + return name[..cut].TrimEnd(); + } + + public static string? Address(string? raw) + { + if ( + string.IsNullOrWhiteSpace(raw) + || !IPEndPoint.TryParse(raw.Trim(), out IPEndPoint? endpoint) + ) + { + return null; + } + + IPAddress address = endpoint.Address.IsIPv4MappedToIPv6 + ? endpoint.Address.MapToIPv4() + : endpoint.Address; + + if ( + IPAddress.IsLoopback(address) + || address.Equals(IPAddress.Any) + || address.Equals(IPAddress.IPv6Any) + ) + { + return null; + } + + return address.ToString(); + } + + // Identified by slot, not Steam id: an unverified victim can claim the + // attacker's id. + public static bool CountsAsKill( + int attackerSlot, + int victimSlot, + int attackerTeam, + int victimTeam, + bool teammatesAreEnemies + ) + { + return attackerSlot != victimSlot && (teammatesAreEnemies || attackerTeam != victimTeam); + } + + private void Hold(DateTimeOffset now) + { + if (_state != eRosterState.Hold) + { + _state = eRosterState.Hold; + _holdSince = now; + _held = _current.Keys.ToHashSet(); + } + } + + private void Advance(DateTimeOffset now) + { + _departed.RemoveAll(departure => now - departure.At > DepartedTtl); + + if (_state == eRosterState.Hold && Settled(now)) + { + _state = eRosterState.Known; + Reconcile(now); + } + + if (_state != eRosterState.Known) + { + return; + } + + if (_current.Count > 0 && Silent(now)) + { + _latest = new(); + Reconcile(now); + } + + if (!Matches()) + { + _dueAt ??= now + Debounce; + } + } + + // Only an observation from the new map counts: everyone held was on the + // old one. + private bool Settled(DateTimeOffset now) + { + if (now - _holdSince >= HoldCap) + { + return true; + } + + return _releaseAt != null && (now >= _releaseAt || _held.All(_latest.ContainsKey)); + } + + private bool Silent(DateTimeOffset now) + { + if (_observedAt == null) + { + return true; + } + + if (_worldAt == null) + { + return now - _observedAt > Silence; + } + + return now - _observedAt > HoldCap || _worldAt - _observedAt > Silence; + } + + private void Reconcile(DateTimeOffset now) + { + foreach (string gone in _current.Keys.Where(id => !_latest.ContainsKey(id)).ToList()) + { + Entry entry = _current[gone]; + _current.Remove(gone); + _departed.Add( + new Departure( + new DepartedPlayer + { + steam_id = gone, + conn = entry.Conn, + kills = entry.Kills, + deaths = entry.Deaths, + }, + now + ) + ); + } + + Admit(); + } + + // Through a hold players are only ever added: whoever has not made it back + // yet is still loading the new map, not gone. + private void Admit() + { + foreach (ObservedPlayer player in _latest.Values) + { + if (!_current.TryGetValue(player.SteamId, out Entry? entry)) + { + entry = new Entry(); + _current[player.SteamId] = entry; + } + + entry.Name = Name(player.Name); + entry.Ip = Address(player.Ip) ?? entry.Ip; + } + } + + private bool Matches() + { + if (_sent == null || _sent.Count != _current.Count) + { + return false; + } + + foreach (Entry entry in _current.Values) + { + if (!_sent.TryGetValue(entry.Conn, out string? name) || name != entry.Name) + { + return false; + } + } + + return true; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs index 1ea36cc3..d2d6d8ef 100644 --- a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs +++ b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs @@ -14,6 +14,7 @@ public sealed class SanctionSyncLoop : IDisposable private readonly SanctionBook _book; private readonly ServerAccessBook _access; + private readonly PlayerRoster _roster; private readonly SanctionsClient _client; private readonly Func _settings; private readonly string _version; @@ -33,6 +34,7 @@ public sealed class SanctionSyncLoop : IDisposable public SanctionSyncLoop( SanctionBook book, ServerAccessBook access, + PlayerRoster roster, SanctionsClient client, Func settings, string version, @@ -43,6 +45,7 @@ Action info { _book = book; _access = access; + _roster = roster; _client = client; _settings = settings; _version = version; @@ -64,14 +67,34 @@ public void Dispose() } // Only the game thread can read the player list, so it hands it over here. - public void Observe(IEnumerable present) + // Every human is asked about, verified or not, and before the roster sees + // them, so a roster that fails never costs a ban check. + public void Observe(IEnumerable players, DateTimeOffset now) { - List snapshot = present.Distinct().ToList(); + List observed = players.ToList(); + List snapshot = observed.Select(player => player.SteamId).Distinct().ToList(); lock (_lock) { _present = snapshot; } + + DateTimeOffset? due = _roster.Observe(observed, now); + + if (due == null) + { + return; + } + + lock (_lock) + { + // Never pushed back past a sync already asked for: that one is what + // gets a joining player's ban enforced. + if (!_requested) + { + Wake(due.Value - now); + } + } } // A sync already in flight predates whatever asked for this one, so the @@ -81,15 +104,7 @@ public void Request() lock (_lock) { _requested = true; - } - - try - { - _timer?.Change(TimeSpan.Zero, Poll); - } - catch (ObjectDisposedException) - { - // Unloading. + Wake(TimeSpan.Zero); } } @@ -107,7 +122,12 @@ public async Task Tick(DateTimeOffset now) lock (_lock) { - bool due = _requested || _startedAt == null || now - _startedAt >= Interval; + DateTimeOffset? rosterDue = _roster.DueAt(now); + bool due = + _requested + || _startedAt == null + || now - _startedAt >= Interval + || (rosterDue != null && now >= rosterDue); if (_syncing || !due) { @@ -122,6 +142,7 @@ public async Task Tick(DateTimeOffset now) try { + RosterReport roster = _roster.Report(now); PlayerManagementSettings settings = _settings(); if (!settings.IsConnected()) @@ -137,6 +158,8 @@ public async Task Tick(DateTimeOffset now) steam_ids = queried, plugin_version = _version, plugin_runtime = _runtime, + players = roster.Players, + departed = roster.Departed, } ); @@ -146,6 +169,8 @@ public async Task Tick(DateTimeOffset now) return; } + _roster.Delivered(roster, result.RosterRecorded); + // Ahead of the sanctions: recording those ends the joining players' // wait, and their access has to be known by then. if (result.Access != null) @@ -232,6 +257,18 @@ private async Task RefreshAccess(PlayerManagementSettings settings, ServerAccess } } + private void Wake(TimeSpan after) + { + try + { + _timer?.Change(after < TimeSpan.Zero ? TimeSpan.Zero : after, Poll); + } + catch (ObjectDisposedException) + { + // Unloading. + } + } + private void Failed(List queried, string error) { _book.Unanswered(queried); diff --git a/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs b/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs index 6d8c61a4..b4b66ff6 100644 --- a/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs +++ b/shared/dotnet/FiveStack.Utilities/SanctionsClient.cs @@ -10,7 +10,8 @@ namespace FiveStack.Utilities; public sealed record SanctionSync( List? Sanctions, string? Error, - ServerAccessSync? Access = null + ServerAccessSync? Access = null, + bool? RosterRecorded = null ); public sealed record ServerAccessFetch(ServerAccessList? List, string? Error); @@ -67,7 +68,8 @@ PlayerSanctionsRequest body return new SanctionSync( parsed?.sanctions ?? new List(), null, - parsed?.access + parsed?.access, + parsed?.roster_recorded ); } catch (Exception error)