From ad015074c11bdafef081d04a9fb3c55fb0803367 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 3 Nov 2026 13:27:51 -0500 Subject: [PATCH] feat: an idle player on a practice server is warned, warned again and then kicked, so nobody can hold one by walking away --- .../utility-sw/src/Events/PracticeAfkWatch.cs | 200 +++++++ apps/utility-sw/src/Hud/PracticeHud.cs | 2 + apps/utility-sw/src/Hud/PracticeKeys.cs | 2 + apps/utility-sw/src/UtilityPractice.csproj | 1 + apps/utility-sw/src/UtilityPracticePlugin.cs | 10 + apps/utility-sw/test/FiveStack.Tests.csproj | 1 + apps/utility-sw/test/PracticeAfkTests.cs | 565 ++++++++++++++++++ .../utility-sw/test/PracticeAfkWiringTests.cs | 89 +++ .../Practice/PracticeSessionData.cs | 3 + .../Practice/UtilitySessionRow.cs | 2 + .../dotnet/FiveStack.Utilities/PracticeAfk.cs | 275 +++++++++ 11 files changed, 1150 insertions(+) create mode 100644 apps/utility-sw/src/Events/PracticeAfkWatch.cs create mode 100644 apps/utility-sw/test/PracticeAfkTests.cs create mode 100644 apps/utility-sw/test/PracticeAfkWiringTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeAfk.cs diff --git a/apps/utility-sw/src/Events/PracticeAfkWatch.cs b/apps/utility-sw/src/Events/PracticeAfkWatch.cs new file mode 100644 index 00000000..f4b6ffbb --- /dev/null +++ b/apps/utility-sw/src/Events/PracticeAfkWatch.cs @@ -0,0 +1,200 @@ +using System.Collections.Concurrent; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared.Events; +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.ProtobufDefinitions; +using SwiftlyS2.Shared.SchemaDefinitions; +using static SwiftlyS2.Shared.Helper; + +namespace UtilityPractice; + +public partial class UtilityPracticePlugin +{ + private readonly PracticeAfk _afk = new(); + + // Player slots with any input since the last sweep. Taps are shorter than + // the one-second sample, so the input paths mark them as they happen. + private readonly ConcurrentDictionary _afkStirred = new(); + + private Guid? _afkChatHook; + private EventDelegates.OnClientVoice? _voiceHandler; + + private static int AfkNow => (int)(Environment.TickCount64 / 1000); + + private void Stir(int playerId) + { + _afkStirred[playerId] = 0; + } + + private void WireAfk() + { + _afkChatHook = Core.Command.HookClientChat( + (playerId, _, _) => + { + Stir(playerId); + + return HookResult.Continue; + } + ); + + _voiceHandler = @event => Stir(@event.PlayerId); + Core.Event.OnClientVoice += _voiceHandler; + } + + private void UnwireAfk() + { + if (_afkChatHook is { } hook) + { + Core.Command.UnhookClientChat(hook); + _afkChatHook = null; + } + + if (_voiceHandler != null) + { + Core.Event.OnClientVoice -= _voiceHandler; + _voiceHandler = null; + } + + _afkStirred.Clear(); + _afk.Reset(); + } + + private void AfkWatch() + { + if (_config.RenderMode) + { + return; + } + + var samples = new List(); + var players = new Dictionary(); + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + players[player.SteamID] = player; + samples.Add(AfkSample(player)); + } + + _afkStirred.Clear(); + + int minutes = _session.Current?.afk_kick_minutes ?? 0; + + PracticeAfk.Dispatch( + _afk.Sweep(samples, AfkNow, minutes), + due => + { + if (!players.TryGetValue(due.SteamId, out IPlayer? player)) + { + return; + } + + if (due.Step == PracticeAfk.Step.Kick) + { + AfkKick(player, due.IdleSeconds); + + return; + } + + AfkWarn(player, PracticeAfk.Warning(due.Step, due.SecondsLeft)); + }, + (due, error) => + _logger.LogWarning(error, "unable to act on {step} for {steam}", due.Step, due.SteamId) + ); + } + + // A dead or spectating player's own pawn is frozen, held buttons and all, + // so they are judged by the observer pawn they look through and by what + // they press, say or type. A player who cannot be read is taken as present + // rather than kicked on a fault. + private PracticeAfk.Sample AfkSample(IPlayer player) + { + try + { + bool inGame = player.Controller.Connected == PlayerConnectedState.Connected; + bool alive = player.IsAlive; + bool stirred = _afkStirred.ContainsKey(player.PlayerID); + ulong buttons = alive ? (ulong)player.PressedButtons : 0; + + if (!alive) + { + CBasePlayerPawn? observer = player.Pawn; + + if (observer == null || !observer.IsValid) + { + return new PracticeAfk.Sample(player.SteamID, inGame, Alive: alive, stirred, 0, null, null); + } + + bool roaming = + observer.ObserverServices?.ObserverMode == (byte)ObserverMode_t.OBS_MODE_ROAMING; + QAngle view = observer.V_angle; + Vector seat = observer.AbsOrigin ?? new Vector(0, 0, 0); + + return new PracticeAfk.Sample( + player.SteamID, + inGame, + Alive: alive, + stirred, + 0, + (view.Y, view.X), + (seat.X, seat.Y, seat.Z), + Roaming: roaming + ); + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return new PracticeAfk.Sample(player.SteamID, inGame, Alive: alive, stirred, buttons, null, null); + } + + QAngle eyes = pawn.EyeAngles; + Vector at = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + return new PracticeAfk.Sample( + player.SteamID, + inGame, + Alive: alive, + stirred, + buttons, + (eyes.Y, eyes.X), + (at.X, at.Y, at.Z) + ); + } + catch + { + return new PracticeAfk.Sample(player.SteamID, false, false, true, 0, null, null); + } + } + + private void AfkWarn(IPlayer player, string text) + { + Tell(player.SteamID, $" {ChatColors.Red}{text}"); + + if (UseHud(player.SteamID) && _hud!.Available(HudSlots.Bar)) + { + Toast(player, text); + } + } + + private void AfkKick(IPlayer player, int idleSeconds) + { + ulong steamId = player.SteamID; + + _afk.Forget(steamId); + _logger.LogInformation("kicking {steam}: idle for {seconds} seconds", steamId, idleSeconds); + + player.Kick( + PracticeAfk.KickReason(idleSeconds), + ENetworkDisconnectionReason.NETWORK_DISCONNECT_KICKED_IDLE + ); + } +} diff --git a/apps/utility-sw/src/Hud/PracticeHud.cs b/apps/utility-sw/src/Hud/PracticeHud.cs index 57688244..737a169b 100644 --- a/apps/utility-sw/src/Hud/PracticeHud.cs +++ b/apps/utility-sw/src/Hud/PracticeHud.cs @@ -217,6 +217,8 @@ bool onAngle private void OnHudClicked(IPlayer player, string layout, string button) { + Stir(player.PlayerID); + if (layout == HudSlots.NadeEdit) { OnEditClicked(player, button); diff --git a/apps/utility-sw/src/Hud/PracticeKeys.cs b/apps/utility-sw/src/Hud/PracticeKeys.cs index 68ab5de4..71771f06 100644 --- a/apps/utility-sw/src/Hud/PracticeKeys.cs +++ b/apps/utility-sw/src/Hud/PracticeKeys.cs @@ -179,6 +179,8 @@ private void UnwireKeys() private void OnKey(IOnClientKeyStateChangedEvent @event) { + Stir(@event.PlayerId); + KeyKind key = @event.Key; if (key is not (KeyKind.Tab or KeyKind.R or KeyKind.Ctrl or KeyKind.Shift or KeyKind.F)) diff --git a/apps/utility-sw/src/UtilityPractice.csproj b/apps/utility-sw/src/UtilityPractice.csproj index 0afc8d86..7ede724c 100644 --- a/apps/utility-sw/src/UtilityPractice.csproj +++ b/apps/utility-sw/src/UtilityPractice.csproj @@ -59,6 +59,7 @@ + diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index f61e5f9f..8d3c14d3 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -203,6 +203,7 @@ public override void Load(bool hotReload) _tickHandler = OnGameTick; Core.Event.OnTick += _tickHandler; WireKeys(); + WireAfk(); if (_hud != null) { @@ -480,6 +481,7 @@ private void TearDown() _prompt?.Stop(); UnwireKeys(); + UnwireAfk(); if (_tickHandler != null) { @@ -1040,6 +1042,11 @@ private void UseWatch() continue; } + if (buttons != 0 && player.IsAlive) + { + Stir(player.PlayerID); + } + bool down = (buttons & InUse) != 0; // The edge, not the state: holding use must not teleport once a @@ -1932,6 +1939,7 @@ private void OnSecond() DrainLibraryLoads(); DrainPendingMapLoad(); KeepWorldsCurrent(); + AfkWatch(); _ = _api.Second(); } @@ -2144,6 +2152,7 @@ private void OnPlayerGone(ulong steamId) SendHeldSaves(steamId); _drill.Forget(steamId); _system.Forget(steamId); + _afk.Forget(steamId); _replay.ForgetPlayer(steamId); _shown.Remove(steamId); @@ -2223,6 +2232,7 @@ private void OnMapLoad(string mapName) // its thrower's pawn went with the map. _director.Reset(null); _hud?.Reset(); + _afk.Reset(); _menus.Clear(); _shown.Clear(); _standingIn.Clear(); diff --git a/apps/utility-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj index db928309..3733bb62 100644 --- a/apps/utility-sw/test/FiveStack.Tests.csproj +++ b/apps/utility-sw/test/FiveStack.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/apps/utility-sw/test/PracticeAfkTests.cs b/apps/utility-sw/test/PracticeAfkTests.cs new file mode 100644 index 00000000..b3da8b4e --- /dev/null +++ b/apps/utility-sw/test/PracticeAfkTests.cs @@ -0,0 +1,565 @@ +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +public class PracticeAfkTests +{ + private const ulong Idle = 76561198000000001; + private const ulong Busy = 76561198000000002; + + private static PracticeAfk.Sample Still(ulong steamId) + { + return new PracticeAfk.Sample(steamId, true, true, false, 0, (90f, 10f), (100f, 200f, 0f)); + } + + private static PracticeAfk.Sample Pressing(ulong steamId) + { + return Still(steamId) with { Stirred = true }; + } + + private static PracticeAfk.Sample Spectating(ulong steamId) + { + return Still(steamId) with { Alive = false }; + } + + private static List<(ulong SteamId, PracticeAfk.Step Step, int At)> Run( + PracticeAfk afk, + int from, + int to, + int minutes, + Func> players + ) + { + var due = new List<(ulong, PracticeAfk.Step, int)>(); + + for (int now = from; now <= to; now++) + { + foreach (PracticeAfk.Due step in afk.Sweep(players(now), now, minutes)) + { + due.Add((step.SteamId, step.Step, now)); + } + } + + return due; + } + + private static IEnumerable<(PracticeAfk.Step, int)> Steps( + List<(ulong SteamId, PracticeAfk.Step Step, int At)> due + ) + { + return due.Select(step => (step.Step, step.At)); + } + + [Fact] + public void WarnsThenWarnsAgainThenKicksAnIdlePlayer() + { + var afk = new PracticeAfk(); + + var due = Run(afk, 0, 3600, 15, _ => new[] { Still(Idle) }); + + Assert.Equal( + new[] + { + (PracticeAfk.Step.Warn, 600), + (PracticeAfk.Step.FinalWarn, 840), + (PracticeAfk.Step.Kick, 900), + }, + Steps(due) + ); + } + + [Fact] + public void TheWarningsSayHowLongIsLeft() + { + var afk = new PracticeAfk(); + var said = new List(); + + for (int now = 0; now <= 900; now++) + { + said.AddRange(afk.Sweep(new[] { Still(Idle) }, now, 15)); + } + + Assert.Equal(300, said[0].SecondsLeft); + Assert.Equal(60, said[1].SecondsLeft); + Assert.Contains("in 5 minutes", PracticeAfk.Warning(said[0].Step, said[0].SecondsLeft)); + Assert.Contains("in 1 minute.", PracticeAfk.Warning(said[1].Step, said[1].SecondsLeft)); + Assert.StartsWith("Final warning", PracticeAfk.Warning(said[1].Step, said[1].SecondsLeft)); + Assert.Contains("30 seconds", PracticeAfk.Warning(PracticeAfk.Step.Warn, 30)); + Assert.Contains("15 minutes", PracticeAfk.KickReason(900)); + } + + [Fact] + public void PressingAnythingStartsTheClockOver() + { + var afk = new PracticeAfk(); + + var due = Run( + afk, + 0, + 1500, + 15, + now => new[] { now == 700 ? Pressing(Idle) : Still(Idle) } + ); + + Assert.Equal( + new[] { (PracticeAfk.Step.Warn, 600), (PracticeAfk.Step.Warn, 1300) }, + Steps(due) + ); + } + + [Fact] + public void LookingAroundCountsButAimJitterDoesNot() + { + var jittering = new PracticeAfk(); + + var jitter = Run( + jittering, + 0, + 900, + 15, + now => new[] { Still(Idle) with { View = (90f + (now % 2) * 0.5f, 10f) } } + ); + + Assert.Contains(jitter, step => step.Step == PracticeAfk.Step.Kick); + + var looking = new PracticeAfk(); + + var aimed = Run( + looking, + 0, + 3600, + 15, + now => new[] { Still(Idle) with { View = (90f + (now / 300) * 2f, 10f) } } + ); + + Assert.Empty(aimed); + } + + [Fact] + public void LookingAcrossTheSeamCounts() + { + Assert.True(PracticeAfk.Looked((359.5f, 0f), (1.5f, 0f))); + Assert.False(PracticeAfk.Looked((359.8f, 0f), (0.2f, 0f))); + Assert.True(PracticeAfk.Looked((10f, 0f), (10f, 1.5f))); + Assert.False(PracticeAfk.Looked(null, (10f, 0f))); + } + + [Fact] + public void WalkingCountsButSettlingDoesNot() + { + Assert.True(PracticeAfk.Moved((0f, 0f, 0f), (5f, 0f, 0f))); + Assert.False(PracticeAfk.Moved((0f, 0f, 0f), (2f, 2f, 0f))); + Assert.False(PracticeAfk.Moved((0f, 0f, 0f), null)); + + var afk = new PracticeAfk(); + + var walking = Run( + afk, + 0, + 3600, + 15, + now => new[] { Still(Idle) with { At = (100f + (now / 300) * 10f, 200f, 0f) } } + ); + + Assert.Empty(walking); + } + + [Fact] + public void ZeroMinutesIsOff() + { + var afk = new PracticeAfk(); + + Assert.Empty(Run(afk, 0, 7200, 0, _ => new[] { Still(Idle) })); + } + + [Fact] + public void APlayerStillLoadingIsNotOnTheClock() + { + var afk = new PracticeAfk(); + + var due = Run( + afk, + 0, + 1200, + 15, + now => new[] { Still(Idle) with { InGame = now >= 500 } } + ); + + Assert.Equal((PracticeAfk.Step.Warn, 1099), Steps(due).First()); + } + + [Fact] + public void EveryPlayerHasTheirOwnClock() + { + var afk = new PracticeAfk(); + + var due = Run( + afk, + 0, + 1800, + 15, + now => new[] { Still(Idle), now % 60 == 0 ? Pressing(Busy) : Still(Busy) } + ); + + Assert.All(due, step => Assert.Equal(Idle, step.SteamId)); + Assert.Contains(due, step => step.Step == PracticeAfk.Step.Kick); + } + + [Fact] + public void APlayerSeenForTheFirstTimeIsNotJudgedOnTimeNobodyWatched() + { + var afk = new PracticeAfk(); + + Assert.Empty(afk.Sweep(new[] { Still(Idle) }, 100_000, 15)); + Assert.Empty(afk.Sweep(new[] { Still(Idle) }, 100_001, 15)); + } + + [Fact] + public void ALeaverComesBackToAFreshClock() + { + var afk = new PracticeAfk(); + + Run(afk, 0, 850, 15, _ => new[] { Still(Idle) }); + afk.Sweep(Array.Empty(), 851, 15); + + var back = Run(afk, 852, 1500, 15, _ => new[] { Still(Idle) }); + + Assert.Equal(new[] { (PracticeAfk.Step.Warn, 1452) }, Steps(back)); + } + + [Fact] + public void ForgettingEveryoneStartsEveryClockOver() + { + var afk = new PracticeAfk(); + + Run(afk, 0, 800, 15, _ => new[] { Still(Idle) }); + afk.Reset(); + + var due = Run(afk, 801, 1500, 15, _ => new[] { Still(Idle) }); + + Assert.Equal((PracticeAfk.Step.Warn, 1401), Steps(due).First()); + } + + [Fact] + public void TurningItOnJudgesNobodyOnTheTimeItWasOff() + { + var afk = new PracticeAfk(); + + Run(afk, 0, 1000, 0, _ => new[] { Still(Idle) }); + + var due = Run(afk, 1001, 2000, 1, _ => new[] { Still(Idle) }); + + Assert.Equal( + new[] + { + (PracticeAfk.Step.Warn, 1031), + (PracticeAfk.Step.FinalWarn, 1046), + (PracticeAfk.Step.Kick, 1061), + }, + Steps(due) + ); + } + + // A shorter limit still warns first, for that limit's full lead. + [Fact] + public void ShorteningTheLimitStillWarnsBeforeTheKick() + { + var afk = new PracticeAfk(); + + Assert.Empty(Run(afk, 0, 800, 60, _ => new[] { Still(Idle) })); + + var due = Run(afk, 801, 2000, 1, _ => new[] { Still(Idle) }); + + Assert.Equal( + new[] + { + (PracticeAfk.Step.Warn, 801), + (PracticeAfk.Step.FinalWarn, 816), + (PracticeAfk.Step.Kick, 831), + }, + Steps(due) + ); + } + + // A warning is a promise of how long is left, so a change of setting after + // it was given never brings the kick forward. + [Fact] + public void AWarningAlreadyGivenKeepsItsKickTime() + { + var afk = new PracticeAfk(); + + Run(afk, 0, 700, 15, _ => new[] { Still(Idle) }); + + var due = Run(afk, 701, 2000, 1, _ => new[] { Still(Idle) }); + + Assert.Equal( + new[] { (PracticeAfk.Step.FinalWarn, 885), (PracticeAfk.Step.Kick, 900) }, + Steps(due) + ); + } + + [Theory] + [InlineData(1, 30, 15)] + [InlineData(3, 60, 30)] + [InlineData(5, 120, 60)] + [InlineData(10, 300, 60)] + [InlineData(15, 300, 60)] + [InlineData(60, 300, 60)] + public void TheLeadsFitTheSetting(int minutes, int warn, int final) + { + Assert.Equal((warn, final), PracticeAfk.Leads(minutes * 60)); + } + + [Fact] + public void HoldingAKeyCountsForAPlayerWhoIsAlive() + { + var afk = new PracticeAfk(); + + Assert.Empty(Run(afk, 0, 3600, 15, _ => new[] { Still(Idle) with { Buttons = 8 } })); + } + + // A pawn that died with a key down keeps reporting it. + [Fact] + public void ADeadPawnsFrozenButtonsDoNotCount() + { + var afk = new PracticeAfk(); + + var due = Run(afk, 0, 900, 15, _ => new[] { Spectating(Idle) with { Buttons = 8 } }); + + Assert.Contains(due, step => step.Step == PracticeAfk.Step.Kick); + } + + [Fact] + public void ASpectatorLookingAroundInFreeRoamCounts() + { + var afk = new PracticeAfk(); + + var due = Run( + afk, + 0, + 3600, + 15, + now => new[] + { + Spectating(Idle) with { Roaming = true, View = (90f + (now / 300) * 2f, 10f) }, + } + ); + + Assert.Empty(due); + } + + // In eye or chase, the observer's view and seat follow whoever they + // watch: that is the target moving, not the spectator. + [Fact] + public void ASpectatorFollowingSomeoneIsNotStirredByThem() + { + var afk = new PracticeAfk(); + + var due = Run( + afk, + 0, + 900, + 15, + now => new[] + { + Spectating(Idle) with + { + View = (90f + (now % 7) * 20f, 10f), + At = (100f + now, 200f, 0f), + }, + } + ); + + Assert.Contains(due, step => step.Step == PracticeAfk.Step.Kick); + } + + [Fact] + public void ASpectatorTalkingOrTypingCounts() + { + var afk = new PracticeAfk(); + + var due = Run( + afk, + 0, + 3600, + 15, + now => new[] { Spectating(Idle) with { Stirred = now % 300 == 0 } } + ); + + Assert.Empty(due); + } + + // Raising the limit is a reprieve: the warning given under the old one no + // longer stands. + [Fact] + public void RaisingTheLimitClearsAPendingWarning() + { + var afk = new PracticeAfk(); + + var warned = Run(afk, 0, 700, 15, _ => new[] { Still(Idle) }); + var after = Run(afk, 701, 2000, 30, _ => new[] { Still(Idle) }); + + Assert.Equal(new[] { (PracticeAfk.Step.Warn, 600) }, Steps(warned)); + Assert.Equal( + new[] + { + (PracticeAfk.Step.Warn, 1500), + (PracticeAfk.Step.FinalWarn, 1740), + (PracticeAfk.Step.Kick, 1800), + }, + Steps(after) + ); + } + + [Fact] + public void TheKickSaysHowLongThePlayerWasActuallyIdle() + { + var afk = new PracticeAfk(); + var kicks = new List(); + + for (int now = 0; now <= 700; now++) + { + afk.Sweep(new[] { Still(Idle) }, now, 15); + } + + for (int now = 701; now <= 1000; now++) + { + kicks.AddRange( + afk.Sweep(new[] { Still(Idle) }, now, 1).Where(due => due.Step == PracticeAfk.Step.Kick) + ); + } + + Assert.Equal(900, Assert.Single(kicks).IdleSeconds); + Assert.Contains("15 minutes", PracticeAfk.KickReason(kicks[0].IdleSeconds)); + Assert.Contains("90 seconds", PracticeAfk.KickReason(90)); + } + + [Theory] + [InlineData(int.MaxValue)] + [InlineData(1441)] + public void ALimitPastADayIsADay(int minutes) + { + var afk = new PracticeAfk(); + + var due = Run(afk, 0, 1440 * 60, minutes, _ => new[] { Still(Idle) }); + + Assert.Equal( + new[] + { + (PracticeAfk.Step.Warn, 1435 * 60), + (PracticeAfk.Step.FinalWarn, 1439 * 60), + (PracticeAfk.Step.Kick, 1440 * 60), + }, + Steps(due) + ); + } + + [Fact] + public void ANegativeLimitIsOff() + { + var afk = new PracticeAfk(); + + Assert.Empty(Run(afk, 0, 7200, -5, _ => new[] { Still(Idle) })); + } + + // A warning or kick that throws, for a player who has just left, must not + // stop the next player's from going out. + [Fact] + public void OneFailedStepDoesNotStopTheRest() + { + var acted = new List(); + var failed = new List(); + + PracticeAfk.Dispatch( + new[] + { + new PracticeAfk.Due(Idle, PracticeAfk.Step.Kick, 0, 900), + new PracticeAfk.Due(Busy, PracticeAfk.Step.Kick, 0, 900), + }, + due => + { + if (due.SteamId == Idle) + { + throw new InvalidOperationException("the player is gone"); + } + + acted.Add(due.SteamId); + }, + (due, _) => failed.Add(due.SteamId) + ); + + Assert.Equal(new[] { Busy }, acted); + Assert.Equal(new[] { Idle }, failed); + } + + [Theory] + [InlineData("2147483647", 1440)] + [InlineData("3000000000", 1440)] + [InlineData("-5", 0)] + [InlineData("7.9", 7)] + public void TheSessionClampsTheIdleLimit(string sent, int read) + { + PracticeSessionData session = JsonSerializer + .Deserialize( + $$""" + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_nuke", + "password": "hunter2", + "steam_ids": ["76561198000000001"], + "afk_kick_minutes": {{sent}} + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(read, session.afk_kick_minutes); + Assert.Single(session.allowed_steam_ids); + } + + [Fact] + public void TheSessionCarriesTheIdleLimit() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_nuke", + "password": "hunter2", + "steam_ids": [], + "afk_kick_minutes": 15 + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(15, session.afk_kick_minutes); + } + + [Fact] + public void AnApiThatSaysNothingLeavesTheKickOff() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_nuke", + "password": "hunter2", + "steam_ids": [] + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(0, session.afk_kick_minutes); + } +} diff --git a/apps/utility-sw/test/PracticeAfkWiringTests.cs b/apps/utility-sw/test/PracticeAfkWiringTests.cs new file mode 100644 index 00000000..72e7c44e --- /dev/null +++ b/apps/utility-sw/test/PracticeAfkWiringTests.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; +using Xunit; + +// PracticeAfkTests prove the clock. What feeds it needs the engine, so these +// read the source and go red when a line that does that is lost. +public class PracticeAfkWiringTests +{ + private static string Code(params string[] path) + { + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + string candidate = Path.Combine( + new[] { directory.FullName, "apps", "utility-sw", "src" }.Concat(path).ToArray() + ); + + if (File.Exists(candidate)) + { + return Regex.Replace(File.ReadAllText(candidate), @"//[^\n]*", ""); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"{path[^1]} was not found above the test output"); + } + + private static string Method(string signature, params string[] file) + { + string source = Code(file); + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + [Fact] + public void TalkingCountsAsActivity() + { + string wire = Method("private void WireAfk()", "Events", "PracticeAfkWatch.cs"); + string unwire = Method("private void UnwireAfk()", "Events", "PracticeAfkWatch.cs"); + + Assert.Contains("Core.Event.OnClientVoice +=", wire); + Assert.Contains("Core.Event.OnClientVoice -=", unwire); + Assert.Matches(@"_voiceHandler\s*=\s*@?\w+\s*=>\s*Stir\(", wire); + } + + [Fact] + public void HeldButtonsCountOnlyForALivePawn() + { + string watch = Method("private void UseWatch()", "UtilityPracticePlugin.cs"); + + Assert.Matches(@"if \(buttons != 0 && player\.IsAlive\)\s*\{\s*Stir\(player\.PlayerID\);", watch); + } + + [Fact] + public void ASpectatorIsJudgedByTheObserverPawnTheyLookThrough() + { + string sample = Method("private PracticeAfk.Sample AfkSample(IPlayer player)", "Events", "PracticeAfkWatch.cs"); + + Assert.Contains("player.Pawn", sample); + Assert.Contains("V_angle", sample); + Assert.Contains("Alive: alive", sample); + } + + [Fact] + public void OnlyAFreeRoamingSpectatorIsJudgedByTheirView() + { + string sample = Method("private PracticeAfk.Sample AfkSample(IPlayer player)", "Events", "PracticeAfkWatch.cs"); + + Assert.Contains("ObserverServices", sample); + Assert.Contains("ObserverMode_t.OBS_MODE_ROAMING", sample); + Assert.Contains("Roaming: roaming", sample); + } + + [Fact] + public void EveryDueStepIsTriedOnItsOwn() + { + string watch = Method("private void AfkWatch()", "Events", "PracticeAfkWatch.cs"); + + Assert.Contains("PracticeAfk.Dispatch(", watch); + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs index 5f2ecbd1..70c83cec 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs @@ -13,4 +13,7 @@ public class PracticeSessionData // Null unless the panel has loaded an execute onto this session. public UtilityPlaybook? playbook { get; set; } + + // 0 is off, and is what an api from before the setting sends. + public int afk_kick_minutes { get; set; } } diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs index 3d8564de..9ee4b09d 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs @@ -18,6 +18,7 @@ public class UtilitySessionRow public List? steam_ids { get; set; } public List? allowed_steam_ids { get; set; } public UtilityPlaybook? playbook { get; set; } + public double? afk_kick_minutes { get; set; } public PracticeSessionData ToSession() { @@ -29,6 +30,7 @@ public PracticeSessionData ToSession() map = map_name ?? map ?? "", allowed_steam_ids = steam_ids ?? allowed_steam_ids ?? new List(), playbook = playbook, + afk_kick_minutes = FiveStack.Utilities.PracticeAfk.ClampMinutes(afk_kick_minutes), }; } diff --git a/shared/dotnet/FiveStack.Utilities/PracticeAfk.cs b/shared/dotnet/FiveStack.Utilities/PracticeAfk.cs new file mode 100644 index 00000000..81ac8641 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeAfk.cs @@ -0,0 +1,275 @@ +namespace FiveStack.Utilities; + +public sealed class PracticeAfk +{ + // Below these, a change between two one-second samples is aim jitter or a + // pawn settling on a slope, not a person. + public const float LookDegrees = 1f; + public const float MoveUnits = 4f; + + public const int MaxKickMinutes = 1440; + + public const int MaxWarnLeadSeconds = 300; + public const int MaxFinalLeadSeconds = 60; + + public enum Step + { + None, + Warn, + FinalWarn, + Kick, + } + + public readonly record struct Sample( + ulong SteamId, + bool InGame, + bool Alive, + bool Stirred, + ulong Buttons, + (float Yaw, float Pitch)? View, + (float X, float Y, float Z)? At, + bool Roaming = false + ); + + public readonly record struct Due(ulong SteamId, Step Step, int SecondsLeft, int IdleSeconds); + + public static void Dispatch(IEnumerable due, Action act, Action failed) + { + foreach (Due step in due) + { + try + { + act(step); + } + catch (Exception error) + { + failed(step, error); + } + } + } + + public static int ClampMinutes(double? minutes) + { + if (minutes is not { } value || double.IsNaN(value) || value <= 0) + { + return 0; + } + + return value >= MaxKickMinutes ? MaxKickMinutes : (int)Math.Floor(value); + } + + private sealed class Clock + { + public int LastActive; + public Step Shown; + public int KickAt; + public (float Yaw, float Pitch)? View; + public (float X, float Y, float Z)? At; + } + + private readonly Dictionary _clocks = new(); + + // A player seen for the first time starts a clock rather than being judged + // on time nobody watched. + public IReadOnlyList Sweep(IEnumerable players, int now, int kickMinutes) + { + var due = new List(); + + kickMinutes = ClampMinutes(kickMinutes); + + if (kickMinutes == 0) + { + _clocks.Clear(); + + return due; + } + + int kick = kickMinutes * 60; + (int warnLead, int finalLead) = Leads(kick); + var present = new HashSet(); + + foreach (Sample player in players) + { + present.Add(player.SteamId); + + // A spectator in eye or chase sees through whoever they follow, so + // only free roam is their own view and seat. + bool ownView = player.Alive || player.Roaming; + (float Yaw, float Pitch)? view = ownView ? player.View : null; + (float X, float Y, float Z)? at = ownView ? player.At : null; + + if (!_clocks.TryGetValue(player.SteamId, out Clock? clock)) + { + _clocks[player.SteamId] = new Clock + { + LastActive = now, + View = view, + At = at, + }; + + continue; + } + + bool stirred = + !player.InGame + || player.Stirred + || (player.Alive && player.Buttons != 0) + || Looked(clock.View, view) + || Moved(clock.At, at); + + clock.View = view; + clock.At = at; + + if (stirred) + { + clock.LastActive = now; + clock.Shown = Step.None; + clock.KickAt = 0; + + continue; + } + + if ( + clock.Shown is Step.Warn or Step.FinalWarn + && clock.LastActive + kick > clock.KickAt + ) + { + clock.Shown = Step.None; + clock.KickAt = 0; + } + + Step step = Next(clock, now, kick, warnLead, finalLead); + int idle = now - clock.LastActive; + + if (step == Step.Warn) + { + due.Add(new Due(player.SteamId, step, warnLead, idle)); + } + else if (step == Step.FinalWarn) + { + due.Add(new Due(player.SteamId, step, finalLead, idle)); + } + else if (step == Step.Kick) + { + due.Add(new Due(player.SteamId, step, 0, idle)); + } + } + + foreach (ulong gone in _clocks.Keys.Where(steamId => !present.Contains(steamId)).ToList()) + { + _clocks.Remove(gone); + } + + return due; + } + + // The kick time is fixed when the first warning is given, so a warning + // always means what it said: a shorter limit set afterwards never brings + // the kick forward. + private static Step Next(Clock clock, int now, int kick, int warnLead, int finalLead) + { + if (clock.Shown == Step.None && now - clock.LastActive >= kick - warnLead) + { + clock.Shown = Step.Warn; + clock.KickAt = now + warnLead; + + return Step.Warn; + } + + if (clock.Shown == Step.Warn && now >= clock.KickAt - finalLead) + { + clock.Shown = Step.FinalWarn; + + return Step.FinalWarn; + } + + if (clock.Shown == Step.FinalWarn && now >= clock.KickAt) + { + clock.Shown = Step.Kick; + + return Step.Kick; + } + + return Step.None; + } + + public void Forget(ulong steamId) + { + _clocks.Remove(steamId); + } + + public void Reset() + { + _clocks.Clear(); + } + + // Whole minutes once there are any, so a warning reads "2 minutes" rather + // than "150 seconds". + public static (int Warn, int Final) Leads(int kickSeconds) + { + int warn = Math.Min(MaxWarnLeadSeconds, kickSeconds / 2); + + if (warn >= 60) + { + warn -= warn % 60; + } + + return (warn, Math.Min(MaxFinalLeadSeconds, warn / 2)); + } + + public static bool Looked((float Yaw, float Pitch)? before, (float Yaw, float Pitch)? now) + { + if (before is not { } was || now is not { } @is) + { + return false; + } + + float yaw = MathF.Abs(((@is.Yaw - was.Yaw) % 360f + 540f) % 360f - 180f); + float pitch = MathF.Abs(@is.Pitch - was.Pitch); + + return yaw >= LookDegrees || pitch >= LookDegrees; + } + + public static bool Moved((float X, float Y, float Z)? before, (float X, float Y, float Z)? now) + { + if (before is not { } was || now is not { } @is) + { + return false; + } + + float dx = @is.X - was.X; + float dy = @is.Y - was.Y; + float dz = @is.Z - was.Z; + + return dx * dx + dy * dy + dz * dz >= MoveUnits * MoveUnits; + } + + public static string Warning(Step step, int secondsLeft) + { + if (step == Step.FinalWarn) + { + return $"Final warning: you will be kicked for being idle in {Duration(secondsLeft)}."; + } + + return $"Are you still there? Move or press any key, or you will be kicked for being idle in {Duration(secondsLeft)}."; + } + + public static string KickReason(int idleSeconds) + { + int shown = idleSeconds >= 120 ? idleSeconds - idleSeconds % 60 : idleSeconds; + + return $"Kicked for being idle for {Duration(shown)} on a practice server"; + } + + private static string Duration(int seconds) + { + if (seconds >= 60 && seconds % 60 == 0) + { + int minutes = seconds / 60; + + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } + + return seconds == 1 ? "1 second" : $"{seconds} seconds"; + } +}