Buyables/MysteryBox.cs

MysteryBox component controlling the mystery box buyable. It builds visual renderers and skin parts, handles opening/closing lid state machine, plays sounds, rolls a random weapon (or teddy), shows the rising/flicker reveal, applies rarity outlines, handles taking/expiring offers, networked broadcast of rolls/takes, and manages per-game static counters for teddy odds.

NetworkingFile AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// MYSTERY BOX — 950 points for a random weapon.
///
/// Ported from `random_box` (entities/entities/random_box). The price is the
/// original's: `nzMapping.Settings.rboxprice or 950` (display/cl_target.lua:81),
/// with a Fire Sale dropping it to 10 — that powerup does not exist here yet, so
/// the discount is a property waiting for it rather than a hardcoded 950.
///
/// ⛔ PHASE ONE ON PURPOSE. No windup, no lid, no floating weapon, no teddy bear.
/// The question worth answering first is whether spending 950 on a random gun
/// feels good against a 31-weapon pool, and none of that presentation changes the
/// answer. Building the lid before knowing would be building on a guess.
/// </summary>
public sealed class MysteryBox : Component
{
	/// <summary>The original's price. `rboxprice or 950`.</summary>
	[Property] public int Cost { get; set; } = 950;

	/// <summary>
	/// What it costs during a Fire Sale.
	///
	/// ⚠️ Present but unreachable — powerups do not exist yet. It is here because
	/// the original's price lookup IS the discount check, and splitting them would
	/// mean finding this line again later.
	/// </summary>
	[Property] public int FireSaleCost { get; set; } = 10;

	/// <summary>How close you must stand. Matches the barricade's repair reach.</summary>
	[Property] public float Reach { get; set; } = 80f;

	/// <summary>Every live box, for the use trace and the prompt to scan.</summary>
	public static readonly List<MysteryBox> All = new();

	/// <summary>The box standing nearest <paramref name="at"/> — the one a message about "the box there" means (`NZNet.BoxRolled`).</summary>
	public static MysteryBox Nearest( Vector3 at )
		=> All.Where( b => b.IsValid() ).OrderBy( b => b.WorldPosition.DistanceSquared( at ) ).FirstOrDefault();

	/// <summary>Price right now. One place, so the prompt and the charge agree.</summary>
	public int Price => PowerupEffects.FireSale ? FireSaleCost : Cost;

	protected override void OnEnabled()
	{
		if ( !All.Contains( this ) ) All.Add( this );
		BuildVisual();
	}

	protected override void OnDisabled()
	{
		All.Remove( this );
		StopHum();
	}

	GameObject _visual;

	/// <summary>The box model, with its authored idle/open/close/arrive/leave.</summary>
	[Property] public string BoxModel { get; set; } = "models/nz/magicbox/magic_box.vmdl";

	SkinnedModelRenderer _renderer;

	/// <summary>The renderer, for playing the lid animations.</summary>
	public SkinnedModelRenderer Renderer => _renderer;

	/// <summary>
	/// The real crate, decompiled from `models/nzr/2022/magicbox/bo2/magic_box.mdl`
	/// — the original's "Original" box type (random_box/shared.lua:164).
	///
	/// ⚠️ A SkinnedModelRenderer, not a ModelRenderer: the model carries five
	/// sequences on three bones, and only the skinned one can play them. Using the
	/// plain renderer would draw it perfectly and leave the lid welded shut.
	/// </summary>
	void BuildVisual()
	{
		_visual?.Destroy();

		var go = Scene.CreateObject();
		go.Name = "box";
		go.SetParent( GameObject );
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// ⛔ ZEROED **AFTER** SetParent, AND THIS IS THE WHOLE BUG. Scene.CreateObject
		// puts the object at the WORLD ORIGIN, and SetParent PRESERVES WORLD
		// POSITION — so the child stayed at 0,0,0 and merely acquired a local
		// offset to compensate. `nz_box_debug` caught it in one line: component at
		// 120,0,0 while its renderer reported worldPos 0,-0,0 and localPos
		// -93.1,75.7,0. The box was rendering under the map, which from the
		// player's side is indistinguishable from a model that failed to load —
		// and cost two rounds of chasing the model instead.
		//
		// ⚠️ The barricade sets LocalPosition after parenting too. That is why it
		// was never wrong there, and why dropping the line here when the real
		// model went in reintroduced a solved problem.
		go.LocalPosition = Vector3.Zero;
		go.LocalRotation = Rotation.Identity;

		// ⚠️ THE MAP'S OWN BOX, IF IT HAS ONE (`Gameplay.BoxSkin`, `MysteryBoxSkins`, 2026-09-28) — basalt's Origins stone
		_skin = MysteryBoxSkins.Current;

		_renderer = go.Components.Create<SkinnedModelRenderer>();
		_renderer.Model = Model.Load( _skin.IsOriginal ? BoxModel : _skin.Model );

		// ⚠️ Sits closed until something opens it. `idle` is the only looping clip
		// in the set — the rest are one-shots the buy sequence will drive.
		//
		// ⛔ Sequence.Name, NOT Set("idle", true). Set() writes an ANIMGRAPH
		// PARAMETER, and this model has no animgraph — it has five plain
		// sequences. The call would have compiled, done nothing, and left the box
		// on its bind pose looking like the clip was missing.
		if ( _renderer.Model is not null )
			_renderer.Sequence.Name = "idle";

		_visual = go;

		BuildSkinParts();
	}

	// ── the skin (`MysteryBoxSkins`, 2026-09-28) ─────────────────────────────

	MysteryBoxSkin _skin = MysteryBoxSkins.Original;
	SkinnedModelRenderer _base;
	GameObject _offerFrame;
	SoundHandle _hum;

	/// <summary>The skin this box was built in (`Gameplay.BoxSkin`).</summary>
	public MysteryBoxSkin Skin => _skin;

	/// <summary>
	/// The skin's parts beside the crate: the base it stands on — Origins', whose doors open as the crate rises out of it and sinks
	/// back in (`PlayClip`) — and the offer's own frame, carrying the skin's lift, so every placement of the offer below keeps its
	/// own tuned numbers.
	/// </summary>
	void BuildSkinParts()
	{
		_offerFrame?.Destroy();
		_offerFrame = null;
		_base = null;

		if ( _skin.BaseUnderBox && _visual.IsValid() )
		{
			var b = Scene.CreateObject();
			b.Name = "base";
			b.SetParent( _visual );
			b.Flags |= GameObjectFlags.NotSaved;
			b.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

			// ⚠️ LOCAL AFTER PARENTING — the trap BuildVisual's own note records. The base shares the crate's origin: both are
			// authored on one `tag_origin`, the chest nesting into the base's top.
			b.LocalPosition = Vector3.Zero;
			b.LocalRotation = Rotation.Identity;

			_base = b.Components.Create<SkinnedModelRenderer>();
			_base.Model = Model.Load( _skin.Platform );
			if ( _base.Model is not null ) _base.Sequence.Name = "idle";
		}

		var f = Scene.CreateObject();
		f.Name = "offer frame";
		f.SetParent( GameObject );
		f.Flags |= GameObjectFlags.NotSaved;
		f.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		f.LocalPosition = Vector3.Up * _skin.OfferLift;
		f.LocalRotation = Rotation.Identity;
		_offerFrame = f;
	}

	/// <summary>A clip on the crate, and on its base where the base has it (`arrive`, `leave`, `idle` — never the lid's).</summary>
	void PlayClip( string clip )
	{
		if ( _renderer.IsValid() ) _renderer.Sequence.Name = clip;

		if ( _base.IsValid() && _base.Model is not null && _base.Sequence.SequenceNames.Contains( clip ) )
		{
			_base.PlaybackRate = 1f;
			_base.Sequence.Name = clip;
		}
	}

	/// <summary>One of the skin's own sounds, at the box. The original has none of these, and plays nothing extra.</summary>
	void SkinSound( string cue )
	{
		if ( !string.IsNullOrEmpty( cue ) ) NZSound.Play( cue, WorldPosition );
	}

	/// <summary>
	/// The skin's hum while the box stands — Origins' `magicbox_idle_high`, GMod's `SND` for it (random_box:118): restarted as it
	/// ends and carried as the box moves. ⚠️ `PlayAmbient`, as Pack-a-Punch's hum: a box across the map spends nothing.
	/// </summary>
	void TickHum()
	{
		if ( string.IsNullOrEmpty( _skin.HumSound ) ) return;

		if ( !_hum.IsValid() || _hum.IsStopped )
			_hum = NZSound.PlayAmbient( _skin.HumSound, WorldPosition );
		else
			_hum.Position = WorldPosition;
	}

	void StopHum()
	{
		if ( _hum.IsValid() ) _hum.Stop();
		_hum = default;
	}

	// ── the offer ────────────────────────────────────────────────────────────

	WeaponLibrary.Entry _offer;
	GameObject _offerGO;
	ModelRenderer _offerRenderer;

	/// <summary>
	/// The rarity rolled for THIS offer. Applied when the player takes it.
	///
	/// ⚠️ ROLLED WITH THE WEAPON, at the buy, not at the settle — the same reason
	/// the weapon and the teddy are decided there. A rarity chosen at the top of the
	/// rise could not have been paid for, and the outline colour has to be right from
	/// the moment the gun becomes visible.
	/// </summary>
	int _offerRarity;

