UI/DifficultyState.cs

UI helper for the lobby difficulty page. Defines presets, a list of settings with metadata and preset values, reads gamemode config values, tracks UI state (open/closed, hovered, section), manages changes from presets, formats values and exposes a console command for debugging.

File Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE LOBBY'S DIFFICULTY PAGE: its presets, its settings and what is chosen (2026-10-05). The user: *"after selecting a map and
/// gamemode, i want a new option on the lobby that allows me to customize dificulty of the game … i was thinking of having 3
/// default dificulty presets, and then be able to change the individual things in them"*, then: *"let's design the ui for it, not
/// wired to any of the option yet"*.
///
/// ⛔ WIRED SINCE 2026-10-05 (the user: *"ok, now lets wire it"*), THROUGH `Difficulty`, NEVER FROM HERE. This is the host's
/// choice in the lobby. At the start of each game the host takes the settings that differ from the gamemode (<see cref="Snapshot"/>)
/// and sends them to everyone (`Difficulty.StartMatch`), and the game reads only that copy, so a change here mid-game reaches the
/// next game, never the running one.
///
/// ⚠️ THE PERCENTAGES ARE SHARES OF WHAT THE GAMEMODE ALREADY USES, NORMAL BEING 100%: each gamemode keeps its own curves, and
/// Normal is the game as it is. Easy's and Hard's numbers are first guesses, for the user to tune. The presets' names and whether
/// Hard pays more were still open when this was wired.
///
/// ⚠️ THE FLAT ONES ARE FETCHED FROM THE CONFIG (`Setting.Config`): the points, which the user asked to see as the numbers the
/// gamemode pays, and since the wiring every other flat one too (max at once, the special and boss rounds, max health, starting
/// points), so Normal is this gamemode's own number. Their presets are multipliers of it, shown as the number they come to.
///
/// ⚠️ A SEAM, LIKE `MapBrowserState`: razor types are generated and plain .cs cannot name them, so the page reads this and the
/// lobby's row and the console talk to it.
/// </summary>
public static class DifficultyState
{
	public enum Preset { Easy, Normal, Hard }

	/// <summary>
	/// One setting: what the page calls it and says about it, how it is shown (`Unit`), the range it steps through, and its value
	/// in each preset.
	///
	/// ⚠️ WITH A `Config`, A FLAT NUMBER FETCHED FROM THE GAMEMODE (2026-10-05): <see cref="ConfigValue"/> reads that field of the
	/// loaded config, and Easy / Normal / Hard are MULTIPLIERS of it (Normal 1, the config's number itself), the others rounded to
	/// the step. The range is in the flat numbers, and bends to take in the config's own (<see cref="MinOf"/>).
	/// </summary>
	public record Setting( string Id, string Group, string Label, string Description, string Unit,
		float Min, float Max, float Step, float Easy, float Normal, float Hard, string Config = null );

	/// <summary>The page's groups, in order.</summary>
	public static string[] Groups => new[] { "Zombies", "Specials and bosses", "You", "Economy" };

