Component that represents a wall-buy in the NZombies game. It stores prefab, price, rarity, and purchase state, lets a player buy the weapon or top up ammo, handles local effects (sounds, burn-in, reveal), and notifies the network when a buy occurs.
using System;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// A weapon bought off a wall — nZombies' most-used interaction.
///
/// Ported from GMod's `wall_buys` entity. The rules are the original's, because
/// they are what players have muscle memory for:
///
/// • you do NOT own it -> full price, gives the weapon
/// • you DO own it -> HALF price (floored to 10), refills reserve to max
/// • creative mode -> free
///
/// ⚠️ THE HALF-PRICE AMMO REFILL IS THE WHOLE POINT. A wallbuy is not a vending
/// machine you use once; it is a resupply point you come back to every few
/// rounds, and the discount is what makes returning worth the walk.
/// </summary>
public sealed class WallBuy : Component, Component.ExecuteInEditor
{
/// <summary>Weapon prefab this sells, e.g. "prefabs/weapons/nz_galil.prefab".</summary>
[Property] public string WeaponPrefab { get; set; } = "prefabs/weapons/nz_m1911.prefab";
/// <summary>Cost of the weapon itself. Ammo is half this.</summary>
[Property] public int Price { get; set; } = 500;
/// <summary>
/// Rarity tier this wall sells at, 0-4.
///
/// ⚠️ MIRRORS WallBuySpot.Rarity — the config is the authority, this is the copy the world
/// object works from, exactly as WeaponPrefab and Price already do.
/// </summary>
[Property] public int Rarity { get; set; }
/// <summary>
/// Has this been bought? Once true the weapon model shows permanently.
///
/// ⚠️ Not persisted — a wallbuy is bought per round, and the map author's saved
/// state should not remember someone's purchase.
/// </summary>
public bool Bought { get; private set; }
/// <summary>
/// Its place in the config's `WallBuys`, which names the same wall on every machine: each builds its own from the same list in the
/// same order (`WallBuyManager.Rebuild`), so the index is how a purchase travels (`NZNet.WallBought`). -1: not from the config.
/// </summary>
public int Index { get; set; } = -1;
/// <summary>How close the player must be to use it.</summary>
[Property] public float UseRange { get; set; } = 80f;
/// <summary>
/// Hacked wallbuys invert the pricing (the original charges 4500 for ammo).
/// Present so the field exists for a later Hacker perk; unused for now.
/// </summary>
[Property] public bool Hacked { get; set; }
/// <summary>
/// Ammo refill price: HALF, floored to the nearest 10.
///
/// ⚠️ `(price - price % 10) / 2` is the original's arithmetic, kept verbatim
/// — a 500 buy refills for 250, and a 1255 buy for 625 (not 628): the price
/// is floored to 1250 BEFORE halving, which is why it is not simply price/2.
/// </summary>
public int AmmoPrice => Hacked ? 4500 : (int)MathF.Ceiling( (Price - Price % 10) / 2f );
/// <summary>
/// Display name, derived from the prefab path.
///
/// ⚠️ COMPUTED, NOT CACHED IN OnStart. Placement logs the name immediately
/// after creating the component, and OnStart has not run by then — the name
/// came out blank in both the placement log and the list.
///
/// ⚠️ Derived from the PREFAB, never a field anyone maintains: a wallbuy
/// whose label disagrees with what it sells is worse than one with no label.
/// </summary>
public string WeaponName =>
string.IsNullOrWhiteSpace( WeaponPrefab )
? "?"
: System.IO.Path.GetFileNameWithoutExtension( WeaponPrefab )
.Replace( "nz_", "" ).ToUpper();
/// <summary>The map's own wall-buy sound (`Gameplay.WallBuySound`: basalt's flame), or null for the game's own.</summary>
static string OwnSound
{
get
{
var cue = ActiveConfig.Current?.Gameplay?.WallBuySound?.Trim();
return string.IsNullOrEmpty( cue ) ? null : cue;
}
}
/// <summary>
/// Buy the weapon, or top up its ammo if already held.
/// Returns what was actually spent — 0 means nothing happened.
/// </summary>
public int TryBuy( NZPlayer player )
{
if ( !player.IsValid() ) return 0;
// ⚠️ Creative is free, exactly as the original (`ROUND_CREATE -> price = 0`).
// Placing wallbuys means testing them, and paying each time makes that
// tedious enough that they go untested.
var free = NZGame.IsCreative;
// ⛔ SEARCHES EVERY SLOT, NOT JUST THE FIRST WEAPON FOUND. This used to be
// `.FirstOrDefault()`, which was right while the player held one gun and is
// wrong now: own the Galil in slot 2, walk up holding the pistol, and the
// wallbuy decided you did NOT own it and sold you a full-price duplicate.
// The original checks `activator:HasWeapon( class )` across all of them
// (wall_buys/sharedwpws.lua:225) for exactly this reason.
//
// ⚠️ `Everything…` includes DISABLED components, which matters because a
// holstered weapon is a disabled one — the owned gun is usually the one NOT
// in your hands.
var held = OwnedBy( player );
var owns = held.IsValid();
var cost = free ? 0 : (owns ? AmmoPrice : Price);
// ⚠️ Refill BEFORE charging, so a full magazine costs nothing. Charging
// first and refunding would fire the points popup twice.
if ( owns && !RefillAmmo( held ) ) return 0;
// ⚠️ THE MAP'S OWN SOUND FOR IT, IF IT HAS ONE (`Gameplay.WallBuySound`, 2026-09-28) — basalt's flame — plays ONCE, at the
// wall, in place of the spend's cha-ching: *"replace the wallbuy sound with a flame sound"*. So the spend keeps quiet. A map
// without one plays what it always did.
var own = OwnSound;
// ⚠️ TrySpend fuses the check with the deduction on purpose (see NZPlayer)
// — asking CanAfford separately is how you get free doors.
if ( !player.TrySpend( cost, quiet: own is not null ) ) return 0;
// ⛔ NO LONGER DESTROYS EVERY WEAPON FIRST. That was correct while the player
// had one slot; with two it threw away the gun you were not replacing —
// buy a Galil off the wall and your Pack-a-Punched pistol vanished.
// GiveWeapon fills a free slot and only replaces the ACTIVE one when both
// are full, which is the original's rule (`GetPriorityWeaponSlot`: first
// free slot of 1..max, else the active weapon's).
if ( !owns )
{
// ⛔ STORED BEFORE Give, NOT AFTER — the rule MysteryBox documents at its own
// hand-over. GiveWeapon spawns the weapon and ApplyStoredUpgrades stamps the
// multipliers onto it DURING that call, so writing the tier afterwards would leave the
// gun now in your hands on the old multiplier until the next equip.
//
// ⛔ AND IT TAKES THE MAX. Rarity is keyed per PREFAB, not per weapon entity, so a
// Common wall would otherwise overwrite a Legendary tier bought with salvage. Upgrading
// is a gift; downgrading is a theft.
//
// ⚠️ ONLY ON THE WEAPON PURCHASE, NEVER ON AN AMMO REFILL. Refills cost half, and
// letting them apply the tier would sell a Legendary upgrade at half price to anyone
// already holding the gun.
if ( Rarity > 0 )
{
var had = player.RarityTierFor( WeaponPrefab );
if ( Rarity > had )
{
player.SetRarityTier( WeaponPrefab, Rarity );
Log.Info( $"[nz-rarity] wall buy handed over a "
+ $"{NZombies.Rarity.NameFor( Rarity ).ToUpper()} weapon"
+ $" — damage x{NZombies.Rarity.Mult( Rarity ):0.##}" );
}
}
player.GiveWeapon( WeaponPrefab );
}
// ⚠️ Once bought, the model stays on the wall for good — you own it, so the
// wallbuy stops being a question ("what is this?") and becomes a landmark
// ("that is where my Galil is"). Aim-gating it after purchase would hide
// the one thing that makes a route memorable.
Bought = true;
WallBuyManager.SetRevealed( this, true );
// ⚠️ AND THE FIRE PASSES OVER IT ONCE MORE, with the flame (`WallBuyBurnIn`, where the map burns its chalk in)
WallBuyBurnIn.Ignite( this );
if ( own is not null ) NZSound.Play( own, WorldPosition );
else Sound.Play( "nz.purchase", WorldPosition );
// ⛔ AND EVERY OTHER MACHINE HEARS OF IT (`NZNet.WallBought`, the co-op pass, 2026-09-28). The buy runs here, on the buyer's own
// machine, so without this the wall was bought — and its fire and flame seen and heard — for the buyer alone.
if ( Networking.IsActive && Connection.Local is not null && Index >= 0 )
NZNet.WallBought( Connection.Local.Id.ToString(), Index, live: true );
Log.Info( $"[wallbuy] {(owns ? "ammo" : "bought")} {WeaponName} for {cost}" );
return cost;
}
/// <summary>
/// Somebody else bought from this wall (`NZNet.WallBought`): it is bought here too, its gun burned in and kept, and — live, not a
/// joiner's catch-up — the fire passes over it with the map's own sound at the wall. ⚠️ NOTHING IS SPENT OR GIVEN: the points and
/// the gun were the buyer's, on the buyer's machine. Nor is the cha-ching played — that is the buyer's own, and 2D.
/// </summary>
public void BoughtElsewhere( bool live )
{
Bought = true;
WallBuyManager.SetRevealed( this, true, instant: !live );
if ( !live ) return;
WallBuyBurnIn.Ignite( this );
if ( OwnSound is { } own ) NZSound.Play( own, WorldPosition );
}
/// <summary>Top the reserve back up. False when it was already full.</summary>
private bool RefillAmmo( SWB.Base.Weapon weapon )
{
// ⛔ SIEGE FIRST, BECAUSE ITS RESERVE IS 0 AND `0 >= 0` READS AS "ALREADY FULL". A siege
// weapon keeps everything in the magazine, so the wall has to fill THAT or the purchase is
// refused forever on the one node that most needs a wall.
if ( NZombies.Siege.Has( weapon ) ) return NZombies.Siege.Refill( weapon );
var ammo = weapon.GameObject.Components.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
if ( !ammo.IsValid() || ammo.Reserve >= ammo.MaxReserve ) return false;
ammo.Reserve = ammo.MaxReserve;
return true;
}
private string PrefabClassName() =>
System.IO.Path.GetFileNameWithoutExtension( WeaponPrefab );
/// <summary>Is this player close enough and looking at it?</summary>
public bool CanUse( NZPlayer player ) =>
player.IsValid()
&& Vector3.DistanceBetween( WorldPosition, player.WorldPosition ) <= UseRange;
/// <summary>Prompt text for the HUD.</summary>
/// <summary>
/// This wallbuy's weapon, if the player is carrying it in ANY slot — else null.
///
/// ⛔ ONE CHECK, SHARED BY THE PROMPT AND THE PURCHASE. They each had their own
/// `.FirstOrDefault()` copy, which was fine with one slot and disagreed the
/// moment there were two: the prompt read whichever weapon came first (usually
/// the HOLSTERED one), so it offered "Hold F for Galil [500]" while TryBuy
/// correctly charged 250 for ammo. A prompt that misquotes the price is worse
/// than a wrong price, because the player plans around it.
///
/// ⚠️ `Everything…` includes DISABLED components. A holstered weapon is a
/// disabled one, and the gun you already own is usually the one NOT in your
/// hands — which is exactly the case that was broken.
/// </summary>
public SWB.Base.Weapon OwnedBy( NZPlayer player )
{
if ( !player.IsValid() ) return null;
return player.GameObject.Components
.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
.FirstOrDefault( w => w.IsValid() && string.Equals(
w.ClassName, PrefabClassName(), StringComparison.OrdinalIgnoreCase ) );
}
public string UseText( NZPlayer player )
{
var owns = OwnedBy( player ).IsValid();
return owns
? $"Hold F for Ammo [{AmmoPrice}]"
: $"Hold F for {WeaponName} [{Price}]";
}
}