Player/TortoiseAugments.cs

Component and static augment system for the Tortoise perk. Defines a TortoiseRing component that represents a planted floor ring and TortoiseAugments static class that manages ring creation, mirroring across networked clients, per-frame planting logic, augment effects (damage multipliers, incoming damage, rally stacks, entrench healing, shellshock stun, auto-repair, handyman kill-on-repair), diagnostics and console commands.

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

namespace NZombies;

/// <summary>
/// A planted Tortoise ring. Lives on its own GameObject so any player can stand in it.
///
/// ⛔ A COMPONENT, NOT A POSITION ON THE PLAYER, because the ring is a place in the world and
/// its effects apply to "every player inside" — not to whoever planted it. A `Vector3` on
/// `NZPlayer` could not answer "which ring am I in and what does it grant", which is the
/// question every one of M1, M4, m2 and m5 asks.
///
/// ⚠️ IT CARRIES WHAT IT GRANTS, latched at spawn. The augments could change while a ring is
/// standing — bought, cleared, or the perk lost — and a ring whose behaviour shifted underfoot
/// would be indistinguishable from a bug. What you planted is what you get.
///
/// ⚠️ THE STACKS LIVE HERE, NOT ON THE PLAYER, because M4 is explicitly shared: "every zombie
/// you or any player who is also inside kills". One counter per ring is that sentence. And
/// "leaving resets this" needs no code at all — leaving DESTROYS the ring, and the stacks go
/// with it.
/// </summary>
public sealed class TortoiseRing : Component
{
	/// <summary>Who planted it. The ring is destroyed when this player leaves it.</summary>
	public NZPlayer Owner { get; set; }

	/// <summary>Radius in units, already including m2's bonus.</summary>
	public float Radius { get; set; } = 100f;

	/// <summary>Does it grant M1's damage and defence.</summary>
	public bool Dig { get; set; }

	/// <summary>Does it accumulate M4's kill stacks.</summary>
	public bool Rally { get; set; }

	/// <summary>Does it regenerate armor — m5.</summary>
	public bool Entrench { get; set; }

	/// <summary>M4's shared kill count.</summary>
	public int Stacks { get; set; }

	/// <summary>
	/// A STABLE NAME FOR THIS RING, for ordering rings the same way on every machine.
	///
	/// ⚠️ THE OWNER'S CONNECTION ID, not the GameObject's — a ring is `NetworkMode.Never`, so
	/// its own id is different on every machine and could not order anything consistently. Its
	/// owner's id is the same everywhere.
	/// </summary>
	public string Key => Owner.IsValid() ? NZPlayers.OwnerOf( Owner.GameObject ) ?? "" : "";

	/// <summary>Is a point inside this ring. Flat XY — a ring is painted on the floor.</summary>
	public bool Contains( Vector3 at )
		=> WorldPosition.WithZ( 0f ).Distance( at.WithZ( 0f ) ) <= Radius;

	/// <summary>
	/// A ring whose planter is gone goes with them.
	///
	/// ⛔ A TEAMMATE'S RING IS A MIRROR, AND ONLY `MirrorRing` EVER DROPPED ONE (2026-09-27). That runs
	/// from `Tick` for the teammate's body — so when they disconnected, the body went and the mirror
	/// stayed: drawn forever, still ×1.5 for anyone standing in it on the host (which scales every
	/// hit), still halving damage taken, still banking Rally stacks. The host's own ring could be
	/// orphaned the same way by the body swap in `NZPlayers.EnsureHostBody`.
	///
	/// ⚠️ HERE, ON THE RING, rather than in `TortoiseAugments.Rings`, because that only runs when
	/// something asks — and in the lobby nothing does, so a dead ring would stay drawn.
	/// </summary>
	protected override void OnUpdate()
	{
		if ( !Owner.IsValid() ) GameObject.Destroy();
	}
}

/// <summary>
/// Victorious Tortoise's augments. Base perk: 50% less damage from behind.
///
/// | id | name | effect |
/// |----|------|--------|
/// | M1 | **Dig In**        | stand still 3s → a **ring**: everyone inside deals ×1.5 and takes half |
/// | M2 | **Fortifier**     | barricades within 250u repair themselves |
/// | M3 | **Turtle Shell**  | **90%** less damage from behind, replacing the base 50% |
/// | M4 | **Rallying Stand** | ring: every kill by anyone inside adds **+2% damage, up to ×3** |
/// | m1 | **Shellshock**    | breaking an armor bar stuns zombies within 400u for 0.5s |
/// | m2 | **Wider Stance**  | rings are **30% bigger** |
/// | m3 | **Handyman**      | repairing a board **kills** the zombies tearing at that barricade |
/// | m4 | **Quick Hands**   | barricades repair **twice as fast** |
/// | m5 | **Entrench**      | inside a ring, regain **5 armor/second** |
///
/// ⛔ ONE RING, MANY RIDERS. M1 and M4 both plant "a ring after 3 seconds standing still", so
/// there is ONE ring per player and the augments decide what it grants. Two ring systems would
/// have meant two overlapping circles with two lifetimes and two radii — and m2 ("rings are
/// bigger") and m5 ("inside a ring") would each have had to pick which one they meant.
///
/// ⚠️ THE RING DOES NOT MOVE AND DIES WHEN YOU LEAVE IT. Both are requested behaviour and
/// together they make the whole perk a positional commitment: you choose where to stand and the
/// bonus is the reward for not moving. A ring that followed the player would just be a passive
/// aura.
///
/// ⚠️ M3 REPLACES THE BASE FIGURE RATHER THAN STACKING. The base perk is already 50% off from
/// behind, so a stacking 90% would be 95% and no number in the UI would match anything. Same
/// resolution as Vigor Rush's M1 Overkill, which replaces its own base multiplier for the same
/// reason.
/// </summary>
public static class TortoiseAugments
{
	const string Perk = "tortoise";

