Config/Difficulty.cs

Static Difficulty helper for a NZombies match, stores and applies the host-chosen difficulty snapshot. Parses an id=value;... string, keeps the overridden values and exposes scaled/flat getters used by game systems, applies and broadcasts at match start, refreshes player-local state, and logs the current settings.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE MATCH'S DIFFICULTY: the lobby's Difficulty page as the game plays it (2026-10-05). The page was built unwired first, then
/// the user: *"ok, now lets wire it"*.
///
/// ⛔ TAKEN ONCE, AT THE START OF A GAME, BY THE HOST, AND SENT TO EVERYONE (`RoundManager.StartGame` → <see cref="StartMatch"/> →
/// `NZNet.DifficultyIs`). `DifficultyState` is the host's choice in the lobby; this is what the match was started on, and the
/// game reads only this. A change on the page mid-game reaches the next game, never the running one, and every machine plays the
/// host's numbers: the host's zombies, and each player's own body, whose speed, stamina, health, bleedout and wallet are worked
/// out on the machine that owns it.
///
/// ⚠️ A JOINER IS CAUGHT UP (`NZNet.SendGame`), and a new game takes the lobby's choice afresh. It is dropped on the way to the
/// lobby or the editor (`NZGame.SetMode`), so Creative, and a game not started from the lobby, play the gamemode as it is.
///
/// ⚠️ ONLY WHAT DIFFERS FROM THE GAMEMODE IS HELD. A percentage at 100 and a flat number equal to the config's are left out
/// (`DifficultyState.Snapshot`), and a setting left out reads the config live: Normal is today's game exactly, and a config
/// retuned mid-game still reaches it.
///
/// ⚠️ EVERY READ IS ONE OF THE PROPERTIES BELOW, AT THE PLACE THE GAME USES THE GAMEMODE'S NUMBER — grep `Difficulty.` for them.
/// A new enemy attack multiplies its damage to players by <see cref="ZombieDamage"/>, as the walkers', Oberon's and the napalm
/// zombie's do; a new boss's health goes through `ZombieAI`'s spawn, which applies <see cref="HealthFor"/>.
/// </summary>
public static class Difficulty
{
	// ⚠️ NULLABLE-BACKED, INSTRUCTIONS §1: a static keeps its value through a hotload, its initialiser does not re-run.
	static Dictionary<string, float> _values;
	static string _name;
	static string _sent;

	/// <summary>Is this match on anything but the gamemode's own numbers?</summary>
	public static bool IsSet => _values is not null && _values.Count > 0;

	/// <summary>What the match was started on: "Hard", "Custom (Hard)", or "Normal" with nothing set.</summary>
	public static string Name => IsSet && !string.IsNullOrEmpty( _name ) ? _name : "Normal";

	/// <summary>The settings as they travel, `id=value;…`; "" for none.</summary>
	public static string Sent => _sent ?? "";

	/// <summary>A percentage setting as a multiplier: 1 when the match leaves it at the gamemode's own.</summary>
	public static float Scale( string id )
		=> _values is not null && _values.TryGetValue( id, out var v ) ? v / 100f : 1f;

	/// <summary>A flat setting: the match's number, else the gamemode's.</summary>
	public static float Flat( string id, float gamemode )
		=> _values is not null && _values.TryGetValue( id, out var v ) ? v : gamemode;

	/// <summary>A flat setting, whole: the match's number, else the gamemode's.</summary>
	public static int Flat( string id, int gamemode )
		=> _values is not null && _values.TryGetValue( id, out var v ) ? (int)MathF.Round( v ) : gamemode;

	// ══ what the game reads ══════════════════════════════════════════════════════════

	// ── zombies: the host's ──

	/// <summary>Zombie health, specials' included; a boss has <see cref="BossHealth"/>. Multiplies the spawn health, so the
	/// knife and the other round-curve damage do not grow with it: on Hard they take more hits, as a gun does.</summary>
	public static float ZombieHealth => Scale( "zhealth" );

	/// <summary>Every enemy hit on a player: a zombie's swing, a boss's included, Oberon's blasts, the napalm zombie's blast and
	/// fire.</summary>
	public static float ZombieDamage => Scale( "zdamage" );

