Zombies/ThrasherBoss.cs

A Boss component implementing the Thrasher boss AI and visuals for a zombie game mode. Handles sac weakpoints, popping/regrowing, enraging, burrowing teleport, eating downed players and zombies, shove knockbacks, animations, sounds, and networked part visibility across host and clients.

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

namespace NZombies;

/// <summary>
/// The Thrasher — BO3 Zetsubou No Shima's spore-sacked brute, ported 2026-10-07 from GMod nZombies' `nz_zombie_boss_thrasher`
/// (GhostlyMoo's port), the third batch. Spec: `Sbox nzombies/Docs/boss_specs/thrasher.md`.
///
///   • THREE SPORE SACS (leg, chest, back) ARE HIS WEAK POINTS: a hit on a live one — its own hitbox, or within <see
///     cref="SacReach"/> of its bone, upstream's test — takes x<see cref="SacScale"/>, anything else x<see cref="BodyScale"/>.
///     Nothing pops above <see cref="PopFirstAt"/> of his health; under it a hit on a sac pops it, the second under <see
///     cref="PopSecondAt"/>, never a third until one grows back. A pop: the sac folds away (upstream's `ManipulateBoneScale`),
///     +<see cref="PopPoints"/> to the shooter, 4 s of i-frames (x0.25 on everything, nothing pops), he enrages, and he staggers
///     (`stunned_v1/v2/v3`, 3.1/1.7/1.5 s — after the move he is in, where upstream cuts in and resumes it).
///     ⚠️ EACH SAC IS ITS OWN: upstream rewrites every flag from ANOTHER sac's bone each tick, so popped spots stayed weak as
///     ghosts, live ones went immune and a regrowth regrew the wrong one.
///   • HE ARRIVES IN I-FRAMES (3 s) and takes nothing for the first second, upstream's spawn protection.
///   • HE ENRAGES FOR GOOD 15-30 s after he arrives — sooner on <see cref="EnrageOnHit"/> of any hit, and at every pop:
///     `nz_thrasher_enrage`, the spawn roar and the impact heard map-wide, a dust pulse, an aura, and the run (139.8 or 161.7 u/s)
///     for the walk (51.8 or 63.2).
///   • HE BURROWS TO YOU from <see cref="TeleportRange"/> away, at most every <see cref="TeleportGap"/> s after the last: down
///     (`teleport_out`), up behind his target (`teleport_in`), 4.7 s, the ground breaking both ends. ⚠️ Behind where they are when
///     he comes up, not where they stood when he went down. ⚠️ The roots prop is not ported: dirt, a ring and the quake.
///   • HE EATS A DOWNED PLAYER (upstream's stand-in for BO3's swallow): one within <see cref="EatRange"/>, picked one pass in three;
///     he goes to them and eats (`nz_thrasher_eat`, 4.57 s) — still down at its end, they bleed out at once (`NZNet.BossDevour`, on
///     their own machine). A revive in time saves them. ⚠️ He goes in a straight line over clear floor or not at all (the AI here
///     paths only to what it chases). ⚠️ One who crawled 60 past his reach got away (upstream ate them anywhere).
///     ⛔ OFF BY DEFAULT, solo and co-op: it ends a down outright, which the user's no-insta-kill rule forbids —
///     `nz_thrasher_set eat 1` turns it on (co-op), `alone 1` adds solo (upstream's own solo guard is commented out).
///   • HE EATS A ZOMBIE TO GROW A SAC BACK, at most 3 times, 10 s apart: a walker within 85 is pulled into his hands
///     (`eat_z_b`, 4.17 s) and gone at 3.33 s; the first missing sac (leg, chest, back) grows back and his health rises to 45% (or
///     85%). ⚠️ The walker has no `eaten_by_thrasher` clip: it is held frozen, leaning in (`ZombieAI.Pull`). ⚠️ Shot out of his
///     hands first, nothing grows (upstream's `CancelCurrentAction` was set for that; its check is commented out). ⚠️ One zombie a
///     pass — upstream's loop queued an eat for every zombie in reach.
///   • HE SHOVES WALKERS ASIDE: any within 64 goes over (`ZombieAI.Knockback`, upstream's knockdown reactions).
///   • HIS SWING (the variant's two attacks): the swipe 90 and the heavy swing 150 (x<see cref="HeavyWeight"/>) against a walker's
///     50, the whoosh on each clip's own frame.
///   • HE DIES IN A BURST: the last sac goes with him, then `thrasher_explode` (2.27 or 0.63 s into his fall; at once if he died
///     mid-move) and he is gone — no corpse.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the sacs and the rage are `NZNet.BossPart` (`ShowPart`: bones folded in
/// `OnPreRender`, an aura, a watching machine's gait), the clips `PlayShared`, the bursts `BossFx.BurstShared`, the meal's kill
/// `NZNet.BossDevour` (on the victim's machine).
/// ⛔ NO HIT OF HIS DOWNS ANYONE FROM FULL HEALTH (`BossBase.MaxHit` on his swing). His meal only ends a down already there.
/// ⚠️ HIS ROARS: upstream's enraged passive voice is the roar set; the AI here takes the sprint voice above 180 u/s only and his
/// run is 140-162, so he groans on unless `ZombieAI.VoiceOverride` exists (notes.md).
/// </summary>
public sealed class ThrasherBoss : BossBase
{
	protected override string Tag => "[nz-thrasher]";
	protected override float VoiceHeight => 72f;

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.

	static float? _sacScale, _bodyScale, _sacReach, _popFirst, _popSecond, _enrageOnHit, _heavy;
	static int? _popPoints;
	/// <summary>A live sac takes x0.85 and anything else x0.25 — upstream's.</summary>
	public static float SacScale { get => _sacScale ?? 0.85f; set => _sacScale = value; }
	public static float BodyScale { get => _bodyScale ?? 0.25f; set => _bodyScale = value; }
	/// <summary>A hit this near a live sac's bone is on it: 18 (upstream's); a pop reaches 2 further (its 20).</summary>
	public static float SacReach { get => _sacReach ?? 18f; set => _sacReach = value; }
	/// <summary>The first pop at 65% of his health or less, the second at 35% (upstream's `SporeCount` gate).</summary>
	public static float PopFirstAt { get => _popFirst ?? 0.65f; set => _popFirst = value; }
	public static float PopSecondAt { get => _popSecond ?? 0.35f; set => _popSecond = value; }
	/// <summary>The shooter's pay for a pop: 50, upstream's.</summary>
	public static int PopPoints { get => _popPoints ?? 50; set => _popPoints = value; }
	/// <summary>A hit's chance to enrage him: 0.24 (upstream's `math.random(100) &lt; 25`).</summary>
	public static float EnrageOnHit { get => _enrageOnHit ?? 0.24f; set => _enrageOnHit = value; }
	/// <summary>His heavy swing against his swipe: 1.667 — upstream's 150 against 90.</summary>
	public static float HeavyWeight { get => _heavy ?? 150f / 90f; set => _heavy = value; }

