Power/Power.cs

Static Power manager for the map. Tracks total switches, which switch indices have been flipped, exposes derived flags (IsOn, Switched), handles flipping logic, host/client network handoff, plays sounds and fires OnPowered event when power becomes active.

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

namespace NZombies;

/// <summary>
/// The map's electricity. One flag, read by doors, spawns and anything else
/// gated behind it.
///
/// ⚠️ THE "ON" STATE IS DERIVED, NOT STORED.
///
///     no switch placed  ->  ON       (nothing on this map is gated)
///     switch placed     ->  OFF until somebody flips it
///
/// Storing a bool instead would mean adding the first switch to a map leaves
/// the power stuck on until something remembers to clear the flag, and deleting
/// the last switch leaves a map permanently unpowered with no way to fix it.
/// Deriving it makes both cases correct for free.
/// </summary>
public static class Power
{
	/// <summary>Has somebody flipped a switch this game? Meaningless on a map
	/// with no switches — see IsOn.</summary>
	public static bool Switched => !HasSwitch || Flipped >= Total;

	// ── every switch, not any switch ─────────────────────────────────────────
	// ⛔ THIS WAS ONE GLOBAL BOOL AND ANY SINGLE SWITCH POWERED THE WHOLE MAP. A map author
	// placing a second panel got a second way to do the same thing rather than a second thing to
	// do — which makes placing more than one actively misleading, since the map looks like it is
	// asking for both.
	//
	// ⚠️ THE SET IS OF INDICES INTO `ActiveConfig.Current.PowerSwitches`, which is the same key
	// `PowerManager` spawns and aims by — so "which switch did you flip" has one answer
	// everywhere and the visual can show a flipped-but-not-yet-enough panel.

	static readonly HashSet<int> _flipped = new();

	/// <summary>How many switches this map has. 0 means the power is simply on.</summary>
	public static int Total => ActiveConfig.Current?.PowerSwitches?.Count ?? 0;

	/// <summary>How many have been flipped.</summary>
	public static int Flipped => _flipped.Count;

	/// <summary>Has this particular switch been flipped — for the prop's own look.</summary>
	public static bool IsFlipped( int index ) => _flipped.Contains( index );

	/// <summary>
	/// Require EVERY switch, rather than any one. On by request.
	///
	/// ⚠️ A KNOB BECAUSE IT CHANGES EXISTING MAPS. Any map already built around "one switch, any
	/// switch" that happens to carry two panels becomes unfinishable the moment this is on and
	/// the second panel is somewhere the author never expected anyone to go. `nz_power_all 0`
	/// restores the old rule.
	/// </summary>
	public static bool RequireAll { get; set; } = true;

	/// <summary>Does this map have a switch at all?</summary>
	public static bool HasSwitch
		=> ActiveConfig.Current?.PowerSwitches?.Count > 0;

	/// <summary>Is the power on right now?</summary>
	/// <summary>
	/// Is the power on. No switches = always on; otherwise every switch, or any one when
	/// `RequireAll` is off.
	/// </summary>
	public static bool IsOn
		=> !HasSwitch || (RequireAll ? _flipped.Count >= Total : _flipped.Count > 0);

	/// <summary>Fired when the power comes on, so systems can react without
	/// polling. PowerManager uses it to open the free doors.</summary>
	public static event System.Action OnPowered;

	/// <summary>
	/// Flip the switch. Returns false if it was already on, so a caller can tell
	/// "you turned it on" from "it was already on" without checking first.
	/// </summary>
	public static bool TurnOn()
	{
		if ( IsOn ) return false;

		// ⛔ `Switched` IS A STATIC BOOL, so the power was on for exactly the machine that flipped
		// the switch. A client turning it on lit its own map, opened its own free doors and left
		// the host in the dark; the host's zombies, doors and perk machines never heard.
		// User: *"when the client turns electricity on... it opens for the client, not for the host."*
		//
		// ⚠️ A CLIENT ASKS AND RETURNS FALSE. It must NOT flip locally first: the host's broadcast
		// arrives a moment later and drives every consequence — the free doors, the flags, the nav —
		// through the same path on every machine. Flipping locally as well would open the client's
		// free doors twice and its flags from two directions.
		if ( Networking.IsActive && NZGame.IsClient )
		{
			NZNet.PowerAsk();
			return false;
		}

		// ⚠️ FLIPS EVERY SWITCH, because this is the "make the power on" entry point — the console
		// command and the host applying a client's request. `Flip` is the one that means "a player
		// pulled THIS lever"; conflating them would make `nz_power_on` unable to express itself on
		// a two-switch map.
		for ( int i = 0; i < Total; i++ ) _flipped.Add( i );
		ApplyPowered();
		return true;
	}

	/// <summary>
	/// Back to unpowered. Only meaningful on a map that has a switch — on one
	/// without, IsOn stays true and this is a no-op by design.
	/// </summary>
	public static void Reset()
	{
		// ⛔ SILENT WHEN IT WAS ALREADY OFF. Reset runs at the start of every
		// game, and the original only plays power_down when the electricity
		// actually goes OUT — firing it on each round-one countdown would turn a
		// rare, alarming cue into part of the loading noise.
		bool was = Switched;
		_flipped.Clear();

		if ( was ) NZSound.Play( NZSound.PowerOff );
	}