	/// <summary>
	/// Every setting, in the order the page lists them.
	///
	/// ⛔ A PROPERTY THAT BUILDS THE ARRAY, NEVER A `static readonly` FIELD (INSTRUCTIONS §1): a static initialiser does not re-run
	/// on hotload, so a setting added or retuned here would not appear in a running session.
	/// </summary>
	public static Setting[] All => new[]
	{
		new Setting( "zhealth", "Zombies", "Zombie health", "How much it takes to put a zombie down, at every round. Bosses have their own.",
			"%", 50, 250, 10, 60, 100, 150 ),
		new Setting( "zdamage", "Zombies", "Zombie damage", "How much each hit takes off you, a boss's included.",
			"%", 50, 250, 10, 60, 100, 150 ),
		// ⚠️ ATTACK AND MOVE SPEED (2026-10-05, the user's own additions). Move speed keeps close to 100% on Hard: the user likes
		// the zombies' speed as it is (`nzombies-late-game-balance`), so Hard pushes it a little and the range stops at 150%.
		new Setting( "zattack", "Zombies", "Attack speed", "How fast zombies swing at you.",
			"%", 50, 200, 5, 85, 100, 115 ),
		new Setting( "zspeed", "Zombies", "Move speed", "How fast zombies move, at every round. Bosses keep their own pace.",
			"%", 50, 150, 5, 85, 100, 110 ),
		new Setting( "horde", "Zombies", "Horde size", "How many zombies come each round, special rounds included.",
			"%", 50, 200, 5, 75, 100, 125 ),
		new Setting( "spawnrate", "Zombies", "Spawn rate", "How fast they pour in during a round.",
			"%", 50, 200, 5, 80, 100, 125 ),
		// ⚠️ THE FLAT ONES READ THE GAMEMODE TOO, SINCE THE WIRING (2026-10-05), as the points already did: Normal is this
		// gamemode's own number, and Easy and Hard are multipliers that come to the first guesses at the defaults (50 at once,
		// specials every 5, the first boss on 11 then every 10, 150 health, 500 points).
		new Setting( "maxalive", "Zombies", "Max at once", "The most zombies alive at the same time, in an ordinary round. More than 60 starts to slow the game down.",
			"count", 20, 60, 5, 0.7f, 1, 1.2f, "MaxAlive" ),

		new Setting( "specialevery", "Specials and bosses", "Special rounds", "How often a special round comes: hellhounds and the like.",
			"every", 3, 10, 1, 1.4f, 1, 0.8f, "SpecialEvery" ),
		new Setting( "bossfirst", "Specials and bosses", "First boss", "The round the first boss can appear.",
			"round", 5, 30, 1, 1.364f, 1, 0.727f, "BossFirst" ),
		new Setting( "bossevery", "Specials and bosses", "Boss rounds", "Rounds between bosses after the first.",
			"every", 5, 20, 1, 1.5f, 1, 0.8f, "BossEvery" ),
		new Setting( "bosshealth", "Specials and bosses", "Boss health", "How much it takes to bring a boss down.",
			"%", 50, 250, 10, 70, 100, 130 ),

		new Setting( "maxhealth", "You", "Max health", "Your health before perks and armor.",
			"count", 100, 300, 25, 1.334f, 1, 0.834f, "MaxHealth" ),
		// ⚠️ "HEALTH RECOVERY DELAY", THE USER'S NAME FOR IT (2026-10-05); it was "Healing starts after", the same setting.
		// ⚠️ IT AND BLEEDOUT ARE SHARES OF THE GAMEMODE'S OWN TIME (the user: *"13 and 14 can be percentage, as in a multiplier over
		// the current ammount"*): 5 s and 45 s by default, so 60% is 3 s and 135% is about 61 s.
		new Setting( "regendelay", "You", "Health recovery delay", "How long you go without a hit before your health starts coming back.",
			"%", 50, 200, 5, 60, 100, 160 ),
		new Setting( "bleedout", "You", "Bleedout time", "How long you can wait for a revive once you go down.",
			"%", 50, 200, 5, 135, 100, 65 ),
		// ⚠️ YOUR MOVEMENT AND STAMINA (2026-10-05, the user's own additions)
		new Setting( "walk", "You", "Walk speed", "How fast you walk.",
			"%", 50, 150, 5, 105, 100, 95 ),
		new Setting( "sprint", "You", "Sprint speed", "How fast you sprint.",
			"%", 50, 150, 5, 105, 100, 95 ),
		new Setting( "slide", "You", "Slide speed", "How fast a slide launches you.",
			"%", 50, 150, 5, 110, 100, 90 ),
		new Setting( "stamina", "You", "Stamina", "How long you can sprint before you run out.",
			"%", 50, 300, 25, 150, 100, 75 ),

		new Setting( "startpoints", "Economy", "Starting points", "Points each player starts the game with. Normal is what this gamemode gives.",
			"points", 0, 5000, 250, 3, 1, 1, "StartingPoints" ),
		// ⚠️ FLAT POINTS, FETCHED FROM THE GAMEMODE'S CONFIG, AND THE THREE KILLS APART (2026-10-05, the user: *"20 and 21 should be
		// flat, and we should also separate headshot klill knife kill and normal kill"*). Normal is what the gamemode pays
		// (`Gameplay.Points…`: 5, 50, 100 and 130 by default); Easy pays a quarter more and Hard the same, as the old percentage did.
		new Setting( "pointshit", "Economy", "Points per shot", "Points for every shot that hits a zombie without killing it. Normal is what this gamemode pays.",
			"points", 0, 50, 1, 1.25f, 1, 1, "PointsHit" ),
		new Setting( "pointskill", "Economy", "Points per kill", "Points for a kill that is not a headshot or a knife kill. Normal is what this gamemode pays.",
			"points", 0, 500, 5, 1.25f, 1, 1, "PointsKill" ),
		new Setting( "pointshead", "Economy", "Points per headshot kill", "Points for a kill with a shot to the head. Normal is what this gamemode pays.",
			"points", 0, 500, 5, 1.25f, 1, 1, "PointsKillHeadshot" ),
		new Setting( "pointsknife", "Economy", "Points per knife kill", "Points for a knife kill. Normal is what this gamemode pays.",
			"points", 0, 500, 5, 1.25f, 1, 1, "PointsKillKnife" ),
		new Setting( "powerups", "Economy", "Power-up drops", "How often zombies drop power-ups like Insta-Kill and Max Ammo, and how many a round can give.",
			"%", 50, 200, 25, 150, 100, 75 ),
		// ⚠️ SALVAGE AND PLATES APART (2026-10-05, the user: *"on the salvage and plates both should be separated"*)
		new Setting( "salvage", "Economy", "Salvage drops", "How often zombies drop salvage.",
			"%", 50, 200, 5, 130, 100, 75 ),
		new Setting( "plates", "Economy", "Plate drops", "How often zombies drop armor plates.",
			"%", 50, 200, 5, 130, 100, 75 ),
	};

