Buyables/PerkMachine.cs

A component representing a standing perk vending machine. It tracks authored Spot data, sells a specific perk to players with price/availability checks, manages one-at-a-time ambient jingles across machines, and exposes a console command to control jingles.

NetworkingFile Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// A standing perk machine — the thing the player walks up to.
///
/// ⛔ SELLS ONE PERK, WITH NO MENU. That is the entire difference from <see cref="Wunderfizz"/>,
/// which rolls a random perk and opens a picker: BASE PERKS come from these machines, AUGMENTS
/// stay exclusive to the Wunderfizz. So E buys outright here, the same shape as the ammo box.
///
/// ⚠️ The config's <see cref="PerkMachineSpot"/> is the AUTHORED data; this is the live machine
/// built from it. They are separate because the spot survives a round and the machine does not.
/// </summary>
public sealed class PerkMachine : Component
{
	public static readonly List<PerkMachine> All = new();

	protected override void OnEnabled() => All.Add( this );

	protected override void OnDisabled()
	{
		All.Remove( this );
		StopJingle();
	}

	/// <summary>The authored settings this machine was built from.</summary>
	[Property] public PerkMachineSpot Spot { get; set; }

	/// <summary>The perk this machine sells, or null if the id is unknown.</summary>
	public PerkRegistry.Perk Perk => PerkRegistry.Find( Spot?.PerkId );

	/// <summary>How far away it can be used from. Matches the Wunderfizz.</summary>
	public const float UseRange = 96f;

	// ── the jingle ───────────────────────────────────────────────────────────
	//
	// ⚠️ ADDED 2026-09-28 — *"yes let's add the jingles"*. Every perk's jingle had sat in `sounds/nz/perk/jingle/` all along with
	// nothing playing one (`PerkRegistry`'s note on the orphaned Tombstone file). The original plays each machine's own on a long random
	// timer from the moment it is powered (`NextJingle = CurTime() + math.random(0,600)`, perk_machine:159, :226): a tune you catch
	// now and then drifting down a corridor, not a soundtrack. The events (`nz.perk.jingle.<id>`) are levelled to Pack-a-Punch's.
	//
	// ⛔ THE HOST DECIDES EVERY JINGLE, AND EVERYONE IN EARSHOT HEARS THE SAME ONE (the co-op pass, 2026-09-28) — the original's
	// `EmitSound` on the server. Each machine had drawn its own timers, so two players at one machine heard different tunes at different
	// times, and the minute between two was kept per machine rather than per game. The host runs the timers, the minute and one at a
	// time, and sends each start (`NZNet.WorldSound`); a client plays what arrives and draws nothing.

	/// <summary>`nz_jingles 0` silences them (this session; on the host, for everyone).</summary>
	public static bool Jingles
	{
		get => _jingles ?? true;
		set => _jingles = value;
	}

	static bool? _jingles;

	/// <summary>Seconds between one machine's jingles, drawn fresh each time: the original's 0-600, less its first half-minute.</summary>
	public static readonly Vector2 JingleGap = new( 30f, 600f );

	TimeUntil _nextJingle;
	SoundHandle _jingle;
	bool _wasPowered;

	/// <summary>
	/// ⛔ ONE PERK JINGLE AT A TIME, ACROSS EVERY MACHINE — Pack-a-Punch's rule, for its reason: two machines in earshot singing
	/// over each other. ⚠️ BY THE CLOCK, NOT BY A SOUND HANDLE: the host sings for players it may be nowhere near, and a tune it cannot
	/// hear itself gives it no handle to ask. The jingle's own length is the window (`JingleSeconds`), so it cannot stick either.
	/// </summary>
	static RealTimeUntil _jingleEnds;

	/// <summary>The host's own copy of the last one, to cut it short (`nz_jingles now`).</summary>
	static SoundHandle _anyJingle;

	static bool JinglePlaying => !_jingleEnds;

	/// <summary>
	/// ⛔ A MINUTE BETWEEN ANY TWO JINGLES' STARTS, ACROSS EVERY MACHINE — *"make sure that no machine can start playing within 1
	/// minute from each other"* (2026-09-28). One at a time alone let the next begin the moment the last one ended.
	/// </summary>
	public const float JingleSpacing = 60f;

	/// <summary>
	/// When the last one started, or null before the first. ⚠️ ON THE REAL CLOCK: `Time.Now` starts again with every play session and
	/// a static outlives it, so a scene-time stamp left by the last session would hold the gate shut for as long as that one ran.
	/// </summary>
	static RealTimeSince? _sinceAnyJingle;

	static bool JingleSpaced => !_sinceAnyJingle.HasValue || (float)_sinceAnyJingle.Value >= JingleSpacing;