	/// <summary>The rarity of the gun currently on offer, for the prompt and the HUD.</summary>
	public int OfferRarity => _offer is null || IsTeddy ? 0 : _offerRarity;

	/// <summary>
	/// Is there a weapon waiting to be taken?
	///
	/// ⛔ REQUIRES THE LID TO BE **HELD**, which is what makes the rise mean
	/// anything. During Opening and Rising this is false, so the use key does not
	/// route to Take and the prompt does not offer one — you cannot grab the gun
	/// out of the air while it is still cycling. The wait IS the mechanic.
	/// </summary>
	/// ⛔ NEVER FOR THE BEAR. It is on the box and it is not yours — no prompt, no
	/// take, and the use key falls through to nothing. Letting Take fire on it would
	/// hand the player whatever weapon `_offer` still held underneath.
	/// ⛔ AND ONLY ON THE BUYER'S MACHINE (the co-op pass, 2026-09-28). `ShowRoll` fills `_offer` on every other machine for the
	/// picture, with `_buyer` null — the gate its own note promised and nothing enforced — so a teammate saw "Take", pressed it and
	/// walked off with a copy while the buyer took theirs.
	public bool HasOffer => _offer is not null && _buyer.IsValid() && !IsTeddy && Lid == LidState.Held;

	/// <summary>A bear's sequence under way, before the crate has gone: the host's word on where it goes is held until then (`PlaceAt`).</summary>
	public bool IsLeaving => IsTeddy && Lid is not (LidState.Closed or LidState.Arriving);

	/// <summary>Who paid for the roll in progress, for the teddy's refund.</summary>
	NZPlayer _buyer;

	/// <summary>Mid-sequence — no roll can start and nothing can be taken yet.</summary>
	public bool IsBusy => Lid != LidState.Closed;

	/// <summary>What is on the box right now, for the prompt.</summary>
	public string OfferName => _offer?.Name ?? "";

	/// <summary>
	/// How high the floating offer's MESH currently sits above the box, or null.
	///
	/// ⚠️ Measured along the box's own up, not world z — the crate aligns to the
	/// surface it stands on, and a box on a ramp would otherwise report a height
	/// that shrinks the more the ground tilts. Same trap `nz_box_watch` fell into.
	/// </summary>
	public float? OfferMeshHeight
	{
		get
		{
			if ( !_offerGO.IsValid() || !_offerRenderer.IsValid() ) return null;

			var model = _offerRenderer.Model;
			if ( model is null ) return null;

			var centre = _offerGO.WorldPosition + _offerGO.WorldRotation * model.Bounds.Center;
			return (centre - WorldPosition).Dot( WorldRotation.Up );
		}
	}

	/// <summary>
	/// How high above the box the weapon ends up.
	///
	/// ⚠️ 45 — WELL CLEAR OF THE CRATE, ~26 above a rim that sits at 19. It was 18
	/// (the mouth itself) and that is the height the CLIMB needs, not the height
	/// the offer needs: presented up here the weapon is visible over the crate
	/// walls from across a room, which is what a box you are deciding whether to
	/// run for has to be.
	/// </summary>
	[Property] public float OfferHeight { get; set; } = 45f;

	/// <summary>
	/// Where it starts from — down inside the crate, below the rim.
	///
	/// ⚠️ Deliberately BELOW the top of the box (bounds are 95.6 x 26.6 x 19.2, so
	/// the rim sits at ~19). The weapon has to be born hidden or the "rise" is a
	/// weapon that was already there suddenly moving.
	/// </summary>
	[Property] public float RiseFrom { get; set; } = 4f;

	/// <summary>
	/// How long the weapon takes to climb, and therefore how long it cycles.
	///
	/// ⚠️ ONE NUMBER FOR BOTH, exactly as the original: `WeaponCycleTime` is set to
	/// the duration of the `rise` animation (random_box_windup:259-261), so the
	/// spin lands on the real weapon at the instant it arrives. Splitting them
	/// gives you either a gun that settles and then keeps flickering, or one that
	/// stops changing and then drifts upward in silence.
	///
	/// ⚠️ 4.6s. Went 4.2 -> 6 for a slower lift, then -15% and -10% by ear. It has
	/// to stay inside the 7.18s jingle, which is the real ceiling: run past that
	/// and the reveal lands after the music has stopped. Plenty of room now.
	/// </summary>
	[Property] public float RiseTime { get; set; } = 4.6f;

	/// <summary>
	/// How hard the climb front-loads. Higher = more of the distance covered sooner.
	///
	/// ⚠️ THE EXPONENT OF A DECAY CURVE, not a speed. The weapon leaves the hay
	/// fast and decelerates the whole way up, arriving asymptotically — 0 would be
	/// a straight line and there is no useful upper bound, it just gets snappier.
	/// 6 covers 87% of the climb in the first half of the time.
	/// </summary>
	[Property] public float RiseCurve { get; set; } = 6f;

	/// <summary>
	/// How the offered weapon is angled, once still.
	///
	/// ⚠️ IT DOES NOT TURN. It used to spin at 60°/s, which reads as a pickup in a
	/// looter and not as a weapon being presented. A static pose has to be aimed,
	/// though — a spinning object is never wrong from any one angle and a still one
	/// always can be — so this is tunable live via `nz_box_offer`.
	///
	/// ⚠️ YAW 90 lays the weapon across the crate's short axis rather than along
	/// its length. The original poses it the same way — `WEAPANG = Angle(0,90,0)`
	/// on every box style (random_box_windup:50) — applied, like this, as an offset
	/// from the box's own angles rather than as a world heading.
	/// </summary>
	[Property] public Angles OfferAngles { get; set; } = new( 0f, 90f, 0f );

	/// <summary>The pose the weapon wears: <see cref="OfferAngles"/>, turned by the skin's own (`MysteryBoxSkin.OfferYaw`).</summary>
	Angles OfferPose => new( OfferAngles.pitch, OfferAngles.yaw + _skin.OfferYaw, OfferAngles.roll );

	/// <summary>
	/// Place the weapon by its MESH rather than by its origin.
	///
	/// ⛔ WITHOUT THIS, EVERY WEAPON IS PUT AT THE SAME HEIGHT AND THEY DO NOT LOOK
	/// LIKE IT. These are viewmodels, authored around a camera and a pair of hands,
	/// so where the geometry sits relative to its origin is whatever suited the
	/// animator. `nz_box_heights` measured the pool: 29 of 31 fall in a 5.4u band,
	/// and **the Uzi and the ASP are both ~50 units up** — placed by origin they
	/// float a body-length above every other roll.
	///
	/// ⚠️ It also steadies the CYCLE. The flicker swaps models up to twenty times a
	/// second, and uncompensated each one draws at its own offset, so the spinning
	/// weapon jitters vertically the whole way up.
	///
	/// ⚠️ Toggleable (`nz_box_align`) because it moves all 31, not just the two —
	/// the rest shift by under 5u, which is small but is not nothing after the
	/// resting height was tuned by eye.
	/// </summary>
	[Property] public bool AlignToMesh { get; set; } = true;

	/// <summary>
	/// Local position that puts the current mesh's centre at height `z`.
	///
	/// ⚠️ The bounds centre is in MODEL space, so it is rotated into the parent's
	/// frame before being subtracted — with yaw-only angles that is the same as
	/// subtracting the z, but it stops being true the moment anything is pitched.
	///
	/// ⚠️ Corrects x/y as well. A viewmodel offset sideways from its origin is the
	/// same authoring artefact as one offset upward, and it reads as a weapon
	/// hovering beside the crate rather than over it.
	/// </summary>
	Vector3 OfferLocalPos( float z ) => OfferLocalPos( z, OfferPose );

	/// <param name="angles">
	/// The pose the object is ACTUALLY wearing. Defaults to the weapon pose, but the
	/// bear wears its own — and compensating with the wrong one puts the mesh centre
	/// off by however far the two rotations differ.
	/// </param>
	Vector3 OfferLocalPos( float z, Angles angles )
	{
		var p = Vector3.Up * z;

		var model = _offerRenderer.IsValid() ? _offerRenderer.Model : null;
		if ( !AlignToMesh || model is null ) return p;

		return p - angles.ToRotation() * model.Bounds.Center;
	}

	TimeSince _sinceRise;
	TimeUntil _nextCycle;

	/// <summary>
	/// How long THIS rise lasts — `RiseTime` after Timeslip Tonic.
	///
	/// ⛔ CAPTURED AT CYCLE START, NOT READ LIVE. The rise interpolates on
	/// `_sinceRise / span`, so a span that changed mid-climb would make the weapon
	/// visibly jump — buying or losing the perk during a 2.3s spin would teleport
	/// the gun. Sampling the buyer's perk once, here, makes that impossible.
	///
	/// ⚠️ Defaults to `RiseTime` so a rise that somehow starts without going through
	/// StartRise still behaves, rather than running a 0-second climb.
	/// </summary>
	float _riseSpan;