	/// <summary>The name every ring GameObject carries.</summary>
	public const string RingName = "nz_tortoise_ring";

	// ══ tuning ════════════════════════════════════════════════════════════════
	//
	// ⚠️ Nullable getters, not initialisers — a changed default has to survive a hotload.

	static float? _ringSeconds;
	/// <summary>How long you must stand still to plant a ring. 3s.</summary>
	public static float RingSeconds { get => _ringSeconds ?? 3f; set => _ringSeconds = value; }

	static float? _ringRadius;
	/// <summary>
	/// Ring radius. 100u — i.e. **200 units across**.
	///
	/// ⛔ REQUESTED AS "200 units wide", READ AS A DIAMETER. Width across a circle is its
	/// diameter, so the radius is 100 — but every other distance in this project is authored as a
	/// radius, so the reading is worth stating rather than assuming. If 200 was meant as the
	/// radius, `nz_tortoise_set ringRadius 200` is the whole fix.
	/// </summary>
	public static float RingRadius { get => _ringRadius ?? 100f; set => _ringRadius = value; }

	static float? _stillTolerance;
	/// <summary>
	/// How far you may drift and still count as standing still. 8u.
	///
	/// ⚠️ NOT ZERO. Exact-zero velocity is rare — idle sway, separation from a zombie brushing
	/// past, or standing on anything that settles — so a strict test would make the ring almost
	/// impossible to plant and read as the augment not working.
	/// </summary>
	public static float StillTolerance { get => _stillTolerance ?? 8f; set => _stillTolerance = value; }

	static float? _digDamage;
	/// <summary>M1 Dig In — damage multiplier inside the ring. ×1.5.</summary>
	public static float DigDamage { get => _digDamage ?? 1.5f; set => _digDamage = value; }

	static float? _digDefence;
	/// <summary>M1 Dig In — incoming damage multiplier inside the ring. Half.</summary>
	public static float DigDefence { get => _digDefence ?? 0.5f; set => _digDefence = value; }

	static float? _rallyPerKill;
	/// <summary>M4 Rallying Stand — damage added per kill inside the ring. +2%.</summary>
	public static float RallyPerKill { get => _rallyPerKill ?? 0.02f; set => _rallyPerKill = value; }

	static float? _rallyMax;
	/// <summary>M4 Rallying Stand — the ceiling. ×3, so 100 kills.</summary>
	public static float RallyMax { get => _rallyMax ?? 3f; set => _rallyMax = value; }

	static float? _backReduction;
	/// <summary>
	/// M3 Turtle Shell — what fraction of a back hit gets through. 0.10, i.e. 90% off.
	///
	/// ⚠️ READ BY `PerkEffects.TortoiseScale`, which owns the base 50%. One author for "damage
	/// from behind", with the augment supplying the number — not a second reduction multiplied on
	/// top, which would land at 95% and match nothing the UI says.
	/// </summary>
	public static float BackReduction { get => _backReduction ?? 0.10f; set => _backReduction = value; }

	static float? _breakStunRadius;
	/// <summary>m1 Shellshock — stun radius when an armor bar breaks. 400u.</summary>
	public static float BreakStunRadius { get => _breakStunRadius ?? 400f; set => _breakStunRadius = value; }

	static float? _breakStunSeconds;
	/// <summary>m1 Shellshock — how long they are stunned. 0.5s.</summary>
	public static float BreakStunSeconds { get => _breakStunSeconds ?? 0.5f; set => _breakStunSeconds = value; }

	static float? _ringRadiusBonus;
	/// <summary>m2 Wider Stance — ring radius multiplier. +30%.</summary>
	public static float RingRadiusBonus { get => _ringRadiusBonus ?? 1.3f; set => _ringRadiusBonus = value; }

	static float? _autoRepairRadius;
	/// <summary>M2 Fortifier — how far barricades repair themselves. 250u.</summary>
	public static float AutoRepairRadius { get => _autoRepairRadius ?? 250f; set => _autoRepairRadius = value; }

