Buyables/MysteryBoxManager.cs

Component that builds and manages mystery box game objects and their marker platforms from the active config. It picks a single active box spot (host decides), spawns the box and per-spot platforms, handles moving the box, and manages temporary 'fire sale' extra boxes.

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

namespace NZombies;

/// <summary>
/// Builds the config's mystery boxes into the world.
///
/// ⚠️ The same shape as DebrisManager / BarricadeManager — Ensure creates it on
/// demand, NotSaved keeps it out of the map, Rebuild is the single entry point.
/// Copying that shape is deliberate: a manager refreshed differently from its
/// siblings is one more thing to remember at every call site that puts a config
/// into the world.
/// </summary>
public sealed class MysteryBoxManager : Component
{
	public static MysteryBoxManager Instance { get; private set; }

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

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

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

	/// <summary>Destroy what is standing and build the config again.</summary>
	public void Rebuild()
	{
		_hostMove = false;

		foreach ( var b in MysteryBox.All.ToList() )
			b?.GameObject?.Destroy();
		MysteryBox.All.Clear();

		// ⚠️ Platforms are tracked separately because they are NOT MysteryBox
		// components — they outnumber the box and exist at spots that have none.
		foreach ( var p in Platforms.ToList() )
			p?.Destroy();
		Platforms.Clear();

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

		// ⛔ ONE BOX, NOT ONE PER SPOT. The list is where the box CAN be — the
		// original's `random_box_spawns` — and only one exists at a time. Building
		// them all would turn every candidate location into a working box and
		// remove the entire reason the teddy bear exists.
		//
		// ⚠️ Chosen from spots flagged CanStart. If a mapper has turned them all
		// off, fall back to the whole list rather than spawning no box at all: an
		// unreachable box is a bug, a box in an unintended spot is a note.
		var starts = list.Where( s => s.CanStart ).ToList();
		if ( starts.Count == 0 )
		{
			starts = list;
			Log.Warning( "[nz] no box spot is flagged as a start — using any" );
		}

		// ⛔ EACH SPOT IS EITHER THE BOX OR THE MARKER, NEVER BOTH. They are the
		// two states of one location: the crate is where the box IS, and the
		// footlockers-and-teddy is what a location the box has LEFT looks like.
		// Building both at every spot stacked them on top of each other, which
		// read as one cluttered object rather than two states.
		// ⛔ EVERY MACHINE ROLLED ITS OWN SPOT, SO EVERY PLAYER HAD A DIFFERENT BOX. Reported as
		// *"a caixa não está no mesmo sítio para os dois"*. `NZGame` rebuilds the whole map on each
		// machine from the shared config — which is right for scenery, because scenery is a pure
		// function of the config. The box is not: it is one CHOICE out of seven, and a choice has
		// to be made once and told to everyone.
		//
		// ⚠️ THE HOST DECIDES AND ANNOUNCES; a client uses what it was told. `_netSpot` survives
		// between rebuilds precisely because the order is not guaranteed — a client that builds
		// its map before the announcement arrives has nothing to go on, so it rolls a placeholder
		// and `PlaceAt` corrects it the moment the message lands.
		var chosen = !Networking.IsActive || NZGame.IsHost
			? Game.Random.FromList( starts )
			: (_netSpot >= 0 && _netSpot < list.Count ? list[_netSpot] : Game.Random.FromList( starts ));

		Current = chosen;

		if ( !Networking.IsActive || NZGame.IsHost )
			NZNet.BoxSpot( list.IndexOf( chosen ) );

		foreach ( var spot in list )
		{
			if ( spot == chosen ) BuildBox( spot );
			else BuildPlatform( spot );
		}

		Log.Info( $"[nz] box placed — 1 of {list.Count} spot(s), "
			+ $"{starts.Count} eligible as a start" );
	}

	/// <summary>Which spot currently holds the box. Every other spot shows a marker.</summary>
	public MysteryBoxSpot Current { get; private set; }

	/// <summary>
	/// The spot the host last announced, as an index into the config's `Boxes`.
	///
	/// ⚠️ STATIC, AND THAT IS DELIBERATE. The manager component is destroyed and recreated by
	/// map loads; the host's decision outlives that, and a client that rebuilt before the
	/// announcement arrived must still be able to use it on the next rebuild.
	///
	/// ⚠️ -1 MEANS "NOT TOLD YET", not "spot zero". A client that has heard nothing rolls a
	/// placeholder rather than silently defaulting to the first spot, which would be wrong in
	/// six cases out of seven and look deliberate.
	/// </summary>
	static int _netSpot = -1;