	/// <summary>A buyer's Timeslip on the climb — the base perk and m3's Fast Forward, as one product. Sent with the roll (`NZNet.BoxRolled`).</summary>
	static float RiseScaleOf( NZPlayer buyer )
		=> PerkEffects.MachineCycleMultiplier( buyer ) * TimeAugments.BoxCycleScale( buyer );

	/// <summary>The climb scale of a roll bought on another machine (`ShowRoll`); 1 for this machine's own.</summary>
	float _shownRiseScale = 1f;

	/// <summary>
	/// Create the floating weapon down inside the box and start it climbing.
	///
	/// ⚠️ The model it is BORN with is a random one, not the rolled one — the whole
	/// point of the cycle is that the player cannot see what they won until it
	/// settles. Handing it the real model here and swapping it away would show the
	/// answer for one frame on every roll.
	/// </summary>
	void StartRise()
	{
		ClearOffer();
		if ( _offer is null ) return;

		var go = Scene.CreateObject();
		go.Name = "offer";
		// ⚠️ UNDER THE SKIN'S OFFER FRAME, which carries its lift (`BuildSkinParts`)
		go.SetParent( _offerFrame.IsValid() ? _offerFrame : GameObject );
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// ⚠️ Local AFTER parenting — the same trap that put the box itself at the
		// world origin earlier tonight.
		go.LocalRotation = OfferPose.ToRotation();

		_offerRenderer = go.Components.Create<ModelRenderer>();
		_offerGO = go;

		// ── rarity outline ────────────────────────────────────
		//
		// ⚠️ THE SAME MECHANISM THE SALVAGE PICKUP USES — a HighlightOutline plus the
		// camera's Highlight, which WallBuyManager.EnsureHighlight installs. Without
		// that camera component the outline silently does not draw, which is why
		// ApplyRarityOutline calls it rather than assuming chalk has already run.
		//
		// ⛔ THE OUTLINE IS **NOT** CREATED HERE ANY MORE — see ApplyRarityOutline, called from
		// SettleOffer. It used to be built with the offer, which meant the rarity colour was on
		// screen from the first frame of the climb: every weapon flickering past during the spin
		// wore the FINAL rarity's outline, so a gold edge on the first spun model told you it was
		// Legendary before the reveal landed. The roll still happens at the buy (see _offerRarity)
		// — only the moment it becomes VISIBLE moved.

		_sinceRise = 0f;
		_nextCycle = 0f;

		// Timeslip Tonic halves the climb. Sampled from the BUYER, once, for the
		// reason on _riseSpan — and null-safe, so a console spin runs at x1.
		// ⚠ m3 FAST FORWARD MULTIPLIES THE SAME PRODUCT the base perk already scales, rather
		// than adding a second path to `_riseSpan`. The `Max( .., 0.01f )` is what makes m3's 0
		// safe — it was already there for the base perk's multiplier and needs no change.
		// ⚠️ SOMEBODY ELSE'S ROLL CLIMBS AT THEIR SCALE, sent with it (`ShowRoll`, the co-op pass, 2026-09-28): with `_buyer` null here
		// it ran at x1, and a Timeslip buyer's reveal landed three seconds early on every other screen
		_riseSpan = MathF.Max( RiseTime * (_buyer.IsValid() ? RiseScaleOf( _buyer ) : _shownRiseScale), 0.01f );

		// ⛔ MODEL FIRST, **THEN** POSITION — THE THIRD PATH WITH THIS ORDERING, and
		// the one I missed after fixing the other two. Placing before the model
		// exists means OfferLocalPos has nothing to compensate for and returns the
		// raw height, so the first frame drew the weapon at its own uncorrected
		// offset before TickRise moved it down the next frame. Visible as exactly
		// one frame of the weapon sitting too high as the box opens — and on the
		// Uzi or the ASP that single frame is 50 units out.
		//
		// ⚠️ TickRise cannot cover this. It runs on the NEXT update; this object is
		// created and drawn during the current one.
		string first = CycleModel();
		go.LocalPosition = OfferLocalPos( RiseFrom );

		if ( first is not null && Watch )
			Report( first, _offerRenderer.Model, "start" );
	}

	/// <summary>
	/// Climb, and flicker through the pool on the way up.
	///
	/// ⚠️ RUNS OUTSIDE THE STATE MACHINE'S TIMER GATE, like the spin it replaced.
	/// That gate only fires on the frame a stage ENDS; a rise driven from inside it
	/// would be a weapon that teleports to the top once.
	/// </summary>
	void TickRise()
	{
		if ( Lid != LidState.Rising || !_offerGO.IsValid() ) return;

		float t = MathX.Clamp( _sinceRise / MathF.Max( _riseSpan, 0.01f ), 0f, 1f );

		// ⛔ EXPONENTIAL DECAY — fast out of the hay, decelerating the whole way up.
		// Smoothstep was tried in between and is wrong here: it eases IN, so the
		// weapon crept off the bottom, and the box reads better when the gun is
		// flung up and then floats to a stop.
		//
		// ⚠️ THE HEIGHT IS WHAT MAKES THIS WORK. The first version front-loaded the
		// climb too, and looked bad, because the whole fast phase happened below a
		// 19-unit rim where nobody could see it. Over a 41-unit climb the front
		// load IS the part you watch. The curve was never the problem on its own.
		//
		// ⚠️ NORMALISED by its own value at t=1, so it lands exactly on the top
		// instead of the 1-2^-k just short of it — a couple of units of permanent
		// undershoot that SettleOffer would then snap away as a visible jump.
		float k = MathF.Max( RiseCurve, 0.01f );
		float full = 1f - MathF.Pow( 2f, -k );
		float eased = (1f - MathF.Pow( 2f, -k * t )) / full;

		// ⛔ SWAP THE MODEL, **THEN** POSITION. OfferLocalPos compensates for the
		// model the renderer is holding right now, so positioning first aligns the
		// incoming weapon using the OUTGOING one's offset — one visible frame of
		// every high-origin weapon in the wrong place, which is precisely the
		// artefact this pass exists to remove. `nz_box_watch` caught it: an ASP
		// reported at mesh z=94.5, and the Makarov behind it reported asked z=-7.2,
		// the ASP's own correction still applied a frame later.
		string swapped = _nextCycle ? CycleModel() : null;

		// ⚠️ Recomputed every frame, not cached — the offset it corrects for
		// changes with each model the cycle swaps in.
		_offerGO.LocalPosition = OfferLocalPos( MathX.Lerp( RiseFrom, OfferHeight, eased ) );

		// ⚠️ Reported after positioning for the same reason: a watch that prints
		// the position from before the correction is a watch that reports the bug
		// it is being used to confirm is fixed.
		if ( swapped is not null && Watch )
			Report( swapped, _offerRenderer.IsValid() ? _offerRenderer.Model : null, "cycle" );
	}

	TimeSince _sinceHeld;

	/// <summary>
	/// Sink the revealed weapon back into the crate over the grab window.
	///
	/// ⛔ NOT FOR THE BEAR. `DoWeaponFall` is guarded by `!self:GetIsTeddy()` in the
	/// original for a reason — the bear is not on offer, so it has no window to
	/// express. It sits and laughs, and the crate leaves out from under it.
	///
	/// ⚠️ LINEAR, unlike the rise. The climb is a flourish and gets a curve; this is
	/// a clock, and a clock that speeds up or slows down misreports how long is
	/// left — which is the one thing it exists to communicate.
	/// </summary>
	void TickLower()
	{
		if ( Lid != LidState.Held || IsTeddy || !_offerGO.IsValid() ) return;

		float t = MathX.Clamp( _sinceHeld / MathF.Max( HoldTime, 0.01f ), 0f, 1f );

		_offerGO.LocalPosition = OfferLocalPos(
			MathX.Lerp( OfferHeight, RiseFrom, t ), OfferPose );
	}

	/// <summary>
	/// Show a random weapon and schedule the next swap.
	///
	/// ⚠️ THE GAP IS `0.2 / timeLeft`, THE ORIGINAL'S CURVE (random_box_windup:303)
	/// — a deceleration with no ceiling, so it flickers roughly 20x/sec at the
	/// start and lands on a near-second pause at the end. That slowdown is the
	/// whole tell that the roll is about to finish.
	///
	/// ⛔ CLAMPED TO THE TIME ACTUALLY LEFT, which is the NZAUGMENT fix the addon
	/// carries a comment about: unclamped, a gap computed at 0.1s remaining is 2.0s
	/// long and finalises 1.9s LATE — the weapon settles, and then changes once more
	/// while the player is already reaching for it.
	/// </summary>
	/// <returns>What it swapped to, or null if nothing changed.</returns>
	string CycleModel()
	{
		if ( !_offerRenderer.IsValid() ) return null;

		// ⚠️ The SHORTENED span, so the model-swap cadence stays tied to the climb.
		// Against the unscaled RiseTime a Timeslip spin would still be flickering
		// on the old schedule after the weapon had already settled.
		float left = MathF.Max( _riseSpan - _sinceRise, 0.01f );
		_nextCycle = MathF.Min( 0.2f / left, left );

		var pool = Pool();
		if ( pool.Count == 0 ) return null;

		var pick = Game.Random.FromList( pool );
		var model = ModelFor( pick );

		// ⚠️ A miss LEAVES THE PREVIOUS MODEL UP rather than blanking. Any weapon
		// without a viewmodel would otherwise punch a hole in the flicker, and a
		// gun that vanishes mid-roll looks like the box broke.
		if ( model is not null ) _offerRenderer.Model = model;

		return pick.Name;
	}

