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