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.
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;
}
}