	// ── where the thing actually IS ──────────────────────────────────────────

	/// <summary>
	/// Log every model the offer takes, and where its MESH lands. `nz_box_watch`.
	///
	/// ⛔ THE OBJECT POSITION IS NOT THE ANSWER AND NEVER MOVES. Every weapon is
	/// placed at the same OfferHeight, so a log of the GameObject's position prints
	/// the same number 32 times while the guns visibly sit at different heights.
	/// What differs is where each MESH is relative to its own origin — these are
	/// VIEWMODELS, authored around a camera and a pair of hands rather than around
	/// the weapon — so the number that explains it is the model's bounds centre.
	/// </summary>
	public static bool Watch { get; set; }

	/// <summary>
	/// One line: what is showing, where it was put, and where it ended up.
	///
	/// ⚠️ `bias` is the whole point — the gap between the height we ASKED for and
	/// the height the mesh's middle actually occupies. A weapon that "appears a lot
	/// higher" is one with a large negative bias: its origin is far above its own
	/// geometry, so placing the origin at 45 hangs the gun well above 45.
	/// </summary>
	void Report( string name, Model model, string stage )
	{
		if ( !_offerGO.IsValid() ) return;

		float asked = _offerGO.LocalPosition.z;

		if ( model is null )
		{
			Log.Info( $"[box-watch] {name,-14} {stage,-6} NO MODEL — previous left up" );
			return;
		}

		// The mesh's middle in world space, measured back along THE BOX'S OWN UP.
		//
		// ⛔ NOT WORLD Z. The box aligns to the surface it was placed on, so on a
		// ramp — or on a wall, which `nz_box` will happily do, since it uses the
		// trace normal — its up is not the world's. Measured in world z this
		// printed `mesh z=0.0` for every weapon on a wall-mounted box and looked
		// exactly like a total failure of the alignment, which was in fact correct
		// to four decimal places. `asked` is a LOCAL z, so the two only compare in
		// the box's frame.
		var up = WorldRotation.Up;
		var centre = _offerGO.WorldPosition + _offerGO.WorldRotation * model.Bounds.Center;
		float actual = (centre - WorldPosition).Dot( up );

		Log.Info( $"[box-watch] {name,-14} {stage,-6} asked z={asked,6:0.0}  "
			+ $"mesh z={actual,6:0.0}  bias={actual - asked,6:+0.0;-0.0}  "
			+ $"bounds c={model.Bounds.Center}  size={model.Bounds.Size}" );

		// ⚠️ Only when the box is NOT sitting level, because that is the case where
		// every other number needs reinterpreting — and printing it always doubles
		// the output of something that already logs twenty times a second.
		if ( up.z < 0.99f )
			Log.Warning( $"[box-watch]   box is not level — up={up}, "
				+ "heights are along ITS axis, not the world's" );
	}

	/// <summary>
	/// Stop cycling and show what was actually rolled.
	///
	/// ⚠️ Snapped to the exact end height rather than left wherever the lerp got
	/// to. The rise is frame-timed and the last frame lands slightly short — one or
	/// two units, invisible in motion and obvious once it is the resting pose.
	/// </summary>
	void SettleOffer()
	{
		if ( !_offerGO.IsValid() ) return;

		_offerGO.LocalRotation = OfferPose.ToRotation();

		// ⛔ THE BEAR REPLACES THE WEAPON AT THE REVEAL, and the points come back.
		// Refunding is not generosity — the box is about to leave, and charging 950
		// for the privilege of losing your box would make the teddy a punishment
		// rather than an event. `DoFinalSelection` refunds the full 950 too
		// (random_box_windup:321).
		if ( IsTeddy )
		{
			var bear = Model.Load( TeddyModel );
			if ( _offerRenderer.IsValid() )
			{
				_offerRenderer.Enabled = bear is not null && !bear.IsError;
				if ( bear is not null && !bear.IsError ) _offerRenderer.Model = bear;
			}

			// ⚠️ Rotation overridden AFTER the shared line above — and the position
			// computed after BOTH, because OfferLocalPos reads the rotation to place
			// the mesh centre. Rotating afterwards would leave the bear aligned by
			// the weapon pose it no longer has.
			_offerGO.LocalRotation = TeddyAngles.ToRotation();
			_offerGO.LocalPosition = OfferLocalPos( OfferHeight, TeddyAngles );

			if ( _buyer.IsValid() ) _buyer.AddPoints( Price );
			NZSound.Play( NZSound.MapCue( _skin.TeddySound, NZSound.BoxTeddy ), WorldPosition );

			Log.Info( $"[nz] the bear — {Price} refunded, the box is leaving "
				+ $"(use #{Uses})" );

			if ( Watch ) Report( "TEDDY", bear, "FINAL" );
			return;
		}

		var model = _offer is null ? null : ModelFor( _offer );

		// ⛔ HIDE rather than leave the last cycled model standing. Showing a weapon
		// that is not the one Take will hand over is worse than showing nothing —
		// it is a lie the player only discovers after spending 950.
		if ( _offerRenderer.IsValid() )
			_offerRenderer.Enabled = model is not null;

		if ( model is not null && _offerRenderer.IsValid() )
			_offerRenderer.Model = model;

		// ⛔ POSITIONED **AFTER** THE MODEL IS ASSIGNED. OfferLocalPos reads the
		// renderer's CURRENT model to know what to compensate for, so running it
		// first would align the settled weapon to whichever one the cycle happened
		// to leave up — the reveal landing at the wrong height, and only ever on
		// the frame the player is looking at it.
		_offerGO.LocalPosition = OfferLocalPos( OfferHeight );

		// ⚠️ Logged at FINAL too, not just during the cycle. This is the one the
		// player actually looks at and the only one whose height matters — a bias
		// that flashes past mid-flicker is noise, the same bias on the resting pose
		// is the bug.
		if ( Watch ) Report( _offer?.Name ?? "?", model, "FINAL" );

		// ⚠️ LAST, AFTER THE MODEL AND THE POSITION ARE FINAL. The outline traces whatever mesh the
		// renderer is holding, so adding it before the settle model is assigned would silhouette
		// the last cycled weapon.
		ApplyRarityOutline();
	}

	/// <summary>
	/// Put the rarity outline on the settled weapon.
	///
	/// ⛔ AT THE SETTLE, NOT AT THE BUY. Built with the offer, the colour was up for the whole
	/// climb — and since the spin only swaps the MODEL, every weapon that flashed past wore the
	/// final rarity's outline. That told you the tier before the reveal, which is the one thing the
	/// spin exists to withhold.
	///
	/// ⚠️ THE ROLL DID NOT MOVE. `_offerRarity` is still decided at the buy, beside the weapon, for
	/// the reason written on it — a rarity chosen at the top of the rise could not have been paid
	/// for. Only the moment it becomes visible changed.
	///
	/// ⚠️ EVERY TIER GETS AN OUTLINE, COMMON INCLUDED — grey, per `Rarity.ColorFor( 0 )`. This was
	/// the other way round at first, on the argument that below round 7 every gun is Common so a
	/// grey edge would be on all of them and stop meaning "special". Overruled by request, and the
	/// counter-argument is decent: now the outline reads as "here is your weapon's tier" on every
	/// single reveal rather than as a rare flourish, so its ABSENCE never has to be interpreted.
	///
	/// ⛔ NOT FOR THE BEAR. The teddy is not on offer and has no rarity; `OfferRarity` already
	/// returns 0 for it, but `_offerRarity` itself can be non-zero, so the guard has to be here.
	/// SettleOffer's teddy branch returns before this is reached — this is belt and braces for any
	/// future caller.
	/// </summary>
	void ApplyRarityOutline()
	{
		// ⚠️ NO TIER FLOOR — tier 0 is grey, not "skip". Only the bear and a missing object bail.
		if ( IsTeddy || !_offerGO.IsValid() ) return;

		// ⚠️ The highlight system needs its camera component; this calls it rather than assuming
		// chalk has already run.
		WallBuyManager.EnsureHighlight( Scene );

		// ⛔ ON THE OFFER OBJECT, NOT THE BOX. ClearOffer destroys that GameObject, which takes the
		// outline with it — one parented to the box would survive the teddy and the lid closing,
		// leaving a coloured silhouette around nothing.
		var outline = _offerGO.Components.GetOrCreate<HighlightOutline>();
		outline.Color = Rarity.ColorFor( _offerRarity );
		outline.InsideColor = Color.Transparent;

		// ⚠️ VISIBLE THROUGH THE BOX. The gun rests partly behind the lid; an outline that vanished
		// for that stretch would flicker on exactly the frames the reveal is meant to land.
		outline.ObscuredColor = Rarity.ColorFor( _offerRarity ).WithAlpha( 0.55f );
		outline.InsideObscuredColor = Color.Transparent;
	}