	/// <summary>The settings in one group, in page order.</summary>
	public static IEnumerable<Setting> InGroup( string group ) => All.Where( s => s.Group == group );

	// ⚠️ NULLABLE-BACKED, INSTRUCTIONS §1: a static's value survives a hotload, its initialiser does not re-run.
	static string _section;

	/// <summary>
	/// The section the page lists, one of <see cref="Groups"/>: the groups are tabs, like the presets (2026-10-05). The user: *"i want
	/// to turn the other sections into side by side tabs i can switch between, the same way you did easy hard and normal"*.
	///
	/// ⚠️ KEPT BETWEEN OPENINGS, like the values. A name no longer among the groups (one renamed since) reads as the first.
	/// </summary>
	public static string Section => _section is not null && Groups.Contains( _section ) ? _section : Groups[0];

	/// <summary>Show this section's settings.</summary>
	public static void ChooseSection( string group )
	{
		if ( !Groups.Contains( group ) ) return;

		_section = group;
		Hovered = "";
	}

	/// <summary>Is any setting in this section moved off its preset? Its tab says so, since its rows are out of sight.</summary>
	public static bool SectionChanged( string group ) => InGroup( group ).Any( IsChanged );

	/// <summary>Is the page showing?</summary>
	public static bool Showing { get; private set; }

	/// <summary>The setting the cursor last pointed at, by id; "" until it points at one.</summary>
	public static string Hovered { get; set; } = "";

	/// <summary>The preset last picked: what every setting starts from, and what Reset returns to.</summary>
	public static Preset Base { get; private set; } = Preset.Normal;

	/// <summary>Settings moved off the preset, by id. Null or missing: the preset's value.</summary>
	static Dictionary<string, float> _changed;

	/// <summary>Changes on every edit, for the page's `BuildHash`.</summary>
	public static int Version { get; private set; }

	public static void Open()
	{
		Showing = true;
		Hovered = "";
	}

	public static void Close() => Showing = false;

	/// <summary>The setting the page's right side describes: the one last pointed at in this section, else the section's first.</summary>
	public static Setting Shown
	{
		get
		{
			var shown = InGroup( Section ).ToList();
			return shown.FirstOrDefault( s => s.Id == Hovered ) ?? shown.FirstOrDefault() ?? All.FirstOrDefault();
		}
	}

