Power/PowerCommands.cs

Console command handlers for the power system. Provides developer commands to query prompts, show power status, place/clear/flip switches, force power on/off, mark doors or spawns as gated by power, and change the switch model.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>Console access to the whole power system — place, flip, inspect.</summary>
public static class PowerCommands
{
	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>Created on demand, like DebrisManager — nothing in the scene file
	/// has to be wired for the power to work. NotSaved so it never gets baked
	/// into a map.</summary>
	static PowerManager Manager => PowerManager.Ensure( Game.ActiveScene );

	/// <summary>
	/// What the use prompt says right now: nz_prompt.
	///
	/// The prompt is the only feedback a player gets about why something will
	/// not open, and it has four different wordings per door. Checking it by
	/// walking up to things one at a time is how you miss the case you did not
	/// think of.
	/// </summary>
	[ConCmd( "nz_prompt" )]
	public static void Prompt()
	{
		var p = Player;
		var text = UsePrompt.Text( p );

		Log.Info( string.IsNullOrEmpty( text )
			? $"[nz] no prompt — nothing usable within {UsePrompt.Range:0}u"
			: $"[nz] \"{text}\"{(UsePrompt.IsRefusal( text ) ? "  (refusal — drawn amber)" : "")}" );

		// ⚠️ Report the ACTUAL distance whether or not a prompt showed. "No
		// prompt" has two very different causes — nothing under the crosshair,
		// or something under it that you are standing too far from — and the
		// number is the only thing that tells them apart.
		if ( !p.IsValid() ) return;

		var cam = Game.ActiveScene?.Camera;
		if ( !cam.IsValid() ) return;

		var tr = Game.ActiveScene.Trace
			.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 512f )
			.IgnoreGameObjectHierarchy( p.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Info( "[nz]   crosshair on nothing within 512u" ); return; }