	/// <summary>
	/// A weapon entry's display model, or null.
	///
	/// ⚠️ Uses the weapon's VIEWMODEL — `nz_galil` -> `weapons/galil/v_galil.vmdl`
	/// — because the ported weapons have no world model. A miss returns null rather
	/// than an error model: a box that offers an invisible gun is bad, one that
	/// offers a red ERROR is worse.
	///
	/// ⛔ TWO CANDIDATES, BECAUSE THE `nz_` CONVENTION HAS AN EXCEPTION. 31 of the
	/// 32 folders drop the prefix; **the MPL keeps it** — `weapons/nz_mpl/
	/// v_nz_mpl.vmdl`. Stripping unconditionally sent it to `weapons/mpl/v_mpl.vmdl`,
	/// so an MPL roll floated NOTHING: you paid 950, watched the lid open on empty
	/// air, and only found out you owned an MPL by pressing E. Found in the engine
	/// log, not by looking — `ERROR_FILEOPEN` scrolled past during a cycle.
	/// </summary>
	public static Model ModelFor( WeaponLibrary.Entry entry )
	{
		if ( entry is null || string.IsNullOrWhiteSpace( entry.Prefab ) ) return null;
		if ( _models.TryGetValue( entry.Prefab, out var cached ) ) return cached;

		var raw = entry.Prefab;
		var slash = raw.LastIndexOf( '/' );
		if ( slash >= 0 ) raw = raw[(slash + 1)..];
		raw = raw.Replace( ".prefab", "" );

		var bare = raw.StartsWith( "nz_" ) ? raw[3..] : raw;

		Model found = null;
		foreach ( var stem in new[] { bare, raw } )
		{
			// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`): an MW viewmodel's bind pose floats in pieces
			var m = WeaponDisplay.Load( $"weapons/{stem}/v_{stem}.vmdl" );
			if ( m is not null && !m.IsError ) { found = m; break; }
		}

		return _models[entry.Prefab] = found;
	}

	/// <summary>
	/// Prefab -> viewmodel, misses included.
	///
	/// ⚠️ CACHING THE NULLS IS THE POINT. The cycle asks up to twenty times a
	/// second, so a weapon with no viewmodel had the engine printing
	/// `ERROR_FILEOPEN` at that rate — console noise loud enough to bury whatever
	/// you were actually reading, from a case the code already handles correctly.
	/// </summary>
	static readonly Dictionary<string, Model> _models = new();

	/// <summary>
	/// Destroy the floating weapon and its outline.
	///
	/// ⛔ DO NOT RESET `_offerRarity` HERE. It looks like the obvious home for it and
	/// it would break the outline outright: StartRise calls this method FIRST, and the
	/// rarity was already rolled back at the buy — so clearing it here would zero the
	/// tier moments before StartRise reads it to colour the highlight. Every offer
	/// would come up Common-coloured, and the roll itself would still be correct, so
	/// the fault would look like the outline code rather than this line.
	///
	/// The two places that finish an offer — Take and ExpireOffer — clear it
	/// themselves, after they are done reading it.
	/// </summary>
	void ClearOffer()
	{
		// ⚠️ The HighlightOutline goes with it: it is a component ON _offerGO, not on
		// the box, so destroying the object takes the silhouette with it. An outline
		// parented to the box would outlive the teddy and the lid closing.
		_offerGO?.Destroy();
		_offerGO = null;
		_offerRenderer = null;
	}

	/// <summary>
	/// Re-apply the pose to a weapon that is already floating. For `nz_box_offer`.
	///
	/// ⚠️ Only touches ROTATION mid-rise. Snapping the height while the climb is
	/// running would fight TickRise for the same frame and jitter — the new height
	/// arrives on its own when the lerp's target is read next frame.
	/// </summary>
	public void RefreshOffer()
	{
		if ( !_offerGO.IsValid() ) return;

		// ⚠️ Whichever pose this object is actually wearing — the bear has its own,
		// and refreshing it with the weapon pose would snap it back sideways the
		// moment any unrelated tuning command touched the offer.
		var pose = IsTeddy ? TeddyAngles : OfferPose;

		_offerGO.LocalRotation = pose.ToRotation();

		// ⛔ POSITION ONLY FOR THE BEAR. A weapon in Held is being lerped downward
		// every frame by TickLower, and writing OfferHeight here would fight it for
		// the same frame — the gun would jump back to the top the moment any tuning
		// command touched the offer. The bear does not descend, so nothing else owns
		// its position and this is the only thing that can move it.
		if ( Lid == LidState.Held && IsTeddy )
			_offerGO.LocalPosition = OfferLocalPos( OfferHeight, pose );
	}

	/// <summary>
	/// Take the offered weapon. Closes the lid.
	///
	/// ⚠️ Closing on TAKE rather than letting the timer run out is what makes the
	/// grab feel like a grab — the box reacts to the player instead of ignoring
	/// them.
	/// </summary>
	public string Take( NZPlayer player )
	{
		if ( !HasOffer || !player.IsValid() ) return "";

		var name = _offer.Name;
		var prefab = _offer.Prefab;
		var rolled = _offerRarity;

		// ⛔ THE RARITY IS STORED BEFORE Give, NOT AFTER. GiveWeapon spawns the weapon
		// and ApplyStoredUpgrades stamps the multipliers onto it during that call — so
		// writing the tier afterwards would leave the gun you are now holding on the
		// OLD multiplier until the next equip. §13: changing the data is not changing
		// the world, and here the order is the whole difference.
		//
		// ⚠️ TAKES THE MAX, NEVER DOWNGRADES. This is a deviation the prefab keying
		// forces: the original stores rarity per weapon ENTITY, so its box AK and your
		// Legendary AK are different objects. Ours share one key, and a Common roll
		// overwriting a tier you paid 9,500 salvage for would be the single most
		// expensive bug in the system. Upgrading is a gift; downgrading is a theft.
		if ( rolled > 0 && !string.IsNullOrEmpty( prefab ) )
		{
			var had = player.RarityTierFor( prefab );
			if ( rolled > had )
			{
				player.SetRarityTier( prefab, rolled );
				Log.Info( $"[nz-rarity] the box handed over a "
					+ $"{Rarity.NameFor( rolled ).ToUpper()} {name}"
					+ $" — damage x{Rarity.Mult( rolled ):0.##}" );
			}
			else
			{
				Log.Info( $"[nz-rarity] box rolled {Rarity.NameFor( rolled )} but this "
					+ $"weapon is already {Rarity.NameFor( had )} — kept the better one" );
			}
		}

		Give( player, prefab );

		_offer = null;
		_offerRarity = 0;
		ClearOffer();
		CloseLid();

		// ⛔ AND EVERY OTHER MACHINE SHUTS ITS LID NOW (`NZNet.BoxTaken`, the co-op pass, 2026-09-28) — not when its own hold ran out,
		// fifteen seconds of a lid nobody else could buy from
		NZNet.BoxTaken( WorldPosition );

		NZSound.Play( NZSound.Purchase, WorldPosition );
		return $"took the {name}";
	}

	/// <summary>The lid shut on it. The points are spent and the weapon is gone.</summary>
	void ExpireOffer()
	{
		if ( _offer is null ) return;

		// ⚠️ Silent for the bear. `_offer` still holds the weapon that WOULD have
		// come up — the roll happens before the teddy check — so the usual line
		// would name a gun the player never saw and never had a chance at.
		if ( !IsTeddy ) Log.Info( $"[nz] the box closed on the {_offer.Name}" );

		_offer = null;
		_offerRarity = 0;
		ClearOffer();
	}

	void CloseLid()
	{
		if ( !_renderer.IsValid() ) return;

		// ⚠️ Rate restored before the clip is named — the open stage left it at 0,
		// and a close played at zero speed is a lid that never shuts.
		_renderer.PlaybackRate = 1f;
		_renderer.Sequence.Name = "close";
		SkinSound( _skin.CloseSound );

		// ⚠️ THE TUNE PLAYS ON TO ITS END (2026-09-28) — see StopJingle

		Lid = LidState.Closing;
		_lidNext = SequenceLength( "close", 0.75f );
	}

	// ── the jingle ───────────────────────────────────────────────────────────

	SoundHandle _jingle;

	/// <summary>
	/// Cut the tune — before a new spin starts one, and when the box goes.
	///
	/// ⛔ NOT WHEN THE LID SHUTS ANY MORE (2026-09-28). The tune plays to its end, as the original lets all 7.18 s play out:
	/// *"make the box jingle not be interrupted by picking up a weapon, so it always plays untill the end"*. It used to be cut
	/// on the grab — a deliberate divergence, since a tune from a shut crate tells the room there is still something to run to —
	/// and the user preferred the original.
	/// </summary>
	void StopJingle()
	{
		if ( _jingle.IsValid() ) _jingle.Stop();
		_jingle = default;
	}

	protected override void OnDestroy()
	{
		StopJingle();
		StopHum();
	}

	// ── the lid ──────────────────────────────────────────────────────────────

	/// <summary>
	/// What the lid is doing. Closed is the only state that accepts a buy.
	///
	/// ⚠️ A STATE MACHINE, not a chain of awaits. The box can be destroyed
	/// mid-sequence (nz_box_clear, a round restart, the teddy moving it) and an
	/// async continuation would come back to a dead component holding a stale
	/// renderer. A state plus a TimeUntil simply stops being ticked.
	/// </summary>
	public enum LidState { Closed, Opening, Rising, Held, Closing, Leaving, Arriving }

