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.
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" );
}
}