Buyables/BuyableEnding.cs

Component representing a walk-up buyable 'ending' in the game, with price, hint text, availability checks, and purchase logic that grants team perks and ends the run when bought.

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

namespace NZombies;

/// <summary>
/// THE BUYABLE ENDING — walk up, pay, the run is over.
///
/// Ported from the original's `buyable_ending` entity
/// (entities/entities/buyable_ending/shared.lua). The prop is whatever the mapper
/// points it at; upstream only DEFAULTS to a teddy bear.
///
/// ⚠️ THE SHAPE IS THE AMMO BOX'S, deliberately — `All` / `Near` / `Unavailable` /
/// `Buy`, with `Unavailable` answering for both the prompt and the key. Every
/// walk-up-and-spend machine here is written this way so the prompt can never offer
/// something the use key then refuses.
/// </summary>
public sealed class BuyableEnding : Component
{
	/// <summary>The original's fallback prop, set in its `ENT:Initialize`.</summary>
	public const string DefaultModel = "models/hoff/props/teddy_bear/teddy_bear.vmdl";

	/// <summary>Every live ending, for the use trace and the prompt.</summary>
	public static readonly List<BuyableEnding> All = new();

	protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
	protected override void OnDisabled() => All.Remove( this );

	/// <summary>The config row this was built from.</summary>
	[Property] public EndingSpot Spot { get; set; }

	/// <summary>How close you must stand. Matches the ammo box and Pack-a-Punch.</summary>
	public const float UseRange = 90f;

	/// <summary>
	/// The nearest ending, or null.
	///
	/// ⚠️ Distance to the ORIGIN, which sits at the prop's base — the same choice
	/// `AmmoBox.Near` and `Wunderfizz.Near` document. Measuring from a tall model's
	/// middle makes it feel unreachable when you are stood against it.
	/// </summary>
	public static BuyableEnding Near( Vector3 pos )
	{
		BuyableEnding best = null;
		var bestDist = UseRange;

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

			var d = pos.Distance( e.WorldPosition );
			if ( d > bestDist ) continue;

			bestDist = d;
			best = e;
		}

		return best;
	}

	/// <summary>What it costs here. 0 is free.</summary>
	public int Price => Spot?.Price ?? 0;

	/// <summary>The prompt line. Blank falls back to the original's "End game".</summary>
	public string Hint => string.IsNullOrWhiteSpace( Spot?.Hint ) ? "End game" : Spot.Hint;

	/// <summary>
	/// Why this cannot be used right now, or empty when it can.
	///
	/// ⚠️ ONE METHOD ANSWERS FOR BOTH THE PROMPT AND THE KEY — the rule `NZPlayer.TickUse`
	/// and `UsePrompt.Text` are both written against. A prompt that offers what E refuses
	/// is worse than no prompt.
	///
	/// ⛔ THE PRICE CHECK IS LAST. Round, power and flag gates describe the WORLD and do
	/// not change while you stand there; "you need 200 more" describes YOU and does. Put
	/// the affordability test first and a locked exit reads as merely expensive.
	/// </summary>
	public string Unavailable( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";

		var round = RoundManager.Instance;

		// ⚠️ ALREADY OVER IS NOT AN ERROR, IT IS SILENCE. The original returns early
		// from `ENT:Use` when `nzRound:Victory()`, and a prompt still offering the exit
		// after the run has ended would invite a second purchase.
		if ( round.IsValid() && round.State == RoundState.GameOver )
			return "the run is already over";

		var start = Spot?.StartRound ?? 0;
		if ( start > 1 && (round?.Round ?? 1) < start )
			return $"Locked until round {start}";

		if ( Spot?.RequiresPower == true && !Power.IsOn )
			return "Needs power";

		if ( !DoorLinks.IsOpen( Spot?.Link ) )
			return "Locked";

		if ( player.Points < Price )
			return $"{Hint} costs {Price:N0} — you need {Price - player.Points:N0} more";

		return "";
	}

	/// <summary>
	/// Buy it: spend the points, apply the perk options, end the run.
	///
	/// ⛔ THE POINTS ARE SPENT BEFORE ANYTHING ELSE HAPPENS, and through `TrySpend` rather
	/// than a subtraction. Ending the run first and charging afterwards would hand a free
	/// exit to anyone the spend then failed for — and `TrySpend` is the only thing that
	/// knows whether it succeeded.
	/// </summary>
	public string Buy( NZPlayer player )
	{
		var blocked = Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		if ( Price > 0 && !player.TrySpend( Price ) )
			return "Purchase failed";

		NZSound.Play( NZSound.Purchase, WorldPosition );

		// ⛔ PERKS ARE HANDED OUT BEFORE THE RUN ENDS, not after. `PermaPerks` only means
		// anything while there are still downs to survive, and `RewardPerks` on a run that
		// has already stopped is a gift nobody gets to use. Both are no-ops unless
		// KeepPlaying is set — which is exactly the case the original wrote them for.
		if ( Spot?.PermaPerks == true || Spot?.RewardPerks == true )
		{
			// ⚠️ EVERY player, not the buyer. One person pays and the whole team collects —
			// the original loops `player.Iterator()` for both flags.
			foreach ( var p in Scene.GetAllComponents<NZPlayer>().Where( x => x.IsValid() ) )
			{
				if ( Spot.PermaPerks ) p.PreventPerkLoss = true;

				// ⚠️ THROUGH `GivePerk`, SO THE SLOT CAP STILL APPLIES. The original has no
				// perk cap at all and its `GiveAllPerks` really does hand over all of them;
				// this project added `PerkSlots`, and a reward that ignored the cap would be
				// the one way to exceed a limit every other path enforces. So "reward perks"
				// fills the player's slots rather than granting the whole roster — and
				// `GivePerk` logs each refusal, so the shortfall is visible rather than silent.
				if ( Spot.RewardPerks )
					foreach ( var perk in PerkRegistry.All )
						p.GivePerk( perk.Id );
			}
		}

		if ( Spot?.KeepPlaying == true )
			return $"{Hint} bought for {Price:N0} — the run continues";

		var reason = string.IsNullOrWhiteSpace( Spot?.CustomText )
			? "Escaped"
			: Spot.CustomText;

		RoundManager.Instance?.EndGame( reason );

		return $"{Hint} bought for {Price:N0} — {reason}";
	}
}