	[Property, ReadOnly] public LidState Lid { get; private set; } = LidState.Closed;

	/// <summary>
	/// How long the weapon takes to sink back into the crate. The grab window.
	///
	/// ⛔ THE DESCENT **IS** THE WINDOW — there is no separate hold. The weapon
	/// starts sinking the instant it is revealed and the lid shuts when it lands, so
	/// how much time you have left is legible from across the room instead of being
	/// a hidden timer. The original works the same way: `DoWeaponFall` is called
	/// immediately after `DoFinalSelection` and the box closes when the `lower`
	/// animation ends (random_box_windup:369-383).
	///
	/// ⚠️ 15s, where the static hold was 4. A sinking weapon can afford to be
	/// generous in a way a frozen one cannot: the pressure comes from watching it
	/// go, not from the clock running out on something that looks settled.
	/// </summary>
	[Property] public float HoldTime { get; set; } = 15f;

	TimeUntil _lidNext;

	/// <summary>Drive the lid. Measured: open 1.04s, close 0.75s.</summary>
	protected override void OnUpdate()
	{
		TickHum();

		// ⚠️ The climb runs OUTSIDE the timer gate below — that gate only fires on
		// the frame a stage ends, and a rise driven from inside it would be a weapon
		// that teleports to the top once.
		TickRise();
		TickLower();

		// ⚠️ OUTSIDE the stage gate below, like the climb. That gate fires only on
		// the frame a stage ENDS, and this has to land 0.25s INTO the leave.
		if ( _byePending && _byeAt )
		{
			_byePending = false;
			NZSound.Play( NZSound.BoxBye );
		}

		if ( Lid == LidState.Closed ) return;
		if ( !_renderer.IsValid() ) return;
		if ( !_lidNext ) return;

		switch ( Lid )
		{
			case LidState.Opening:
				// ⛔ FROZEN EXPLICITLY. The vmdl declares `looping = false` and the
				// renderer cycles the clip anyway — so the lid played its opening
				// over and over while the weapon sat there. The flag describes the
				// ASSET; it does not stop SkinnedModelRenderer from advancing time.
				//
				// ⚠️ Time is pinned to the end BEFORE the rate is zeroed. Zeroing
				// alone freezes on whatever frame the timer happened to land on,
				// which is near the end but not reliably AT it — and a lid stopped
				// nine tenths open reads as a lid that is stuck.
				_renderer.Sequence.Time = _renderer.Sequence.Duration;
				_renderer.PlaybackRate = 0f;

				// ⚠️ The weapon starts climbing only once the lid is out of the way.
				// Beginning it with the buy would have the gun pass through a
				// closing-height lid on every roll.
				StartRise();

				Lid = LidState.Rising;
				// ⚠️ _riseSpan, set by StartRise() one line above — the stage timer
				// has to end when the climb does, or a Timeslip box settles and then
				// sits there waiting out the unscaled remainder.
				_lidNext = _riseSpan;
				break;

			case LidState.Rising:
				SettleOffer();

				Lid = LidState.Held;
				_sinceHeld = 0f;

				// ⚠️ The bear gets its OWN, much shorter hold. HoldTime is the length
				// of a descent nothing here performs — holding the bear up for the
				// full fifteen seconds is fifteen seconds of a player waiting to be
				// told what they already know.
				_lidNext = IsTeddy ? TeddyHold : HoldTime;
				break;

			case LidState.Held:
				// Nobody took it in time — or it was never theirs to take.
				ExpireOffer();
				CloseLid();
				break;

			case LidState.Closing:
				// ⛔ THE BEAR'S EXIT RUNS OFF THE BACK OF THE CLOSE, not off the
				// reveal. The lid has to be shut before the crate can animate away,
				// and driving the move from the teddy's own timer would start it
				// mid-close.
				if ( IsTeddy ) { StartLeaving(); break; }

				PlayClip( "idle" );
				Lid = LidState.Closed;
				break;

			case LidState.Leaving:
				Relocate();
				break;

			case LidState.Arriving:
				PlayClip( "idle" );
				Lid = LidState.Closed;
				IsTeddy = false;
				break;
		}
	}

	// ── going somewhere else ─────────────────────────────────────────────────

	/// <summary>How long the bear sits there before the lid shuts on it.</summary>
	[Property] public float TeddyHold { get; set; } = 2.5f;

	/// <summary>How long a client's box, gone, waits for the host's word on where it went before going by the last one (`Relocate`).</summary>
	public const float HostMoveWait = 5f;

	TimeSince _hostWait;
	bool _hostWaiting;

	/// <summary>Hold a renderer on its clip's last frame: a clip whose time runs on starts over, and the crate would rise again.</summary>
	static void Freeze( SkinnedModelRenderer r )
	{
		if ( !r.IsValid() ) return;

		r.Sequence.Time = r.Sequence.Duration;
		r.PlaybackRate = 0f;
	}

	TimeUntil _byeAt;
	bool _byePending;

	/// <summary>
	/// Play the crate's exit.
	///
	/// ⚠️ The "Bye" announcer is delayed 0.25s rather than fired now, exactly as
	/// `MoveAway` does it (random_box/shared.lua:372) — immediately, it lands on top
	/// of the teddy's laugh and the two just muddy each other.
	/// </summary>
	void StartLeaving()
	{
		if ( !_renderer.IsValid() ) return;

		_renderer.PlaybackRate = 1f;
		PlayClip( "leave" );
		SkinSound( _skin.LeaveSound );

		_byeAt = 0.25f;
		_byePending = true;

		Lid = LidState.Leaving;
		_lidNext = SequenceLength( "leave", 1.5f );
	}

	/// <summary>
	/// Put the crate at a different spot and play its arrival.
	///
	/// ⚠️ THE SAME COMPONENT MOVES — it is not destroyed and rebuilt. A rebuild
	/// would drop this state machine mid-sequence and there would be nothing left
	/// to play `arrive` on.
	/// </summary>
	void Relocate()
	{
		// ⛔ A CLIENT WAITS FOR THE HOST'S WORD ON WHERE (the co-op pass, 2026-09-28). Each machine runs this sequence on its own clock,
		// and a client that rolled its own spot here — or took the host's while its bear still laughed, then rolled again — sent the
		// box somewhere else, three times in four on basalt's five spots, and nothing ever put it right. Sunk out of sight in its
		// base, or poofed away, it waits unseen, held on the clip's last frame (a clip left running starts over), for up to
		// `HostMoveWait`; then it goes where it last heard, and a late word corrects it (`MysteryBoxManager.PlaceAt`).
		var mgr = MysteryBoxManager.Instance;
		if ( mgr.IsValid() && !mgr.HostMoveReady )
		{
			if ( !_hostWaiting )
			{
				_hostWaiting = true;
				_hostWait = 0f;
				Freeze( _renderer );
				Freeze( _base );
			}

			if ( _hostWait < HostMoveWait )
			{
				_lidNext = 0.1f;
				return;
			}

			Log.Warning( $"[nz-box] no word from the host on where the box went in {HostMoveWait:0} s — going by the last one" );
		}

		_hostWaiting = false;
		if ( _renderer.IsValid() ) _renderer.PlaybackRate = 1f;

		// ⚠️ A SKIN WITH ITS OWN LEAVE SOUND PLAYED IT AS THE CRATE SANK (`StartLeaving`); the original poofs here
		if ( string.IsNullOrEmpty( _skin.LeaveSound ) ) NZSound.Play( NZSound.BoxPoof, WorldPosition );

		// ⚠️ Set BEFORE the move, because the teddy ladder reads it — the harsher
		// 30%/50% bands only open once the box has relocated at least once.
		HasMoved = true;

		var moved = MysteryBoxManager.Instance.IsValid()
			&& MysteryBoxManager.Instance.MoveBox( this );

		// ⚠️ The grace window starts at the ARRIVAL, so it is set from the result of the move
		// rather than from the intention to move. The `!moved` branch below says why.
		if ( moved ) UsesSinceMove = 0;

		if ( !moved )
		{
			// Nowhere to go. Sit back down rather than vanish.
			// ⛔ AND NO GRACE WINDOW. `UsesSinceMove` is deliberately NOT reset here: the box is
			// still standing where it was, so the player got none of the walk that the safe rolls
			// are meant to pay for. Resetting on the attempt rather than the arrival would hand
			// out a free window every time the only other spot was occupied.
			Log.Warning( "[nz] the box has nowhere to move to — staying put" );
			PlayClip( "idle" );
			Lid = LidState.Closed;
			IsTeddy = false;
			return;
		}

		NZSound.Play( NZSound.MapCue( _skin.ArriveSound, NZSound.BoxPoof ), WorldPosition );

		PlayClip( "arrive" );
		Lid = LidState.Arriving;
		_lidNext = SequenceLength( "arrive", 1.5f );
	}

	/// <summary>Start the lid opening. Ignored unless it is closed.</summary>
	void OpenLid()
	{
		if ( !_renderer.IsValid() ) return;

		_renderer.Sequence.Name = "open";
		SkinSound( _skin.OpenSound );
		Lid = LidState.Opening;
		_lidNext = SequenceLength( "open", 1.04f );
	}