		var dist = tr.EndPosition.Distance( p.WorldPosition );
		Log.Info( $"[nz]   crosshair on '{tr.GameObject?.Name ?? "?"}' at {dist:0}u "
			+ $"(reach {UsePrompt.Range:0}u — {(dist <= UsePrompt.Range ? "in range" : "TOO FAR")})" );
	}

	/// <summary>
	/// What a barrier's prompt would say: nz_prompt_door [index]. No index dumps
	/// every one, which is the only way to see all the wordings at once.
	/// </summary>
	[ConCmd( "nz_prompt_door" )]
	public static void PromptDoor( int index = -1 )
	{
		var list = ActiveConfig.Current.Debris;
		if ( list.Count == 0 ) { Log.Warning( "[nz] no barriers placed" ); return; }

		Log.Info( $"[nz] power {Power.Summary}, mode {NZGame.Mode}" );

		for ( int i = 0; i < list.Count; i++ )
		{
			if ( index >= 0 && i != index ) continue;

			var d = list[i];
			var text = UsePrompt.ForDebris( d );

			Log.Info( $"[nz]   #{i} {d.Price,6}pts link {d.Link}"
				+ $"{(d.RequiresPower ? " elec" : "     ")}"
				+ $"{(DoorLinks.IsOpen( d.Link ) ? " OPEN" : "     ")}"
				+ $"  -> {(string.IsNullOrEmpty( text ) ? "<nothing>" : $"\"{text}\"")}" );
		}
	}

	/// <summary>State of the power, and what depends on it: nz_power.</summary>
	[ConCmd( "nz_power" )]
	public static void Status()
	{
		var cfg = ActiveConfig.Current;

		Log.Info( $"[nz] power {Power.Summary}" );
		Log.Info( $"[nz]   {cfg.PowerSwitches.Count} switch(es) placed" );

		var doors = cfg.Debris.Where( d => d.RequiresPower ).ToList();
		var free = doors.Count( d => d.Price == 0 );
		var spawns = cfg.ZombieSpawns.Count( s => s.RequiresPower );

		Log.Info( $"[nz]   {doors.Count} door(s) need power "
			+ $"({free} of them free — they open by themselves)" );
		Log.Info( $"[nz]   {spawns} zombie spawn(s) need power" );

		if ( !Power.HasSwitch && (doors.Count > 0 || spawns > 0) )
			Log.Warning( "[nz]   ⚠ things are gated on power but NO SWITCH is placed — "
				+ "so they are all permanently available. Place one with nz_power_place." );
	}

	/// <summary>Place a switch where you are looking: nz_power_place.</summary>
	[ConCmd( "nz_power_place" )]
	public static void Place()
	{
		// Aim from the CAMERA, matching nz_debris and DebrisManager.Aimed — that
		// is where the crosshair is. (The camera is only the wrong anchor for
		// measuring distances, which is the bug four other commands had.)
		var scene = Game.ActiveScene;
		var cam = scene?.Camera;
		if ( !cam.IsValid() ) { Log.Warning( "[nz] no camera" ); return; }

		// ⚠️ Ignore the player. The camera is at eye height inside their own
		// collider, so without this the trace stops on the body it started in
		// and the switch gets placed at your feet — which is exactly what
		// happened the first two times.
		var tr = scene.Trace
			.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 512f )
			.IgnoreGameObjectHierarchy( Player?.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Warning( "[nz] aim at something within 512u" ); return; }

		// Face the placer, same as the spawn markers and debris.
		var yaw = Rotation.LookAt( (cam.WorldPosition - tr.HitPosition).WithZ( 0 ) ).Yaw();

		// Sit it ON the surface rather than centred in it — half its height up.
		var sw = new PowerSwitch { Yaw = yaw };
		sw.Position = tr.HitPosition + Vector3.Up * (sw.Size.z * 0.5f);

		var wasOn = Power.IsOn;
		ActiveConfig.Current.PowerSwitches.Add( sw );
		Manager?.Rebuild();

		Log.Info( $"[nz] switch #{ActiveConfig.Current.PowerSwitches.Count - 1} at {sw.Position}" );

		// The first switch on a map flips the power off by itself. Say so —
		// otherwise it looks like placing a prop broke every door.
		if ( wasOn && !Power.IsOn )
			Log.Info( "[nz] ⚠ this was the FIRST switch — power is now OFF until it is used" );
	}

	/// <summary>Remove every switch: nz_power_clear. Power returns to ON.</summary>
	[ConCmd( "nz_power_clear" )]
	public static void ClearSwitches()
	{
		var n = ActiveConfig.Current.PowerSwitches.Count;
		ActiveConfig.Current.PowerSwitches.Clear();
		Manager?.Rebuild();

		Log.Info( $"[nz] removed {n} switch(es) — power {Power.Summary}" );
	}

	/// <summary>Flip the switch you are looking at: nz_power_use.</summary>
	[ConCmd( "nz_power_use" )]
	public static void Use()
	{
		if ( Manager is null ) { Log.Warning( "[nz] no PowerManager in the scene" ); return; }
		Log.Info( $"[nz] {Manager.Use( Player )}" );
	}

	/// <summary>Force the power on without walking to a switch: nz_power_on.
	/// Testing shortcut — the real route is nz_power_use.</summary>
	/// <summary>
	/// `nz_power_all [0|1]` — does EVERY switch have to be flipped, or any one.
	///
	/// ⚠️ 1 BY DEFAULT AND BY REQUEST. 0 is the old rule, kept because a map already built around
	/// "any switch" that happens to carry a second panel becomes unfinishable under the new one.
	/// </summary>
	[ConCmd( "nz_power_all" )]
	public static void RequireAllCmd( int on = -1 )
	{
		if ( on >= 0 ) Power.RequireAll = on != 0;

		Log.Info( Power.RequireAll
			? $"[nz] power needs ALL {Power.Total} switch(es) — {Power.Flipped} flipped, {Power.Summary}"
			: $"[nz] power needs ANY ONE of {Power.Total} switch(es) — {Power.Summary}" );

		for ( int i = 0; i < Power.Total; i++ )
			Log.Info( $"[nz]   switch #{i + 1} {(Power.IsFlipped( i ) ? "ON" : "off")}"
				+ $"  at {ActiveConfig.Current.PowerSwitches[i].Position}" );
	}

	/// <summary>`nz_power_flip &lt;n&gt;` — flip ONE switch, 1-based, without walking to it.</summary>
	[ConCmd( "nz_power_flip" )]
	public static void FlipCmd( int n = 0 )
	{
		if ( n < 1 || n > Power.Total )
		{ Log.Info( $"[nz] nz_power_flip <1..{Power.Total}>" ); return; }

		Log.Info( $"[nz] switch #{n}: {Power.Flip( n - 1 )}" );
		PowerManager.Instance?.Refresh();
	}

	[ConCmd( "nz_power_on" )]
	public static void On()
	{
		if ( !Power.TurnOn() ) { Log.Info( $"[nz] power was already {Power.Summary}" ); return; }
		Manager?.Rebuild();
	}

	/// <summary>Back to unpowered: nz_power_off. No-op on a map with no switch,
	/// because there IsOn is always true by design.</summary>
	[ConCmd( "nz_power_off" )]
	public static void Off()
	{
		Power.Reset();
		Manager?.Rebuild();

		Log.Info( Power.HasSwitch
			? "[nz] power OFF"
			: "[nz] no switch on this map — power stays ON by design" );
	}

	/// <summary>
	/// Mark a barrier as needing power: nz_power_door [0/1] [index].
	///
	/// Without an index it uses whatever you are aiming at. With one it works
	/// headless, same as nz_debris_edit — which is the only way to drive this
	/// from a console with no crosshair.
	/// </summary>
	[ConCmd( "nz_power_door" )]
	public static void GateDoor( int on = 1, int index = -1 )
	{
		var list = ActiveConfig.Current.Debris;

		if ( index < 0 )
		{
			var mgr = DebrisManager.Instance;
			if ( mgr is null ) { Log.Warning( "[nz] no DebrisManager" ); return; }

			index = mgr.Aimed( Player );
			if ( index < 0 ) { Log.Warning( "[nz] not looking at a barrier — pass an index" ); return; }
		}

		if ( index >= list.Count ) { Log.Warning( $"[nz] no debris #{index}" ); return; }

		var d = list[index];
		d.RequiresPower = on != 0;

		Log.Info( $"[nz] debris #{index} price {d.Price}, "
			+ (d.RequiresPower
				? d.Price == 0
					? "needs power — and is FREE, so it opens itself when the power comes on"
					: "needs power — buyable only once the power is on"
				: "no longer needs power") );
	}

	/// <summary>Mark the nearest zombie spawn as needing power:
	/// nz_power_spawn [0/1].</summary>
	[ConCmd( "nz_power_spawn" )]
	public static void GateSpawn( int on = 1 )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var spawns = ActiveConfig.Current.ZombieSpawns;
		if ( spawns.Count == 0 ) { Log.Warning( "[nz] no zombie spawns placed" ); return; }

		var index = 0;
		var best = float.MaxValue;
		for ( int i = 0; i < spawns.Count; i++ )
		{
			var dist = spawns[i].Position.Distance( p.WorldPosition );
			if ( dist < best ) { best = dist; index = i; }
		}

		spawns[index].RequiresPower = on != 0;
		Log.Info( $"[nz] spawn #{index} ({best:0}u away) "
			+ (on != 0 ? "needs power" : "no longer needs power") );
	}

	/// <summary>
	/// Which prop the switches use: nz_power_model [path].
	///
	/// Bare command lists the two that are ported. ⚠️ Rebuilds immediately, so
	/// the change is visible without leaving and re-entering creative — a model
	/// swap you cannot see is indistinguishable from one that did not apply.
	/// </summary>
	[ConCmd( "nz_power_model" )]
	public static void SwitchModel( string path = "" )
	{
		var m = Manager;
		if ( m is null ) { Log.Warning( "[nz] no scene" ); return; }

		if ( string.IsNullOrWhiteSpace( path ) )
		{
			Log.Info( $"[nz] power switch model: {m.SwitchModel}" );
			Log.Info( "[nz]   models/nzprops/zombies_power_lever_short.vmdl  (the original's Power Switch)" );
			Log.Info( "[nz]   models/nzprops/zombies_power_lever.vmdl        (the tall one)" );
			Log.Info( "[nz]   \"\" or a bad path                              (tinted box fallback)" );
			return;
		}

		m.SwitchModel = path == "box" ? "" : path;
		m.Rebuild();
	}
}