Objectives/SoulBox.cs

A component that represents a standing soul box objective. It tracks authored settings (Spot, Index), current souls collected, handles feeding from zombie kills, plays sounds and animations, spawns rewards or build parts when full, synchronizes host->client state, and removes the box when its reward is claimed.

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

namespace NZombies;

/// <summary>
/// A standing soul box — kills near it fill it, and a full set of them opens a flag.
///
/// ⚠️ The config's <see cref="SoulBoxSpot"/> is the AUTHORED data; this is the live box built from
/// it. They are separate because the FILL is runtime state: it must reset on a new game and must
/// never be written into the saved map, which is exactly the split WunderfizzSpot documents.
/// </summary>
public sealed class SoulBox : Component
{
	public static readonly List<SoulBox> All = new();

	protected override void OnEnabled() => All.Add( this );
	protected override void OnDisabled() => All.Remove( this );

	/// <summary>The authored settings this box was built from.</summary>
	[Property] public SoulBoxSpot Spot { get; set; }

	/// <summary>Its index in the config, so messages can name it.</summary>
	[Property] public int Index { get; set; }

	/// <summary>The skinned renderer, when this box has a real model. Null for the placeholder.</summary>
	[Property] public SkinnedModelRenderer Renderer { get; set; }

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTER — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _powerupHeight;

	/// <summary>
	/// How high above the box the reward powerup sits.
	///
	/// ⚠️ ABSOLUTE FROM THE BOX'S ORIGIN, not relative to Spot.Height. It used to be
	/// `Spot.Height + 16`, which was 64 units up — the placeholder's height is 48 while the real
	/// stone box is about 30 tall, so the reward floated well clear of a box it is supposed to be
	/// coming out of.
	///
	/// ⚠️ DIAL IT, DO NOT DERIVE IT. 36 was set on the slider, not reasoned to — and the two
	/// reasoned attempts before it were both wrong: `Spot.Height + 16` gave 64, and 24 was a guess
	/// at "a lot lower". `nz_soul_tune` reopens the slider; see TradeTableTuner for why the pattern
	/// exists at all.
	/// </summary>
	public static float PowerupHeight
	{
		get => _powerupHeight ?? 36f;
		set => _powerupHeight = value;
	}

	/// <summary>The reward this box dropped, while it still exists.</summary>
	Powerup _reward;

	/// <summary>
	/// Does losing the reward remove the box?
	///
	/// ⛔ FALSE FOR A TUNER TEST DROP. The tuner spawns a reward to position without filling the
	/// box, and a test that deleted the thing being tuned would be useless.
	/// </summary>
	bool _claimRemoves;

	/// <summary>What PowerupHeight was when the reward was last placed.</summary>
	float _rewardAt = float.NaN;

	/// <summary>
	/// Souls collected so far.
	///
	/// ⛔ ON THE COMPONENT, NOT THE SPOT. TeleporterManager.Rebuild-style destruction is what
	/// resets it — NZGame.ShowConfig rebuilds every manager when a config is put into the world, so
	/// a new game starts every box empty with no reset path of its own.
	/// </summary>
	public int Current { get; private set; }

	public int Target => Math.Max( 1, Spot?.Target ?? 20 );
	public float Range => MathF.Max( 32f, Spot?.Range ?? 500f );
	public string Link => Spot?.Link ?? DoorLinks.Unlinked;

	/// <summary>Is this box done?</summary>
	public bool IsFull => Current >= Target;

	/// <summary>
	/// Why this box will not take souls, or "" when it will.
	///
	/// ⚠️ Upstream expresses this as a per-map `Condition` callback, and the one example gates on
	/// `nzElec.Active`. That is the only condition worth a setting, so it is the only one here.
	/// </summary>
	public string Unavailable()
	{
		if ( Spot is null ) return "no spot";
		if ( IsFull ) return "full";
		if ( Spot.RequiresPower && !Power.IsOn ) return "needs power";
		return "";
	}

	/// <summary>Would a kill at this position feed this box?</summary>
	public bool InRange( Vector3 at )
		=> Spot is not null && at.Distance( Spot.Position ) <= Range;

