Buyables/WallBuy.cs

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.

NetworkingFile Access
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}]";
	}
}