	// ⚠️ m3 HANDYMAN HAS NO RADIUS ANY MORE (2026-10-03). It kills the zombies tearing at the
	// window (`ZombieAI.IsTearing`), so its reach is theirs: each zombie's `BarricadeReach`. The
	// 200u `HandymanRadius` went with the old "everything near the barricade" rule, and with it
	// the `handyman` argument of `nz_tortoise_set`.

	static float? _repairSpeedup;
	/// <summary>m4 Quick Hands — how much faster boards go up. ×2.</summary>
	public static float RepairSpeedup { get => _repairSpeedup ?? 2f; set => _repairSpeedup = value; }

	static float? _entrenchArmor;
	/// <summary>m5 Entrench — armor per second inside a ring. 5.</summary>
	public static float EntrenchArmor { get => _entrenchArmor ?? 5f; set => _entrenchArmor = value; }

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

	static NZPlayer PlayerOf( GameObject go )
		=> go.IsValid()
			? go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

	// ══ the ring ══════════════════════════════════════════════════════════════

	static float _ringStamp = -1f;
	static TortoiseRing[] _ringCache = Array.Empty<TortoiseRing>();

	/// <summary>
	/// Every live ring, resolved once per frame.
	///
	/// ⚠️ CACHED like `VultureStink.Clouds` and `TimeAugments.Pits`, and for the same reason: the
	/// damage hooks ask per hit, and a hit can happen many times a frame. The stamp seeds to -1
	/// so the first frame cannot match it and return a stale empty list (§1).
	/// </summary>
	static TortoiseRing[] Rings()
	{
		if ( _ringStamp == Time.Now ) return _ringCache;
		_ringStamp = Time.Now;

		var scene = Game.ActiveScene;

		_ringCache = scene.IsValid()
			// ⚠️ AN OWNERLESS RING IS SKIPPED FOR THE FRAME IT IS STILL STANDING — its own
			// `OnUpdate` destroys it, and a destroy lands at the end of the frame.
			? scene.GetAllComponents<TortoiseRing>().Where( r => r.IsValid() && r.Owner.IsValid() ).ToArray()
			: Array.Empty<TortoiseRing>();

		return _ringCache;
	}

	/// <summary>
	/// The ring this player is standing in, or null.
	///
	/// ⛔ DETERMINISTIC, WHICH IT WAS NOT. It took the FIRST containing ring out of
	/// `scene.GetAllComponents`, whose order is scene order — and a ring is created at runtime on
	/// each machine independently, so two overlapping rings could be enumerated in one order on the
	/// host and the other on a client. The host would then scale a bullet by one ring while the
	/// client drew the player standing in the other, and neither machine was wrong.
	///
	/// ⚠️ NEAREST CENTRE WINS, TIE-BROKEN BY OWNER ID. Nearest is the rule a player would guess
	/// — the ring you are most inside — and positions are synced, so every machine measures the
	/// same distances. The id breaks exact ties, which two rings planted on one spot can produce.
	/// </summary>
	public static TortoiseRing RingOf( NZPlayer player )
	{
		if ( !player.IsValid() ) return null;

		var at = player.WorldPosition.WithZ( 0f );

		TortoiseRing best = null;
		var bestDist = float.MaxValue;

		foreach ( var r in Rings() )
		{
			if ( !r.IsValid() || !r.Contains( player.WorldPosition ) ) continue;

			var d = r.WorldPosition.WithZ( 0f ).Distance( at );

			if ( best is not null )
			{
				if ( d > bestDist ) continue;
				if ( d == bestDist && string.CompareOrdinal( r.Key, best.Key ) >= 0 ) continue;
			}

			best = r;
			bestDist = d;
		}

		return best;
	}

	/// <summary>M1/M4/m5 as the bits `NZPlayer.RingFlags` carries.</summary>
	public static int FlagsOf( TortoiseRing ring )
		=> !ring.IsValid() ? 0
			: (ring.Dig ? 1 : 0) | (ring.Rally ? 2 : 0) | (ring.Entrench ? 4 : 0);

	/// <summary>Ring radius for this player, including m2.</summary>
	public static float RadiusFor( NZPlayer player )
		=> RingRadius * (Has( player, "m2" ) ? MathF.Max( 0.1f, RingRadiusBonus ) : 1f);

	/// <summary>Does this player's loadout plant rings at all.</summary>
	public static bool PlantsRings( NZPlayer player )
		=> Has( player, "M1" ) || Has( player, "M4" );

