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.
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}";
}
}