	static float? _tpRange, _tpGap, _eatRange, _eatReach, _regrowGap;
	static bool? _eats, _alone;
	/// <summary>He burrows to a target 750 away or more (upstream's `!TargetInRange(750)`), 7 s after his last one is over.</summary>
	public static float TeleportRange { get => _tpRange ?? 750f; set => _tpRange = value; }
	public static float TeleportGap { get => _tpGap ?? 7f; set => _tpGap = value; }
	/// <summary>He goes for a downed player within 450 (upstream's), and eats within 90 of them (its walk's 75, and his body).</summary>
	public static float EatRange { get => _eatRange ?? 450f; set => _eatRange = value; }
	public static float EatReach { get => _eatReach ?? 90f; set => _eatReach = value; }
	/// <summary>
	/// He eats downed players at all; and when the one player there is (upstream's solo guard is commented out).
	/// ⛔ BOTH OFF BY DEFAULT (2026-10-07): a meal ends a down outright, and the user's rule is that no attack kills like that
	/// (*"we cant have any attack insta kill like that"*). `nz_thrasher_set eat 1` turns it on; `alone 1` adds solo.
	/// </summary>
	public static bool EatsDowned { get => _eats ?? false; set => _eats = value; }
	public static bool EatAlone { get => _alone ?? false; set => _alone = value; }
	/// <summary>Between two zombies eaten, and after a pop: 10 s (upstream's `RegenCooldown`).</summary>
	public static float RegrowGap { get => _regrowGap ?? 10f; set => _regrowGap = value; }

	// his clips (30 fps) and their events — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
	const string ClipEnrage = "nz_thrasher_enrage", ClipOut = "nz_thrasher_teleport_out", ClipIn = "nz_thrasher_teleport_in",
		ClipEat = "nz_thrasher_eat", ClipSnack = "nz_thrasher_eat_z_b", ClipSwipe = "nz_thrasher_attack_swipe",
		ClipHeavy = "nz_thrasher_attack_swing_swipe", ClipDeath2 = "nz_thrasher_death_v2", ClipMantle = "nz_thrasher_mantle_over_36",
		ClipWalk1 = "nz_thrasher_walk_f_v1", ClipWalk2 = "nz_thrasher_walk_f_v2", ClipRun1 = "nz_thrasher_run_f_v1",
		ClipRun2 = "nz_thrasher_run_f_v2";
	const float EnrageLen = 53f / 30f, OutLen = 45f / 30f, BurrowAt = 15f / 30f, InLen = 96f / 30f, EmergeAt = 1f / 30f,
		FallAt = 20f / 30f, PainAt = 1f / 30f,
		MealLen = 137f / 30f, MealGrab = 23f / 30f, MealBite1 = 54f / 30f, MealBite2 = 74f / 30f, MealBite3 = 94f / 30f,
		SnackLen = 125f / 30f, SnackGrab = 18f / 30f, SnackBite1 = 56f / 30f, SnackBite2 = 76f / 30f, SnackBite3 = 86f / 30f,
		SnackGone = 100f / 30f,
		Death1Fall = 25f / 30f, Death1Explode = 68f / 30f, Death2Fall = 18f / 30f, Death2Explode = 19f / 30f;
	/// <summary>Each swing's `melee_whoosh`, as a share of its clip (frame / last frame): the swipe's 17 of 63, the heavy's 18 of 57.</summary>
	const float SwipeWhoosh = 17f / 63f, HeavyWhoosh = 18f / 57f;

	/// <summary>His four gaits, each its own tier of `thrasher.zvar`, picked by its own speed (`speeds("thrasher")`).</summary>
	const float WalkSlow = 51.8f, WalkFast = 63.2f, RunSlow = 139.8f, RunFast = 161.7f;

	/// <summary>Upstream's fixed numbers: the spawn's nothing-at-all (1 s) and i-frames (3 s), a pop's i-frames (4 s), the pop's
	/// extra reach (2), the rage's timer (15-30 whole seconds), three regrowths, the health they give back (45%, else 85%).</summary>
	const float SpawnGrace = 1f, SpawnIFrames = 3f, PopIFrames = 4f, PopExtra = 2f, RegrowLow = 0.45f, RegrowHigh = 0.85f;
	const int EnrageMin = 15, EnrageMax = 30, RegrowMax = 3;

	/// <summary>A zombie within 85 is food (upstream's), one within 64 is shoved (its `8^2`), 40 back, down 1.5 s.</summary>
	const float RegrowRange = 85f, ShoveRange = 64f, ShoveDistance = 40f, ShoveSeconds = 1.5f, ShoveRise = 0.6f;

	/// <summary>A downed player is picked one pass in three (upstream's `math.random(3) == 1`), and got away crawling 60 past his reach.</summary>
	const float EatChance = 1f / 3f, EatEscape = 60f;

	/// <summary>Where he comes up: a navmesh point 45-100 from his target, behind their aim (upstream's `FindSpotBehindPlayer`, 100).</summary>
	const float BehindNear = 45f, BehindFar = 100f;

	/// <summary>How far in front of him a zombie is held to be eaten (his clip's lunge is stripped with its root motion).</summary>
	const float SnackHold = 40f;

	/// <summary>How small a popped sac is drawn — ⚠️ not 0, a degenerate matrix (the gore's `GoreShrink`; upstream's 0.00001).</summary>
	const float Fold = 0.001f;

	const float ThinkGap = 0.25f;

	const int Leg = 0, Chest = 1, Back = 2;
	const string RagePart = "rage";

	static string SacBone( int s ) => s switch { Leg => "tag_spore_leg", Chest => "tag_spore_chest", _ => "tag_spore_back" };
	static string SacPart( int s ) => s switch { Leg => "spore_leg", Chest => "spore_chest", _ => "spore_back" };
	static string SacName( int s ) => s switch { Leg => "leg", Chest => "chest", _ => "back" };
	static string StunClip( int s ) => s switch { Leg => "nz_thrasher_stunned_v2", Chest => "nz_thrasher_stunned_v1", _ => "nz_thrasher_stunned_v3" };
	static float StunLen( int s ) => s switch { Leg => 51f / 30f, Chest => 93f / 30f, _ => 45f / 30f };

	/// <summary>Upstream tests a pop on the chest, then the leg, then the back (and pops one for show at death in that order).</summary>
	static int PopOrder( int i ) => i == 0 ? Chest : i == 1 ? Leg : Back;