	/// <summary>
	/// Has the host's word on a bear's move come, and is it still to be spent? Set by `PlaceAt` while the box is leaving, spent by
	/// `MoveBox`. ⚠️ STATIC, WITH `_netSpot`, for its reason.
	/// </summary>
	static bool _hostMove;

	/// <summary>May a leaving box pick its next spot now? On the host always (it picks); on a client once the host's word has come.</summary>
	public bool HostMoveReady => !Networking.IsActive || NZGame.IsHost || _hostMove;

	/// <summary>
	/// Put the box at the spot the host chose. Called from `NZNet.BoxSpot` on every client.
	///
	/// ⛔ BY CONFIG INDEX, NOT BY POSITION OR NAME. `ActiveConfig.Current.Boxes` is the same
	/// list on every machine — the host sends the whole config before a map starts — so the index
	/// is stable in a way a world position is not: a position would have to survive float
	/// round-tripping through an RPC and then be matched back to a spot by distance.
	///
	/// ⚠️ IT MOVES AN EXISTING BOX RATHER THAN REBUILDING, for the reason `MoveBox`'s own header
	/// gives: `Rebuild` destroys and recreates, which drops the lid state machine mid-sequence.
	/// A correction arriving while a client watches the box open must not delete the box.
	/// </summary>
	public void PlaceAt( int index )
	{
		var list = ActiveConfig.Current?.Boxes;
		if ( list is null || index < 0 || index >= list.Count ) return;

		_netSpot = index;

		var target = list[index];
		if ( target == Current ) return;

		// the real box is the one standing where we currently think it is
		var from = Current;

		// A FIRE SALE PUTS A BOX AT EVERY SPOT, so "the first valid MysteryBox" is not
		// necessarily the real one -- `_saleBoxes` exists because the scene cannot tell them
		// apart. Picking the one nearest where the box WAS is the question that has a right
		// answer during a sale as well as outside one.
		var box = from is null ? null : MysteryBox.All
			.Where( b => b.IsValid() )
			.OrderBy( b => b.WorldPosition.Distance( from.Position ) )
			.FirstOrDefault();

		// ⛔ A BOX IN THE MIDDLE OF LEAVING IS NOT MOVED UNDER ITSELF (the co-op pass, 2026-09-28). The host's word lands while this
		// machine's own bear still laughs or its crate still sinks; moved then, its own `MoveBox` rolled again when the sequence got
		// there and sent it somewhere else. The word is kept, and the box's `Relocate` takes it (`HostMoveReady`).
		if ( box.IsValid() && box.IsLeaving )
		{
			_hostMove = true;
			Log.Info( $"[nz-box] host says spot {index} — held until the box has left" );
			return;
		}

		Current = target;

		if ( box.IsValid() )
		{
			box.WorldPosition = target.Position;
			box.WorldRotation = OnFloor( target, 180f );
			box.Cost = target.Cost;
		}

		RebuildPlatforms();
		Log.Info( $"[nz-box] host says the box is at spot {index} {target.Position}" );
	}

	/// <summary>Remember the host's choice even when no manager exists yet.</summary>
	public static void Remember( int index ) => _netSpot = index;

	/// <summary>
	/// Send the box to a different spot, in place.
	///
	/// ⛔ MOVES THE EXISTING COMPONENT, it does not rebuild. Rebuild() destroys and
	/// recreates every box, which would drop the state machine mid-sequence — and
	/// the machine is precisely what has to survive, because it still owes the crate
	/// an `arrive` animation at the far end.
	///
	/// ⚠️ The marker swap is the other half of the move and is easy to forget: the
	/// spot being LEFT has to gain a platform and the spot being taken has to lose
	/// one, or the box arrives standing on a pile of footlockers while its old home
	/// shows bare ground.
	/// </summary>
	/// <returns>false when there is nowhere else to go.</returns>
	public bool MoveBox( MysteryBox box )
	{
		if ( !box.IsValid() ) return false;

		var list = ActiveConfig.Current?.Boxes;
		if ( list is null || list.Count <= 1 ) return false;

		// ⚠️ Anywhere BUT here. CanStart gates where the box may BEGIN, not where it
		// may move to — the original's non-start spots exist precisely so the box
		// can wander somewhere it never starts.
		var elsewhere = list.Where( s => s != Current ).ToList();
		if ( elsewhere.Count == 0 ) return false;

		// ⚠️ THE MOVE DESYNCS THE SAME WAY THE PLACEMENT DID, and it is the easier one to miss
		// because it only happens after a teddy bear. Same rule: the host picks and announces,
		// and a client that has been told where to go uses that instead of rolling.
		var target = !Networking.IsActive || NZGame.IsHost
			? Game.Random.FromList( elsewhere )
			: (_netSpot >= 0 && _netSpot < list.Count && list[_netSpot] != Current
				? list[_netSpot] : Game.Random.FromList( elsewhere ));

		// ⚠️ THE HOST'S WORD IS SPENT (`PlaceAt`)
		_hostMove = false;

		if ( !Networking.IsActive || NZGame.IsHost )
			NZNet.BoxSpot( list.IndexOf( target ) );

		var from = Current;
		Current = target;

		box.WorldPosition = target.Position;
		box.WorldRotation = OnFloor( target, 180f );
		box.Cost = target.Cost;

		// The vacated spot gets its marker back; the new one loses its own.
		RebuildPlatforms();

		Log.Info( $"[nz] the box moved to {target.Position} "
			+ $"(from {from?.Position.ToString() ?? "nowhere"})" );
		return true;
	}