	/// <summary>How fast a zombie swings (`ZombieAI.ScaledAttackSpeed`).</summary>
	public static float ZombieAttackSpeed => Scale( "zattack" );

	/// <summary>Zombie move speed. Never a boss's, whose speed is absolute (`ZombieAI.ApplyGroundSpeed`).</summary>
	public static float ZombieMoveSpeed => Scale( "zspeed" );

	/// <summary>Zombies in a round, a special round's included.</summary>
	public static float HordeSize => Scale( "horde" );

	/// <summary>How fast they come: divides the wait between spawns.</summary>
	public static float SpawnRate => Scale( "spawnrate" );

	/// <summary>The most alive at once in an ordinary round; a special round keeps its own.</summary>
	public static int MaxAlive => Flat( "maxalive", ActiveConfig.Zombies.MaxAlive );

	/// <summary>A zombie's health multiplier: <see cref="BossHealth"/> for a boss, <see cref="ZombieHealth"/> for the rest.</summary>
	public static float HealthFor( ZombieVariant variant ) => variant?.IsBoss == true ? BossHealth : ZombieHealth;

	// ── specials and bosses ──

	/// <summary>Rounds between special rounds, from the gamemode's first one.</summary>
	public static int SpecialEvery( int gamemode ) => Flat( "specialevery", gamemode );

	/// <summary>The first round a boss can come.</summary>
	public static int BossFirst( int gamemode ) => Flat( "bossfirst", gamemode );

	/// <summary>Rounds between bosses after the first; 0 is the first one only, as in the config.</summary>
	public static int BossEvery( int gamemode ) => Flat( "bossevery", gamemode );

	/// <summary>A boss's health, a round's boss or an Easter egg's.</summary>
	public static float BossHealth => Scale( "bosshealth" );

	// ── you: each player's own machine ──

	/// <summary>Health before perks and armor.</summary>
	public static float MaxHealth => Flat( "maxhealth", ActiveConfig.Player.MaxHealth );

	/// <summary>Seconds without a hit before health comes back, before Quick Revive.</summary>
	public static float RegenDelay => ActiveConfig.Player.HealthRegenDelay * Scale( "regendelay" );

	/// <summary>Multiplies a player's bleedout (`NZPlayer.BleedoutSeconds`).</summary>
	public static float Bleedout => Scale( "bleedout" );

	/// <summary>Walk speed before perks, tech and aiming.</summary>
	public static float WalkSpeed => ActiveConfig.Player.WalkSpeed * Scale( "walk" );

	/// <summary>Sprint speed before perks and tech.</summary>
	public static float SprintSpeed => ActiveConfig.Player.SprintSpeed * Scale( "sprint" );

	/// <summary>Multiplies a slide's launch (`Slide`).</summary>
	public static float SlideSpeed => Scale( "slide" );

	/// <summary>The stamina pool before perks.</summary>
	public static float StaminaMax => ActiveConfig.Player.StaminaMax * Scale( "stamina" );

	// ── economy ──

	/// <summary>Points each player starts with.</summary>
	public static int StartingPoints => Flat( "startpoints", ActiveConfig.Gameplay.StartingPoints );

	/// <summary>Points for a hit that does not kill.</summary>
	public static int PointsHit => Flat( "pointshit", ActiveConfig.Gameplay.PointsHit );

	/// <summary>Points for a kill that is not a headshot or a knife kill.</summary>
	public static int PointsKill => Flat( "pointskill", ActiveConfig.Gameplay.PointsKill );

	/// <summary>Points for a headshot kill.</summary>
	public static int PointsKillHeadshot => Flat( "pointshead", ActiveConfig.Gameplay.PointsKillHeadshot );

	/// <summary>Points for a knife kill.</summary>
	public static int PointsKillKnife => Flat( "pointsknife", ActiveConfig.Gameplay.PointsKillKnife );

	/// <summary>Multiplies the power-up drop chance, and the most a round gives (<see cref="PowerupCap"/>).</summary>
	public static float PowerupDrops => Scale( "powerups" );