	/// <summary>
	/// The current sequence's length, or a measured fallback.
	///
	/// ⚠️ Read AFTER setting Name, because Duration reports the sequence that is
	/// playing. The fallbacks are the values nz_box_anim actually printed, so a
	/// model that stops reporting durations degrades to the right timing rather
	/// than to zero — and a zero would make the state machine advance every frame.
	/// </summary>
	float SequenceLength( string name, float fallback )
	{
		var d = _renderer.Sequence.Duration;
		return d > 0.01f ? d : fallback;
	}

	/// <summary>
	/// Buy a roll. Returns what happened, for the log and the prompt.
	///
	/// ⚠️ The weapon is chosen BEFORE the points are spent but given AFTER — so a
	/// player who cannot afford it never sees a roll happen, and a roll that
	/// somehow finds no weapon does not silently charge them.
	/// </summary>
	public string Buy( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";

		// ⚠️ One roll at a time. Without this, holding the use key re-triggers
		// mid-animation: the lid snaps back to frame zero and the player is
		// charged again for a box that never visibly closed.
		if ( Lid != LidState.Closed ) return "";

		var pick = Roll( player );
		if ( pick is null ) return "the box is empty — no weapons in the library";

		// ⚠️ TrySpend fuses the check with the deduction, the same reason WallBuy
		// uses it: asking CanAfford separately is how you get free rolls.
		if ( !player.TrySpend( Price ) )
			return $"not enough points — the box costs {Price}";

		// ⚠ TIMESLIP m2 TIME OUT — AFTER THE SPEND SUCCEEDED, never before. A refused purchase
		// must not buy 15 seconds of invisibility; that is the same reasoning the box's own
		// `Uses++` comment gives two lines down.
		TimeAugments.OnMachineUsed( player, "the mystery box" );

		// ⚠️ COUNTED AFTER THE SPEND, so a refused buy does not push the box closer
		// to leaving. Someone walking up broke should not shorten its life.
		Uses++;
		UsesSinceMove++;

		// ⚠️ Decided HERE, with the roll, and not at the settle. The cycle has to run
		// for the same duration either way — a teddy that could be predicted from a
		// shorter spin would be readable before the reveal.
		IsTeddy = RollTeddy();
		_buyer = player;
		_shownRiseScale = 1f;

		// ⛔ THE WEAPON IS OFFERED, NOT GIVEN. It hangs over the open box until the
		// player takes it or the lid shuts on it — that choice IS the mystery box.
		// Handing it over on payment turns 950 points into a vending machine.
		//
		// ⚠️ The pick is decided HERE and only revealed when the rise ends. The
		// cycling models on the way up are pure theatre and roll nothing — a
		// player who could act on what flickers past would be reading the result
		// early, and a result chosen at the top could not have been paid for.
		_offer = pick;

		// ⚠️ Rolled here WITH the weapon, for the reason on _offerRarity. Reads the
		// live round so the gates mean what they say, and falls to 0 with no round
		// manager — a console spin on an unstarted game gives Common, not a crash.
		_offerRarity = Rarity.RollForRound(
			RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0 );

		OpenLid();

		// ⚠️ On the BUY, not on the rise — the tune covers the lid opening as well,
		// which is what makes the 1.04s before anything moves feel like part of the
		// sequence instead of a delay. The original starts it at the same moment
		// (random_box/shared.lua:286).
		StopJingle();
		// ⚠️ THE MAP'S OWN SPIN, IF IT HAS ONE (`Gameplay.BoxSpinSound`, 2026-09-27) — basalt's is made for it
		_jingle = NZSound.Play( NZSound.MapCue( ActiveConfig.Gameplay.BoxSpinSound, NZSound.BoxJingle ), WorldPosition );

		// ⛔ NO SEPARATE PURCHASE BLIP HERE. TrySpend already plays one on every
		// successful spend (NZPlayer:773) and WallBuy relies on exactly that, so the
		// box playing its own made it the one buyable that double-blipped. Inaudible
		// until the jingle landed on top of it; `nz_sound_trace` showed three cues
		// stacked on one keypress.
		//
		// ⚠️ Take still plays one — that is the GRAB confirming, and Take spends
		// nothing, so it is the only sound that moment makes.
		// ⛔ AND TELL EVERYONE ELSE, OR THE BOX ONLY OPENS FOR THE BUYER. Reported as *"the box
		// does not open for all players"*. Nothing about this object was networked: the lid, the
		// roll and the teddy were all decided and played locally, so a teammate standing at the
		// box watched a closed crate while somebody bought from it.
		//
		// ⚠️ THE ROLL TRAVELS, NOT A "PLAY THE ANIMATION" SIGNAL. Every machine has the same
		// `WeaponLibrary`, but each would roll a DIFFERENT weapon out of it — the same mistake the
		// box's own spot placement made. Sending what came out is what makes the two screens agree.
		//
		// ⚠️ BROADCAST UNCONDITIONALLY, INCLUDING BACK TO THIS MACHINE, because `ShowRoll`
		// early-returns unless the lid is Closed — and on the buyer's machine `OpenLid` already ran.
		// The guard the remote path already needs is the same guard that makes the echo harmless, so
		// there is no sender id to pass and nothing to keep in step.
		//
		// ⚠️ WITH WHERE, THE TIER AND THE CLIMB (the co-op pass, 2026-09-28): a fire sale's buy opened every box on every other
		// machine, the offer's outline came up grey there, and a Timeslip buyer's reveal landed early.
		NZNet.BoxRolled( pick.Prefab, IsTeddy, WorldPosition, _offerRarity, RiseScaleOf( player ) );

		return $"rolled {pick.Name} for {Price} — take it before it closes";
	}

	/// <summary>
	/// Play somebody else's roll. Visuals and sound only — no charge, no offer to take.
	///
	/// ⛔ `_buyer` IS DELIBERATELY LEFT NULL, AND THAT IS WHAT KEEPS THIS HONEST. The weapon
	/// hanging over the box is a picture on every machine except the one that paid for it; `Take`
	/// is gated on the buyer, so a teammate can watch the roll and cannot walk off with it.
	///
	/// ⚠️ THE LID GUARD IS LOAD-BEARING IN BOTH DIRECTIONS. It stops a second roll landing
	/// mid-animation, exactly as `Buy`'s own copy does — and it is what lets the broadcast echo
	/// back to the buyer with no effect instead of restarting their lid at frame zero.
	///
	/// ⚠️ AN UNKNOWN PREFAB IS NOT AN ERROR WORTH ABORTING ON when the roll was a teddy: the
	/// bear has no weapon and the sequence still has to play. A missing weapon with NO teddy is a
	/// real mismatch and is refused rather than shown as an empty box.
	/// </summary>
	public void ShowRoll( string prefab, bool teddy, int rarity, float riseScale )
	{
		if ( Lid != LidState.Closed ) return;

		_offer = WeaponLibrary.All.FirstOrDefault( e => e.Prefab == prefab );
		if ( _offer is null && !teddy ) return;

		IsTeddy = teddy;
		_buyer = null;

		// ⚠️ THE TIER FOR THE OUTLINE ONLY — nothing is taken here (`HasOffer`) — and the buyer's climb (the co-op pass, 2026-09-28)
		_offerRarity = rarity;
		_shownRiseScale = MathF.Max( 0f, riseScale );

		// ⚠️ AND COUNTED, AS THE BUYER'S OWN MACHINE COUNTS IT (`Buy`). The bear's odds read these, and a teammate's buys never
		// reached them: the first three rolls were always safe on each machine however often anyone else had used the box.
		Uses++;
		UsesSinceMove++;

		OpenLid();

		StopJingle();
		// ⚠️ THE MAP'S OWN SPIN, IF IT HAS ONE (`Gameplay.BoxSpinSound`, 2026-09-27) — basalt's is made for it
		_jingle = NZSound.Play( NZSound.MapCue( ActiveConfig.Gameplay.BoxSpinSound, NZSound.BoxJingle ), WorldPosition );
	}

	/// <summary>
	/// The buyer took the offer (`NZNet.BoxTaken`): the lid shuts on the picture here too, now. ⚠️ THE TAKER'S OWN COPY FINDS ITS LID
	/// ALREADY CLOSING and does nothing — `ShowRoll`'s guard, for the same echo.
	/// </summary>
	public void ShowTaken()
	{
		if ( IsTeddy || Lid is not (LidState.Rising or LidState.Held) ) return;

		_offer = null;
		_offerRarity = 0;
		ClearOffer();
		CloseLid();
	}

	/// <summary>
	/// Pick a weapon.
	///
	/// ⚠️ EXCLUDES WHAT THE PLAYER IS ALREADY HOLDING. Paying 950 for the gun in
	/// your hands is the single most annoying outcome the box can produce, and
	/// with a 31-weapon pool it would happen roughly one roll in thirty.
	/// </summary>
	WeaponLibrary.Entry Roll( NZPlayer player )
	{
		var pool = Pool();
		if ( pool.Count == 0 ) return null;

		var held = player.StartingWeapon;
		if ( !string.IsNullOrWhiteSpace( held ) && pool.Count > 1 )
			pool.RemoveAll( e => e.Prefab == held );

		return Game.Random.FromList( pool );
	}

	// ── the bear ─────────────────────────────────────────────────────────────