	/// <summary>
	/// Take one soul. Returns true if it counted.
	///
	/// ⚠️ THE CUE PLAYS AT THE BOX, NOT AT THE KILL. The box is the thing the player is trying to
	/// find and fill; a chime at the corpse says "something happened here" instead of "that went
	/// over there", which is the whole information the sound is carrying.
	/// </summary>
	/// <summary>
	/// Where this box sits in the config, which is its identity across machines.
	///
	/// ⛔ GUIDS ARE USELESS HERE, for the same reason they are for barricades: soul boxes are
	/// BUILT at runtime from `ActiveConfig` on every machine separately, so each machine's box has
	/// a different guid for the same spot. The list, in order, is what they share.
	/// </summary>
	/// <summary>
	/// How this box is named on the wire.
	///
	/// ⛔ THE CONFIG INDEX, NOT `All.IndexOf( this )`, WHICH IS WHAT IT WAS. `All` is filled in
	/// `OnEnabled` — scene-enable order, decided independently on each machine — and worse,
	/// `Rebuild` SKIPS a box whose mesh fails to build (it logs "not standing" and returns). One
	/// box missing on one machine shifts every index after it, so the host's fill for box 3 landed
	/// on the client's box 4, or on nothing at all if the client had fewer.
	///
	/// ⚠️ `Index` IS ALREADY THE STABLE NAME. It is the box's position in
	/// `ActiveConfig.Current.SoulBoxes`, which is the same list on every machine by construction —
	/// it is the file. The receiver looks boxes up BY this rather than indexing a list.
	/// </summary>
	public int NetIndex => Index;

	/// <summary>How many fills this machine has been told about. `nz_soul_net`.</summary>
	public static int NetReceived, NetApplied;

	/// <summary>Log every soul relay, both ends: `nz_soul_net`.</summary>
	public static bool NetDebug { get; set; }

	/// <summary>Find the box the wire is talking about.</summary>
	public static SoulBox ByNetIndex( int index )
	{
		foreach ( var b in All )
			if ( b.IsValid() && b.Index == index ) return b;

		return null;
	}

	/// <summary>
	/// The host says this box holds this many. Clients only.
	///
	/// ⚠️ IT REPLAYS `Feed()` RATHER THAN ASSIGNING `Current`, so the lid, the sound, the reward
	/// and the link check all happen on the client exactly as they did on the host — assigning the
	/// number would fill the box silently and leave a full box with its lid still open.
	/// </summary>
	public void SetSoulsFromHost( int souls )
	{
		NetReceived++;

		var was = Current;
		var guard = 0;

		while ( Current < souls && guard++ < 256 )
			if ( !FeedLocal() ) break;

		if ( Current > was ) NetApplied++;

		// ⚠️ IT REPORTS A NO-OP AS LOUDLY AS A FILL. "The message arrived and changed nothing"
		// and "the message never arrived" look identical in game — silence — and are completely
		// different bugs. `Unavailable()` is printed because it is the only thing that can make
		// `FeedLocal` refuse, and "needs power" or "full" is the whole answer when it does.
		if ( NetDebug )
			Log.Info( $"[nz-soul-net] GOT box #{Index} → {souls}"
				+ $" · was {was}, now {Current}/{Target}"
				+ (Current > was ? "  ✓ sound played" : "  ⛔ NOTHING HAPPENED")
				+ (string.IsNullOrEmpty( Unavailable() ) ? "" : $"  ({Unavailable()})") );
	}

	public bool Feed()
	{
		if ( !FeedLocal() ) return false;

		// ⛔ THE COUNT NEVER LEFT THE HOST. Zombies die on the host, so `OnZombieKilled` runs
		// there and the box filled there — `Current` is a plain component property and replicates
		// to nobody, so a client watched a box that never moved however many it killed.
		// User: *"soul boxes do not fill when the client kills the zombies."*
		//
		// ⚠️ THE TOTAL, NOT "ONE MORE". A count cannot drift the way a stream of increments can,
		// and a client that missed a message catches up on the next kill rather than staying one
		// short forever.
		if ( Networking.IsActive && NZGame.IsHost )
		{
			var i = NetIndex;
			if ( i >= 0 ) NZNet.SoulBoxSouls( i, Current );

			if ( NetDebug )
				Log.Info( $"[nz-soul-net] SENT box #{i} = {Current}/{Target}" );
		}

		return true;
	}