	public static float PresetValue( Setting s, Preset p )
	{
		var v = p switch
		{
			Preset.Easy => s.Easy,
			Preset.Hard => s.Hard,
			_ => s.Normal,
		};

		// ⚠️ A FLAT NUMBER FROM THE GAMEMODE: the preset's value is a multiplier of the config's, rounded to the step — and Normal
		// is the config's number itself, unrounded, so Normal is always the gamemode as it is.
		if ( s.Config is null ) return v;
		var config = ConfigValue( s.Config );
		if ( Same( v, 1f ) ) return config;
		return Math.Clamp( RoundToStep( config * v, s.Step ), MinOf( s ), MaxOf( s ) );
	}

	/// <summary>
	/// A field of the loaded gamemode's config, for a setting fetched from it (`Setting.Config`); the game's own default when no
	/// config is loaded.
	/// </summary>
	public static float ConfigValue( string field )
	{
		var c = ActiveConfig.Current;
		var g = c?.Gameplay;
		return field switch
		{
			"StartingPoints" => (float)(g?.StartingPoints ?? 500),
			"MaxAlive" => (float)(c?.Zombies?.MaxAlive ?? ZombieStats.MaxAlive),
			"SpecialEvery" => (float)(c?.Specials?.RoundInterval ?? 5),
			"BossFirst" => (float)(c?.Bosses?.FirstRound ?? 11),
			"BossEvery" => (float)(c?.Bosses?.RoundInterval ?? 10),
			"MaxHealth" => c?.Player?.MaxHealth ?? 150f,
			"PointsHit" => (float)(g?.PointsHit ?? ZombieStats.PointsHit),
			"PointsKill" => (float)(g?.PointsKill ?? ZombieStats.PointsKillBody),
			"PointsKillHeadshot" => (float)(g?.PointsKillHeadshot ?? ZombieStats.PointsKillHeadshot),
			"PointsKillKnife" => (float)(g?.PointsKillKnife ?? ZombieStats.PointsKillMelee),
			_ => 0f,
		};
	}

	/// <summary>
	/// The lowest a setting steps to: its own minimum, or the gamemode's number when that is lower (`Setting.Config`), so Normal
	/// always sits inside the range.
	/// </summary>
	public static float MinOf( Setting s ) => s.Config is null ? s.Min : MathF.Min( s.Min, ConfigValue( s.Config ) );

	/// <summary>The highest a setting steps to: its own maximum, or the gamemode's number when that is higher.</summary>
	public static float MaxOf( Setting s ) => s.Config is null ? s.Max : MathF.Max( s.Max, ConfigValue( s.Config ) );

	/// <summary>
	/// What a game started now plays on, as it travels (`Difficulty`): `id=value;…` for every setting off the gamemode's own — a
	/// percentage not at 100, a flat number not the config's. "" when it is all the gamemode's.
	/// </summary>
	public static string Snapshot()
	{
		var parts = new List<string>();
		foreach ( var s in All )
		{
			var v = ValueOf( s );
			var gamemode = s.Config is null ? 100f : ConfigValue( s.Config );
			if ( !Same( v, gamemode ) )
				parts.Add( s.Id + "=" + v.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) );
		}

