Player/BananaAugments.cs

Static helper for the Banana Colada perk. Defines tuning values, resolves which placeable kind a player has, awards charge on zombie kills, enforces placement rules, handles refunds when a placeable is broken, and provides console commands for diagnostics and live tuning.

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

namespace NZombies;

/// <summary>
/// Banana Colada's augments. Base perk: slides last ×1.8 as long, with a forgiving 1.5s window to
/// chain the next one.
///
/// | | effect |
/// |---|---|
/// | M1 **Slick Bar** | a bar on the floor; zombies crossing it slip |
/// | M2 **One-Way Wall** | you walk through it, they have to break it |
/// | M3 **Banana Stand** | the horde goes for it instead of you |
/// | M4 **Springboard** | a pad that flings you |
/// | m1 **Sticky Fingers** | kills recharge **+25%** |
/// | m2 **Big Bunch** | footprint **+50%** |
/// | m3 **Tough Peel** | durability **+20%** |
/// | m4 **Long Shelf Life** | **double** the time limit |
/// | m5 **Nothing Wasted** | broken early, unspent time comes back as charge (up to **50%**) |
///
/// ⛔ ALL FOUR MAJORS ARE PLACEABLES, WHICH IS THE PERK'S WHOLE IDENTITY AND A DELIBERATE REDESIGN.
/// The ported pool was nine slide/dive/low-gravity augments, most of which needed a dive system that
/// does not exist, and PhD Flopper already owns vertical mobility (m4 jump height, m5 double jump).
/// Re-specified by request around the one thing nothing else in the project does: letting the player
/// put an object into the world that zombies have to deal with.
///
/// ⚠️ AND THE FOUR ARE FOUR DIFFERENT *CATEGORIES*, not one idea printed four times — debuff, block,
/// redirect, propel. That spread is what stops the perk collapsing into "place a thing" with cosmetic
/// variations, and it is worth preserving if any of them is ever retuned.
///
/// ⛔ THE FIVE MINORS ALL SCALE THE SHARED SPINE rather than adding behaviour, so every one of them
/// works on whichever major you took. That is why `Placeable` has exactly four numbers — charge,
/// durability, lifetime, size — and the minors are one multiplier each.
///
/// ⚠️ m4 HELPS m5 AND m3 FIGHTS IT, which is a real tension and not a bug. m5 refunds a FRACTION of
/// the lifetime, so doubling the lifetime (m4) makes the same wall-clock death a bigger fraction and
/// a bigger refund; raising durability (m3) makes it survive longer, so less time is left when it
/// finally breaks. Worth knowing before either is retuned.
/// </summary>
public static class BananaAugments
{
	const string Perk = "banana";

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _chargePerKill;
	/// <summary>
	/// Charge earned per kill, as a fraction of a full placement. 0.02 — 50 kills (40 with Sticky Fingers).
	///
	/// ⛔ WAS 0.08 (13 KILLS) UNTIL 2026-10-03, FOUR TIMES FASTER. In the round-88 game a late horde refilled it in seconds:
	/// 60 Banana Stands in 49 minutes, one every ~22 s, each pulling the whole live horde off the player. The user: *"players
	/// hardly need to kill to recharge the bar, i need the recharge to be 4x slower"*. Nothing Wasted's refund (m5) is a share
	/// of a full charge, so it is unaffected.
	///
	/// ⚠️ EXPRESSED AS A SHARE OF ONE PLACEMENT rather than as points, so the minors are plain
	/// multipliers and the HUD can draw it as a bar without knowing a scale.
	/// </summary>
	public static float ChargePerKill
	{
		get => _chargePerKill ?? 0.02f;
		set => _chargePerKill = value;
	}

	static float? _stickyFingers;
	/// <summary>m1 Sticky Fingers — charge-per-kill multiplier. ×1.25.</summary>
	public static float StickyFingers
	{
		get => _stickyFingers ?? 1.25f;
		set => _stickyFingers = value;
	}