	/// <summary>This machine's jingle, `nz.perk.jingle.&lt;perk id&gt;` — none for a perk without one (Napalm Nectar).</summary>
	public string JingleCue => Spot is null ? "" : $"nz.perk.jingle.{Spot.PerkId}";

	/// <summary>Is it powered — the power on, or a machine that never needed it?</summary>
	bool Powered => Spot is not null && (!Spot.RequiresPower || Power.IsOn);

	/// <summary>
	/// A jingle now and then, once the machine is powered. ⚠️ THE TIMER IS DRAWN AS THE POWER COMES ON, as the original draws it in
	/// `TurnOn`: drawn at build instead, every machine's would have run out in the dark and they would have queued up to sing, one
	/// after another, the moment the lever went.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( Spot is null ) return;

		// ⛔ THE HOST'S ALONE — see the section's note. A client's own timers would be a second, disagreeing set of jingles.
		if ( !NZGame.IsHost ) return;

		var powered = Powered;
		if ( powered && !_wasPowered ) _nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );
		_wasPowered = powered;

		if ( !powered || !Jingles || !_nextJingle ) return;

		_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );

		// ⚠️ DUE WHILE ANOTHER SINGS, OR INSIDE THE MINUTE: IT DRAWS AGAIN RATHER THAN WAITING ITS TURN. Waiting is what one at a time
		// used to do, and with the gap it would line the machines up to sing every sixty seconds on the dot — a soundtrack, the thing
		// the long random timer exists to avoid.
		if ( JinglePlaying || !JingleSpaced ) return;

		var cue = JingleCue;
		if ( !NZSound.Exists( cue ) || !InEarshot( cue ) ) return;

		Sing( cue );
	}

	/// <summary>
	/// ⚠️ A TUNE NOBODY IS IN EARSHOT OF IS NOT PLAYED, AND DOES NOT HOLD THE MINUTE — what `PlayAmbient`'s cull did for one listener,
	/// asked of every player now that the host sings for all of them.
	/// </summary>
	bool InEarshot( string cue )
	{
		if ( NZSound.AudibleRange( cue ) is not float range ) return true;

		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
			if ( p.IsValid() && p.WorldPosition.Distance( WorldPosition ) <= range ) return true;

		return false;
	}

	/// <summary>The jingle, on every machine: the host's own copy here, each client's by `NZNet.WorldSound`. Host only.</summary>
	void Sing( string cue )
	{
		_sinceAnyJingle = (RealTimeSince)0f;
		_jingleEnds = JingleSeconds( cue );

		if ( Networking.IsActive ) NZNet.WorldSound( cue, WorldPosition );

		// ⚠️ PlayAmbient here, as Pack-a-Punch's hum: a machine across the map from the host spends nothing on the host's own ears
		_jingle = NZSound.PlayAmbient( cue, WorldPosition );
		if ( _jingle.IsValid() ) _anyJingle = _jingle;
	}

	/// <summary>
	/// Each jingle's length, measured (ffprobe, 2026-09-28): what one-at-a-time holds the next one off for when the file's own
	/// length cannot be read. ⚠️ NOT ALL UNDER THE MINUTE — Banana Bomb's runs almost three, and eight run past sixty seconds.
	/// ⚠️ A PROPERTY THAT BUILDS THE TABLE, not a static one (INSTRUCTIONS §1): a hotload keeps a static's first values.
	/// </summary>
	static Dictionary<string, float> MeasuredJingle => new()
	{
		["nz.perk.jingle.banana"] = 177.3f, ["nz.perk.jingle.deadshot"] = 62.8f, ["nz.perk.jingle.death"] = 88.9f,
		["nz.perk.jingle.dtap"] = 35.7f, ["nz.perk.jingle.jugg"] = 29.9f, ["nz.perk.jingle.mulekick"] = 59.7f,
		["nz.perk.jingle.phd"] = 69.6f, ["nz.perk.jingle.pop"] = 87.0f, ["nz.perk.jingle.revive"] = 27.8f,
		["nz.perk.jingle.speed"] = 30.0f, ["nz.perk.jingle.staminup"] = 60.0f, ["nz.perk.jingle.time"] = 74.7f,
		["nz.perk.jingle.tortoise"] = 58.2f, ["nz.perk.jingle.vigor"] = 63.9f, ["nz.perk.jingle.vulture"] = 71.3f,
		["nz.perk.jingle.widowswine"] = 44.8f,
	};

	/// <summary>
	/// How long a jingle runs: its sound's own length when the file says, else the measured table, else the minute.
	///
	/// ⛔ ONLY A LOADED SOUND HAS A LENGTH (2026-10-03). The standalone game had not loaded a jingle's recording when it came due,
	/// and `SoundFile.Duration` on it threw "VSound_t was null when calling Duration" — inside `Sing`, BEFORE the jingle played,
	/// so each throw was a jingle that never sounded: 73 in the round-88 game, and in every standalone log since 2026-09-29. The
	/// editor loads them up front, which is why it never showed there. An unloaded recording now falls through to the measured
	/// table, which has all sixteen.
	/// </summary>
	static float JingleSeconds( string cue )
	{
		if ( ResourceLibrary.TryGet<SoundEvent>( $"sounds/nz/{cue}.sound", out var ev ) && ev.Sounds is { Count: > 0 } sounds )
		{
			var longest = 0f;
			foreach ( var s in sounds )
			{
				if ( s is null || !s.IsLoaded ) continue;
				try
				{
					if ( s.Duration > longest ) longest = s.Duration;
				}
				catch ( System.Exception ) { }
			}

			if ( longest > 1f ) return longest;
		}

		return MeasuredJingle.TryGetValue( cue, out var measured ) ? measured : JingleSpacing;
	}

	void StopJingle()
	{
		if ( _jingle.IsValid() ) _jingle.Stop();
		_jingle = default;
	}

	/// <summary>
	/// `nz_jingles [0|1|now]` — the perk machines' jingles: every machine's cue and when its next is due. `0` silences them and `1`
	/// brings them back (this session — on the host, for everyone); `now` plays the nearest powered machine's at once, for everyone
	/// from the host and for this machine alone from a client.
	/// </summary>
	[ConCmd( "nz_jingles" )]
	public static void JinglesCmd( string what = "" )
	{
		if ( what == "0" ) Jingles = false;
		else if ( what == "1" ) Jingles = true;

		if ( what == "now" )
		{
			var me = NZPlayer.Local;
			var near = All.Where( m => m.IsValid() && m.Powered && NZSound.Exists( m.JingleCue ) )
				.OrderBy( m => me.IsValid() ? m.WorldPosition.DistanceSquared( me.WorldPosition ) : 0f ).FirstOrDefault();

			if ( near is null ) { Log.Warning( "[nz-perk] no powered machine with a jingle" ); return; }

			// ⚠️ A CLIENT HEARS IT ALONE: the host sings for everyone, and this is for hearing one
			if ( !NZGame.IsHost )
			{
				NZSound.Play( near.JingleCue, near.WorldPosition );
				Log.Info( $"[nz-perk] {near.JingleCue} — on this machine only; the host decides everyone's" );
				return;
			}

			// ⚠️ AND THE WINDOW AND THE MINUTE OPEN: `now` is for hearing one at once, and it cuts the one before
			if ( _anyJingle.IsValid() ) _anyJingle.Stop();
			_anyJingle = default;
			_jingleEnds = 0f;
			_sinceAnyJingle = null;
			Jingles = true;

			near.Sing( near.JingleCue );
			near._nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );
		}

		if ( !NZGame.IsHost )
		{
			Log.Info( "[nz-perk] the host decides every jingle and sends it — this machine draws no timers of its own"
				+ (what is "0" or "1" ? " (so 0 and 1 change nothing here: run them on the host)" : "") );
			return;
		}

		Log.Info( $"[nz-perk] jingles {(Jingles ? "ON" : "OFF")} · one at a time, {JingleGap.x:0}-{JingleGap.y:0} s apart per machine,"
			+ $" never two starting within {JingleSpacing:0} s"
			+ (_sinceAnyJingle.HasValue ? $" · the last began {(float)_sinceAnyJingle.Value:0} s ago" : "")
			+ (JinglePlaying ? $" · one is playing, {(float)_jingleEnds:0} s left" : "") );

		foreach ( var m in All.Where( m => m.IsValid() ) )
			Log.Info( $"[nz-perk]   {m.Perk?.Name ?? m.Spot?.PerkId,-20} {(NZSound.Exists( m.JingleCue ) ? m.JingleCue : "no jingle"),-28}"
				+ (m._wasPowered ? $" next in {(float)m._nextJingle:0}s" : " unpowered")
				+ (NZSound.Exists( m.JingleCue ) ? $" · {JingleSeconds( m.JingleCue ):0} s long" : "") );
	}

	/// <summary>
	/// What it costs THIS player: the Wunderfizz's price, 2,500 + 500 for every perk they own.
	///
	/// ⛔ THE SAME PRICE AS THE WUNDERFIZZ, BY REQUEST (user, 2026-09-27: "perk machines should cost
	/// the same as wunderfizz, 2500 base + 500 per perk owned"). It was the perk's own list price,
	/// flat: Quick Revive 1,500 and Mule Kick 4,000 however many perks you had, and never what the
	/// Wunderfizz charged for the same perk.
	///
	/// ⚠️ THE MAP'S WUNDERFIZZ'S NUMBERS when it has one, through <see cref="Wunderfizz.PerkPriceFor"/>,
	/// so retuning that machine retunes these and the two cannot drift.
	///
	/// ⚠️ A PRICE SET ON THE SPOT STILL WINS, flat. -1, which every placed machine has, is "auto",
	/// and auto is now this; a number is a choice a mapper made, like the free Quick Revive
	/// PerkMachineSpot.Price describes.
	///
	/// ⚠️ PER PLAYER, so there is no Price property any more: a number with no player in it is not
	/// what anyone is charged.
	/// </summary>
	public int PriceFor( NZPlayer player )
	{
		if ( Spot is null ) return 0;
		if ( Spot.Price >= 0 ) return Spot.Price;
		return Wunderfizz.PerkPriceFor( player );
	}

	/// <summary>
	/// Why this machine will not serve, or "" when it will.
	///
	/// ⚠️ The gates that depend on the WORLD only — power, round, flag. Whether this particular
	/// player can afford it or already owns the perk belongs in <see cref="Buy"/>, because those
	/// are refusals with a price attached and the prompt shows the offer instead.
	/// </summary>
	public string Unavailable( NZPlayer player )
	{
		if ( Spot is null ) return "";

		var name = Perk?.Name ?? "Perk machine";

		if ( Spot.RequiresPower && !Power.IsOn )
			return $"{name} — needs power";

		var round = Game.ActiveScene?.GetAllComponents<RoundManager>()
			.FirstOrDefault()?.Round ?? 0;

		if ( Spot.StartRound > 1 && round < Spot.StartRound )
			return $"{name} — from round {Spot.StartRound}";

		if ( !DoorLinks.IsOpen( Spot.Link ) )
			return $"{name} — locked";

		return "";
	}

	/// <summary>
	/// Buy this machine's perk. Returns what to tell the player.
	///
	/// ⚠️ EVERY REFUSAL IS A SENTENCE, not a silent false — "nothing happened when I pressed E"
	/// is the failure this whole class is written against, the same as the Wunderfizz.
	/// </summary>
	public string Buy( NZPlayer player )
	{
		if ( !player.IsValid() ) return "";

		var perk = Perk;
		if ( perk is null ) return $"perk machine has unknown perk '{Spot?.PerkId}'";

		var blocked = Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		if ( player.HasPerk( perk.Id ) )
			return $"You already have {perk.Name}";

		// ⛔ THE SLOT CHECK COMES BEFORE THE SPEND. GivePerk refuses when the cap is full and the
		// spend below is UNCONDITIONAL, so without this the player pays full price and receives
		// nothing — the exact trap Wunderfizz.Buy documents.
		if ( player.PerksFull )
			return $"No free perk slot ({player.Perks.Count}/{player.PerkSlots})";

		var price = PriceFor( player );

		// ⛔ CHECKED BEFORE SPENDING, and the message says the shortfall. A bare "not enough
		// points" makes the player count in their head.
		if ( player.Points < price )
			return $"{perk.Name} costs {price} — you need {price - player.Points} more";

		if ( !player.TrySpend( price ) )
			return "Purchase failed";

		// ⚠ TIMESLIP m2 TIME OUT — AFTER THE SPEND SUCCEEDED, never before. A refused purchase
		// must not buy 15 seconds of invisibility.
		TimeAugments.OnMachineUsed( player, perk.Name );

		player.GivePerk( perk.Id );

		// ⚠️ The multiplier effects need nothing here — they are derived from the owned list. This
		// is only for the one-offs, which today means Juggernog moving current health up to the
		// new maximum.
		PerkEffects.OnPerkGained( player, perk.Id );

		NZSound.Play( NZSound.PerkVend, WorldPosition );

		return $"{perk.Name} bought for {price}";
	}

	/// <summary>The machine within use range of a point, or null.</summary>
	public static PerkMachine Near( Vector3 pos )
	{
		PerkMachine best = null;
		float bestDist = UseRange;

		foreach ( var m in All )
		{
			if ( !m.IsValid() ) continue;

			// ⚠️ Distance to the machine's ORIGIN, which sits at its base. A tall machine measured
			// from its centre would refuse a player standing at its foot, which is exactly where
			// they stand.
			var d = pos.Distance( m.WorldPosition );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = m;
		}

		return best;
	}
}