	/// <summary>One line for the console and the map editor.</summary>
	/// <summary>
	/// Flip ONE switch. Returns a line describing what happened, for the use prompt.
	///
	/// ⛔ THE HOST DECIDES, ALWAYS. A client flipping locally would light its own map on the
	/// last switch while the host stayed dark — the exact bug `TurnOn`'s own note records from
	/// before the relay existed. The client asks and waits.
	/// </summary>
	public static string Flip( int index )
	{
		if ( IsOn ) return "power is already on";
		if ( index < 0 || index >= Total ) return $"no switch #{index}";
		if ( _flipped.Contains( index ) ) return $"switch #{index + 1} is already on";

		if ( Networking.IsActive && NZGame.IsClient )
		{
			NZNet.PowerAsk( index );
			return "asking the host";
		}

		_flipped.Add( index );

		if ( !IsOn )
		{
			// ⚠️ TOLD TO EVERYONE EVEN WHEN IT DOES NOT COMPLETE THE SET, so a teammate across the map
			// sees that lever move and the count on their own prompt go up. Without it the only
			// feedback for switch 1 of 3 is on the machine that pulled it.
			if ( Networking.IsActive && NZGame.IsHost )
				NZNet.PowerFlipped( index );

			NZSound.Play( NZSound.PowerOn );
			Log.Info( $"[nz] switch {Flipped}/{Total}" );
			return $"switch {Flipped} of {Total} — find the rest";
		}

		// ⛔ BUT NOT THE LEVER THAT COMPLETES THE SET: `PowerIsOn` carries that one (the co-op pass, 2026-09-28). Sent as a flip
		// first, it reached a client before the power did and counted every lever down, so `TurnOnFromHost` — which returns when
		// they all are — returned: no `OnPowered` on any client, so no tints, no tremor, no motif, and no power-on sound. On basalt,
		// with its one lever, that was every time.
		//
		// ⛔ THE SET IS COMPLETE, SO RUN THE REAL THING. `_flipped` already contains this index, so
		// `TurnOn`'s `if ( IsOn ) return false` would refuse — the consequences are invoked here
		// through the same helper `TurnOn` uses rather than by duplicating them.
		ApplyPowered();
		return "power on";
	}

	/// <summary>Record a flip the host made. Client side, no consequences — `PowerIsOn`
	/// carries those when the set completes.</summary>
	public static void FlipFromHost( int index )
	{
		if ( index < 0 ) return;
		if ( !_flipped.Add( index ) ) return;

		if ( !IsOn ) NZSound.Play( NZSound.PowerOn );
	}

	public static string Summary => !HasSwitch
		? "ON (no switch on this map)"
		: IsOn ? $"ON ({Total} switch(es))"
			: $"OFF — {Flipped} of {Total} switch(es) flipped{(RequireAll ? "" : ", any one will do")}";

	/// <summary>
	/// The host says the power is on. Clients only, and it must not ask again.
	///
	/// ⚠️ IT RUNS THE SAME CONSEQUENCES `TurnOn` DOES — `OnPowered` is what opens the free doors,
	/// tints the machines and unlocks everything that tests `IsOn` — so a client reaches the state
	/// the host reached rather than a subset of it.
	/// </summary>
	/// <param name="live">It has just come on. False for a joiner's catch-up (`NZNet.SendGame`), which is told the power IS on —
	/// the doors, the tints — but must not feel the tremor or hear the motif as it walks in (the co-op pass, 2026-09-28).</param>
	public static void TurnOnFromHost( bool live = true )
	{
		if ( Switched ) return;

		for ( int i = 0; i < Total; i++ ) _flipped.Add( i );
		OnPowered?.Invoke();

		if ( !live ) return;

		// ⚠️ THE POWER-ON SOUND, WHICH ONLY THE HOST HAD PLAYED (`ApplyPowered`) — the cue is 2D and map-wide by design (`NZSound.PowerOn`)
		NZSound.Play( NZSound.PowerOn );

		// ⚠️ AND THE GROUND SHAKES HERE TOO, each machine its own view (`PowerTremor`), on a map that asks for it — and the map's
		// motif sounds (`PowerMotif`)
		PowerTremor.OnPowerOn();
		PowerMotif.Play();
	}

	/// <summary>
	/// Everything that happens when the power actually comes on.
	///
	/// ⛔ EXTRACTED SO `Flip` AND `TurnOn` CANNOT DIVERGE. There are now two routes to powered —
	/// the last of several switches, and "turn it on" from the console or a client request — and
	/// a second copy of the voice line, the sound, `OnPowered` and the broadcast is a second copy
	/// that will eventually be missing one of them. The ORDER matters and is the original's:
	/// cue before `OnPowered` so the lever is heard before the doors it opens, and the broadcast
	/// after it so the host's own free doors are already open when clients apply theirs.
	/// </summary>
	static void ApplyPowered()
	{
		Log.Info( "[nz] POWER ON" );
		CharacterVoice.Say( "poweron" );
		NZSound.Play( NZSound.PowerOn );

		// ⚠️ AND THE GROUND SHAKES, THE CEILING SHEDDING (`PowerTremor`, 2026-09-28) on a map that asks for it (`Gameplay.PowerOnTremor`,
		// basalt's, since 2026-10-01), AND THE MAP'S MOTIF SOUNDS (`PowerMotif`) — here for the host and solo; a client's are in
		// `TurnOnFromHost`
		PowerTremor.OnPowerOn();
		PowerMotif.Play();

		OnPowered?.Invoke();

		if ( Networking.IsActive && NZGame.IsHost )
			NZNet.PowerIsOn( live: true );
	}
}