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.
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.<perk id>` — 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;
}
}