	bool FeedLocal()
	{
		if ( !string.IsNullOrEmpty( Unavailable() ) ) return false;

		Current++;

		if ( IsFull )
		{
			NZSound.Play( NZSound.SoulFull, WorldPosition );

			ApplyLid();

			var reward = DropReward();

			Log.Info( $"[nz-soul] box #{Index} FULL ({Current}/{Target})"
				+ (DoorLinks.IsUnlinked( Link ) ? "  (no flag)" : $"  flag {Link}")
				+ (reward is null ? "" : $"  ·  {reward}") );
		}
		else
		{
			NZSound.Play( NZSound.SoulCatch, WorldPosition );
		}

		return true;
	}

	/// <summary>
	/// Drop the completion reward. Returns the kind's name, or null if none.
	///
	/// ⚠️ ABOVE THE BOX, NOT AT ITS ORIGIN. The mesh runs from Position UP to Height, so spawning
	/// at the origin puts the powerup inside the box — visible from nowhere and reachable from
	/// nowhere.
	/// </summary>
	string DropReward()
	{
		// ⛔ THE SET'S REWARD OUTRANKS THE BOX'S, AND IS TESTED FIRST FOR THAT REASON. This runs
		// from inside the `IsFull` branch, so THIS box is already counted; if every sibling on the
		// flag is full too then this is the box that finished the set, and the flag's part is its
		// payout instead of a powerup.
		if ( CompletesSet() )
		{
			var part = SetPart();
			if ( BuildParts.Valid( part ) ) return DropBuildPart( part );
		}

		if ( Spot is null || !Spot.Powerup ) return null;

		// ⛔ THE HOST SPAWNS IT AND `Powerup.Spawn` BROADCASTS IT, SO A CLIENT DOING THE SAME
		// MAKES A SECOND ONE. Filling a box now happens on every machine — `NZNet.SoulBoxSouls`
		// replays `Feed` on the client so the lid, the sound and the reward all behave — and the
		// reward was the one step that must NOT be replayed, because it is an object rather than a
		// presentation. The client got the host's broadcast copy and its own local one.
		// User: *"the soulboxes drop 2 powerups visually on the client instead of just one."*
		//
		// ⚠️ `SpawnReward` ITSELF IS LEFT ALONE, because the tuner calls it directly to look at
		// placement and that preview is local by design. Only the gameplay path is gated.
		if ( Networking.IsActive && !NZGame.IsHost ) return null;

		return SpawnReward( claimRemoves: true );
	}

	/// <summary>Is this the box that just finished its flag's set?</summary>
	///
	/// ⚠️ AN "ALL", WHERE THE REST OF THE CODEBASE HAS AN "ANY" — the same distinction
	/// `SoulBoxManager.CheckLink` is written around, and for the same reason: a flag belongs to
	/// every box carrying it, so the set is done only when none of them is short.
	///
	/// ⚠️ AN UNLINKED BOX FINISHES NOTHING. A box with no flag is a box being tested, and a lone
	/// test box quietly handing out a wonder-weapon part would be a surprise, not a feature.
	bool CompletesSet()
	{
		if ( DoorLinks.IsUnlinked( Link ) ) return false;

		var group = SoulBoxManager.OnLink( Link );
		return group.Count > 0 && group.All( b => b.IsValid() && b.IsFull );
	}

	/// <summary>The part this flag awards, asked of the whole group rather than of this box.</summary>
	///
	/// ⚠️ THE FIRST BOX THAT NAMES A PART WINS, so the setting can live on one box of the flag or
	/// on all of them. Which box fills last is up to where the players happen to fight, and a
	/// reward that depended on that would fire or not fire for no reason anybody could see.
	///
	/// ⛔ ASKED OF THE CONFIG'S BOXES, NOT OF THE ONES STILL STANDING. A box that isn't last is removed once its powerup goes
	/// (`OnUpdate`), so asking the live boxes lost the setting whenever the box that carries it — basalt's #0, the claws —
	/// filled before the last one: the set finished and nothing dropped (the co-op audit, 2026-09-27). *"make sure it drops
	/// as long as they all finish no matter which one's last"*.
	int SetPart()
	{
		foreach ( var s in SetSpots() )
			if ( BuildParts.Valid( s.FinalPart ) ) return s.FinalPart;

		return 0;
	}

	/// <summary>Every box of this flag, as the config lays them out — the removed ones included.</summary>
	IEnumerable<SoulBoxSpot> SetSpots()
	{
		var all = ActiveConfig.Current?.SoulBoxes;
		if ( all is null ) yield break;

		foreach ( var s in all )
			if ( s is not null && DoorLinks.Same( s.Link, Link ) ) yield return s;
	}