	static Color SacBlood => new( 1f, 0.55f, 0.2f );
	static Color Gore => new( 0.55f, 0.04f, 0.03f );
	static Color Ember => new( 1f, 0.39f, 0f );
	static Color Rage => new( 1f, 0.32f, 0.08f );
	static Color Spark => new( 1f, 0.62f, 0.32f );
	static Color Dust => new( 0.55f, 0.5f, 0.42f );
	static Color Pulse => new( 0.62f, 0.68f, 0.8f );

	// ══ state ════════════════════════════════════════════════════════════════

	// the host's
	readonly bool[] _sac = { true, true, true };
	int _popped, _regens, _stun = -1;
	bool _enraged, _away, _swallowed, _wasBusy, _exploded;
	float _bornAt, _iframesUntil, _enrageAt, _nextTeleport, _regrowReadyAt, _nextThink, _nextWet, _explodeAt, _fallAt;
	float _pace = WalkSlow;
	string _paceClip = ClipWalk1;
	NZPlayer _meal;
	ZombieAI _snack;
	string _swingClip;
	float _swingT;

	// every machine's
	readonly bool[] _gone = new bool[3];
	readonly BoneCollection.Bone[] _sacBones = new BoneCollection.Bone[3];
	readonly Transform[] _sacInParent = new Transform[3];
	Model _rigModel;
	bool _clearFolds, _rage;
	BossGlow _aura;

	enum Beat { Emerge, Fall, Burrow, Surface, Up, Pain, Grab, Bite, MealStart, Devour, Swallow, Regrow }

	public bool Enraged => _enraged;
	public int SacsPopped => _popped;

	protected override void BossStart()
	{
		// ⚠️ EVERY MACHINE: his eyes (upstream draws two 6x6 orange sprites on `lefteye`/`righteye`, j_head)
		var eyes = Components.Create<BossGlow>();
		eyes.Bone = "j_head";
		eyes.Offset = new Vector3( 2.85f, 3.25f, 0f );
		eyes.Colour = Ember;
		eyes.Radius = 50f;
		eyes.Brightness = 1.6f;

		// his rage (`bo3_thrasher_aura`): the whole body smoulders and sparks rise off it — out until he enrages (`ShowPart`)
		_aura = Components.Create<BossGlow>();
		_aura.Bone = "j_spine4";
		_aura.Colour = Rage;
		_aura.Radius = 190f;
		_aura.Brightness = 1.8f;
		_aura.SparkEvery = 0.07f;
		_aura.SparkReach = 28f;
		_aura.SparkColour = Spark;
		_aura.Lit = false;
	}

	protected override void HostStart()
	{
		Ai.MaxHitDamage = MaxHit;

		// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON HIS MODEL: his own mantle (upstream's barricade jump, the default traversal)
		Ai.ClimbClipOverride = ClipMantle;

		_bornAt = Time.Now;
		_iframesUntil = _bornAt + SpawnIFrames;
		_enrageAt = _bornAt + Game.Random.Int( EnrageMin, EnrageMax );
		_nextTeleport = _bornAt + 1f;
		_regrowReadyAt = _bornAt + 5f;

		// one of his two walks, as upstream's tier rolls one
		Pace( Game.Random.Int( 0, 1 ) == 0 ? WalkSlow : WalkFast );

		// ⚠️ THE ENTRANCE PLAYS `SpawnSound` ON THE HOST ALONE (`ZombieAI.BeginSpawn`'s `NZSound.Play`): his map-wide line goes
		// to the clients from here (`NZNet.WorldSound` skips the host, so it cannot double)
		if ( Networking.IsActive ) NZNet.WorldSound( "nz.thrasher.spawn", Voice );

		// `nz_thrasher_transform`: the ground breaks on frame 1, he lands on 20
		Queue( EmergeAt, (int)Beat.Emerge );
		Queue( FallAt, (int)Beat.Fall );

		Say( $"spawned — {Hp?.Max ?? 0f:0} hp (x{BodyScale:0.##}, x{SacScale:0.##} on a sac) · enrages in {_enrageAt - Time.Now:0}s"
			+ $" · {(_pace == WalkFast ? "walk v2" : "walk v1")} · hitboxes {Ai.HitboxCount}" );
	}

	protected override void EveryFrame()
	{
		// the aura is the rage's, whatever else touched it (`BossFx.SetHidden` lights every glow when it shows a boss)
		if ( _aura.IsValid() && _aura.Lit != _rage ) _aura.Lit = _rage;
	}

	protected override void HostFrame()
	{
		Ai.MaxHitDamage = MaxHit;
		_wasBusy = Ai.State is ZombieState.Special or ZombieState.Spawning;
		if ( Ai.State == ZombieState.Spawning ) return;

		TickSwing();

		// the stagger a pop owes him, as soon as he is free (a burrow or a meal finishes first)
		if ( _stun >= 0 && Free( true ) ) Stun();

		if ( Time.Now < _nextThink ) return;
		_nextThink = Time.Now + ThinkGap;

		// ⚠️ UPSTREAM'S ORDER (`AI`): the rage, the shove, a zombie to eat, the burrow, a downed player to eat
		if ( !_enraged && Time.Now >= _enrageAt && Free( true ) )
		{
			Enrage();
			return;
		}

		if ( !Free( false ) ) return;
		Shove();
		if ( TrySnack() || TryBurrow() ) return;
		TryMeal();
	}

	/// <summary>Not mid-move: chasing, idle, or (<paramref name="swinging"/>) mid-swing, which a rage or a stagger cuts.</summary>
	bool Free( bool swinging ) => !Ai.Arcing
		&& ((Ai.State is ZombieState.Chasing or ZombieState.Idle) || (swinging && Ai.State == ZombieState.Attacking));

	protected override void OnBeat( int beat )
	{
		switch ( (Beat)beat )
		{
			case Beat.Emerge:
				Ground( WorldPosition, false );
				break;

			case Beat.Fall:
				NZSound.PlayShared( "nz.thrasher.fall", WorldPosition );
				NZSound.PlayShared( "nz.thrasher.fallswt", WorldPosition );
				break;

			case Beat.Burrow:
				Ground( WorldPosition, true );
				break;

			case Beat.Surface:
				Surface();
				break;

			case Beat.Up:
				_away = false;
				break;

			case Beat.Pain:
				NZSound.PlayShared( "nz.thrasher.pain", Voice );
				break;

			case Beat.Grab:
				NZSound.PlayShared( "nz.thrasher.grab", Voice );
				if ( Game.Random.Int( 1, 100 ) == 69 ) NZSound.PlayShared( "nz.thrasher.laby", Voice );
				break;

			case Beat.Bite:
				// `thrasher_eat`: the bite and the goop at his mouth (attachment `mouth`, on j_spinelower)
				var mouth = Body.IsValid() && Body.TryGetBoneTransform( "j_spinelower", out var spine )
					? spine.PointToWorld( new Vector3( -2f, 6f, 0f ) )
					: Voice;
				NZSound.PlayShared( "nz.thrasher.bite", mouth );
				BossFx.BurstShared( mouth, 24f * Size, SacBlood, 8 );
				break;

			case Beat.MealStart:
				BeginMeal( true );
				break;

			case Beat.Devour:
				Devour();
				break;

			case Beat.Swallow:
				Swallow();
				break;

			case Beat.Regrow:
				if ( !_swallowed ) break;
				_swallowed = false;
				Regrow();
				break;
		}
	}

