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