Buyables/PackAPunchCommands.cs

Console command helpers for Pack-a-Punch debugging and testing. Defines commands to place machines, buy/take guns, report status, set rounds/levels/timings/pose, run a shoot test, clear machines, and find the nearest machine.

File Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// PACK-A-PUNCH/COMMANDS — a console equivalent for every interaction.
///
/// ⚠️ STANDING RULE: every button gets a command. A proximity-gated machine that
/// also wants 30,000 points and a specific weapon in hand is otherwise untestable
/// over MCP — it can only be looked at.
/// </summary>
public static class PackAPunchCommands
{
	/// <summary>
	/// Place a machine on the floor ahead: `nz_pap`.
	///
	/// ⚠️ OUT THEN DOWN, like the fixed `nz_box`. Tracing along the aim until it
	/// hits mounts the machine on whatever wall you happened to be facing, and
	/// makes every distance land on the same point.
	/// </summary>
	[ConCmd( "nz_pap" )]
	public static void Place( float distance = 140f )
	{
		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;

		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;
		var normal = drop.Hit && drop.Normal.z > 0.7f ? drop.Normal : Vector3.Up;

		// ⚠️ Faces the player, so you can read it from where you placed it.
		var yaw = (player.WorldPosition - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.PackAPunches.Add( new PackAPunchSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal,
		} );
		PackAPunchManager.Ensure( scene )?.Rebuild();

		Log.Info( $"[nz] Pack-a-Punch placed at {at} facing {yaw:0} "
			+ $"({ActiveConfig.Current.PackAPunches.Count} total)" );
	}

	/// <summary>
	/// Feed the machine your weapon: `nz_pap_buy`.
	///
	/// ⚠️ Nearest machine ANYWHERE, not the one in reach — the point is to test the
	/// upgrade without walking, and refusing on distance would make it untestable
	/// for exactly the reason it exists.
	/// </summary>
	[ConCmd( "nz_pap_buy" )]
	public static void Buy()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var pap = Nearest( player );
		if ( pap is null ) { Log.Warning( "[nz] no machine — nz_pap to place one" ); return; }