	protected override void Died( bool host )
	{
		if ( !host ) return;

		Ai.SwingScale = 1f;
		_meal = null;
		_snack = null;
		_stun = -1;

		// the last sac goes with him, the first still on (upstream's `PerformDeath`: chest, leg, back)
		for ( int i = 0; i < 3; i++ )
		{
			var s = PopOrder( i );
			if ( !_sac[s] ) continue;
			_sac[s] = false;
			SetPart( SacPart( s ), 1 );
			SacBurst( s );
			break;
		}

		// ⚠️ DEAD MID-MOVE (an entrance, a burrow, a stagger, a meal), HE BURSTS AT ONCE, upstream's; else on his fall's own frame
		var v2 = Ai.CurrentClip == ClipDeath2;
		_explodeAt = Time.Now + (_wasBusy ? 0.05f : v2 ? Death2Explode : Death1Explode);
		_fallAt = _wasBusy ? 0f : Time.Now + (v2 ? Death2Fall : Death1Fall);
		Say( "killed" );
	}

	/// <summary>On his fall's `thrasher_explode` he bursts and is gone — hidden on every machine, and no corpse. THE HOST.</summary>
	protected override void DeadFrame()
	{
		if ( !IsHost || _exploded ) return;

		if ( _fallAt > 0f && Time.Now >= _fallAt )
		{
			_fallAt = 0f;
			NZSound.PlayShared( "nz.thrasher.fall", WorldPosition );
		}

		if ( Time.Now < _explodeAt ) return;
		_exploded = true;

		// `bo3_margwa_death`: a burst of flesh and burning goop, and upstream's `ExplodeSWTSounds` (the Margwa's head popping)
		var at = WorldPosition + Vector3.Up * 40f * Size;
		SetHidden( true );
		BossFx.BurstShared( at, 140f * Size, SacBlood, 20 );
		BossFx.BurstShared( at, 90f * Size, Gore, 12 );
		NZSound.PlayShared( "nz.margwa.headpop", at );
		NZNet.ShakeAt( at, 0.4f, 900f );
		Ai.CorpseLinger = 0.2f;
	}

	// ══ the spore sacs ═══════════════════════════════════════════════════════

	/// <summary>
	/// His table: x<see cref="SacScale"/> on a live sac, x<see cref="BodyScale"/> anywhere else and through the i-frames, nothing in
	/// his first second — and the hit that may pop a sac or enrage him. THE HOST, once a hit.
	/// </summary>
	protected override float ScaleDamage( float amount, in DamageInfo damage )
	{
		if ( Time.Now < _bornAt + SpawnGrace ) return 0f;

		// any hit may enrage him; the rage itself waits until he is free
		if ( !_enraged && Game.Random.Float( 0f, 1f ) < EnrageOnHit ) _enrageAt = 0f;

		var pos = damage.Position;
		var bone = damage.Hitbox?.Bone.Name;

		// ⚠️ THE POP FIRST, upstream's order: it starts the i-frames, so the hit that pops a sac takes x0.25
		if ( Time.Now >= _iframesUntil ) TryPop( pos, bone, damage.Attacker );
		if ( Time.Now < _iframesUntil || SacAt( pos, bone, SacReach * Size, false ) < 0 ) return BodyScale;

		// the only sign you found one: a wet hit (upstream's `WeakImpactSounds`, the Margwa's), throttled per bullet
		if ( Time.Now >= _nextWet )
		{
			_nextWet = Time.Now + 0.07f;
			NZSound.PlayShared( "nz.margwa.weakspot", pos );
		}
		return SacScale;
	}

	/// <summary>
	/// The live sac a hit landed on — its own hitbox, or within <paramref name="reach"/> of its bone (upstream's test, which the
	/// capsule in front of a sac cannot hide) — or -1. Leg, chest, back for the weak spot; chest, leg, back for a pop.
	/// </summary>
	int SacAt( Vector3 pos, string bone, float reach, bool popOrder )
	{
		for ( int i = 0; i < 3; i++ )
		{
			var s = popOrder ? PopOrder( i ) : i;
			if ( !_sac[s] ) continue;
			if ( bone == SacBone( s ) ) return s;
			if ( pos.IsNearlyZero() || !Body.IsValid() || !Body.TryGetBoneTransform( SacBone( s ), out var t ) ) continue;
			if ( pos.Distance( t.Position ) <= reach ) return s;
		}
		return -1;
	}

	void TryPop( Vector3 pos, string bone, GameObject attacker )
	{
		if ( !Hp.IsValid() || Hp.Max <= 0f ) return;
		var share = Hp.Current / Hp.Max;
		var ready = (_popped == 0 && share <= PopFirstAt) || (_popped == 1 && share <= PopSecondAt);
		if ( !ready ) return;

		var s = SacAt( pos, bone, (SacReach + PopExtra) * Size, true );
		if ( s >= 0 ) Pop( s, attacker );
	}

	/// <summary>A sac off: folded away on every machine, the goop, the pay, the i-frames, the rage, and a stagger owed. THE HOST.</summary>
	void Pop( int s, GameObject attacker )
	{
		if ( !_sac[s] ) return;
		_sac[s] = false;
		_popped++;
		_regrowReadyAt = Time.Now + RegrowGap;
		_iframesUntil = Time.Now + PopIFrames;
		_enrageAt = 0f;

		SetPart( SacPart( s ), 1 );
		SacBurst( s );

		// ⚠️ THE PAY GOES THROUGH `AddPoints`, which relays to a client's own machine
		var shooter = attacker.IsValid() ? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		if ( shooter.IsValid() && PopPoints > 0 ) shooter.AddPoints( PopPoints );

		_stun = s;
		Say( $"{SacName( s ).ToUpperInvariant()} SAC POPPED — {_popped} gone · {Hp?.Current ?? 0f:0}/{Hp?.Max ?? 0f:0} hp"
			+ $"{(shooter.IsValid() ? $" · +{PopPoints} to {shooter.GameObject.Name}" : "")}" );
	}

