Console command helpers for the Mystery Box system. Provides many developer commands to place boxes, buy/take offers, tune timings and poses, inspect boxes, run odds/height reports, force teddy, move/clear boxes, and toggle debug/watch behavior.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// MYSTERY BOX/COMMANDS — a console equivalent for every box interaction.
///
/// ⚠️ STANDING RULE: every button gets a command. Nobody can click a UI button or
/// walk up to a crate over MCP, so a proximity-gated feature is otherwise
/// untestable remotely — it can only be looked at.
/// </summary>
public static class MysteryBoxCommands
{
/// <summary>
/// Place a box where you are looking: `nz_box`.
///
/// ⛔ DOES NOT GO THROUGH MapEditor. It used to, and MapEditor only exists in
/// CREATIVE — so from a Survival session this printed "no map editor" and did
/// nothing, which reads as the box being broken rather than the command being
/// unavailable. A dev command that only works in one mode cannot be used to
/// diagnose the other.
/// </summary>
[ConCmd( "nz_box" )]
public static void Place( float distance = 120f )
{
var scene = Game.ActiveScene;
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var controller = player.Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;
// ⛔ OUT THEN DOWN, NOT ALONG-THE-AIM-UNTIL-IT-HITS. This used to take the
// first surface the eye ray touched, which had two bad consequences: aiming
// anywhere near a wall MOUNTED THE BOX ON IT (a real one turned up with
// up=(-0.82,0.57,0), rising sideways), and every distance you passed landed
// on the same wall point — so `nz_box 120` and `nz_box 300` produced two
// spots at IDENTICAL coordinates and the box "moved" to where it already was.
//
// A box belongs on the floor in front of you. Go out along the aim, flattened
// to horizontal, then drop.
var ahead = eye + rot.Forward.WithZ( 0 ).Normal * distance;
var drop = scene.Trace.Ray( ahead + Vector3.Up * 64f, ahead + Vector3.Down * 512f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
var at = drop.Hit ? drop.HitPosition : ahead;
// ⚠️ Refuse a surface too steep to be a floor rather than silently mounting
// the box on it — a wall placement is never what was meant, and it is not
// obvious from a screenshot that it happened.
var normal = drop.Hit && drop.Normal.z > 0.7f ? drop.Normal : Vector3.Up;
if ( drop.Hit && drop.Normal.z <= 0.7f )
Log.Warning( $"[nz] surface at {at} is too steep to stand a box on "
+ $"(up={drop.Normal}) — placing level instead" );
var yaw = (player.WorldPosition - at).WithZ( 0 ).EulerAngles.yaw;
ActiveConfig.Current.Boxes.Add( new MysteryBoxSpot
{
Position = at,
Yaw = yaw,
// ⚠️ The floor's normal, so the box sits flat on a ramp.
Normal = normal,
} );
MysteryBoxManager.Ensure( scene )?.Rebuild();
Log.Info( $"[nz] box spot added at {at} facing {yaw:0}° "
+ $"({ActiveConfig.Current.Boxes.Count} total)" );
}
/// <summary>
/// Buy a roll from the nearest box: `nz_box_buy`.
///
/// ⚠️ Goes through the SAME Buy() the use key calls, so a passing test here is
/// evidence about the interaction and not just about the roll.
/// </summary>
[ConCmd( "nz_box_buy" )]
public static void Buy()
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
// ⚠️ Nearest box ANYWHERE, not the one in reach — the point of the command
// is to test the roll without walking, and refusing on distance would make
// it untestable for exactly the reason it exists.
var box = MysteryBox.All
.Where( b => b.IsValid() )
.OrderBy( b => b.WorldPosition.DistanceSquared( player.WorldPosition ) )
.FirstOrDefault();
if ( box is null ) { Log.Warning( "[nz] no box placed — nz_box to make one" ); return; }
Log.Info( $"[nz] {box.Buy( player )}" );
}
/// <summary>
/// Take whatever the nearest box is offering: `nz_box_take`.
///
/// ⚠️ The console half of the SAME key the player presses — TickUse routes to
/// Take when an offer is up and Buy when it is not, and this exercises the
/// Take side. Without it the grab is only reachable by standing at the box,
/// which no remote session can do.
/// </summary>
[ConCmd( "nz_box_take" )]
public static void TakeOffer()
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }
var box = MysteryBox.All.FirstOrDefault( b => b.IsValid() && b.HasOffer );
if ( box is null ) { Log.Info( "[nz] nothing on offer — nz_box_buy first" ); return; }
Log.Info( $"[nz] {box.Take( player )}" );
}
/// <summary>
/// How long the lid stays open: `nz_box_hold 4`.
///
/// ⚠️ The grab window, and the number most worth tuning by feel — too short
/// and a player who is fighting cannot reach it, too long and the gamble stops
/// being one. Applies to every live box.
/// </summary>
[ConCmd( "nz_box_hold" )]
public static void Hold( float seconds = -1f, float teddy = -1f )
{
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
{
if ( seconds >= 0f ) b.HoldTime = seconds;
if ( teddy >= 0f ) b.TeddyHold = teddy;
}
var first = MysteryBox.All.FirstOrDefault( x => x.IsValid() );
Log.Info( first is null
? "[nz] no boxes placed"
: $"[nz] weapon sinks back over {first.HoldTime:0.##}s — that IS the grab "
+ $"window (bear sits {first.TeddyHold:0.##}s and does not sink), "
+ $"after ~1.04s of lid and {first.RiseTime:0.##}s of rise" );
}
/// <summary>
/// How long the weapon climbs and cycles: `nz_box_rise 4.2`.
///
/// ⚠️ The suspense dial, and it sets BOTH the climb and the flicker — they are
/// one number on purpose. Worth tuning against the 7.18s jingle: too short and
/// the tune outlives the reveal, too long and the player is waiting on a gun
/// they can already guess from the slowdown.
/// </summary>
[ConCmd( "nz_box_rise" )]
public static void Rise( float seconds = -1f, float curve = -1f )
{
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
{
if ( seconds >= 0f ) b.RiseTime = seconds;
if ( curve >= 0f ) b.RiseCurve = curve;
}
var first = MysteryBox.All.FirstOrDefault( x => x.IsValid() );
if ( first is null ) { Log.Info( "[nz] no boxes placed" ); return; }
// ⚠️ Prints the HALFWAY FRACTION, not the exponent. "curve 6" says nothing
// about what the climb looks like; "87% up by halfway" is the number you
// are actually judging, and it is what tells you a change did anything.
float half = (1f - MathF.Pow( 2f, -first.RiseCurve * 0.5f ))
/ (1f - MathF.Pow( 2f, -first.RiseCurve ));
Log.Info( $"[nz] weapon rises and cycles for {first.RiseTime:0.##}s, "
+ $"curve {first.RiseCurve:0.#} ({half:P0} of the climb by halfway)" );
}
/// <summary>
/// Where and how the offered weapon sits: `nz_box_offer 26 0 0 0`.
///
/// ⛔ THIS EXISTS BECAUSE THE WEAPON STOPPED SPINNING. A rotating object looks
/// deliberate from every angle; a still one has exactly one correct pose, and
/// finding it is an eyeball job that would otherwise cost a recompile per
/// guess. Height first because it is the one that also has to clear the lid.
/// </summary>
[ConCmd( "nz_box_offer" )]
public static void Offer( float height = -999f, float pitch = -999f,
float yaw = -999f, float roll = -999f )
{
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
{
if ( height > -998f ) b.OfferHeight = height;
var a = b.OfferAngles;
if ( pitch > -998f ) a.pitch = pitch;
if ( yaw > -998f ) a.yaw = yaw;
if ( roll > -998f ) a.roll = roll;
b.OfferAngles = a;
// ⚠️ Applied to what is ALREADY floating, not just to the next roll.
// Tuning a pose you cannot see until you spend another 950 points is
// not tuning, it is guessing with a cooldown.
b.RefreshOffer();
}
var first = MysteryBox.All.FirstOrDefault( x => x.IsValid() );
Log.Info( first is null
? "[nz] no boxes placed"
: $"[nz] offer sits {first.OfferHeight:0.#}u up, "
+ $"angles {first.OfferAngles.pitch:0},{first.OfferAngles.yaw:0},"
+ $"{first.OfferAngles.roll:0} (rises from {first.RiseFrom:0.#}u)" );
}
/// <summary>What is placed and what it costs: `nz_boxes`.</summary>
[ConCmd( "nz_boxes" )]
public static void List()
{
if ( MysteryBox.All.Count == 0 ) { Log.Info( "[nz] no boxes placed" ); return; }
var player = NZPlayer.Local;
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
Log.Info( $"[nz] {b.Price} points "
+ (player.IsValid() ? $"{b.WorldPosition.Distance( player.WorldPosition ):0}u away" : "") );
Log.Info( $"[nz] pool: {WeaponLibrary.All.Count} weapon(s)" );
}
/// <summary>
/// Roll the pool N times without paying: `nz_box_odds [rolls]`.
///
/// ⚠️ THE POINT OF PHASE ONE. The question is whether a 31-weapon pool feels
/// good to gamble on, and that is a question about the DISTRIBUTION — which no
/// amount of buying one gun at a time will show. Prints what came up and how
/// often, so a pool full of pistols is visible before it is annoying.
/// </summary>
[ConCmd( "nz_box_odds" )]
public static void Odds( int rolls = 50 )
{
var pool = WeaponLibrary.All.Where( e => !string.IsNullOrWhiteSpace( e.Prefab ) ).ToList();
if ( pool.Count == 0 ) { Log.Warning( "[nz] weapon library is empty" ); return; }
var counts = new System.Collections.Generic.Dictionary<string, int>();
for ( int i = 0; i < rolls; i++ )
{
var pick = Game.Random.FromList( pool );
counts[pick.Name] = counts.GetValueOrDefault( pick.Name ) + 1;
}
Log.Info( $"[nz] {rolls} rolls over {pool.Count} weapons:" );
foreach ( var (name, n) in counts.OrderByDescending( kv => kv.Value ) )
Log.Info( $"[nz] {n,3}x {name}" );
Log.Info( $"[nz] {counts.Count} distinct, {pool.Count - counts.Count} never came up" );
}
/// <summary>
/// Why a box is not visible: `nz_box_debug`.
///
/// ⚠️ Reports the CHAIN, not a verdict — component, child object, renderer,
/// model, bounds, scale. An invisible box can fail at any link and the
/// symptom is identical at every one of them, so guessing which costs a
/// round trip each time.
/// </summary>
[ConCmd( "nz_box_debug" )]
public static void Debug()
{
Log.Info( $"[box] config spots: {ActiveConfig.Current.Boxes.Count}" );
Log.Info( $"[box] live components: {MysteryBox.All.Count}" );
var model = Model.Load( "models/nz/magicbox/magic_box.vmdl" );
Log.Info( $"[box] model loads: {model is not null}"
+ (model is not null ? $" error={model.IsError} bounds={model.Bounds.Size}" : "") );
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
{
Log.Info( $"[box] at {b.WorldPosition} enabled={b.GameObject.Enabled}" );
Log.Info( $"[box] lid={b.Lid} hasOffer={b.HasOffer} busy={b.IsBusy}"
+ (string.IsNullOrEmpty( b.OfferName ) ? "" : $" offering '{b.OfferName}'")
+ (b.OfferMeshHeight is float h
? $" mesh {h:0.0}u up (of {b.OfferHeight:0.#} -> {b.RiseFrom:0.#})"
: "") );
var r = b.Renderer;
if ( !r.IsValid() )
{
Log.Warning( "[box] NO RENDERER — BuildVisual did not run or was destroyed" );
continue;
}
Log.Info( $"[box] renderer enabled={r.Enabled} go='{r.GameObject.Name}'"
+ $" goEnabled={r.GameObject.Enabled}" );
Log.Info( $"[box] model={(r.Model is null ? "NULL" : r.Model.Name)}"
+ $" isError={(r.Model?.IsError.ToString() ?? "-")}" );
Log.Info( $"[box] worldPos={r.WorldPosition} scale={r.WorldScale}"
+ $" localPos={r.LocalPosition}" );
Log.Info( $"[box] sequence='{(r.Model is null ? "-" : r.Sequence.Name)}'" );
}
}
/// <summary>
/// Play a sequence on the nearest box and report its length:
/// `nz_box_anim open`.
///
/// ⚠️ Prints the DURATION, which is the number the buy sequence has to be
/// built around — a lid that takes 1.2s and a hold that assumes 0.5s produce
/// a box that closes while it is still opening.
/// </summary>
[ConCmd( "nz_box_anim" )]
public static void Anim( string sequence = "" )
{
var box = MysteryBox.All.FirstOrDefault( b => b.IsValid() );
if ( box is null ) { Log.Warning( "[box] none placed" ); return; }
var r = box.Renderer;
if ( !r.IsValid() || r.Model is null ) { Log.Warning( "[box] no renderer/model" ); return; }
if ( string.IsNullOrWhiteSpace( sequence ) )
{
Log.Info( $"[box] sequences: {string.Join( ", ", r.Sequence.SequenceNames )}" );
return;
}
r.Sequence.Name = sequence;
Log.Info( $"[box] playing '{r.Sequence.Name}' duration {r.Sequence.Duration:0.00}s" );
}
/// <summary>
/// Log every weapon the offer shows and where its mesh lands: `nz_box_watch 1`.
///
/// ⚠️ Fires on each cycle swap AND on the final settle, tagged `cycle` / `FINAL`.
/// The flicker runs up to twenty a second, so leave it on for one roll and turn
/// it off — this is a spike, not a monitor.
/// </summary>
[ConCmd( "nz_box_watch" )]
public static void WatchOffer( int on = -1 )
{
if ( on >= 0 ) MysteryBox.Watch = on != 0;
Log.Info( $"[nz] offer watch {(MysteryBox.Watch ? "ON" : "off")}"
+ (MysteryBox.Watch ? " — buy a roll; asked vs mesh z per weapon" : "") );
}
/// <summary>
/// Place the weapon by its mesh instead of its origin: `nz_box_align 0`.
///
/// ⚠️ The toggle for what `nz_box_heights` measures. Off, every weapon is put at
/// the same height and the Uzi and ASP hang ~50u above the rest; on, they are
/// all centred at OfferHeight and the other 29 shift by under 5u.
/// </summary>
[ConCmd( "nz_box_align" )]
public static void Align( int on = -1 )
{
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
{
if ( on >= 0 ) b.AlignToMesh = on != 0;
b.RefreshOffer();
}
var first = MysteryBox.All.FirstOrDefault( x => x.IsValid() );
Log.Info( first is null
? "[nz] no boxes placed"
: $"[nz] mesh alignment {(first.AlignToMesh ? "ON — centred on OfferHeight" : "off — origin at OfferHeight")}" );
}
/// <summary>
/// Measure the WHOLE pool at once: `nz_box_heights`.
///
/// ⛔ THE ANSWER TO "SOME WEAPONS SIT HIGHER", WITHOUT ROLLING FOR IT. Waiting
/// for a 32-weapon pool to show you its outliers is a lot of 950-point rolls and
/// you still would not know whether the one you saw was the worst. This reads
/// every viewmodel's bounds directly and sorts by the offset, so the outliers
/// are the top and bottom of one list.
///
/// ⚠️ Reports Z OF THE BOUNDS CENTRE, which is exactly what the placement
/// ignores: the offer sets the object's ORIGIN to OfferHeight, and the mesh
/// hangs wherever it was authored relative to that. A weapon with centre z of
/// -8 draws eight units lower than one at 0 from identical placement.
/// </summary>
[ConCmd( "nz_box_heights" )]
public static void Heights()
{
var pool = MysteryBox.Pool();
if ( pool.Count == 0 ) { Log.Warning( "[nz] weapon library is empty" ); return; }
var rows = new List<(string Name, float Z, float H, bool Ok)>();
foreach ( var e in pool )
{
var m = MysteryBox.ModelFor( e );
rows.Add( m is null
? (e.Name, 0f, 0f, false)
: (e.Name, m.Bounds.Center.z, m.Bounds.Size.z, true) );
}
var ok = rows.Where( r => r.Ok ).OrderByDescending( r => r.Z ).ToList();
if ( ok.Count == 0 ) { Log.Warning( "[nz] no viewmodels resolved" ); return; }
Log.Info( $"[nz] {ok.Count} viewmodels — bounds centre z, highest-drawing first:" );
foreach ( var r in ok )
Log.Info( $"[nz] {r.Name,-16} centre z {r.Z,7:+0.00;-0.00} height {r.H,6:0.0}" );
foreach ( var r in rows.Where( r => !r.Ok ) )
Log.Warning( $"[nz] {r.Name,-16} NO VIEWMODEL — rolls invisible" );
// ⚠️ The SPREAD is the number that says whether this needs fixing at all.
// A pool that all sits within a couple of units needs nothing; the gap
// between the extremes is how far apart two rolls can look.
float spread = ok[0].Z - ok[^1].Z;
float mean = ok.Average( r => r.Z );
Log.Info( $"[nz] spread {spread:0.0}u (highest {ok[0].Name} {ok[0].Z:+0.0;-0.0}, "
+ $"lowest {ok[^1].Name} {ok[^1].Z:+0.0;-0.0}, mean {mean:+0.0;-0.0})" );
}
/// <summary>
/// Make the next roll the bear: `nz_box_teddy`.
///
/// ⛔ WITHOUT THIS THE TEDDY IS BARELY TESTABLE. It cannot happen at all for the
/// first 3 buys and is 15% after that, so reaching one honestly costs thousands
/// of points and a dozen rolls — per attempt, on a sequence with a model, two
/// animations, three sounds, a refund and a relocation to get right.
/// </summary>
[ConCmd( "nz_box_teddy" )]
public static void Teddy()
{
MysteryBox.ForceTeddy = true;
Log.Info( "[nz] next roll is the bear — nz_box_buy" );
}
/// <summary>
/// Aim the bear: `nz_box_bear 0 180 0`.
///
/// ⚠️ The bear's own pose, NOT `nz_box_offer` — the weapons are yawed 90 to lie
/// broadside and that same value shows a bear its side. Applies live to one
/// already floating, so it can be judged rather than recompiled per guess.
/// </summary>
[ConCmd( "nz_box_bear" )]
public static void BearPose( float pitch = -999f, float yaw = -999f, float roll = -999f )
{
foreach ( var b in MysteryBox.All.Where( x => x.IsValid() ) )
{
var a = b.TeddyAngles;
if ( pitch > -998f ) a.pitch = pitch;
if ( yaw > -998f ) a.yaw = yaw;
if ( roll > -998f ) a.roll = roll;
b.TeddyAngles = a;
b.RefreshOffer();
}
var first = MysteryBox.All.FirstOrDefault( x => x.IsValid() );
Log.Info( first is null
? "[nz] no boxes placed"
: $"[nz] bear angles {first.TeddyAngles.pitch:0},{first.TeddyAngles.yaw:0},"
+ $"{first.TeddyAngles.roll:0}" );
}
/// <summary>
/// Send the box somewhere else right now: `nz_box_move`.
///
/// ⚠️ Skips the whole reveal and jumps to the relocation, so the arrive/leave
/// pair and the marker swap can be checked without sitting through a roll.
/// </summary>
[ConCmd( "nz_box_move" )]
public static void Move()
{
var box = MysteryBox.All.FirstOrDefault( b => b.IsValid() );
if ( box is null ) { Log.Warning( "[nz] no box placed" ); return; }
var mgr = MysteryBoxManager.Ensure( Game.ActiveScene );
Log.Info( mgr is not null && mgr.MoveBox( box )
? "[nz] moved"
: "[nz] nowhere to move to — place a second spot with nz_box" );
}
/// <summary>
/// The teddy ladder's state, and a way to wind it: `nz_box_uses [n]`.
///
/// ⚠️ Prints the CHANCE, not just the counter. "uses 7" says nothing on its own —
/// the odds depend on the count, on whether the box has ever moved, and on how
/// many spots exist, and all three have to line up for a teddy to be possible.
/// </summary>
[ConCmd( "nz_box_uses" )]
public static void UseCount( int n = -1 )
{
if ( n >= 0 ) MysteryBox.Uses = n;
int spots = ActiveConfig.Current?.Boxes?.Count ?? 0;
int uses = MysteryBox.Uses;
bool moved = MysteryBox.HasMoved;
string odds;
if ( spots <= 1 ) odds = "impossible — only one spot";
else if ( uses <= MysteryBox.MinUses ) odds = $"impossible — needs > {MysteryBox.MinUses} uses";
// ⚠️ `<` not `<=`, and no `+ 1`. The guard in RollTeddy tests UsesSinceMove AFTER the
// increment, so with u buys since the move the number of FUTURE buys still protected is
// `SafeRollsAfterMove - u`, which hits zero exactly when the next roll is live.
else if ( MysteryBox.UsesSinceMove < MysteryBox.SafeRollsAfterMove )
odds = $"impossible — {MysteryBox.SafeRollsAfterMove - MysteryBox.UsesSinceMove}"
+ " safe roll(s) left at this spot";
else if ( uses <= (int)MathF.Round( MysteryBox.MaxUses * 0.6f ) )
odds = $"{MathF.Round( MysteryBox.MaxTeddyPercent * 0.3f ):0}%";
else if ( !moved ) odds = "GUARANTEED — never moved and past the early band";
else if ( uses <= MysteryBox.MaxUses )
odds = $"{MathF.Round( MysteryBox.MaxTeddyPercent * 0.6f ):0}%";
else odds = $"{MysteryBox.MaxTeddyPercent}%";
Log.Info( $"[nz] box used {uses}x, moved={moved}, {spots} spot(s) — "
+ $"teddy chance: {odds}" );
}
/// <summary>Delete every placed box: `nz_box_clear`.</summary>
[ConCmd( "nz_box_clear" )]
public static void Clear()
{
int n = ActiveConfig.Current.Boxes.Count;
ActiveConfig.Current.Boxes.Clear();
MysteryBoxManager.Ensure( Game.ActiveScene )?.Rebuild();
Log.Info( $"[nz] removed {n} box(es)" );
}
}