		return string.Join( ";", parts );
	}

	/// <summary>
	/// Why a setting does nothing in the loaded gamemode, or "": no special rounds, no bosses, no salvage, no armor. The page shows
	/// it under the description, so a change that cannot show is not taken for one that is broken.
	/// </summary>
	public static string GamemodeNote( Setting s )
	{
		var c = ActiveConfig.Current;
		if ( c is null || s is null ) return "";

		return s.Id switch
		{
			"specialevery" when !c.Specials.Enabled || (c.Specials.UseZombieSpawns ? c.ZombieSpawns.Count == 0 : c.SpecialSpawns.Count == 0)
				=> "This gamemode has no special rounds, so this changes nothing in it.",
			"bossfirst" or "bossevery" when !c.Bosses.Enabled || c.BossSpawns.Count == 0
				=> "This gamemode has no boss rounds, so this changes nothing in it.",
			"salvage" when !c.Salvage.Enabled => "Salvage is off in this gamemode, so this changes nothing in it.",
			"plates" when !c.Armor.Enabled => "Armor is off in this gamemode, so this changes nothing in it.",
			_ => "",
		};
	}

	static float RoundToStep( float v, float step )
		=> step > 0f ? MathF.Round( v / step, MidpointRounding.AwayFromZero ) * step : v;

	public static float ValueOf( Setting s )
		=> _changed is not null && _changed.TryGetValue( s.Id, out var v ) ? v : PresetValue( s, Base );

	/// <summary>Two values the same, as stepped floats are.</summary>
	public static bool Same( float a, float b ) => MathF.Abs( a - b ) < 0.001f;

	/// <summary>Is this setting moved off its preset?</summary>
	public static bool IsChanged( Setting s ) => !Same( ValueOf( s ), PresetValue( s, Base ) );

	/// <summary>Is any setting moved off the preset: a custom difficulty?</summary>
	public static bool IsCustom => All.Any( IsChanged );

	/// <summary>What the lobby's Difficulty row reads: the preset, or "Custom (Hard)".</summary>
	public static string Label => IsCustom ? $"Custom ({NameOf( Base )})" : NameOf( Base );

	/// <summary>Take a preset: every setting goes to its value.</summary>
	public static void Choose( Preset p )
	{
		Base = p;
		_changed = null;
		Version++;
	}

	/// <summary>Back to the preset, every setting.</summary>
	public static void Reset() => Choose( Base );

	/// <summary>One step up (+1) or down (-1), within the setting's range.</summary>
	public static void Step( Setting s, int dir )
	{
		var v = Math.Clamp( ValueOf( s ) + dir * s.Step, MinOf( s ), MaxOf( s ) );
		_changed ??= new Dictionary<string, float>();
		_changed[s.Id] = v;
		Version++;
	}

	public static string NameOf( Preset p ) => p switch
	{
		Preset.Easy => "Easy",
		Preset.Hard => "Hard",
		_ => "Normal",
	};

	/// <summary>The line under the presets, saying what each is for.</summary>
	public static string BlurbOf( Preset p ) => p switch
	{
		Preset.Easy => "Weaker zombies, fewer of them, and more to spend. For learning a map.",
		Preset.Hard => "Tougher zombies, sooner and more often, with less room for mistakes.",
		_ => "The game as it is tuned.",
	};

	/// <summary>A value as the page shows it, by the setting's unit.</summary>
	public static string Format( Setting s, float v ) => s.Unit switch
	{
		"%" => $"{v:0}%",
		// ⚠️ 0 IS THE CONFIG'S OWN "NOT AGAIN": no special rounds at all, or the first boss and no other (`BossSettings.DueOn`)
		"every" => v < 0.5f ? (s.Id == "bossevery" ? "Only the first" : "Never") : Same( v, 1 ) ? "Every round" : $"Every {v:0} rounds",
		"round" => $"Round {v:0}",
		"sec" => $"{v:0} s",
		"points" => $"{v:N0}",
		_ => $"{v:0}",
	};

	/// <summary>`nz_difficulty_ui [open|close|easy|normal|hard|zombies|specials|you|economy]` — the page, a preset, a section, or (no
	/// argument) every setting as it stands.</summary>
	[ConCmd( "nz_difficulty_ui" )]
	public static void Cmd( string what = "" )
	{
		var w = (what ?? "").Trim().ToLowerInvariant();
		switch ( w )
		{
			case "open": Open(); break;
			case "close": Close(); break;
			case "easy": Choose( Preset.Easy ); break;
			case "normal": Choose( Preset.Normal ); break;
			case "hard": Choose( Preset.Hard ); break;
			case "": break;
			default:
				var group = Groups.FirstOrDefault( g => g.StartsWith( w, StringComparison.OrdinalIgnoreCase ) );
				if ( group is not null ) ChooseSection( group );
				break;
		}

		Log.Info( $"[nz-difficulty] the lobby's page: {Label}{(Showing ? " · open on " + Section : "")} · the next game started takes it"
			+ $" · this match: {Difficulty.Name} (nz_difficulty)" );
		foreach ( var s in All )
			Log.Info( $"[nz-difficulty]   {(IsChanged( s ) ? "*" : " ")} {s.Label,-24} {Format( s, ValueOf( s ) )}" );
	}
}