	/// <summary>
	/// Per-frame: plant a ring after standing still, drop it on leaving, and run m5's regen.
	///
	/// ⛔ CALLED FROM `NZPlayer.OnUpdate`. A component created at runtime does not survive a
	/// hotload — `Slide` carries that warning and `PhdAugments.Tick` follows the same rule.
	///
	/// ⚠️ THE RING IS DESTROYED THE MOMENT ITS OWNER STEPS OUT, which is requested behaviour and
	/// is also what makes M4's "leaving resets this" need no code: the stacks live on the ring.
	/// </summary>
	public static void Tick( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		// ⛔ SOMEBODY ELSE'S RING IS MIRRORED, NOT DECIDED. Everything below this line asks
		// whether the player owns the perk, has stood still long enough and is still inside — all
		// of which are answerable only on their own machine. On a proxy every one of those is
		// false, so a teammate's ring was simply never built here and never drawn.
		//
		// ⚠️ IT RECONCILES RATHER THAN REACTS. Whatever `RingRadius` says is what exists; there
		// is no plant or drop event to miss, and a ring cannot be left burned into the world.
		if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) )
		{
			MirrorRing( player );
			return;
		}

		var ring = player.TortoiseRing;

		// ── the ring dies when its owner leaves it, or loses the perk ──
		if ( ring.IsValid() )
		{
			if ( !PlantsRings( player ) || !ring.Contains( player.WorldPosition ) )
			{
				Log.Info( $"[nz-aug] tortoise ring dropped"
					+ $" ({(PlantsRings( player ) ? "left it" : "perk lost")})"
					+ (ring.Rally && ring.Stacks > 0 ? $" — {ring.Stacks} stack(s) lost" : "") );

				ring.GameObject.Destroy();
				player.TortoiseRing = null;
				player.TortoiseStill = 0f;
				return;
			}

			TickEntrench( player, ring );
			return;
		}

		if ( !PlantsRings( player ) ) return;

		// ── standing still long enough plants one ──
		//
		// ⚠️ MEASURED FROM A REMEMBERED POSITION, not from velocity. Velocity can read zero for a
		// frame mid-stride, which would let a running player plant a ring; a position that has
		// not moved cannot.
		if ( player.WorldPosition.Distance( player.TortoiseStillAt ) > StillTolerance )
		{
			player.TortoiseStillAt = player.WorldPosition;
			player.TortoiseStill = 0f;
			return;
		}

		if ( player.TortoiseStill < RingSeconds ) return;

		player.TortoiseRing = Plant( player );
	}

	/// <summary>
	/// Create the ring at the player's feet.
	///
	/// ⛔ A DRAWN CIRCLE, NOT A FLATTENED BOX. This was `models/dev/box.vmdl` scaled thin, which is
	/// a SQUARE however thin it gets — the placeholder note said "real art later" and the shape was
	/// what read as wrong. `PitVisual` already draws a floor ring as a closed `LineRenderer` traced
	/// onto the terrain, which is the same thing the napalm pit's edge is.
	///
	/// ⚠️ REUSED RATHER THAN COPIED. PitVisual's own header argues the case: three copies of a
	/// floor ring would be three places to fix the day one looks wrong. This adds a `Tortoise`
	/// style to that file instead — ring on, gas off, no drag rings.
	///
	/// ⚠️ NO COLLIDER STILL. It cannot block a bullet or a path; only the look changed.
	/// </summary>
	/// <summary>
	/// Build or drop this machine's copy of somebody else's ring, from what they published.
	///
	/// ⛔ IT CARRIES THE GRANTS, NOT ONLY THE SHAPE — THIS WAS THE BUG. `DamageScale` reads M1
	/// and M4 off the RING, deliberately, because the zone buffs everyone standing in it rather
	/// than its planter. So a copy with every flag false is a ring that is drawn and does nothing:
	/// a client shooting from inside their own Dig In ring was scaled on the HOST, against the
	/// host's flag-less mirror, and got ×1 — and a client standing in the HOST's ring took full
	/// damage because the defence was read against their own flag-less mirror.
	///
	/// ⚠️ A FOLLOWER, STILL NOT AN AUTHOR. Nothing here decides anything: the flags and the
	/// stack count are whatever their owner last published. The owner remains the only writer —
	/// see `OnZombieKilled`, which relays a kill to the ring's owner rather than counting it here.
	/// </summary>
	static void MirrorRing( NZPlayer player )
	{
		var want = player.RingRadius > 0f;
		var ring = player.TortoiseRing;

		if ( !want )
		{
			if ( !ring.IsValid() ) return;

			ring.GameObject.Destroy();
			player.TortoiseRing = null;
			return;
		}

		if ( ring.IsValid() )
		{
			// ⚠️ MOVED RATHER THAN REBUILT if it drifts — a ring that is destroyed and recreated
			// every time its owner's position updates would flicker once a frame.
			if ( ring.WorldPosition != player.RingAt ) ring.WorldPosition = player.RingAt;
			if ( ring.Radius != player.RingRadius ) ring.Radius = player.RingRadius;
			ApplyFlags( ring, player );
			return;
		}

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.CreateObject();
		go.Name = RingName;
		go.WorldPosition = player.RingAt;
		go.NetworkMode = NetworkMode.Never;

		// ⚠️ THE SAME TWO LINES `Plant` USES, INCLUDING `Life = 0`. A ring has no lifetime — it
		// lives until its owner steps out — and `Attach` clamps life to 0.1s, so a mirrored ring
		// built without this would fade out and leave the teammate standing in nothing.
		var fx = PitVisual.Attach( go, player.RingRadius, PitVisual.Style.Tortoise, life: 1f );
		if ( fx.IsValid() ) fx.Life = 0f;

		var copy = go.Components.GetOrCreate<TortoiseRing>();
		copy.Owner = player;
		copy.Radius = player.RingRadius;
		ApplyFlags( copy, player );

		player.TortoiseRing = copy;
	}

	/// <summary>Copy a published ring's grants onto this machine's mirror of it.</summary>
	static void ApplyFlags( TortoiseRing ring, NZPlayer player )
	{
		var flags = player.RingFlags;

		ring.Dig = (flags & 1) != 0;
		ring.Rally = (flags & 2) != 0;
		ring.Entrench = (flags & 4) != 0;
		ring.Stacks = player.RingStacks;
	}

	static TortoiseRing Plant( NZPlayer player )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var radius = RadiusFor( player );

		var go = scene.CreateObject();
		go.Name = RingName;
		go.WorldPosition = player.WorldPosition;
		go.NetworkMode = NetworkMode.Never;

		// ⚠️ `Attach` SNAPS THE OBJECT TO THE FLOOR, which is what we want — the ring is planted at
		// the player's feet and `Contains` tests flat XY, so a Z snap cannot change who is inside.
		var fx = PitVisual.Attach( go, radius, PitVisual.Style.Tortoise, life: 1f );

		// ⛔ `Life = 0` MEANS "DO NOT FADE", AND IT HAS TO BE SET AFTER. A pit fades out over its
		// life; this ring has no lifetime — it lives until its owner steps out and is then
		// destroyed outright. `Attach` clamps life to a minimum of 0.1s, so passing 0 through it
		// would still fade; the fade maths reads `Life <= 0` as "full brightness, forever".
		if ( fx.IsValid() ) fx.Life = 0f;

		var ring = go.Components.Create<TortoiseRing>();
		ring.Owner = player;
		ring.Radius = radius;
		ring.Dig = Has( player, "M1" );
		ring.Rally = Has( player, "M4" );
		ring.Entrench = Has( player, "m5" );

		Log.Info( $"[nz-aug] tortoise ring planted — {radius:0}u radius"
			+ $" ({radius * 2f:0}u across)"
			+ $"{(ring.Dig ? " · Dig In" : "")}"
			+ $"{(ring.Rally ? " · Rallying Stand" : "")}"
			+ $"{(ring.Entrench ? " · Entrench" : "")}" );

		return ring;
	}

	/// <summary>
	/// m5 Entrench — armor while planted.
	///
	/// ⚠️ `Armor.CapFor`, NOT the raw tier cap, so Juggernog's armor augments still bound it.
	/// That helper is the one author of "how much armor can this player hold".
	/// </summary>
	static void TickEntrench( NZPlayer player, TortoiseRing ring )
	{
		if ( !ring.Entrench ) return;
		if ( player.ArmorTier <= 0 ) return;

		var cap = NZombies.Armor.CapFor( player );
		if ( player.Armor >= cap ) return;

		player.Armor = MathF.Min( cap, player.Armor + EntrenchArmor * Time.Delta );
	}

	// ══ M1 / M4 — damage in and out ═══════════════════════════════════════════

	/// <summary>
	/// Damage multiplier for someone standing in a ring. M1's ×1.5 and M4's kill stacks.
	///
	/// ⚠️ READ OFF THE RING, NOT THE SHOOTER'S OWN AUGMENTS. The effect belongs to the ring —
	/// "every player inside the ring" — so a teammate with no Tortoise at all still benefits from
	/// standing in yours. That is the whole point of a planted zone rather than a personal buff.
	///
	/// ⚠️ THE TWO TERMS MULTIPLY. A ring with both augments at 50 kills gives 1.5 × 2.0 = ×3.0,
	/// and both were asked for independently. Timeslip's slows had to take a minimum instead
	/// because stacking severities compound to a stop; stacking BONUSES has no such cliff.
	/// </summary>
	public static float DamageScale( GameObject attacker )
	{
		var p = PlayerOf( attacker );
		if ( !p.IsValid() ) return 1f;

		var ring = RingOf( p );
		if ( ring is null ) return 1f;

		var mult = 1f;

		if ( ring.Dig ) mult *= MathF.Max( 0f, DigDamage );

		if ( ring.Rally )
			mult *= MathF.Min( MathF.Max( 1f, RallyMax ),
				1f + ring.Stacks * MathF.Max( 0f, RallyPerKill ) );

		return mult;
	}

	/// <summary>Incoming damage multiplier for someone standing in a Dig In ring.</summary>
	public static float IncomingScale( NZPlayer victim )
	{
		var ring = RingOf( victim );

		return ring is { Dig: true } ? MathF.Max( 0f, DigDefence ) : 1f;
	}

	/// <summary>
	/// M4 Rallying Stand — a zombie died. Credit the killer's ring.
	///
	/// ⛔ THE RING'S OWNER COUNTS IT, NOT THE KILLER'S MACHINE. This hook already runs on the
	/// KILLER's machine — `AugmentEffects.OnZombieKilled` relays it there so their perks are real
	/// — but the ring they are standing in may be SOMEBODY ELSE'S, and here that ring is only a
	/// mirror. Incrementing it would raise a number that its owner overwrites on their next
	/// publish, so a client killing inside the host's Rallying Stand added nothing to it: exactly
	/// the case the augment exists for, *"you or any player who is also inside"*.
	///
	/// ⚠️ CAPPED WHERE IT IS COUNTED, not only where it is read. An uncapped counter would keep
	/// climbing, so a ring at the ceiling would behave the same as one at 500 kills — which stops
	/// mattering the moment the ceiling is retuned.
	/// </summary>
	public static void OnZombieKilled( NZPlayer killer, Vector3 position )
	{
		if ( !killer.IsValid() ) return;

		var ring = RingOf( killer );
		if ( ring is not { Rally: true } ) return;

		// ⚠️ RELAYED BY THE RING'S OWNER, NOT THE KILLER — the two are the same player often
		// enough that this reads like a no-op, and are not the same player in the one situation
		// M4 was written for.
		if ( Networking.IsActive && ring.Owner.IsValid()
			&& PlayerPresence.Theirs( ring.Owner.GameObject ) )
		{
			var owner = NZPlayers.OwnerOf( ring.Owner.GameObject );
			if ( !string.IsNullOrEmpty( owner ) ) NZNet.TortoiseRally( owner );

			return;
		}

		var max = MaxStacks();
		if ( ring.Stacks >= max ) return;

		ring.Stacks++;
	}

	/// <summary>
	/// A kill happened inside MY ring, somewhere else. Count it.
	///
	/// ⚠️ MY OWN RING BY REFERENCE, not a position lookup. The message means "inside yours",
	/// and `player.TortoiseRing` is the only ring this machine authors — resolving it by geometry
	/// again could land on a mirror of a ring that happens to overlap.
	/// </summary>
	public static void RallyFromNetwork()
	{
		var me = NZPlayer.Local;
		if ( !me.IsValid() ) return;

		var ring = me.TortoiseRing;
		if ( ring is not { Rally: true } ) return;

		var max = MaxStacks();
		if ( ring.Stacks >= max ) return;

		ring.Stacks++;
	}

	/// <summary>
	/// How many kills reach the ceiling. 100 at the defaults.
	///
	/// ⚠️ DERIVED, NOT A SECOND CONSTANT. "+2% per kill up to ×3" already says 100; a literal
	/// beside those two is a third number that can disagree with them (§6).
	/// </summary>
	public static int MaxStacks()
	{
		var step = MathF.Max( 0.0001f, RallyPerKill );

		return Math.Max( 1, (int)MathF.Ceiling( (MathF.Max( 1f, RallyMax ) - 1f) / step ) );
	}

	/// <summary>
	/// What fraction of a hit from BEHIND gets through, for this player.
	///
	/// ⛔ THE BASE PERK'S NUMBER UNLESS M3 IS HELD, and then M3's outright. Called by
	/// `PerkEffects.TortoiseScale`, which keeps the geometry — so "what counts as behind" is
	/// decided once and "how much it saves you" is decided once, in two different places that
	/// cannot disagree.
	/// </summary>
	public static float BackReductionFor( NZPlayer player )
		=> Has( player, "M3" )
			? Math.Clamp( BackReduction, 0f, 1f )
			: PerkEffects.TortoiseReduction;

	// ══ m1 — Shellshock ═══════════════════════════════════════════════════════

	/// <summary>
	/// m1 Shellshock — an armor BAR just broke, so stun everything nearby.
	///
	/// ⚠️ A BAR, NOT THE VEST. Called from `Armor` when the armor value crosses a bar boundary
	/// downward, so a tier-3 player gets three of these rather than one — which is what "breaking
	/// an armor bar" says, and it makes the augment scale with the vest you paid for.
	///
	/// ⚠️ USES THE `stun` STATUS, which already exists and already disarms the zombie
	/// (`StatusEffects.Disarms`). Writing a bespoke stun would mean a second answer to "is this
	/// zombie able to attack".
	/// </summary>
	public static void OnArmorBarBroken( NZPlayer player )
	{
		if ( !Has( player, "m1" ) ) return;

		var at = player.WorldPosition;
		var hit = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( at.Distance( z.WorldPosition ) > BreakStunRadius ) continue;

			StatusEffects.Apply( z.GameObject, "stun", player.GameObject,
				seconds: BreakStunSeconds );
			hit++;
		}

		Log.Info( $"[nz-aug] tortoise m1 Shellshock — armor bar broke,"
			+ $" stunned {hit} zombie(s) for {BreakStunSeconds:0.##}s within {BreakStunRadius:0}u" );
	}

	// ══ M2 / m3 / m4 — barricades ═════════════════════════════════════════════

	/// <summary>
	/// M2 Fortifier — repair nearby barricades without holding Use.
	///
	/// ⚠️ IT GOES THROUGH `Barricade.Repair`, the same method the Use key calls. That method owns
	/// the per-board cooldown, the points award and the sound, so auto-repair is throttled and
	/// paid for exactly as manual repair is — and m4's speed-up applies to both without being
	/// written twice.
	///
	/// ⚠️ NEAREST ONLY, per tick. `RepairableNear` already returns the closest repairable
	/// barricade within its own reach; boarding several at once from across the room would be a
	/// different and much stronger augment than the one described.
	/// </summary>
	public static void TickAutoRepair( NZPlayer player )
	{
		if ( !Has( player, "M2" ) ) return;

		// ⚠️ NOT WHILE DOWN OR OUT OF THE ROUND (2026-09-27) — the manual repair refuses both
		// (`NZPlayer`'s `if ( IsDown ) return;`), and M2 kept boarding windows from the floor, which a
		// perk kept through a down (Grave Keeper) made reachable.
		if ( player.IsDown || player.IsOutOfRound ) return;

		var at = player.WorldPosition;

		// ⚠️ THE AUGMENT'S OWN RADIUS, not `RepairReach`. `RepairableNear` gates on each
		// barricade's reach, which is a melee distance — the point of M2 is that you do not have
		// to walk over, so it scans wider and then repairs whichever is closest.
		Barricade best = null;
		var bestDist = float.MaxValue;

		foreach ( var b in Barricade.All )
		{
			if ( !b.IsValid() || b.IsFull ) continue;

			// ⚠️ Same vertical band as the manual repair — see Barricade.InReach. Without it the
			// augment quietly repairs windows on other floors.
			if ( !b.InReach( at, AutoRepairRadius ) ) continue;

			var d = b.DistanceToRun( at );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = b;
		}

		if ( best is null ) return;

		best.Repair( player );
	}

	/// <summary>Multiplier on the gap between boards. m4 halves it.</summary>
	public static float RepairIntervalScale( NZPlayer player )
		=> Has( player, "m4" ) ? 1f / MathF.Max( 0.1f, RepairSpeedup ) : 1f;

	/// <summary>
	/// m3 Handyman — a board went up, so kill the zombies tearing at that barricade.
	///
	/// ⛔ IT KILLS RATHER THAN DAMAGES, as asked. `Health.OnDamage` with the barricade's own
	/// damage would be a different augment; this puts through the zombie's remaining health so
	/// the kill counts, pays points and fires every on-kill augment — going around `OnDamage`
	/// would silently skip all three.
	///
	/// ⛔ ONLY THE ZOMBIES ATTACKING THAT WINDOW (user, 2026-10-03: "make it kill only zombies
	/// attacking the barricade"). It was every zombie within 200u of it, which reached well into
	/// the room behind: zombies that had already climbed through and were chasing someone died to
	/// a board going up on a window they had left. `ZombieAI.IsTearing` is the game's own test —
	/// the zombies whose swings this window takes — and it reads only position and the board
	/// count, so it holds on a client, where Handyman runs for a client's own boards.
	///
	/// ⚠️ THE FIRST BOARD ON AN OPEN WINDOW counts too: it makes the window something to tear
	/// again, so the zombies standing in it are attacking it by that same rule.
	/// </summary>
	public static void OnBarricadeRepaired( NZPlayer player, Barricade barricade )
	{
		if ( !Has( player, "m3" ) || !barricade.IsValid() ) return;

		var hit = 0;

		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
			if ( !z.IsTearing( barricade ) ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			hp.OnDamage( new DamageInfo
			{
				Damage = hp.Current,
				Attacker = player.GameObject,
				Position = z.WorldPosition + Vector3.Up * 32f,
				Tags = new TagSet(),
			} );

			hit++;
		}

		if ( hit > 0 )
			Log.Info( $"[nz-aug] tortoise m3 Handyman — repair killed {hit} zombie(s)"
				+ " tearing at the barricade" );
	}

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

	public static void Report( NZPlayer player )
	{
		if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var has = player.HasPerk( Perk );
		var equipped = PerkAugments.EquippedOn( player, Perk );
		var ring = RingOf( player );

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

		Log.Info( $"[nz-aug]  ring            {(PlantsRings( player ) ? $"{RadiusFor( player ):0}u radius ({RadiusFor( player ) * 2f:0}u across) after {RingSeconds:0.#}s still" : "- (needs M1 or M4)")}"
			+ $"   {(ring is null ? "not standing in one" : $"INSIDE ({ring.Radius:0}u)")}"
			+ $" · still for {(float)player.TortoiseStill:0.0}s" );

		Log.Info( $"[nz-aug]  M1 Dig In       {(Has( player, "M1" ) ? $"x{DigDamage:0.##} damage, x{DigDefence:0.##} taken, inside the ring" : "-")}" );
		Log.Info( $"[nz-aug]  M2 Fortifier    {(Has( player, "M2" ) ? $"auto-repairs within {AutoRepairRadius:0}u" : "-")}" );
		Log.Info( $"[nz-aug]  M3 Turtle Shell {(Has( player, "M3" ) ? $"back hits x{BackReduction:0.##} ({(1f - BackReduction) * 100f:0}% off)" : $"- (base perk is {(1f - PerkEffects.TortoiseReduction) * 100f:0}% off)")}" );
		Log.Info( $"[nz-aug]  M4 Rallying     {(Has( player, "M4" ) ? $"+{RallyPerKill * 100f:0.#}%/kill to x{RallyMax:0.##} ({MaxStacks()} kills)" : "-")}"
			+ $"   {(ring is { Rally: true } ? $"{ring.Stacks} stack(s) = x{1f + ring.Stacks * RallyPerKill:0.##}" : "no rally ring")}" );
		Log.Info( $"[nz-aug]  m1 Shellshock   {(Has( player, "m1" ) ? $"armor bar break stuns {BreakStunSeconds:0.##}s within {BreakStunRadius:0}u" : "-")}" );
		Log.Info( $"[nz-aug]  m2 Wider Stance {(Has( player, "m2" ) ? $"rings x{RingRadiusBonus:0.##}" : "-")}" );
		Log.Info( $"[nz-aug]  m3 Handyman     {(Has( player, "m3" ) ? "repair kills the zombies tearing at that barricade" : "-")}" );
		Log.Info( $"[nz-aug]  m4 Quick Hands  {(Has( player, "m4" ) ? $"boards x{RepairSpeedup:0.##} faster (interval x{RepairIntervalScale( player ):0.##})" : "-")}" );
		Log.Info( $"[nz-aug]  m5 Entrench     {(Has( player, "m5" ) ? $"{EntrenchArmor:0.#} armor/sec inside a ring" : "-")}"
			+ $"   armor {player.Armor:0}/{NZombies.Armor.CapFor( player ):0}" );

		// ⚠️ THE RESOLVED DAMAGE, because two multipliers stacking is exactly where a player
		// stops being able to predict the number.
		Log.Info( $"[nz-aug]  right now       damage x{DamageScale( player.GameObject ):0.###}"
			+ $" · incoming x{IncomingScale( player ):0.###}" );
	}

	/// <summary>`nz_aug_tortoise` — the resolved state of all nine.</summary>
	[ConCmd( "nz_aug_tortoise" )]
	public static void ReportCmd()
		=> Report( NZPlayer.Local );

	/// <summary>
	/// `nz_tortoise_ring` — plant a ring immediately, skipping the 3s wait.
	///
	/// ⚠️ EXISTS BECAUSE FIVE AUGMENTS NEED A RING TO DO ANYTHING and standing still for three
	/// seconds before every test is three seconds of not testing. It also makes the ring's
	/// radius checkable against the placeholder disc on the floor.
	/// </summary>
	[ConCmd( "nz_tortoise_ring" )]
	public static void RingCmd()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		if ( !PlantsRings( p ) )
		{
			Log.Warning( "[nz-aug] no ring augment — nz_perk_give tortoise,"
				+ " then nz_augment tortoise M1 free" );
			return;
		}

		if ( p.TortoiseRing.IsValid() ) p.TortoiseRing.GameObject.Destroy();

		p.TortoiseRing = Plant( p );
	}

	/// <summary>`nz_tortoise_set` — retune live. Negative or omitted leaves a value alone.</summary>
	[ConCmd( "nz_tortoise_set" )]
	public static void SetCmd( float ringSeconds = -1f, float ringRadius = -1f,
		float still = -1f, float digDamage = -1f, float digDefence = -1f,
		float perKill = -1f, float rallyMax = -1f, float back = -1f,
		float stunRadius = -1f, float stunSeconds = -1f, float radiusBonus = -1f,
		float autoRepair = -1f, float speedup = -1f, float armor = -1f )
	{
		if ( ringSeconds >= 0f ) RingSeconds = ringSeconds;
		if ( ringRadius >= 0f ) RingRadius = ringRadius;
		if ( still >= 0f ) StillTolerance = still;
		if ( digDamage >= 0f ) DigDamage = digDamage;
		if ( digDefence >= 0f ) DigDefence = digDefence;
		if ( perKill >= 0f ) RallyPerKill = perKill;
		if ( rallyMax >= 0f ) RallyMax = rallyMax;
		if ( back >= 0f ) BackReduction = back;
		if ( stunRadius >= 0f ) BreakStunRadius = stunRadius;
		if ( stunSeconds >= 0f ) BreakStunSeconds = stunSeconds;
		if ( radiusBonus >= 0f ) RingRadiusBonus = radiusBonus;
		if ( autoRepair >= 0f ) AutoRepairRadius = autoRepair;
		if ( speedup >= 0f ) RepairSpeedup = speedup;
		if ( armor >= 0f ) EntrenchArmor = armor;

		ReportCmd();
	}
}