Utility class implementing the Banana Stand augment behavior. Tracks stands in the world, provides queries for zombies to lure them, forces retargets in range, absorbs zombie swings by delegating to Placeable.Use(), and includes a console report command.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// M3 Banana Stand — the horde goes for it instead of you.
///
/// ⛔ IT IS NOT A NEW TARGETING SYSTEM. `ZombieAI.GetTargetables()` already returns an
/// `IEnumerable<GameObject>` of candidates and already FILTERS it — downed players and players
/// inside Vulture Aid's gas are both removed there, and that file's own note explains why the
/// candidate list is the right seam: *"every zombie picks it up on its next acquire automatically,
/// and stepping out of the gas restores you just as automatically with nothing to un-set."* A lure is
/// that same mechanism pointed the other way: one entry ADDED rather than removed.
///
/// ⛔ WHICH ALSO MEANS THE RETARGET CADENCE APPLIES, AND THAT WOULD HAVE MADE IT USELESS.
/// `AcquireTarget` runs every 3-15 seconds, so a stand placed to buy a reload could take fifteen
/// seconds to attract anything. `PullNearby` pushes `ForceRetarget()` at zombies in range at the
/// moment it lands — a method that exists precisely because "every other retarget in this class is
/// PULLED by `TickChase`" and a stalled zombie never re-acquires no matter how overdue it is.
///
/// ⚠️ THE SWING IS ABSORBED THE WAY A BARRICADE'S IS. `DoAttackDamage` already has a branch that
/// hands a swing to `BlockingBarricade()` and returns without touching the player — the stand takes
/// the same branch. So it needs no `Health`, no `IDamageable`, and nothing in the damage pipeline
/// changes: a zombie attacking it spends one durability and the player takes nothing.
///
/// ⚠️ AND IT IS DELIBERATELY NOT A DAMAGE SPONGE WITH HIT POINTS. Durability is "swings absorbed",
/// so a Legendary-tier horde chews it at the same rate a round-1 one does. That keeps the augment's
/// value flat across a run rather than making it worthless by round 30, and it matches the slick
/// bar's "zombies crossed" and the springboard's "launches" — one spine, three readings.
/// </summary>
public static class BananaStand
{
static float? _pullRadius;
/// <summary>
/// How far out zombies are yanked into re-acquiring when a stand lands. 0 uses the stand's own
/// attraction radius.
///
/// ⚠️ SEPARATE FROM THE ATTRACTION RADIUS ON PURPOSE, and defaulting to it. The attraction radius
/// decides who CAN pick the stand once they next look; this decides who is made to look NOW.
/// They are the same by default because two numbers that always agree should look like one — but
/// a lure that grabs a wider ring than it can hold is a tuning option worth having.
/// </summary>
public static float PullRadius { get => _pullRadius ?? 0f; set => _pullRadius = value; }
/// <summary>
/// Every stand currently standing, as targets.
///
/// ⛔ CALLED FROM `GetTargetables`, WHICH IS PER ZOMBIE PER ACQUIRE. With 35 zombies this runs 35
/// times per retarget round, so it must stay cheap — it is a scene component sweep over a list
/// that is almost always empty or has one entry, which is the same cost profile as the
/// `VultureStink.IsInGas` call already in that method.
///
/// ⚠️ RANGE IS CHECKED BY THE CALLER, not here. `AcquireTarget` picks the NEAREST candidate with
/// no distance limit at all — so returning stands unconditionally would let a zombie on the far
/// side of the map path to one. The caller passes its own position and this filters.
/// </summary>
public static IEnumerable<GameObject> TargetsFor( Vector3 from )
{
foreach ( var p in Placeable.All )
{
if ( p.Kind != PlaceKind.Stand ) continue;
if ( !p.GameObject.IsValid() ) continue;
if ( from.Distance( p.WorldPosition ) > p.Size ) continue;
yield return p.GameObject;
}
}
/// <summary>
/// Is a point inside any stand's attraction radius?
///
/// ⚠️ THE CHEAP HALF OF `TargetsFor`, for the per-tick caller. `ZombieAI`'s chase tick asks this
/// every frame for every zombie to catch the ones that WALK INTO a radius — the candidate filter
/// alone only bites on the next acquire, and that is 3-15 seconds away. `TargetsFor` allocates an
/// iterator and yields objects nobody wants there; this short-circuits on the first hit.
/// </summary>
public static bool Luring( Vector3 at )
{
foreach ( var p in Placeable.All )
{
if ( p.Kind != PlaceKind.Stand ) continue;
if ( !p.GameObject.IsValid() ) continue;
if ( at.Distance( p.WorldPosition ) > p.Size ) continue;
return true;
}
return false;
}
/// <summary>
/// A stand has gone — broken or expired. Put everyone who was on it back onto a player NOW.
///
/// ⛔ THE MIRROR OF `PullNearby`, AND IT IS NOT OPTIONAL NOW THAT THE LURE IS ABSOLUTE. With the
/// stand winning outright inside its radius, a whole horde can be targeting one object — and when
/// it breaks, every one of them has an invalid target at the same instant. `TickChase` sends that
/// to `OnNoTarget`, which drops the zombie to Idle and waits 0.4s before re-acquiring. That is a
/// visible stall on the entire horde at the exact moment the player needs them to commit.
///
/// ⛔ `ForceRetargetAll`, NOT A RANGE LOOP OF `ForceRetarget`. Each `ForceRetarget` calls
/// `InvalidatePlayerTargets` on its way in, so pushing a horde one at a time rebuilds the shared
/// candidate cache once per zombie — `ZombieAI`'s own note calls that quadratic and is why the
/// All variant invalidates once for the whole sweep. A stand's radius is 900u, so "in range" is
/// most of the live horde anyway; the range loop would have bought nothing and cost the cache.
///
/// ⚠️ IT DOES NOT NEED TO CLEAR `Target` FIRST — `ForceRetargetCore` does `Target = null` then
/// re-acquires, which is the whole point of going through it rather than assigning.
///
/// ⚠️ ONCE PER STAND DEATH, so the 35-acquire spike is the same one the gas already pays when a
/// cloud expires. Seconds apart at worst, never per frame.
/// </summary>
public static void ReleaseNearby( Placeable stand )
{
if ( stand is null ) return;
ZombieAI.ForceRetargetAll();
Log.Info( "[nz-aug] banana stand gone — horde released back onto players" );
}
/// <summary>
/// The stand on this object, or null. How the attack path recognises one.
/// </summary>
public static Placeable On( GameObject go )
{
if ( !go.IsValid() ) return null;
var p = go.Components.Get<Placeable>( FindMode.EverythingInSelf );
return p.IsValid() && p.Kind == PlaceKind.Stand ? p : null;
}
/// <summary>
/// Make every zombie in range look at this stand right now.
///
/// ⚠️ `ForceRetarget` RATHER THAN WRITING `Target`. That method is documented as issuing the move
/// order itself, because "if `Think` is the thing that is stalled, setting `Target` alone produces
/// a zombie that has a target and still stands still — which looks exactly as broken as before".
///
/// ⛔ IT NOW DOES GUARANTEE THEY PICK THE STAND, AND THIS NOTE USED TO SAY THE OPPOSITE. It read
/// *"`AcquireTarget` takes the nearest candidate, so a zombie already on top of the player keeps
/// the player — that is correct, bait that overrides proximity would let you place a stand under
/// your own feet and become invulnerable"*. That was overruled by request: *"as long as a banana
/// bunch exists, all zombies inside its area completely ignore the players and focus only on
/// it."* `GetTargetables` now returns the stand INSTEAD OF the player list inside the radius, so
/// nearest-candidate has nothing to choose against.
///
/// ⚠️ THE INVULNERABILITY THE OLD NOTE WARNED ABOUT IS REAL AND IS BOUNDED BY DURABILITY, not by
/// targeting. The stand absorbs 80 swings and carries a lifetime; stand inside your own lure and
/// you are untouchable until one of those runs out. That is the trade the augment now is.
/// </summary>
public static void PullNearby( Placeable stand )
{
if ( !stand.IsValid() ) return;
var reach = PullRadius > 0f ? PullRadius : stand.Size;
var n = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() ) continue;
if ( stand.WorldPosition.Distance( z.WorldPosition ) > reach ) continue;
z.ForceRetarget();
n++;
}
Log.Info( $"[nz-aug] banana M3 Banana Stand — pulled {n} zombie(s) within {reach:0}u"
+ " (the stand now wins outright inside its radius)" );
}
/// <summary>
/// A zombie swung at a stand. Returns true when the swing was absorbed.
///
/// ⚠️ MIRRORS `Barricade.TearPlank`'s SHAPE so the call site reads the same for both: ask, and if
/// it says yes the player takes nothing this swing.
/// </summary>
public static bool AbsorbSwing( GameObject target )
{
var stand = On( target );
if ( stand is null ) return false;
var dead = stand.Use();
Log.Info( $"[nz-aug] banana stand hit — {stand.Left}/{stand.Total} left"
+ (dead ? " · BROKEN" : "") );
return true;
}
/// <summary>`nz_aug_banana_stand` — what the lure is doing, and who it has.</summary>
[ConCmd( "nz_aug_banana_stand" )]
public static void Report()
{
var stands = Placeable.All.Where( p => p.Kind == PlaceKind.Stand ).ToList();
Log.Info( $"[nz-aug] BANANA STAND · {stands.Count} up"
+ $" · pull radius {(PullRadius > 0f ? $"{PullRadius:0}u" : "same as attraction")}" );
foreach ( var s in stands )
Log.Info( $"[nz-aug] {s.Left}/{s.Total} left · {(float)s.Dies:0.#}s"
+ $" · attracts within {s.Size:0}u · at {s.WorldPosition}" );
// ⛔ WHO IS ACTUALLY TARGETING ONE IS THE ONLY THING THAT ANSWERS "is the lure working". The
// stand existing, the radius being right and zombies being in it are all true of a lure that
// nothing chose.
//
// ⚠️ SINCE THE LURE BECAME ABSOLUTE, "on a player" SHOULD BE ZERO for anyone inside a
// radius. A non-zero count while a stand is up is now a REAL failure rather than the
// nearest-candidate rule doing its job — check the zombie is not Dead or Spawning, which
// `ForceRetargetCore` skips deliberately.
var onStand = 0;
var onPlayer = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() ) continue;
if ( On( z.Target ) is not null ) onStand++;
else if ( z.Target.IsValid() ) onPlayer++;
}
Log.Info( $"[nz-aug] targeting: {onStand} on a stand, {onPlayer} on a player" );
}
}