		Log.Info( $"[nz] {pap.Insert( player )}" );
	}

	/// <summary>Collect the finished weapon: `nz_pap_take`.</summary>
	[ConCmd( "nz_pap_take" )]
	public static void Take()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var pap = PackAPunch.All.FirstOrDefault( m => m.IsValid() && m.HasFinishedGun );
		if ( pap is null ) { Log.Info( "[nz] nothing finished — nz_pap_buy first" ); return; }

		Log.Info( $"[nz] {pap.Collect( player )}" );
	}

	/// <summary>
	/// What is upgraded and by how much: `nz_pap_status`.
	///
	/// ⛔ PRINTS THE MULTIPLIER, not just the level. "MK2" says nothing about
	/// whether the damage actually changed — and the whole feature is the damage.
	/// This reads it back off the LIVE weapon's ShootInfo rather than recomputing
	/// it, so a multiplier that failed to reach the spawned instance shows up as a
	/// disagreement instead of being reported from the same number twice.
	/// </summary>
	[ConCmd( "nz_pap_status" )]
	public static void Status()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		int level = player.PapLevelFor( player.StartingWeapon );
		float expected = player.PapMultiplierFor( player.StartingWeapon );

		// ⚠️ THE LEVEL **IS** THE MK NUMBER — level 1 is MK1, and level 0 is not
		// "MK1", it is an unpacked gun. Reporting it as MK1 is how the off-by-one
		// that shipped in the first pass stayed invisible: every message agreed with
		// every other message, and all of them were one too high.
		Log.Info( $"[nz] holding '{player.StartingWeapon}' — "
			+ (level == 0 ? "not packed" : $"MK{level}")
			+ $", expected damage x{expected:0.##}" );

		// ⛔ MACHINES FIRST, AND NEVER BEHIND AN EARLY RETURN. This used to print the
		// weapon read-back first and `return` when there was no live weapon — which
		// is EXACTLY the state the machine puts you in while it holds your gun. The
		// diagnostic went blind at the only moment worth diagnosing, and reported
		// nothing at all about the cycle it was written to watch.
		foreach ( var m in PackAPunch.All.Where( x => x.IsValid() ) )
			Log.Info( $"[nz] machine at {m.WorldPosition}: {m.State}"
				+ $"  next costs {m.PriceFor( player )}"
				+ (m.GunOut is float g
					? $"  gun {g:0.0}u out (in {-m.GunInDepth:0.#} -> out {m.GunOutDistance:0.#})"
					: "") );

		// ⛔ THE ACTIVE WEAPON, NOT THE FIRST ONE FOUND. With two slots
		// `.FirstOrDefault()` returns whichever the component list happens to yield
		// first — usually the HOLSTERED gun — so the command compared the active
		// weapon's stored level against the other weapon's live multiplier and
		// reported a disagreement that did not exist. A diagnostic that reads the
		// wrong object is worse than none: it accuses working code.
		var active = player.Inventory.Active;
		var wep = active.IsValid()
			? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
			: null;

		if ( !wep.IsValid() || wep.Primary is null )
		{
			Log.Info( "[nz] no weapon in hand — expected while a machine holds it" );
			return;
		}

		float live = wep.Primary.DamageMultiplier;
		Log.Info( $"[nz] live weapon: base {wep.Primary.Damage:0.#} x{live:0.##} "
			+ $"= {wep.Primary.Damage * live:0.#} per bullet"
			+ (MathF.Abs( live - expected ) > 0.01f ? "   ⚠ DISAGREES WITH STORED" : "") );
	}

	/// <summary>
	/// `nz_pap_rounds [mk1] [mk2] [mk3] [mk4] [mk5]` — the round gates, and the whole ladder. MK6 has none, and none can be
	/// set: basalt's Easter egg is its only gate.
	///
	/// ⛔ IT PRINTS WHAT IS LOCKED RIGHT NOW BESIDE THE TABLE. The gate is invisible until you
	/// walk to the machine at the wrong round, and "why can't I pack" is exactly the question
	/// this has to answer without a trip across the map.
	///
	/// ⚠️ PASSING NOTHING ONLY REPORTS. The gates are authored per map in the config; a command
	/// that rewrote them on a bare call would silently overwrite a map's own ladder.
	/// </summary>
	[ConCmd( "nz_pap_rounds" )]
	public static void Rounds( int mk1 = -1, int mk2 = -1, int mk3 = -1,
		int mk4 = -1, int mk5 = -1 )
	{
		var pap = ActiveConfig.Pap;
		if ( pap is null ) { Log.Warning( "[nz-pap] no config" ); return; }

		var given = new[] { mk1, mk2, mk3, mk4, mk5 };
		if ( given.Any( v => v >= 0 ) )
		{
			// ⛔ NOT `Array.Clone()`. s&box refuses `System.Array.Clone` under its whitelist —
			// SB1000 — and it refuses it at the EDITOR's compile, not at `dotnet build`, so the
			// local build this was written against reported success on code the game cannot load.
			//
			// ⚠️ COPYING ELEMENT BY ELEMENT INTO A CORRECTLY-SIZED ARRAY also folds in the resize
			// this used to do on the next line: clone, then grow, then write was three steps for
			// what is one. A shorter authored ladder keeps its values and pads with 0, which
			// `UnlockRoundFor` already reads as "no gate".
			var src = pap.UnlockRounds;
			var rounds = new int[PapSettings.MaxMapTiers];
			for ( int i = 0; i < rounds.Length; i++ )
				rounds[i] = src is not null && i < src.Length ? src[i] : 0;

			for ( int i = 0; i < given.Length && i < rounds.Length; i++ )
				if ( given[i] >= 0 ) rounds[i] = given[i];

			pap.UnlockRounds = rounds;
		}

		var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
		var cap = PackAPunch.TierCapForNow();

		// ⚠️ SAYS WHEN CREATIVE IS THE REASON. "highest buyable MK5" on round 0 is true and looks
		// like the gate is broken; the table below it still prints the real round thresholds, so
		// without this line the two halves of one report appear to disagree.
		Log.Info( $"[nz-pap] round {round} — highest buyable tier MK{cap}"
			+ $" of {NZPlayer.PapMaxLevel}"
			+ (HexPlatforms.EggComplete ? "   (MK6 — basalt's Easter egg is complete)" : "")
			+ (NZGame.IsCreative ? "   (CREATIVE — round gate ignored)" : "") );

		// ⚠️ THE PRICE AND THE PUNCH THE MACHINE WILL USE — `CostToReach` and `MultiplierAt` — not the raw arrays: a save's
		// missing tiers read as they ship, and MK6 is one on basalt's.
		for ( int i = 0; i < NZPlayer.PapMaxLevel; i++ )
		{
			var at = pap.UnlockRoundFor( i );
			var open = pap.TierOpenAt( i + 1, round );
			var gate = i + 1 > PapSettings.MaxMapTiers ? "the Easter egg" : $"round {at,3}";
			Log.Info( $"[nz-pap]   MK{i + 1}  {gate}  {pap.CostToReach( i, NZPlayer.PapMaxLevel ),7} pts"
				+ $"  x{pap.MultiplierAt( i + 1 ):0.##} damage"
				+ $"  {(open ? "OPEN" : "locked")}" );
		}
	}

	/// <summary>
	/// Set the held weapon's upgrade level directly: `nz_pap_level 3`.
	///
	/// ⛔ THE ONLY PRACTICAL WAY TO TEST MK3. Reaching it honestly costs 50,000
	/// points across three cycles, and the thing most worth checking — whether
	/// x15.6 damage is survivable game balance — needs to be reachable in one
	/// command, not in a twenty-minute run.
	/// </summary>
	[ConCmd( "nz_pap_level" )]
	public static void Level( int level = -1 )
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		if ( level >= 0 )
		{
			player.ClearPap();
			for ( int i = 0; i < level && i < NZPlayer.PapMaxLevel; i++ )
				player.AddPapLevel( player.StartingWeapon );

			// ⛔ BOTH, AND IN THIS ORDER — they do different halves of the job and
			// each alone is broken.
			//
			// EquipStartingWeapon SPAWNS a weapon when there is none (creative
			// starts you empty-handed) but early-returns when one already exists, so
			// on its own it never updates the live gun: `nz_pap_status` caught that
			// as stored x15.63 against a live x2.5.
			//
			// PushStoredUpgrades APPLIES the multipliers to whatever is in hand but never
			// creates one — so on its own it left creative with "no live weapon" and
			// the shoot test unrunnable. Swapping one for the other just moved the
			// bug.
			player.EquipStartingWeapon( force: true );
			player.PushStoredUpgrades();
		}

		Status();
	}

	/// <summary>
	/// Fire the real weapon at a real zombie and measure the hole: `nz_pap_shoot`.
	///
	/// ⛔ THE ONLY HONEST TEST OF THIS FEATURE. `nz_pap_status` proves the
	/// multiplier reached `ShootInfo.DamageMultiplier`, which is a FIELD READ — it
	/// says the number was stored, not that a bullet does more damage. Those are
	/// different claims and only one of them is what Pack-a-Punch promises.
	///
	/// ⚠️ Goes through `Weapon.Shoot`, the same entry point the fire button uses, so
	/// the whole path runs: spread, the bullet type's own Shoot, the trace, the hit
	/// tags, `DamageFor`, and Health.OnDamage. Measuring health before and after is
	/// the result no amount of reading the code can substitute for.
	///
	/// ⚠️ Teleports the zombie in front of the muzzle first — a shot that misses
	/// reports zero damage and looks exactly like a broken multiplier.
	/// </summary>
	[ConCmd( "nz_pap_shoot" )]
	public static void ShootTest()
	{
		var scene = Game.ActiveScene;
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var wep = player.Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.FirstOrDefault();

		if ( !wep.IsValid() || wep.Primary is null )
		{
			Log.Warning( "[nz] no live weapon — nz_pap_level 0 spawns one in creative" );
			return;
		}

		var z = ZombieAI.All
			.Where( x => x.IsValid() && x.State != ZombieState.Dead )
			.OrderBy( x => x.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();

		if ( !z.IsValid() ) { Log.Warning( "[nz] no live zombies — nz_spawn first" ); return; }

		var hp = z.Components.Get<Health>();
		if ( !hp.IsValid() ) { Log.Warning( "[nz] zombie has no Health yet" ); return; }

		// ⛔ AIM THE PLAYER, DO NOT MOVE THE ZOMBIE. Teleporting it in front of the
		// muzzle and firing in the same call always reported zero damage: the
		// physics body has not synced to the new position when the trace runs one
		// line later, so every shot sailed through where the zombie used to be. A
		// miss and a dead multiplier produce identical output, which is exactly the
		// confusion this command exists to remove.
		var controller = player.Components.Get<PlayerController>();
		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;

		// Chest height, so the shot lands on the body rather than clipping a leg or
		// sailing over — and so the hit-group multiplier is the ordinary one.
		var target = z.WorldPosition + Vector3.Up * 40f;
		float range = eye.Distance( target );

		if ( controller.IsValid() )
			controller.EyeAngles = Rotation.LookAt( (target - eye).Normal ).Angles();

		float before = hp.Current;
		float expected = wep.Primary.DamageFor( range, null );

		wep.Shoot( wep.Primary, true );

		Log.Info( $"[nz] fired {wep.DisplayName} at {range:0}u: base {wep.Primary.Damage:0.#}"
			+ $" x{wep.Primary.DamageMultiplier:0.##} -> DamageFor says {expected:0.#}" );

		// ⚠️ Read AFTER the shot resolves. Hitscan lands this frame; a physical
		// bullet is in flight and will report 0 here, which is a timing artefact and
		// not a failure — the line says which one you are looking at.
		Log.Info( $"[nz] zombie {before:0} -> {hp.Current:0}"
			+ $"  (lost {before - hp.Current:0})"
			+ (before - hp.Current <= 0f
				? "   ⚠ NO DAMAGE — physical bullet still travelling, or a miss"
				: "") );
	}

	/// <summary>
	/// The three timings: `nz_pap_time <travel> <work> <ready>`.
	///
	/// ⚠️ Travel is the one that is hard to SEE without this. It is 0.7s by design,
	/// which is shorter than a single console round-trip — so the only way to
	/// confirm the weapon actually travels rather than teleports is to stretch it,
	/// watch, and put it back.
	/// </summary>
	[ConCmd( "nz_pap_time" )]
	public static void Timing( float travel = -1f, float work = -1f, float ready = -1f )
	{
		foreach ( var m in PackAPunch.All.Where( x => x.IsValid() ) )
		{
			if ( travel >= 0f ) m.TravelTime = travel;
			if ( work >= 0f ) m.WorkTime = work;
			if ( ready >= 0f ) m.ReadyTime = ready;
		}

		var first = PackAPunch.All.FirstOrDefault( x => x.IsValid() );
		Log.Info( first is null
			? "[nz] no machines placed"
			: $"[nz] travel {first.TravelTime:0.##}s each way, work {first.WorkTime:0.##}s, "
				+ $"then {first.ReadyTime:0.##}s to collect" );
	}

	/// <summary>
	/// Where the weapon sits: `nz_pap_gun <height> <out> <depth>`.
	///
	/// ⚠️ The same by-eye job as `nz_box_offer`, and for the same reason: the
	/// presented pose is one specific spot in front of one specific model, and
	/// finding it is a looking problem, not a reasoning one. Applies live.
	/// </summary>
	[ConCmd( "nz_pap_gun" )]
	public static void GunPose( float height = -999f, float outDist = -999f,
		float depth = -999f )
	{
		foreach ( var m in PackAPunch.All.Where( x => x.IsValid() ) )
		{
			if ( height > -998f ) m.GunHeight = height;
			if ( outDist > -998f ) m.GunOutDistance = outDist;
			if ( depth > -998f ) m.GunInDepth = depth;
		}

		var first = PackAPunch.All.FirstOrDefault( x => x.IsValid() );
		Log.Info( first is null
			? "[nz] no machines placed"
			: $"[nz] gun sits {first.GunHeight:0.#}u up, {first.GunOutDistance:0.#}u out, "
				+ $"travels {first.GunInDepth:0.#}u into the throat" );
	}

	/// <summary>Delete every placed machine: `nz_pap_clear`.</summary>
	[ConCmd( "nz_pap_clear" )]
	public static void Clear()
	{
		int n = ActiveConfig.Current.PackAPunches.Count;
		ActiveConfig.Current.PackAPunches.Clear();
		PackAPunchManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz] removed {n} machine(s)" );
	}

	static PackAPunch Nearest( NZPlayer player )
		=> PackAPunch.All
			.Where( m => m.IsValid() )
			.OrderBy( m => m.WorldPosition.DistanceSquared( player.WorldPosition ) )
			.FirstOrDefault();
}