	/// <summary>`bo3_thrasher_blood` at a sac and the pop (upstream's `SporeExplodeSounds` at 511 — the Margwa's head popping).</summary>
	void SacBurst( int s )
	{
		var at = Body.IsValid() && Body.TryGetBoneTransform( SacBone( s ), out var t ) ? t.Position : Voice;
		BossFx.BurstShared( at, 30f * Size, SacBlood, 12 );
		NZSound.PlayShared( "nz.margwa.headpop", at );
	}

	/// <summary>The stagger for the sac that went: chest `stunned_v1`, leg `_v2`, back `_v3`. Owed until he is free. THE HOST.</summary>
	void Stun()
	{
		var s = _stun;
		if ( !PlayShared( StunClip( s ), StunLen( s ) ) ) return;
		_stun = -1;
		Queue( PainAt, (int)Beat.Pain );
		Say( $"staggered ({StunClip( s )})" );
	}

	/// <summary>
	/// A zombie eaten: one more regrowth, one sac fewer gone — the first missing of leg, chest, back — and his health up to 45%, or
	/// to 85% if it was over that (upstream's `RegenSpore`). THE HOST.
	/// </summary>
	void Regrow()
	{
		_regens++;
		_popped = Math.Max( 0, _popped - 1 );

		if ( Hp.IsValid() && Hp.Max > 0f )
		{
			var share = Hp.Current / Hp.Max;
			if ( share < RegrowLow ) Hp.SetCurrent( Hp.Max * RegrowLow );
			else if ( share < RegrowHigh ) Hp.SetCurrent( Hp.Max * RegrowHigh );
		}

		for ( int s = 0; s < 3; s++ )
		{
			if ( _sac[s] ) continue;
			_sac[s] = true;
			SetPart( SacPart( s ), 0 );
			Say( $"{SacName( s )} sac grown back ({_regens}/{RegrowMax}) — {Hp?.Current ?? 0f:0}/{Hp?.Max ?? 0f:0} hp" );
			break;
		}
	}

	/// <summary>
	/// A part on THIS machine, from `NZNet.BossPart` (the host's <see cref="BossBase.SetPart"/>): a sac gone or back, his rage on.
	/// ⚠️ NEITHER IS A BODYGROUP — the sacs are three leaf bones (`tag_spore_*`, ~1,000 vertices each, weight 1) folded away in
	/// <see cref="OnPreRender"/>, the rage an aura and, on a watching machine, the run.
	/// </summary>
	public override bool ShowPart( string group, int choice )
	{
		for ( int s = 0; s < 3; s++ )
		{
			if ( group != SacPart( s ) ) continue;
			var gone = choice != 0;
			// a sac back: every override off, and the ones still gone folded again on the next draw
			if ( _gone[s] && !gone ) _clearFolds = true;
			_gone[s] = gone;
			return true;
		}

		if ( group != RagePart ) return false;
		_rage = choice != 0;
		if ( _aura.IsValid() ) _aura.Lit = _rage;

		// ⚠️ A WATCHING MACHINE'S BODY RUNS TOO: the host's `SetSpeed` does not travel, so its puppet re-picks its legs from the
		// run tiers (either one: they are both upstream's run)
		if ( _rage && Ai.IsValid() && Ai.IsPuppet )
		{
			Ai.SpeedOverride = Game.Random.Int( 0, 1 ) == 0 ? RunSlow : RunFast;
			Ai.RepickAnimations();
		}
		return true;
	}

	/// <summary>
	/// After animation, every frame a sac is gone: fold it onto its own point at a scale of nothing, on THIS machine.
	///
	/// ⛔ THE GORE'S RULES (`ZombieAI.ApplyGore`), KEPT EXACTLY: `SetBoneOverride` takes MODEL space and the getters answer in
	/// WORLD (`sm.Transform` converts); an override is the bone's FINAL transform; and an overridden bone is never read back —
	/// the point comes from its PARENT (j_hiptwist_le, j_spine4), never overridden, plus its bind-pose offset.
	/// </summary>
	protected override void OnPreRender()
	{
		if ( !Body.IsValid() ) return;
		var sm = Body.SceneModel;
		if ( !sm.IsValid() || !SacRig() ) return;

		if ( _clearFolds )
		{
			sm.ClearBoneOverrides();
			_clearFolds = false;
		}

		if ( !_gone[Leg] && !_gone[Chest] && !_gone[Back] ) return;

		var model = sm.Transform;
		for ( int s = 0; s < 3; s++ )
		{
			var bone = _sacBones[s];
			if ( !_gone[s] || bone is null || !Body.TryGetBoneTransform( bone.Parent, out var parent ) ) continue;
			var folded = model.ToLocal( parent.ToWorld( _sacInParent[s] ) ).WithScale( Fold );
			sm.SetBoneOverride( bone.Index, in folded );
		}
	}

	/// <summary>The three sac bones on this body's model, once per model. False with no model yet.</summary>
	bool SacRig()
	{
		var model = Body.Model;
		if ( model is null ) return false;
		if ( model == _rigModel ) return true;

		_rigModel = model;
		for ( int s = 0; s < 3; s++ )
		{
			var b = model.Bones?.GetBone( SacBone( s ) );
			_sacBones[s] = b?.Parent is null ? null : b;
			if ( _sacBones[s] is not null ) _sacInParent[s] = b.Parent.LocalTransform.ToLocal( b.LocalTransform );
		}
		return true;
	}

	// ══ the rage, the swing, the shove ══════════════════════════════════════

	/// <summary>His walk or run, by its own speed — `thrasher.zvar` has a tier for each clip. THE HOST.</summary>
	void Pace( float speed )
	{
		_pace = speed;
		_paceClip = speed >= RunFast ? ClipRun1 : speed >= RunSlow ? ClipRun2 : speed >= WalkFast ? ClipWalk2 : ClipWalk1;
		SetSpeed( speed );
	}

	/// <summary>Enraged, for good: the roar, the impact, the dust pulse, the aura, then the run. THE HOST.</summary>
	void Enrage()
	{
		if ( _enraged ) return;
		_enraged = true;

		SetPart( RagePart, 1 );
		Pace( Game.Random.Int( 0, 1 ) == 0 ? RunSlow : RunFast );
		PlayShared( ClipEnrage, EnrageLen );

		// upstream's `spawn_0N` at 677 and `enrage_imp_00` at 577, and `bo3_astronaut_pulse` at his chest
		NZSound.PlayShared( "nz.thrasher.spawn", Voice );
		NZSound.PlayShared( "nz.thrasher.enrage", Voice );
		BossFx.BurstShared( WorldPosition + Vector3.Up * 60f * Size, 160f * Size, Pulse, 12 );
		Say( $"ENRAGED — running at {_pace:0} u/s" );
	}