	/// <summary>Markers on every spot except the one the box is standing on.</summary>
	void RebuildPlatforms()
	{
		foreach ( var p in Platforms.ToList() )
			p?.Destroy();
		Platforms.Clear();

		var list = ActiveConfig.Current?.Boxes;
		if ( list is null ) return;

		foreach ( var spot in list )
			if ( spot != Current ) BuildPlatform( spot );
	}

	/// <summary>
	/// A rotation that faces `yaw` but lies FLAT on the spot's floor.
	///
	/// ⛔ `Rotation.From(0, yaw, 0)` is level with the WORLD, not with the ground
	/// — on a ramp that leaves the box floating at one end and buried at the
	/// other. This builds the same heading against the surface normal instead.
	///
	/// ⚠️ The forward vector is PROJECTED ONTO THE SURFACE PLANE before being
	/// used. Feeding LookAt a yaw direction that is not perpendicular to the up
	/// vector makes it renormalise however it likes, which twists the box on
	/// steep ground — the heading has to be expressed in the plane it is going to
	/// live in.
	///
	/// ⚠️ ONE HELPER FOR BOTH MODELS. The marker takes the same floor with a +90
	/// offset; two copies of this maths would eventually disagree about slopes.
	/// </summary>
	static Rotation OnFloor( MysteryBoxSpot spot, float yawOffset = 0f )
	{
		var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
		var heading = Rotation.FromYaw( spot.Yaw + yawOffset ).Forward;

		// Drop the component pointing into the floor, leaving the heading as it
		// reads ALONG the slope.
		var forward = (heading - up * heading.Dot( up )).Normal;

		// Straight-down-the-normal heading has nothing left to project; fall back
		// to level rather than emitting a zero-length forward.
		if ( forward.IsNearlyZero() )
			return Rotation.From( 0f, spot.Yaw + yawOffset, 0f );

		return Rotation.LookAt( forward, up );
	}

	/// <summary>The box itself, at the one spot that currently holds it.</summary>
	GameObject BuildBox( MysteryBoxSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Mystery Box";
		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;

		// ⚠️ +180 — THE MODEL FACES BACKWARDS ON ITS OWN AXIS. The crate was
		// authored with its lid hinge on what the engine calls forward, so a spot's
		// yaw pointed the open side away from whoever placed it. Corrected here
		// rather than in MysteryBoxSpot.Yaw, for the same reason the platform's +90
		// is: that value is the SPOT's heading and is shared by both models, so
		// baking one model's authoring quirk into it moves the other one too.
		go.WorldRotation = OnFloor( spot, 180f );

		var box = go.Components.Create<MysteryBox>();
		box.Cost = spot.Cost;

		// ⚠️ RETURNS THE OBJECT NOW, so the fire sale can track the extras it creates and take
		// exactly those away again. It used to return void, which meant the only way to find a box
		// afterwards was to search the scene — and that cannot tell a sale box apart from the real
		// one.
		return go;
	}

	// ── fire sale ────────────────────────────────────────────────────────────

	/// <summary>
	/// Boxes conjured for the duration of a fire sale. NOT the real one.
	///
	/// ⛔ TRACKED SEPARATELY FROM `Current` BECAUSE ONLY THESE GET REMOVED. The box at `Current` is
	/// the game's actual box and survives the sale; every other spot gets a temporary one. Searching
	/// the scene for MysteryBox components at the end would find all of them and could not say which
	/// was which.
	/// </summary>
	readonly List<GameObject> _saleBoxes = new();

	bool _saleOn;

