Objectives/SoulBoxCommands.cs

Console command handlers for soul box editor and debug operations. Provides commands to list, place, clear, rebuild, reset, feed, inspect, and configure soul boxes and their rewards, and includes utilities for debugging network relay and lid animation.

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the soul boxes. Every button in the tool panel has an equivalent here, and
/// `nz_soul_feed` exists because the only other way to fill one is to kill twenty zombies.
/// </summary>
public static class SoulBoxCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>
	/// Watch the soul relay: `nz_soul_net [on]`.
	///
	/// ⚠️ RUN IT ON BOTH MACHINES. The host prints SENT, the client prints GOT, and the pair of
	/// logs is the whole diagnosis — no SENT means the kill never reached a box, a SENT with no GOT
	/// means the message is lost or the index does not exist here, and a GOT that says NOTHING
	/// HAPPENED means the box was already at that count or refused the feed.
	/// </summary>
	[ConCmd( "nz_soul_net" )]
	public static void NetCmd( int on = -1 )
	{
		SoulBox.NetDebug = on < 0 ? !SoulBox.NetDebug : on > 0;

		Log.Info( $"[nz-soul-net] relay logging {(SoulBox.NetDebug ? "ON" : "off")}"
			+ $" · {(NZGame.IsHost ? "I am the HOST (expect SENT)" : "I am a CLIENT (expect GOT)")}"
			+ $" · {SoulBox.All.Count} box(es) here"
			+ $" · received {SoulBox.NetReceived}, applied {SoulBox.NetApplied}" );

		foreach ( var b in SoulBox.All )
			if ( b.IsValid() )
				Log.Info( $"[nz-soul-net]   box #{b.Index}: {b.Current}/{b.Target}"
					+ $" at {b.WorldPosition}"
					+ (string.IsNullOrEmpty( b.Unavailable() ) ? "  ready" : $"  ⛔ {b.Unavailable()}") );
	}

	/// <summary>Drop one at your feet: `nz_soul [target] [range] [flag]`.</summary>
	[ConCmd( "nz_soul" )]
	public static void Place( int target = 0, float range = 0f, string flag = "" )
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-soul] no editor/player" ); return; }

		if ( target > 0 ) ed.SoulBoxTarget = target;
		if ( range > 0f ) ed.SoulBoxRange = range;
		if ( !string.IsNullOrWhiteSpace( flag ) ) ed.SpawnLink = flag;

		ed.AddSoulBoxAt( p.WorldPosition, Vector3.Up );
	}

	/// <summary>
	/// What is placed, grouped by flag: `nz_soul_list`.
	///
	/// ⛔ GROUPED BY FLAG, NOT LISTED FLAT. The flag is the unit that matters — a box on its own
	/// tells you nothing about when a door opens, and "3 of 5 full" is the only line an author
	/// actually wants.
	/// </summary>
	[ConCmd( "nz_soul_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current.SoulBoxes;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-soul] none placed — Q > Placeables > Map objects > Soul box, or nz_soul" );
			return;
		}

		var live = SoulBoxManager.All();

		for ( int i = 0; i < list.Count; i++ )
		{
			var b = list[i];
			var l = live.FirstOrDefault( x => x.Index == i );

			Log.Info( $"[nz-soul] [{i}] {(l is null ? "?" : l.Current.ToString()),3}/{b.Target,-3}"
				+ $"  range {b.Range,5:0}"
				+ (b.RequiresPower ? "  ⚡" : "   ")
				+ $"  flag {DoorLinks.Display( b.Link ),-8}"
				+ $"  {b.Position}" );
		}

		foreach ( var flag in list.Select( b => b.Link ).Distinct() )
		{
			var group = list.Where( b => DoorLinks.Same( b.Link, flag ) ).ToList();
			var full = live.Count( x => DoorLinks.Same( x.Link, flag ) && x.IsFull );

			// ⚠️ THE FLAG'S PART IS REPORTED PER FLAG, NOT PER BOX, because that is what it is —
			// one box of the set carries the setting and the set pays it once.
			var part = group.Select( b => b.FinalPart ).FirstOrDefault( BuildParts.Valid );

			Log.Info( $"[nz-soul] flag {DoorLinks.Display( flag )}: {full}/{group.Count} full"
				+ (DoorLinks.IsUnlinked( flag )
					? "  — gates nothing"
					: DoorLinks.IsOpen( flag ) ? "  — OPEN" : "  — closed")
				+ (BuildParts.Valid( part )
					? $"  ·  pays the {BuildParts.Name( part )} (part {part})"
						+ (group.FirstOrDefault( b => b.HasPartDrop ) is SoulBoxSpot d
							? $" at {d.PartDrop}"
							: " above the last box filled")
					: "  ·  pays a powerup") );
		}

		// ⚠️ THE LIVE COUNT COMES FROM THE SCENE, not from SoulBoxManager.Instance. A hotload nulls
		// the manager's static while leaving the boxes standing, and reporting "?" then is a lie
		// about the world rather than a fact about the manager.
		Log.Info( $"[nz-soul] {list.Count} configured, {live.Count} standing"
			+ (SoulBoxManager.Instance.IsValid() ? "" : "  (manager static lost to a hotload)") );
	}

	/// <summary>
	/// What a flag's completed set pays: `nz_soul_part &lt;flag&gt; [1..3]`, 0 for a powerup.
	/// </summary>
	///
	/// ⚠️ IT WRITES ONE BOX OF THE FLAG AND CLEARS THE REST, which is the shape the reward has:
	/// the set pays once, and the box that happens to fill last asks its siblings what is owed.
	/// Leaving a second box with a stale part would still work — the first match wins — but the
	/// config would then say two different things and only one of them would be true.
	///
	/// ⛔ IT CHANGES THE LIVE CONFIG, NOT THE FILE. Save the map config afterwards or the edit dies
	/// with the session, and remember that a saved config lands in Data rather than Assets — run
	/// `py Tools/ship_data.py --apply` before publishing or the published game keeps the old one.
	[ConCmd( "nz_soul_part" )]
	public static void Part( string flag, int part = 0 )
	{
		var group = ActiveConfig.Current.SoulBoxes
			.Where( b => DoorLinks.Same( b.Link, flag ) )
			.ToList();

		if ( group.Count == 0 )
		{
			Log.Warning( $"[nz-soul] no boxes on flag {flag}" );
			return;
		}

		if ( part != 0 && !BuildParts.Valid( part ) )
		{
			Log.Warning( $"[nz-soul] {part} is not a build part — 1..{BuildParts.Count}, or 0 for"
				+ " a powerup" );
			return;
		}

		foreach ( var b in group ) b.FinalPart = 0;
		group[0].FinalPart = part;

		Log.Info( $"[nz-soul] flag {DoorLinks.Display( flag )} ({group.Count} box(es)) now pays "
			+ (BuildParts.Valid( part )
				? $"the {BuildParts.Name( part )} (part {part})"
				: "a powerup")
			+ " — save the config, then py Tools/ship_data.py --apply" );
	}

	/// <summary>
	/// Where a flag's completed set drops its part: `nz_soul_partdrop &lt;flag&gt; here`, or an
	/// explicit `x y z [yaw]`, or `clear` for "above the last box filled".
	/// </summary>
	///
	/// ⛔ ON THE FLOOR, THE WAY A PLACED PART IS. `MapEditor.AddBuildPartAt` puts an authored part
	/// at the surface hit, so a drop spot is traced down to the floor too — `here` from where you
	/// stand, which in noclip is wherever you are floating, and explicit coordinates the same way.
	/// A part hanging at eye height where the author happened to be flying would read as a bug.
	///
	/// ⚠️ `here` FACES THE WAY YOU ARE LOOKING, since you are standing on the spot and there is no
	/// placer position to face, which is what the placement tool uses.
	///
	/// ⚠️ IT WRITES ONE BOX OF THE FLAG AND CLEARS THE REST — the shape `nz_soul_part` gives the part
	/// itself, for the same reason: the set asks its boxes and the first answer wins, so a stale
	/// second answer would be a config that says two things.
	///
	/// ⛔ IT CHANGES THE LIVE CONFIG, NOT THE FILE. Save the map config afterwards, then
	/// `py Tools/ship_data.py --apply` before publishing.
	[ConCmd( "nz_soul_partdrop" )]
	public static void PartDrop( string flag, string x = "", float y = float.NaN, float z = float.NaN,
		float yaw = float.NaN )
	{
		var group = ActiveConfig.Current.SoulBoxes
			.Where( b => DoorLinks.Same( b.Link, flag ) )
			.ToList();

		if ( group.Count == 0 )
		{
			Log.Warning( $"[nz-soul] no boxes on flag {flag}" );
			return;
		}

		var part = group.Select( b => b.FinalPart ).FirstOrDefault( BuildParts.Valid );

		if ( x.Equals( "clear", System.StringComparison.OrdinalIgnoreCase ) )
		{
			foreach ( var b in group ) b.HasPartDrop = false;
			Log.Info( $"[nz-soul] flag {DoorLinks.Display( flag )} drops its part above the last box"
				+ " filled — save the config, then py Tools/ship_data.py --apply" );
			return;
		}

		Vector3 at;
		float facing;

		if ( x.Equals( "here", System.StringComparison.OrdinalIgnoreCase ) )
		{
			var p = NZPlayer.Local;
			if ( !p.IsValid() ) { Log.Warning( "[nz-soul] no local player to stand on the spot" ); return; }

			at = p.WorldPosition;
			facing = p.EyeAngles.yaw;
		}
		else if ( float.TryParse( x, System.Globalization.NumberStyles.Float,
			System.Globalization.CultureInfo.InvariantCulture, out var px )
			&& !float.IsNaN( y ) && !float.IsNaN( z ) )
		{
			at = new Vector3( px, y, z );
			facing = float.IsNaN( yaw ) ? 0f : yaw;
		}
		else
		{
			var set = group.FirstOrDefault( b => b.HasPartDrop );
			Log.Info( $"[nz-soul] flag {DoorLinks.Display( flag )}"
				+ (BuildParts.Valid( part ) ? $" pays part {part}" : " pays no part yet (nz_soul_part)")
				+ (set is null ? ", dropped above the last box filled" : $", dropped at {set.PartDrop}")
				+ $" · nz_soul_partdrop {flag} here | x y z [yaw] | clear" );
			return;
		}

		var floor = Floor( at );

		foreach ( var b in group ) b.HasPartDrop = false;

		// ⚠️ ONTO THE BOX THAT CARRIES THE PART, when there is one, so the two settings a reader
		// looks for together are found together in the config. Either box works at runtime.
		var owner = group.FirstOrDefault( b => BuildParts.Valid( b.FinalPart ) ) ?? group[0];
		owner.HasPartDrop = true;
		owner.PartDrop = floor;
		owner.PartDropYaw = facing;

		Log.Info( $"[nz-soul] flag {DoorLinks.Display( flag )} now drops"
			+ (BuildParts.Valid( part ) ? $" the {BuildParts.Name( part )} (part {part})" : " its part")
			+ $" at {floor} facing {facing:0}"
			+ (floor.z < at.z - 0.5f ? $" (on the floor, {at.z - floor.z:0} below {at})" : "")
			+ " — save the config, then py Tools/ship_data.py --apply"
			+ (BuildParts.Valid( part ) ? "" : "  ⚠ this flag pays no part yet: nz_soul_part" ) );
	}

	/// <summary>The floor under a point, or the point itself if nothing is under it.</summary>
	///
	/// ⚠️ FROM A LITTLE ABOVE, so a point already touching the floor still finds it, and the
	/// player, zombies and triggers are ignored so the trace cannot land on whoever is standing
	/// there — which, for `here`, is always somebody.
	static Vector3 Floor( Vector3 at )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return at;

		var tr = scene.Trace.Ray( at + Vector3.Up * 16f, at + Vector3.Down * 512f )
			.WithoutTags( "player", "zombie", "ragdoll", "trigger" )
			.Run();

		return tr.Hit ? tr.HitPosition : at;
	}

	/// <summary>Remove them all: `nz_soul_clear`.</summary>
	[ConCmd( "nz_soul_clear" )]
	public static void Clear()
	{
		var n = ActiveConfig.Current.SoulBoxes.Count;
		ActiveConfig.Current.SoulBoxes.Clear();
		SoulBoxManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-soul] removed {n}" );
	}

	/// <summary>
	/// Rebuild the boxes from the config: `nz_soul_rebuild`.
	///
	/// ⛔ EXISTS FOR HOTLOADS. Editing code re-creates the SoulBox type, and the boxes standing in
	/// the world stay bound to the old one — they are visible, solid, and invisible to every count
	/// and to the death hook. The symptom is `1 configured, 0 standing` with the box on screen.
	/// Nothing else short of restarting play recovers it, and Rebuild sweeps the orphans first so
	/// this cannot double them.
	/// </summary>
	[ConCmd( "nz_soul_rebuild" )]
	public static void RebuildCmd()
	{
		var mgr = SoulBoxManager.Ensure( Game.ActiveScene );
		if ( !mgr.IsValid() ) { Log.Warning( "[nz-soul] no scene" ); return; }

		mgr.Rebuild();
		Log.Info( $"[nz-soul] rebuilt — {SoulBoxManager.All().Count} standing" );
	}

	/// <summary>Empty every box without removing them: `nz_soul_reset`.</summary>
	[ConCmd( "nz_soul_reset" )]
	public static void ResetFill()
	{
		SoulBoxManager.ClearAll();
		Log.Info( $"[nz-soul] emptied {SoulBoxManager.All().Count} box(es)"
			+ "  ⚠ open flags stay open — nz_links_reset closes them" );
	}

	/// <summary>
	/// Feed souls by hand: `nz_soul_feed [count] [index]`.
	///
	/// ⛔ THE ONLY WAY TO TEST THE FLAG RULE WITHOUT KILLING A HUNDRED ZOMBIES. Five boxes at
	/// twenty kills is a hundred deaths in the right five places, which is not a test anyone runs
	/// twice — and the "all boxes, not any" rule is precisely the part worth testing.
	///
	/// ⚠️ Goes through the same Feed and the same CheckLink the death hook does, so it exercises
	/// the real path rather than setting a counter.
	/// </summary>
	[ConCmd( "nz_soul_feed" )]
	public static void Feed( int count = 1, int index = -1 )
	{
		var all = SoulBoxManager.All();
		if ( all.Count == 0 ) { Log.Info( "[nz-soul] none standing" ); return; }

		var targets = index >= 0
			? all.Where( b => b.Index == index ).ToList()
			: all;

		if ( targets.Count == 0 ) { Log.Warning( $"[nz-soul] no box #{index}" ); return; }

		foreach ( var b in targets )
		{
			int fed = 0;
			for ( int i = 0; i < count; i++ )
				if ( b.Feed() ) fed++;

			if ( b.IsFull ) SoulBoxManager.CheckLink( b.Link );

			Log.Info( $"[nz-soul] box #{b.Index}: fed {fed}, now {b.Current}/{b.Target}"
				+ (b.IsFull ? "  FULL" : "") );
		}
	}

	/// <summary>
	/// Drive the lid directly: `nz_soul_lid [open|close|reset] [index]`.
	///
	/// ⛔ THE ONLY WAY TO CHECK THE ANIMATION LANDED. The Crowbar -> Blender -> FBX route gives
	/// each sequence a `take` index, and add_animations_to_vmdl assigns those in the order the
	/// names were passed — which must match Blender's action export order. Its own note flags this
	/// as the likely failure, and it cannot be tested from the editor at all:
	/// SkinnedModelRenderer.Sequence is not writable over MCP and CreateBoneObjects does nothing in
	/// edit mode. If `open` plays the closing motion, the takes are swapped.
	///
	/// ⚠️ `reset` puts the lid back to what the FILL says, which is what every other caller does.
	/// Leaving a box forced open would look like a bug in the fill logic rather than in this.
	/// </summary>
	[ConCmd( "nz_soul_lid" )]
	public static void Lid( string state = "open", int index = -1 )
	{
		var all = SoulBoxManager.All();
		if ( all.Count == 0 ) { Log.Info( "[nz-soul] none standing" ); return; }

		var targets = index >= 0 ? all.Where( b => b.Index == index ).ToList() : all;
		if ( targets.Count == 0 ) { Log.Warning( $"[nz-soul] no box #{index}" ); return; }

		foreach ( var b in targets )
		{
			if ( !b.Renderer.IsValid() )
			{
				Log.Info( $"[nz-soul] box #{b.Index} has no skinned renderer"
					+ " — it is the placeholder box, which has no lid" );
				continue;
			}

			if ( state.Equals( "reset", System.StringComparison.OrdinalIgnoreCase ) )
			{
				b.ApplyLid();
				Log.Info( $"[nz-soul] box #{b.Index} lid -> {b.Renderer.Sequence.Name} (from fill)" );
				continue;
			}

			b.Renderer.Sequence.Name = state;
			Log.Info( $"[nz-soul] box #{b.Index} lid -> '{state}'"
				+ $"  ·  model {b.Renderer.Model?.ResourcePath ?? "none"}" );
		}
	}

	/// <summary>
	/// Why is the box beside me not filling? `nz_soul_why`.
	///
	/// ⛔ EVERY GATE IN ONE LINE. "It is not counting" has five causes — no box in range of where
	/// the zombie DIED, the power off, the box already full, another box nearer, or no boxes
	/// standing at all — and from in-game they all look like nothing happening.
	/// </summary>
	[ConCmd( "nz_soul_why" )]
	public static void Why()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-soul] no player" ); return; }

		var all = SoulBoxManager.All();
		Log.Info( $"[nz-soul] {ActiveConfig.Current.SoulBoxes.Count} configured, {all.Count} standing"
			+ $"  ·  power {(Power.IsOn ? "ON" : "off")}" );

		if ( all.Count == 0 ) return;

		var at = p.WorldPosition;

		foreach ( var b in all.OrderBy( b => at.Distance( b.Spot.Position ) ) )
		{
			var d = at.Distance( b.Spot.Position );
			var why = b.Unavailable();

			Log.Info( $"[nz-soul]   #{b.Index} {d:0,6}u away"
				+ (b.InRange( at ) ? "  IN RANGE" : "  out of range")
				+ $"  {b.Current}/{b.Target}"
				+ (string.IsNullOrEmpty( why ) ? "  ready" : $"  BLOCKED: {why}") );
		}

		// ⚠️ WHICH BOX WOULD ACTUALLY WIN, said explicitly. With overlapping boxes the nearest
		// takes the soul and the others get nothing, and that is not visible from the list above.
		var winner = all.Where( b => string.IsNullOrEmpty( b.Unavailable() ) && b.InRange( at ) )
			.OrderBy( b => at.Distance( b.Spot.Position ) ).FirstOrDefault();

		Log.Info( winner is null
			? "[nz-soul] a kill here would feed NOTHING"
			: $"[nz-soul] a kill here would feed box #{winner.Index}" );
	}
}