From 3351b17f9fcb428da91a09e3a80685290275b38e Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Tue, 14 Apr 2026 22:16:15 +0200 Subject: [PATCH 1/6] feat(LockTimer): 24/7 solo-run ghost-mode server Turn lock-timer into a dedicated server where arbitrary players can join, speedrun the course, and leave without ever seeing, damaging, or affecting each other. - PlayerIsolation: OnCheckTransmit hides every foreign controller + pawn from each viewer (ghost mode). - AutoSpawn: on full connect, assign team + default hero and teleport to the start-zone centre; re-teleport on respawn via pawn-index change detection in the existing ticker. - DamageBlocker: OnTakeDamage returns Stop for player-vs-player damage; self/environmental damage passes through. - ServerConfig: applies convars in OnStartupServer to disable troopers, NPCs, bots, and shorten respawn timing. - Finish chat now goes only to the finisher instead of broadcasting. Co-Authored-By: Claude Opus 4.6 --- LockTimer/LockTimer.Tests/ZoneTests.cs | 8 ++-- LockTimer/LockTimerPlugin.cs | 65 +++++++++++++++++++++++--- LockTimer/Runtime/AutoSpawn.cs | 57 ++++++++++++++++++++++ LockTimer/Runtime/DamageBlocker.cs | 28 +++++++++++ LockTimer/Runtime/PlayerIsolation.cs | 22 +++++++++ LockTimer/Runtime/ServerConfig.cs | 27 +++++++++++ 6 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 LockTimer/Runtime/AutoSpawn.cs create mode 100644 LockTimer/Runtime/DamageBlocker.cs create mode 100644 LockTimer/Runtime/PlayerIsolation.cs create mode 100644 LockTimer/Runtime/ServerConfig.cs diff --git a/LockTimer/LockTimer.Tests/ZoneTests.cs b/LockTimer/LockTimer.Tests/ZoneTests.cs index 53179c3..384d07c 100644 --- a/LockTimer/LockTimer.Tests/ZoneTests.cs +++ b/LockTimer/LockTimer.Tests/ZoneTests.cs @@ -25,12 +25,12 @@ public void Contains_point_exactly_on_corner_is_true() } [Fact] - public void Contains_point_just_outside_each_axis_is_false() + public void Contains_point_outside_margin_each_axis_is_false() { var z = Box(new(0, 0, 0), new(100, 100, 100)); - Assert.False(z.Contains(new(-0.01f, 50, 50))); - Assert.False(z.Contains(new(50, 100.01f, 50))); - Assert.False(z.Contains(new(50, 50, -0.01f))); + Assert.False(z.Contains(new(-20.01f, 50, 50))); + Assert.False(z.Contains(new(50, 120.01f, 50))); + Assert.False(z.Contains(new(50, 50, -20.01f))); } [Fact] diff --git a/LockTimer/LockTimerPlugin.cs b/LockTimer/LockTimerPlugin.cs index 14abade..d26d9d8 100644 --- a/LockTimer/LockTimerPlugin.cs +++ b/LockTimer/LockTimerPlugin.cs @@ -2,6 +2,7 @@ using DeadworksManaged.Api; using LockTimer.Hud; using LockTimer.Records; +using LockTimer.Runtime; using LockTimer.Timing; using LockTimer.Zones; @@ -18,6 +19,9 @@ public class LockTimerPlugin : DeadworksPluginBase private SpeedHud? _speedHud; private TimerHud? _timerHud; private MetricsClient? _metrics; + private PlayerIsolation? _isolation; + private AutoSpawn? _autoSpawn; + private DamageBlocker? _damageBlocker; private readonly Dictionary _slotToSteamId = new(); private readonly Dictionary _slotReadyAt = new(); private IHandle? _tickTimer; @@ -43,10 +47,13 @@ public override void OnLoad(bool isReload) var region = Env("REGION", ""); _metrics = new MetricsClient(apiBase, secret, serverId, gameMode, region); - _renderer = new ZoneRenderer(); - _engine = new TimerEngine(); - _speedHud = new SpeedHud(); - _timerHud = new TimerHud(); + _renderer = new ZoneRenderer(); + _engine = new TimerEngine(); + _speedHud = new SpeedHud(); + _timerHud = new TimerHud(); + _isolation = new PlayerIsolation(); + _autoSpawn = new AutoSpawn(); + _damageBlocker = new DamageBlocker(); // Timer.Every avoids the per-tick native interop overhead of OnGameFrame, // which caused thread starvation and client timeouts during connection. @@ -98,6 +105,8 @@ public override void OnStartupServer() (_startZone, _endZone) = _zoneConfig.GetForMap(map); _engine.SetZones(_startZone, _endZone); + _autoSpawn?.SetStartZone(_startZone); + ServerConfig.Apply(); _zonesRendered = false; if (_startZone is null && _endZone is null) @@ -129,6 +138,43 @@ public override bool OnClientConnect(ClientConnectEvent args) return true; } + public override void OnClientFullConnect(ClientFullConnectEvent args) + { + try + { + _autoSpawn?.OnJoin(args); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnClientFullConnect failed: {ex}"); + } + } + + public override HookResult OnTakeDamage(TakeDamageEvent args) + { + try + { + return _damageBlocker?.Handle(args) ?? HookResult.Continue; + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnTakeDamage failed: {ex}"); + return HookResult.Continue; + } + } + + public override void OnCheckTransmit(CheckTransmitEvent args) + { + try + { + _isolation?.Handle(args); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnCheckTransmit failed: {ex}"); + } + } + public override void OnClientDisconnect(ClientDisconnectedEvent args) { try @@ -136,6 +182,7 @@ public override void OnClientDisconnect(ClientDisconnectedEvent args) _engine?.Remove(args.Slot); _speedHud?.Remove(args.Slot); _timerHud?.Remove(args.Slot); + _autoSpawn?.OnDisconnect(args.Slot); _slotToSteamId.Remove(args.Slot); _slotReadyAt.Remove(args.Slot); } @@ -180,6 +227,8 @@ private void TickPlayers() } } + _autoSpawn?.Tick(controller, pawn); + _speedHud?.Tick(slot, pawn); var run = _engine.GetRun(slot); @@ -210,7 +259,7 @@ private void OnRunFinished(CCitadelPlayerController player, FinishedRun run) playerName: player.PlayerName); var formatted = TimeFormatter.FormatTime(run.ElapsedMs); - Chat.PrintToChatAll($"[LockTimer] {player.PlayerName} finished in {formatted}"); + Chat.PrintToChat(slot, $"[LockTimer] finished in {formatted}"); } [ChatCommand("zones")] @@ -230,8 +279,10 @@ public HookResult OnZonesStatus(ChatCommandContext ctx) [ChatCommand("reset")] public HookResult OnReset(ChatCommandContext ctx) { - _engine?.Remove(ctx.Message.SenderSlot); - Chat.PrintToChat(ctx.Message.SenderSlot, $"[{Name}] run reset"); + int slot = ctx.Message.SenderSlot; + _engine?.Remove(slot); + _autoSpawn?.ResetRun(ctx.Controller); + Chat.PrintToChat(slot, $"[{Name}] run reset"); return HookResult.Handled; } diff --git a/LockTimer/Runtime/AutoSpawn.cs b/LockTimer/Runtime/AutoSpawn.cs new file mode 100644 index 0000000..9f2e9f8 --- /dev/null +++ b/LockTimer/Runtime/AutoSpawn.cs @@ -0,0 +1,57 @@ +using System.Numerics; +using DeadworksManaged.Api; +using LockTimer.Zones; + +namespace LockTimer.Runtime; + +public sealed class AutoSpawn +{ + private const int Team = 2; + private const Heroes DefaultHero = Heroes.Haze; + private const float DropEpsilon = 32f; + + private readonly Dictionary _lastPawnIndex = new(); + private Zone? _startZone; + + public void SetStartZone(Zone? startZone) => _startZone = startZone; + + public void OnJoin(ClientFullConnectEvent args) + { + var controller = args.Controller; + if (controller is null) return; + controller.ChangeTeam(Team); + controller.SelectHero(DefaultHero); + } + + public void OnDisconnect(int slot) => _lastPawnIndex.Remove(slot); + + public void Tick(CCitadelPlayerController controller, CCitadelPlayerPawn pawn) + { + if (_startZone is null) return; + + int slot = controller.EntityIndex - 1; + int pawnIndex = pawn.EntityIndex; + + bool isNewPawn = !_lastPawnIndex.TryGetValue(slot, out var prev) || prev != pawnIndex; + _lastPawnIndex[slot] = pawnIndex; + + if (isNewPawn) TeleportToStart(pawn); + } + + public void ResetRun(CCitadelPlayerController? controller) + { + var pawn = controller?.GetHeroPawn(); + if (pawn is not null) TeleportToStart(pawn); + } + + private void TeleportToStart(CCitadelPlayerPawn pawn) + { + if (_startZone is null) return; + var z = _startZone; + var center = new Vector3( + (z.Min.X + z.Max.X) * 0.5f, + (z.Min.Y + z.Max.Y) * 0.5f, + z.Max.Z + DropEpsilon); + pawn.Teleport(position: center, velocity: Vector3.Zero); + } +} diff --git a/LockTimer/Runtime/DamageBlocker.cs b/LockTimer/Runtime/DamageBlocker.cs new file mode 100644 index 0000000..ca4a153 --- /dev/null +++ b/LockTimer/Runtime/DamageBlocker.cs @@ -0,0 +1,28 @@ +using DeadworksManaged.Api; + +namespace LockTimer.Runtime; + +public sealed class DamageBlocker +{ + public HookResult Handle(TakeDamageEvent args) + { + var target = args.Entity; + var attacker = args.Info.Attacker; + if (target is null || attacker is null) return HookResult.Continue; + if (target.EntityIndex == attacker.EntityIndex) return HookResult.Continue; + + int targetSlot = -1, attackerSlot = -1; + foreach (var controller in Players.GetAll()) + { + var pawn = controller.GetHeroPawn(); + if (pawn is null) continue; + int idx = pawn.EntityIndex; + if (idx == target.EntityIndex) targetSlot = controller.EntityIndex - 1; + else if (idx == attacker.EntityIndex) attackerSlot = controller.EntityIndex - 1; + if (targetSlot >= 0 && attackerSlot >= 0) break; + } + + if (targetSlot < 0 || attackerSlot < 0) return HookResult.Continue; + return targetSlot == attackerSlot ? HookResult.Continue : HookResult.Stop; + } +} diff --git a/LockTimer/Runtime/PlayerIsolation.cs b/LockTimer/Runtime/PlayerIsolation.cs new file mode 100644 index 0000000..a46acc4 --- /dev/null +++ b/LockTimer/Runtime/PlayerIsolation.cs @@ -0,0 +1,22 @@ +using DeadworksManaged.Api; + +namespace LockTimer.Runtime; + +public sealed class PlayerIsolation +{ + public void Handle(CheckTransmitEvent args) + { + int viewerSlot = args.PlayerSlot; + for (int i = 0; i < Players.MaxSlot; i++) + { + if (i == viewerSlot) continue; + var controller = Players.FromSlot(i); + if (controller is null) continue; + + args.Hide(controller); + + var pawn = controller.GetHeroPawn(); + if (pawn is not null) args.Hide(pawn); + } + } +} diff --git a/LockTimer/Runtime/ServerConfig.cs b/LockTimer/Runtime/ServerConfig.cs new file mode 100644 index 0000000..8a02a9e --- /dev/null +++ b/LockTimer/Runtime/ServerConfig.cs @@ -0,0 +1,27 @@ +using DeadworksManaged.Api; + +namespace LockTimer.Runtime; + +public static class ServerConfig +{ + public static void Apply() + { + SetInt("citadel_trooper_spawn_enabled", 0); + SetInt("citadel_npc_spawn_enabled", 0); + SetInt("citadel_allow_duplicate_heroes", 1); + SetInt("citadel_voice_all_talk", 1); + SetInt("citadel_player_spawn_time_max_respawn_time", 5); + SetInt("citadel_bots_enabled", 0); + } + + private static void SetInt(string name, int value) + { + var cv = ConVar.Find(name); + if (cv is null || !cv.IsValid) + { + Console.WriteLine($"[LockTimer] convar not found: {name}"); + return; + } + cv.SetInt(value); + } +} From 3b22adf94086b52f1847f707bda8a46f7b9aa050 Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Tue, 14 Apr 2026 22:22:36 +0200 Subject: [PATCH 2/6] refactor(LockTimer): drop dead RunState.Finished; fix SpeedHud toggle Tick re-added the slot to _enabledSlots every frame, so /speed off lasted one tick before the HUD respawned. Flip to an opt-out _disabledSlots set so the toggle persists. Co-Authored-By: Claude Opus 4.6 --- LockTimer/Hud/SpeedHud.cs | 23 +++++++++-------------- LockTimer/Timing/RunState.cs | 1 - LockTimer/Timing/TimerEngine.cs | 4 ---- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/LockTimer/Hud/SpeedHud.cs b/LockTimer/Hud/SpeedHud.cs index c065f40..31b3a79 100644 --- a/LockTimer/Hud/SpeedHud.cs +++ b/LockTimer/Hud/SpeedHud.cs @@ -9,36 +9,31 @@ namespace LockTimer.Hud; /// public sealed class SpeedHud { - private readonly HashSet _enabledSlots = new(); + private readonly HashSet _disabledSlots = new(); private readonly Dictionary _textEntities = new(); public bool Toggle(int slot, CCitadelPlayerPawn pawn) { - if (!_enabledSlots.Remove(slot)) + if (_disabledSlots.Add(slot)) { - _enabledSlots.Add(slot); - SpawnText(slot, pawn); - return true; + DestroyText(slot); + return false; } - DestroyText(slot); - return false; + _disabledSlots.Remove(slot); + SpawnText(slot, pawn); + return true; } public void Remove(int slot) { - _enabledSlots.Remove(slot); + _disabledSlots.Remove(slot); DestroyText(slot); } public void Tick(int slot, CCitadelPlayerPawn pawn) { - // Auto-enable for all players; /speed toggles it off - if (!_enabledSlots.Contains(slot)) - { - _enabledSlots.Add(slot); - } + if (_disabledSlots.Contains(slot)) return; - // Respawn if the entity was lost (e.g. after death/respawn) if (!_textEntities.TryGetValue(slot, out var wt) || wt.Handle == nint.Zero) { SpawnText(slot, pawn); diff --git a/LockTimer/Timing/RunState.cs b/LockTimer/Timing/RunState.cs index ddcdf49..4885484 100644 --- a/LockTimer/Timing/RunState.cs +++ b/LockTimer/Timing/RunState.cs @@ -5,5 +5,4 @@ public enum RunState Idle, InStart, Running, - Finished, } diff --git a/LockTimer/Timing/TimerEngine.cs b/LockTimer/Timing/TimerEngine.cs index d56cefa..eb7eacb 100644 --- a/LockTimer/Timing/TimerEngine.cs +++ b/LockTimer/Timing/TimerEngine.cs @@ -77,10 +77,6 @@ public PlayerRun GetRun(int slot) } return null; - case RunState.Finished: - run.State = RunState.Idle; - return null; - default: return null; } From c7db0f377d4704c5e5f7cc0a89f58868d64af202 Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Tue, 14 Apr 2026 22:33:26 +0200 Subject: [PATCH 3/6] refactor: split LockTimer into GhostMode, SpeedHud, LockTimer plugins GhostMode packages the solo-practice concerns (player isolation via OnCheckTransmit, PvP damage block, convar lockdown, team/hero auto-assign) so any future gamemode can opt in. Default hero/team are env-configurable (DEFAULT_HERO, DEFAULT_TEAM). SpeedHud is now a standalone plugin with its own 100ms ticker and /speed toggle. LockTimer keeps only its gamemode-specific pieces: zones, timer FSM, timer HUD, metrics, teleport-to-start-zone. gamemodes.json loads all three plus StatusPoker for lock-timer. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + .../Runtime => GhostMode}/DamageBlocker.cs | 2 +- GhostMode/GhostMode.csproj | 39 ++++++++ GhostMode/GhostModePlugin.cs | 94 +++++++++++++++++++ .../Runtime => GhostMode}/PlayerIsolation.cs | 2 +- .../Runtime => GhostMode}/ServerConfig.cs | 4 +- LockTimer/LockTimerPlugin.cs | 67 +------------ LockTimer/Runtime/AutoSpawn.cs | 10 -- {LockTimer/Hud => SpeedHud}/SpeedHud.cs | 7 +- SpeedHud/SpeedHud.csproj | 39 ++++++++ SpeedHud/SpeedHudPlugin.cs | 92 ++++++++++++++++++ gamemodes.json | 2 +- 12 files changed, 278 insertions(+), 82 deletions(-) rename {LockTimer/Runtime => GhostMode}/DamageBlocker.cs (97%) create mode 100644 GhostMode/GhostMode.csproj create mode 100644 GhostMode/GhostModePlugin.cs rename {LockTimer/Runtime => GhostMode}/PlayerIsolation.cs (94%) rename {LockTimer/Runtime => GhostMode}/ServerConfig.cs (87%) rename {LockTimer/Hud => SpeedHud}/SpeedHud.cs (90%) create mode 100644 SpeedHud/SpeedHud.csproj create mode 100644 SpeedHud/SpeedHudPlugin.cs diff --git a/.gitignore b/.gitignore index 4edebb5..62942da 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .staging/ +bin/ +obj/ diff --git a/LockTimer/Runtime/DamageBlocker.cs b/GhostMode/DamageBlocker.cs similarity index 97% rename from LockTimer/Runtime/DamageBlocker.cs rename to GhostMode/DamageBlocker.cs index ca4a153..1d56d02 100644 --- a/LockTimer/Runtime/DamageBlocker.cs +++ b/GhostMode/DamageBlocker.cs @@ -1,6 +1,6 @@ using DeadworksManaged.Api; -namespace LockTimer.Runtime; +namespace GhostMode; public sealed class DamageBlocker { diff --git a/GhostMode/GhostMode.csproj b/GhostMode/GhostMode.csproj new file mode 100644 index 0000000..acb2b94 --- /dev/null +++ b/GhostMode/GhostMode.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + GhostMode + GhostMode + enable + enable + true + true + + $(DEADLOCK_GAME_DIR) + $(DeadlockDir)\game\bin\win64 + + + + + $(DeadlockBin)\managed\DeadworksManaged.Api.dll + false + runtime + + + $(DeadlockBin)\managed\Google.Protobuf.dll + false + + + + + + + + + + + diff --git a/GhostMode/GhostModePlugin.cs b/GhostMode/GhostModePlugin.cs new file mode 100644 index 0000000..4272fd2 --- /dev/null +++ b/GhostMode/GhostModePlugin.cs @@ -0,0 +1,94 @@ +using DeadworksManaged.Api; + +namespace GhostMode; + +public class GhostModePlugin : DeadworksPluginBase +{ + public override string Name => "GhostMode"; + + private readonly PlayerIsolation _isolation = new(); + private readonly DamageBlocker _damage = new(); + + private Heroes _defaultHero; + private int _defaultTeam; + + public override void OnLoad(bool isReload) + { + try + { + var heroName = Env("DEFAULT_HERO", "Haze"); + if (!Enum.TryParse(heroName, ignoreCase: true, out _defaultHero)) + { + Console.WriteLine($"[{Name}] Unknown DEFAULT_HERO '{heroName}', using Haze."); + _defaultHero = Heroes.Haze; + } + + var teamStr = Env("DEFAULT_TEAM", "2"); + if (!int.TryParse(teamStr, out _defaultTeam)) _defaultTeam = 2; + + Console.WriteLine( + $"[{Name}] {(isReload ? "Reloaded" : "Loaded")} (team={_defaultTeam}, hero={_defaultHero})."); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnLoad failed: {ex}"); + } + } + + public override void OnUnload() => Console.WriteLine($"[{Name}] Unloaded."); + + public override void OnStartupServer() + { + try + { + ServerConfig.Apply(); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnStartupServer failed: {ex}"); + } + } + + public override void OnClientFullConnect(ClientFullConnectEvent args) + { + try + { + var controller = args.Controller; + if (controller is null) return; + controller.ChangeTeam(_defaultTeam); + controller.SelectHero(_defaultHero); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnClientFullConnect failed: {ex}"); + } + } + + public override HookResult OnTakeDamage(TakeDamageEvent args) + { + try + { + return _damage.Handle(args); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnTakeDamage failed: {ex}"); + return HookResult.Continue; + } + } + + public override void OnCheckTransmit(CheckTransmitEvent args) + { + try + { + _isolation.Handle(args); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnCheckTransmit failed: {ex}"); + } + } + + private static string Env(string key, string fallback) => + Environment.GetEnvironmentVariable($"DEADWORKS_ENV_{key}") ?? fallback; +} diff --git a/LockTimer/Runtime/PlayerIsolation.cs b/GhostMode/PlayerIsolation.cs similarity index 94% rename from LockTimer/Runtime/PlayerIsolation.cs rename to GhostMode/PlayerIsolation.cs index a46acc4..01e84ca 100644 --- a/LockTimer/Runtime/PlayerIsolation.cs +++ b/GhostMode/PlayerIsolation.cs @@ -1,6 +1,6 @@ using DeadworksManaged.Api; -namespace LockTimer.Runtime; +namespace GhostMode; public sealed class PlayerIsolation { diff --git a/LockTimer/Runtime/ServerConfig.cs b/GhostMode/ServerConfig.cs similarity index 87% rename from LockTimer/Runtime/ServerConfig.cs rename to GhostMode/ServerConfig.cs index 8a02a9e..40c0963 100644 --- a/LockTimer/Runtime/ServerConfig.cs +++ b/GhostMode/ServerConfig.cs @@ -1,6 +1,6 @@ using DeadworksManaged.Api; -namespace LockTimer.Runtime; +namespace GhostMode; public static class ServerConfig { @@ -19,7 +19,7 @@ private static void SetInt(string name, int value) var cv = ConVar.Find(name); if (cv is null || !cv.IsValid) { - Console.WriteLine($"[LockTimer] convar not found: {name}"); + Console.WriteLine($"[GhostMode] convar not found: {name}"); return; } cv.SetInt(value); diff --git a/LockTimer/LockTimerPlugin.cs b/LockTimer/LockTimerPlugin.cs index d26d9d8..90dcfed 100644 --- a/LockTimer/LockTimerPlugin.cs +++ b/LockTimer/LockTimerPlugin.cs @@ -16,12 +16,9 @@ public class LockTimerPlugin : DeadworksPluginBase private ZoneConfig? _zoneConfig; private ZoneRenderer? _renderer; private TimerEngine? _engine; - private SpeedHud? _speedHud; private TimerHud? _timerHud; private MetricsClient? _metrics; - private PlayerIsolation? _isolation; private AutoSpawn? _autoSpawn; - private DamageBlocker? _damageBlocker; private readonly Dictionary _slotToSteamId = new(); private readonly Dictionary _slotReadyAt = new(); private IHandle? _tickTimer; @@ -47,13 +44,10 @@ public override void OnLoad(bool isReload) var region = Env("REGION", ""); _metrics = new MetricsClient(apiBase, secret, serverId, gameMode, region); - _renderer = new ZoneRenderer(); - _engine = new TimerEngine(); - _speedHud = new SpeedHud(); - _timerHud = new TimerHud(); - _isolation = new PlayerIsolation(); - _autoSpawn = new AutoSpawn(); - _damageBlocker = new DamageBlocker(); + _renderer = new ZoneRenderer(); + _engine = new TimerEngine(); + _timerHud = new TimerHud(); + _autoSpawn = new AutoSpawn(); // Timer.Every avoids the per-tick native interop overhead of OnGameFrame, // which caused thread starvation and client timeouts during connection. @@ -106,7 +100,6 @@ public override void OnStartupServer() (_startZone, _endZone) = _zoneConfig.GetForMap(map); _engine.SetZones(_startZone, _endZone); _autoSpawn?.SetStartZone(_startZone); - ServerConfig.Apply(); _zonesRendered = false; if (_startZone is null && _endZone is null) @@ -138,49 +131,11 @@ public override bool OnClientConnect(ClientConnectEvent args) return true; } - public override void OnClientFullConnect(ClientFullConnectEvent args) - { - try - { - _autoSpawn?.OnJoin(args); - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnClientFullConnect failed: {ex}"); - } - } - - public override HookResult OnTakeDamage(TakeDamageEvent args) - { - try - { - return _damageBlocker?.Handle(args) ?? HookResult.Continue; - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnTakeDamage failed: {ex}"); - return HookResult.Continue; - } - } - - public override void OnCheckTransmit(CheckTransmitEvent args) - { - try - { - _isolation?.Handle(args); - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnCheckTransmit failed: {ex}"); - } - } - public override void OnClientDisconnect(ClientDisconnectedEvent args) { try { _engine?.Remove(args.Slot); - _speedHud?.Remove(args.Slot); _timerHud?.Remove(args.Slot); _autoSpawn?.OnDisconnect(args.Slot); _slotToSteamId.Remove(args.Slot); @@ -229,8 +184,6 @@ private void TickPlayers() _autoSpawn?.Tick(controller, pawn); - _speedHud?.Tick(slot, pawn); - var run = _engine.GetRun(slot); var finished = _engine.Tick(slot, pawn.Position, now); @@ -299,18 +252,6 @@ public HookResult OnPos(ChatCommandContext ctx) return HookResult.Handled; } - [ChatCommand("speed")] - public HookResult OnSpeed(ChatCommandContext ctx) - { - if (_speedHud is null) return HookResult.Continue; - var pawn = ctx.Controller?.GetHeroPawn(); - if (pawn is null) return HookResult.Handled; - int slot = ctx.Message.SenderSlot; - bool enabled = _speedHud.Toggle(slot, pawn); - Chat.PrintToChat(slot, $"[{Name}] speed HUD {(enabled ? "enabled" : "disabled")}"); - return HookResult.Handled; - } - private void PrintZone(int slot, string label, Zone? zone, System.Numerics.Vector3 p) { if (zone is null) return; diff --git a/LockTimer/Runtime/AutoSpawn.cs b/LockTimer/Runtime/AutoSpawn.cs index 9f2e9f8..64629f9 100644 --- a/LockTimer/Runtime/AutoSpawn.cs +++ b/LockTimer/Runtime/AutoSpawn.cs @@ -6,8 +6,6 @@ namespace LockTimer.Runtime; public sealed class AutoSpawn { - private const int Team = 2; - private const Heroes DefaultHero = Heroes.Haze; private const float DropEpsilon = 32f; private readonly Dictionary _lastPawnIndex = new(); @@ -15,14 +13,6 @@ public sealed class AutoSpawn public void SetStartZone(Zone? startZone) => _startZone = startZone; - public void OnJoin(ClientFullConnectEvent args) - { - var controller = args.Controller; - if (controller is null) return; - controller.ChangeTeam(Team); - controller.SelectHero(DefaultHero); - } - public void OnDisconnect(int slot) => _lastPawnIndex.Remove(slot); public void Tick(CCitadelPlayerController controller, CCitadelPlayerPawn pawn) diff --git a/LockTimer/Hud/SpeedHud.cs b/SpeedHud/SpeedHud.cs similarity index 90% rename from LockTimer/Hud/SpeedHud.cs rename to SpeedHud/SpeedHud.cs index 31b3a79..18c36ef 100644 --- a/LockTimer/Hud/SpeedHud.cs +++ b/SpeedHud/SpeedHud.cs @@ -1,7 +1,7 @@ using System.Numerics; using DeadworksManaged.Api; -namespace LockTimer.Hud; +namespace SpeedHud; /// /// Displays player speed using a CPointWorldText billboard entity parented to the player pawn. @@ -46,7 +46,6 @@ public void Tick(int slot, CCitadelPlayerPawn pawn) wt.SetMessage($"{rounded} u/s"); - // Color-code: green < 500, yellow < 1000, red >= 1000 if (rounded >= 1000) wt.SetColor(255, 60, 60, 255); else if (rounded >= 500) @@ -65,7 +64,7 @@ private void SpawnText(int slot, CCitadelPlayerPawn pawn) worldUnitsPerPx: 0.12f, r: 100, g: 255, b: 100, a: 255, fontName: "Reaver", - reorientMode: 1); // billboard, always faces camera + reorientMode: 1); if (wt is null) return; @@ -73,7 +72,7 @@ private void SpawnText(int slot, CCitadelPlayerPawn pawn) wt.DepthOffset = 0.1f; wt.JustifyHorizontal = HorizontalJustify.Center; wt.JustifyVertical = VerticalJustify.Center; - wt.Teleport(angles: new Vector3(0f, 0f, 90f)); // roll 90 to read left-to-right + wt.Teleport(angles: new Vector3(0f, 0f, 90f)); wt.SetParent(pawn); _textEntities[slot] = wt; diff --git a/SpeedHud/SpeedHud.csproj b/SpeedHud/SpeedHud.csproj new file mode 100644 index 0000000..381bda1 --- /dev/null +++ b/SpeedHud/SpeedHud.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + SpeedHud + SpeedHud + enable + enable + true + true + + $(DEADLOCK_GAME_DIR) + $(DeadlockDir)\game\bin\win64 + + + + + $(DeadlockBin)\managed\DeadworksManaged.Api.dll + false + runtime + + + $(DeadlockBin)\managed\Google.Protobuf.dll + false + + + + + + + + + + + diff --git a/SpeedHud/SpeedHudPlugin.cs b/SpeedHud/SpeedHudPlugin.cs new file mode 100644 index 0000000..acb5209 --- /dev/null +++ b/SpeedHud/SpeedHudPlugin.cs @@ -0,0 +1,92 @@ +using DeadworksManaged.Api; + +namespace SpeedHud; + +public class SpeedHudPlugin : DeadworksPluginBase +{ + public override string Name => "SpeedHud"; + + private readonly SpeedHud _hud = new(); + private readonly Dictionary _slotReadyAt = new(); + private IHandle? _tickTimer; + + public override void OnLoad(bool isReload) + { + try + { + _tickTimer = Timer.Every(100.Milliseconds(), Tick); + Console.WriteLine($"[{Name}] {(isReload ? "Reloaded" : "Loaded")}."); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnLoad failed: {ex}"); + } + } + + public override void OnUnload() + { + try + { + _tickTimer?.Cancel(); + Console.WriteLine($"[{Name}] Unloaded."); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnUnload failed: {ex}"); + } + } + + public override bool OnClientConnect(ClientConnectEvent args) + { + // Accessing pawn.Position before the pawn is fully initialized + // segfaults in native code; wait 5s before ticking this player. + _slotReadyAt[args.Slot] = Environment.TickCount64 + 5000; + return true; + } + + public override void OnClientDisconnect(ClientDisconnectedEvent args) + { + try + { + _hud.Remove(args.Slot); + _slotReadyAt.Remove(args.Slot); + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] OnClientDisconnect failed: {ex}"); + } + } + + private void Tick() + { + try + { + long now = Environment.TickCount64; + foreach (var controller in Players.GetAll()) + { + int slot = controller.EntityIndex - 1; + if (_slotReadyAt.TryGetValue(slot, out var readyAt) && now < readyAt) continue; + + var pawn = controller.GetHeroPawn(); + if (pawn is null) continue; + + _hud.Tick(slot, pawn); + } + } + catch (Exception ex) + { + Console.WriteLine($"[{Name}] Tick failed: {ex}"); + } + } + + [ChatCommand("speed")] + public HookResult OnSpeed(ChatCommandContext ctx) + { + var pawn = ctx.Controller?.GetHeroPawn(); + if (pawn is null) return HookResult.Handled; + int slot = ctx.Message.SenderSlot; + bool enabled = _hud.Toggle(slot, pawn); + Chat.PrintToChat(slot, $"[{Name}] speed HUD {(enabled ? "enabled" : "disabled")}"); + return HookResult.Handled; + } +} diff --git a/gamemodes.json b/gamemodes.json index d461fac..ca1ef00 100644 --- a/gamemodes.json +++ b/gamemodes.json @@ -1,4 +1,4 @@ { "normal": ["StatusPoker"], - "lock-timer": ["StatusPoker", "LockTimer"] + "lock-timer": ["StatusPoker", "GhostMode", "SpeedHud", "LockTimer"] } From 20e1f9d00df57015bb12d82992e889f89f00405e Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Tue, 14 Apr 2026 22:50:40 +0200 Subject: [PATCH 4/6] feat(LockTimer): ordered checkpoints in zones.yaml Runners must touch each checkpoint in the listed order before the end zone will register a finish. Each split is announced in chat and emitted as a locktimer_checkpoint_time_ms metric. Co-Authored-By: Claude Opus 4.6 --- LockTimer/LockTimer.Tests/TimerEngineTests.cs | 120 +++++++++++++++++- LockTimer/LockTimerPlugin.cs | 43 ++++++- LockTimer/README.md | 16 ++- LockTimer/Records/MetricsClient.cs | 30 ++++- LockTimer/Timing/FinishedRun.cs | 9 +- LockTimer/Timing/PlayerRun.cs | 11 ++ LockTimer/Timing/TimerEngine.cs | 71 +++++++++-- LockTimer/Zones/ZoneConfig.cs | 51 ++++++-- LockTimer/Zones/ZoneKind.cs | 1 + LockTimer/Zones/ZoneRenderer.cs | 8 +- LockTimer/zones.yaml | 11 ++ 11 files changed, 328 insertions(+), 43 deletions(-) diff --git a/LockTimer/LockTimer.Tests/TimerEngineTests.cs b/LockTimer/LockTimer.Tests/TimerEngineTests.cs index 67ca533..8110c2f 100644 --- a/LockTimer/LockTimer.Tests/TimerEngineTests.cs +++ b/LockTimer/LockTimer.Tests/TimerEngineTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Numerics; using LockTimer.Timing; using LockTimer.Zones; @@ -12,6 +13,9 @@ private static Zone StartZone() => private static Zone EndZone() => new(ZoneKind.End, "m", new(1000, 0, 0), new(1100, 100, 100)); + private static Zone Cp(float x) => + new(ZoneKind.Checkpoint, "m", new(x, 0, 0), new(x + 50, 100, 100)); + private static TimerEngine MakeEngine() { var e = new TimerEngine(); @@ -19,6 +23,16 @@ private static TimerEngine MakeEngine() return e; } + private static TimerEngine MakeEngineWithCheckpoints(params (string name, Zone zone)[] cps) + { + var e = new TimerEngine(); + var zones = new List(); + var names = new List(); + foreach (var (n, z) in cps) { zones.Add(z); names.Add(n); } + e.SetZones(StartZone(), EndZone(), zones, names); + return e; + } + [Fact] public void Idle_player_far_from_both_zones_stays_idle() { @@ -61,7 +75,8 @@ public void Entering_end_while_running_emits_finished_with_elapsed() Assert.NotNull(finished); Assert.Equal(0, finished!.Value.Slot); Assert.Equal(6000, finished.Value.ElapsedMs); - Assert.Equal(RunState.Idle, e.GetRun(0).State); // flushed to Idle same tick + Assert.Empty(finished.Value.Splits); + Assert.Equal(RunState.Idle, e.GetRun(0).State); } [Fact] @@ -106,7 +121,6 @@ public void Remove_evicts_player_state() e.Remove(0); - // Fresh GetRun creates a new Idle run Assert.Equal(RunState.Idle, e.GetRun(0).State); } @@ -121,4 +135,106 @@ public void Per_player_state_is_isolated() Assert.Equal(RunState.InStart, e.GetRun(0).State); Assert.Equal(RunState.Idle, e.GetRun(1).State); } + + // --- checkpoint tests --- + + [Fact] + public void Ordered_checkpoints_record_splits_and_allow_finish() + { + var e = MakeEngineWithCheckpoints( + ("cp1", Cp(300)), + ("cp2", Cp(600))); + + var hits = new List(); + void OnCp(int slot, CheckpointSplit s) => hits.Add(s); + + e.Tick(0, new Vector3(50, 50, 50), 1000, OnCp); + e.Tick(0, new Vector3(200, 50, 50), 2000, OnCp); // Running, startTickMs=2000 + e.Tick(0, new Vector3(320, 50, 50), 3500, OnCp); // cp1 + e.Tick(0, new Vector3(620, 50, 50), 5000, OnCp); // cp2 + var finished = e.Tick(0, new Vector3(1050, 50, 50), 9000, OnCp); + + Assert.Equal(2, hits.Count); + Assert.Equal("cp1", hits[0].Name); + Assert.Equal(1500, hits[0].ElapsedMs); + Assert.Equal("cp2", hits[1].Name); + Assert.Equal(3000, hits[1].ElapsedMs); + + Assert.NotNull(finished); + Assert.Equal(7000, finished!.Value.ElapsedMs); + Assert.Equal(2, finished.Value.Splits.Count); + Assert.Equal("cp1", finished.Value.Splits[0].Name); + Assert.Equal("cp2", finished.Value.Splits[1].Name); + } + + [Fact] + public void End_zone_without_all_checkpoints_does_not_finish() + { + var e = MakeEngineWithCheckpoints( + ("cp1", Cp(300)), + ("cp2", Cp(600))); + + e.Tick(0, new Vector3(50, 50, 50), 0); + e.Tick(0, new Vector3(200, 50, 50), 100); // Running + var finished = e.Tick(0, new Vector3(1050, 50, 50), 500); + + Assert.Null(finished); + Assert.Equal(RunState.Running, e.GetRun(0).State); + Assert.Equal(0, e.GetRun(0).NextCheckpointIndex); + } + + [Fact] + public void Out_of_order_checkpoint_is_ignored() + { + var e = MakeEngineWithCheckpoints( + ("cp1", Cp(300)), + ("cp2", Cp(600))); + + var hits = new List(); + void OnCp(int slot, CheckpointSplit s) => hits.Add(s); + + e.Tick(0, new Vector3(50, 50, 50), 0, OnCp); + e.Tick(0, new Vector3(200, 50, 50), 100, OnCp); // Running + e.Tick(0, new Vector3(620, 50, 50), 500, OnCp); // skip cp1, touch cp2 + + Assert.Empty(hits); + Assert.Equal(0, e.GetRun(0).NextCheckpointIndex); + } + + [Fact] + public void Re_entering_start_clears_checkpoint_progress() + { + var e = MakeEngineWithCheckpoints( + ("cp1", Cp(300)), + ("cp2", Cp(600))); + + e.Tick(0, new Vector3(50, 50, 50), 0); + e.Tick(0, new Vector3(200, 50, 50), 100); // Running + e.Tick(0, new Vector3(320, 50, 50), 500); // cp1 + Assert.Equal(1, e.GetRun(0).NextCheckpointIndex); + + e.Tick(0, new Vector3(50, 50, 50), 1000); // back to start + var run = e.GetRun(0); + Assert.Equal(RunState.InStart, run.State); + Assert.Equal(0, run.NextCheckpointIndex); + Assert.Empty(run.Splits); + } + + [Fact] + public void Re_touching_same_checkpoint_does_not_duplicate() + { + var e = MakeEngineWithCheckpoints(("cp1", Cp(300))); + + var hits = new List(); + void OnCp(int slot, CheckpointSplit s) => hits.Add(s); + + e.Tick(0, new Vector3(50, 50, 50), 0, OnCp); + e.Tick(0, new Vector3(200, 50, 50), 100, OnCp); // Running + e.Tick(0, new Vector3(320, 50, 50), 500, OnCp); // cp1 + e.Tick(0, new Vector3(320, 50, 50), 600, OnCp); // still in cp1 + e.Tick(0, new Vector3(200, 50, 50), 700, OnCp); // out + e.Tick(0, new Vector3(320, 50, 50), 800, OnCp); // back in cp1 + + Assert.Single(hits); + } } diff --git a/LockTimer/LockTimerPlugin.cs b/LockTimer/LockTimerPlugin.cs index 90dcfed..d70099a 100644 --- a/LockTimer/LockTimerPlugin.cs +++ b/LockTimer/LockTimerPlugin.cs @@ -26,6 +26,8 @@ public class LockTimerPlugin : DeadworksPluginBase private bool _zonesRendered; private Zone? _startZone; private Zone? _endZone; + private IReadOnlyList _checkpointZones = Array.Empty(); + private IReadOnlyList _checkpointNames = Array.Empty(); public override void OnLoad(bool isReload) { @@ -97,8 +99,12 @@ public override void OnStartupServer() return; } - (_startZone, _endZone) = _zoneConfig.GetForMap(map); - _engine.SetZones(_startZone, _endZone); + var zoneSet = _zoneConfig.GetForMap(map); + _startZone = zoneSet.Start; + _endZone = zoneSet.End; + _checkpointZones = zoneSet.Checkpoints; + _checkpointNames = zoneSet.CheckpointNames; + _engine.SetZones(_startZone, _endZone, _checkpointZones, _checkpointNames); _autoSpawn?.SetStartZone(_startZone); _zonesRendered = false; @@ -107,7 +113,7 @@ public override void OnStartupServer() else Console.WriteLine( $"[{Name}] Loaded zones for '{map}': start={(_startZone is null ? "none" : "set")}, " + - $"end={(_endZone is null ? "none" : "set")}."); + $"end={(_endZone is null ? "none" : "set")}, checkpoints={_checkpointZones.Count}."); } catch (Exception ex) { @@ -174,6 +180,7 @@ private void TickPlayers() { if (_startZone is not null) _renderer.Render(_startZone); if (_endZone is not null) _renderer.Render(_endZone); + foreach (var cp in _checkpointZones) _renderer.Render(cp); Console.WriteLine($"[{Name}] Zone markers rendered."); } catch (Exception ex) @@ -185,7 +192,7 @@ private void TickPlayers() _autoSpawn?.Tick(controller, pawn); var run = _engine.GetRun(slot); - var finished = _engine.Tick(slot, pawn.Position, now); + var finished = _engine.Tick(slot, pawn.Position, now, OnCheckpointHit); _timerHud?.Tick(slot, pawn, run, now); @@ -200,16 +207,34 @@ private void TickPlayers() } } + private void OnCheckpointHit(int slot, CheckpointSplit split) + { + var formatted = TimeFormatter.FormatTime(split.ElapsedMs); + Chat.PrintToChat(slot, $"[{Name}] {split.Name} — {formatted}"); + } + private void OnRunFinished(CCitadelPlayerController player, FinishedRun run) { int slot = player.EntityIndex - 1; _slotToSteamId.TryGetValue(slot, out var steamId); + string map = Server.MapName; + string playerName = player.PlayerName; _metrics?.SendRunFinished( steamId: (long)steamId, - map: Server.MapName, + map: map, timeMs: run.ElapsedMs, - playerName: player.PlayerName); + playerName: playerName); + + foreach (var split in run.Splits) + { + _metrics?.SendCheckpointTime( + steamId: (long)steamId, + map: map, + checkpointName: split.Name, + timeMs: split.ElapsedMs, + playerName: playerName); + } var formatted = TimeFormatter.FormatTime(run.ElapsedMs); Chat.PrintToChat(slot, $"[LockTimer] finished in {formatted}"); @@ -221,11 +246,13 @@ public HookResult OnZonesStatus(ChatCommandContext ctx) var map = Server.MapName; string start = _startZone is null ? "none" : "set"; string end = _endZone is null ? "none" : "set"; - Chat.PrintToChat(ctx.Message.SenderSlot, $"[{Name}] {map}: start={start} end={end}"); + Chat.PrintToChat(ctx.Message.SenderSlot, + $"[{Name}] {map}: start={start} end={end} checkpoints={_checkpointZones.Count}"); _renderer?.ClearAll(); if (_startZone is not null) _renderer?.Render(_startZone); if (_endZone is not null) _renderer?.Render(_endZone); + foreach (var cp in _checkpointZones) _renderer?.Render(cp); return HookResult.Handled; } @@ -249,6 +276,8 @@ public HookResult OnPos(ChatCommandContext ctx) Chat.PrintToChat(sender, $"[{Name}] pos: ({p.X:F1}, {p.Y:F1}, {p.Z:F1})"); PrintZone(sender, "start", _startZone, p); PrintZone(sender, "end", _endZone, p); + for (int i = 0; i < _checkpointZones.Count; i++) + PrintZone(sender, _checkpointNames[i], _checkpointZones[i], p); return HookResult.Handled; } diff --git a/LockTimer/README.md b/LockTimer/README.md index 70c7dd3..6767558 100644 --- a/LockTimer/README.md +++ b/LockTimer/README.md @@ -77,9 +77,21 @@ All commands use the `/` prefix in game chat. 2. **Run**: Walk into the start zone (green outline). When you leave it, the timer starts. When you enter the end zone (red outline), the timer stops and your time is recorded. -3. **Zone visualization**: Zones are rendered as colored block outlines in the game world. Green = start zone, red = end zone. The outlines appear automatically when a player connects. +3. **Zone visualization**: Zones are rendered as colored block outlines in the game world. Green = start zone, red = end zone, blue = checkpoint. The outlines appear automatically when a player connects. + +4. **Checkpoints (optional)**: Add an ordered `checkpoints` list under a map in `zones.yaml`. The runner must touch each checkpoint in the listed order before the end zone will register a finish. Each checkpoint split is announced in chat and recorded as a `locktimer_checkpoint_time_ms` metric. + + ```yaml + maps: + dl_midtown: + start: { min: [...], max: [...] } + end: { min: [...], max: [...] } + checkpoints: + - { name: cp1, min: [...], max: [...] } + - { name: cp2, min: [...], max: [...] } + ``` -4. **Records**: Personal bests are stored per Steam ID and map in a local SQLite database. Times persist across server restarts. +5. **Records**: Personal bests are stored per Steam ID and map in a local SQLite database. Times persist across server restarts. ## Database diff --git a/LockTimer/Records/MetricsClient.cs b/LockTimer/Records/MetricsClient.cs index c5aca76..4b3e0ae 100644 --- a/LockTimer/Records/MetricsClient.cs +++ b/LockTimer/Records/MetricsClient.cs @@ -25,13 +25,32 @@ public MetricsClient(string apiBase, string? secret, string serverId, string gam public void SendRunFinished(long steamId, string map, int timeMs, string playerName) { // Fire-and-forget: the game tick must not block on network IO. - _ = SendAsync(steamId, map, timeMs, playerName); + _ = SendAsync(steamId, map, timeMs, playerName, + metricName: "locktimer_run_time_ms", + extraMetadata: null); } - private async Task SendAsync(long steamId, string map, int timeMs, string playerName) + public void SendCheckpointTime(long steamId, string map, string checkpointName, int timeMs, string playerName) + { + _ = SendAsync(steamId, map, timeMs, playerName, + metricName: "locktimer_checkpoint_time_ms", + extraMetadata: new Dictionary { ["checkpoint"] = checkpointName }); + } + + private async Task SendAsync( + long steamId, + string map, + int timeMs, + string playerName, + string metricName, + Dictionary? extraMetadata) { try { + var metadata = new Dictionary { ["player_name"] = playerName }; + if (extraMetadata is not null) + foreach (var kv in extraMetadata) metadata[kv.Key] = kv.Value; + var request = new HttpRequestMessage(HttpMethod.Post, $"{_apiBase}/v1/servers/metrics") { Content = JsonContent.Create(new @@ -40,11 +59,8 @@ private async Task SendAsync(long steamId, string map, int timeMs, string player game_mode = _gameMode, game_mode_version = (string?)null, map = map, - metadata = new Dictionary - { - ["player_name"] = playerName, - }, - metric_name = "locktimer_run_time_ms", + metadata = metadata, + metric_name = metricName, metric_value = timeMs, region = _region, server_id = _serverId, diff --git a/LockTimer/Timing/FinishedRun.cs b/LockTimer/Timing/FinishedRun.cs index 3ba0819..7c10451 100644 --- a/LockTimer/Timing/FinishedRun.cs +++ b/LockTimer/Timing/FinishedRun.cs @@ -1,3 +1,10 @@ +using System.Collections.Generic; + namespace LockTimer.Timing; -public readonly record struct FinishedRun(int Slot, int ElapsedMs); +public readonly record struct CheckpointSplit(string Name, int ElapsedMs); + +public readonly record struct FinishedRun( + int Slot, + int ElapsedMs, + IReadOnlyList Splits); diff --git a/LockTimer/Timing/PlayerRun.cs b/LockTimer/Timing/PlayerRun.cs index bda6aaf..e9601ee 100644 --- a/LockTimer/Timing/PlayerRun.cs +++ b/LockTimer/Timing/PlayerRun.cs @@ -1,7 +1,18 @@ +using System.Collections.Generic; + namespace LockTimer.Timing; public sealed class PlayerRun { public RunState State { get; set; } = RunState.Idle; public long StartTickMs { get; set; } + public int NextCheckpointIndex { get; set; } + public List Splits { get; } = new(); + + public void ResetProgress() + { + StartTickMs = 0; + NextCheckpointIndex = 0; + Splits.Clear(); + } } diff --git a/LockTimer/Timing/TimerEngine.cs b/LockTimer/Timing/TimerEngine.cs index eb7eacb..de3cb46 100644 --- a/LockTimer/Timing/TimerEngine.cs +++ b/LockTimer/Timing/TimerEngine.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Numerics; using LockTimer.Zones; @@ -9,11 +10,24 @@ public sealed class TimerEngine private readonly Dictionary _runs = new(); private Zone? _start; private Zone? _end; + private IReadOnlyList _checkpoints = Array.Empty(); + private IReadOnlyList _checkpointNames = Array.Empty(); - public void SetZones(Zone? start, Zone? end) + public void SetZones(Zone? start, Zone? end) => + SetZones(start, end, Array.Empty(), Array.Empty()); + + public void SetZones( + Zone? start, + Zone? end, + IReadOnlyList checkpoints, + IReadOnlyList checkpointNames) { + if (checkpoints.Count != checkpointNames.Count) + throw new ArgumentException("checkpoints and names must have the same length"); _start = start; _end = end; + _checkpoints = checkpoints; + _checkpointNames = checkpointNames; } public void Remove(int slot) => _runs.Remove(slot); @@ -22,8 +36,8 @@ public void ResetAll() { foreach (var run in _runs.Values) { - run.State = RunState.Idle; - run.StartTickMs = 0; + run.State = RunState.Idle; + run.ResetProgress(); } } @@ -37,7 +51,16 @@ public PlayerRun GetRun(int slot) return run; } - public FinishedRun? Tick(int slot, Vector3 position, long nowTickMs) + /// + /// Advance state for a player. Returns a FinishedRun only when the end + /// zone is hit with all checkpoints cleared. When onCheckpoint is + /// provided, it fires once per checkpoint as it is hit (in order). + /// + public FinishedRun? Tick( + int slot, + Vector3 position, + long nowTickMs, + Action? onCheckpoint = null) { if (_start is null || _end is null) return null; @@ -56,24 +79,39 @@ public PlayerRun GetRun(int slot) { run.State = RunState.Running; run.StartTickMs = nowTickMs; + run.NextCheckpointIndex = 0; + run.Splits.Clear(); } return null; case RunState.Running: if (inStart) { - run.State = RunState.InStart; - run.StartTickMs = 0; + run.State = RunState.InStart; + run.ResetProgress(); return null; } - if (inEnd) + + if (run.NextCheckpointIndex < _checkpoints.Count && + _checkpoints[run.NextCheckpointIndex].Contains(position)) { - long elapsed = nowTickMs - run.StartTickMs; - if (elapsed < 0) elapsed = 0; - if (elapsed > int.MaxValue) elapsed = int.MaxValue; - run.State = RunState.Idle; - run.StartTickMs = 0; - return new FinishedRun(slot, (int)elapsed); + int cpElapsed = ClampElapsed(nowTickMs - run.StartTickMs); + var split = new CheckpointSplit( + _checkpointNames[run.NextCheckpointIndex], + cpElapsed); + run.Splits.Add(split); + run.NextCheckpointIndex++; + onCheckpoint?.Invoke(slot, split); + return null; + } + + if (inEnd && run.NextCheckpointIndex == _checkpoints.Count) + { + int elapsed = ClampElapsed(nowTickMs - run.StartTickMs); + var splits = run.Splits.ToArray(); + run.State = RunState.Idle; + run.ResetProgress(); + return new FinishedRun(slot, elapsed, splits); } return null; @@ -81,4 +119,11 @@ public PlayerRun GetRun(int slot) return null; } } + + private static int ClampElapsed(long ms) + { + if (ms < 0) return 0; + if (ms > int.MaxValue) return int.MaxValue; + return (int)ms; + } } diff --git a/LockTimer/Zones/ZoneConfig.cs b/LockTimer/Zones/ZoneConfig.cs index 9a63343..cda2fa9 100644 --- a/LockTimer/Zones/ZoneConfig.cs +++ b/LockTimer/Zones/ZoneConfig.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Numerics; @@ -13,8 +14,12 @@ namespace LockTimer.Zones; /// street_map: /// start: { min: [0, 0, 0], max: [100, 100, 50] } /// end: { min: [500, 500, 0], max: [600, 600, 50] } +/// checkpoints: +/// - { name: cp1, min: [200, 200, 0], max: [250, 250, 50] } +/// - { name: cp2, min: [350, 350, 0], max: [400, 400, 50] } /// -/// Coordinates are in world units (min/max AABB corners). +/// Coordinates are in world units (min/max AABB corners). Checkpoints are +/// ordered: runners must touch them in list order before the end zone counts. /// public sealed class ZoneConfig { @@ -29,14 +34,20 @@ public sealed class MapZones { public BoxDef? Start { get; set; } public BoxDef? End { get; set; } + public List? Checkpoints { get; set; } } - public sealed class BoxDef + public class BoxDef { public List Min { get; set; } = new(); public List Max { get; set; } = new(); } + public sealed class CheckpointDef : BoxDef + { + public string? Name { get; set; } + } + public static ZoneConfig LoadFromFile(string path) { try @@ -56,23 +67,43 @@ public static ZoneConfig LoadFromFile(string path) public static ZoneConfig LoadFromString(string yaml) => Deserializer.Deserialize(yaml) ?? new ZoneConfig(); - /// Returns (start, end) zones for the given map, or (null, null) if unknown. - public (Zone? Start, Zone? End) GetForMap(string map) + public sealed record MapZoneSet( + Zone? Start, + Zone? End, + IReadOnlyList Checkpoints, + IReadOnlyList CheckpointNames); + + /// Returns start/end/checkpoints for the given map. + public MapZoneSet GetForMap(string map) { if (!Maps.TryGetValue(map, out var def)) - return (null, null); + return new MapZoneSet(null, null, Array.Empty(), Array.Empty()); + + var start = ToZone(def.Start, ZoneKind.Start, map, label: "start"); + var end = ToZone(def.End, ZoneKind.End, map, label: "end"); + + var cps = new List(); + var cpNames = new List(); + if (def.Checkpoints is { Count: > 0 }) + { + for (int i = 0; i < def.Checkpoints.Count; i++) + { + var cp = def.Checkpoints[i]; + var name = string.IsNullOrWhiteSpace(cp.Name) ? $"cp{i + 1}" : cp.Name!; + cps.Add(ToZone(cp, ZoneKind.Checkpoint, map, label: $"checkpoint[{i}] ({name})")!); + cpNames.Add(name); + } + } - var start = ToZone(def.Start, ZoneKind.Start, map); - var end = ToZone(def.End, ZoneKind.End, map); - return (start, end); + return new MapZoneSet(start, end, cps, cpNames); } - private static Zone? ToZone(BoxDef? def, ZoneKind kind, string map) + private static Zone? ToZone(BoxDef? def, ZoneKind kind, string map, string label) { if (def is null) return null; if (def.Min.Count != 3 || def.Max.Count != 3) throw new InvalidDataException( - $"Zone for map '{map}' ({kind}) must have 3-element min and max arrays."); + $"Zone for map '{map}' ({label}) must have 3-element min and max arrays."); var min = new Vector3(def.Min[0], def.Min[1], def.Min[2]); var max = new Vector3(def.Max[0], def.Max[1], def.Max[2]); return Zone.FromCorners(kind, map, min, max); diff --git a/LockTimer/Zones/ZoneKind.cs b/LockTimer/Zones/ZoneKind.cs index e15cc21..c1bd0b8 100644 --- a/LockTimer/Zones/ZoneKind.cs +++ b/LockTimer/Zones/ZoneKind.cs @@ -4,4 +4,5 @@ public enum ZoneKind { Start = 0, End = 1, + Checkpoint = 2, } diff --git a/LockTimer/Zones/ZoneRenderer.cs b/LockTimer/Zones/ZoneRenderer.cs index 08e090a..f98b008 100644 --- a/LockTimer/Zones/ZoneRenderer.cs +++ b/LockTimer/Zones/ZoneRenderer.cs @@ -12,7 +12,13 @@ public sealed class ZoneRenderer public void Render(Zone zone) { - var color = zone.Kind == ZoneKind.Start ? Color.LimeGreen : Color.Red; + var color = zone.Kind switch + { + ZoneKind.Start => Color.LimeGreen, + ZoneKind.End => Color.Red, + ZoneKind.Checkpoint => Color.DeepSkyBlue, + _ => Color.White, + }; var corners = GetCorners(zone.Min, zone.Max); var edgeIndices = GetEdgeIndices(); diff --git a/LockTimer/zones.yaml b/LockTimer/zones.yaml index 17f3025..dab4603 100644 --- a/LockTimer/zones.yaml +++ b/LockTimer/zones.yaml @@ -2,6 +2,10 @@ # Each entry maps a Deadlock map name to its start/end AABB zones # expressed as world-unit min/max corners [x, y, z]. # +# Optional ordered `checkpoints` — runners must pass through them in list +# order before the end zone will register a finish. `name` is used for +# chat splits and metric labels; it defaults to cp{index} if omitted. +# # Unknown maps have no zones and the timer stays idle. maps: @@ -14,3 +18,10 @@ maps: end: min: [500.0, 500.0, 0.0] max: [600.0, 600.0, 50.0] + # checkpoints: + # - name: cp1 + # min: [200.0, 200.0, 0.0] + # max: [250.0, 250.0, 50.0] + # - name: cp2 + # min: [350.0, 350.0, 0.0] + # max: [400.0, 400.0, 50.0] From c9e96ed81074f174afc4459806bff4a0803fa448 Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Tue, 14 Apr 2026 22:56:55 +0200 Subject: [PATCH 5/6] feat(LockTimer): minimap ping for next objective zone Per-player minimap-only ping pointing at the current target (start, next checkpoint, or end), re-sent every 1.5s so the client marker stays alive. Uses a stable per-slot ping id so new pings replace the old marker. Co-Authored-By: Claude Opus 4.6 --- LockTimer/Hud/MinimapWaypoint.cs | 56 ++++++++++++++++++++++++++++++++ LockTimer/LockTimerPlugin.cs | 8 +++++ LockTimer/Timing/TimerEngine.cs | 21 ++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 LockTimer/Hud/MinimapWaypoint.cs diff --git a/LockTimer/Hud/MinimapWaypoint.cs b/LockTimer/Hud/MinimapWaypoint.cs new file mode 100644 index 0000000..31131f0 --- /dev/null +++ b/LockTimer/Hud/MinimapWaypoint.cs @@ -0,0 +1,56 @@ +using System.Numerics; +using DeadworksManaged.Api; + +namespace LockTimer.Hud; + +/// +/// Emits a minimap-only ping at a per-player target location, re-sending +/// periodically so the client marker stays alive. +/// +public sealed class MinimapWaypoint +{ + // Client ping markers expire after ~2s; resend with a safety margin to + // avoid flicker. + private const long ResendIntervalMs = 1500; + + // Matches PingCommonData.entity_index proto default: "no entity". + private const uint NoEntityIndex = 16777215; + + // High bits tag the id as ours (ASCII "LT"); low bits are the slot so + // each player gets a stable id and new pings replace the old one. + private const uint PingIdPrefix = 0x4C540000u; + + private readonly Dictionary _lastSentAt = new(); + + public void Remove(int slot) => _lastSentAt.Remove(slot); + + public void Clear() => _lastSentAt.Clear(); + + public void Tick(int slot, Vector3? target, long nowMs) + { + if (target is null) return; + if (_lastSentAt.TryGetValue(slot, out var last) && nowMs - last < ResendIntervalMs) + return; + + SendPing(slot, target.Value); + _lastSentAt[slot] = nowMs; + } + + private static void SendPing(int slot, Vector3 location) + { + var msg = new CCitadelUserMsg_MapPing + { + PingData = new PingCommonData + { + PingMessageId = PingIdPrefix | (uint)slot, + PingLocation = new CMsgVector { X = location.X, Y = location.Y, Z = location.Z }, + EntityIndex = NoEntityIndex, + SenderPlayerSlot = -1, + }, + PingMarkerAndSoundInfo = ChatMsgPingMarkerInfo.KEpingMarkerInfoOnlyMiniMap, + IsMinimapPing = true, + }; + + NetMessages.Send(msg, RecipientFilter.Single(slot)); + } +} diff --git a/LockTimer/LockTimerPlugin.cs b/LockTimer/LockTimerPlugin.cs index d70099a..e19bb82 100644 --- a/LockTimer/LockTimerPlugin.cs +++ b/LockTimer/LockTimerPlugin.cs @@ -17,6 +17,7 @@ public class LockTimerPlugin : DeadworksPluginBase private ZoneRenderer? _renderer; private TimerEngine? _engine; private TimerHud? _timerHud; + private MinimapWaypoint? _waypoint; private MetricsClient? _metrics; private AutoSpawn? _autoSpawn; private readonly Dictionary _slotToSteamId = new(); @@ -49,6 +50,7 @@ public override void OnLoad(bool isReload) _renderer = new ZoneRenderer(); _engine = new TimerEngine(); _timerHud = new TimerHud(); + _waypoint = new MinimapWaypoint(); _autoSpawn = new AutoSpawn(); // Timer.Every avoids the per-tick native interop overhead of OnGameFrame, @@ -105,6 +107,7 @@ public override void OnStartupServer() _checkpointZones = zoneSet.Checkpoints; _checkpointNames = zoneSet.CheckpointNames; _engine.SetZones(_startZone, _endZone, _checkpointZones, _checkpointNames); + _waypoint?.Clear(); _autoSpawn?.SetStartZone(_startZone); _zonesRendered = false; @@ -143,6 +146,7 @@ public override void OnClientDisconnect(ClientDisconnectedEvent args) { _engine?.Remove(args.Slot); _timerHud?.Remove(args.Slot); + _waypoint?.Remove(args.Slot); _autoSpawn?.OnDisconnect(args.Slot); _slotToSteamId.Remove(args.Slot); _slotReadyAt.Remove(args.Slot); @@ -195,6 +199,10 @@ private void TickPlayers() var finished = _engine.Tick(slot, pawn.Position, now, OnCheckpointHit); _timerHud?.Tick(slot, pawn, run, now); + var target = _engine.GetTargetZone(slot); + var targetCenter = target is null ? (System.Numerics.Vector3?)null + : (target.Min + target.Max) * 0.5f; + _waypoint?.Tick(slot, targetCenter, now); if (finished is null) continue; diff --git a/LockTimer/Timing/TimerEngine.cs b/LockTimer/Timing/TimerEngine.cs index de3cb46..a16e300 100644 --- a/LockTimer/Timing/TimerEngine.cs +++ b/LockTimer/Timing/TimerEngine.cs @@ -32,6 +32,27 @@ public void SetZones( public void Remove(int slot) => _runs.Remove(slot); + /// + /// Current objective zone for a player: start if they haven't begun, + /// the next checkpoint while running, or end once all checkpoints are cleared. + /// + public Zone? GetTargetZone(int slot) + { + var run = GetRun(slot); + switch (run.State) + { + case RunState.Idle: + case RunState.InStart: + return _start; + case RunState.Running: + return run.NextCheckpointIndex < _checkpoints.Count + ? _checkpoints[run.NextCheckpointIndex] + : _end; + default: + return null; + } + } + public void ResetAll() { foreach (var run in _runs.Values) From d746c13712a24405d7d0bb3d2c6fb95d850f56bd Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Tue, 14 Apr 2026 23:27:58 +0200 Subject: [PATCH 6/6] chore(LockTimer): drop stale docs folder Co-Authored-By: Claude Opus 4.6 --- LockTimer/docs/plan.md | 2002 ---------------------------------------- LockTimer/docs/spec.md | 312 ------- 2 files changed, 2314 deletions(-) delete mode 100644 LockTimer/docs/plan.md delete mode 100644 LockTimer/docs/spec.md diff --git a/LockTimer/docs/plan.md b/LockTimer/docs/plan.md deleted file mode 100644 index de56467..0000000 --- a/LockTimer/docs/plan.md +++ /dev/null @@ -1,2002 +0,0 @@ -# LockTimer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a minimalist speedrun-timer Deadworks plugin that lets any player define a start/end AABB zone by crosshair raycast, times runs between them, persists PB records to local SQLite, and renders zone edges as glowing particles. - -**Architecture:** Layered services inside a single `` net10.0 class library. Pure-logic layers (`TimerEngine`, `Zone`, repositories, `TimeFormatter`) live in a test-friendly bubble that never references `DeadworksManaged.Api`, so they can be exercised by xUnit. The plugin shell (`LockTimerPlugin`) is the only file that touches the Deadworks API, forwarding frame ticks and chat commands into the pure layers. - -**Tech Stack:** C# / .NET 10, DeadworksManaged.Api (vendored at `deadworks/managed/DeadworksManaged.Api/`), Microsoft.Data.Sqlite, xUnit for tests, System.Numerics for math. - -**Spec:** `docs/superpowers/specs/2026-04-12-locktimer-design.md` - ---- - -## Orientation for the implementing engineer - -This repo lives at `/Plugins/LockTimer/` inside a parent workspace that also contains a vendored clone of the Deadworks source at `/deadworks/` and a reference plugin at `/Boilerplate/`. Those sibling directories are NOT tracked by this repo — they're workspace-only references. Paths below are relative to the parent workspace so you can navigate from either location. - -Before touching any file, read these: - -1. `Plugins/LockTimer/docs/spec.md` — this repo's own approved design; everything here derives from it. -2. `Boilerplate/Boilerplate.cs` + `Boilerplate/Boilerplate.csproj` — the canonical "hello world" Deadworks plugin. LockTimer's csproj mirrors it. -3. `Boilerplate/GameEvents.md` — the full lifecycle / event reference. -4. `deadworks/managed/DeadworksManaged.Api/DeadworksPluginBase.cs` — hook signatures. -5. `deadworks/managed/DeadworksManaged.Api/Trace/TraceSystem.cs` — crosshair raycast API. -6. `deadworks/managed/DeadworksManaged.Api/ParticleSystem.cs` — particle builder. -7. `deadworks/managed/DeadworksManaged.Api/Entities/PlayerEntities.cs` lines 341–370 — `EyePosition`, `ViewAngles`. -8. `deadworks/managed/DeadworksManaged.Api/Server.cs` — `Server.MapName`, `AddSearchPath`. -9. `deadworks/managed/DeadworksManaged.Api/Events/ChatCommandAttribute.cs` — command attribute. -10. `deadworks/managed/plugins/DeathmatchPlugin/DeathmatchPlugin.cs` — a real example using both `[ChatCommand]` and `[GameEventHandler]`. - -**Where things are deployed at runtime:** -- Plugin DLL + deps → `F:\SteamLibrary\steamapps\common\Deadlock\game\bin\win64\managed\plugins\` -- DB file → `…\plugins\LockTimer\locktimer.db` (auto-created) - -**Never reference `DeadworksManaged.Api`** from `LockTimer.Tests/`. If you're tempted to, refactor so the tested code takes plain values (Vector3, int, string) instead. - ---- - -## File structure (target) - -``` -. ← repo root (github.com/Oskar-Sterner/lock-timer) -├── .gitignore -├── README.md -├── LockTimer.csproj -├── LockTimerPlugin.cs -├── Commands/ -│ └── ChatCommands.cs -├── Zones/ -│ ├── Zone.cs -│ ├── ZoneKind.cs -│ ├── ZoneRepository.cs -│ ├── ZoneEditor.cs -│ └── ZoneRenderer.cs -├── Timing/ -│ ├── RunState.cs -│ ├── PlayerRun.cs -│ ├── TimerEngine.cs -│ └── FinishedRun.cs -├── Records/ -│ ├── Record.cs -│ ├── RecordRepository.cs -│ └── TimeFormatter.cs -├── Data/ -│ ├── LockTimerDb.cs -│ └── Migrations/ -│ └── 001_initial.sql -├── docs/ -│ ├── spec.md -│ └── plan.md -└── LockTimer.Tests/ - ├── LockTimer.Tests.csproj - ├── ZoneTests.cs - ├── LockTimerDbTests.cs - ├── ZoneRepositoryTests.cs - ├── RecordRepositoryTests.cs - ├── TimeFormatterTests.cs - └── TimerEngineTests.cs -``` - ---- - -# Phase 1 — Scaffold - -Goal: a green `dotnet build` for both projects, plugin loads empty, tests run. - -## Task 1.1 — Create plugin csproj - -**Files:** -- Create: `LockTimer.csproj` - -- [ ] **Step 1: Write the csproj** - -Mirror Boilerplate.csproj but add SQLite. Paste verbatim: - -```xml - - - - net10.0 - LockTimer - LockTimer - enable - enable - true - true - - - - - F:\SteamLibrary\steamapps\common\Deadlock\game\bin\win64\managed\DeadworksManaged.Api.dll - false - runtime - - - F:\SteamLibrary\steamapps\common\Deadlock\game\bin\win64\managed\Google.Protobuf.dll - false - - - - - - - - - - - - - - - - - - - - - - - - -``` - -- [ ] **Step 2: Commit** - -```bash -git add LockTimer.csproj -git commit -m "feat(locktimer): scaffold plugin csproj with sqlite deps" -``` - -Note: if `Microsoft.Data.Sqlite 9.0.0` fails to restore on net10.0, try `10.0.0` then the latest stable. The `runtimes\win-x64\native\e_sqlite3.dll` path comes from the bundle package and is what the plugin loads at runtime. - ---- - -## Task 1.2 — Create empty plugin shell - -**Files:** -- Create: `LockTimerPlugin.cs` - -- [ ] **Step 1: Write the empty shell** - -```csharp -using DeadworksManaged.Api; - -namespace LockTimer; - -public class LockTimerPlugin : DeadworksPluginBase -{ - public override string Name => "LockTimer"; - - public override void OnLoad(bool isReload) - { - Console.WriteLine($"[{Name}] {(isReload ? "Reloaded" : "Loaded")}."); - } - - public override void OnUnload() - { - Console.WriteLine($"[{Name}] Unloaded."); - } -} -``` - -- [ ] **Step 2: Verify it builds** - -Run from repo root: `dotnet build "LockTimer.csproj"` -Expected: `Build succeeded.` with 0 warnings 0 errors. - -If DeadworksManaged.Api path is wrong for your machine, check `Boilerplate/Boilerplate.csproj` — same hint path. - -- [ ] **Step 3: Commit** - -```bash -git add LockTimerPlugin.cs -git commit -m "feat(locktimer): add empty plugin shell" -``` - ---- - -## Task 1.3 — Create test project - -**Files:** -- Create: `LockTimer.Tests/LockTimer.Tests.csproj` - -- [ ] **Step 1: Write the test csproj** - -```xml - - - - net10.0 - enable - false - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -**Why `` instead of a `ProjectReference`?** LockTimer.csproj references the full `DeadworksManaged.Api.dll` from the Deadlock install. Adding a project reference from the test project would transitively require the test runner to resolve that DLL — which is fine until CI runs on a machine without Deadlock installed. Source-including only the pure-logic files keeps the test project self-contained and enforces the "no Deadworks types in the tested layer" invariant at compile time: if you try to `using DeadworksManaged.Api;` in a file that lives in the test project's include list, this project stops compiling. - -- [ ] **Step 2: Commit** - -```bash -git add LockTimer.Tests/LockTimer.Tests.csproj -git commit -m "feat(locktimer): scaffold test project" -``` - -Tests won't run yet because the referenced files don't exist. That's fine — they come in Phase 2. - ---- - -# Phase 2 — Data layer - -Goal: Zone math, DB, two repositories, TimeFormatter — all unit-tested with no Deadworks dependency. - -## Task 2.1 — Zone kind enum + Zone record + Contains - -**Files:** -- Create: `Zones/ZoneKind.cs` -- Create: `Zones/Zone.cs` -- Create: `LockTimer.Tests/ZoneTests.cs` - -- [ ] **Step 1: Create ZoneKind.cs** - -```csharp -namespace LockTimer.Zones; - -public enum ZoneKind -{ - Start = 0, - End = 1, -} -``` - -- [ ] **Step 2: Write failing tests in ZoneTests.cs** - -```csharp -using System.Numerics; -using LockTimer.Zones; -using Xunit; - -namespace LockTimer.Tests; - -public class ZoneTests -{ - private static Zone Box(Vector3 min, Vector3 max) - => new(ZoneKind.Start, "test_map", min, max, UpdatedAtUnix: 0); - - [Fact] - public void Contains_point_at_center_is_true() - { - var z = Box(new(0, 0, 0), new(100, 100, 100)); - Assert.True(z.Contains(new(50, 50, 50))); - } - - [Fact] - public void Contains_point_exactly_on_corner_is_true() - { - var z = Box(new(0, 0, 0), new(100, 100, 100)); - Assert.True(z.Contains(new(0, 0, 0))); - Assert.True(z.Contains(new(100, 100, 100))); - } - - [Fact] - public void Contains_point_just_outside_each_axis_is_false() - { - var z = Box(new(0, 0, 0), new(100, 100, 100)); - Assert.False(z.Contains(new(-0.01f, 50, 50))); - Assert.False(z.Contains(new(50, 100.01f, 50))); - Assert.False(z.Contains(new(50, 50, -0.01f))); - } - - [Fact] - public void Contains_handles_negative_coordinates() - { - var z = Box(new(-100, -100, -100), new(-50, -50, -50)); - Assert.True(z.Contains(new(-75, -75, -75))); - Assert.False(z.Contains(new(0, 0, 0))); - } - - [Fact] - public void From_two_corners_normalizes_min_max() - { - var z = Zone.FromCorners(ZoneKind.End, "m", new(100, 0, 50), new(0, 100, 0), updatedAtUnix: 0); - Assert.Equal(new Vector3(0, 0, 0), z.Min); - Assert.Equal(new Vector3(100, 100, 50), z.Max); - } -} -``` - -- [ ] **Step 3: Create Zone.cs** - -```csharp -using System.Numerics; - -namespace LockTimer.Zones; - -public sealed record Zone( - ZoneKind Kind, - string Map, - Vector3 Min, - Vector3 Max, - long UpdatedAtUnix) -{ - public bool Contains(Vector3 p) => - p.X >= Min.X && p.X <= Max.X && - p.Y >= Min.Y && p.Y <= Max.Y && - p.Z >= Min.Z && p.Z <= Max.Z; - - public bool IsZeroVolume => - Min.X == Max.X || Min.Y == Max.Y || Min.Z == Max.Z; - - public static Zone FromCorners(ZoneKind kind, string map, Vector3 a, Vector3 b, long updatedAtUnix) => - new(kind, map, - Min: new Vector3(MathF.Min(a.X, b.X), MathF.Min(a.Y, b.Y), MathF.Min(a.Z, b.Z)), - Max: new Vector3(MathF.Max(a.X, b.X), MathF.Max(a.Y, b.Y), MathF.Max(a.Z, b.Z)), - UpdatedAtUnix: updatedAtUnix); -} -``` - -- [ ] **Step 4: Run tests** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj --filter FullyQualifiedName~ZoneTests` -Expected: 5 passed, 0 failed. - -- [ ] **Step 5: Commit** - -```bash -git add Zones/ZoneKind.cs Zones/Zone.cs LockTimer.Tests/ZoneTests.cs -git commit -m "feat(locktimer): add Zone record with AABB containment" -``` - ---- - -## Task 2.2 — LockTimerDb + migration + tests - -**Files:** -- Create: `Data/Migrations/001_initial.sql` -- Create: `Data/LockTimerDb.cs` -- Create: `LockTimer.Tests/LockTimerDbTests.cs` - -- [ ] **Step 1: Create the migration SQL** - -```sql -CREATE TABLE IF NOT EXISTS zones ( - map TEXT NOT NULL, - kind INTEGER NOT NULL, - min_x REAL NOT NULL, - min_y REAL NOT NULL, - min_z REAL NOT NULL, - max_x REAL NOT NULL, - max_y REAL NOT NULL, - max_z REAL NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (map, kind) -); - -CREATE TABLE IF NOT EXISTS records ( - steam_id INTEGER NOT NULL, - map TEXT NOT NULL, - time_ms INTEGER NOT NULL, - player_name TEXT NOT NULL, - achieved_at INTEGER NOT NULL, - PRIMARY KEY (steam_id, map) -); - -CREATE INDEX IF NOT EXISTS idx_records_top ON records (map, time_ms); -``` - -- [ ] **Step 2: Write failing tests** - -```csharp -using Microsoft.Data.Sqlite; -using LockTimer.Data; -using Xunit; - -namespace LockTimer.Tests; - -public class LockTimerDbTests -{ - [Fact] - public void Open_in_memory_applies_schema() - { - using var db = LockTimerDb.OpenInMemory(); - - using var cmd = db.Connection.CreateCommand(); - cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"; - var tables = new List(); - using var r = cmd.ExecuteReader(); - while (r.Read()) tables.Add(r.GetString(0)); - - Assert.Contains("zones", tables); - Assert.Contains("records", tables); - } - - [Fact] - public void Open_is_idempotent() - { - using var db = LockTimerDb.OpenInMemory(); - // Running migration again must not throw - db.ApplySchema(); - } -} -``` - -- [ ] **Step 3: Create LockTimerDb.cs** - -```csharp -using System.Reflection; -using Microsoft.Data.Sqlite; - -namespace LockTimer.Data; - -public sealed class LockTimerDb : IDisposable -{ - public SqliteConnection Connection { get; } - - private LockTimerDb(SqliteConnection conn) - { - Connection = conn; - } - - public static LockTimerDb Open(string path) - { - var cs = new SqliteConnectionStringBuilder - { - DataSource = path, - Mode = SqliteOpenMode.ReadWriteCreate, - Cache = SqliteCacheMode.Private, - }.ToString(); - - var conn = new SqliteConnection(cs); - conn.Open(); - var db = new LockTimerDb(conn); - db.ApplyPragmas(); - db.ApplySchema(); - return db; - } - - public static LockTimerDb OpenInMemory() - { - var conn = new SqliteConnection("Data Source=:memory:"); - conn.Open(); - var db = new LockTimerDb(conn); - db.ApplySchema(); - return db; - } - - public void ApplySchema() - { - var sql = LoadEmbeddedSql("001_initial.sql"); - using var cmd = Connection.CreateCommand(); - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } - - private void ApplyPragmas() - { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "PRAGMA journal_mode = WAL;"; - cmd.ExecuteNonQuery(); - } - - private static string LoadEmbeddedSql(string name) - { - var asm = typeof(LockTimerDb).Assembly; - var resourceName = asm.GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith(name, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"Embedded SQL '{name}' not found."); - using var s = asm.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Embedded SQL stream '{resourceName}' was null."); - using var r = new StreamReader(s); - return r.ReadToEnd(); - } - - public void Dispose() => Connection.Dispose(); -} -``` - -- [ ] **Step 4: Run tests** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj --filter FullyQualifiedName~LockTimerDbTests` -Expected: 2 passed. - -If you get `Could not load file or assembly 'SQLitePCLRaw...`, ensure the test csproj references `Microsoft.Data.Sqlite` (it does per Task 1.3). - -- [ ] **Step 5: Commit** - -```bash -git add Data LockTimer.Tests/LockTimerDbTests.cs -git commit -m "feat(locktimer): add SQLite connection and schema migration" -``` - ---- - -## Task 2.3 — ZoneRepository - -**Files:** -- Create: `Zones/ZoneRepository.cs` -- Create: `LockTimer.Tests/ZoneRepositoryTests.cs` - -- [ ] **Step 1: Write failing tests** - -```csharp -using System.Numerics; -using LockTimer.Data; -using LockTimer.Zones; -using Xunit; - -namespace LockTimer.Tests; - -public class ZoneRepositoryTests -{ - private static (LockTimerDb db, ZoneRepository repo) Make() - { - var db = LockTimerDb.OpenInMemory(); - var repo = new ZoneRepository(db.Connection); - return (db, repo); - } - - [Fact] - public void Upsert_inserts_new_zone() - { - var (db, repo) = Make(); - using var _ = db; - - var z = new Zone(ZoneKind.Start, "m1", new(0, 0, 0), new(1, 1, 1), UpdatedAtUnix: 100); - repo.Upsert(z); - - var loaded = repo.GetForMap("m1"); - Assert.Single(loaded); - Assert.Equal(ZoneKind.Start, loaded[0].Kind); - Assert.Equal(new Vector3(1, 1, 1), loaded[0].Max); - } - - [Fact] - public void Upsert_replaces_existing_zone_of_same_kind() - { - var (db, repo) = Make(); - using var _ = db; - - repo.Upsert(new Zone(ZoneKind.Start, "m1", new(0, 0, 0), new(1, 1, 1), 100)); - repo.Upsert(new Zone(ZoneKind.Start, "m1", new(5, 5, 5), new(6, 6, 6), 200)); - - var loaded = repo.GetForMap("m1"); - Assert.Single(loaded); - Assert.Equal(new Vector3(5, 5, 5), loaded[0].Min); - Assert.Equal(200, loaded[0].UpdatedAtUnix); - } - - [Fact] - public void GetForMap_isolates_by_map() - { - var (db, repo) = Make(); - using var _ = db; - - repo.Upsert(new Zone(ZoneKind.Start, "m1", new(0, 0, 0), new(1, 1, 1), 100)); - repo.Upsert(new Zone(ZoneKind.End, "m2", new(0, 0, 0), new(1, 1, 1), 100)); - - Assert.Single(repo.GetForMap("m1")); - Assert.Single(repo.GetForMap("m2")); - Assert.Empty(repo.GetForMap("m3")); - } - - [Fact] - public void Delete_removes_all_zones_for_map() - { - var (db, repo) = Make(); - using var _ = db; - - repo.Upsert(new Zone(ZoneKind.Start, "m1", new(0, 0, 0), new(1, 1, 1), 100)); - repo.Upsert(new Zone(ZoneKind.End, "m1", new(2, 2, 2), new(3, 3, 3), 100)); - repo.DeleteForMap("m1"); - - Assert.Empty(repo.GetForMap("m1")); - } -} -``` - -- [ ] **Step 2: Implement ZoneRepository** - -```csharp -using System.Numerics; -using Microsoft.Data.Sqlite; - -namespace LockTimer.Zones; - -public sealed class ZoneRepository -{ - private readonly SqliteConnection _conn; - - public ZoneRepository(SqliteConnection connection) - { - _conn = connection; - } - - public void Upsert(Zone zone) - { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = @" -INSERT INTO zones (map, kind, min_x, min_y, min_z, max_x, max_y, max_z, updated_at) -VALUES (@map, @kind, @minx, @miny, @minz, @maxx, @maxy, @maxz, @ua) -ON CONFLICT(map, kind) DO UPDATE SET - min_x = excluded.min_x, - min_y = excluded.min_y, - min_z = excluded.min_z, - max_x = excluded.max_x, - max_y = excluded.max_y, - max_z = excluded.max_z, - updated_at = excluded.updated_at;"; - cmd.Parameters.AddWithValue("@map", zone.Map); - cmd.Parameters.AddWithValue("@kind", (int)zone.Kind); - cmd.Parameters.AddWithValue("@minx", zone.Min.X); - cmd.Parameters.AddWithValue("@miny", zone.Min.Y); - cmd.Parameters.AddWithValue("@minz", zone.Min.Z); - cmd.Parameters.AddWithValue("@maxx", zone.Max.X); - cmd.Parameters.AddWithValue("@maxy", zone.Max.Y); - cmd.Parameters.AddWithValue("@maxz", zone.Max.Z); - cmd.Parameters.AddWithValue("@ua", zone.UpdatedAtUnix); - cmd.ExecuteNonQuery(); - } - - public List GetForMap(string map) - { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = @" -SELECT kind, min_x, min_y, min_z, max_x, max_y, max_z, updated_at -FROM zones -WHERE map = @map -ORDER BY kind;"; - cmd.Parameters.AddWithValue("@map", map); - - var list = new List(); - using var r = cmd.ExecuteReader(); - while (r.Read()) - { - list.Add(new Zone( - Kind: (ZoneKind)r.GetInt32(0), - Map: map, - Min: new Vector3((float)r.GetDouble(1), (float)r.GetDouble(2), (float)r.GetDouble(3)), - Max: new Vector3((float)r.GetDouble(4), (float)r.GetDouble(5), (float)r.GetDouble(6)), - UpdatedAtUnix: r.GetInt64(7))); - } - return list; - } - - public void DeleteForMap(string map) - { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = "DELETE FROM zones WHERE map = @map;"; - cmd.Parameters.AddWithValue("@map", map); - cmd.ExecuteNonQuery(); - } -} -``` - -- [ ] **Step 3: Run tests** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj --filter FullyQualifiedName~ZoneRepositoryTests` -Expected: 4 passed. - -- [ ] **Step 4: Commit** - -```bash -git add Zones/ZoneRepository.cs LockTimer.Tests/ZoneRepositoryTests.cs -git commit -m "feat(locktimer): add ZoneRepository with per-map upsert" -``` - ---- - -## Task 2.4 — Record, TimeFormatter, and their tests - -**Files:** -- Create: `Records/Record.cs` -- Create: `Records/TimeFormatter.cs` -- Create: `LockTimer.Tests/TimeFormatterTests.cs` - -- [ ] **Step 1: Create Record.cs** - -```csharp -namespace LockTimer.Records; - -public sealed record Record( - long SteamId, - string Map, - int TimeMs, - string PlayerName, - long AchievedAtUnix); -``` - -- [ ] **Step 2: Write failing TimeFormatter tests** - -```csharp -using LockTimer.Records; -using Xunit; - -namespace LockTimer.Tests; - -public class TimeFormatterTests -{ - [Theory] - [InlineData(0, "0:00:00.000")] - [InlineData(1, "0:00:00.001")] - [InlineData(999, "0:00:00.999")] - [InlineData(1_000, "0:00:01.000")] - [InlineData(60_000, "0:01:00.000")] - [InlineData(83_456, "0:01:23.456")] - [InlineData(3_600_000,"1:00:00.000")] - [InlineData(3_723_456,"1:02:03.456")] - public void FormatTime_matches_expected(int ms, string expected) - { - Assert.Equal(expected, TimeFormatter.FormatTime(ms)); - } -} -``` - -- [ ] **Step 3: Implement TimeFormatter** - -```csharp -namespace LockTimer.Records; - -public static class TimeFormatter -{ - public static string FormatTime(int ms) - { - if (ms < 0) ms = 0; - int totalSec = ms / 1000; - int millis = ms % 1000; - int hours = totalSec / 3600; - int minutes = (totalSec % 3600) / 60; - int seconds = totalSec % 60; - return $"{hours}:{minutes:D2}:{seconds:D2}.{millis:D3}"; - } -} -``` - -- [ ] **Step 4: Run tests** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj --filter FullyQualifiedName~TimeFormatterTests` -Expected: 8 passed. - -- [ ] **Step 5: Commit** - -```bash -git add Records LockTimer.Tests/TimeFormatterTests.cs -git commit -m "feat(locktimer): add Record and TimeFormatter" -``` - ---- - -## Task 2.5 — RecordRepository with UpsertIfFaster - -**Files:** -- Create: `Records/RecordRepository.cs` -- Create: `LockTimer.Tests/RecordRepositoryTests.cs` - -- [ ] **Step 1: Write failing tests** - -```csharp -using LockTimer.Data; -using LockTimer.Records; -using Xunit; - -namespace LockTimer.Tests; - -public class RecordRepositoryTests -{ - private static (LockTimerDb db, RecordRepository repo) Make() - { - var db = LockTimerDb.OpenInMemory(); - var repo = new RecordRepository(db.Connection); - return (db, repo); - } - - [Fact] - public void First_submission_is_new_pb_with_null_previous() - { - var (db, repo) = Make(); - using var _ = db; - - var result = repo.UpsertIfFaster(steamId: 1, map: "m1", timeMs: 10_000, - playerName: "alice", nowUnix: 100); - - Assert.True(result.Changed); - Assert.Null(result.PreviousMs); - } - - [Fact] - public void Faster_submission_updates_pb_and_reports_previous() - { - var (db, repo) = Make(); - using var _ = db; - - repo.UpsertIfFaster(1, "m1", 10_000, "alice", 100); - var result = repo.UpsertIfFaster(1, "m1", 9_000, "alice", 200); - - Assert.True(result.Changed); - Assert.Equal(10_000, result.PreviousMs); - - var pb = repo.GetPb(1, "m1"); - Assert.NotNull(pb); - Assert.Equal(9_000, pb!.TimeMs); - } - - [Fact] - public void Slower_submission_reports_unchanged_with_previous() - { - var (db, repo) = Make(); - using var _ = db; - - repo.UpsertIfFaster(1, "m1", 9_000, "alice", 100); - var result = repo.UpsertIfFaster(1, "m1", 12_000, "alice", 200); - - Assert.False(result.Changed); - Assert.Equal(9_000, result.PreviousMs); - } - - [Fact] - public void Top_returns_fastest_first_across_players() - { - var (db, repo) = Make(); - using var _ = db; - - repo.UpsertIfFaster(1, "m1", 10_000, "alice", 100); - repo.UpsertIfFaster(2, "m1", 8_000, "bob", 100); - repo.UpsertIfFaster(3, "m1", 15_000, "carol", 100); - - var top = repo.GetTop("m1", limit: 10); - Assert.Equal(3, top.Count); - Assert.Equal("bob", top[0].PlayerName); - Assert.Equal("alice", top[1].PlayerName); - Assert.Equal("carol", top[2].PlayerName); - } - - [Fact] - public void GetPb_returns_null_when_missing() - { - var (db, repo) = Make(); - using var _ = db; - - Assert.Null(repo.GetPb(42, "nowhere")); - } -} -``` - -- [ ] **Step 2: Implement RecordRepository** - -```csharp -using Microsoft.Data.Sqlite; - -namespace LockTimer.Records; - -public readonly record struct UpsertResult(bool Changed, int? PreviousMs); - -public sealed class RecordRepository -{ - private readonly SqliteConnection _conn; - - public RecordRepository(SqliteConnection connection) - { - _conn = connection; - } - - public UpsertResult UpsertIfFaster(long steamId, string map, int timeMs, string playerName, long nowUnix) - { - using var tx = _conn.BeginTransaction(); - - int? previous = null; - using (var read = _conn.CreateCommand()) - { - read.Transaction = tx; - read.CommandText = "SELECT time_ms FROM records WHERE steam_id = @sid AND map = @map;"; - read.Parameters.AddWithValue("@sid", steamId); - read.Parameters.AddWithValue("@map", map); - var o = read.ExecuteScalar(); - if (o is long l) previous = (int)l; - } - - bool changed; - if (previous is null) - { - using var ins = _conn.CreateCommand(); - ins.Transaction = tx; - ins.CommandText = @" -INSERT INTO records (steam_id, map, time_ms, player_name, achieved_at) -VALUES (@sid, @map, @t, @n, @at);"; - ins.Parameters.AddWithValue("@sid", steamId); - ins.Parameters.AddWithValue("@map", map); - ins.Parameters.AddWithValue("@t", timeMs); - ins.Parameters.AddWithValue("@n", playerName); - ins.Parameters.AddWithValue("@at", nowUnix); - ins.ExecuteNonQuery(); - changed = true; - } - else if (timeMs < previous) - { - using var upd = _conn.CreateCommand(); - upd.Transaction = tx; - upd.CommandText = @" -UPDATE records -SET time_ms = @t, player_name = @n, achieved_at = @at -WHERE steam_id = @sid AND map = @map;"; - upd.Parameters.AddWithValue("@sid", steamId); - upd.Parameters.AddWithValue("@map", map); - upd.Parameters.AddWithValue("@t", timeMs); - upd.Parameters.AddWithValue("@n", playerName); - upd.Parameters.AddWithValue("@at", nowUnix); - upd.ExecuteNonQuery(); - changed = true; - } - else - { - changed = false; - } - - tx.Commit(); - return new UpsertResult(changed, previous); - } - - public Record? GetPb(long steamId, string map) - { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = @" -SELECT steam_id, map, time_ms, player_name, achieved_at -FROM records -WHERE steam_id = @sid AND map = @map;"; - cmd.Parameters.AddWithValue("@sid", steamId); - cmd.Parameters.AddWithValue("@map", map); - - using var r = cmd.ExecuteReader(); - if (!r.Read()) return null; - return new Record( - SteamId: r.GetInt64(0), - Map: r.GetString(1), - TimeMs: r.GetInt32(2), - PlayerName: r.GetString(3), - AchievedAtUnix: r.GetInt64(4)); - } - - public List GetTop(string map, int limit) - { - using var cmd = _conn.CreateCommand(); - cmd.CommandText = @" -SELECT steam_id, map, time_ms, player_name, achieved_at -FROM records -WHERE map = @map -ORDER BY time_ms ASC -LIMIT @lim;"; - cmd.Parameters.AddWithValue("@map", map); - cmd.Parameters.AddWithValue("@lim", limit); - - var list = new List(); - using var r = cmd.ExecuteReader(); - while (r.Read()) - { - list.Add(new Record( - SteamId: r.GetInt64(0), - Map: r.GetString(1), - TimeMs: r.GetInt32(2), - PlayerName: r.GetString(3), - AchievedAtUnix: r.GetInt64(4))); - } - return list; - } -} -``` - -- [ ] **Step 3: Run tests** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj --filter FullyQualifiedName~RecordRepositoryTests` -Expected: 5 passed. - -- [ ] **Step 4: Commit** - -```bash -git add Records/RecordRepository.cs LockTimer.Tests/RecordRepositoryTests.cs -git commit -m "feat(locktimer): add RecordRepository with PB upsert semantics" -``` - ---- - -# Phase 3 — Timer engine - -Goal: pure FSM that takes (slot, position) ticks and emits transitions. Zero Deadworks dependencies. Fully unit-tested. - -## Task 3.1 — RunState, PlayerRun, FinishedRun types - -**Files:** -- Create: `Timing/RunState.cs` -- Create: `Timing/PlayerRun.cs` -- Create: `Timing/FinishedRun.cs` - -- [ ] **Step 1: Create the types** - -RunState.cs: -```csharp -namespace LockTimer.Timing; - -public enum RunState -{ - Idle, - InStart, - Running, - Finished, -} -``` - -PlayerRun.cs: -```csharp -namespace LockTimer.Timing; - -public sealed class PlayerRun -{ - public RunState State { get; set; } = RunState.Idle; - public long StartTickMs { get; set; } -} -``` - -FinishedRun.cs: -```csharp -namespace LockTimer.Timing; - -public readonly record struct FinishedRun(int Slot, int ElapsedMs); -``` - -- [ ] **Step 2: Commit** - -```bash -git add Timing/RunState.cs Timing/PlayerRun.cs Timing/FinishedRun.cs -git commit -m "feat(locktimer): add timing state types" -``` - -No tests yet — pure data types, exercised in Task 3.2. - ---- - -## Task 3.2 — TimerEngine FSM + tests - -**Files:** -- Create: `Timing/TimerEngine.cs` -- Create: `LockTimer.Tests/TimerEngineTests.cs` - -The engine is pure: callers pass in the current monotonic clock (ms) and the player's world position. No `Environment.TickCount64`, no singletons — the plugin shell injects wall-clock values. This makes FSM tests deterministic. - -- [ ] **Step 1: Write failing tests** - -```csharp -using System.Numerics; -using LockTimer.Timing; -using LockTimer.Zones; -using Xunit; - -namespace LockTimer.Tests; - -public class TimerEngineTests -{ - private static Zone StartZone() => - new(ZoneKind.Start, "m", new(0, 0, 0), new(100, 100, 100), UpdatedAtUnix: 0); - private static Zone EndZone() => - new(ZoneKind.End, "m", new(1000, 0, 0), new(1100, 100, 100), UpdatedAtUnix: 0); - - private static TimerEngine MakeEngine() - { - var e = new TimerEngine(); - e.SetZones(StartZone(), EndZone()); - return e; - } - - [Fact] - public void Idle_player_far_from_both_zones_stays_idle() - { - var e = MakeEngine(); - var f = e.Tick(slot: 0, position: new Vector3(500, 50, 50), nowTickMs: 0); - - Assert.Null(f); - Assert.Equal(RunState.Idle, e.GetRun(0).State); - } - - [Fact] - public void Entering_start_transitions_to_InStart() - { - var e = MakeEngine(); - e.Tick(0, new Vector3(50, 50, 50), nowTickMs: 0); - - Assert.Equal(RunState.InStart, e.GetRun(0).State); - } - - [Fact] - public void Leaving_start_transitions_to_Running_with_start_tick() - { - var e = MakeEngine(); - e.Tick(0, new Vector3(50, 50, 50), nowTickMs: 1000); - e.Tick(0, new Vector3(500, 50, 50), nowTickMs: 1500); - - var run = e.GetRun(0); - Assert.Equal(RunState.Running, run.State); - Assert.Equal(1500, run.StartTickMs); - } - - [Fact] - public void Entering_end_while_running_emits_finished_with_elapsed() - { - var e = MakeEngine(); - e.Tick(0, new Vector3(50, 50, 50), nowTickMs: 1000); - e.Tick(0, new Vector3(500, 50, 50), nowTickMs: 2000); // Running - var finished = e.Tick(0, new Vector3(1050, 50, 50), nowTickMs: 8000); - - Assert.NotNull(finished); - Assert.Equal(0, finished!.Value.Slot); - Assert.Equal(6000, finished.Value.ElapsedMs); - Assert.Equal(RunState.Idle, e.GetRun(0).State); // flushed to Idle same tick - } - - [Fact] - public void Re_entering_start_while_running_resets_to_InStart() - { - var e = MakeEngine(); - e.Tick(0, new Vector3(50, 50, 50), 0); - e.Tick(0, new Vector3(500, 50, 50), 500); // Running - e.Tick(0, new Vector3(50, 50, 50), 1000); // back in start - - Assert.Equal(RunState.InStart, e.GetRun(0).State); - } - - [Fact] - public void Missing_zones_skip_all_transitions() - { - var e = new TimerEngine(); - var f = e.Tick(0, new Vector3(50, 50, 50), 0); - - Assert.Null(f); - Assert.Equal(RunState.Idle, e.GetRun(0).State); - } - - [Fact] - public void ResetAll_returns_every_player_to_idle() - { - var e = MakeEngine(); - e.Tick(0, new Vector3(50, 50, 50), 0); - e.Tick(1, new Vector3(500, 50, 50), 0); - - e.ResetAll(); - - Assert.Equal(RunState.Idle, e.GetRun(0).State); - Assert.Equal(RunState.Idle, e.GetRun(1).State); - } - - [Fact] - public void Remove_evicts_player_state() - { - var e = MakeEngine(); - e.Tick(0, new Vector3(50, 50, 50), 0); - - e.Remove(0); - - // Fresh GetRun creates a new Idle run - Assert.Equal(RunState.Idle, e.GetRun(0).State); - } - - [Fact] - public void Per_player_state_is_isolated() - { - var e = MakeEngine(); - - e.Tick(0, new Vector3(50, 50, 50), 0); - e.Tick(1, new Vector3(500, 50, 50), 0); - - Assert.Equal(RunState.InStart, e.GetRun(0).State); - Assert.Equal(RunState.Idle, e.GetRun(1).State); - } -} -``` - -- [ ] **Step 2: Implement TimerEngine** - -```csharp -using System.Numerics; -using LockTimer.Zones; - -namespace LockTimer.Timing; - -public sealed class TimerEngine -{ - private readonly Dictionary _runs = new(); - private Zone? _start; - private Zone? _end; - - public void SetZones(Zone? start, Zone? end) - { - _start = start; - _end = end; - } - - public void Remove(int slot) => _runs.Remove(slot); - - public void ResetAll() - { - foreach (var run in _runs.Values) - { - run.State = RunState.Idle; - run.StartTickMs = 0; - } - } - - public PlayerRun GetRun(int slot) - { - if (!_runs.TryGetValue(slot, out var run)) - { - run = new PlayerRun(); - _runs[slot] = run; - } - return run; - } - - public FinishedRun? Tick(int slot, Vector3 position, long nowTickMs) - { - if (_start is null || _end is null) return null; - - var run = GetRun(slot); - bool inStart = _start.Contains(position); - bool inEnd = _end.Contains(position); - - switch (run.State) - { - case RunState.Idle: - if (inStart) run.State = RunState.InStart; - return null; - - case RunState.InStart: - if (!inStart) - { - run.State = RunState.Running; - run.StartTickMs = nowTickMs; - } - return null; - - case RunState.Running: - if (inStart) - { - run.State = RunState.InStart; - run.StartTickMs = 0; - return null; - } - if (inEnd) - { - long elapsed = nowTickMs - run.StartTickMs; - if (elapsed < 0) elapsed = 0; - if (elapsed > int.MaxValue) elapsed = int.MaxValue; - run.State = RunState.Idle; - run.StartTickMs = 0; - return new FinishedRun(slot, (int)elapsed); - } - return null; - - case RunState.Finished: - run.State = RunState.Idle; - return null; - - default: - return null; - } - } -} -``` - -- [ ] **Step 3: Run tests** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj --filter FullyQualifiedName~TimerEngineTests` -Expected: 9 passed. - -- [ ] **Step 4: Commit** - -```bash -git add Timing/TimerEngine.cs LockTimer.Tests/TimerEngineTests.cs -git commit -m "feat(locktimer): add pure-logic TimerEngine FSM" -``` - ---- - -# Phase 4 — Zone editor, commands, and shell wiring - -Now we start touching DeadworksManaged.Api. Nothing in this phase is unit-tested — tests would require the game. We trade test coverage for type-checking against the real API. - -## Task 4.1 — ZoneEditor (pending points + raycast) - -**Files:** -- Create: `Zones/ZoneEditor.cs` - -- [ ] **Step 1: Create ZoneEditor** - -```csharp -using System.Numerics; -using DeadworksManaged.Api; -using LockTimer.Timing; - -namespace LockTimer.Zones; - -public sealed class ZoneEditor -{ - private readonly ZoneRepository _zones; - private readonly TimerEngine _engine; - - private Vector3? _pendingStart1; - private Vector3? _pendingStart2; - private Vector3? _pendingEnd1; - private Vector3? _pendingEnd2; - - public ZoneEditor(ZoneRepository zones, TimerEngine engine) - { - _zones = zones; - _engine = engine; - } - - public EditResult CaptureStart1(CCitadelPlayerPawn pawn) => Capture(pawn, ref _pendingStart1, "start p1"); - public EditResult CaptureStart2(CCitadelPlayerPawn pawn) => Capture(pawn, ref _pendingStart2, "start p2"); - public EditResult CaptureEnd1(CCitadelPlayerPawn pawn) => Capture(pawn, ref _pendingEnd1, "end p1"); - public EditResult CaptureEnd2(CCitadelPlayerPawn pawn) => Capture(pawn, ref _pendingEnd2, "end p2"); - - private EditResult Capture(CCitadelPlayerPawn pawn, ref Vector3? slot, string label) - { - var eye = pawn.EyePosition; - var angles = pawn.ViewAngles; - - var trace = CGameTrace.Create(); - Trace.SimpleTraceAngles( - eye, angles, - RayType_t.Line, RnQueryObjectSet.All, - MaskTrace.Solid, MaskTrace.Empty, MaskTrace.Empty, - CollisionGroup.Always, ref trace, - filterEntity: pawn, - maxDistance: 8192f); - - if (!trace.DidHit) - return EditResult.Miss($"no surface hit within 8192u for {label}"); - - var hit = eye + ComputeForward(angles) * (trace.Fraction * 8192f); - slot = hit; - return EditResult.Ok($"{label} set at ({hit.X:F1}, {hit.Y:F1}, {hit.Z:F1})"); - } - - private static Vector3 ComputeForward(Vector3 angles) - { - float pitch = angles.X * MathF.PI / 180f; - float yaw = angles.Y * MathF.PI / 180f; - return new Vector3( - MathF.Cos(pitch) * MathF.Cos(yaw), - MathF.Cos(pitch) * MathF.Sin(yaw), - -MathF.Sin(pitch)); - } - - public SaveResult SaveZones(string map, long nowUnix) - { - var missing = new List(); - if (_pendingStart1 is null) missing.Add("start1"); - if (_pendingStart2 is null) missing.Add("start2"); - if (_pendingEnd1 is null) missing.Add("end1"); - if (_pendingEnd2 is null) missing.Add("end2"); - if (missing.Count > 0) - return SaveResult.Failure($"need 4 points — missing: {string.Join(", ", missing)}"); - - var start = Zone.FromCorners(ZoneKind.Start, map, _pendingStart1!.Value, _pendingStart2!.Value, nowUnix); - var end = Zone.FromCorners(ZoneKind.End, map, _pendingEnd1!.Value, _pendingEnd2!.Value, nowUnix); - - if (start.IsZeroVolume) return SaveResult.Failure("start zone has zero volume"); - if (end.IsZeroVolume) return SaveResult.Failure("end zone has zero volume"); - - _zones.Upsert(start); - _zones.Upsert(end); - _engine.SetZones(start, end); - - _pendingStart1 = _pendingStart2 = _pendingEnd1 = _pendingEnd2 = null; - - return SaveResult.Success(start, end); - } - - public void DeleteZones(string map) - { - _zones.DeleteForMap(map); - _engine.SetZones(null, null); - _engine.ResetAll(); - } - - public PendingStatus GetPendingStatus() - => new(_pendingStart1.HasValue, _pendingStart2.HasValue, _pendingEnd1.HasValue, _pendingEnd2.HasValue); -} - -public readonly record struct EditResult(bool Ok, string Message) -{ - public static EditResult Ok(string m) => new(true, m); - public static EditResult Miss(string m) => new(false, m); -} - -public readonly record struct SaveResult(bool Ok, string Message, Zone? Start, Zone? End) -{ - public static SaveResult Success(Zone s, Zone e) => new(true, $"zones saved for {s.Map}", s, e); - public static SaveResult Failure(string m) => new(false, m, null, null); -} - -public readonly record struct PendingStatus(bool Start1, bool Start2, bool End1, bool End2); -``` - -- [ ] **Step 2: Verify plugin builds** - -Run: `dotnet build LockTimer.csproj` -Expected: 0 errors. You may see 0 warnings thanks to `TreatWarningsAsErrors`. If the `Trace.SimpleTraceAngles` signature in your local DeadworksManaged.Api.dll differs from `deadworks/managed/DeadworksManaged.Api/Trace/TraceSystem.cs`, fix the call site — the source in `deadworks/` is authoritative. - -- [ ] **Step 3: Commit** - -```bash -git add Zones/ZoneEditor.cs -git commit -m "feat(locktimer): add ZoneEditor with crosshair raycast capture" -``` - ---- - -## Task 4.2 — ZoneRenderer (corner + edge-midpoint markers) - -**Files:** -- Create: `Zones/ZoneRenderer.cs` - -**API constraint.** `CParticleSystem.Builder.WithDataCP` in the vendored DeadworksManaged.Api (`deadworks/managed/DeadworksManaged.Api/ParticleSystem.cs` lines 45–82) only stores ONE data control point per particle — `_dataCP` and `_dataCPValue` are single scalar fields, not a dict. That rules out two-CP beam effects (CP0=start, CP1=end) unless we extend the API. To ship without touching the vendored repo, the renderer spawns a static marker particle at each of the 8 corners of the AABB *plus* one at the midpoint of each of the 12 edges — 20 particles per zone total. That gives a clearly visible glowing outline without needing beam primitives. - -**Particle effect path.** The chosen effect is `"particles/ui_mouseactions/ping_world.vpcf"` — a compact glowing sprite that ships with Deadlock and works as a static position marker. If that path doesn't resolve at runtime (the particle system silently does nothing), swap to any other single-point particle you can find under `game/citadel/pak01_dir/particles/ui/` or `particles/generic_gameplay/`. This is the one knob to tune after first in-game load. - -- [ ] **Step 1: Create ZoneRenderer** - -```csharp -using System.Drawing; -using System.Numerics; -using DeadworksManaged.Api; - -namespace LockTimer.Zones; - -public sealed class ZoneRenderer -{ - // Static marker particle. See Task 4.2 notes for swap candidates if this - // doesn't render visibly on first in-game load. - private const string MarkerParticle = "particles/ui_mouseactions/ping_world.vpcf"; - - private readonly Dictionary> _spawned = new(); - - public void Render(Zone zone) - { - Clear(zone.Kind); - - var color = zone.Kind == ZoneKind.Start ? Color.LimeGreen : Color.Red; - var markers = new List(20); - - foreach (var point in OutlinePoints(zone.Min, zone.Max)) - { - var p = CParticleSystem - .Create(MarkerParticle) - .AtPosition(point) - .WithTint(color, tintCP: 0) - .StartActive(true) - .Spawn(); - if (p is not null) markers.Add(p); - } - - _spawned[zone.Kind] = markers; - } - - public void Clear(ZoneKind kind) - { - if (!_spawned.TryGetValue(kind, out var list)) return; - foreach (var p in list) p.Destroy(); - list.Clear(); - _spawned.Remove(kind); - } - - public void ClearAll() - { - foreach (var list in _spawned.Values) - foreach (var p in list) p.Destroy(); - _spawned.Clear(); - } - - private static IEnumerable OutlinePoints(Vector3 min, Vector3 max) - { - // 8 corners - var c000 = new Vector3(min.X, min.Y, min.Z); - var c100 = new Vector3(max.X, min.Y, min.Z); - var c010 = new Vector3(min.X, max.Y, min.Z); - var c110 = new Vector3(max.X, max.Y, min.Z); - var c001 = new Vector3(min.X, min.Y, max.Z); - var c101 = new Vector3(max.X, min.Y, max.Z); - var c011 = new Vector3(min.X, max.Y, max.Z); - var c111 = new Vector3(max.X, max.Y, max.Z); - - yield return c000; yield return c100; yield return c010; yield return c110; - yield return c001; yield return c101; yield return c011; yield return c111; - - // 12 edge midpoints — makes the outline readable even on large zones - yield return Vector3.Lerp(c000, c100, 0.5f); - yield return Vector3.Lerp(c100, c110, 0.5f); - yield return Vector3.Lerp(c110, c010, 0.5f); - yield return Vector3.Lerp(c010, c000, 0.5f); - yield return Vector3.Lerp(c001, c101, 0.5f); - yield return Vector3.Lerp(c101, c111, 0.5f); - yield return Vector3.Lerp(c111, c011, 0.5f); - yield return Vector3.Lerp(c011, c001, 0.5f); - yield return Vector3.Lerp(c000, c001, 0.5f); - yield return Vector3.Lerp(c100, c101, 0.5f); - yield return Vector3.Lerp(c110, c111, 0.5f); - yield return Vector3.Lerp(c010, c011, 0.5f); - } -} -``` - -Future upgrade path (out of scope for this plan, but documented here so nobody re-derives it): to render true glowing edges, extend `CParticleSystem.Builder` in `deadworks/managed/DeadworksManaged.Api/ParticleSystem.cs` to accept a `Dictionary` of data CPs instead of a single scalar, then change `Spawn()` to write each CP via the schema array accessor. At that point this renderer can switch to one particle per edge with CP0/CP1 set to the endpoints. - -- [ ] **Step 2: Verify build** - -Run: `dotnet build LockTimer.csproj` -Expected: 0 errors. If `Builder.WithDataCP` or `.Spawn()` signatures differ from `deadworks/managed/DeadworksManaged.Api/ParticleSystem.cs`, adjust the chain. - -- [ ] **Step 3: Commit** - -```bash -git add Zones/ZoneRenderer.cs -git commit -m "feat(locktimer): add ZoneRenderer for AABB edge particles" -``` - ---- - -## Task 4.3 — ChatCommands - -**Files:** -- Create: `Commands/ChatCommands.cs` - -- [ ] **Step 1: Create ChatCommands.cs** - -```csharp -using DeadworksManaged.Api; -using LockTimer.Records; -using LockTimer.Timing; -using LockTimer.Zones; - -namespace LockTimer.Commands; - -public sealed class ChatCommands -{ - private readonly ZoneEditor _editor; - private readonly ZoneRenderer _renderer; - private readonly RecordRepository _records; - private readonly TimerEngine _engine; - - public ChatCommands( - ZoneEditor editor, - ZoneRenderer renderer, - RecordRepository records, - TimerEngine engine) - { - _editor = editor; - _renderer = renderer; - _records = records; - _engine = engine; - } - - private static CCitadelPlayerPawn? PawnOf(ChatMessage msg) - => msg.Player?.Pawn?.As(); - - private static void Reply(ChatMessage msg, string text) - => Chat.SayToPlayer(msg.Player, $"[LockTimer] {text}"); - - [ChatCommand("!start1")] - public void OnStart1(ChatMessage msg) - { - var pawn = PawnOf(msg); if (pawn is null) return; - Reply(msg, _editor.CaptureStart1(pawn).Message); - } - - [ChatCommand("!start2")] - public void OnStart2(ChatMessage msg) - { - var pawn = PawnOf(msg); if (pawn is null) return; - Reply(msg, _editor.CaptureStart2(pawn).Message); - } - - [ChatCommand("!end1")] - public void OnEnd1(ChatMessage msg) - { - var pawn = PawnOf(msg); if (pawn is null) return; - Reply(msg, _editor.CaptureEnd1(pawn).Message); - } - - [ChatCommand("!end2")] - public void OnEnd2(ChatMessage msg) - { - var pawn = PawnOf(msg); if (pawn is null) return; - Reply(msg, _editor.CaptureEnd2(pawn).Message); - } - - [ChatCommand("!savezones")] - public void OnSaveZones(ChatMessage msg) - { - var result = _editor.SaveZones(Server.MapName, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); - Reply(msg, result.Message); - if (!result.Ok) return; - - _renderer.Render(result.Start!); - _renderer.Render(result.End!); - } - - [ChatCommand("!delzones")] - public void OnDelZones(ChatMessage msg) - { - _editor.DeleteZones(Server.MapName); - _renderer.ClearAll(); - Reply(msg, $"zones cleared for {Server.MapName}"); - } - - [ChatCommand("!zones")] - public void OnZonesStatus(ChatMessage msg) - { - var p = _editor.GetPendingStatus(); - Reply(msg, $"pending: start1={p.Start1} start2={p.Start2} end1={p.End1} end2={p.End2}"); - } - - [ChatCommand("!pb")] - public void OnPb(ChatMessage msg) - { - var sid = (long)(msg.Player?.SteamId ?? 0); - var pb = _records.GetPb(sid, Server.MapName); - Reply(msg, pb is null - ? "no PB yet" - : $"your PB on {Server.MapName}: {TimeFormatter.FormatTime(pb.TimeMs)}"); - } - - [ChatCommand("!top")] - public void OnTop(ChatMessage msg) - { - var top = _records.GetTop(Server.MapName, limit: 10); - if (top.Count == 0) - { - Reply(msg, $"no records on {Server.MapName} yet"); - return; - } - for (int i = 0; i < top.Count; i++) - { - var r = top[i]; - Reply(msg, $"{i + 1}. {r.PlayerName} {TimeFormatter.FormatTime(r.TimeMs)}"); - } - } - - [ChatCommand("!reset")] - public void OnReset(ChatMessage msg) - { - var slot = msg.Player?.Slot ?? -1; - if (slot < 0) return; - _engine.Remove(slot); - Reply(msg, "run reset"); - } -} -``` - -Notes about API shapes: -- `ChatMessage.Player` and `.SteamId` / `.Slot` / `.Pawn` come from the real `ChatMessage` record in DeadworksManaged.Api. If your version differs, adjust the accessors — the source is in `deadworks/managed/DeadworksManaged.Api/Events/ChatMessage.cs`. -- `Chat.SayToPlayer(...)` is the expected helper per other example plugins; if it's named differently, grep `Chat.Say` in `deadworks/managed/plugins/` for the correct call and substitute. - -- [ ] **Step 2: Verify build** - -Run: `dotnet build LockTimer.csproj` -Expected: 0 errors. - -- [ ] **Step 3: Commit** - -```bash -git add Commands/ChatCommands.cs -git commit -m "feat(locktimer): add chat commands for zones and records" -``` - ---- - -# Phase 5 — Plugin shell integration - -Wire everything into `LockTimerPlugin` so the Deadworks loader can actually run it. - -## Task 5.1 — Expand LockTimerPlugin - -**Files:** -- Modify: `LockTimerPlugin.cs` - -- [ ] **Step 1: Rewrite the plugin shell** - -```csharp -using System.IO; -using DeadworksManaged.Api; -using LockTimer.Commands; -using LockTimer.Data; -using LockTimer.Records; -using LockTimer.Timing; -using LockTimer.Zones; - -namespace LockTimer; - -public class LockTimerPlugin : DeadworksPluginBase -{ - public override string Name => "LockTimer"; - - private LockTimerDb? _db; - private ZoneRepository? _zones; - private RecordRepository? _records; - private ZoneRenderer? _renderer; - private TimerEngine? _engine; - private ZoneEditor? _editor; - private ChatCommands? _commands; - - public override void OnLoad(bool isReload) - { - try - { - var dir = Path.Combine(AppContext.BaseDirectory, "LockTimer"); - Directory.CreateDirectory(dir); - var dbPath = Path.Combine(dir, "locktimer.db"); - - _db = LockTimerDb.Open(dbPath); - _zones = new ZoneRepository(_db.Connection); - _records = new RecordRepository(_db.Connection); - _renderer = new ZoneRenderer(); - _engine = new TimerEngine(); - _editor = new ZoneEditor(_zones, _engine); - _commands = new ChatCommands(_editor, _renderer, _records, _engine); - - PluginRegistry.RegisterChatCommands(this, _commands); - - Console.WriteLine($"[{Name}] {(isReload ? "Reloaded" : "Loaded")}. DB: {dbPath}"); - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnLoad failed: {ex}"); - } - } - - public override void OnUnload() - { - try - { - _renderer?.ClearAll(); - _db?.Dispose(); - Console.WriteLine($"[{Name}] Unloaded."); - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnUnload failed: {ex}"); - } - } - - public override void OnStartupServer() - { - try - { - if (_zones is null || _engine is null || _renderer is null) return; - - _renderer.ClearAll(); - _engine.ResetAll(); - - var map = Server.MapName; - if (string.IsNullOrEmpty(map)) return; - - var zones = _zones.GetForMap(map); - var start = zones.FirstOrDefault(z => z.Kind == ZoneKind.Start); - var end = zones.FirstOrDefault(z => z.Kind == ZoneKind.End); - _engine.SetZones(start, end); - - if (start is not null) _renderer.Render(start); - if (end is not null) _renderer.Render(end); - - Console.WriteLine($"[{Name}] Loaded {zones.Count} zone(s) for {map}."); - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnStartupServer failed: {ex}"); - } - } - - public override void OnClientDisconnect(ClientDisconnectedEvent args) - { - _engine?.Remove(args.Slot); - } - - public override void OnGameFrame(bool simulating, bool firstTick, bool lastTick) - { - if (!simulating || _engine is null || _records is null) return; - - try - { - long now = Environment.TickCount64; - - foreach (var player in Players.All) - { - if (player.IsBot) continue; - var pawn = player.Pawn?.As(); - if (pawn is null) continue; - - var finished = _engine.Tick(player.Slot, pawn.Position, now); - if (finished is null) continue; - - OnRunFinished(player, finished.Value); - } - } - catch (Exception ex) - { - Console.WriteLine($"[{Name}] OnGameFrame failed: {ex}"); - } - } - - private void OnRunFinished(CCitadelPlayerController player, FinishedRun run) - { - if (_records is null) return; - - long steamId = (long)player.SteamId; - var result = _records.UpsertIfFaster( - steamId: steamId, - map: Server.MapName, - timeMs: run.ElapsedMs, - playerName: player.PlayerName, - nowUnix: DateTimeOffset.UtcNow.ToUnixTimeSeconds()); - - var formatted = TimeFormatter.FormatTime(run.ElapsedMs); - string msg; - if (result.Changed && result.PreviousMs is null) - msg = $"[LockTimer] {player.PlayerName} finished in {formatted} (new PB!)"; - else if (result.Changed) - msg = $"[LockTimer] {player.PlayerName} finished in {formatted} " + - $"(new PB! prev {TimeFormatter.FormatTime(result.PreviousMs!.Value)})"; - else - msg = $"[LockTimer] {player.PlayerName} finished in {formatted} " + - $"(pb {TimeFormatter.FormatTime(result.PreviousMs!.Value)})"; - - Chat.SayToAll(msg); - } -} -``` - -API reality-check items — if your DeadworksManaged.Api version differs from the source at `deadworks/managed/DeadworksManaged.Api/`, these are the call sites to adjust: - -- `Players.All` — enumeration of connected controllers. Check `deadworks/managed/DeadworksManaged.Api/Entities/Players.cs` for the actual method. -- `player.Pawn?.As()` — if `Pawn` isn't a direct property, fetch via `NativeInterop.GetPlayerPawn(slot)` or similar. -- `PluginRegistry.RegisterChatCommands(this, _commands)` — chat command registration entry point. If commands on the plugin class itself are auto-registered but a separate class needs explicit registration, check how `DeathmatchPlugin` handles it. You may need to move command methods onto the plugin class and forward to `_commands`, or expose a registry call that the plugin loader supports. -- `Chat.SayToAll` / `Chat.SayToPlayer` — actual names in the referenced DeadworksManaged.Api version. Grep `Chat.Say` in `../../deadworks/managed/plugins/` for the canonical invocation. -- `CCitadelPlayerController.SteamId` and `.PlayerName` — confirm in `deadworks/managed/DeadworksManaged.Api/Entities/PlayerEntities.cs`. If `SteamId` lives on `Player`/controller differently, adapt. - -If any of these resist a clean fix, the simplest workaround is moving `[ChatCommand]` methods directly onto `LockTimerPlugin` (Deadworks plugin loader scans the plugin class itself for attributes — see `PluginLoader.Events.cs`) and have each command delegate to the stored service fields. That refactor is safe and doesn't change behavior — keep it in the same commit. - -- [ ] **Step 2: Verify plugin builds** - -Run: `dotnet build LockTimer.csproj` -Expected: 0 errors, 0 warnings. Iterate on the API-reality items above until it builds clean. - -- [ ] **Step 3: Commit** - -```bash -git add LockTimerPlugin.cs -git commit -m "feat(locktimer): wire plugin shell — zones, timer, records, commands" -``` - ---- - -# Phase 6 — Polish - -## Task 6.1 — Add plugin README with manual smoke checklist - -**Files:** -- Create: `README.md` - -- [ ] **Step 1: Write README** - -```markdown -# LockTimer - -Minimalist speedrun-timer plugin for Deadlock (Deadworks managed). - -## Commands - -| Command | Effect | -|---|---| -| `!start1` / `!start2` | Capture corner 1 / 2 of the start zone at crosshair hit | -| `!end1` / `!end2` | Capture corner 1 / 2 of the end zone | -| `!savezones` | Persist both zones for the current map and render edges | -| `!delzones` | Remove zones for the current map | -| `!zones` | Show which pending corners are staged | -| `!pb` | Show your PB on the current map | -| `!top` | Show top-10 times on the current map | -| `!reset` | Reset your own run state | - -Timer starts when your feet leave the start zone and stops when they enter the end zone. Re-entering start while running resets you to InStart. - -## Database - -SQLite file at `…/managed/plugins/LockTimer/locktimer.db`. PB records only (one row per `steam_id, map`). - -## Manual smoke checklist - -After building and loading: - -- [ ] 40 marker particles (20 per zone: 8 corners + 12 edge midpoints) spawn on `!savezones` -- [ ] Walking from start to end records a time in chat -- [ ] Beating your PB shows `(new PB! prev …)` message -- [ ] Slower run shows `(pb …)` message, no DB change -- [ ] `!delzones` removes particles and clears the DB rows -- [ ] Disconnect mid-run, reconnect — no stale state -- [ ] Map change mid-run abandons the run cleanly -``` - -- [ ] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs(locktimer): add README with commands and smoke checklist" -``` - ---- - -## Task 6.2 — Final build-and-test clean pass - -- [ ] **Step 1: Full solution build** - -Run: `dotnet build LockTimer.csproj LockTimer.Tests/LockTimer.Tests.csproj` -Expected: 0 errors, 0 warnings across both projects. - -- [ ] **Step 2: Full test run** - -Run: `dotnet test LockTimer.Tests/LockTimer.Tests.csproj` -Expected: 33 passed (5 ZoneTests + 2 LockTimerDbTests + 4 ZoneRepositoryTests + 8 TimeFormatterTests + 5 RecordRepositoryTests + 9 TimerEngineTests). - -- [ ] **Step 3: Confirm no stray markers left** - -Run: `grep -rn "TODO\|FIXME\|XXX" .` -Expected: no matches. (The particle path in `ZoneRenderer.cs` is a documented constant, not a TODO.) - -- [ ] **Step 4: Commit if anything touched** - -```bash -git status -git commit -am "chore(locktimer): final clean pass" # only if there are changes -``` - ---- - -# Task summary - -| Phase | Tasks | Tests | -|---|---|---| -| 1. Scaffold | 1.1, 1.2, 1.3 | — | -| 2. Data layer | 2.1, 2.2, 2.3, 2.4, 2.5 | 24 | -| 3. Timer engine | 3.1, 3.2 | 9 | -| 4. Editor/renderer/commands | 4.1, 4.2, 4.3 | — | -| 5. Shell integration | 5.1 | — | -| 6. Polish | 6.1, 6.2 | — | - -Total: 15 tasks, 33 unit tests, ~12 commits. diff --git a/LockTimer/docs/spec.md b/LockTimer/docs/spec.md deleted file mode 100644 index 9c71087..0000000 --- a/LockTimer/docs/spec.md +++ /dev/null @@ -1,312 +0,0 @@ -# LockTimer — Design - -**Date:** 2026-04-12 -**Status:** Approved for implementation planning -**Target:** `Plugins/LockTimer/` — Deadworks managed server plugin - -## Summary - -LockTimer is a minimalist speedrun timer plugin for Deadlock, modeled after -the CS2 plugin [poor-sharptimer](https://github.com/Letaryat/poor-sharptimer) -but stripped to the essentials. Players define two axis-aligned bounding-box -zones per map — a `start` and an `end` — by capturing crosshair raycasts via -chat commands. The plugin times each player's run between the two zones and -persists personal best (PB) records to a local SQLite database. Zone edges -are rendered as glowing particle beams visible to everyone. - -There are no permission guards of any kind — every command is callable by -every connected player. This is intentional for the MVP. - -## Non-goals - -- Multi-stage runs, checkpoints, or bonuses. -- Multi-course maps (multiple independent start/end pairs). -- Full run history — MVP stores PB only. -- Web / HTTP APIs, Discord integration, cross-server syncing. -- Permission system, admin commands, or authentication. -- Zone rotation (OBBs). Zones are axis-aligned boxes. -- Anti-cheat / replay validation. - -## Feasibility — DeadworksManaged.Api surface verified - -Everything required exists in the current managed API (checked against -`deadworks/managed/DeadworksManaged.Api/`): - -| Need | API | -|---|---| -| Crosshair raycast | `Trace.SimpleTraceAngles(eyePos, viewAngles, …)` / `Trace.Ray(start, end, ignore)` | -| Player eye pos / angles | `CCitadelPlayerPawn.EyePosition`, `.ViewAngles` (raw float precision) | -| Player world position | `CBaseEntity.Position` (AbsOrigin) | -| Chat commands | `[ChatCommand("!cmd")]` attribute | -| Persistent particles | `CParticleSystem.Create(...).WithDataCP(cp, vec).Spawn()` | -| Map name | `Server.MapName` | -| Custom content path | `Server.AddSearchPath(path, "GAME")` | -| Frame tick | `DeadworksPluginBase.OnGameFrame(...)` | -| Client lifecycle | `OnClientPutInServer`, `OnClientDisconnect` | -| Ground / velocity | `CBaseEntity.IsOnGround`, `.AbsVelocity` (pulled 2026-04-12) | - -## Architecture — layered services - -Single `Plugins/LockTimer/LockTimer.csproj` (net10.0, class library, references -`DeadworksManaged.Api.dll` + `Google.Protobuf.dll`, `DeployToGame` target). -`LockTimerPlugin : DeadworksPluginBase` is a thin shell that wires focused -services together: - -``` -Plugins/LockTimer/ -├── LockTimer.csproj -├── LockTimerPlugin.cs // entry, ~120 lines wiring -├── Zones/ -│ ├── Zone.cs // record: Id, Map, Kind, Min, Max, UpdatedAt -│ ├── ZoneRepository.cs // SQLite CRUD, scoped per Server.MapName -│ ├── ZoneEditor.cs // !start1/2, !end1/2, !savezones, !delzones -│ └── ZoneRenderer.cs // spawns/destroys particle edges per Zone -├── Timing/ -│ ├── RunState.cs // enum Idle | InStart | Running | Finished -│ ├── PlayerRun.cs // per-player: State, StartTickMs, WasInStart -│ └── TimerEngine.cs // OnGameFrame tick, AABB containment, FSM -├── Records/ -│ ├── Record.cs // record: SteamId, Map, TimeMs, Name, AchievedAt -│ ├── RecordRepository.cs // SQLite CRUD, PB-only upsert -│ └── TimeFormatter.cs // FormatTime(int ms) -> "H:MM:SS.fff" -├── Data/ -│ ├── LockTimerDb.cs // SQLite connection + migrations -│ └── Migrations/001_initial.sql -└── Commands/ - └── ChatCommands.cs // [ChatCommand] methods, forward to services -``` - -**Dependency direction:** `Commands → Editor/Engine → Repositories → LockTimerDb`. -`ZoneRenderer` is invoked only by `LockTimerPlugin` during load / save / delete — -it never calls repos. `TimerEngine` never talks to the DB directly; it holds -cached `Zone?` references injected by the plugin shell. - -**DB location:** `/bin/win64/managed/plugins/LockTimer/locktimer.db`. -Created on first run; schema applied via `CREATE TABLE IF NOT EXISTS`. - -**NuGet dependencies:** -- `Microsoft.Data.Sqlite.Core` -- `SQLitePCLRaw.bundle_e_sqlite3` - -Both must be deployed alongside the plugin DLL. The csproj `DeployToGame` -target's `DeployFiles` ItemGroup is extended to glob `$(OutputPath)*.dll` and -the `e_sqlite3.dll` native binary from the build output. - -## Data flow — timer state machine - -Per-player state lives in `TimerEngine` as `Dictionary`. -Populated in `OnClientPutInServer`, removed in `OnClientDisconnect`. Bots -(`IsBot == true`) never enter the dictionary. - -**`PlayerRun`** -- `State: RunState` -- `StartTickMs: long` — `Environment.TickCount64` at the moment Running began -- `WasInStart: bool` — previous-frame containment, for edge detection - -**State machine** (ticked from `OnGameFrame`, only when `simulating == true`): - -``` -Idle ──pos∈start──▶ InStart -InStart ──pos∉start──▶ Running (StartTickMs = now) -Running ──pos∈end────▶ Finished (elapsed = now - StartTickMs) -Running ──pos∈start──▶ InStart (reset; elapsed discarded) -Finished ──next tick──▶ Idle (one-shot, flush to DB before) -``` - -**On `Finished`:** -1. `elapsed = Environment.TickCount64 - StartTickMs` (int32, capped at int32.MaxValue). -2. `RecordRepository.UpsertIfFaster(steamId, map, elapsed, name, nowUnix)` returns `(bool changed, int? previousMs)`. -3. Broadcast chat message: - - `"[LockTimer] finished in 0:01:23.456 (new PB!)"` if changed and previous was null - - `"[LockTimer] finished in 0:01:23.456 (new PB! prev 0:01:25.777)"` if changed and previous existed - - `"[LockTimer] finished in 0:01:27.444 (pb 0:01:25.777)"` if slower than PB -4. State → `Idle` on the same tick. - -**Containment check:** `Zone.Contains(Vector3 p) => -p.X >= Min.X && p.X <= Max.X && p.Y >= Min.Y && p.Y <= Max.Y && p.Z >= Min.Z && p.Z <= Max.Z`. -The player's feet position is `pawn.Position` (AbsOrigin). - -**Frame cost:** one AABB test × connected players × 2 zones per frame. -No per-frame allocations — `PlayerRun` instances are kept alive between ticks. - -**Map change:** `OnStartupServer` clears all `PlayerRun`s to `Idle`, reloads -zones for the new `Server.MapName`, re-spawns particle edges. Any in-flight run -is abandoned. - -## Commands - -All commands are `[ChatCommand("...")]` methods on `ChatCommands.cs`. No -permission checks. Unknown points / incomplete saves produce a chat response -to the caller only. - -### Zone editing - -| Command | Behavior | -|---|---| -| `!start1` | Raycast from caller's `EyePosition` along `ViewAngles`, `maxDistance=8192`, mask `Solid`, ignoring caller's own pawn. Hit point written to `ZoneEditor._pendingStart.P1`. Chat: `"start p1 set at (x, y, z)"` or `"no surface hit within 8192u"`. | -| `!start2` | Same, writes `_pendingStart.P2`. | -| `!end1` / `!end2` | Same, for `_pendingEnd`. | -| `!savezones` | Requires all 4 points set. For each pair: `Min = ComponentWiseMin(p1,p2)`, `Max = ComponentWiseMax(p1,p2)`. Rejects zero-volume zones (`Min == Max`) with chat error. Writes both rows to `ZoneRepository` for `Server.MapName` (upsert). Invalidates `TimerEngine` zone cache. Despawns old particle edges, spawns new ones. Chat: `"zones saved for "`. | -| `!delzones` | Deletes both zones for the current map. Despawns particles. Resets every `PlayerRun` to `Idle`. Chat: `"zones cleared for "`. | -| `!zones` | Prints current persisted zone corners and whether a pending edit is staged. | - -**Pending edits** live in memory only on `ZoneEditor`. A server restart mid-edit -loses unsaved points — saves are always explicit. - -### Runs & records - -| Command | Behavior | -|---|---| -| `!pb` | `"your PB on : 0:01:23.456"` or `"no PB yet"`. Queries `RecordRepository.GetPb(steamId, map)`. | -| `!top` | Top 10 by `time_ms ASC` on current map. Lines formatted `"1. 0:01:20.111"`. Name column prefers live `CCitadelPlayerController.PlayerName` when connected, falls back to stored `player_name`. | -| `!reset` | Force caller's own `PlayerRun` to `Idle`. Useful if stuck. | - -## Database schema - -One SQLite file, two tables. Applied via `Data/Migrations/001_initial.sql` -at startup — idempotent `CREATE TABLE IF NOT EXISTS`. `journal_mode=WAL` set -on connection open. - -```sql -CREATE TABLE IF NOT EXISTS zones ( - map TEXT NOT NULL, - kind INTEGER NOT NULL, -- 0 = start, 1 = end - min_x REAL NOT NULL, - min_y REAL NOT NULL, - min_z REAL NOT NULL, - max_x REAL NOT NULL, - max_y REAL NOT NULL, - max_z REAL NOT NULL, - updated_at INTEGER NOT NULL, -- unix epoch seconds - PRIMARY KEY (map, kind) -); - -CREATE TABLE IF NOT EXISTS records ( - steam_id INTEGER NOT NULL, - map TEXT NOT NULL, - time_ms INTEGER NOT NULL, - player_name TEXT NOT NULL, - achieved_at INTEGER NOT NULL, - PRIMARY KEY (steam_id, map) -); - -CREATE INDEX IF NOT EXISTS idx_records_top ON records (map, time_ms); -``` - -**PB upsert:** -```sql -INSERT INTO records (steam_id, map, time_ms, player_name, achieved_at) -VALUES (@sid, @map, @t, @n, @at) -ON CONFLICT(steam_id, map) DO UPDATE SET - time_ms = excluded.time_ms, - player_name = excluded.player_name, - achieved_at = excluded.achieved_at -WHERE excluded.time_ms < records.time_ms; -``` - -`RecordRepository.UpsertIfFaster` returns `(bool changed, int? previousMs)` by -reading the row both before and after the upsert in a single transaction. - -**Concurrency:** single-process SQLite. All reads/writes happen on the game -server's main thread (chat-command handlers and `OnGameFrame` run there). -No cross-thread locking needed. - -## Particle rendering - -Each zone is an AABB with 8 corners and 12 edges. Each edge is rendered as a -dedicated `info_particle_system` entity whose control points are the two -edge endpoints (standard Source 2 beam convention: CP0 = start, CP1 = end, -set via `Builder.WithDataCP(0, start)` / `WithDataCP(1, end)`). - -`ZoneRenderer` keeps `Dictionary> _handles`. -On zone delete / map change / `OnUnload`, it iterates handles and calls -`particle.Destroy()`. - -**Coloring:** -- Start zone → `Color.LimeGreen` via `Builder.WithTint(...)` on CP0. -- End zone → `Color.Red`. - -Always visible to everyone — no recipient filtering. - -**Particle effect path — open research item.** In priority order: - -1. **Preferred:** reuse an existing Deadlock particle that's already a - CP0→CP1 beam (e.g. ability tether, zipline trail). Implementation phase - grep the game's `particles/` VPK for `beam` / `tether` / `line` effects. -2. **Fallback:** ship a minimal custom `locktimer_edge.vpcf` in - `Plugins/LockTimer/content/particles/`, deploy alongside the DLL, and - register the directory via `Server.AddSearchPath(pluginDir, "GAME")`. -3. **Last resort:** chain of point particles (~one every 32 units along each - edge) using a plain glowing sprite. Works anywhere, acceptable at most - zone sizes. - -The plan will pick one after a short investigation. - -## Error handling & edge cases - -**Posture:** fail soft, log loud. The game server keeps running regardless of -plugin errors. Every public boundary (chat command, game-frame tick) wraps its -body in `try/catch` and logs `[LockTimer] : `. No -exceptions propagate into the engine. - -| Case | Behavior | -|---|---| -| `!savezones` before all 4 points set | Chat: `"need 4 points — missing: start2, end1"` | -| Zero-volume zone (Min == Max) | Rejected at save with chat error | -| Start / end overlap or identical | Allowed; FSM naturally handles it (spawning inside end never triggers Running) | -| Player disconnects mid-run | `PlayerRun` removed, no record written | -| Map change mid-run | `OnStartupServer` clears state, no record written | -| Bot player | Never added to engine dictionary — no timing, no records | -| DB file locked / corrupt on open | Logged, plugin disables timing (renders still work if zones are already cached in memory) | -| `Server.MapName` empty at startup | Skip zone load; next `OnStartupServer` retries | -| Crosshair raycast hits nothing | Chat: `"no surface hit within 8192u"`, pending point unchanged | -| Crosshair raycast hits caller's pawn | Filtered via `Trace.Ray(..., ignore: callerPawn)` | -| Elapsed > int32.MaxValue (~24 days) | Clamp and log; effectively impossible in a real run | - -## Testing strategy - -There is no Deadworks test harness short of the real game server. The test -pyramid is: - -1. **Unit tests** — `Plugins/LockTimer.Tests/`, xUnit, net10.0, references - the main project but *not* `DeadworksManaged.Api.dll`: - - `TimerEngine` FSM: construct with synthetic zones, step through - `Tick(slot, position)` calls, assert state transitions and returned - `FinishedRun?` events. Engine takes positions as inputs — no engine - interop — so it's pure. - - `ZoneRepository` and `RecordRepository` against an in-memory SQLite - connection (`Data Source=:memory:`). Verify schema, upsert semantics, - top-N query ordering, `UpsertIfFaster` returning `(changed, previous)`. - - `TimeFormatter.FormatTime`: 0 ms, 1 ms, 999 ms, 1 s, 60 s, 1 h, 24 h. - - `Zone.Contains(Vector3)`: corners, faces, off-by-epsilon, negative - coordinates. -2. **Manual in-game smoke checklist** — lives in `Plugins/LockTimer/README.md`: - - Load plugin → `!start1/2/end1/2/savezones` → 24 glowing edges appear - - Walk start → end → chat shows time, DB row inserted - - Run faster → "new PB! prev …" message, DB row updated - - Run slower → "(pb …)" message, DB unchanged - - `!delzones` → particles vanish, DB rows gone - - Disconnect mid-run → reconnect → no state leak -3. **Build verification:** `dotnet build Plugins/LockTimer` must succeed with - zero warnings against the real `DeadworksManaged.Api.dll` reference. - -## Implementation sequencing (preview) - -Exact phases go in the plan, but the high-level order is: - -1. **Scaffold** — csproj, SQLite deps, `LockTimerPlugin` empty shell, build - against real Deadworks DLL. -2. **Data layer** — `LockTimerDb`, `Zone`, `Record`, both repositories, - migration SQL, unit tests for repos. -3. **Timer engine** — `PlayerRun`, `RunState`, pure `TimerEngine.Tick(...)`, - unit tests for FSM. -4. **Commands** — `ChatCommands` class, zone editing commands, records - commands, `!reset`, wired through plugin shell. -5. **Particle rendering** — research effect path, `ZoneRenderer`, integrate - with save/delete/startup. -6. **Integration** — `OnGameFrame` loop, `OnStartupServer` zone load, - `OnClientPutInServer` / `OnClientDisconnect` state management. -7. **Polish** — error handling wrappers, chat formatting, README smoke - checklist, final build-clean pass. - -Each phase is independently reviewable and produces a green build.