	/// <summary>
	/// The most power-ups a round gives. ⚠️ SCALED WITH THE CHANCE: at the gamemode's 4 a round, a late round reaches the cap
	/// early whatever the chance, so a chance alone would do nothing past the first rounds. Never below 1 unless the gamemode's
	/// own is.
	/// </summary>
	public static int PowerupCap( int gamemode )
		=> gamemode <= 0 ? gamemode : Math.Max( 1, (int)MathF.Round( gamemode * PowerupDrops ) );

	/// <summary>Multiplies the salvage drop chance.</summary>
	public static float SalvageDrops => Scale( "salvage" );

	/// <summary>Multiplies the armor plate drop chance.</summary>
	public static float PlateDrops => Scale( "plates" );

	// ══ taking it ════════════════════════════════════════════════════════════════════

	/// <summary>
	/// The host, at the start of a game (`RoundManager.StartGame`): take the lobby's choice and send it to everyone.
	///
	/// ⚠️ HERE FIRST, THEN TO EVERYONE, so the broadcast's own run on this machine is the same one again and does nothing — and
	/// a game not networked has it without a broadcast at all.
	/// </summary>
	public static void StartMatch()
	{
		if ( NZGame.IsClient ) return;

		var values = DifficultyState.Snapshot();
		var name = DifficultyState.Label;

		Apply( values, name );
		if ( Networking.IsActive ) NZNet.DifficultyIs( values, name );
	}

	/// <summary>
	/// Take the match's difficulty as sent (`NZNet.DifficultyIs`), on every machine. "" is none: the gamemode as it is.
	///
	/// ⚠️ THE SAME ONE AGAIN IS NOTHING. The host hears its own broadcast and every joiner's catch-up, and a restart from the
	/// score screen sends it again; each of those refilling everybody's stamina would be a bug of its own.
	/// </summary>
	public static void Apply( string values, string name )
	{
		values ??= "";
		name ??= "";
		if ( values == Sent && (values.Length == 0 || name == (_name ?? "")) ) return;

		var parsed = Parse( values );
		_values = parsed.Count > 0 ? parsed : null;
		_name = parsed.Count > 0 ? name : null;
		_sent = parsed.Count > 0 ? values : "";

		Report( "this match" );
		Refresh();
	}

	/// <summary>Drop it: the gamemode as it is (`NZGame.SetMode`, on the way to the lobby or the editor).</summary>
	public static void Clear( string why )
	{
		if ( _values is null && string.IsNullOrEmpty( _sent ) ) return;

		_values = null;
		_name = null;
		_sent = "";

		Log.Info( $"[nz-difficulty] dropped ({why}) — the gamemode as it is until a game is started from the lobby" );
		Refresh();
	}

