namespace Outnumbered.Data; // DTO for the !top leaderboard. public sealed record TopPlayer(string Name, int Level, int Prestige, long Xp); // DTOs for the records leaderboards (survival best wave / fastest Gun Game ladder). Names only — no SteamIDs on the wire. public sealed record TopWave(string Name, int BestWave); public sealed record TopGgTime(string Name, long BestMs); // DTO returned by a load. public sealed record LoadedPlayer( string Name, long Xp, int Level, int Prestige, int Points, Dictionary Upgrades, string? PrimaryWeapon, string? SecondaryWeapon); // DTO handed to a save (absolute values; last-write-wins). public sealed record PersistPlayer( ulong SteamId, string Name, long Xp, int Level, int Prestige, int Points, IReadOnlyDictionary Upgrades, string? PrimaryWeapon, string? SecondaryWeapon); // Per-match stats (RAM-first: written only on disconnect/shutdown, wiped on round end). Restored on rejoin mid-match. public sealed record MatchState(ulong SteamId, int Kills, int Deaths, int Streak, int HeadshotKills, long GgRunStartedAtMs = 0); // In-memory cache row. Mutated ONLY on the main game thread. public sealed class PlayerData { public required ulong SteamId { get; init; } public string Name { get; set; } = ""; public long Xp { get; set; } // progress toward the next level (within the current prestige) public int Level { get; set; } = 1; public int Prestige { get; set; } public int Points { get; set; } = 1; // start with one spendable point (spec §2) public Dictionary Upgrades { get; } = new(); // Chosen loadout (null = use the config default). Persisted. Free selection, validated against the allowed lists. public string? PrimaryWeapon { get; set; } public string? SecondaryWeapon { get; set; } public bool Dirty { get; set; } // Runtime-only: sub-1 XP remainder carried between per-hit grants so fractional damage-XP isn't lost to rounding. public double XpCarry { get; set; } // Runtime-only: combat tracking for the handicap (and killstreak abilities). public int Kills { get; set; } public int Deaths { get; set; } public int Streak { get; set; } // current killstreak; resets on death public int HeadshotKills { get; set; } // headshot kills this match — feeds the handicap's HS-rate factor // Runtime-only (Gun Game mode): current weapon-ladder rung + kills banked toward clearing it. Persist across // deaths within a match, reset on map start. NOT round-tripped in match_state — a mid-match reconnect // restarts the climb at rung 0. public int GgRung { get; set; } public int GgRungKills { get; set; } // Gun Game speedrun clock: wall-clock unix ms of the run's first damage dealt-or-taken (0 = not armed; reset on map // start). Wall clock, not Server.CurrentTime, because it round-trips through match_state and must stay comparable // across a changelevel/restart (the game clock restarts at ~0 there). Unlike GgRung, this DOES round-trip — a // mid-match reconnect restarts the climb but never restarts the clock (the once-per-match anti-abuse rule). public long GgRunStartedAtMs { get; set; } // Runtime-only: killstreak ability state, indexed by AbilityRegistry index (0..N-1). // AbilityReadyAt = Server.CurrentTime at which the ability comes off cooldown (survives death). // AbilityActiveUntil = Server.CurrentTime until which the ability's effect is live (cleared on death). // Sized to AbilitySlots (headroom over the current 5) so adding an AbilityRegistry row can't IndexOutOfRange; a // startup guard (Initialize_Abilities) errors loudly if the registry ever exceeds this. public const int AbilitySlots = 8; public double[] AbilityReadyAt { get; } = new double[AbilitySlots]; public double[] AbilityActiveUntil { get; } = new double[AbilitySlots]; public PersistPlayer ToPersist() => new(SteamId, Name, Xp, Level, Prestige, Points, new Dictionary(Upgrades), PrimaryWeapon, SecondaryWeapon); public MatchState ToMatchState() => new(SteamId, Kills, Deaths, Streak, HeadshotKills, GgRunStartedAtMs); // GgRunStartedAtMs counts as activity: an armed-but-zero-kill speedrun clock must still reach match_state, // or a disconnect before the first kill silently discards it (this predicate gates every match-state save). public bool HasMatchActivity => Kills > 0 || Deaths > 0 || Streak > 0 || HeadshotKills > 0 || GgRunStartedAtMs > 0; }