Component representing a standing Der Wunderfizz perk machine. It tracks all live machines, computes prices and slot costs per player, enforces purchase rules (affordability, slot cap, power/door/round availability), and performs the buy actions (spend points, grant perk or slot, invoke time/perk effect hooks).
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// A standing Der Wunderfizz machine — the thing the player walks up to.
///
/// ⚠️ The config's <see cref="WunderfizzSpot"/> 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: price escalation, cooldowns and whatever else
/// accrues belong here, so nothing accumulates into the saved map.
/// </summary>
public sealed class Wunderfizz : Component
{
public static readonly List<Wunderfizz> All = new();
protected override void OnEnabled() => All.Add( this );
protected override void OnDisabled() => All.Remove( this );
/// <summary>The authored settings this machine was built from.</summary>
[Property] public WunderfizzSpot Spot { get; set; }
/// <summary>
/// Buy the selected perk for a player. Returns what to tell them.
///
/// ⚠️ EVERY REFUSAL IS A SENTENCE, not a silent false. "Nothing happened when
/// I pressed buy" is the failure this whole class has been written against.
/// </summary>
public string Buy( NZPlayer player, PerkRegistry.Perk perk )
{
if ( !player.IsValid() || perk is null ) return "";
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 returns false — and the spend below is UNCONDITIONAL, so
// without this the player pays full price and receives nothing. Checked
// here rather than trusting the return value because refunding after the
// fact is a second failure path that can also go wrong.
if ( player.PerksFull )
return $"No free perk slot ({player.Perks.Count}/{player.PerkSlots}) — buy a slot first";
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; that is the same reasoning the box's own
// `Uses++` comment gives two lines down.
TimeAugments.OnMachineUsed( player, "the wunderfizz" );
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 );
return $"{perk.Name} bought for {price}";
}
/// <summary>Buy one extra perk SLOT, raising this player's cap by one.
///
/// ⚠️ Priced from the spot's `PerkSlotPrice`, flat — it does not ride the
/// per-perk price ramp, because the ramp already makes each PERK dearer and
/// compounding both would put the fourth slot out of reach of any real game.
///
/// ⛔ The slot lands on the PLAYER (`BonusPerkSlots`), never on the shared
/// config.</summary>
/// <summary>What one more perk slot costs THIS player at THIS machine.
///
/// `PerkSlotPrice + PerkSlotIncrement * BonusPerkSlots` — the first extra slot costs
/// exactly the configured base and each one after costs the increment more. An
/// increment of 0 is flat, which is what every map predating the setting gets.
///
/// ⛔ THE ONE AUTHOR OF THIS NUMBER. The menu prints it, the affordability test
/// compares against it and `BuySlot` charges it — three readers, and recomputing the
/// escalation in the UI is how a price shown and a price charged start to disagree.
///
/// ⚠️ IT RIDES THE PLAYER, NOT THE MACHINE. Two players at the same Wunderfizz see
/// different prices, which is correct — the escalation is a property of how many
/// slots YOU have bought — and it is why the figure cannot be cached on the spot.</summary>
public int SlotPriceFor( NZPlayer player )
=> SlotPriceAt( player.IsValid() ? player.BonusPerkSlots : 0 );
/// <summary>The same price, asked for a hypothetical number of slots bought.
///
/// ⚠️ THE FORMULA LIVES HERE AND `SlotPriceFor` IS A WRAPPER, so a diagnostic that
/// wants the ladder ahead of the player can ask for it without TEMPORARILY WRITING
/// `BonusPerkSlots` — which would hand the player invented slots if anything threw
/// between the write and the restore.</summary>
public int SlotPriceAt( int bought )
{
var basePrice = Spot?.PerkSlotPrice ?? 10000;
var step = Spot?.PerkSlotIncrement ?? 0;
// ⚠️ Clamped at 0 rather than trusted: a negative increment is reachable from a
// console command and would eventually pay the player to buy slots.
return Math.Max( 0, basePrice + step * Math.Max( 0, bought ) );
}
public string BuySlot( NZPlayer player )
{
if ( !player.IsValid() ) return "";
var blocked = Unavailable( player );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
var price = SlotPriceFor( player );
if ( player.Points < price )
return $"A perk slot 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; that is the same reasoning the box's own
// `Uses++` comment gives two lines down.
TimeAugments.OnMachineUsed( player, "the wunderfizz" );
player.BonusPerkSlots++;
// ⚠️ Says what the NEXT one costs. The price moved as a result of this purchase,
// and a player who is not told reads the new figure on the button as the machine
// changing its mind.
var next = SlotPriceFor( player );
return $"Perk slot bought for {price} — {player.Perks.Count}/{player.PerkSlots} used"
+ ( next != price ? $" · next slot {next}" : "" );
}
/// <summary>How far away it can be used from. Matches the mystery box.</summary>
public const float UseRange = 96f;
/// <summary>
/// What the next perk costs THIS player.
///
/// ⛔ NOT A COST TO OPEN IT. Walking up and browsing is free; this is charged
/// when something is taken out. The prompt therefore carries no bracketed
/// price — see UsePrompt.ForWunderfizz.
///
/// ⛔ SCALES ON THE PLAYER'S OWNED PERKS, NOT ON THIS MACHINE'S USE COUNT.
/// That was the open question when these options were added, and it is now
/// answered: your fourth perk costs the same wherever you buy it, and a
/// second machine is not a discount. Per-machine would have rewarded walking
/// to the other one, which is a route optimisation and not a decision.
/// </summary>
public int PriceFor( NZPlayer player ) => PriceFrom( Spot, player );
/// <summary>
/// A perk's price from a Wunderfizz's numbers: its base, plus its increment for every perk the
/// player already owns.
///
/// ⛔ THE ONE FORMULA. This machine's price and every perk machine's come through here (see
/// <see cref="PerkPriceFor"/>), so a perk costs the same at either, which is the user's rule —
/// and two copies of the sum are how one gets retuned and the other not.
///
/// ⚠️ NO SPOT PRICES AT A FRESH SPOT'S DEFAULTS, 2,500 and 500, so the numbers live in one place.
/// </summary>
public static int PriceFrom( WunderfizzSpot spot, NZPlayer player )
{
spot ??= new WunderfizzSpot();
var owned = player.IsValid() ? player.Perks.Count : 0;
return Math.Max( 0, spot.BasePrice + spot.PriceIncrement * owned );
}
/// <summary>
/// The Wunderfizz whose numbers the perk machines charge: the map's first, or null when it has
/// none, and then the defaults apply.
///
/// ⚠️ THE FIRST, if a map has two priced differently. Picking the cheapest instead would let a
/// second, cheaper Wunderfizz reprice every perk machine on the map.
/// </summary>
public static WunderfizzSpot PerkPriceSpot
=> ActiveConfig.Current?.Wunderfizzes?.FirstOrDefault();
/// <summary>What a perk machine charges this player: the Wunderfizz's price.</summary>
public static int PerkPriceFor( NZPlayer player ) => PriceFrom( PerkPriceSpot, player );
/// <summary>The rule in words, for readouts: "2,500 + 500 per perk owned", or brief "2,500 +500/perk".</summary>
public static string PerkPriceRule( bool brief = false )
{
var s = PerkPriceSpot ?? new WunderfizzSpot();
if ( s.PriceIncrement == 0 ) return $"{s.BasePrice:N0}";
return brief
? $"{s.BasePrice:N0} +{s.PriceIncrement:N0}/perk"
: $"{s.BasePrice:N0} + {s.PriceIncrement:N0} per perk owned";
}
/// <summary>Flat price with no player — for readouts only.</summary>
public int Price => Spot?.BasePrice ?? 2500;
/// <summary>
/// Why this machine cannot be used right now, or empty if it can.
///
/// ⚠️ Returns a REASON rather than a bool, because every one of these needs
/// saying out loud on the HUD. "Nothing happens when I press E" is the worst
/// possible answer to a locked machine, and a bool cannot tell the player
/// whether to switch the power on or come back in four rounds.
/// </summary>
public string Unavailable( NZPlayer player )
{
if ( Spot is null ) return "";
if ( Spot.RequiresPower && !Power.IsOn )
return "Der Wunderfizz — needs power";
var round = Game.ActiveScene?.GetAllComponents<RoundManager>()
.FirstOrDefault()?.Round ?? 0;
if ( Spot.StartRound > 1 && round < Spot.StartRound )
return $"Der Wunderfizz — from round {Spot.StartRound}";
if ( !DoorLinks.IsOpen( Spot.Link ) )
return "Der Wunderfizz — locked";
return "";
}
/// <summary>The machine within use range of a point, or null.</summary>
public static Wunderfizz Near( Vector3 pos )
{
Wunderfizz best = null;
float bestDist = UseRange;
foreach ( var w in All )
{
if ( !w.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( w.WorldPosition );
if ( d >= bestDist ) continue;
bestDist = d;
best = w;
}
return best;
}
}