	static float? _bigBunch;
	/// <summary>m2 Big Bunch — footprint multiplier. ×1.5.</summary>
	public static float BigBunch { get => _bigBunch ?? 1.5f; set => _bigBunch = value; }

	static float? _toughPeel;
	/// <summary>m3 Tough Peel — durability multiplier. ×1.2.</summary>
	public static float ToughPeel { get => _toughPeel ?? 1.2f; set => _toughPeel = value; }

	static float? _shelfLife;
	/// <summary>m4 Long Shelf Life — lifetime multiplier. ×2.</summary>
	public static float ShelfLife { get => _shelfLife ?? 2f; set => _shelfLife = value; }

	static float? _refundCap;
	/// <summary>
	/// m5 Nothing Wasted — the most of a placement that can come back. 0.5.
	///
	/// ⚠️ THIS IS THE CEILING, NOT THE PAYOUT. The refund is `timeLeftFraction × this`, so half the
	/// time left pays 25% and a placeable destroyed the instant it lands pays the full 50%. Which
	/// means the augment rewards placing INTO a crowd rather than placing safely — deliberate.
	/// </summary>
	public static float RefundCap { get => _refundCap ?? 0.5f; set => _refundCap = value; }

	static float? _placeAhead;
	/// <summary>How far in front of the player a placeable lands. 90u.</summary>
	public static float PlaceAhead { get => _placeAhead ?? 90f; set => _placeAhead = value; }

