Component attached to a player that tracks per-round stats: kills, headshots, downs, revives and points earned. It records events locally, relays events to the authoritative owner when appropriate, publishes the local owner’s line over the network, provides accessors to gather all players' stats, and includes console commands to print, reset or fake stats.
using System;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// What one player did this game: kills, headshots, downs, revives, points earned.
///
/// ⛔ A COMPONENT ON THE PLAYER, NOT A STATIC TABLE. Per-player state belongs to the player —
/// `SERVER_ROADMAP.md` §4 rule 2 — and this project already keeps `Health`, `Stamina`,
/// `HealthRegen` and `NZInventory` that way. A static dictionary keyed by player would have to be
/// pruned on disconnect and would survive a hotload holding entries for players that no longer
/// exist, which is exactly the class of bug `WunderfizzMenu.Current` is listed for.
///
/// ⚠️ SEPARATE FILE RATHER THAN MORE FIELDS ON NZPlayer, which is already past 3,400 lines. The
/// component sits on the same GameObject, so `Components.Get` reaches it from anywhere the player
/// is in hand.
///
/// ⚠️ THESE ARE COUNTS FOR THE RUN, NOT THE SESSION. `ResetStats` is called from RoundManager when a
/// game starts, beside the other per-game clears.
/// </summary>
public sealed class PlayerStats : Component
{
/// <summary>Zombies this player landed the killing blow on.</summary>
public int Kills { get; private set; }
/// <summary>
/// Kills where the killing blow was a headshot.
///
/// ⚠️ A SUBSET OF <see cref="Kills"/>, not a separate tally — a headshot kill increments both.
/// Showing them as separate columns that sum to more than the kills would read as a bug.
/// </summary>
public int Headshots { get; private set; }
/// <summary>Times this player went down. A bleedout death counts once, when they went down.</summary>
public int Downs { get; private set; }
/// <summary>Players this one picked back up. Self-revives do not count.</summary>
public int Revives { get; private set; }
/// <summary>
/// Every point this player was ever awarded, ignoring what they spent.
///
/// ⛔ NOT `NZPlayer.Points`, WHICH IS A BALANCE. A player who earned 40,000 and spent it all is
/// not a player who scored nothing, and the scoreboard is a record of what you did rather than
/// of what is left in your pocket.
/// ⚠️ AWARDS ONLY. `AddPoints` is also how refunds arrive, but it is never called with a
/// negative — spending goes through `TakePoints` — so the guard below is belt and braces.
/// </summary>
public int PointsEarned { get; private set; }
/// <summary>Headshots as a percentage of kills, or 0 when nothing has died yet.</summary>
public float HeadshotPercent => Kills > 0 ? 100f * Headshots / Kills : 0f;
// ── recording ────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Tell everybody my line. Called from every `Record*`, which is the one set of places it
/// changes.
///
/// ⚠️ ONLY FOR MY OWN BODY. Every machine holds a `PlayerStats` for every player; a copy
/// publishing its owner's figures would let two machines argue about one line.
///
/// ⚠️ AT THE RECORDERS RATHER THAN ON A TICK. There are four of them and they are the only
/// ways these numbers move, so a fifth would have to be written before it could be forgotten —
/// and a tick would send twice a second forever to carry a change that happens twice a round.
/// </summary>
void Publish()
{
if ( !Networking.IsActive || Connection.Local is null ) return;
if ( !PlayerPresence.Mine( GameObject ) ) return;
NZNet.StatsAre( Connection.Local.Id, Kills, Headshots, Downs, Revives, PointsEarned );
}
/// <summary>
/// Is this somebody else's body? Then the record belongs on THEIR machine, not here.
///
/// ⛔ THE HOST RECORDS EVERY KILL, INCLUDING A CLIENT'S. `ZombieAI` credits the kill where the
/// zombie died, and that is always the host — so a client's kill was recorded on the host's
/// PROXY copy of that client, whose `Publish` then correctly refuses to speak for a body it does
/// not own. The number went nowhere and the scoreboard showed zero.
///
/// ⚠️ RELAY, NOT RECORD-AND-PUBLISH. Publishing from the proxy would make the host the
/// author of somebody else's line, and two machines would then be writing one row.
/// </summary>
bool Relay( int what, bool headshot )
{
if ( !Networking.IsActive ) return false;
if ( !PlayerPresence.Theirs( GameObject ) ) return false;
var owner = NZPlayers.OwnerOf( GameObject );
if ( string.IsNullOrEmpty( owner ) ) return false;
NZNet.RecordStat( owner, what, headshot );
return true;
}
public void RecordKill( bool headshot )
{
if ( Relay( 0, headshot ) ) return;
Kills++;
if ( headshot ) Headshots++;
Publish();
}
public void RecordDown() { if ( Relay( 1, false ) ) return; Downs++; Publish(); }
public void RecordRevive() { if ( Relay( 2, false ) ) return; Revives++; Publish(); }
public void RecordPoints( int amount )
{
if ( amount <= 0 ) return;
PointsEarned += amount;
Publish();
}
/// <summary>
/// Clear this player's scoreline.
///
/// ⛔ NOT `Reset` — that name hides `Component.Reset()`, the editor's reset-to-defaults hook,
/// and the compiler said so on every single build (CS0114). Same collision `CamoEnabled`,
/// `ActiveCamo` and `TradeTableTuner.ResetValues` were renamed for.
///
/// ⚠️ RENAMED RATHER THAN MARKED `new`. `new` silences the warning and KEEPS the ambiguity:
/// the method called then depends on the static type of the reference, so anything holding
/// this as a `Component` would quietly get the engine's Reset instead of clearing the score.
/// A name that means one thing cannot be called wrong.
/// </summary>
public void ResetStats()
{
Kills = Headshots = Downs = Revives = PointsEarned = 0;
}
// ── access ───────────────────────────────────────────────────────────────────────────────
/// <summary>
/// This player's stats, creating the component if it is not there yet.
///
/// ⛔ GetOrCreate, BECAUSE NOTHING ADDS THIS IN THE SCENE. The player prefab has no
/// PlayerStats on it, and requiring one would mean a player who joined before the component
/// existed silently records nothing. Creating on first use makes the system self-installing.
/// </summary>
public static PlayerStats For( NZPlayer player )
=> player.IsValid()
? player.Components.GetOrCreate<PlayerStats>()
: null;
/// <summary>
/// Every player's stats, for the scoreboard.
///
/// ⛔ THE LOCAL PLAYER IS UNIONED IN, NOT ASSUMED TO BE IN THE LIST.
/// `GetAllComponents<NZPlayer>` does not see a DISABLED player — the lobby disables the
/// body — and this is the second time that has bitten: `PlayerCharacters.Local` exists because
/// `nz_character` hit it too. `PlayerPresence.Find` reaches the player either way, so the two
/// sources are merged rather than trusted one at a time.
/// </summary>
public static (NZPlayer Player, PlayerStats Stats)[] All( Scene scene )
{
var found = scene is null
? Enumerable.Empty<NZPlayer>()
: scene.GetAllComponents<NZPlayer>();
var local = PlayerCharacters.Local();
return found
.Concat( local.IsValid() ? new[] { local } : Array.Empty<NZPlayer>() )
.Where( p => p.IsValid() )
.Distinct()
.Select( p => (p, For( p )) )
.Where( t => t.Item2.IsValid() )
.ToArray();
}
/// <summary>Clear everyone's stats. Called when a game starts.</summary>
public static void ResetAll( Scene scene )
{
foreach ( var (_, stats) in All( scene ) )
stats.ResetStats();
}
// ── console ──────────────────────────────────────────────────────────────────────────────
/// <summary>
/// `nz_score` — print the table the scoreboard shows.
///
/// ⚠️ NOT `nz_stats`. That name was registered by an earlier hotload of this file and s&box
/// logs "Command nz_stats already exists - not overwriting" rather than replacing it, so the
/// stale body kept running and printed nothing. A command name is effectively claimed for the
/// life of the editor session.
/// </summary>
[ConCmd( "nz_score" )]
public static void StatsCmd()
{
var rows = All( Game.ActiveScene );
Log.Info( $"[nz-score] scene {(Game.ActiveScene is null ? "NULL" : "ok")}, "
+ $"{rows.Length} player(s)" );
if ( rows.Length == 0 )
{
Log.Info( "[nz-score] no players" );
return;
}
Log.Info( "[nz-score] name kills hs hs% downs revives earned" );
foreach ( var (player, s) in rows )
{
Log.Info( $"[nz-score] {player.GameObject.Name,-20} "
+ $"{s.Kills,5} {s.Headshots,3} {s.HeadshotPercent,5:0.#} "
+ $"{s.Downs,7} {s.Revives,7} {s.PointsEarned,8}" );
}
}
/// <summary>`nz_stats_reset` — zero everyone, as a game start does.</summary>
[ConCmd( "nz_stats_reset" )]
public static void StatsResetCmd()
{
ResetAll( Game.ActiveScene );
Log.Info( "[nz-stats] cleared" );
}
/// <summary>
/// `nz_stats_fake [kills] [headshots] [downs] [revives] [points]` — fill in numbers.
///
/// ⚠️ SO THE PANEL CAN BE JUDGED WITHOUT PLAYING A ROUND. Column widths and alignment only
/// misbehave at four and five digits, which a fresh game never reaches.
/// </summary>
[ConCmd( "nz_stats_fake" )]
public static void StatsFakeCmd( int kills = 137, int headshots = 44, int downs = 2,
int revives = 3, int points = 42750 )
{
var s = For( PlayerCharacters.Local() );
if ( !s.IsValid() ) { Log.Warning( "[nz-stats] no player" ); return; }
s.ResetStats();
for ( int i = 0; i < kills; i++ ) s.RecordKill( i < headshots );
for ( int i = 0; i < downs; i++ ) s.RecordDown();
for ( int i = 0; i < revives; i++ ) s.RecordRevive();
s.RecordPoints( points );
Log.Info( $"[nz-stats] filled — {s.Kills} kills, {s.Headshots} hs, {s.Downs} downs, "
+ $"{s.Revives} revives, {s.PointsEarned} earned" );
}
}