	/// <summary>
	/// The AI picks his swing; this reads which: the heavy one hits x<see cref="HeavyWeight"/> (`SwingScale`, which Oberon's cap
	/// still holds), and each whooshes on its own `melee_whoosh` frame. THE HOST.
	/// </summary>
	void TickSwing()
	{
		var clip = Ai.State == ZombieState.Attacking ? Ai.CurrentClip : null;
		var swing = clip is ClipSwipe or ClipHeavy;
		var t = swing && Body.IsValid() ? Body.Sequence.TimeNormalized : 0f;

		if ( swing )
		{
			Ai.SwingScale = clip == ClipHeavy ? HeavyWeight : 1f;

			// a new swing (another clip, or the same one started again): the cursor back to its start
			if ( clip != _swingClip || t < _swingT ) _swingT = 0f;
			var at = clip == ClipHeavy ? HeavyWhoosh : SwipeWhoosh;
			if ( _swingT < at && t >= at ) NZSound.PlayShared( "nz.thrasher.swing", Voice );
		}

		_swingClip = swing ? clip : null;
		_swingT = t;
	}

	/// <summary>Every walker within 64 goes over, out of his way (upstream's knockdown reactions). THE HOST.</summary>
	void Shove()
	{
		var reach = ShoveRange * Size;
		foreach ( var z in ZombieAI.All.ToList() )
		{
			if ( !Walker( z ) || z.WorldPosition.Distance( WorldPosition ) >= reach ) continue;
			z.Knockback( WorldPosition, ShoveDistance * Size, ShoveSeconds, ShoveRise );
		}
	}

	/// <summary>An ordinary zombie, up and free — what he shoves and what he eats (upstream: a moo zombie, not `IsMooSpecial`, not
	/// mid special animation).</summary>
	bool Walker( ZombieAI z ) => z.IsValid() && z != Ai
		&& (z.State is ZombieState.Chasing or ZombieState.Attacking or ZombieState.Idle)
		&& !(z.Variant?.IsBoss ?? false) && !SpecialEnemies.IsSpecial( z.Variant );

	// ══ the burrow ═══════════════════════════════════════════════════════════

	bool TryBurrow()
	{
		if ( Time.Now < _nextTeleport ) return false;
		var p = TargetPlayer;
		if ( !IsUp( p ) || WorldPosition.Distance( p.WorldPosition ) < TeleportRange * Size ) return false;
		return Burrow();
	}

	/// <summary>Down into the ground (`teleport_out`); the rest is beats. THE HOST.</summary>
	bool Burrow()
	{
		// ⚠️ THE WHOLE ACT IS ONE HOLD: down, and up again — the coming up re-arms it for its own length
		if ( !PlayShared( ClipOut, OutLen + InLen + 0.25f ) ) return false;
		_away = true;
		_nextTeleport = Time.Now + OutLen + InLen + TeleportGap;
		Queue( BurrowAt, (int)Beat.Burrow );
		Queue( OutLen, (int)Beat.Surface );
		Say( "burrowing" );
		return true;
	}

	/// <summary>Under the floor: to a spot behind his target, facing them, and up (`teleport_in`). THE HOST.</summary>
	void Surface()
	{
		var p = TargetPlayer;
		if ( !IsUp( p ) ) p = NearestPlayer();
		var spot = IsUp( p ) ? BehindSpot( p ) : WorldPosition;

		Warp( spot );
		if ( IsUp( p ) ) Face( p.WorldPosition );

		SwapClip( ClipIn );
		Ai.HoldSpecialFor( InLen );
		Queue( EmergeAt, (int)Beat.Emerge );
		Queue( FallAt, (int)Beat.Fall );
		Queue( InLen, (int)Beat.Up );
	}

	/// <summary>
	/// Upstream's `FindSpotBehindPlayer`: random points on the navmesh within 100 of them, kept when they lie behind where the
	/// player looks; the nearest wins. ⚠️ Not nearer than 45, so he does not come up inside them. None found, the mesh at their feet.
	/// </summary>
	Vector3 BehindSpot( NZPlayer p )
	{
		var centre = p.WorldPosition;
		var look = p.EyeAngles.ToRotation().Forward.WithZ( 0f ).Normal;
		var far = BehindFar * Size;

		var nav = Scene.NavMesh;
		Vector3? best = null;
		var bestD = float.MaxValue;
		if ( nav is not null )
			for ( int i = 0; i < 25; i++ )
			{
				var q = nav.GetRandomPoint( centre, far );
				if ( !q.HasValue ) continue;
				var off = (q.Value - centre).WithZ( 0f );
				var d = off.Length;
				if ( d < BehindNear * Size || d > far || MathF.Abs( q.Value.z - centre.z ) > 32f ) continue;
				if ( look.Length > 0.5f && look.Dot( off.Normal ) >= 0f ) continue;
				if ( d >= bestD ) continue;
				bestD = d;
				best = q;
			}

		return best ?? ZombieAI.NavGround( Scene, centre );
	}

	/// <summary>
	/// The ground breaking where he goes down or comes up, on every machine: upstream's `bo3_zombie_spawn` dirt and
	/// `bo3_panzer_landing` ring, the hand, the quake (and the burrow's own sound). ⚠️ Its roots prop (`nz_ent_fx_thrasher_vines`)
	/// is not ported.
	/// </summary>
	void Ground( Vector3 at, bool burrow )
	{
		SpawnDirt.Burst( Scene, at + Vector3.Up, 18, 1.3f );
		ShockRing.FireShared( at + Vector3.Up * 4f, 150f * Size, Dust );
		NZSound.PlayShared( "nz.thrasher.hand", at );
		NZSound.PlayShared( "nz.thrasher.quake", at );
		if ( burrow ) NZSound.PlayShared( "nz.thrasher.burrow", at );
		NZNet.ShakeAt( at, 0.3f, 600f );
	}

	// ══ a downed player ══════════════════════════════════════════════════════

	static bool Downed( NZPlayer p ) => p.IsValid() && (p.IsDown || p.DownedNet) && !p.IsOutOfRound && !p.OutOfRoundNet;

	/// <summary>Each downed player, one pass in three, is his next meal if near enough (upstream's `math.random(3) == 1`). THE HOST.</summary>
	bool TryMeal()
	{
		if ( !EatsDowned ) return false;
		var players = Scene.GetAllComponents<NZPlayer>().ToList();
		if ( !EatAlone && players.Count <= 1 ) return false;

		foreach ( var p in players )
		{
			if ( !Downed( p ) || Game.Random.Float( 0f, 1f ) >= EatChance ) continue;
			if ( StartMeal( p ) ) return true;
		}
		return false;
	}

