Objectives/SoulBoxManager.cs

Manager component that creates, rebuilds and tracks Soul Box game objects from configuration, routes zombie deaths to the nearest eligible box, and opens linked flags when all boxes on a link are full.

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

namespace NZombies;

/// <summary>
/// SOUL BOXES — fill every box on a flag and that flag opens.
///
/// ⚠️ Deliberately the same shape as TeleporterManager / PerkMachineManager / DebrisManager: Ensure
/// creates on demand, NotSaved keeps it out of the map, Rebuild is the single entry point.
///
/// ⛔ AND REBUILDING IS ALSO HOW THE BOXES EMPTY. The fill lives on the SoulBox component, so
/// destroying and re-creating them is the reset — a new game gets empty boxes without needing a
/// reset path of its own. The trading table's manager records the same trick.
/// </summary>
public sealed class SoulBoxManager : Component
{
	public static SoulBoxManager Instance { get; private set; }

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	public static SoulBoxManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Soul Box Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<SoulBoxManager>();
	}

	readonly List<GameObject> _built = new();

	/// <summary>How many boxes are standing right now.</summary>
	public int Built => _built.Count( g => g.IsValid() );

	/// <summary>Destroy what is standing and build the config again.</summary>
	public void Rebuild()
	{
		foreach ( var g in _built ) g?.Destroy();
		_built.Clear();

		// A new set of boxes: none removed yet (`SoulBox.RemovedByHost`, a joiner's catch-up).
		SoulBox.RemovedByHost.Clear();

		// ⛔ AND SWEEP ORPHANS. `_built` lives on THIS component, so a code hotload — which nulls
		// the manager's static while leaving every box GameObject standing — means Ensure hands
		// back a fresh manager with an empty list. Rebuilding then ADDS a second box beside each
		// existing one, and the map quietly doubles every time you edit code. Sweeping the scene is
		// the only way "what is standing" can be made to match the config.
		if ( Scene.IsValid() )
			foreach ( var orphan in Scene.GetAllComponents<SoulBox>().ToList() )
				orphan.GameObject?.Destroy();

		var list = ActiveConfig.Current?.SoulBoxes;
		if ( list is null || list.Count == 0 ) return;

		for ( int i = 0; i < list.Count; i++ )
			Build( list[i], i );

		Log.Info( $"[nz] {Built} of {list.Count} soul box(es) built" );
	}

	void Build( SoulBoxSpot spot, int index )
	{
		var authored = false;
		Model mesh = null;

		if ( !string.IsNullOrWhiteSpace( spot.Model ) )
		{
			var m = Model.Load( spot.Model );

			// ⚠️ Model.Load returns null on a bad path but an ERROR MODEL on a compiled-but-broken
			// one, and the error model renders happily as a checkerboard. Both fall through to the
			// placeholder, SAID OUT LOUD.
			if ( m is not null && !m.IsError )
			{
				mesh = m;
				authored = true;
			}
			else
			{
				Log.Warning( $"[nz] soul box #{index}: model {spot.Model} "
					+ $"({(m is null ? "not found" : "error model")}) — standing as a plain box" );
			}
		}

		mesh ??= DebrisMesh.Build( spot.Footprint(), MathF.Max( 4f, spot.Height ),
			MaterialFor( spot ) );

		if ( mesh is null )
		{
			Log.Warning( $"[nz] soul box #{index}: its mesh could not be built — not standing" );
			return;
		}

		var go = Scene.CreateObject();
		go.Name = $"Soul Box #{index}";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.WorldPosition = spot.Position;
		go.WorldRotation = Rotation.FromYaw( spot.Yaw );

		var box = go.Components.Create<SoulBox>();
		box.Spot = spot;
		box.Index = index;

		if ( authored )
		{
			// ⛔ SkinnedModelRenderer, NOT ModelRenderer. The lid is a BONE (`j_lid`) driven by the
			// `open`/`close` sequences — a plain ModelRenderer shows the bind pose forever and no
			// amount of setting a sequence on it does anything.
			var r = go.Components.Create<SkinnedModelRenderer>();
			r.Model = mesh;

			// ⚠️ OFF, so a raw sequence plays. With UseAnimGraph on, the graph owns the pose and
			// Sequence.Name is ignored — and this model has no graph.
			r.UseAnimGraph = false;

			box.Renderer = r;

			// ⛔ BoxCollider FROM THE MODEL'S OWN BOUNDS. Crowbar's physics SMD is not a format
			// s&box reads, and bridging it would mean a second OBJ conversion for a chest.
			// PerkMachineManager and WunderfizzManager both do exactly this.
			var solid = go.Components.Create<BoxCollider>();
			solid.Scale = mesh.Bounds.Size;
			solid.Center = mesh.Bounds.Center;
		}
		else
		{
			var r = go.Components.Create<ModelRenderer>();
			r.Model = mesh;

			// ⛔ THE TINT IS FOR THE PLACEHOLDER ONLY. Multiplying orange into carved stone would
			// just make the real box wrong.
			r.Tint = spot.Tint;

			// ⛔ ModelCollider here — DebrisMesh gives the prism a convex hull per triangle, and a
			// box would be a different solid from the one being drawn.
			var hull = go.Components.Create<ModelCollider>();
			hull.Model = mesh;
		}

		box.ApplyLid();

		_built.Add( go );
	}

	static Material MaterialFor( SoulBoxSpot spot )
	{
		if ( !string.IsNullOrWhiteSpace( spot.Material ) )
		{
			var mat = Material.Load( spot.Material );
			if ( mat is not null ) return mat;
		}

		return Material.Load( "materials/dev/gray_50.vmat" );
	}

	// ── collecting ───────────────────────────────────────────────────────────

	/// <summary>Every live box carrying a flag, in config order.</summary>
	public static List<SoulBox> OnLink( string link )
		=> All().Where( b => DoorLinks.Same( b.Link, link ) ).OrderBy( b => b.Index ).ToList();

	/// <summary>
	/// Every live box, in config order.
	///
	/// ⛔ THE STATIC LIST FIRST, THE SCENE WALK ONLY AS A FALLBACK — AND THE FIRST VERSION HAD THIS
	/// EXACTLY BACKWARDS. It walked the scene unconditionally to survive a code hotload orphaning
	/// SoulBox.All, and its own comment justified that by noting the death hook calls this on every
	/// kill. That is the reason to make it CHEAP, not the reason to make it thorough: measured in a
	/// survival log, kill frames cost ~20ms of pure CPU with GPU time unchanged and allocation up
	/// 2.6x, from a full GetAllComponents + Where + OrderBy + ToList per death — twice per death
	/// when a box filled.
	///
	/// ⚠️ The fallback still does the hotload job. SoulBox.All is filled in OnEnabled, so a hotload
	/// leaves it empty while the boxes stand; an empty list is exactly the signal to go and look.
	/// A populated list is always right, and free.
	/// </summary>
	public static List<SoulBox> All()
	{
		if ( SoulBox.All.Count > 0 )
			return SoulBox.All.Where( b => b.IsValid() && b.Spot is not null )
				.OrderBy( b => b.Index ).ToList();

		return Game.ActiveScene is null
			? new List<SoulBox>()
			: Game.ActiveScene.GetAllComponents<SoulBox>()
				.Where( b => b.IsValid() && b.Spot is not null )
				.OrderBy( b => b.Index ).ToList();
	}

	/// <summary>
	/// A zombie died. Feed the nearest box that can take it.
	///
	/// ⛔ ONE BOX PER KILL, matching upstream's `break`. It walks the catchers and stops at the
	/// first match, so a zombie killed between two boxes feeds one of them — not both. Splitting
	/// the soul would make a cluster of boxes fill in a fraction of the intended kills, which is
	/// the balance of the whole feature.
	///
	/// ⚠️ NEAREST rather than first-in-list, which is the one place this deliberately improves on
	/// upstream: `ents.FindByClass` order is arbitrary, so with two overlapping boxes upstream
	/// feeds whichever the engine happened to list first. Nearest is the one the player believes
	/// they were fighting at.
	/// </summary>
	public static void OnZombieKilled( Vector3 at )
	{
		// ⛔ ITERATES THE RAW LIST, ALLOCATING NOTHING. This is the hottest path in the feature — one
		// call per zombie death, and several zombies can die in a frame. It does not need a sorted
		// copy, it needs the nearest usable box, so All()'s Where/OrderBy/ToList is pure waste here.
		//
		// ⚠️ AND IT DOES NOT NEED THE HOTLOAD FALLBACK EITHER. If SoulBox.All is empty because a
		// hotload cleared it, the kill simply feeds nothing for the rest of that session — which is
		// what nz_soul_rebuild is for. Paying a scene walk on every death to cover a dev-only case
		// is the trade that produced 20ms kill frames.
		SoulBox best = null;
		var bestDist = float.MaxValue;

		var boxes = SoulBox.All;
		for ( int i = 0; i < boxes.Count; i++ )
		{
			var b = boxes[i];
			if ( !b.IsValid() || b.Spot is null ) continue;
			if ( !string.IsNullOrEmpty( b.Unavailable() ) ) continue;
			if ( !b.InRange( at ) ) continue;

			var d = at.Distance( b.Spot.Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = b;
		}

		if ( best is null ) return;
		if ( !best.Feed() ) return;

		if ( best.IsFull ) CheckLink( best.Link );
	}

	/// <summary>
	/// A box on this flag just filled. Open the flag only if EVERY box on it is full.
	///
	/// ⛔ THIS IS THE INVERTED LINK RULE, AND IT IS THE WHOLE FEATURE. Every other link user opens
	/// on the FIRST event — DebrisManager.OpenAllOnLink fires the moment one barrier is bought.
	/// Five soul boxes sharing a flag must all be full before it opens, so the check is an "all"
	/// where the rest of the codebase has an "any". Written here rather than in SoulBox because a
	/// box cannot see its siblings without asking the manager anyway.
	///
	/// ⚠️ A BLANK FLAG OPENS NOTHING, and that is not a failure. A box with no flag still fills and
	/// still reports, which is how you test one before deciding what it should gate.
	/// </summary>
	public static void CheckLink( string link )
	{
		if ( DoorLinks.IsUnlinked( link ) ) return;

		var group = OnLink( link );
		if ( group.Count == 0 ) return;

		var done = group.Count( b => b.IsFull );

		if ( done < group.Count )
		{
			Log.Info( $"[nz-soul] flag {link}: {done}/{group.Count} boxes full" );
			return;
		}

		// ⚠️ NOTED, NOT RETURNED ON — AND THAT DISTINCTION IS NEW. This was `if ( IsOpen ) return;`,
		// which was right while opening a flag meant only setting a boolean: re-entry would announce
		// the same unlock twice, and CheckLink runs on every completion. Now that opening also
		// removes debris and regenerates nav, an already-open flag with barriers still standing is a
		// broken state worth CORRECTING rather than a reason to skip. Suppress the announcement, do
		// the work regardless — the sweep is idempotent, since a link with no barriers left on it
		// finds nothing to destroy.
		//
		// ⚠️ It is also what lets a flag opened by the old broken version be repaired in place,
		// instead of needing a fresh session to test the fix.
		var wasOpen = DoorLinks.IsOpen( link );

		// ⛔ OpenLink, NOT DoorLinks.Open — AND THIS WAS THE BUG. `DoorLinks.Open` only records that
		// the flag is open. It does not remove the debris standing on it and it does not regenerate
		// the navmesh where that debris was, so filling every soul box on a flag flipped a boolean
		// and left the door physically shut. Everything that tests IsOpen per frame — perk machines,
		// wallbuys, the teleporter — unlocked correctly, which is what made it look like the soul
		// boxes had worked and the door was a separate problem.
		//
		// ⚠️ IF THERE IS NO DebrisManager the flag is still opened, so a map whose objective gates
		// something other than debris keeps working. That is why the flag is opened by OpenLink
		// rather than here.
		if ( DebrisManager.Instance.IsValid() )
		{
			DebrisManager.Instance.OpenLink( link );
		}
		else
		{
			DoorLinks.Open( link );
			NavLinkManager.Instance?.Rebuild();
		}

		if ( wasOpen ) return;

		Log.Info( $"[nz-soul] flag {link} OPEN — all {group.Count} soul box(es) full" );

		// ⚠️ REUSED, NOT ITS OWN CUE. This is the powerup pickup sting, which already means "a good
		// thing just resolved"; inventing a second sound for a moment that happens once or twice a
		// map would be a new asset for no new information.
		var where = group[^1].WorldPosition;
		NZSound.Play( NZSound.PowerupPickup, where );
	}

	/// <summary>Empty every box — a console reset.</summary>
	public static void ClearAll()
	{
		foreach ( var b in All() ) b.Clear();
	}
}