	/// <summary>Drop a Prisma build part above the box. Returns what to print.</summary>
	///
	/// ⛔ THE HOST DROPS IT, THE SAME AS THE POWERUP BELOW — AND THE NOTE HERE ONCE SAID THE
	/// OPPOSITE. The first version let every machine build its own copy, reasoning that a
	/// `BuildPart` is a local object each machine makes from config while a `Powerup` is a
	/// networked spawn. True of an AUTHORED part; false of a dropped one, which appears in no
	/// config — so a player joining afterwards had nothing to build it from and the part simply did
	/// not exist for them. `BuildPartManager.Drop` now owns the authority, the broadcast and the
	/// record that gets replayed on join.
	///
	/// ⚠️ A CLIENT THEREFORE DROPS NOTHING HERE AND MUST NOT FALL THROUGH TO THE POWERUP EITHER —
	/// which is why the caller returns on this whether or not it produced anything.
	///
	/// ⚠️ THE PART ITSELF IS SHARED — collecting it puts it in the team's pool — so one object pays
	/// the whole squad and there is nothing to divide.
	string DropBuildPart( int part )
	{
		var mgr = BuildPartManager.Ensure( Scene );
		if ( !mgr.IsValid() ) return null;

		// ⛔ THE SET'S DROP SPOT, IF THE AUTHOR CHOSE ONE — see `SoulBoxSpot.HasPartDrop`. Asked of
		// the whole flag, first match wins, exactly like `SetPart` asks for the part itself: which
		// box fills last is up to the fight, so a setting that only worked on that box would work
		// or not for no visible reason.
		var drop = SetDrop();
		var at = drop?.PartDrop ?? RewardPosition;
		var yaw = drop?.PartDropYaw ?? WorldRotation.Yaw();

		return mgr.Drop( part, at, yaw ).IsValid()
			? $"{BuildParts.WeaponName} {BuildParts.Name( part )} (part {part})"
				+ (drop is null ? " above the box" : $" at its spot {at}")
			: null;
	}

	/// <summary>The box of this flag that carries a drop spot, or null for "above the last box".</summary>
	/// ⚠️ FROM THE CONFIG TOO, for `SetPart`'s reason: the box that carries the spot may be gone by the time the set finishes.
	SoulBoxSpot SetDrop()
	{
		foreach ( var s in SetSpots() )
			if ( s.HasPartDrop ) return s;

		return null;
	}

	/// <summary>
	/// Put a reward above the box. Returns the kind's name, or a failure note.
	///
	/// ⚠️ THE PREVIOUS ONE IS DESTROYED FIRST. The tuner calls this repeatedly to look at
	/// placement, and leaving a pile of powerups stacked on one box would be both a mess and a free
	/// Max Ammo for every click.
	/// </summary>
	public string SpawnReward( bool claimRemoves )
	{
		if ( _reward.IsValid() ) _reward.GameObject?.Destroy();

		var kind = PowerupDrops.RandomKind();
		var p = Powerup.Spawn( RewardPosition, kind );

		// ⚠️ Spawn can return null — the kind's model may be missing. Saying so beats a silent
		// nothing where a reward was promised.
		if ( p is null ) return $"{kind} FAILED TO SPAWN";

		_reward = p;
		_claimRemoves = claimRemoves;
		_rewardAt = PowerupHeight;

		return kind.ToString();
	}

	/// <summary>Is a reward standing on this box right now?</summary>
	public bool HasReward => _reward.IsValid();

	/// <summary>Where the reward sits, in world space.</summary>
	public Vector3 RewardPosition => WorldPosition + Vector3.Up * PowerupHeight;

	/// <summary>
	/// Watch the reward: follow the tuner, and remove the box once it is gone.
	///
	/// ⛔ THE BOX GOES WHEN THE REWARD GOES, EITHER WAY. Powerup destroys its own GameObject both
	/// on pickup (Powerup.cs:269) and when its 30s Lifetime runs out (:147), so one validity check
	/// covers both of the cases asked for — "when I pick up the powerup or the powerup disappears".
	/// Upstream's map script ends its CompleteFunc with `self:Remove()` for the same reason: a
	/// finished box is scenery that still reads as an objective.
	///
	/// ⚠️ NOTHING HAPPENS IF THE BOX AWARDS NO POWERUP. With `Powerup` off there is no reward to
	/// claim, so the box stays standing and open — the removal is tied to the reward, not to the
	/// fill.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( !_claimRemoves && !_reward.IsValid() ) return;