	/// <summary>
	/// In reach, he eats; further, he walks there first — ⚠️ IN A STRAIGHT LINE OVER CLEAR FLOOR (`ZombieAI.BeginArc`, no height,
	/// his own walk or run playing at its own speed), since the AI here paths only to what it chases and a downed player is not
	/// that. No clear line, no meal (upstream's `MoveToPos` pathed). THE HOST.
	/// </summary>
	bool StartMeal( NZPlayer p )
	{
		var flat = (p.WorldPosition - WorldPosition).WithZ( 0f );
		var d = flat.Length;
		// not through a floor: the same level, within a stair's rise
		if ( d > EatRange * Size || MathF.Abs( p.WorldPosition.z - WorldPosition.z ) > 64f * Size ) return false;

		_meal = p;
		if ( d <= EatReach * Size ) return BeginMeal( false );

		var to = p.WorldPosition - flat / d * EatReach * 0.7f * Size;
		var walk = (to - WorldPosition).WithZ( 0f ).Length / MathF.Max( 30f, _pace * Size );
		if ( !ClearWalk( to ) || !PlayShared( _paceClip, walk + MealLen + 0.5f ) )
		{
			_meal = null;
			return false;
		}

		Ai.BeginArc( to, walk, 0f );
		Queue( walk, (int)Beat.MealStart );
		Say( $"going for {p.GameObject.Name}, down {d:0} away" );
		return true;
	}

	/// <summary>Nothing in the way at knee height, and the navmesh under every 32 along the line, within a step of it.</summary>
	bool ClearWalk( Vector3 to )
	{
		var from = WorldPosition;
		var flat = (to - from).WithZ( 0f );
		var d = flat.Length;
		if ( d < 8f ) return true;

		var knee = Vector3.Up * 24f * Size;
		var tr = Scene.Trace.Ray( from + knee, to + knee ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "player", "zombie", "trigger", "ragdoll" ).Run();
		if ( tr.Hit ) return false;

		var nav = Scene.NavMesh;
		if ( nav is null ) return false;
		var dir = flat / d;
		for ( var s = 32f; s < d; s += 32f )
		{
			var at = (from + dir * s).WithZ( MathX.Lerp( from.z, to.z, s / d ) );
			var box = new BBox( at - new Vector3( 16f, 16f, 24f ), at + new Vector3( 16f, 16f, 24f ) );
			if ( !nav.GetClosestPoint( box ).HasValue ) return false;
		}
		return true;
	}

	/// <summary>`nz_thrasher_eat`, facing them: the grab, three bites, and at its end the verdict. THE HOST.</summary>
	bool BeginMeal( bool walked )
	{
		var p = _meal;
		if ( !Downed( p ) )
		{
			_meal = null;
			if ( walked ) Ai.CancelSpecial();
			return false;
		}

		Face( p.WorldPosition );
		if ( walked )
		{
			SwapClip( ClipEat );
			Ai.HoldSpecialFor( MealLen );
		}
		else if ( !PlayShared( ClipEat, MealLen ) )
		{
			_meal = null;
			return false;
		}

		Queue( MealGrab, (int)Beat.Grab );
		Queue( MealBite1, (int)Beat.Bite );
		Queue( MealBite2, (int)Beat.Bite );
		Queue( MealBite3, (int)Beat.Bite );
		Queue( MealLen, (int)Beat.Devour );
		Say( $"eating {p.GameObject.Name}" );
		return true;
	}

	/// <summary>
	/// Still down, and still in front of him: they bleed out at once, on their own machine (`NZNet.BossDevour`), in a burst of gore
	/// (upstream's `Kill`, `BloodExplodeSounds` and `zmb_monster_explosion`). THE HOST.
	/// </summary>
	void Devour()
	{
		var p = _meal;
		_meal = null;
		if ( !Downed( p ) || p.WorldPosition.Distance( WorldPosition ) > (EatReach + EatEscape) * Size ) return;

		NZNet.BossDevour( p.GameObject.Id );
		var at = p.WorldPosition + Vector3.Up * 15f;
		NZSound.PlayShared( "nz.thrasher.gibs", at );
		BossFx.BurstShared( at, 70f * Size, Gore, 16 );
		Say( $"ate {p.GameObject.Name}" );
	}

	// ══ a zombie ═════════════════════════════════════════════════════════════

	/// <summary>A sac gone, fewer than 3 regrowths, 10 s since the last pop or meal: the nearest walker within 85. THE HOST.</summary>
	bool TrySnack()
	{
		if ( _popped == 0 || _regens >= RegrowMax || Time.Now < _regrowReadyAt ) return false;

		ZombieAI best = null;
		var bestD = RegrowRange * Size;
		foreach ( var z in ZombieAI.All )
		{
			if ( !Walker( z ) ) continue;
			var d = z.WorldPosition.Distance( WorldPosition );
			if ( d >= bestD ) continue;
			bestD = d;
			best = z;
		}
		return best is not null && Snack( best );
	}

	/// <summary>
	/// `nz_thrasher_eat_z_b`: the walker pulled into his hands and held there, frozen (`ZombieAI.Pull`, Gravity Well's hold), until
	/// it is gone at 3.33 s; the sac grows back as the clip ends. THE HOST.
	/// </summary>
	bool Snack( ZombieAI z )
	{
		Face( z.WorldPosition );
		if ( !z.Pull( WorldPosition + Facing * SnackHold * Size, 0f, SnackGone + 0.25f, SnackGrab ) ) return false;
		if ( !PlayShared( ClipSnack, SnackLen ) ) return false;

		_snack = z;
		_swallowed = false;
		_regrowReadyAt = Time.Now + RegrowGap;
		Queue( SnackGrab, (int)Beat.Grab );
		Queue( SnackBite1, (int)Beat.Bite );
		Queue( SnackBite2, (int)Beat.Bite );
		Queue( SnackBite3, (int)Beat.Bite );
		Queue( SnackGone, (int)Beat.Swallow );
		Queue( SnackLen, (int)Beat.Regrow );
		Say( $"eating a zombie ({z.GameObject.Name}) to grow a sac back" );
		return true;
	}

	/// <summary>
	/// The walker gone (upstream's `remove_zombie`: no kill, no points, no drop). ⚠️ SHOT OUT OF HIS HANDS FIRST, NOTHING GROWS —
	/// what upstream's `CancelCurrentAction` was set for, its check commented out. THE HOST.
	/// </summary>
	void Swallow()
	{
		var z = _snack;
		_snack = null;
		if ( !z.IsValid() || z.State == ZombieState.Dead )
		{
			Ai.CancelSpecial();
			Say( "his meal was shot out of his hands — nothing grows back" );
			return;
		}

		BossFx.BurstShared( z.WorldPosition + Vector3.Up * 40f, 40f * Size, Gore, 10 );
		z.GameObject.Destroy();
		_swallowed = true;
	}

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

