Buyables/MiseryDevice.cs

A game Component representing a map object that toggles a global "Misery" game state which removes round gaps, forces minimum spawn delay, and raises zombie speed. Tracks all instances, provides proximity lookup, handles player use and a console command, and resets state at round start.

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

namespace NZombies;

/// <summary>
/// THE MISERY ACCELERATION DEVICE — walk up, press E, the round stops being kind.
///
/// While it is ON:
/// <list type="bullet">
/// <item>the gap between rounds is gone — the next round begins immediately</item>
/// <item>the spawn delay drops to the configured minimum</item>
/// <item>every zombie, living and future, is at the top speed tier</item>
/// </list>
///
/// ⛔ THE STATE IS ONE STATIC, READ BY THREE SYSTEMS — not three copies pushed into
/// them. `RoundManager.BeginPrep`, `RoundManager.TickSpawn` and `ZombieAI`'s spawn all
/// ASK. Pushing values in would mean each of them owning a "what was it before" to
/// restore, and turning the device off would then depend on three separate restores
/// being right — the exact shape that leaves one setting stuck on.
///
/// ⚠️ SO TURNING IT OFF NEEDS NO UNDO. The round gap and the spawn delay simply go back
/// to being computed the normal way. The only thing that cannot un-happen is the speed
/// of zombies ALREADY raised, and that is deliberate: `RaiseSpeedRating` is documented
/// as a floor that must never slow one down, and a horde that got faster staying faster
/// is the honest reading of a device you chose to switch on.
/// </summary>
public sealed class MiseryDevice : Component
{
	/// <summary>Every live device, for the use trace and the prompt.</summary>
	public static readonly List<MiseryDevice> All = new();

	protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
	protected override void OnDisabled() => All.Remove( this );

	/// <summary>The config row this was built from.</summary>
	[Property] public MiserySpot Spot { get; set; }

	/// <summary>
	/// Is misery running?
	///
	/// ⛔ STATIC, AND STARTS FALSE. It is a property of the RUN, not of the box — two
	/// devices on one map are two switches for one state, which is what a player would
	/// expect from a lever, and it means the round systems have one thing to ask rather
	/// than a list to search.
	///
	/// ⚠️ RESET BY `RoundManager.StartGame`, so a new run never inherits the last one's
	/// misery. A static that survives a restart is the §1 trap this project keeps hitting.
	///
	/// ⛔ `Running`, NOT `Active` — `Component.Active` ALREADY EXISTS and a static of that
	/// name hides it (CS0108, a WARNING not an error, so it would have shipped). This is the
	/// THIRD time in this project: `DamageOverlay.Enabled`, `DamageWallVolume.Enabled`, now
	/// this. Any short state word on a Component is suspect.
	/// </summary>
	public static bool Running { get; private set; }

	/// <summary>How close you must stand. Matches the ammo box and Pack-a-Punch.</summary>
	public const float UseRange = 90f;

	/// <summary>The nearest device, or null.</summary>
	public static MiseryDevice Near( Vector3 pos )
	{
		MiseryDevice best = null;
		var bestDist = UseRange;

		foreach ( var d in All )
		{
			if ( !d.IsValid() ) continue;

			var dist = pos.Distance( d.WorldPosition );
			if ( dist > bestDist ) continue;

			bestDist = dist;
			best = d;
		}

		return best;
	}

	/// <summary>Why this cannot be used right now, or empty when it can.
	///
	/// ⚠️ ONE METHOD FOR BOTH THE PROMPT AND THE KEY — the rule `NZPlayer.TickUse` and
	/// `UsePrompt.Text` are written against.</summary>
	public string Unavailable( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";

		var round = RoundManager.Instance;

		// ⚠️ NOT IN THE LOBBY OR AFTER A GAME OVER. Toggling misery with no round running
		// would set a flag that the next `StartGame` immediately clears, which reads as the
		// switch not working.
		if ( round.IsValid() && round.State == RoundState.GameOver )
			return "the run is over";

		return "";
	}

	/// <summary>Flip it. Returns what happened, for the log and the prompt.</summary>
	public string Toggle( NZPlayer player )
	{
		var blocked = Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		Running = !Running;

		NZSound.Play( NZSound.Purchase, WorldPosition );

		if ( Running )
		{
			// ⛔ THE ZOMBIES ALREADY OUT HAVE TO BE RAISED HERE. The spawn path only sets the
			// rating for zombies created AFTER this point, so without this the horde already
			// chasing you would keep shambling and the device would look like it did nothing
			// until the next round.
			var raised = 0;

			foreach ( var z in ZombieAI.All )
				if ( z.IsValid() && z.RaiseSpeedRating( WalkerAnimations.SuperSprintRating ) )
					raised++;

			return $"MISERY ON — no gap between rounds, minimum spawn delay, "
				+ $"top speed tier ({raised} already out raised)";
		}

		// ⚠️ NOTHING IS PUT BACK. See the class remarks: the two round settings go back to
		// being computed normally on their own, and zombies already raised stay raised.
		return "MISERY OFF — rounds and spawns back to normal (zombies already sped up stay)";
	}

	/// <summary>Clear the flag for a new run.
	///
	/// ⛔ CALLED FROM `RoundManager.StartGame`. `Running` is static and would otherwise
	/// survive into the next game — a run that began already miserable, with nothing on
	/// screen saying why.
	///
	/// ⛔ `ClearForNewRun`, NOT `Reset` — `Component.Reset()` EXISTS and this would have
	/// hidden it (CS0114). Same family of collision as `Running` above.</summary>
	public static void ClearForNewRun() => Running = false;

	/// <summary>`nz_misery [0/1]` — toggle it without walking to one.</summary>
	[ConCmd( "nz_misery" )]
	public static void Cmd( int on = -1 )
	{
		var player = NZPlayer.Local;

		if ( on >= 0 )
		{
			var want = on != 0;

			// ⚠️ ROUTED THROUGH `Toggle` WHEN A DEVICE EXISTS, so the console and the use key
			// cannot diverge — the raise-the-horde step lives there and a second copy here
			// would be a second definition of what "on" means.
			var dev = All.FirstOrDefault( d => d.IsValid() );

			if ( want != Running && dev is not null && player.IsValid() )
				Log.Info( $"[nz-misery] {dev.Toggle( player )}" );
			else if ( want != Running )
				Running = want;
		}

		Log.Info( $"[nz-misery] {( Running ? "ON" : "off" )} · {All.Count} device(s) placed"
			+ $" · zombies out {ZombieAI.All.Count( z => z.IsValid() )}" );
	}
}