		// The tuner moved the slider — follow it, so placement can be judged by eye.
		if ( _reward.IsValid() )
		{
			if ( !PowerupHeight.AlmostEqual( _rewardAt ) )
			{
				_reward.WorldPosition = RewardPosition;
				_rewardAt = PowerupHeight;
			}
			return;
		}

		if ( !_claimRemoves ) return;

		// ⚠️ CLEARED BEFORE DESTROYING, so a second frame cannot run this again if the object
		// outlives the call by a tick.
		_claimRemoves = false;

		Log.Info( $"[nz-soul] box #{Index} reward claimed — box removed" );

		// ⛔ ON EVERY MACHINE, NOT ONLY HERE. Only the host spawns the reward, so only the host ever saw it go — a client's box
		// stood on, full and open, for the rest of the game (the co-op audit, 2026-09-27).
		if ( Networking.IsActive && NZGame.IsHost ) NZNet.SoulBoxGone( Index );
		if ( NZGame.IsHost ) RemovedByHost.Add( Index );

		GameObject?.Destroy();
	}

	/// <summary>
	/// A JOINER'S CATCH-UP: this box holds this many, set at once. Clients only, from `NZNet.PushState`.
	///
	/// ⚠️ NOT `SetSoulsFromHost`, which replays every soul as a feed — the sound for each, and for eight boxes a hundred and
	/// sixty of them at once. This sets the number and the lid and nothing else: no sound, no reward (the host's reward, if
	/// one is up, is a powerup of its own, replayed with the rest).
	/// </summary>
	/// <summary>
	/// The boxes the host has removed this game, by config index — for a joiner's catch-up (`NZNet.PushState`). Emptied by
	/// `SoulBoxManager.Rebuild`, which is a new game's.
	/// </summary>
	public static readonly HashSet<int> RemovedByHost = new();

	public void RestoreFromHost( int souls )
	{
		Current = Math.Clamp( souls, 0, Target );
		ApplyLid();
	}

	/// <summary>
	/// Show the lid in the state the fill implies: shut while collecting, open once full.
	///
	/// ⛔ OPEN IS THE PAYOFF, WHICH IS THE OPPOSITE OF UPSTREAM'S OWN SEMANTICS. Origins' QC ties
	/// `open` to STARTING a looping particle and `close` to stopping it — so there, open means
	/// "collecting". We have no activation step and no loop particle, so a box would sit open from
	/// the moment it spawned and the only animation in the model would never mark anything. Tying
	/// it to the fill makes the lid the moment the player is working towards, and the reward
	/// powerup already spawns above the box, so it reads as coming out of it.
	///
	/// ⚠️ IDEMPOTENT, so Build, Feed and Clear can all call it without tracking what it last did.
	/// </summary>
	public void ApplyLid()
	{
		if ( !Renderer.IsValid() ) return;

		// ⛔ LOOPING MUST BE SET FALSE, AND IT DEFAULTS TRUE. A lid is a one-shot: left looping,
		// the box plays its closing motion over and over forever. That is what it did — the
		// reported symptom was "permanently doing the closing animation".
		//
		// ⚠️ SET BEFORE THE NAME, so the very first play is already non-looping rather than
		// starting looped and being corrected a frame later.
		Renderer.Sequence.Looping = false;
		Renderer.Sequence.Name = IsFull ? "open" : "close";
	}

	/// <summary>Empty it again — a new game, or a console reset.</summary>
	public void Clear()
	{
		Current = 0;
		ApplyLid();
	}

	/// <summary>What to say to a player standing at it.</summary>
	public string Readout()
	{
		if ( Spot is null ) return "";

		if ( Spot.RequiresPower && !Power.IsOn ) return "Soul box — needs power";

		if ( IsFull )
		{
			// ⚠️ A FULL BOX STILL SAYS SOMETHING, and it says whether the SET is done. "Full" alone
			// on the fourth of five boxes reads as the puzzle being finished when it is not.
			var group = SoulBoxManager.OnLink( Link );
			var done = group.Count( b => b.IsFull );

			return DoorLinks.IsUnlinked( Link ) || group.Count <= 1
				? "Soul box — full"
				: $"Soul box — full  ·  {done}/{group.Count} boxes done";
		}

		return $"Soul box — {Current}/{Target} souls";
	}
}