Buyables/MysteryBoxCommands.cs

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.

File AccessNetworking
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)" );
	}
}