	static bool Has( NZPlayer p, string augId )
		=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );

	// ══ the minors, as resolvers the spine reads ══════════════════════════════

	/// <summary>m1 — charge-per-kill multiplier. 1 when not held.</summary>
	public static float ChargeScale( NZPlayer p )
		=> Has( p, "m1" ) ? MathF.Max( 0f, StickyFingers ) : 1f;

	/// <summary>m2 — footprint multiplier. 1 when not held.</summary>
	public static float SizeScale( NZPlayer p )
		=> Has( p, "m2" ) ? MathF.Max( 0.1f, BigBunch ) : 1f;

	/// <summary>m3 — durability multiplier. 1 when not held.</summary>
	public static float DurabilityScale( NZPlayer p )
		=> Has( p, "m3" ) ? MathF.Max( 0.1f, ToughPeel ) : 1f;

	/// <summary>m4 — lifetime multiplier. 1 when not held.</summary>
	public static float LifeScale( NZPlayer p )
		=> Has( p, "m4" ) ? MathF.Max( 0.1f, ShelfLife ) : 1f;

	// ══ charge ════════════════════════════════════════════════════════════════

	/// <summary>
	/// Which placeable this player's equipped major grants, or null.
	///
	/// ⛔ FIRST MAJOR FOUND, AND THAT ONLY MATTERS IN CREATIVE. Normal play equips ONE major, so
	/// there is exactly one answer; the Creative override lifts that limit, and rather than leave the
	/// behaviour undefined the rule is written down here: M1 wins, then M2, then M3, then M4. The
	/// same situation `DtapAugments` documents for its major pair.
	/// </summary>
	public static PlaceKind? KindFor( NZPlayer player )
	{
		if ( Has( player, "M1" ) ) return PlaceKind.SlickBar;
		if ( Has( player, "M2" ) ) return PlaceKind.Wall;
		if ( Has( player, "M3" ) ) return PlaceKind.Stand;
		if ( Has( player, "M4" ) ) return PlaceKind.Springboard;

		return null;
	}

	/// <summary>
	/// A zombie died — bank charge toward the next placement.
	///
	/// ⛔ GATED ON HOLDING A MAJOR, NOT JUST THE PERK. Charge with nothing to spend it on is a bar
	/// that fills and never empties, which reads as broken rather than as unused. A player on the
	/// base perk alone banks nothing.
	///
	/// ⚠️ IT DOES NOT CARE WHICH MAJOR. One meter serves all four because only one is equipped —
	/// see `KindFor` for the Creative case.
	/// </summary>
	public static void OnZombieKilled( GameObject killer, GameObject victim )
	{
		if ( !killer.IsValid() ) return;

		var player = killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
		if ( !player.IsValid() ) return;

		if ( KindFor( player ) is null ) return;

		// ⛔ NO CHARGE WHILE ONE OF YOURS IS OUT (2026-10-04, the user: *"No recharge while a placeable is out is a great
		// idea"*). The 4x slower recharge alone left the round-88 rhythm where it was: at the late kill rate its 40 kills take
		// about 22 s, the old gap between Banana Stands. Now the next one only starts to charge once the last one is gone.
		//
		// ⚠️ SO A SECOND ONE OF THE SAME KIND CANNOT BE BANKED any more, and the cap of 2 on the bar and the pad is out of
		// reach: placing spends the whole charge, and none comes back until the floor is clear. m5's refund is not a kill
		// and still pays, on a placeable that has just gone.
		if ( HasOneOut( player ) ) return;

		var was = player.PlaceCharge;

		player.PlaceCharge = MathF.Min( 1f,
			was + MathF.Max( 0f, ChargePerKill ) * ChargeScale( player ) );

		// ⚠️ LOGGED ONLY ON REACHING FULL, not on every kill. A line per kill would bury the console
		// in a horde, and "it is ready" is the only moment the player needs told about.
		if ( was < 1f && player.PlaceCharge >= 1f )
			Log.Info( $"[nz-aug] banana — {KindFor( player )} ready to place" );
	}

	/// <summary>
	/// Does this player have a placeable in the world right now — the one condition that stops kills charging the next.
	///
	/// ⚠️ ASKED ON THE KILLER'S OWN MACHINE, which is where the charge lives (`AugmentEffects.OnZombieKilled` relays), and
	/// every machine builds its own copy of every placeable with its `Owner` set (`Placeable.Spawn`), so the answer is there.
	/// </summary>
	public static bool HasOneOut( NZPlayer player )
	{
		if ( !player.IsValid() ) return false;

		foreach ( var p in Placeable.All )
			if ( p.Owner == player ) return true;

		return false;
	}

	/// <summary>
	/// m5 — pay back unspent time as charge, when a placeable was BROKEN rather than expired.
	///
	/// ⛔ `Placeable.OnDestroy` DECIDES WHETHER TO CALL THIS, and this method trusts it. The gate is
	/// there because three things destroy a placeable — durability, the clock, and being evicted at
	/// the cap — and only the first should pay. Putting the gate here as well would be two authors
	/// for one rule (§3); putting it only here would mean a cap-eviction refund, which is a charge
	/// farm: place, place, place at the cap and get paid each time.
	/// </summary>
	public static void RefundOnBreak( NZPlayer player, float timeLeft, float life )
	{
		if ( !Has( player, "m5" ) ) return;
		if ( life <= 0f ) return;

		var fraction = Math.Clamp( timeLeft / life, 0f, 1f );
		var back = fraction * MathF.Max( 0f, RefundCap );

		if ( back <= 0.001f ) return;

		player.PlaceCharge = MathF.Min( 1f, player.PlaceCharge + back );

		Log.Info( $"[nz-aug] banana m5 Nothing Wasted — {fraction * 100f:0}% of the time left"
			+ $" → {back * 100f:0}% charge back (cap {RefundCap * 100f:0}%)"
			+ $" · now {player.PlaceCharge * 100f:0}%" );
	}

	// ══ placing ═══════════════════════════════════════════════════════════════

	/// <summary>
	/// Why this player cannot place right now, or null when they can.
	///
	/// ⚠️ A REASON RATHER THAN A BOOL, the convention `Armor.WhyCannotPlate` set for exactly this
	/// job — a key that "did nothing" must always be able to say why. Every refusal below is a
	/// different thing for the player to fix.
	/// </summary>
	public static string WhyCannotPlace( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";
		if ( !player.HasPerk( Perk ) ) return "you do not have Banana Colada";
		if ( KindFor( player ) is null ) return "no major augment equipped — nothing to place";
		if ( player.PlaceCharge < 1f )
			return HasOneOut( player )
				? $"charge {player.PlaceCharge * 100f:0}% — paused while yours is still out"
				: $"charge {player.PlaceCharge * 100f:0}% — kill zombies to fill it";

		return null;
	}

	/// <summary>
	/// Put one down in front of the player and spend the charge.
	///
	/// ⚠️ FLATTENED WITH `WithZ( 0 )` so looking at the sky still puts it on the floor ahead of you.
	/// `Placeable.Spawn` traces down 256u, and a point thrown into the air would miss entirely.
	/// </summary>
	public static bool TryPlace( NZPlayer player )
	{
		var why = WhyCannotPlace( player );

		if ( why is not null )
		{
			Log.Info( $"[nz-aug] banana — cannot place: {why}" );
			return false;
		}

		var kind = KindFor( player ).Value;
		var fwd = player.EyeAngles.Forward.WithZ( 0f ).Normal;
		var at = player.WorldPosition + fwd * MathF.Max( 0f, PlaceAhead );

		var p = Placeable.Spawn( player, kind, at );
		if ( !p.IsValid() ) return false;

		player.PlaceCharge = 0f;

		// ⛔ NEARBY ZOMBIES ARE PUSHED TO RE-ACQUIRE, AND ONLY FOR THE STAND. `AcquireTarget` runs on
		// a 3-15s cadence, so a lure placed to save you would take up to fifteen seconds to work —
		// which is not a lure, it is a decoration. `ForceRetarget` exists as a public push documented
		// for exactly this from-outside case.
		//
		// ⚠️ THE OTHER THREE MUST *NOT* DO THIS. A bar, a wall and a pad are things zombies walk into
		// while chasing you; making them retarget would be telling the horde to look at a wall.
		if ( kind == PlaceKind.Stand ) BananaStand.PullNearby( p );

		Log.Info( $"[nz-aug] banana — placed {kind} at {p.WorldPosition}"
			+ $" · {p.Total} use(s), {p.Life:0.#}s, {p.Size:0}u"
			+ $" ({Placeable.SpecFor( kind ).SizeNote})" );

		return true;
	}

	// ══ diagnostics ═══════════════════════════════════════════════════════════

	static NZPlayer Me()
		=> NZPlayer.Local;

	/// <summary>
	/// `nz_aug_banana_place` — place one, ignoring the key bind.
	///
	/// ⛔ IT STILL SPENDS THE CHARGE AND STILL REFUSES, because a test command that bypasses the
	/// economy tests something the game never does. `nz_aug_banana_charge 1` is the way to skip the
	/// grind; this is the way to exercise the real path.
	///
	/// ⛔ RENAMED FROM `nz_place`, WHICH IT WAS STEALING FROM THE MAP EDITOR. Two files registered
	/// that name and s&box keeps the FIRST — it logs "Command nz_place already exists - not
	/// overwriting" and carries on — so `nz_place` with a build tool armed answered
	/// "you do not have Banana Colada" instead of placing anything. A generic name on a
	/// perk-specific test command; the editor's is the one that deserves it.
	///
	/// ⚠️ A COMMAND NAME IS CLAIMED FOR THE LIFE OF THE EDITOR SESSION, so this rename does not
	/// take effect until s&box is restarted. Same trap `nz_stats` -> `nz_score` hit.
	/// </summary>
	[ConCmd( "nz_aug_banana_place" )]
	public static void PlaceCmd() => TryPlace( Me() );

	/// <summary>`nz_aug_banana_charge [0-1]` — set the charge meter.</summary>
	[ConCmd( "nz_aug_banana_charge" )]
	public static void ChargeCmd( float value = 1f )
	{
		var p = Me();
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		p.PlaceCharge = Math.Clamp( value, 0f, 1f );
		Log.Info( $"[nz-aug] banana charge = {p.PlaceCharge * 100f:0}%" );
	}

	/// <summary>`nz_aug_banana` — every resolved number, and what is on the floor.</summary>
	[ConCmd( "nz_aug_banana" )]
	public static void BananaCmd()
	{
		var player = Me();
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var has = player.HasPerk( Perk );
		var equipped = PerkAugments.EquippedOn( player, Perk );
		var kind = KindFor( player );

		Log.Info( $"[nz-aug] BANANA COLADA {(has ? "owned" : "NOT OWNED — every line below is inert")}"
			+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]"
			+ $" · slide x{PerkEffects.BananaSlideDuration:0.##}" );

		Log.Info( $"[nz-aug]  placing      {(kind is null ? "NOTHING — no major equipped" : kind.ToString())}"
			+ $" · charge {player.PlaceCharge * 100f:0}%{(HasOneOut( player ) ? " (PAUSED — one is out)" : "")}"
			+ $" · {ChargePerKill * ChargeScale( player ) * 100f:0.#}% per kill"
			+ $" (~{MathF.Ceiling( 1f / MathF.Max( 0.001f, ChargePerKill * ChargeScale( player ) )):0} kills)" );

		// ⚠️ THE RESOLVED SPEC IS SHOWN AGAINST THE AUTHORED ONE, because every minor is a multiplier
		// and "x1.5" says nothing about whether the result is sensible.
		if ( kind is not null )
		{
			var spec = Placeable.SpecFor( kind.Value );

			Log.Info( $"[nz-aug]  durability   {spec.Durability}"
				+ $" -> {Math.Max( 1, (int)MathF.Round( spec.Durability * DurabilityScale( player ) ) )}"
				+ $"   life {spec.Seconds:0.#}s -> {spec.Seconds * LifeScale( player ):0.#}s"
				+ $"   {spec.SizeNote} {spec.Size:0}u -> {spec.Size * SizeScale( player ):0}u"
				+ $"   cap {spec.Cap}" );
		}

		Log.Info( $"[nz-aug]  m1 Sticky    {(Has( player, "m1" ) ? $"charge x{StickyFingers:0.##}" : "-")}"
			+ $"   m2 Bunch {(Has( player, "m2" ) ? $"size x{BigBunch:0.##}" : "-")}"
			+ $"   m3 Peel {(Has( player, "m3" ) ? $"dura x{ToughPeel:0.##}" : "-")}"
			+ $"   m4 Shelf {(Has( player, "m4" ) ? $"life x{ShelfLife:0.##}" : "-")}" );

		// ⚠️ THE REFUND IS SHOWN AS A WORKED EXAMPLE, because "up to 50%" is the part people misread
		// — it is a fraction OF a fraction, not a flat 50%.
		Log.Info( $"[nz-aug]  m5 Wasted    {(Has( player, "m5" ) ? $"up to {RefundCap * 100f:0}%" : "-")}"
			+ $"   worked: half the time left -> {0.5f * RefundCap * 100f:0}% back"
			+ $" · broken instantly -> {RefundCap * 100f:0}%"
			+ " · expired -> 0%" );

		var live = Placeable.All.ToList();

		Log.Info( $"[nz-aug]  on the floor {live.Count}" );

		foreach ( var p in live )
			Log.Info( $"[nz-aug]    {p.Kind,-12} {p.Left}/{p.Total} use(s)"
				+ $" · {(float)p.Dies:0.#}s of {p.Life:0.#}s left"
				+ $" · {p.Size:0}u · at {p.WorldPosition}" );
	}

	/// <summary>`nz_aug_banana_set &lt;key&gt; &lt;value&gt;` — retune one number live.</summary>
	[ConCmd( "nz_aug_banana_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "perkill": ChargePerKill = value; break;
			case "sticky": StickyFingers = value; break;
			case "bunch": BigBunch = value; break;
			case "peel": ToughPeel = value; break;
			case "shelf": ShelfLife = value; break;
			case "refund": RefundCap = value; break;
			case "ahead": PlaceAhead = value; break;

			default:
				Log.Info( "[nz-aug] nz_aug_banana_set <perkill|sticky|bunch|peel|shelf"
					+ "|refund|ahead> <value>" );
				Log.Info( "[nz-aug]   per-kind durability/life/size: literals in Placeable.SpecFor" );
				return;
		}

		Log.Info( $"[nz-aug] banana {key} = {value:0.###}" );
		BananaCmd();
	}
}