AmmoBox component that represents purchasable ammo refill stations. It tracks all live boxes, finds the nearest usable box, computes prices based on Pack-a-Punch level and per-weapon per-player use counts, checks availability, performs purchases (spending player points and topping up reserve ammo), and resets per-round use counts.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// THE AMMO BOX — refills the held weapon's reserve, for a price that climbs as you lean on it.
///
/// ⛔ THE PRICE IS A FUNCTION OF TWO THINGS, and neither is which box you walked to: the held
/// weapon's Pack-a-Punch level, and how many times THIS PLAYER has bought a refill THIS ROUND.
/// See <see cref="AmmoBoxSettings"/> for the ladder.
///
/// ⛔ USES ARE COUNTED PER WEAPON, PER PLAYER — not per box and not per player overall. Per-box
/// would mean a map with two boxes has no escalation at all (alternate between them, pay base
/// forever). A single per-player counter was the first attempt and was also wrong: it taxed the
/// PLAYER, so refilling your pistol twice made the first refill of your rifle expensive.
/// `NZPlayer.AmmoBoxUses` is a dictionary keyed on the prefab path, the same join key `PapLevels`
/// uses.
///
/// ⚠️ RESERVE ONLY, NOT THE LOADED MAGAZINE — the same thing `WallBuy.RefillAmmo` does, and for the
/// same reason: topping up the mag as well turns every purchase into a free reload on top of the
/// ammo, and the two systems selling the same thing differently is worse than either choice.
/// </summary>
public sealed class AmmoBox : Component
{
/// <summary>Every live box, for the use trace and the prompt.</summary>
public static readonly List<AmmoBox> All = new();
protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
protected override void OnDisabled() => All.Remove( this );
/// <summary>The config row this was built from.</summary>
[Property] public AmmoBoxSpot Spot { get; set; }
/// <summary>How close you must stand. Matches the box, the barricade and Pack-a-Punch.</summary>
public const float UseRange = 90f;
/// <summary>
/// The nearest usable box, or null.
///
/// ⚠️ Distance to the box's ORIGIN, which sits at its base — the same choice
/// `Wunderfizz.Near` documents. A tall model would otherwise measure from its middle and feel
/// unreachable when you are stood against it.
/// </summary>
public static AmmoBox Near( Vector3 pos )
{
AmmoBox best = null;
float bestDist = UseRange;
foreach ( var b in All )
{
if ( !b.IsValid() ) continue;
var d = pos.Distance( b.WorldPosition );
if ( d > bestDist ) continue;
bestDist = d;
best = b;
}
return best;
}
/// <summary>
/// The weapon in the ACTIVE slot, or null.
///
/// ⛔ VIA THE INVENTORY, NOT `GetInChildren`. That returns whichever Weapon comes first in the
/// hierarchy — usually the HOLSTERED one since the second slot landed, which had
/// `nz_wep_anims` reporting on the wrong gun for weeks (WeaponTuningCommands.Held). Refilling
/// the wrong weapon's reserve while charging for the right one's tier would be worse.
/// </summary>
static SWB.Base.Weapon HeldWeapon( NZPlayer player )
{
if ( !player.IsValid() ) return null;
var active = player.Components.Get<NZInventory>( FindMode.EverythingInSelf )?.Active;
return active.IsValid()
? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
}
// ── pricing ──────────────────────────────────────────────────────────────────────────────
/// <summary>
/// What a refill costs at a given tier after a given number of refills. Pure.
///
/// ⛔ SEPARATE FROM <see cref="PriceFor"/> SO THE LADDER CAN BE SHOWN WITHOUT LYING. The price
/// preview used to walk the player's real use count forward and put it back afterwards, which
/// meant a read-only command briefly mutated live state — and would have left the count wrong
/// if anything between the two writes threw. A pure function needs no restore.
///
/// ⛔ CLAMPED AGAINST OVERFLOW, NOT JUST AGAINST THE CONFIG'S CAP. The multiplier compounds, so
/// an MK5 base at 1.5x reaches int.MaxValue in the low twenties of uses in one round — reachable
/// on a long camp. `double` for the maths and a clamp on the way out removes a whole class of
/// nonsense from the prompt.
/// </summary>
public static int PriceAt( int papLevel, int uses )
{
var cfg = ActiveConfig.AmmoBox;
if ( cfg is null ) return 0;
double price = cfg.BaseFor( papLevel );
// ⚠️ `uses` IS HOW MANY ALREADY BOUGHT, so the FIRST refill of a round pays the base —
// pow(mult, 0) == 1. Starting the exponent at 1 would charge the escalated price
// immediately, which reads as the base price in the settings panel being a lie.
float mult = MathF.Max( 1f, cfg.RepeatMultiplier );
// Capped before the pow, because 1.5^1000 is infinity and infinity clamps to a number that
// looks deliberate.
price *= Math.Pow( mult, Math.Clamp( uses, 0, 64 ) );
if ( cfg.MaxPrice > 0 ) price = Math.Min( price, cfg.MaxPrice );
return (int)Math.Clamp( Math.Round( price ), 0, 1_000_000_000 );
}
/// <summary>
/// What a refill costs this player right now, for what they are holding.
///
/// ⚠️ THE HELD WEAPON'S LEVEL AND THE HELD WEAPON'S COUNT. You pay for what you are refilling —
/// an MK5 in the other slot must not make topping up a wall pistol cost 5,000, and refills
/// bought for that MK5 must not raise the pistol's price either.
/// </summary>
public static int PriceFor( NZPlayer player )
{
var prefab = HeldPrefab( player );
if ( string.IsNullOrEmpty( prefab ) ) return 0;
return PriceAt( player.PapLevelFor( prefab ), player.AmmoBoxUsesFor( prefab ) );
}
/// <summary>
/// The prefab path of the weapon being refilled, or null.
///
/// ⛔ ONE RESOLUTION, SHARED BY THE PRICE AND THE PURCHASE. The tier and the use count are both
/// keyed on this string, and resolving it twice in two places is exactly how they end up
/// disagreeing about which gun is in your hands.
///
/// ⚠️ FROM THE WEAPON'S OWN STAMP, falling back to StartingWeapon — the same resolution
/// `PapCamo.LevelFor` and `ApplyTechPassives` use. Reading the held prefab off the player
/// instead would price the holstered gun.
/// </summary>
static string HeldPrefab( NZPlayer player )
{
var wep = HeldWeapon( player );
if ( !wep.IsValid() ) return null;
var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
return string.IsNullOrEmpty( src ) ? player.StartingWeapon : src;
}
// ── availability ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Why this box will not serve, or "" when it will.
///
/// ⚠️ ONE METHOD ANSWERS FOR BOTH THE PROMPT AND THE KEY, which is the rule `NZPlayer.TickUse`
/// and `UsePrompt.Text` are both written against: a prompt that offers what E refuses is worse
/// than no prompt.
/// </summary>
public string Unavailable( NZPlayer player )
{
if ( Spot is null ) return "";
if ( Spot.RequiresPower && !Power.IsOn )
return "Ammo Box — needs power";
var round = Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault()?.Round ?? 1;
if ( Spot.StartRound > 1 && round < Spot.StartRound )
return $"Ammo Box — from round {Spot.StartRound}";
if ( !DoorLinks.IsOpen( Spot.Link ) )
return "Ammo Box — locked";
if ( !player.IsValid() ) return "";
var wep = HeldWeapon( player );
if ( !wep.IsValid() ) return "Ammo Box — nothing in hand";
if ( IsFull( wep ) ) return "Ammo Box — reserve already full";
return "";
}
/// <summary>Is this weapon's reserve already topped out.</summary>
static bool IsFull( SWB.Base.Weapon wep )
{
var ammo = wep.GameObject.Components
.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
return !ammo.IsValid() || ammo.Reserve >= ammo.MaxReserve;
}
// ── purchase ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Buy a refill. Returns what happened, for the log and the prompt.
///
/// ⚠️ THE USE COUNT RISES ONLY ON A SUCCESSFUL PURCHASE. Incrementing on a refusal would make
/// walking up to a full-reserve box with no points quietly raise the price of the refill you
/// eventually do buy.
/// </summary>
public string Buy( NZPlayer player )
{
var blocked = Unavailable( player );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
var wep = HeldWeapon( player );
var ammo = wep.GameObject.Components
.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
int price = PriceFor( player );
if ( player.Points < price )
return $"not enough points — a refill costs {price:N0}";
if ( !player.TrySpend( price ) )
return $"not enough points — a refill costs {price:N0}";
// ⚠️ SIEGE FILLS THE MAGAZINE INSTEAD — see `WallBuy.RefillAmmo` for why a reserve of
// zero cannot be the thing a purchase tops up.
if ( !NZombies.Siege.Refill( wep ) )
ammo.Reserve = ammo.MaxReserve;
player.AddAmmoBoxUse( HeldPrefab( player ) );
Sound.Play( "nz.purchase", WorldPosition );
return $"ammo for {wep.DisplayName} — {price:N0} points"
+ $" (next {PriceFor( player ):N0} this round)";
}
// ── per-round reset ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Clear everyone's use count. Called from `RoundManager.BeginRound`.
///
/// ⛔ THE ESCALATION IS PER ROUND, so this is what makes the mechanic a mechanic rather than a
/// permanent tax. Without it the box becomes unusable a few rounds in and the "same round"
/// half of the rule is silently dropped.
///
/// ⚠️ UNIONS IN THE LOCAL PLAYER like `PlayerStats.All` does — `GetAllComponents<NZPlayer>`
/// does not see a DISABLED player and the lobby disables the body, which has bitten this
/// project twice already.
/// </summary>
public static void OnRoundStart()
{
var scene = Game.ActiveScene;
var found = scene is null
? Enumerable.Empty<NZPlayer>()
: scene.GetAllComponents<NZPlayer>();
var local = PlayerCharacters.Local();
foreach ( var p in found
.Concat( local.IsValid() ? new[] { local } : Array.Empty<NZPlayer>() )
.Where( p => p.IsValid() )
.Distinct() )
{
p.ClearAmmoBoxUses();
}
}
}