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.
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();
}
}