Buyables/Wunderfizz.cs

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).

File Access
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;
	}
}