	/// <summary>
	/// `id=value;…` back to settings. ⚠️ ONLY THE PAGE'S OWN IDS, each kept to a sane range: a setting another build does not
	/// know is skipped and said, never guessed at.
	/// </summary>
	static Dictionary<string, float> Parse( string values )
	{
		var known = DifficultyState.All.ToDictionary( s => s.Id );
		var parsed = new Dictionary<string, float>();

		foreach ( var part in values.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
		{
			var eq = part.IndexOf( '=' );
			if ( eq <= 0 ) continue;

			var id = part.Substring( 0, eq ).Trim();
			if ( !known.TryGetValue( id, out var s ) )
			{
				Log.Warning( $"[nz-difficulty] '{id}' is no setting this build has — skipped (is everyone on the same version?)" );
				continue;
			}

			if ( !float.TryParse( part.Substring( eq + 1 ), NumberStyles.Float, CultureInfo.InvariantCulture, out var v ) ) continue;
			if ( float.IsNaN( v ) || float.IsInfinity( v ) ) continue;

			// ⚠️ A PERCENTAGE WITHIN THE PAGE'S RANGE; A FLAT NUMBER NEVER BELOW 0 (its range bends to the gamemode's own number,
			// `DifficultyState.MinOf`, so the host's may lie past the page's ends)
			parsed[id] = s.Config is null ? Math.Clamp( v, s.Min, s.Max ) : MathF.Max( 0f, v );
		}

		return parsed;
	}

	/// <summary>
	/// Put the difficulty on what has already read the gamemode's numbers: the controllers (as a settings change does), and each
	/// body this machine owns — its maximum health, its stamina and the sprint it restores, and a joiner's untouched wallet.
	/// </summary>
	static void Refresh()
	{
		ActiveConfig.NotifyChanged();

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
		{
			// ⚠️ `PlayerPresence.Mine`, THE PROJECT'S ONE ANSWER TO "IS THIS BODY MINE" (NZPlayer's notes say why not `IsProxy`)
			if ( !p.IsValid() || !PlayerPresence.Mine( p.GameObject ) ) continue;

			// up into a higher ceiling, trimmed under a lower one; Juggernog and its augments on top, as ever
			if ( !p.IsDown ) JuggAugments.RefreshHealth( p );

			// ⚠️ THE POOL'S MAXIMUM IS READ LIVE, BUT THE SPRINT IT RESTORES IS CACHED (`Stamina._sprintSpeed`): a refill takes both
			p.Components.Get<Stamina>( FindMode.EverythingInSelf )?.Refill();

			// ⚠️ A JOINER'S WALLET: a body made before this arrived started on the gamemode's starting points. Untouched and
			// nothing earned, it takes the match's. At a game's start the run's own reset sets it anyway (`ResetPlayerForRun`).
			if ( NZGame.IsSurvival && p.Points == ActiveConfig.Gameplay.StartingPoints && p.Points != StartingPoints
				&& (PlayerStats.For( p )?.PointsEarned ?? 0) == 0 )
				p.SetPoints( StartingPoints );
		}
	}

	/// <summary>The match's difficulty in the log: its name, then every setting it changes, as the page shows them.</summary>
	static void Report( string what )
	{
		if ( !IsSet )
		{
			Log.Info( $"[nz-difficulty] {what}: Normal — the gamemode as it is" );
			return;
		}

		Log.Info( $"[nz-difficulty] {what}: {Name} — {_values.Count} setting(s) off the gamemode's own" );
		foreach ( var s in DifficultyState.All )
		{
			if ( _values.TryGetValue( s.Id, out var v ) )
				Log.Info( $"[nz-difficulty]   {s.Label,-24} {DifficultyState.Format( s, v )}" );
		}
	}

	/// <summary>`nz_difficulty` — the difficulty this match plays on, and the numbers the game reads from it now.</summary>
	[ConCmd( "nz_difficulty" )]
	public static void Cmd()
	{
		Report( "this match" );

		Log.Info( $"[nz-difficulty]   zombies: health x{ZombieHealth:0.##} · damage x{ZombieDamage:0.##} · swing x{ZombieAttackSpeed:0.##}"
			+ $" · move x{ZombieMoveSpeed:0.##} · horde x{HordeSize:0.##} · spawns x{SpawnRate:0.##} · max at once {MaxAlive}" );

		var c = ActiveConfig.Current;
		if ( c is not null )
			Log.Info( $"[nz-difficulty]   specials every {SpecialEvery( c.Specials.RoundInterval )} · first boss round"
				+ $" {BossFirst( c.Bosses.FirstRound )} · bosses every {BossEvery( c.Bosses.RoundInterval )} · boss health x{BossHealth:0.##}" );

		Log.Info( $"[nz-difficulty]   you: health {MaxHealth:0} · recovery after {RegenDelay:0.#}s · bleedout x{Bleedout:0.##}"
			+ $" · walk {WalkSpeed:0} · sprint {SprintSpeed:0} · slide x{SlideSpeed:0.##} · stamina {StaminaMax:0}" );
		Log.Info( $"[nz-difficulty]   points: start {StartingPoints} · hit {PointsHit} · kill {PointsKill} · headshot {PointsKillHeadshot}"
			+ $" · knife {PointsKillKnife} · power-ups x{PowerupDrops:0.##} (at most {PowerupCap( NZombies.PowerupDrops.MaxPerRound )} a round)"
			+ $" · salvage x{SalvageDrops:0.##} · plates x{PlateDrops:0.##}" );
		Log.Info( $"[nz-difficulty]   the lobby's page holds {DifficultyState.Label} — the next game started takes it" );
	}
}