	static IEnumerable<ThrasherBoss> All
		=> Game.ActiveScene?.GetAllComponents<ThrasherBoss>() ?? Enumerable.Empty<ThrasherBoss>();

	/// <summary>Every Thrasher alive does <paramref name="act"/> now — on the host, which decides.</summary>
	static void Each( Action<ThrasherBoss> act )
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-thrasher] the host decides" ); return; }
		foreach ( var t in All.Where( t => t.Ai.IsValid() && !t.Dead ).ToList() ) act( t );
	}

	string SacsShown => $"{(_sac[Leg] ? "leg" : "-")}/{(_sac[Chest] ? "chest" : "-")}/{(_sac[Back] ? "back" : "-")}";

	/// <summary>`nz_thrasher` — every Thrasher alive, and the tuning.</summary>
	[ConCmd( "nz_thrasher" )]
	public static void Report()
	{
		Log.Info( $"[nz-thrasher] sacs x{SacScale:0.##} within {SacReach:0}, else x{BodyScale:0.##} · pops at {PopFirstAt * 100f:0}% and"
			+ $" {PopSecondAt * 100f:0}% (+{PopPoints}) · rage {EnrageOnHit * 100f:0}% a hit · burrows from {TeleportRange:0},"
			+ $" {TeleportGap:0.#}s apart · eats the downed within {EatRange:0}/{EatReach:0}"
			+ $" ({(EatsDowned ? (EatAlone ? "solo too" : "not solo") : "OFF")}) · regrows {RegrowGap:0}s apart · heavy x{HeavyWeight:0.##}"
			+ $" · hit cap {MaxHit:0}" );

		var list = All.ToList();
		Log.Info( $"[nz-thrasher] {list.Count} alive" );
		foreach ( var t in list )
			Log.Info( $"[nz-thrasher]   hp {t.Hp?.Current ?? 0f:0}/{t.Hp?.Max ?? 0f:0} · state {t.Ai?.State} · speed {t.Ai?.MoveSpeed ?? 0f:0}"
				+ $" · sacs {t.SacsShown} ({t._popped} popped, {t._regens} regrown)"
				+ $"{(t._enraged ? " · ENRAGED" : $" · rage in {MathF.Max( 0f, t._enrageAt - Time.Now ):0}s")}"
				+ $"{(Time.Now < t._iframesUntil ? $" · i-frames {t._iframesUntil - Time.Now:0.#}s" : "")}{(t._away ? " · BURROWING" : "")}"
				+ $"{(t._meal.IsValid() ? $" · eating {t._meal.GameObject.Name}" : "")}{(t._snack.IsValid() ? " · eating a zombie" : "")}"
				+ $" · next burrow in {MathF.Max( 0f, t._nextTeleport - Time.Now ):0.#}s · hitboxes {t.Ai?.HitboxCount ?? 0}" );
	}

	/// <summary>`nz_thrasher_pop [leg|chest|back]` — that sac (the chest's by default) off every Thrasher now, past the health gate.</summary>
	[ConCmd( "nz_thrasher_pop" )]
	public static void PopCmd( string which = "chest" )
		=> Each( t => t.Pop( which.ToLowerInvariant() switch { "leg" => Leg, "back" => Back, _ => Chest }, null ) );

	/// <summary>`nz_thrasher_enrage` — enraged now. `nz_thrasher_burrow` — down and up behind his target now, whatever the range.</summary>
	[ConCmd( "nz_thrasher_enrage" )]
	public static void EnrageCmd() => Each( t => t.Enrage() );

	[ConCmd( "nz_thrasher_burrow" )]
	public static void BurrowCmd() => Each( t => { if ( !t.Burrow() ) Log.Info( "[nz-thrasher] busy — again when he walks" ); } );

	/// <summary>`nz_thrasher_snack` — with a sac gone, he eats the nearest walker within 300 now.</summary>
	[ConCmd( "nz_thrasher_snack" )]
	public static void SnackCmd() => Each( t =>
	{
		var z = ZombieAI.All.Where( t.Walker ).OrderBy( c => c.WorldPosition.Distance( t.WorldPosition ) ).FirstOrDefault();
		if ( t._popped == 0 ) Log.Info( "[nz-thrasher] no sac to grow back — nz_thrasher_pop first" );
		else if ( z is null || z.WorldPosition.Distance( t.WorldPosition ) > 300f ) Log.Info( "[nz-thrasher] no walker within 300" );
		else if ( !t.Snack( z ) ) Log.Info( "[nz-thrasher] could not eat it (busy, or it is mid-window)" );
	} );

	/// <summary>`nz_thrasher_eat` — he goes for the nearest downed player in range now (no roll).</summary>
	[ConCmd( "nz_thrasher_eat" )]
	public static void EatCmd() => Each( t =>
	{
		var p = t.Scene.GetAllComponents<NZPlayer>().Where( Downed ).OrderBy( q => q.WorldPosition.Distance( t.WorldPosition ) )
			.FirstOrDefault();
		if ( p is null ) Log.Info( "[nz-thrasher] nobody is down" );
		else if ( !t.StartMeal( p ) ) Log.Info( $"[nz-thrasher] no way to {p.GameObject.Name} (past {EatRange:0}, or no straight line)" );
	} );

	/// <summary>`nz_thrasher_set &lt;key&gt; &lt;value&gt;` — retune him live.</summary>
	[ConCmd( "nz_thrasher_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "sac": SacScale = value; break;
			case "body": BodyScale = value; break;
			case "reach": SacReach = value; break;
			case "pop1": PopFirstAt = value; break;
			case "pop2": PopSecondAt = value; break;
			case "points": PopPoints = (int)value; break;
			case "rage": EnrageOnHit = value; break;
			case "heavy": HeavyWeight = value; break;
			case "range": TeleportRange = value; break;
			case "gap": TeleportGap = value; break;
			case "eatrange": EatRange = value; break;
			case "eatreach": EatReach = value; break;
			case "eat": EatsDowned = value != 0f; break;
			case "alone": EatAlone = value != 0f; break;
			case "regrow": RegrowGap = value; break;
			default:
				Log.Info( "[nz-thrasher] nz_thrasher_set <sac|body|reach|pop1|pop2|points|rage|heavy|range|gap|eatrange|eatreach|eat|alone|regrow> <value>" );
				return;
		}
		Log.Info( $"[nz-thrasher] {key} = {value:0.###}" );
		Report();
	}
}