initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

View file

@ -0,0 +1,78 @@
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<string, int> 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<string, int> 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<string, int> 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<string, int>(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;
}