	/// <summary>
	/// How many times the box has been bought this game.
	///
	/// ⚠️ PER GAME, NOT PER BOX — `nzRandomBox:GetBoxUses()` is a single counter
	/// even though the box exists at one spot at a time. It is what the whole teddy
	/// ladder reads, so putting it on the component would reset the odds every time
	/// the box moved, which is the exact moment they are supposed to get worse.
	/// </summary>
	public static int Uses { get; set; }

	/// <summary>Has the box moved at least once this game? Gates the harsher odds.</summary>
	public static bool HasMoved { get; set; }

	/// <summary>
	/// Buys since the box last arrived somewhere new.
	///
	/// ⚠️ A SECOND COUNTER, NOT A RESET OF <see cref="Uses"/>. `Uses` deliberately never resets on
	/// a move — the whole teddy ladder reads it, and zeroing it would hand the box its early-game
	/// safety back every time it relocated, which is the exact moment the odds are supposed to be
	/// getting worse. This one exists only to give the player a short guaranteed window at the new
	/// spot, and it leaves the ladder alone.
	/// </summary>
	public static int UsesSinceMove { get; set; }

	/// <summary>Force the next roll to be the bear. For `nz_box_teddy`.</summary>
	public static bool ForceTeddy { get; set; }

	/// <summary>
	/// Put the teddy ladder back to how a game starts. Called by `RoundManager.StartGame`.
	///
	/// ⛔ THESE ARE STATICS AND NOTHING WAS CLEARING THEM. `Uses`, `HasMoved` and `UsesSinceMove`
	/// are deliberately not on the component — the box exists at one spot at a time and a
	/// per-component counter would reset every time it moved, which is the exact moment the odds
	/// are supposed to be getting worse. The cost of that choice is that they outlive the match:
	/// a second game in the same session began at whatever the first one finished on, so a fresh
	/// box could hand out the bear on its fourth buy with the ladder already at 50%.
	///
	/// ⚠️ `ForceTeddy` too. A `nz_box_teddy` armed and never spent would otherwise fire on the
	/// first buy of the next game, which reads as the new rules being broken rather than as a
	/// leftover debug flag.
	///
	/// ⚠️ Deliberately NOT clearing `IsTeddy` — that is per-component and describes the offer
	/// currently on the crate, which `Rebuild` is about to destroy anyway.
	/// </summary>
	public static void ResetRun()
	{
		Uses = 0;
		UsesSinceMove = 0;
		HasMoved = false;
		ForceTeddy = false;
	}

	/// <summary>The bear, and the only thing that can be rolled that is not a gun.</summary>
	[Property] public string TeddyModel { get; set; } = "models/nz/magicbox/teddy.vmdl";

	/// <summary>
	/// How the bear is angled. Separate from <see cref="OfferAngles"/>.
	///
	/// ⛔ IT CANNOT SHARE THE WEAPONS' POSE. Those are yawed 90 to lie broadside
	/// across the crate, which on a bear means showing the player its side. Two
	/// different models with two different authored facings need two values — the
	/// weapon pose was tuned for a gun lying down, this one is for something sitting
	/// up and looking at you.
	/// </summary>
	[Property] public Angles TeddyAngles { get; set; } = new( 0f, 180f, 0f );

	/// <summary>Is the thing on offer the bear rather than a weapon?</summary>
	public bool IsTeddy { get; private set; }

	/// <summary>
	/// Should this roll be the bear? A port of `nzRandomBox.DecideWep`.
	///
	/// ⛔ THE LADDER IS THE POINT, not the percentages. The box is SAFE for the
	/// first few buys and gets steadily more likely to leave the longer it stays,
	/// which is what stops a team camping one spot all game. Numbers from
	/// sv_random_box.lua:57-99: never at or below `MinUses`, 15% up to 60% of
	/// `MaxUses`, then 30% and 50% once it has moved at least once.
	///
	/// ⛔ GUARANTEED once the box has NEVER moved and is past `MaxUses*0.6`. That
	/// branch reads oddly in the lua — a `chanceofjoker = 100` that later branches
	/// only overwrite when `GetBoxMoved()` — but it is deliberate: the FIRST move is
	/// never left to chance, or a whole match can pass without the box relocating.
	///
	/// ⛔ NEVER WITH ONLY ONE SPOT. The original checks
	/// `ents.FindByClass("random_box_spawns") > 1` for the obvious reason — a bear
	/// that sends the box nowhere is 950 points taken for nothing at all.
	/// </summary>
	static bool RollTeddy()
	{
		if ( ForceTeddy ) { ForceTeddy = false; return true; }

		// ⛔ NEVER DURING A FIRE SALE, for the same reason the one-spot check below exists: a bear
		// takes the box away, and a fire sale is thirty seconds of every box being open at ten
		// points. Sending the box off mid-sale would end the powerup early for whoever was standing
		// at it — the one moment the box is guaranteed to be worth using. The original guards this
		// the same way; a sale is a window, and a window that can close itself is not one.
		//
		// ⚠️ CHECKED HERE RATHER THAN AT THE CALL SITE so every path that rolls gets it, including
		// the console spin commands.
		if ( PowerupEffects.FireSale ) return false;

		int spots = ActiveConfig.Current?.Boxes?.Count ?? 0;
		if ( spots <= 1 ) return false;

		if ( Uses <= MinUses ) return false;

		// ⛔ AND A SHORT WINDOW AT EVERY NEW SPOT. `MinUses` only protects the START of a game;
		// after a move the ladder resumes at whatever `Uses` has climbed to, so a box that has
		// been bought fifteen times can leave again on the very first buy at its new home. The
		// player has just walked across the map to find it — this buys them a couple of rolls
		// before it is allowed to do that.
		if ( UsesSinceMove <= SafeRollsAfterMove ) return false;

		int roll = Game.Random.Int( 1, 100 );

		if ( Uses <= (int)MathF.Round( MaxUses * 0.6f ) )
			return !HasMoved && Uses >= (int)MathF.Round( MaxUses * 0.6f )
				|| roll < (int)MathF.Round( MaxTeddyPercent * 0.3f );

		// Past the early band and still never moved: stop asking.
		if ( !HasMoved ) return true;

		return Uses <= MaxUses
			? roll < (int)MathF.Round( MaxTeddyPercent * 0.6f )
			: roll < MaxTeddyPercent;
	}

	/// <summary>Buys before the bear is possible at all. `minboxhit`, default 3.</summary>
	public static int MinUses { get; set; } = 3;

	/// <summary>
	/// Buys at a new spot that cannot be the bear, counted from the box arriving.
	///
	/// ⚠️ Separate from <see cref="MinUses"/> and it has to be: that one is measured against the
	/// game-long `Uses` and so only ever protects the opening rolls. This is measured against
	/// `UsesSinceMove` and applies every time the box relocates, however late in the game.
	/// </summary>
	public static int SafeRollsAfterMove { get; set; } = 2;

	/// <summary>Where the odds top out. `maxboxhit`, default 13.</summary>
	public static int MaxUses { get; set; } = 13;

	/// <summary>The ceiling the ladder scales against. `maxteddypercent`, default 50.</summary>
	public static int MaxTeddyPercent { get; set; } = 50;

	/// <summary>
	/// Swap the player onto the rolled weapon.
	///
	/// ⚠️ THE SAME SEQUENCE WallBuy USES, including the disable-and-unparent. A
	/// Destroy is deferred to end of frame, so the equip guard would otherwise
	/// still find the old weapon and silently skip spawning the new one.
	/// </summary>
	static void Give( NZPlayer player, string prefab )
	{
		// ⛔ NO LONGER DESTROYS EVERY WEAPON FIRST. That was correct while the
		// player had one slot — the old gun had to go before the new one could
		// spawn. With two slots it would throw away the weapon you were NOT
		// replacing. GiveWeapon adds to a free slot and only replaces the ACTIVE
		// one when both are full, which is the zombies convention.
		player.GiveWeapon( prefab );
	}

	/// <summary>
	/// The weapons this box can roll, after the config's pack filter.
	///
	/// ⚠️ An EMPTY filter means ALL — see MapConfig.BoxPacks. A filter that names
	/// packs which no longer exist yields nothing, so it falls back to the full
	/// list rather than handing the player an empty box: a stale config should
	/// degrade to "everything", not to "nothing".
	/// </summary>
	public static List<WeaponLibrary.Entry> Pool()
	{
		var all = WeaponLibrary.All
			.Where( e => !string.IsNullOrWhiteSpace( e.Prefab ) )
			.ToList();

		var packs = ActiveConfig.Current?.BoxPacks;
		if ( packs is null || packs.Count == 0 ) return all;

		var filtered = all.Where( e => packs.Contains( e.Pack ) ).ToList();
		return filtered.Count > 0 ? filtered : all;
	}

	/// <summary>The nearest box a point could use, or null.</summary>
	public static MysteryBox Near( Vector3 point )
	{
		MysteryBox best = null;
		float bestDist = float.MaxValue;

		foreach ( var b in All )
		{
			if ( !b.IsValid() ) continue;

			float d = b.WorldPosition.Distance( point );
			if ( d > b.Reach || d >= bestDist ) continue;

			bestDist = d;
			best = b;
		}

		return best;
	}
}