Console command handlers for placing, listing, clearing, buying from, pricing, resetting and testing ammo box behavior in the NZombies game. Exposes developer commands (nz_ammobox, nz_ammobox_list, nz_ammobox_clear, nz_ammobox_buy, nz_ammobox_price, nz_ammobox_reset, nz_ammobox_drain) that read and mutate game config and interact with AmmoBox and player state.
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// Console access to the ammo box.
///
/// ⚠️ THE ONLY WAY TO PLACE ONE TODAY. There is no map-editor tool for it yet — that waits on the
/// real model, since a Q-menu entry showing a green dev crate invites bug reports about the crate.
/// `nz_ammobox` at your feet is the same placement route `nz_fizz` uses and saves into the config
/// identically, so a box placed now survives into whatever the tool becomes.
/// </summary>
public static class AmmoBoxCommands
{
static NZPlayer Player
=> PlayerCharacters.Local()
?? NZPlayer.Local;
/// <summary>Drop one at your feet: `nz_ammobox`.</summary>
[ConCmd( "nz_ammobox" )]
public static void Place()
{
var p = Player;
if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }
var cfg = ActiveConfig.Current;
if ( cfg is null ) { Log.Warning( "[nz-ammo] no config" ); return; }
// ⚠️ At the PLAYER's feet with an UP normal, not a crosshair trace — the same choice
// `nz_fizz` documents. The command exists so this can be driven headlessly, and a trace
// needs somewhere to be aiming.
cfg.AmmoBoxes.Add( new AmmoBoxSpot
{
Position = p.WorldPosition,
Yaw = p.EyeAngles.yaw + 180f,
Normal = Vector3.Up,
} );
AmmoBoxManager.Ensure()?.Rebuild();
Log.Info( $"[nz-ammo] placed — {cfg.AmmoBoxes.Count} total" );
}
/// <summary>What is placed: `nz_ammobox_list`.</summary>
[ConCmd( "nz_ammobox_list" )]
public static void List()
{
var list = ActiveConfig.Current?.AmmoBoxes;
if ( list is null || list.Count == 0 )
{
Log.Info( "[nz-ammo] none placed — nz_ammobox to drop one" );
return;
}
for ( int i = 0; i < list.Count; i++ )
{
var b = list[i];
Log.Info( $"[nz-ammo] [{i}] {b.Position}"
+ (b.RequiresPower ? " ⚡power" : "")
+ (b.StartRound > 1 ? $" from round {b.StartRound}" : "")
+ $" flag {DoorLinks.Display( b.Link )}" );
}
// ⚠️ CONFIGURED vs STANDING, said separately — a box whose model failed to load leaves a
// spot in the config and nothing in the world.
var mgr = AmmoBoxManager.Instance;
Log.Info( $"[nz-ammo] {list.Count} configured, "
+ (mgr.IsValid() ? $"{mgr.Built} standing" : "no manager — none standing") );
}
/// <summary>Remove them all: `nz_ammobox_clear`.</summary>
[ConCmd( "nz_ammobox_clear" )]
public static void Clear()
{
var cfg = ActiveConfig.Current;
if ( cfg is null ) return;
int n = cfg.AmmoBoxes.Count;
cfg.AmmoBoxes.Clear();
AmmoBoxManager.Ensure()?.Rebuild();
Log.Info( $"[nz-ammo] cleared {n}" );
}
/// <summary>
/// Buy a refill without walking to a box: `nz_ammobox_buy`.
///
/// ⚠️ USES THE NEAREST BOX IF THERE IS ONE, so the availability gates are exercised — and falls
/// back to any placed box otherwise, because a feature reachable only by standing in the right
/// spot cannot be tested remotely.
/// </summary>
[ConCmd( "nz_ammobox_buy" )]
public static void Buy()
{
var p = Player;
if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }
var box = AmmoBox.Near( p.WorldPosition )
?? AmmoBox.All.FirstOrDefault( b => b.IsValid() );
if ( !box.IsValid() ) { Log.Warning( "[nz-ammo] no box placed — nz_ammobox" ); return; }
Log.Info( $"[nz-ammo] {box.Buy( p )}" );
}
/// <summary>
/// The price ladder for every weapon you carry: `nz_ammobox_price`.
///
/// ⚠️ PRINTS EVERY CARRIED WEAPON, not just the held one, because the escalation is PER WEAPON —
/// a single gun's ladder cannot show that the other slot is still at base price, which is the
/// whole point of the rule.
///
/// ⚠️ READ-ONLY. It calls `AmmoBox.PriceAt` directly rather than walking the real use count
/// forward and putting it back, which is what the first version did — a read command that
/// mutates live state is one exception away from leaving the count wrong.
/// </summary>
[ConCmd( "nz_ammobox_price" )]
public static void Price()
{
var p = Player;
if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }
var cfg = ActiveConfig.AmmoBox;
if ( cfg is null ) { Log.Warning( "[nz-ammo] no config" ); return; }
Log.Info( $"[nz-ammo] base by tier: {string.Join( " / ", cfg.BasePrices )}"
+ $" repeat x{cfg.RepeatMultiplier:0.##} per use, PER WEAPON"
+ (cfg.MaxPrice > 0 ? $" cap {cfg.MaxPrice:N0}" : " uncapped") );
var weapons = p.Components
.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
.ToList();
if ( weapons.Count == 0 ) { Log.Info( "[nz-ammo] no weapons carried" ); return; }
foreach ( var wep in weapons )
{
var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
var prefab = string.IsNullOrEmpty( src ) ? p.StartingWeapon : src;
int level = p.PapLevelFor( prefab );
int used = p.AmmoBoxUsesFor( prefab );
var ladder = string.Join( " ", Enumerable.Range( 0, 4 )
.Select( i => $"{AmmoBox.PriceAt( level, used + i ):N0}" ) );
Log.Info( $"[nz-ammo] {wep.DisplayName,-22} MK{level}"
+ $" used {used}x next: {ladder}" );
}
}
/// <summary>Reset the round counter by hand: `nz_ammobox_reset`.</summary>
[ConCmd( "nz_ammobox_reset" )]
public static void Reset()
{
AmmoBox.OnRoundStart();
Log.Info( "[nz-ammo] use counts cleared" );
}
/// <summary>
/// Empty the held weapon's reserve: `nz_ammobox_drain [leave]`.
///
/// ⛔ WITHOUT THIS THE BOX CANNOT BE TESTED AT ALL. Every weapon spawns with a FULL reserve
/// (`Reserve == MaxReserve` on all 31 prefabs), and a full reserve is precisely the one state
/// the box refuses to serve — so there was no way to reach the purchase path short of firing a
/// gun by hand. Same standing rule as `nz_stats_fake`: a feature only reachable by playing
/// cannot be checked remotely.
///
/// ⚠️ Leaves <paramref name="leave"/> rounds rather than always zeroing, so the "partly spent"
/// case is reachable too — that is the branch `ApplyStoredUpgrades` deliberately does not top up.
/// </summary>
[ConCmd( "nz_ammobox_drain" )]
public static void Drain( int leave = 0 )
{
var p = Player;
if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }
var active = p.Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active;
var wep = active.IsValid()
? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
if ( !wep.IsValid() ) { Log.Warning( "[nz-ammo] nothing in hand" ); return; }
var ammo = wep.GameObject.Components
.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
if ( !ammo.IsValid() ) { Log.Warning( "[nz-ammo] held weapon has no NZAmmo" ); return; }
ammo.Reserve = System.Math.Clamp( leave, 0, ammo.MaxReserve );
Log.Info( $"[nz-ammo] {wep.DisplayName} reserve -> {ammo.Reserve}/{ammo.MaxReserve}" );
}
}