	/// <summary>
	/// Open or close the extra box locations as a fire sale starts and ends.
	///
	/// ⛔ POLLED RATHER THAN EVENT-DRIVEN, deliberately. ActivePowerups expires things on a timer and
	/// there is no "powerup ended" hook to subscribe to; a poll cannot miss an expiry, cannot fire
	/// twice, and cannot be left subscribed by a hotload. It costs one bool comparison per frame.
	/// </summary>
	protected override void OnUpdate()
	{
		var on = PowerupEffects.FireSale;

		if ( on != _saleOn )
		{
			_saleOn = on;
			if ( on ) OpenAllSpots();
		}

		// ⚠️ CLOSING IS ATTEMPTED EVERY FRAME WHILE OFF, not once on the transition, because a box
		// mid-spin refuses to be removed — see CloseSaleSpots. One shot at it would strand that box
		// in the world for the rest of the game.
		if ( !on && _saleBoxes.Count > 0 ) CloseSaleSpots();
	}

	/// <summary>
	/// A box at every spot that has not got one.
	///
	/// ⚠️ THE PLATFORMS GO, because a platform is what marks a spot the box is NOT at — leaving them
	/// under the sale boxes would show every location as both occupied and empty at once.
	/// </summary>
	void OpenAllSpots()
	{
		var list = ActiveConfig.Current?.Boxes;
		if ( list is null || list.Count <= 1 ) return;

		foreach ( var p in Platforms.ToList() ) p?.Destroy();
		Platforms.Clear();

		foreach ( var spot in list )
		{
			if ( spot == Current ) continue;
			_saleBoxes.Add( BuildBox( spot ) );
		}

		Log.Info( $"[nz] FIRE SALE — {_saleBoxes.Count + 1} box location(s) open" );
	}

	/// <summary>
	/// Take the extra boxes away again, once each is safe to remove.
	///
	/// ⛔ A BOX MID-SPIN IS LEFT ALONE UNTIL IT CLOSES. Destroying one while its lid is open takes a
	/// weapon out of a player's hands mid-offer — they paid for it, and the sale ending is not their
	/// doing. LidState.Closed is the only state with nothing in flight.
	/// </summary>
	void CloseSaleSpots()
	{
		for ( int i = _saleBoxes.Count - 1; i >= 0; i-- )
		{
			var go = _saleBoxes[i];

			if ( !go.IsValid() ) { _saleBoxes.RemoveAt( i ); continue; }

			var box = go.Components.Get<MysteryBox>();
			if ( box.IsValid() && box.Lid != MysteryBox.LidState.Closed ) continue;

			go.Destroy();
			_saleBoxes.RemoveAt( i );
		}

		// Only once the last one is gone, or a platform would appear under a box still standing.
		if ( _saleBoxes.Count == 0 )
		{
			RebuildPlatforms();
			Log.Info( "[nz] fire sale over — the box is back to one location" );
		}
	}

	/// <summary>
	/// Every platform in the world, so Rebuild can clear them.
	///
	/// ⚠️ Tracked separately rather than through MysteryBox.All: platforms are not
	/// box components, they outnumber the box, and they exist at spots that have
	/// none.
	/// </summary>
	static readonly List<GameObject> Platforms = new();

	/// <summary>
	/// The pile the box sits on — shown at EVERY candidate location, because
	/// `random_box_spawns/shared.lua:85` puts the platform on the SPAWN POINT
	/// rather than on the box.
	///
	/// ⚠️ `magic_box_fake`, which ships beside `magic_box` in the same pack from
	/// the same author. The lua's "Original" entry points at a t6 pile from a
	/// DIFFERENT pack whose four materials include two stock CS:S textures this
	/// project does not have; this one has two, both already converted and
	/// compiled. Matched art beats a faithful path that renders half untextured.
	/// </summary>
	void BuildPlatform( MysteryBoxSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Box Platform";
		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;

		// ⚠️ +90 ON THE SPOT'S YAW. The two models are authored on different axes,
		// so a shared yaw points the crate correctly and the footlockers across
		// the opening. Applied here rather than baked into MysteryBoxSpot.Yaw —
		// that value orients the BOX, which is the one the player walks up to, and
		// rotating it to suit the marker would tilt the thing that matters.
		// ⚠️ THE SKIN'S OWN PLATFORM, AT ITS OWN TURN (`MysteryBoxSkins`, 2026-09-28): the original's footlockers sit across the
		// opening (+90); the Origins base is authored on the crate's own axes (0) — the box brings its own base where it stands
		var skin = MysteryBoxSkins.Current;
		go.WorldRotation = OnFloor( spot, skin.PlatformYaw );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = Model.Load( skin.Platform );

		Platforms.Add( go );
	}
}