NZPlayer.cs

NZPlayer component for the nZombies game, the game-specific half of a player. It manages health, damage windows, points publishing, weapon spawning/equipping (SWB and legacy), inventory, many perk/augment cooldowns and runtime player state (revives, downed weapons, untargetable windows, movement/ADS speed adjustments, ammo mod persistence and serialization, and other gameplay flags).

File AccessNetworkingExternal Download
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// PLAYER — the nZombies-specific half of a player.
///
/// s&box's PlayerController handles movement, camera and animation but has NO
/// health of any kind (checked: it exposes no Health, Damage, Die or Respawn
/// members). So health is ours, via the shared Health component — the same one
/// zombies use, so the engine's bullet path reaches players too.
///
/// Add this alongside PlayerController. It creates the Health component if one
/// isn't already there, so no scene surgery is needed.
/// </summary>
// ⚠️ PARTIAL. The SWB integration — IPlayerBase, ~20 members that are mostly
// one-line forwards to PlayerController — lives in Player/NZPlayer.SWB.cs so
// that this file stays about nZombies rather than about satisfying an interface.
public sealed partial class NZPlayer : Component
{
	/// <summary>
	/// 100 in the original, and it regenerates rather than being healed —
	/// there are no health pickups in nZombies. Regen is not implemented yet;
	/// this is the pool it will refill.
	/// </summary>
	[Property] public float MaxHealth { get; set; } = 100f;

	/// <summary>
	/// ⚠️ NOT OPTIONAL — reference doc §9.4. Without a window, every zombie in
	/// contact lands its hit in the same tick and being surrounded is instant
	/// death rather than survivable. The original uses 0.5s.
	///
	/// ⚠️ THIS IS THE ROUND-1 VALUE. It shrinks along ZombieStats.VictimImmunityScale — see
	/// ApplyVictimImmunity. It is also the game's real DAMAGE CEILING and the reason raising
	/// zombie attack speed on its own does nothing past a point: the window swallows every hit
	/// that lands inside it no matter WHICH zombie threw it, so the whole horde together can
	/// never land more than `1 / VictimImmunity` hits a second.
	/// </summary>
	[Property] public float VictimImmunity { get; set; } = 0.5f;

	public Health Hp { get; private set; }
	public bool IsDown { get; private set; }

	/// <summary>Points. The whole economy runs on these — doors, perks, the
	/// box. Zombies award them on death; nothing spends them yet.</summary>
	public int Points
	{
		get => _points;
		private set
		{
			if ( _points == value ) return;
			_points = value;

			// ⚠️ EVERY CHANGE, FROM THE ONE PLACE THE NUMBER MOVES. Awards, spends, the creative
			// override and the starting grant all land here, so the scoreboard cannot go stale
			// because a new way of changing points forgot to announce itself.
			//
			// ⚠️ ONLY FOR MY OWN BODY. Every machine holds a copy of every player, and a copy
			// publishing its owner's total would let two machines argue about one number.
			if ( Networking.IsActive && Connection.Local is not null
				&& PlayerPresence.Mine( GameObject ) )
				NZNet.PointsAre( Connection.Local.Id, _points );
		}
	}

	int _points;

	protected override void OnStart()
	{
		// ⚠️ The player ships with NO TAGS AT ALL — nz_collide printed
		// "tags: (none)". Collision filtering in s&box is tag-pair based, so
		// without this there is no name for a matrix rule to reference and the
		// "ragdoll ignores player" row cannot be written. Tagging here rather
		// than in the scene keeps it with the rest of the player setup and
		// survives the prefab being rebuilt.
		if ( !GameObject.Tags.Has( "player" ) )
			GameObject.Tags.Add( "player" );

		ApplyConfig();

		Hp = Components.GetOrCreate<Health>();
		ApplyVictimImmunity();
		// ⚠️ THE MATCH'S MAX HEALTH HERE AND IN EVERY RESET BELOW (the lobby's Difficulty, 2026-10-05): the gamemode's own unless the
		// host changed it
		Hp.Reset( Difficulty.MaxHealth );

		Hp.OnDamaged = ( amount, headshot ) => OnHurt( amount );
		Hp.OnKilled = _ => GoDown();

		// ⚠️ AFTER the Health handler above. HealthRegen chains onto OnDamaged
		// to learn when it was last hit, so it has to see the handler that is
		// already there — created first, it would be overwritten.
		Components.GetOrCreate<HealthRegen>();
		Components.GetOrCreate<Stamina>();

		// ⚠️ ON EVERY COPY, NOT JUST THE LOCAL ONE. The guard decides for itself whether this
		// machine owns the body; creating it only for the local player would mean a client's own
		// body had one and the host's copy of that same player did not.
		Components.GetOrCreate<ShoveGuard>();
		Components.GetOrCreate<LooseChange>();

		// ⚠️ Created in every mode, not just creative. It gates ITSELF on the
		// mode every frame, and it is the thing that has to notice creative
		// ENDING while flying — a component only created in creative could not
		// be there to put gravity back on the way out.
		Components.GetOrCreate<Noclip>();

		// ⚠️ Created here like the rest, so V works without scene surgery — and in
		// EVERY mode, because the knife is the one thing a DOWNED player still has
		// and that is exactly when nothing else is running.
		Components.GetOrCreate<Knife>();

		// ⛔ CREATED AT SPAWN, AND THE COUNT SET HERE. `Count` defaults to 2 on the
		// component, but the component was only ever created lazily by the first G
		// press or an `nz_nade_*` command — so a fresh player HAD no grenades and the
		// HUD had nothing to read, which is indistinguishable from "grenades are
		// broken".
		//
		// ⚠️ SET, not added, for the same reason `Points` is a line below: OnStart
		// re-runs on respawn and on hotload, and topping up each time would make
		// dying a resupply.
		Components.GetOrCreate<Grenade>().Count = 2;

		// ⚠️ Set, not added. A respawn or a hotload re-runs OnStart, and AddPoints
		// would hand out another 500 each time.
		// ⚠️ THE MATCH'S STARTING POINTS (the lobby's Difficulty, 2026-10-05); a body made before it arrives is caught up by
		// `Difficulty.Refresh`
		Points = Difficulty.StartingPoints;

		// ⚠️ BEFORE THE EQUIP, and before any pickup can overwrite StartingWeapon. See CaptureLoadout.
		CaptureLoadout();

		EquipStartingWeapon();

		// A player that appears while Survival is already running places itself.
		// RoundManager.StartGame handles everyone present at the start; this
		// covers the rest — including a play restart, since NZGame.Mode is
		// static and comes back still set to Survival.
		if ( NZGame.IsSurvival )
			PlayerSpawner.PlaceAll();

		// ⚠️ THE SAME HOLE ON THE CREATIVE SIDE. NZGame.SetMode shows the config
		// on the way into Creative, but it early-returns when the mode is
		// unchanged — and Mode is static, so a play restart comes back already
		// Creative and that hook never fires. Without this, restarting play on a
		// map you were building shows an empty one.
		if ( NZGame.IsCreative )
			NZGame.ShowConfig();
	}

	/// <summary>
	/// Slow the player while they are aiming.
	///
	/// ⛔ APPLIED EVERY FRAME FROM THE CONFIG VALUE, never by scaling the current
	/// speed. Multiplying the live WalkSpeed compounds — two frames of aiming and
	/// you are at a quarter speed, then a sixteenth — and it never comes back
	/// because the original value has been overwritten.
	/// </summary>
	/// <summary>
	/// The controller's own crouch speed, captured before we ever touch it.
	///
	/// ⚠️ Needed because DuckedSpeed is not in ActiveConfig — walk and sprint are,
	/// crouch is not — so there is nothing to recompute it from on the way back
	/// up. Read once, restored on revive.
	/// </summary>
	float _baseDuckedSpeed = -1f;

	/// <summary>
	/// Write the round's immunity window onto this player's Health.
	///
	/// ⚠️ PUSHED ON A ROUND CHANGE, NOT READ EVERY FRAME. `Health` has no business knowing what
	/// round it is, and the value only changes 55 times in a run — a per-frame recompute would
	/// pay for that on every player on every tick to catch an event that fires once a round.
	///
	/// ⚠️ AN ACTIVE WINDOW IS NOT RETROACTIVE and that is correct. `_immuneUntil` is stamped from
	/// this value at the moment of the hit, so a player mid-window when the round ticks over
	/// keeps the window they were given; the next hit gets the new one.
	/// </summary>
	public void ApplyVictimImmunity()
	{
		if ( !Hp.IsValid() ) return;
		int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		Hp.ImmunityAfterHit = VictimImmunity * ZombieStats.VictimImmunityScale( round );
	}

	/// <summary>
	/// Re-window every player in the scene for the round that just began.
	///
	/// ⛔ EVERY PLAYER, NOT THE LOCAL ONE. Damage is applied host-side against whichever Health
	/// belongs to the victim, so a proxy body carrying a stale 0.5s window would quietly make
	/// that player tougher than the host — the exact class of single-player-shaped bug this
	/// project keeps out by never reaching for a first player.
	/// </summary>
	public static void OnRoundStart()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;
		foreach ( var p in scene.GetAllComponents<NZPlayer>() )
			p.ApplyVictimImmunity();
	}

	void TickAdsSpeed()
	{
		var c = Components.Get<PlayerController>();
		if ( !c.IsValid() ) return;

		// ⚠️ Captured on the FIRST tick, before anything below writes it. Reading
		// it later would capture our own crawl value and make the restore a no-op
		// — the player would stay slow after being revived.
		if ( _baseDuckedSpeed < 0f )
			_baseDuckedSpeed = c.DuckedSpeed;

		var weapon = Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.FirstOrDefault( w => w.IsValid() && w.GameObject.Enabled );

		var aiming = weapon.IsValid() && weapon.IsAiming;

		// ⚠️ FEATHERWEIGHT MOVES THE PENALTY TOWARD 1, it does not scale your speed.
		// AdsSpeedMultiplier is how much walk you KEEP while aiming, so the node
		// multiplies that fraction up: 0.5 x 1.25 = 0.625 of walk speed instead of 0.5.
		// ⛔ THE CLAMP IS THE CEILING AND STAYS. A big enough factor would otherwise
		// push the fraction past 1 and make aiming FASTER than walking free.
		// ⚠️ Read off the weapon in hand, resolved above — tech is per prefab, so the
		// answer differs between your two slots and there is no player-wide value.
		// ⛔ STEADY AIM IS A FLOOR, NOT A FACTOR, AND THE ORDER IS `Max` AFTER THE CLAMP.
		// `t1_strafe` already multiplies this value under the 0.05–1.0 clamp; a second
		// multiplier would mean a player owning both pegs at the ceiling with no way to
		// tell which term got them there. A floor composes instead of competing — whichever
		// gives more ADS speed wins, and neither can push past 1.
		//
		// ⚠️ `AdsSpeedFloor` returns 0 when the augment is absent, so the Max is
		// unconditional. Returning 1 there would have switched the aiming penalty off for
		// every player in the game.
		var mult = aiming
			? MathF.Max(
				MathX.Clamp( AdsMoveFor( weapon ) * TechEffects.Factor( weapon, "t1_strafe" ), 0.05f, 1f ),
				StaminUpAugments.AdsSpeedFloor( this ) )
			: 1f;

		// ⛔ ADRENALINE ROUNDS REMOVE THE AIMING PENALTY OUTRIGHT, which is why this is an
		// assignment and not another term in the `Max` above. `AdsSpeedMultiplier` is how much walk
		// you KEEP while aiming, so "no penalty" is the value 1 — and expressing it as a factor
		// would mean inventing a number that happens to cancel whatever the two terms above landed
		// on, for every weapon, forever.
		//
		// ⚠️ READ OFF THE WEAPON IN HAND, EVERY FRAME, like Featherweight and Drum Magazine
		// above and below it. Tech is per prefab, so putting the gun away has to restore the penalty
		// with no restore path to forget.
		// ⛔ REDEFINED 2026-10-04: NOT "NO PENALTY" ANY MORE BUT +1% OF THE AIMING WALK SPEED PER STACKED HIT, up to +50%,
		// under the same ceiling of 1 (`AdrenalineRounds.AdsScale`). The two paragraphs above describe the old node.
		if ( aiming ) mult = MathF.Min( 1f, mult * AdrenalineRounds.AdsScale( this, weapon ) );

		// ⛔ ASSAULT GRIP (LMG tier 3, 2026-10-04): AIMING THIS GUN COSTS NO WALK — the value 1, the old Adrenaline Rounds'
		// assignment above, so Featherweight, Steady Aim and Adrenaline have nothing left to lift on it. The gun's own move
		// speed (`TechMoveMultiplier`: the LMG's ×0.8, Carry Handle, Bipod) still applies, and so does the crawl below.
		if ( aiming && TechEffects.Has( weapon, "t3_lmg_assaultgrip" ) ) mult = 1f;

		// ⚠️ Crawl folded in HERE rather than written from GoDown. This method
		// already assigns WalkSpeed every frame from the config, so a one-shot
		// write in GoDown would be overwritten on the very next frame — the
		// downed player would crawl for a frame and then walk normally.
		// ⛔ The same trap the ADS penalty hit: never scale the LIVE value, always
		// recompute from the config, or the two multipliers compound each frame.
		if ( IsDown )
			mult *= MathX.Clamp( DownedSpeedMultiplier, 0.05f, 1f );

		// ⛔ BLED OUT IS NOT SLOW, IT IS STOPPED. `ApplyBledOutBody` takes the body away, and
		// without this the player would go on crawling around the map invisibly — still solid,
		// still holding the camera, and back next round wherever they had wandered to.
		//
		// ⚠️ FOLDED INTO THE SAME MULTIPLIER AS THE CRAWL, for the reason this method's own
		// header gives: it recomputes speed from the config every frame, so a one-shot write
		// anywhere else is undone on the next tick.
		if ( IsOutOfRound )
			mult = 0f;

		// ⚠️ Sprint is left alone: you cannot sprint while aiming anyway, and
		// scaling RunSpeed here would fight Stamina, which owns that value.
		// ⚠️ Perk multiplier folded into the SAME assignment, not applied after.
		// This line already runs every frame from the config value — a second
		// pass multiplying the live WalkSpeed would compound, which is the
		// compounding bug this method's own comment warns about.
		// ⚠️ SPEED COLA'S M2 TICKS FROM HERE, in the method that already runs every frame
		// for this player. A component of its own would need creating, finding and cleaning
		// up for one accumulator that already lives on the weapons.
		SpeedColaAugments.Tick( this );

		// ⚠️ TIMESLIP m2's ARSENAL LEASE TICKS HERE, beside Speed Cola's, and for the same
		// reason: this method already runs every frame for this player. The lease has to be
		// renewed continuously because "until you leave" is a distance test, not a timer.
		TimeAugments.Tick( this );

		var perkSpeed = PerkEffects.SpeedMultiplier( this );

		// ⛔ DRUM MAGAZINE'S MOVE PENALTY IS READ-TIME, UNLIKE ITS OTHER TWO HALVES.
		// WalkSpeed is a PLAYER field and tech is per PREFAB, so stamping the -15% in
		// ApplyStoredUpgrades would leave it on the player after the drum gun was
		// holstered — and with two slots that is the common case, not the edge one. Read
		// off the weapon in HAND, every frame, folded into the SAME assignment as the ADS
		// and perk factors: putting the gun away restores full speed with no restore path
		// to forget. Same shape as Featherweight above, for the same reason.
		//
		// ⛔ `Has`, NOT `Factor`. t4_drum's catalogue factor is 3 — that is the MAGAZINE
		// multiplier — so `Factor` here would TRIPLE the player's walk speed. The walk
		// number is one of the node's secondary magnitudes; see the `DrumWalk` block above
		// ApplyStoredUpgrades for why it is a constant here and not in the catalogue.
		//
		// ⚠️ NOW RESOLVED THROUGH TechMoveMultiplier, which Stamina reads for the sprint
		// half of the same node. Two files asking the same question two ways is how one of
		// them ends up with a stale answer.
		var techSpeed = TechMoveMultiplier( weapon, sprint: false );

		// ⚠️ THE RUSH IS A PLAYER FACT, SO IT IS ITS OWN TERM rather than folded into
		// `techSpeed`. That local is "what the weapon in my hands does to my speed" and is read by
		// `Stamina` for the sprint half of Emplacement; a per-player timer inside it would follow
		// the gun into the other slot and be wrong in both.
		var rush = NZombies.AdrenalineRounds.SpeedScale( this );

		// ⚠️ FROM THE MATCH'S WALK SPEED (the lobby's Difficulty, 2026-10-05), here and in the crawl below
		c.WalkSpeed = Difficulty.WalkSpeed * mult * perkSpeed * techSpeed * rush;

		// ⛔ DUCKED SPEED IS A SEPARATE VALUE, and a downed player is FORCED
		// crouched — so the controller reads this one, not WalkSpeed. Scaling only
		// WalkSpeed would have left the crawl at the full crouch speed and made
		// DownedSpeedMultiplier look like it did nothing.
		//
		// ⚠️ The drum penalty rides here too. Leaving it off would make crouch-walking
		// with a 300-round M60 the fast way to move, which inverts the node.
		c.DuckedSpeed = IsDown
			? Difficulty.WalkSpeed * mult * perkSpeed * techSpeed * rush
			: _baseDuckedSpeed * perkSpeed * techSpeed * rush;

		// ⛔ NO JUMPING WHILE DOWN. Zeroing JumpSpeed rather than swallowing the
		// input: the controller owns the jump, and intercepting the key would
		// leave the jump ANIMATION and any other consumer of the press still
		// firing. With no jump power there is nothing to animate.
		//
		// ⚠️ Recomputed from the config like the speeds above, not toggled — a
		// one-shot write on going down would be undone by the next ApplyConfig.
		// ⚠ m4 HOPS IS A TERM ON THIS LINE, not a write of its own. This value is recomputed
		// from the config every frame (see the note above), so an augment that assigned
		// `JumpSpeed` elsewhere would be overwritten on the very next tick.
		c.JumpSpeed = IsDown
			? 0f
			: ActiveConfig.Player.JumpPower * PhdAugments.JumpMultiplier( this );
	}

	/// <summary>
	/// What the weapon in hand does to the player's move speed — Drum Magazine's walk
	/// penalty and Emplacement's walk AND sprint penalties, as one number. 1 is normal.
	///
	/// ⛔ READ-TIME AND PER-WEAPON, WHICH IS WHY NEITHER HALF CAN BE A SPAWN-TIME WRITE.
	/// `WalkSpeed` and `RunSpeed` are PLAYER fields while tech is per PREFAB, so stamping
	/// either in `ApplyStoredUpgrades` would leave the penalty on the player after the gun
	/// was holstered — and with two slots that is the common case, not the edge one.
	///
	/// ⛔ IT SERVES THE SPRINT NUMBER RATHER THAN APPLYING IT, AND THAT IS NOT TIDINESS.
	/// `Stamina` owns `RunSpeed` — TickAdsSpeed says so above and Stamina says so from the
	/// other side — and its `ApplySprintBlock` stamps the value back on a 0.05s tick. A
	/// write from here would be re-applied every frame against that, with no guaranteed
	/// component order: the player sees a stuttering sprint and reports a physics bug. So
	/// `Stamina.SprintSpeed` multiplies this in where it already multiplies in
	/// `PerkEffects.SpeedMultiplier`, and gets the exhaustion case (which drops sprint to
	/// walk speed, penalty included) for free.
	///
	/// ⚠️ DRUM MAGAZINE IS WALK-ONLY, DELIBERATELY. Its own wiring left `RunSpeed` alone;
	/// extending a tier-4 node to sprint while wiring tier 5 would be a balance change
	/// smuggled in as plumbing. Emplacement authors both numbers, so it carries both.
	/// </summary>
	public float TechMoveMultiplier( bool sprint )
		=> TechMoveMultiplier( Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.FirstOrDefault( w => w.IsValid() && w.GameObject.Enabled ), sprint );

	/// <summary>
	/// The same, for a caller that has already resolved the weapon in hand.
	///
	/// ⚠️ EXISTS SO TickAdsSpeed DOES NOT PAY FOR A SECOND COMPONENT SWEEP EVERY FRAME —
	/// it has the weapon in a local already, for the ADS and Featherweight terms.
	/// </summary>
	public float TechMoveMultiplier( SWB.Base.Weapon weapon, bool sprint )
	{
		if ( !weapon.IsValid() ) return 1f;

		// ⛔ `Mag`, NEVER `Factor`. Emplacement's `Factor` is its x3 DAMAGE, so `Factor`
		// here would TRIPLE the player's speed — a node sold as "the only node that makes
		// standing still correct" turning into the fastest movement in the game.
		// ⛔ THE WEAPON'S OWN MOBILITY JOINS HERE, AND ONLY HERE: this is the one number both halves read —
		// TickAdsSpeed for walking (crouching and aiming included: aiming is walk x AdsMoveFor) and Stamina for
		// sprinting, off which a slide launches (Slide: RunSpeed x its boost). SWB always documented `Mobility`
		// as "Speed *= Mobility" and nothing applied it; the Kitbash Editor's Stats panel sets it per gun (its
		// "movement speed"), and WeaponClassRules' x0.8 for light machine guns now slows them as it says.
		//
		// ⚠️ A ZERO IS 1, NOT A STANDSTILL: an unset or broken value must not freeze the player.
		var mobility = weapon.Mobility > 0f ? weapon.Mobility : 1f;
		var mult = TechEffects.Mag( weapon, "t4_emplacement", sprint ? "sprint" : "walk" ) * mobility
			// ⚠️ THE PER-CLASS AUGMENTS' MOVE SPEED (2026-10-04), walk and sprint alike: Skeleton Stock x1.2, Light
			// Frame x1.25 (which cancels the LMG's x0.8), Bipod x0.5, Heavy Barrel x0.9…
			* TechStats.Mul( weapon, "s.move" )
			// ⚠️ AND TRIGGER GRIP'S x1.1 WHILE THE GUN IS FIRING (auto action, tier 1, 2026-10-04, `Weapon.ActionTech.cs`), with all of them.
			* weapon.TriggerGripMove()
			// ⚠️ AND THE MAGAZINE SETS' (tier 1, 2026-10-04, `Weapon.MagTech.cs`): Running Reload's x1.1 while it reloads, Light Pack's up to
			// x1.1 as its reserve runs down — with all of them.
			* weapon.MagTechMove();

		return sprint
			? mult
			: mult * (TechEffects.Has( weapon, "t4_drum" ) ? WeaponTech.MagOf( "t4_drum", "walk", 1f ) : 1f);
	}

	/// <summary>
	/// How much walk the player keeps while aiming this weapon: its own `AdsMoveSpeed` when it has one (the
	/// Kitbash Editor's Stats), else the player's `AdsSpeedMultiplier`.
	/// </summary>
	float AdsMoveFor( SWB.Base.Weapon weapon )
		=> weapon.IsValid() && weapon.AdsMoveSpeed > 0f ? weapon.AdsMoveSpeed : AdsSpeedMultiplier;

	/// <summary>
	/// Push the active config's player settings onto the controller.
	///
	/// ⚠️ Walk and sprint speed were NOT config settings in the original — it
	/// used GMod's defaults and let perks override them. They are configurable
	/// here by request, so those two values are ours, not ported ones. Health,
	/// stamina and regen numbers ARE the original's.
	/// </summary>
	public void ApplyConfig()
	{
		var s = ActiveConfig.Player;
		var c = Components.Get<PlayerController>();
		if ( !c.IsValid() ) return;

		// ⚠️ THE MATCH'S WALK AND SPRINT (the lobby's Difficulty, 2026-10-05): the gamemode's own unless the host changed them
		c.WalkSpeed = Difficulty.WalkSpeed;
		c.RunSpeed = Difficulty.SprintSpeed;
		c.JumpSpeed = s.JumpPower;

		// ⛔ NO THIRD-PERSON TOGGLE. PlayerController ships a built-in camera-mode
		// key (C by default) — nothing of ours bound it. nZombies is first person:
		// the viewmodel, the ADS solve and every weapon offset assume it, and
		// SWB is told IsFirstPerson => true regardless, so the toggle produced a
		// third-person camera with a first-person weapon still glued to the screen.
		//
		// ⚠️ Cleared here rather than in the scene so it survives a prefab rebuild.
		c.ToggleCameraModeButton = "";

		// ⚠️ STILL FORCED EVERY TICK, but now from a flag rather than a constant. The built-in key
		// stays unbound: this is a diagnostic view, entered deliberately, not something to fall into
		// mid-round by leaning on C.
		c.ThirdPerson = ThirdPerson;
	}

	/// <summary>
	/// Put the starting gun in the player's hands.
	///
	/// A BaseCombatWeapon is a BaseInventoryItem: sitting in the scene as a
	/// child object isn't enough, it has to be ADDED to an inventory and made
	/// active before input reaches it. That's why a weapon can appear correctly
	/// configured and still never fire.
	///
	/// SwitchToBest() rather than Switch(item, bool) on purpose — it does the
	/// same job here without depending on what that boolean means.
	/// </summary>
	/// <summary>
	/// Swap to any ported weapon at runtime. `nz_give galil`, `nz_give m1911`.
	///
	/// ⚠️ Exists because StartingWeapon is a scene PROPERTY — testing a new port
	/// otherwise means editing the scene and respawning for every weapon, and
	/// there are 135 of them in the pack.
	/// </summary>
	[ConCmd( "nz_give" )]
	public static void GiveWeapon( string name = "" )
	{
		var player = Game.ActiveScene?.GetAllComponents<NZPlayer>()?.FirstOrDefault( p => p.IsValid() );
		if ( player is null ) { Log.Info( "[nz_give] no player" ); return; }

		if ( string.IsNullOrWhiteSpace( name ) )
		{
			Log.Info( "[nz_give] usage: nz_give <name>  e.g. nz_give galil" );
			return;
		}

		// accept "galil", "nz_galil" or a full prefab path
		var path = name.Contains( '/' ) ? name
			: $"prefabs/weapons/{(name.StartsWith( "nz_" ) ? name : "nz_" + name)}.prefab";
		if ( !path.EndsWith( ".prefab" ) ) path += ".prefab";

		// ⛔ THE THIRD AND LAST COPY OF THE DESTROY-EVERY-WEAPON PATTERN, and the one
		// that survived longest. It unparented and destroyed every weapon before
		// re-equipping — correct while the player had one slot, and actively
		// destructive now: the inventory is never told, so the orphan stays in
		// `Items`, stays ENABLED, and renders alongside the new gun. That is the
		// "both weapons equipped at once" report.
		//
		// ⚠️ It also produced the `NullReferenceException at Weapon.OnUpdate` spam.
		// `Owner` is resolved once via `Components.GetInAncestors<IPlayerBase>()`,
		// so cutting a still-enabled weapon loose from the player leaves it updating
		// every frame with nothing above it to find.
		player.GiveWeapon( path );
		Log.Info( $"[nz_give] {path}" );
	}

	public void EquipStartingWeapon( bool force = false )
	{
		// ⚠️ NOTHING IN CREATIVE — unless something explicitly asks. You are placing
		// and configuring the map, not playing it: a gun in hand covers a third of
		// the screen and its use key competes with every placement click.
		//
		// ⛔ `force` EXISTS BECAUSE THE COMMENT HERE USED TO LIE. It said "wallbuys
		// still hand one over when you actually buy from them" — but WallBuy.TryBuy
		// calls THIS method, so buying in creative charged nothing and gave nothing.
		// Testing a wallbuy is the main reason to place one.
		if ( NZGame.IsCreative && !force ) return;

		// ⛔ THE OWNER EQUIPS ITS OWN BODY — NOT "THE HOST EQUIPS EVERYONE". This read
		// `if ( NZGame.IsClient ) return;` for most of a day, which is the right rule for a
		// finished authority model and the WRONG one for today: nothing replicates inventory, so
		// host-only did not move the decision to the host, it simply left every client with no
		// weapon at all. A gate without the mirroring it assumes takes something away and gives
		// nothing back.
		//
		// ⛔ AND IT ASKS `PlayerPresence.Mine`, NOT `IsProxy`. It read `IsProxy` directly, which
		// is a SECOND opinion on the one question "is this body mine" — and the two have already
		// disagreed once, in the direction that left a client with a body it could see, that the
		// host could see, and that it would not arm because the engine called it a proxy. One
		// predicate, one answer, one place to fix it when the answer is wrong.
		//
		// ⚠️ THIS IS CLIENT-AUTHORITATIVE INVENTORY AND THAT IS A KNOWN, TEMPORARY POSITION.
		// `SERVER_SPLIT.md` files weapons under HOST. Moving them there needs the host to own the
		// purchase AND the result to replicate; until that exists, the owner simulating its own
		// body is the only arrangement where a client has a gun.
		if ( !PlayerPresence.Mine( GameObject ) ) return;

		var inv = Components.Get<BaseInventoryComponent>();
		if ( !inv.IsValid() )
		{
			Log.Warning( "[NZPlayer] no BaseInventoryComponent — nothing can hold a weapon" );
			return;
		}

		// ⛔ SWB FIRST, THE OLD NZWeapon ONLY AS A FALLBACK — and NOT because both
		// are wanted. NZWeapon is referenced by countdown.scene, and the standing
		// rule is that a .scene is never rewritten from a script, so deleting the
		// class would break the map rather than the code. Spawning the SWB weapon
		// and leaving the old one unequipped retires it without touching the
		// scene; the leftover object can be deleted by hand in the editor.
		if ( EquipSwbWeapon() ) return;

		var weapon = Components.GetInChildren<NZWeapon>( true );
		if ( !weapon.IsValid() )
		{
			Log.Warning( "[NZPlayer] no starting weapon — neither the SWB prefab "
				+ $"('{StartingWeapon}') nor an NZWeapon under the player" );
			return;
		}

		if ( weapon.Inventory is null )
			inv.Add( weapon, -1 );

		if ( !weapon.IsActive )
			inv.SwitchToBest();

		Log.Info( $"[NZPlayer] weapon equipped (legacy NZWeapon): active={weapon.IsActive}" );
	}

	/// <summary>The prefab a player starts with when nothing else says otherwise.</summary>
	public const string DefaultLoadout = "prefabs/weapons/nz_m1911.prefab";

	/// <summary>
	/// The prefab the player starts with. ⚠️ Blank falls back to the old
	/// NZWeapon path, which is how a scene that has not been migrated still
	/// plays.
	///
	/// ⛔ THIS IS NOT THE LOADOUT ANY MORE, DESPITE THE NAME. GiveWeapon overwrites it on every
	/// pickup — see its own remark, "StartingWeapon tracks what is IN HAND, because everything else
	/// in the project still reads it to mean the current weapon". So after buying an M14 this says
	/// M14, and it keeps saying M14 through game over, because nothing resets it. Use
	/// <see cref="LoadoutWeapon"/> for "what should I be handed at the start of a game"; this one
	/// answers "what am I holding right now".
	/// </summary>
	[Property] public string StartingWeapon { get; set; } = DefaultLoadout;

	/// <summary>
	/// What this player should be handed when a game starts, captured before anything can overwrite
	/// it.
	///
	/// ⛔ THE FIX FOR "I KEPT MY WEAPON AFTER GAME OVER". RoundManager.StartGame calls
	/// EquipStartingWeapon, which equipped StartingWeapon — a field that by then held whatever the
	/// player last picked up. So dying with an M14 and readying up again started the next run with
	/// the M14, fully upgraded, for free. The two meanings had to be separated; renaming
	/// StartingWeapon instead would have meant touching every wall buy, the mystery box's duplicate
	/// check and Pack-a-Punch, all of which legitimately want "in hand".
	///
	/// ⚠️ CAPTURED AT SPAWN, NOT READ AT USE. It is snapshotted from the SCENE-CONFIGURED
	/// StartingWeapon in OnStart, before the first pickup can change it, so a scene or prefab that
	/// deliberately sets a different starting gun is still honoured.
	/// </summary>
	public string LoadoutWeapon { get; set; }

	/// <summary>
	/// Remember the configured starting weapon, once.
	///
	/// ⚠️ ONLY IF UNSET, so a hotload — which preserves instance fields but does not re-run OnStart —
	/// cannot recapture a value that has since become "in hand". That is the same bug in slower
	/// motion.
	///
	/// ⚠️ CALLED FROM OnStart AND NOWHERE ELSE. Every other site uses LoadoutWeapon-or-default rather
	/// than capturing, because OnStart is the only moment StartingWeapon is guaranteed to still mean
	/// what its name says.
	/// </summary>
	public void CaptureLoadout()
	{
		if ( !string.IsNullOrWhiteSpace( LoadoutWeapon ) ) return;

		LoadoutWeapon = string.IsNullOrWhiteSpace( StartingWeapon )
			? DefaultLoadout
			: StartingWeapon;
	}

	/// <summary>
	/// Spawn the SWB starting weapon as a child of the player.
	///
	/// ⚠️ A CHILD, and that is the whole integration. SWB's Weapon finds its owner
	/// with `Components.GetInAncestors&lt;IPlayerBase&gt;()`, so being parented to
	/// the player IS being equipped — there is no inventory call to forget.
	/// </summary>
	/// <summary>
	/// Every SWB weapon under this player that is not already being destroyed.
	///
	/// ⚠️ DISABLED ONES COUNT. A holstered weapon is disabled and is still very much carried, which
	/// is why the original search passed `true` — that part was never the problem.
	/// </summary>
	private IEnumerable<SWB.Base.Weapon> LiveWeapons()
		=> Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
			.Where( w => w.IsValid() && w.GameObject.IsValid() && !w.GameObject.IsDestroyed );

	/// <summary>
	/// Take every weapon away, now.
	///
	/// ⛔ THE INVENTORY FIRST, THEN THE STRAGGLERS. NZInventory.Remove does the careful teardown SWB
	/// needs — OnCarryStop, then disable, then destroy — and its own remarks explain why skipping
	/// that produced `NullReferenceException at Weapon.OnUpdate` spam. So anything the inventory
	/// knows about is removed through it; only objects parented to the player WITHOUT being in the
	/// inventory get destroyed directly, and those have no carry state to stop.
	///
	/// ⚠️ RETURNS A COUNT so a caller can log what it actually took, rather than asserting it worked.
	/// </summary>
	public int ClearWeapons()
	{
		var before = LiveWeapons().Count();

		Inventory?.Clear();

		// Anything still standing was never in the inventory — an orphan from an earlier run or a
		// prefab child. Destroy is deferred, which is exactly what LiveWeapons now accounts for.
		foreach ( var w in LiveWeapons().ToList() )
			w.GameObject.Destroy();

		return before;
	}

	private bool EquipSwbWeapon()
	{
		// ⛔ LoadoutWeapon, NOT StartingWeapon — this is the line the bug lived on. See LoadoutWeapon.
		//
		// ⚠️ IT DOES NOT CAPTURE HERE, DELIBERATELY. Capturing at the point of USE would snapshot
		// whatever StartingWeapon happens to hold at that moment — and by the time a game is being
		// started that is the last weapon picked up, which is the exact bug. OnStart is the only
		// place StartingWeapon is guaranteed to still be the configured value, so it is the only
		// place that captures; anywhere else falls back to the default instead of guessing.
		var loadout = string.IsNullOrWhiteSpace( LoadoutWeapon ) ? DefaultLoadout : LoadoutWeapon;
		if ( string.IsNullOrWhiteSpace( loadout ) ) return false;

		// ⛔ WITHOUT THIS, EVERY SWB WEAPON THROWS ONCE PER FRAME AND CANNOT FIRE.
		// `Weapon.OnUpdate` reads `WeaponSettings.Instance` on its third line, and
		// WeaponSettings is a COMPONENT that assigns the singleton in OnAwake — so
		// unless something puts one in the scene it is null, the update throws a
		// NullReferenceException, and **everything below that line never runs**:
		// attack input, reload input, aiming. The gun renders perfectly and does
		// nothing, which reads as "the weapon is broken" rather than "one settings
		// object is missing".
		//
		// ⚠️ Created here rather than saved into the scene, same as every other
		// manager in this project — a scene is never edited from a script.
		EnsureWeaponSettings();

		// Already holding one — a respawn or a hotload re-runs OnStart, and
		// without this the player accumulates a pistol per restart.
		// ⛔ IsDestroyed IS THE WHOLE FIX HERE. This was `GetInChildren<Weapon>( true ).IsValid()`,
		// which searches DISABLED children too — and GameObject.Destroy is DEFERRED to the end of the
		// frame. So immediately after clearing the inventory the doomed weapon objects are still
		// parented, still found, still "valid": the guard concluded a weapon was already equipped,
		// returned true, and handed over nothing. The old guns then vanished at end of frame and the
		// player started the round empty-handed.
		//
		// ⚠️ THE GUARD ITSELF IS STILL RIGHT — a player who genuinely has a weapon should not be
		// handed a second one. It was only ever wrong about what "has" means during the frame a
		// destroy is pending.
		if ( LiveWeapons().Any() ) return true;

		// ⚠️ GiveWeapon sets StartingWeapon to what it just handed over, so the in-hand tracker ends up
		// correct for free — the wall buys and Pack-a-Punch keep reading the right thing.
		return GiveWeapon( loadout, makeActive: true ) is not null;
	}

	/// <summary>
	/// The player's two weapon slots.
	///
	/// ⚠️ Created on demand rather than authored into the scene, like every manager
	/// in this project — a `.scene` is never edited from a script.
	/// </summary>
	/// <summary>
	/// Hits remaining before Napalm Nectar can ignite again.
	///
	/// ⚠ ON THE PLAYER, not on the zombie. The old design counted hits per victim, which meant
	/// spraying a crowd built a separate counter for each and lit none of them.
	/// </summary>
	public int NapalmCooldown { get; set; }

	// ══ Ammo mods ══════════════════════════════════════════════════════════════

	/// <summary>
	/// Which ammo mod sits on which weapon, keyed by PREFAB PATH.
	///
	/// ⚠️ KEYED BY PREFAB, NOT BY WEAPON OBJECT, matching `PapLevels` and
	/// `RarityTiers` directly above. It is not just consistency: a mod has to survive the
	/// weapon being destroyed and re-given, which Quick Revive's down-swap and Mule Kick's
	/// Insurance both do. Upstream stores it on the weapon and loses it on both.
	///
	/// ⚠️ LAZY, because a field added to a component already live in a scene
	/// arrives null after a hotload (§1).
	/// </summary>
	Dictionary<string, string> _ammoModIds;
	public Dictionary<string, string> AmmoModIds => _ammoModIds ??= new();

	/// <summary>
	/// Per-weapon proc cooldown, keyed the same way.
	///
	/// ⚠️ PER WEAPON, NOT PER PLAYER. Two guns carrying two mods must proc
	/// independently or Mule Kick's third slot is worth less than it looks.
	/// </summary>
	Dictionary<string, TimeUntil> _ammoModReady;
	public Dictionary<string, TimeUntil> AmmoModReady => _ammoModReady ??= new();

	/// <summary>
	/// The last mod this player rolled, so the machine never hands out the same one twice.
	///
	/// ⚠️ ON THE PLAYER, NOT THE WEAPON, which is upstream's choice and the right
	/// one: it stops two different guns rolling the same mod back to back, which is what
	/// makes a paid gamble feel rigged.
	/// </summary>
	public string LastAmmoModId { get; set; }

	/// <summary>
	/// Each ammo mod's upgrade level, 1-5 (IV and V since 2026-10-06), keyed by MOD ID; a mod at level 0 has no entry (2026-10-05,
	/// `AmmoModUpgrades`).
	///
	/// ⛔ KEYED BY MOD, NOT BY PREFAB like the slots above. The user: *"each upgrade costing more and being permanent to that
	/// ammo mod, so i can equip on any weapon"*. `AmmoModIds` says which mod a gun carries; this says how far that mod is
	/// upgraded, on whichever gun it goes.
	///
	/// ⚠️ PRIVATE, AND READ THROUGH <see cref="AmmoModLevel"/> ONLY. The owner's machine writes it (`Arsenal.BuyAmmoUpgrade`
	/// runs on the buyer's), so on the host it is EMPTY for a client: `AmmoUpgradeNet` carries the levels there.
	///
	/// ⚠️ LAZY, like `AmmoModIds` (§1).
	/// </summary>
	Dictionary<string, int> _ammoModLevels;
	Dictionary<string, int> AmmoModLevels => _ammoModLevels ??= new();

	/// <summary>
	/// THE SAME LEVELS ON THE WIRE, so the host can read a client's: `deadwire:2;fireworks:1`.
	///
	/// ⚠️ A STRING, FOR THE REASON `TechNet` GIVES: `[Sync]` carries strings, not dictionaries. The two separators are safe by
	/// construction: a mod id is lowercase letters (`AmmoMods.All`) and a level one digit, 5 at most since IV and V (2026-10-06).
	///
	/// ⚠️ SENT ON CHANGE, which is a purchase: five per mod at most in a whole game.
	/// </summary>
	[Sync] public string AmmoUpgradeNet { get; set; } = "";

	/// <summary>`AmmoUpgradeNet`, decoded, and the string it was decoded from. Rebuilt only when the string changes.</summary>
	string _ammoUpgradeSeen;
	Dictionary<string, int> _ammoUpgradeWire;

	/// <summary>
	/// The levels to ANSWER FROM: this machine's own when it holds any, the synced copy otherwise.
	///
	/// ⚠️ `TechStore`'s RULE, TRUE FOR THE SAME REASON: only the owner ever writes the local store, so a proxy's is always
	/// empty, and on the owner the wire is an echo of the local one.
	/// </summary>
	Dictionary<string, int> AmmoUpgradeStore
	{
		get
		{
			if ( AmmoModLevels.Count > 0 ) return AmmoModLevels;

			var wire = _ammoUpgradeWire ??= new();
			var net = AmmoUpgradeNet ?? "";

			if ( net != _ammoUpgradeSeen )
			{
				_ammoUpgradeSeen = net;
				wire.Clear();

				// ⚠️ A MALFORMED ENTRY IS DROPPED, NOT HALF-READ, as `DecodeTech` does.
				foreach ( var entry in net.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
				{
					var colon = entry.IndexOf( ':' );
					if ( colon > 0 && int.TryParse( entry[(colon + 1)..], out var level ) && level > 0 )
						wire[entry[..colon]] = level;
				}
			}

			return wire;
		}
	}

	/// <summary>A mod's upgrade level, 0 to `AmmoModUpgrades.MaxLevel` (5), on any machine. `AmmoModUpgrades.Level` is the read the effects use.</summary>
	public int AmmoModLevel( string modId )
		=> !string.IsNullOrEmpty( modId ) && AmmoUpgradeStore.TryGetValue( modId, out var level )
			? Math.Clamp( level, 0, AmmoModUpgrades.MaxLevel )
			: 0;

	/// <summary>Set a mod's level, 0 to `AmmoModUpgrades.MaxLevel` (5), and publish it. The Arsenal's purchase and `nz_ammomod_level` both come through here.</summary>
	public void SetAmmoModLevel( string modId, int level )
	{
		if ( string.IsNullOrEmpty( modId ) ) return;

		level = Math.Clamp( level, 0, AmmoModUpgrades.MaxLevel );

		if ( level == 0 ) AmmoModLevels.Remove( modId );
		else AmmoModLevels[modId] = level;

		PublishAmmoUpgrades();
	}

	/// <summary>Every mod back to level 0. For a new game (`RoundManager.ResetPlayerForRun`).</summary>
	public void ClearAmmoModLevels()
	{
		AmmoModLevels.Clear();
		PublishAmmoUpgrades();
	}

	/// <summary>
	/// The levels onto the wire. The owner only, for `PublishTech`'s reason: the new-run reset also runs on the host for every
	/// player, and a host-side write to a client's `[Sync]` would blank the host's copy of that client's levels until the
	/// next update arrived.
	/// </summary>
	void PublishAmmoUpgrades()
	{
		if ( !PlayerPresence.Mine( GameObject ) ) return;

		AmmoUpgradeNet = string.Join( ";", AmmoModLevels
			.Where( kv => kv.Value > 0 )
			.Select( kv => $"{kv.Key}:{kv.Value}" ) );
	}

	// ══ Death Perception ════════════════════════════════════════════════════════

	/// <summary>
	/// Cooldown on M3 Blind Spot.
	///
	/// ⚠️ THE WINDOW ITSELF LIVES IN `UntargetableUntil`, not here. That field is
	/// shared with Vulture Aid's gas and Timeslip's m2 because all three mean the same
	/// thing to a zombie; only the COOLDOWN is Death Perception's own.
	/// </summary>
	public TimeUntil DeathIgnoreReady { get; set; }

	// ══ Quick Revive ══════════════════════════════════════════════════════

	/// <summary>
	/// Self-revives spent this game. Base allows 3, M1 Phoenix allows 5.
	///
	/// ⛔ SELF-REVIVE WAS UNLIMITED BEFORE THIS EXISTED. `CanSelfRevive` correctly gated on being
	/// solo, but nothing counted uses — a solo player could pick themselves up forever, which is
	/// most of the difficulty gone.
	/// </summary>
	public int SelfRevivesUsed { get; set; }

	/// <summary>Who this player is currently picking up, or null.</summary>
	public NZPlayer RevivingWho { get; set; }

	/// <summary>The last player this one finished picking up. `ReviveAugments.TargetFor` leaves them
	/// alone for a moment, until their own machine's stand-up arrives here.</summary>
	public NZPlayer JustRevived { get; set; }

	/// <summary>How long ago <see cref="JustRevived"/> was picked up.</summary>
	public TimeSince SinceJustRevived { get; set; }

	/// <summary>
	/// Seconds of revive held on the current target.
	///
	/// ⚠ ON THE RESCUER, not the patient, so two rescuers race rather than share. Progress on the
	/// patient would let two players each do half and finish in half the time.
	/// </summary>
	public float ReviveProgress { get; set; }

	/// <summary>
	/// How long the revive being performed on ME is meant to take. 0 = nobody is picking me up.
	///
	/// ⛔ THE PATIENT CANNOT SEE `ReviveProgress`, EVER — IT IS ON THE RESCUER, AND IN CO-OP THAT
	/// IS ANOTHER COMPUTER. So a downed player watched a bleedout bar drain with no way to know
	/// help had arrived, which is the one thing they most need to be told: whether to hold on or
	/// spend the perk.
	///
	/// ⚠️ TWO MESSAGES PER REVIVE, NOT A STREAM. The rescuer says "starting, it takes N seconds"
	/// and "stopped"; the clock runs locally from there. Replicating the progress itself would be a
	/// float every frame per rescuer for a bar nobody measures against a stopwatch.
	///
	/// ⚠️ AND IT EXPIRES BY ITSELF. A "stopped" that never arrives — the rescuer disconnecting
	/// mid-revive — would otherwise leave a bar frozen at 90% for the rest of the bleedout, which
	/// reads as help that is coming and is not.
	/// </summary>
	public float BeingRevivedSeconds { get; set; }

	/// <summary>When the current revive on me started. Meaningless while the above is 0.</summary>
	public TimeSince BeingRevivedSince { get; set; }

	/// <summary>Is somebody picking me up right now?</summary>
	public bool BeingRevived => BeingRevivedSeconds > 0f
		&& BeingRevivedSince < BeingRevivedSeconds + StaleReviveGrace;

	/// <summary>0..1 of the revive being performed on me.</summary>
	public float BeingRevivedFraction => BeingRevived
		? (BeingRevivedSince / BeingRevivedSeconds).Clamp( 0f, 1f )
		: 0f;

	/// <summary>
	/// How long past its own length a revive announcement stays believed.
	///
	/// ⚠️ NOT ZERO. The completion arrives as its own message and network jitter can put it a
	/// moment after the local clock finishes; expiring exactly on time would blink the bar out just
	/// before the player stands up.
	/// </summary>
	public const float StaleReviveGrace = 1.5f;

	/// <summary>Somebody started, or stopped, picking me up. <paramref name="seconds"/> 0 = stopped.</summary>
	public void BeingRevivedBy( float seconds )
	{
		BeingRevivedSeconds = MathF.Max( 0f, seconds );
		BeingRevivedSince = 0f;
	}

	/// <summary>
	/// The weapons held for this player while they are down, as PREFAB PATHS.
	///
	/// ⚠ PATHS, NOT OBJECTS. `StripWeapons` destroys the weapon GameObjects, and every upgrade is
	/// stored against the prefab path anyway — so re-giving the path restores the Pack-a-Punch
	/// tier, rarity and tech with it.
	///
	/// ⚠ A LAZY PROPERTY: a field added to a component that already exists in a running scene
	/// arrives null after a hotload (§1).
	/// </summary>
	List<string> _downedWeapons;
	public List<string> DownedWeapons => _downedWeapons ??= new();

	/// <summary>m3 Field Medic's speed boost window.</summary>
	public TimeUntil MedicSpeedUntil { get; set; }

	/// <summary>Cooldown on m5 Phase Shift.</summary>
	public TimeUntil PhaseShiftReady { get; set; }

	/// <summary>
	/// Cooldown on Elemental Pop's M1 Elemental Surge.
	///
	/// ⛔ ONE TIMER FOR THE PLAYER, NOT ONE PER WEAPON, AND THAT IS THE DIFFERENCE FROM
	/// `AmmoModReady`. That dictionary is keyed by prefab because a mod belongs to a gun; M1 belongs
	/// to the PERK, so switching weapons must not hand you a fresh surge. A `Dictionary` here would
	/// have made two-weapon builds proc it twice as often.
	/// </summary>
	public TimeUntil PopSurgeReady { get; set; }

	/// <summary>
	/// Banana Colada's placement charge, 0-1. Full means one placeable is ready.
	///
	/// ⛔ ONE METER, NOT ONE PER KIND, because normal play equips exactly one major and therefore
	/// has exactly one thing to place. `BananaAugments.KindFor` writes down what happens in Creative,
	/// where more than one major can be held.
	///
	/// ⚠️ A FRACTION RATHER THAN A COUNT, so every minor that touches it is a plain multiplier and
	/// the HUD can draw it as a bar without being told a scale.
	/// </summary>
	public float PlaceCharge { get; set; }

	// ══ Victorious Tortoise ═══════════════════════════════════════════════

	/// <summary>The ring this player has planted, or null. Destroyed when they leave it.</summary>
	public TortoiseRing TortoiseRing { get; set; }

	/// <summary>How long this player has been standing still, for planting a ring.</summary>
	public TimeSince TortoiseStill { get; set; }

	/// <summary>
	/// Where they were when the stillness timer started.
	///
	/// ⚠ A REMEMBERED POSITION, NOT A VELOCITY TEST. Velocity reads zero for a frame mid-stride,
	/// which would let a sprinting player plant a ring; a position that has not moved cannot.
	/// </summary>
	public Vector3 TortoiseStillAt { get; set; }

	// ══ Timeslip Tonic ══════════════════════════════════════════════════════

	/// <summary>Cooldown on Timeslip M3 Fault Lines.</summary>
	public TimeUntil TimePitReady { get; set; }

	/// <summary>Cooldown on Timeslip m2 Time Out.</summary>
	public TimeUntil TimeOutReady { get; set; }

	/// <summary>
	/// While this is running, the horde cannot see this player. Timeslip m2 writes it.
	///
	/// ⚠ SEPARATE FROM VULTURE AID'S GAS, WHICH IS POSITIONAL. The gas is "am I standing in
	/// it" and is re-evaluated every frame; this is a duration you were granted and carry with
	/// you. Folding them into one field would mean walking out of a cloud cancelling a Time Out.
	/// <see cref="IsUntargetable"/> is where the two meet.
	/// </summary>
	public TimeUntil UntargetableUntil { get; set; }

	/// <summary>
	/// Timeslip m2 Time Out, arsenal variant — the window is held open while the player stays
	/// at the machine instead of running for a fixed 15s.
	///
	/// ⚠️ A FLAG, NOT A WINDOW. The window itself is still `UntargetableUntil`, which is the
	/// one thing `IsUntargetable` reads; this only says "keep renewing it".
	/// `TimeAugments.Tick` clears it on walking away, or on losing the augment.
	/// </summary>
	public bool ArsenalTimeOut { get; set; }

	/// <summary>
	/// Can the horde see this player at all.
	///
	/// ⛔ ONE TEST, TWO CAUSES, AND THAT IS THE POINT. `ZombieAI.GetTargetables` asks this and
	/// nothing else; Vulture Aid's gas and Timeslip's Time Out both feed it. A second bespoke
	/// filter in the AI for the second cause is the §3 shape — and the copy that gets missed is
	/// whichever one was added later.
	///
	/// ⚠ The falling-edge retarget push in `TickTargetability` also works for both without
	/// knowing which cause ended, because it watches THIS.
	/// </summary>
	/// <remarks>
	/// ⚠️ THREE CAUSES SINCE 2026-10-05, AND TWO PLACES THEY ARE KNOWN. The Arsenal's and the Wunderfizz's menus joined the gas and
	/// the windows (<see cref="AtMachine"/>). What this machine knows is <see cref="HiddenHere"/>; what the body's owner knows
	/// comes across as <see cref="HiddenNet"/>, which is how the host's zombies hear of a client's menu or window at all.
	/// </remarks>
	public bool IsUntargetable
		=> HiddenHere || HiddenNet;

	/// <summary>
	/// Hidden by what THIS machine knows: Vulture Aid's gas, a window (Timeslip m2, Death Perception M3), or a menu open at the
	/// Arsenal or the Wunderfizz for this machine's own player. What the owner publishes as <see cref="HiddenNet"/>.
	/// </summary>
	public bool HiddenHere
		=> VultureStink.IsInGas( this ) || UntargetableUntil > 0f || AtMachine;

	/// <summary>
	/// AT THE ARSENAL OR THE WUNDERFIZZ: its menu is open on this machine, for this machine's player (2026-10-05). The user:
	/// *"make it so any player that's in the arsenal or in the wunderfizz do not get targeted by zombies"*, knowing what it does
	/// to Timeslip m2 Time Out at those two machines: *"yes i know what this does to one of the minor augments on time slip,
	/// thats ok"*.
	///
	/// ⚠️ MY BODY ONLY. The two menus are this machine's (`ArsenalMenu.Current`, `WunderfizzMenu.Current`), so another body is
	/// never at one here; other machines hear it through <see cref="HiddenNet"/>. ESC or walking away (1.5× the use range)
	/// closes a menu, so this ends when the player leaves the counter.
	/// </summary>
	public bool AtMachine => (ArsenalMenu.IsOpen || WunderfizzMenu.IsOpen) && PlayerPresence.Mine( GameObject );

	// ══ PhD Flopper ═══════════════════════════════════════════════════════════
	//
	// ⚠ ON `NZPlayer`, NOT ON STATICS IN `PhdAugments`. SERVER_ROADMAP §4 rule 2: "if it
	// holds something a player owns, it belongs on NZPlayer". A static fall-peak would be one
	// number shared by every player in a co-op game, which is the trap listed against
	// `WunderfizzMenu.Current`.
	//
	// ⚠ PLAIN AUTO-PROPERTIES, not the nullable-getter shape the tuning values use. These are
	// per-player RUNTIME state, not defaults — migrating a fall peak across a hotload is
	// harmless and re-deriving it from code would be meaningless.

	/// <summary>Highest z reached during the current airborne period. PhD's fall blast.</summary>
	public float PhdFallPeak { get; set; }

	/// <summary>Was this player off the ground last frame.</summary>
	public bool PhdAirborne { get; set; }

	/// <summary>Is a PhD m1 Ground Slam in progress — detonates on impact regardless of height.</summary>
	public bool PhdSlamming { get; set; }

	/// <summary>Mid-air jumps already spent this airborne period. PhD m5.</summary>
	public int PhdJumps { get; set; }

	/// <summary>Cooldown on PhD M3 Kinetic Burst.</summary>
	public TimeUntil PhdSprintReady { get; set; }

	/// <summary>Cooldown on PhD M4 Reactive Blast.</summary>
	public TimeUntil PhdReactiveReady { get; set; }

	public NZInventory Inventory
	{
		get
		{
			var inv = Components.Get<NZInventory>();
			if ( !inv.IsValid() ) inv = Components.Create<NZInventory>();
			return inv;
		}
	}

	/// <summary>
	/// Spawn a weapon prefab into the inventory.
	///
	/// ⛔ REPLACES THE ACTIVE WEAPON WHEN FULL, rather than refusing. That is the
	/// zombies convention and the only one a player can predict: what you are
	/// holding is what the wall buy or the box takes.
	///
	/// ⚠️ Returns the spawned weapon so callers can act on it; null means the prefab
	/// was missing, which is worth telling them apart from "you already had it".
	/// </summary>
	/// <summary>
	/// Make sure SWB's settings singleton exists.
	///
	/// ⛔ WITHOUT IT EVERY SWB WEAPON THROWS ONCE PER FRAME AND CANNOT FIRE.
	/// `Weapon.OnUpdate` reads `WeaponSettings.Instance` near the top, and the
	/// singleton is assigned by a COMPONENT's OnAwake — so with none in the scene it
	/// is null, the update throws, and everything below that line never runs: attack
	/// input, reload, aiming. The gun renders perfectly and does nothing.
	///
	/// ⛔ CALLED FROM GiveWeapon, NOT JUST EquipStartingWeapon. It used to live in
	/// `EquipSwbWeapon` alone, which was every spawn path when there was one slot.
	/// It is now one of five — the wall buy, the box, Pack-a-Punch and `nz_give` all
	/// spawn weapons directly — and any of them arriving first left the singleton
	/// missing. That is the "both weapons equipped and I can't shoot" report: the
	/// second half was this, throwing on every frame in `OnAimAssistUpdate`.
	/// </summary>
	void EnsureWeaponSettings()
	{
		if ( SWB.Base.WeaponSettings.Instance.IsValid() ) return;

		var settings = Scene.CreateObject();
		settings.Name = "SWB Weapon Settings";
		settings.Flags |= GameObjectFlags.NotSaved;
		settings.Components.Create<SWB.Base.WeaponSettings>();
		Log.Info( "[NZPlayer] created SWB WeaponSettings (none in scene)" );
	}

	public SWB.Base.Weapon GiveWeapon( string prefabPath, bool makeActive = true )
	{
		// ⛔ ONE PLACE FOR EVERY ROUTE — wall buy, mystery box, Arsenal, dev commands. Hanging this
		// off each buyable would be four call sites that drift, and the box already has two of its
		// own paths.
		//
		// ⚠️ IT ALSO FIRES FOR THE STARTING PISTOL AND FOR DEV GRANTS. Harmless at 35% with a 5s
		// cooldown, and cheaper than teaching this method who its caller was.
		CharacterVoice.Say( "pickup", this );

		EnsureWeaponSettings();

		var prefab = ResourceLibrary.Get<PrefabFile>( prefabPath );
		if ( prefab is null )
		{
			Log.Warning( $"[NZPlayer] weapon prefab not found: {prefabPath}" );
			return null;
		}

		var wep = SpawnWeapon( prefab, prefabPath );
		if ( !wep.IsValid() ) return null;

		var inv = Inventory;
		if ( makeActive ) inv.GiveOrReplace( wep.GameObject );
		else inv.Add( wep.GameObject );

		// ⚠️ StartingWeapon tracks what is IN HAND, because everything else in the
		// project still reads it to mean "the current weapon" — the wall buys, the
		// box's duplicate check, Pack-a-Punch. Keeping it in step is what lets two
		// slots land without rewriting all of them at once.
		if ( makeActive ) StartingWeapon = prefabPath;

		return wep;
	}

	SWB.Base.Weapon SpawnWeapon( PrefabFile prefab, string prefabPath )
	{

		// ⛔ PARENTED AT CREATION, AND IT HAS TO BE. Cloning detached was tried — to give the gun
		// a moment to be marked un-networked before it joined a networked body — and it destroyed
		// every weapon in the game:
		//
		//     nz_m1911 cannot find owner, destroying!
		//     'prefabs/weapons/nz_m1911.prefab' has no SWB Weapon component
		//     no starting weapon — neither the SWB prefab nor an NZWeapon under the player
		//
		// `Weapon.OnAwake` resolves `Owner = Components.GetInAncestors<IPlayerBase>()` the instant
		// the object exists. With no parent there are no ancestors, SWB finds no owner and destroys
		// itself. **The parent is an input to the clone, not something to attach afterwards.**
		//
		// ⚠️ WHICH MEANS "DO NOT NETWORK THIS" CANNOT COME FROM A LINE AFTER THE CLONE. By the
		// time any code here runs the object already exists under a networked body. It has to come
		// from the PREFAB — see the `NetworkMode` on each weapon prefab's root object.
		//
		// ⚠️ WHAT THE STAKES ARE: `Weapon.CreateViewModelHandler` parents a viewmodel to
		// `Owner.GameObject` — the BODY, not the camera. Your own viewmodel therefore sits where
		// your camera is and looks right; one built for somebody ELSE'S weapon sits at THEIR body
		// and is drawn by your viewmodel camera, out in the world. A forearm, a hand and an M1911,
		// parallaxing with distance and angle. Exactly the screenshots.
		var go = GameObject.Clone( prefab, new CloneConfig
		{
			Parent = GameObject,
			// ⛔ `Transform.Zero`, NOT `new Transform()`. The docs are explicit that
			// Transform.Zero is "a transform with SCALE OF 1" — the identity-like
			// value. Default-constructing the struct gives scale (0,0,0) and a zero
			// quaternion, so the weapon GameObject had no scale and an invalid
			// rotation. It still rendered, because SWB builds the viewmodel as a
			// SEPARATE object — but anything derived from the weapon's own
			// transform was garbage, which is why `Sound.Play` at the player was
			// audible while SWB's PlaySound (which parents the handle to this
			// object with FollowParent) was silent.
			Transform = global::Transform.Zero,
			StartEnabled = true,
		} );

		// ⚠️ BELT AND BRACES. The prefab is the real guard; this cannot un-network something the
		// network already took, but it costs nothing and covers a prefab that is ever added
		// without the flag.
		if ( go.IsValid() ) go.NetworkMode = NetworkMode.Never;

		// ⛔ A WEAPON MUST NEVER CROSS THE WIRE, AND THIS IS THE ONLY PLACE THAT CAN GUARANTEE IT.
		// The weapon is cloned as a CHILD of the player body, and a body is a network object — so
		// with the default `NetworkMode.Object` the gun travelled with it. On the far machine it
		// arrived as a plain child rather than a network object of its own, which means
		// `IsProxy` reads **false** there (an object that is not networked is nobody's proxy —
		// measured). SWB then concluded the gun was the local player's and built a FIRST-PERSON
		// VIEWMODEL for it, parented to the other player's body:
		//
		//   *"the client sees a duplicate of its own arms that move around the map depending on
		//    my distance towards the host, the angle differs depending on my direction from the
		//    host, and it also does the shoot animation"*
		//
		// That is a viewmodel sitting out in the world. A viewmodel camera draws over everything,
		// so it was also painting on top of the very player it was attached to — one cause, and
		// "there are weird floating arms" and "players do not see each other" are both it.
		//
		// ⚠️ `Never`, NOT A STRIP AFTER THE FACT. `NZPlayers.Disarm` removes weapons from a body
		// about to be cloned, which is correct and not sufficient: the owner re-arms itself a
		// moment later and the new gun had the same default mode as the old one. Stating the rule
		// on the object itself is the only version that cannot be got round.
		//
		// ⚠️ THE VIEWMODEL ALREADY DID THIS — `Weapon.CreateViewModelHandler` sets
		// `NetworkMode.Never` on the viewmodel object for the same reason. The weapon itself was
		// simply never given the same treatment.
		//
		// ⚠️ AND IT IS THE HONEST STATEMENT OF WHERE INVENTORY LIVES TODAY: each machine arms
		// its own body, nothing about a gun replicates, and `SERVER_SPLIT.md` files moving that to
		// the host as later work.
		var wep = go?.Components.Get<SWB.Base.Weapon>();
		if ( !wep.IsValid() )
		{
			Log.Warning( $"[NZPlayer] '{prefabPath}' has no SWB Weapon component" );
			return null;
		}

		// ⚠️ AFTER the component exists, BEFORE anything reads the pose. Applies any
		// sight alignment saved from the offset editor — the prefab is read-only at
		// runtime, so saved overrides live in FileSystem.Data and are pushed on here
		// each spawn. A weapon that was never aligned is untouched.
		WeaponPlacement.Apply( wep );
		ApplyTucking( wep );

		// ⚠️ Stamped BEFORE anything can read it. This is how a live weapon says
		// which prefab it is — the join key for PaP levels and every buyable.
		// ⚠️ BaseName captured BEFORE ApplyStoredUpgrades can write a suffix, and run through
		// BaseName() once in case the prefab's own value already carries one from a
		// previous session writing through to shared state.
		var src = go.Components.Create<WeaponSource>();
		src.Prefab = prefabPath;
		src.BaseName = BaseName( wep.DisplayName );

		// ⚠️ CREATED HERE, BESIDE THE STAMP IT READS. PapCamo resolves the PaP level through
		// WeaponSource.Prefab, so it cannot live anywhere that runs earlier than this line. It
		// paints itself from OnUpdate and needs no further calls — Pack-a-Punch purchases and
		// viewmodel rebuilds are both picked up on the next frame.
		go.Components.GetOrCreate<PapCamo>();

		// ⚠️ Keyed on THIS weapon's prefab, not on whatever is in hand. With two
		// slots the two can differ, and applying the active gun's multiplier to a
		// freshly spawned second weapon would hand out a free upgrade.
		ApplyStoredUpgrades( wep, prefabPath );

		int lvl = PapLevelFor( prefabPath );
		Log.Info( $"[NZPlayer] weapon equipped: {wep.DisplayName}"
			+ (lvl > 0 ? $" MK{lvl}" : "") );
		return wep;
	}

	/// <summary>
	/// Should weapons TUCK against nearby geometry? Off, and that is deliberate.
	///
	/// ⛔ SWB'S TUCKING BLOCKS THE SHOT, not just the animation. `GetTuckDist` traces
	/// `TuckRange` units forward from the eye — 30 by default — and any hit sets
	/// `ShouldTuckVar`, which gates BOTH firing (`CanPrimaryShoot() &amp;&amp;
	/// !ShouldTuckVar`) and aiming. In a corridor shooter that is a nice touch; in
	/// zombies, where you spend the whole game backed against walls with a horde in
	/// your face, it means the gun stops working exactly when you need it.
	///
	/// ⚠️ `-1` IS THE ENGINE'S OWN "OFF" SENTINEL — `GetTuckDist` early-returns on
	/// it. So this disables the feature the way SWB intends rather than by fighting
	/// its output.
	///
	/// ⚠️ APPLIED PER SPAWN, like the Pack-a-Punch multiplier and the sight offsets.
	/// TuckRange is a [Property] baked into each of the 31 weapon prefabs, and the
	/// prefabs are read-only at runtime — so there is nowhere else to put this that
	/// covers every weapon without editing all of them.
	/// </summary>
	public static bool WeaponTucking { get; set; }

	static void ApplyTucking( SWB.Base.Weapon wep )
	{
		if ( !WeaponTucking ) wep.TuckRange = -1f;
	}

	/// <summary>Put tucking back to look at it: `nz_tucking 1`.</summary>
	[ConCmd( "nz_tucking" )]
	public static void CmdTucking( int on = -1 )
	{
		WeaponTucking = on < 0 ? !WeaponTucking : on > 0;

		// ⚠️ Pushed onto the LIVE weapons too, not just the next spawn — otherwise
		// the toggle appears to do nothing until you switch guns.
		foreach ( var p in Game.ActiveScene?.GetAllComponents<NZPlayer>() ?? Enumerable.Empty<NZPlayer>() )
			foreach ( var w in p.Components
				.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
				w.TuckRange = WeaponTucking ? 30f : -1f;

		Log.Info( $"[nz] weapon tucking {(WeaponTucking ? "ON — cannot fire within 30u of anything" : "off")}" );
	}

	// ── Pack-a-Punch ─────────────────────────────────────────────────────────

	/// <summary>
	/// Pack-a-Punch level PER WEAPON PREFAB. Absent = never packed.
	///
	/// ⛔ A MAP NOW, NOT A SINGLE PAIR. It used to be one level plus the prefab it
	/// belonged to, which was correct only while the player could hold ONE gun: the
	/// pairing made switching self-correcting. With two slots that breaks — pack the
	/// AK, switch to the pistol, pack that, and the AK silently loses its MK because
	/// there was only ever room for one answer.
	///
	/// ⚠️ Keyed on the PREFAB PATH rather than the weapon instance, so the level
	/// survives the destroy-and-respawn that Pack-a-Punch and every re-equip do.
	/// </summary>
	public Dictionary<string, int> PapLevels { get; private set; } = new();

	/// <summary>
	/// Max packs — how many MK tiers a weapon can buy.
	///
	/// ⛔ NO LONGER A `const`. It was `const int = 3`, then 5, and is now whatever the map's
	/// `PapSettings.Tiers` says, so a mapper can ship one upgrade or five from the settings panel.
	/// The const was inlined into every caller at compile time, which is exactly why it had to
	/// change shape rather than just change value.
	///
	/// ⚠️ CLAMPED TO `PapSettings.MaxMapTiers`, five, for a map's own Tiers — and all six, MK6 too,
	/// once basalt's Easter egg is complete (<see cref="PapLevelCap"/>).
	/// </summary>
	public static int PapMaxLevel => PapLevelCap( HexPlatforms.EggComplete );

	/// <summary>
	/// The cap as the rule has it: the map's own tiers, 1-5 — and with basalt's Easter egg complete (<paramref name="egg"/>),
	/// six: *"pack a punch up to mk 6"*. The egg apart, for the selftest.
	/// </summary>
	public static int PapLevelCap( bool egg )
		=> egg ? PapSettings.MaxTiers : Math.Clamp( ActiveConfig.Pap?.Tiers ?? 5, 1, PapSettings.MaxMapTiers );

	/// <summary>This prefab's level, or 0 — as it counts (<see cref="PapLevelHeld"/>).</summary>
	public int PapLevelFor( string prefab )
		=> !string.IsNullOrEmpty( prefab ) && PapLevels.TryGetValue( prefab, out var l ) ? PapLevelHeld( l, HexPlatforms.EggComplete ) : 0;

	/// <summary>
	/// A stored level as it counts: an MK6, basalt's Easter egg not complete (<paramref name="egg"/>), is an MK5 — *"only
	/// unlocked after beating the easter egg, no other way to do it"*. ⛔ PACK-A-PUNCH LEVELS OUTLIVE A GAME (`RoundManager`
	/// leaves them), so without this an MK6 bought in one game would still be MK6 in the next, the egg not beaten there. The
	/// egg's tier alone: a map lowering its own Tiers still demotes nothing (`PapSettings.Tiers`). The egg apart, for the
	/// selftest.
	/// </summary>
	public static int PapLevelHeld( int stored, bool egg )
		=> stored > PapSettings.MaxMapTiers && !egg ? PapSettings.MaxMapTiers : stored;

	/// <summary>
	/// Damage scale from packing — the TOTAL multiplier at this weapon's tier.
	///
	/// ⛔ READ FROM THE MAP CONFIG, NOT COMPUTED, and the fallback must MATCH the config's own
	/// default or the two disagree the moment no config is loaded. This was `MathF.Pow( 2.5f, level )`
	/// — the original's geometric curve — which is no longer what `PapSettings.Multipliers`
	/// defaults to: that is now a diminishing ladder ending at x36.7 instead of x97.7. Leaving the
	/// old formula here would mean a weapon packed with no config loaded hit nearly three times
	/// harder than the same weapon with one, which is invisible until someone wonders why the
	/// numbers moved.
	///
	/// ⚠️ NOW THE CONFIG'S OWN DEFAULT, READ RATHER THAN COPIED (`PapSettings.DefaultMultipliers`): the copy here was five
	/// long when the ladder grew MK6 for basalt's Easter egg — and as a static array it would have survived the hotload
	/// holding the five anyway (INSTRUCTIONS.md §1).
	/// </summary>
	static float[] PapFallback => PapSettings.DefaultMultipliers;

	public float PapMultiplierFor( string prefab )
	{
		var level = PapLevelFor( prefab );
		var fromConfig = ActiveConfig.Pap?.MultiplierAt( level );
		if ( fromConfig.HasValue ) return fromConfig.Value;
		if ( level <= 0 ) return 1f;
		var shipped = PapFallback;
		return shipped[Math.Min( level, shipped.Length ) - 1];
	}

	/// <summary>Record a pack. Returns the new level.</summary>
	public int AddPapLevel( string prefab )
	{
		if ( string.IsNullOrEmpty( prefab ) ) return 0;

		int level = Math.Min( PapLevelFor( prefab ) + 1, PapMaxLevel );
		PapLevels[prefab] = level;
		return level;
	}

	/// <summary>
	/// Set a prefab's pack level outright. The symmetric partner of
	/// <see cref="SetRarityTier"/>.
	///
	/// ⛔ EXISTS BECAUSE `AddPapLevel` STEPS BY ONE, and Vulture Aid's Wildcard needs to say
	/// "this gun is MK2 because we are on round 20" — not "one more than whatever it was".
	/// Reaching a target by looping Add would compound on a gun the player had held before
	/// and hand out MK3 within a few rounds regardless of the curve.
	/// </summary>
	public void SetPapLevel( string prefab, int level )
	{
		if ( string.IsNullOrEmpty( prefab ) ) return;

		PapLevels[prefab] = Math.Clamp( level, 0, PapMaxLevel );
	}

	/// <summary>Clear every upgrade. For a new game.</summary>
	public void ClearPap() => PapLevels.Clear();

	// ── weapon rarity ─────────────────────────────────────────

	/// <summary>
	/// Rarity tier per prefab path, 0-5 — 5 Godly, basalt's Easter egg's. See <see cref="Rarity"/>.
	///
	/// ⚠️ A SECOND DICTIONARY BESIDE PapLevels RATHER THAN A COMBINED RECORD, and
	/// keyed the same way for the same reasons — both notes on PapLevels apply
	/// verbatim, including why a single pair breaks with two weapon slots.
	///
	/// ⛔ THEY ARE SEPARATE BECAUSE THE TWO UPGRADES ARE INDEPENDENT. Pack-a-Punch
	/// and rarity multiply, and either can be raised without the other — the box hands
	/// out rarity on an unpacked gun, and the machine packs a Common one. Folding them
	/// into one stored number would make each unable to change without recomputing the
	/// other, and there would be no way to display them apart, which the stats panel
	/// now does.
	/// </summary>
	public Dictionary<string, int> RarityTiers { get; private set; } = new();

	/// <summary>This prefab's rarity tier, or 0 (Common) — as it counts: a Godly is Legendary until the Easter egg is complete (`Rarity.TierHeld`).</summary>
	public int RarityTierFor( string prefab )
		=> BuildParts.IsWonderWeapon( prefab )
			? Rarity.LegendaryTier
			: !string.IsNullOrEmpty( prefab ) && RarityTiers.TryGetValue( prefab, out var t )
				? Rarity.TierHeld( t, HexPlatforms.EggComplete )
				: 0;

	/// <summary>Set a prefab's rarity tier. Clamped to the real range.</summary>
	public void SetRarityTier( string prefab, int tier )
	{
		if ( string.IsNullOrEmpty( prefab ) ) return;

		RarityTiers[prefab] = Rarity.Clamp( tier );
	}

	/// <summary>Raise a prefab one tier. Returns the new tier.</summary>
	public int AddRarityTier( string prefab )
	{
		if ( string.IsNullOrEmpty( prefab ) ) return 0;

		// ⚠️ NO HIGHER THAN THE TOP TO BE HAD NOW (`Rarity.TopTier`): Godly is basalt's Easter egg's
		var cur = RarityTierFor( prefab );
		var tier = cur >= Rarity.TopTier ? cur : Rarity.Clamp( cur + 1 );
		RarityTiers[prefab] = tier;
		return tier;
	}

	/// <summary>Back to Common everywhere. For a new game.</summary>
	public void ClearRarity() => RarityTiers.Clear();

	// ── weapon tech tree ──────────────────────────────────────

	/// <summary>
	/// Tech nodes owned, per prefab path. See <see cref="WeaponTech"/>.
	///
	/// ⚠️ A FLAT LIST OF NODE IDS PER WEAPON, not the original's nested
	/// [class][tier][ids]. The tier of a node is already derivable from the catalogue
	/// (WeaponTech.TierOfNode), so storing it here would be a second copy of a fact
	/// that can go stale the moment a node is moved between tiers during design — and
	/// this catalogue is still being designed.
	///
	/// ⚠️ Keyed on prefab path, exactly like PapLevels and RarityTiers, so it survives
	/// the destroy-and-respawn that Pack-a-Punch and every re-equip do.
	/// </summary>
	public Dictionary<string, List<string>> TechOwned { get; private set; } = new();

	/// <summary>
	/// THE SAME TREE FLATTENED ONTO THE WIRE, so the other machines can read it.
	///
	/// ⛔ WITHOUT THIS, EVERY TECH NODE RESOLVED ON THE VICTIM WAS DEAD FOR A CLIENT'S OWN
	/// SHOTS — EIGHT OF THEM, not the three originally suspected. `NZNet.HurtRemote` carries a
	/// damage figure and no weapon, so the host's `FiredBy` returned null and `TechEffects`
	/// answered "does not own it" for Hollow Points, Body Shot, Precision Rounds, Deadeye, Wide
	/// Bore, Perforator, Bouncy Rounds and Bounty. Nothing logged and nothing failed: a client
	/// simply had eight nodes that did nothing while the host's identical gun worked. The client
	/// cannot apply them itself either — most of them are facts about the VICTIM'S body or its
	/// death, which only the host holds.
	///
	/// ⚠️ A STRING BECAUSE `[Sync]` CARRIES UNMANAGED TYPES AND STRINGS, the same constraint
	/// `HoldTypeId` records one screen down. A `Dictionary&lt;string, List&lt;string&gt;&gt;` does not
	/// replicate at all.
	///
	/// ⚠️ ENCODED `prefab|node,node;prefab|node`, AND THE THREE SEPARATORS ARE SAFE BY
	/// CONSTRUCTION rather than by escaping: a key is an asset path (`weapons/nz_usp.prefab`) and
	/// a value is a node id (`t3_fabricator`), and neither vocabulary contains `;`, `|` or `,`.
	///
	/// ⚠️ IT SENDS ON CHANGE, AND TECH CHANGES ON A PURCHASE — a handful of times in a whole
	/// game. This is not a per-frame cost, which is what made the whole tree affordable to send
	/// rather than just the nodes the damage path happens to ask about.
	/// </summary>
	[Sync] public string TechNet { get; set; } = "";

	/// <summary>
	/// Which buildable parts this player is carrying, as a bitmask. See `BuildParts`.
	/// </summary>
	///
	/// ⚠️ GONE, AND DELIBERATELY NOT REPLACED BY A SYNCED FIELD. Build parts became a TEAM pool on
	/// 2026-09-22, so what is carried is no longer a property of a player at all — it lives in
	/// `BuildParts` and travels by `NZNet.BuildPartsState`. A per-player mirror of a shared value
	/// is a second model of the world that can disagree with the first.

	/// <summary>
	/// How long E has been held at a building table, in seconds.
	/// </summary>
	///
	/// ⛔ NOT SYNCED, AND THAT IS CORRECT. It is the local player's own progress bar; the only
	/// thing anyone else needs to know is the weapon that arrives at the end, which `GiveWeapon`
	/// already handles. Syncing a value that changes every frame to say "someone is holding a key"
	/// would be traffic for a progress bar nobody else can see.
	public float BuildHold { get; private set; }

	/// <summary>
	/// The tree to ANSWER FROM: the local dictionary on the machine that owns this player, the
	/// replicated copy everywhere else.
	///
	/// ⛔ "NON-EMPTY LOCAL WINS" IS NOT A GUESS, IT IS THE SINGLE-WRITER PROPERTY WRITTEN DOWN.
	/// `AddTech` is reached from `Arsenal.BuyTech` alone and that runs on the BUYER's machine, so
	/// a proxy's `TechOwned` is empty on every machine that is not the owner — there is nothing
	/// there to shadow the wire with. And on the owner the wire copy is an echo of the local one,
	/// so falling through to it when the local store is empty returns the same answer rather than
	/// a different one. Both halves have to hold; they do.
	///
	/// ⚠️ THE ALTERNATIVE WAS AN OWNERSHIP TEST AND IT WOULD HAVE COST MORE THAN THE LOOKUP
	/// IT GUARDS. `PlayerPresence.Mine` resolves a component on the object every call, and this
	/// is asked several times per PELLET on the host — `Health` alone reads six nodes on one hit.
	/// `Count` is a field read. The ownership test is still used where it is cheap, in
	/// <see cref="PublishTech"/>, which runs on a purchase.
	///
	/// ⚠️ DECODED ONCE PER CHANGE, not once per read. The string is its own cache key.
	/// </summary>
	Dictionary<string, List<string>> TechStore
	{
		get
		{
			if ( TechOwned.Count > 0 ) return TechOwned;

			var net = TechNet ?? "";
			if ( net != _techSeen )
			{
				_techSeen = net;
				_techWire.Clear();
				DecodeTech( net, _techWire );
			}

			return _techWire;
		}
	}

	/// <summary>The `TechNet` the decoded copy below was built from.</summary>
	string _techSeen = "";

	/// <summary>`TechNet`, decoded. Rebuilt only when the string changes.</summary>
	readonly Dictionary<string, List<string>> _techWire = new();

	/// <summary>
	/// Re-flatten the tree onto the wire. The owner only.
	///
	/// ⛔ OWNER-GUARDED BECAUSE `RoundManager.StartGame` CALLS `ClearTech` ON EVERY PLAYER AND
	/// RUNS ON THE HOST. A `[Sync]` write from a machine that does not own the object does not
	/// replicate — but it DOES change the local value until the next update arrives, so without
	/// this guard starting a new game would blank the host's picture of every client's tech for
	/// as long as it took the real value to come back, and every relayed hit in that window would
	/// silently lose its nodes.
	///
	/// ⚠️ `PlayerPresence.Mine` RATHER THAN `IsProxy`, which is the project's settled answer
	/// to this exact question and has a screen of comment saying why the two derived alternatives
	/// were both wrong. Do not re-derive it here.
	/// </summary>
	void PublishTech()
	{
		if ( !PlayerPresence.Mine( GameObject ) ) return;

		TechNet = EncodeTech( TechOwned );
	}

	/// <summary>The tree as one string. See <see cref="TechNet"/> for the format.</summary>
	static string EncodeTech( Dictionary<string, List<string>> tree )
	{
		if ( tree is null || tree.Count == 0 ) return "";

		var sb = new System.Text.StringBuilder();

		foreach ( var pair in tree )
		{
			// ⚠️ A PREFAB WITH AN EMPTY LIST IS SKIPPED, not written as a bare `path|`. The
			// decoder rejects that shape, so round-tripping one would silently drop it — and the
			// two sides disagreeing about what is in the tree is the failure this whole property
			// exists to end.
			if ( string.IsNullOrEmpty( pair.Key ) || pair.Value is null || pair.Value.Count == 0 )
				continue;

			if ( sb.Length > 0 ) sb.Append( ';' );

			sb.Append( pair.Key ).Append( '|' ).Append( string.Join( ",", pair.Value ) );
		}

		return sb.ToString();
	}

	/// <summary>The inverse of <see cref="EncodeTech"/>, into an already-cleared dictionary.</summary>
	static void DecodeTech( string net, Dictionary<string, List<string>> into )
	{
		if ( string.IsNullOrEmpty( net ) ) return;

		foreach ( var entry in net.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
		{
			// ⚠️ A BAR AT EITHER END IS MALFORMED AND IS DROPPED RATHER THAN HALF-READ. An
			// entry cannot have an empty prefab or an empty node list — see the encoder.
			var bar = entry.IndexOf( '|' );
			if ( bar <= 0 || bar == entry.Length - 1 ) continue;

			into[entry[..bar]] = new List<string>(
				entry[(bar + 1)..].Split( ',', StringSplitOptions.RemoveEmptyEntries ) );
		}
	}

	/// <summary>
	/// Every node owned on a prefab. Never null.
	///
	/// ⚠️ THROUGH `TechStore`, NOT `TechOwned`, WHICH IS WHAT MAKES THE HOST ABLE TO SCORE A
	/// CLIENT'S SHOT. Every tech read in the project funnels through here eventually — `HasTech`,
	/// `TechCount` and all four `TechEffects` accessors — so this one line is the whole read side
	/// of the fix.
	/// </summary>
	/// ⛔ THE WONDER WEAPON OWNS NO NODES, AND THIS IS THE READER RATHER THAN THE SHOP. Everything
	/// asks here — `HasTech`, `TechEffects.Of`, the damage maths, the tech panel — so refusing at
	/// the store would still leave a gun that had been granted one before the rule existed carrying
	/// it. An empty list at the read is true for every caller at once and cannot be gone round.
	public List<string> TechFor( string prefab )
		=> BuildParts.IsWonderWeapon( prefab )
			? new List<string>()
			: !string.IsNullOrEmpty( prefab ) && TechStore.TryGetValue( prefab, out var l )
				? l
				: new List<string>();

	/// <summary>Does this prefab own that node.</summary>
	/// <remarks>
	/// ⚠️ THROUGH `TechOrNull` (2026-10-04): the same answer as `TechFor( prefab ).Contains`, without the fresh empty list
	/// `TechFor` hands out for every gun with no tech. `TechEffects.Has` comes here, several times a frame and a dozen
	/// times a shot.
	/// </remarks>
	public bool HasTech( string prefab, string nodeId )
		=> TechOrNull( prefab )?.Contains( nodeId ) ?? false;

	/// <summary>How many nodes this prefab owns in a tier.</summary>
	public int TechCount( string prefab, int tier )
	{
		var n = 0;
		foreach ( var id in TechFor( prefab ) )
			if ( WeaponTech.TierOfNode( id ) == tier ) n++;

		return n;
	}

	/// <summary>Record a node. Returns false if already owned.</summary>
	public bool AddTech( string prefab, string nodeId )
	{
		if ( string.IsNullOrEmpty( prefab ) || string.IsNullOrEmpty( nodeId ) ) return false;
		if ( HasTech( prefab, nodeId ) ) return false;

		if ( !TechOwned.TryGetValue( prefab, out var list ) )
		{
			list = new List<string>();
			TechOwned[prefab] = list;
		}

		list.Add( nodeId );

		// ⛔ THE CHIMERA ROLL HAPPENS HERE AND NOWHERE ELSE, BECAUSE THIS IS THE ONLY PLACE
		// A NODE IS EVER RECORDED (reached from Arsenal.BuyTech alone). The tempting site is
		// ApplyStoredUpgrades, where the stats are written — and that runs on EVERY equip
		// from five call sites, so the gamble would be re-taken every time the gun was drawn.
		// One roll, permanent, no re-roll is the entire identity of the node.
		if ( nodeId == "t5_chimera" ) RollChimera( prefab );

		// ⛔ AND THE OTHER MACHINES ARE TOLD, WHICH IS THE ENTIRE REASON EIGHT NODES WORK FOR A
		// CLIENT NOW. See `TechNet`: everything resolved on the VICTIM is read on the host, and
		// until this line the host's copy of a client's tree was permanently empty.
		PublishTech();

		return true;
	}

	/// <summary>
	/// Forget a node — the pair of <see cref="AddTech"/>. Returns false if it was not owned.
	/// Reached from `Arsenal.RemoveTech` alone (a right click on an owned card, 2026-10-03),
	/// which refunds and re-applies the weapon; this only edits the store.
	///
	/// ⛔ CHIMERA IS REFUSED HERE AND NOT ONLY AT THE MACHINE. Its roll lives in `ChimeraRolls`,
	/// which `TechBase` substitutes whether or not the node is owned, so forgetting the node
	/// would leave the drawn stats on a gun that owns nothing — and dropping the roll as well
	/// would hand out the re-roll the node exists to forbid. `ClearTech` and `nz_tech_reset`
	/// are the two ways a roll ever goes, and both throw the whole tree away with it.
	///
	/// ⚠️ THE FABRICATOR'S DEADLINE GOES WITH ITS NODE, for the reason `ClearTech` drops them
	/// all: a deadline left behind is already due when the node is bought back, so remove and
	/// re-buy would pay a magazine at once instead of starting a fresh minute.
	/// </summary>
	public bool RemoveTech( string prefab, string nodeId )
	{
		if ( string.IsNullOrEmpty( prefab ) || string.IsNullOrEmpty( nodeId ) ) return false;
		if ( nodeId == "t5_chimera" ) return false;
		if ( !TechOwned.TryGetValue( prefab, out var list ) || !list.Remove( nodeId ) ) return false;

		// ⚠️ AN EMPTY LIST IS DROPPED, NOT KEPT. `TickFabricator` skips its whole weapon walk
		// on `TechOwned.Count == 0`, and an empty entry would keep that walk running for good.
		if ( list.Count == 0 ) TechOwned.Remove( prefab );

		if ( nodeId == "t3_fabricator" ) _fabDue.Remove( $"{prefab}|fab" );

		// ⛔ AND THE OTHER MACHINES ARE TOLD, or the host would keep scoring this player's hits
		// with a node they sold back. See `TechNet`.
		PublishTech();

		return true;
	}

	/// <summary>
	/// Chimera's one roll per prefab — the drawn value for each field, keyed by the SAME key
	/// <see cref="TechBase"/> remembers the authored value under.
	///
	/// ⛔ KEYED BY THE `_techBase` KEY ON PURPOSE, SO THERE IS NO MAPPING TABLE TO DRIFT.
	/// An axis is substituted by `TechBase` looking up the part of its own key after the
	/// prefab — `shot|dmg`, `falloff|start`, `clip` — so the roll cannot name a field the
	/// write path does not read, or the reverse. A second dictionary of friendly names would
	/// be a second place for the capture-once rule to disagree with itself.
	///
	/// ⚠️ PRIMARY FIRE ONLY, AND THAT FALLS OUT OF THE SAME CHOICE RATHER THAN A BRANCH:
	/// the secondary's keys are `clip2` / `shot2|dmg` / `falloff2|start`, which no axis
	/// declares, so an underbarrel keeps its authored stats. One roll, one barrel.
	///
	/// ⚠️ Keyed on prefab path and living beside `_techBase`, `PapLevels`, `RarityTiers` and
	/// `TechOwned` for the reason all of them are: the weapon is a clone that Pack-a-Punch
	/// destroys and respawns, and a roll stored on the instance would be re-taken by the
	/// first upgrade — which is the one thing this node must never do.
	///
	/// ⚠️ THE RELOAD AXIS IS INERT ON THE THREE SHELL-RELOADING SHOTGUNS, and that is a
	/// limit rather than a bug to hide: the HS10, KS23 and SPAS12 (`ShellReloading: true`,
	/// counted off the prefabs) never read either whole-magazine duration — `StartReload`
	/// passes a per-shell override instead — and dropping a 4.7-second magazine time into a
	/// per-shell insert would give a sixteen-round reload of over a minute. One drawn
	/// duration cannot honestly say anything about a per-shell rhythm.
	/// </summary>
	public Dictionary<string, Dictionary<string, float>> ChimeraRolls { get; private set; } = new();

	/// <summary>
	/// The marker saying this roll really happened.
	///
	/// ⚠️ KEY PRESENCE IS NOT ENOUGH ON ITS OWN, because an axis can legitimately draw the
	/// value the weapon already had — roughly one draw in thirty-one per axis. An explicit
	/// marker is the difference between "rolled and got its own numbers back" and "never
	/// rolled", which are the same dictionary otherwise.
	/// </summary>
	public const string ChimeraRolled = "rolled";

	/// <summary>
	/// The fire-mode axis, stored as `(float)(int)FiringType`.
	///
	/// ⛔ IN THE SAME DICTIONARY AS THE NUMBERS, NOT A PARALLEL ONE. Two stores would be two
	/// places for the roll-once rule to drift, and an enum in a float store is exactly as
	/// safe as an int in one — what it CANNOT do is go through `TechBase` like the other
	/// axes, because an enum has no neutral value to compose with. So this key is read by
	/// `Weapon.EffectiveFiringType` at READ time, last, as a substitution for the authored
	/// mode; a spawn-time write would need a remembered base of its own.
	///
	/// ⚠️ ABSENT WHEN THE DRAWN NAME DID NOT PARSE, which means "keep the authored mode".
	/// The pool hands the mode over as a string precisely so that failure is expressible.
	/// </summary>
	public const string ChimeraMode = "mode";

	void RollChimera( string prefab )
	{
		// ⚠️ Never twice for one prefab. AddTech already refuses a node it owns, so this is
		// the belt to that braces — a hotload or a creative-mode re-buy must not re-roll.
		if ( string.IsNullOrEmpty( prefab ) || ChimeraRolls.ContainsKey( prefab ) ) return;

		var roll = ChimeraPool.Roll();

		// ⛔ NULL MEANS NOTHING WAS ROLLED AND NOTHING IS STORED. The pool returns all eight
		// axes or none, because "no donor for this axis" and "a donor whose value is 0" are
		// the same bits once they are floats — and a half roll would hand the player a gun
		// that deals no damage. The node stays owned (the salvage is spent) and runs the
		// weapon's authored stats; `nz_tech_live` prints that state as an error rather than
		// leaving it to be discovered.
		if ( roll is null ) return;

		ChimeraRolls[prefab] = new Dictionary<string, float>
		{
			[ChimeraRolled] = 1f,
			["clip"] = roll.ClipSize,
			["shot|dmg"] = roll.Damage,
			["shot|rpm"] = roll.Rpm,
			["shot|bullets"] = roll.Bullets,
			["shot|recoilup"] = roll.RecoilUp,
			["shot|hipspread"] = roll.SpreadAddHipFire,

			// ⛔ THE BASE-MODE HALF OF THE RECOIL AXIS. `recoilup` alone is read only when
			// `UseRecoilBase` is off, which it has not been since the base was baked — see the
			// Donor record. These two are what the default path actually multiplies.
			["shot|recoilvmult"] = roll.RecoilVerticalMult,
			["shot|recoilhmult"] = roll.RecoilHorizontalMult,

			// ⚠️ AND THE REST OF THAT DONOR'S KICK: the first-shot punch and the pull-down, so a
			// Chimera gun kicks like the gun it stole its recoil from (2026-10-03). See ChimeraPool.
			["shot|recoilkick"] = roll.RecoilKick,
			["shot|recoilauto"] = roll.RecoilAutoControl,
			["falloff|start"] = roll.FalloffStart,
			["falloff|end"] = roll.FalloffEnd,
			["falloff"] = roll.FalloffMultiplier,

			// ⛔ THE ONE DRAWN DURATION GOES ON BOTH WHOLE-MAGAZINE RELOAD FIELDS, AND
			// LEAVING `ReloadEmptyTime` AUTHORED WOULD HAVE MADE THE AXIS INVISIBLE IN THE
			// CASE THAT ACTUALLY HAPPENS. In this game you reload when the magazine is
			// empty, and that path reads `ReloadEmptyTime` — which is authored LONGER than
			// `ReloadTime` on the roster, so a drawn 4.7s would have been silently replaced
			// by the weapon's own 2.0s on nearly every reload a player performs. Writing
			// both keeps them coherent (empty is never faster than tactical) at the cost of
			// collapsing the two into one, which is what one drawn number can honestly say.
			["reload"] = roll.ReloadTime,
			["reloadempty"] = roll.ReloadTime,
		};

		// ⚠️ Parsed case-insensitively because the prefab authors the mode as a lowercase
		// quoted string ("semi", "auto", "burst") and the enum is declared the same way; a
		// name that does not parse simply leaves the axis out, meaning "keep the authored
		// mode" rather than "mode zero".
		if ( System.Enum.TryParse<SWB.Base.FiringType>( roll.FiringMode, true, out var mode ) )
			ChimeraRolls[prefab][ChimeraMode] = (float)(int)mode;

		// ⚠️ LOGGED AT THE MOMENT IT HAPPENS, because it happens exactly once per weapon per
		// game and can never be reproduced. The median roll is deliberately WORSE than the
		// gun it replaces, so without this line "did the roll happen" is unanswerable by
		// feel — `nz_tech_live` prints the same numbers back later, which is the falsifier.
		Log.Info( $"[chimera] rolled on {prefab} — clip {roll.ClipSize}"
			+ $"  dmg {roll.Damage:0.#}  rpm {roll.Rpm}  mode {roll.FiringMode}"
			+ $"  pellets {roll.Bullets}  reload {roll.ReloadTime:0.##}s"
			+ $"  range {roll.FalloffStart:0}-{roll.FalloffEnd:0}u x{roll.FalloffMultiplier:0.##}"
			+ $"  recoil x{roll.RecoilVerticalMult:0.##}/x{roll.RecoilHorizontalMult:0.##}"
			+ $" kick {roll.RecoilKick:0.##} auto {roll.RecoilAutoControl:0.##}"
			+ $"  recoilUp {roll.RecoilUp:0.##}  hipspread {roll.SpreadAddHipFire:0.###}" );
	}

	/// <summary>
	/// Wipe every tree. For a new game.
	///
	/// ⛔ THE FABRICATOR TIMERS GO WITH THEM. They are wall-clock deadlines, and a
	/// deadline that outlives the tree that justified it is either a payout on a node
	/// nobody owns any more or — worse, if `Time.Now` has been rewound by the scene
	/// restarting — a deadline minutes in the future that never comes due, so the node
	/// looks dead on a fresh game.
	/// </summary>
	public void ClearTech()
	{
		TechOwned.Clear();
		_fabDue.Clear();

		// ⛔ AND THE CHIMERA ROLLS GO WITH THEM, for a sharper version of the reason the
		// timers do: a roll that outlives the node is a substitution applied inside
		// `TechBase` on a weapon that owns nothing, so every stat the player sees would come
		// from a gamble they no longer have — and the node is unrepeatable, so there would be
		// no way to roll it back. `_techBase` still holds the real authored values, so the
		// next equip restores the weapon exactly.
		ChimeraRolls.Clear();

		// ⚠️ AND THE WIRE COPY GOES WITH THEM, or the other machines would keep scoring hits
		// against a tree this player no longer has. `PublishTech` is owner-guarded, which is what
		// makes this safe to call from `RoundManager.StartGame`'s loop over EVERY player.
		PublishTech();
	}

	/// <summary>
	/// Push the stored upgrades — Pack-a-Punch AND rarity — onto the weapons ALREADY
	/// IN HAND.
	///
	/// ⚠️ WAS `RefreshPap`, renamed with ApplyStoredUpgrades below it. It refreshes
	/// both now, and a name promising only one is how the other stops being refreshed.
	///
	/// ⛔ A RE-EQUIP IS NOT ENOUGH ON ITS OWN. `EquipStartingWeapon` early-returns
	/// when a weapon is already parented to the player, so calling it after a level
	/// change is a no-op and the live gun keeps its old multiplier. That path is
	/// invisible in normal play — Pack-a-Punch strips the weapon before it hands one
	/// back, so the respawn really does happen — and it was `nz_pap_status` reading
	/// the value back off the live ShootInfo that exposed it: stored x15.63 against
	/// a live x2.5.
	/// </summary>
	public void PushStoredUpgrades()
	{
		// ⚠️ Each weapon is refreshed from ITS OWN prefab, read off the WeaponSource
		// stamped at spawn. Using StartingWeapon for all of them would push the
		// active gun's level onto the holstered one — a free upgrade on the weapon
		// you were not even holding.
		//
		// ⚠️ Falls back to StartingWeapon for anything with no stamp. That is a
		// weapon spawned outside GiveWeapon — which in practice means one that
		// survived a hotload — and silently skipping it made `nz_pap_status` report
		// a disagreement it could not explain.
		foreach ( var wep in Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
		{
			// ⛔ `FindMode.EverythingInSelf` — A HOLSTERED WEAPON IS A DISABLED ONE,
			// and the default Get skips disabled components. Without it this returned
			// null for the holstered gun, fell back to StartingWeapon, and stamped the
			// ACTIVE weapon's Pack-a-Punch level onto it: a free MK2 and a "ASP MK2"
			// name on a weapon that was never packed. Third time this trap has cost a
			// bug — see NZInventory, which documents the same thing.
			var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
			ApplyStoredUpgrades( wep, string.IsNullOrEmpty( src ) ? StartingWeapon : src );
		}
	}

	// ── tier-4 secondary magnitudes ──────────────────────────────────────────
	//
	// ⛔ SEVEN NUMBERS WITH NO CATALOGUE HOME, AND THAT IS A GAP TO REPORT RATHER THAN A
	// CHOICE MADE HERE. `WeaponTech.Node` carries ONE `Factor` and ONE `Bound`, and each
	// tier-4 node spends `Factor` on the number its name is about — Tuned Action on its
	// damage, Scattergun on its pellets, Drum Magazine on its magazine. That leaves these
	// seven with nowhere in the catalogue to live.
	//
	// Five of them fit the free `Bound` on their own node and are read through
	// `WeaponTech.BoundOf( id, fallback )` so a catalogue declaration wins the moment
	// WeaponTech.cs adds one: TunedRpm, ScatterDamage, SlugRange, LastResortReload and
	// DrumReload. The remaining TWO — ScatterRpm and DrumWalk — are their node's THIRD
	// number, which no field on `Node` can hold, so they are read directly and MUST NOT
	// be routed through `BoundOf`: that slot belongs to the sibling above them, and
	// borrowing it would silently make Scattergun's fire rate x0.4 and Drum Magazine's
	// walk speed x2.
	//
	// ⚠️ `nz_tech` CANNOT PRINT WHAT IS NOT IN THE CATALOGUE. That is exactly the
	// second-source problem TechEffects' header warns about, and it is why these are named
	// ONCE here rather than written inline at their call sites. The real fix is a second
	// bound on `Node`; see the report.
	//
	// ⚠️ EXPRESSED AGAINST OUR OWN FIELD, like every catalogue factor: the two reload
	// numbers are >1 because `ReloadTime` is a DURATION where bigger is slower, while
	// Fast Hands' 1.111 is >1 because it is a SPEED that divides. The two are not the same
	// direction and composing them is note (c) on ApplyStoredUpgrades.
	// ⛔ SEVEN MAGNITUDE CONSTS LIVED HERE AND ARE GONE. TunedRpm, ScatterDamage,
	// ScatterRpm, SlugRange, LastResortReload, DrumReload and DrumWalk are now `Mag`
	// entries on their nodes in WeaponTech.cs. Enumerated because the count matters: all
	// seven were secondary magnitudes of tier-4 nodes, and every one of them was invisible
	// to `nz_tech` (which prints the catalogue) and to `nz_tech_amp` (which amplifies it).
	//
	// That is what made Tuned Action read as "not affecting fire rate": at x10 its damage
	// half became x9.3 and its RPM half stayed x1.10. Five were read through
	// `WeaponTech.BoundOf`, which is a SAFETY-CAP accessor and correctly refuses to
	// amplify — so borrowing it for a magnitude inherited exactly the wrong rule. Two
	// (ScatterRpm, DrumWalk) had no accessor at all.

	/// <summary>
	/// Push every stored upgrade onto a freshly spawned weapon — Pack-a-Punch AND
	/// rarity.
	///
	/// ⛔ ON EVERY EQUIP, not once at the machine. The weapon is a CLONE of a
	/// read-only prefab and PaP destroys and respawns it, so the multipliers have to
	/// be re-applied to each new instance — exactly like WeaponPlacement's saved
	/// sight offsets, which is why this sits next to it.
	///
	/// ⚠️ WAS CALLED `ApplyPap`, renamed when rarity joined it. A method that
	/// pushes two upgrades while named after one is how the second gets forgotten at
	/// the call sites — and both call sites here are the ONLY places a weapon can
	/// arrive in a hand, so a miss means an upgrade that silently never applies.
	///
	/// ⚠️ Secondary fire too. A weapon whose underbarrel stayed at base damage
	/// after a 30,000-point MK3 would read as the upgrade not having worked.
	/// </summary>
	void ApplyStoredUpgrades( SWB.Base.Weapon wep, string prefab )
	{
		float mult = PapMultiplierFor( prefab );

		if ( wep.Primary is not null ) wep.Primary.DamageMultiplier = mult;
		if ( wep.Secondary is not null ) wep.Secondary.DamageMultiplier = mult;

		// ⚠️ AND THE FACT, BESIDE THE NUMBER. Everything that wants to PRESENT a packed gun —
		// the shoot sound, the violet muzzle flash, the violet tracer, the shot relay — reads this
		// rather than re-deriving it from the multiplier, which the bullet loop temporarily scales.
		// See `ShootInfo.IsPacked`.
		var packed = mult > 1.01f;

		if ( wep.Primary is not null ) wep.Primary.IsPacked = packed;
		if ( wep.Secondary is not null ) wep.Secondary.IsPacked = packed;

		// ⚠️ AND WHICH TIER, for the flash and the tracer to colour by. Stamped HERE beside the
		// flag rather than asked per bullet, for the reason `ShootInfo.IsPacked` already gives: the
		// answer only changes on equip, and this is equip.
		// ⚠️ NAMED `papTier`, NOT `papLevel`. This method runs long and already declares a
		// `papLevel` further down for the display name — same value, different question, and C#
		// scopes the whole body as one.
		var papTier = packed ? PapLevelFor( prefab ) : 0;

		if ( wep.Primary is not null ) wep.Primary.PapLevel = papTier;
		if ( wep.Secondary is not null ) wep.Secondary.PapLevel = papTier;

		// ⛔ RARITY GOES ON ITS OWN FIELD, NEVER FOLDED INTO DamageMultiplier. The line
		// above ASSIGNS that field per equip, so anything folded into it is silently
		// discarded the next time this runs — a Legendary would lose its rarity damage on
		// every re-equip, which reads as the box roll not having worked.
		//
		// ⚠️ IT NO LONGER DRIVES THE PACKED PRESENTATION. That used to be the argument here
		// — four sites read `DamageMultiplier > 1.01f` to mean "packed" — and two of them
		// were wrong for an unrelated reason. `ShootInfo.IsPacked` is the fact now.
		//
		// ⚠️ ASSIGNED, not multiplied, for the same reason the PaP line above is:
		// this method runs on EVERY equip, so `*=` would compound the tier every time
		// the gun was drawn.
		// ⚠️ `DamageMult`, NOT `Mult`: the wonder weapon reads Legendary but hits for its prefab's
		// number — see Rarity.DamageMult.
		float rarity = Rarity.DamageMult( prefab, RarityTierFor( prefab ) );

		if ( wep.Primary is not null ) wep.Primary.RarityMultiplier = rarity;
		if ( wep.Secondary is not null ) wep.Secondary.RarityMultiplier = rarity;

		// ⚠️ TECH SCALES A RAW FIELD, so unlike the two multipliers above there is no
		// spare field holding the base to assign over — hence the remembered bases in
		// `TechBase`. `*=` here would walk a 30-round mag to 34, 39, 45 across three
		// PushStoredUpgrades calls on the gun already in hand.
		float clipFactor = TechEffects.Factor( this, prefab, "t1_clip" );

		// ⚠️ THE THREE TIER-4 MAGAZINE NODES MULTIPLY INTO THE SAME FACTOR rather than
		// each getting a write of its own, so `ApplyClipTech`'s single absolute expression
		// stays the ONLY thing that touches ClipSize — including its -1 sentinel guard,
		// which a second write would have to remember to duplicate.
		//
		// ⚠️ A PRODUCT, NOT A BRANCH, EVEN THOUGH TIER 4 IS PICK-ONE. `WeaponTech.Unlimited`
		// lifts the pick limit in creative precisely so all eleven nodes can be bought on
		// one weapon, so "only one of these can be owned" is false exactly where the nodes
		// are being tested. All-Rounder x1.15, Last Resort x0.25 and Drum Magazine x3
		// together land on x0.86, which is a sane answer rather than whichever one a
		// precedence rule happened to pick.
		clipFactor *= TechEffects.Factor( this, prefab, "t4_allround" )
			* TechEffects.Factor( this, prefab, "t4_drum" );

		// ⛔ THE TWO TIER-5 MAGAZINE NODES READ `Mag`, NOT `Factor`, AND THE DIFFERENCE IS
		// NOT COSMETIC. Emplacement's `Factor` is its x3 DAMAGE and Bolt Gun's is its x4, so
		// `Factor` at this site would triple and quadruple the magazine — a plausible number
		// on a field where nothing would flag it. Every tier-5 site except damage is in that
		// position, which is why the catalogue names its secondary magnitudes.
		clipFactor *= TechEffects.Mag( this, prefab, "t4_emplacement", "clip" )
			* TechEffects.Mag( this, prefab, "t4_boltgun", "clip" )
			* TechEffects.Mag( this, prefab, "t4_bullbarrel", "clip" )
			* TechEffects.Mag( this, prefab, "t4_scatter", "clip" );

		// ⛔ THE PER-CLASS AUGMENTS' MAGAZINES (2026-10-04): every `s.clip` multiplier an owned node declares (Double
		// Stack x2, Stick Mag x5, Buckshot Belt x0.2…), read once through `TechStats` rather than by id.
		clipFactor *= TechStats.Mul( this, prefab, "s.clip" );

		// ⛔ `ifAbsent: 0f` — TechEffects.Factor DEFAULTS TO 1, which is neutral for the
		// multiply above and is +1 ROUND here. Extra Rounds is the first node in this
		// method whose factor is ADDED, so taking the default would have handed every one
		// of the 31 weapons a free round it never bought, on every equip.
		//
		// ⚠️ AMMO POUCH'S +10 IS THE SAME KIND OF TERM AND RIDES IT (2026-09-27; it was +10 RESERVE), so
		// the two add and `ApplyClipTech` stays the one writer of ClipSize. The same `ifAbsent: 0f`.
		float clipFlat = TechEffects.Factor( this, prefab, "t2_clip_flat", 0f )
			+ TechEffects.Factor( this, prefab, "t3_ammo", 0f )
			// ⚠️ AND THE AUGMENTS' FLAT ROUNDS: Deep Mag +20, Short Belt -20, Moon Clips -1 (2026-10-04).
			+ TechStats.Add( this, prefab, "s.clip+" );

		// ⚠️ A `Has`, NOT A FACTOR. Siege carries no magnitude of its own — the number of
		// magazines it folds in comes from `ReserveAmmo`, which already owns that rule.
		bool siege = TechEffects.Has( this, prefab, "t5_siege" );

		// ⛔ AUTOLOADER RESOLVED ONCE, HERE, AND USED BY THREE DIFFERENT FACTORS. Its magnitude is
		// the weapon's own pellet count, which is a single fact about the gun — asking for it again
		// at the damage and rate sites would be three readers of one number, which is the shape this
		// file already records diverging. It is also read BEFORE `ApplyClipTech` writes anything,
		// which is what seeds the remembered pellet count from the authored value.
		float autoload = AutoloaderFactor( wep, prefab, out bool autoloadOn );

		clipFactor *= autoload;

		// ⚠️ OVERFILL (9–20 rounds, tier 3, 2026-10-04, `Weapon.MagTech.cs`) on the primary, whose reloads it changes: see `ApplyClipTech`.
		ApplyClipTech( wep.Primary, $"{prefab}|clip", clipFactor, clipFlat, siege,
			TechEffects.Has( this, prefab, "t3_mag_overfill" ) );
		ApplyClipTech( wep.Secondary, $"{prefab}|clip2", clipFactor, clipFlat, siege );

		// ⚠️ LONG BARREL IS A FLOOR ON A FLOAT FIELD, so it gets its own helper rather
		// than riding the int clip path — and `ifAbsent: 0f` again, because 0 is the
		// neutral value for a MAX just as 1 is for a multiply.
		float falloffFloor = TechEffects.Factor( this, prefab, "t2_falloff", 0f );

		// ⛔ BOAT TAIL RIDES THE SAME HELPER, NOT A SECOND ONE — and `ifAbsent: 0f`
		// because its 0.5 is ADDED. A helper of its own would have to read back what the
		// floor helper just wrote, and that read-modify-write is precisely what compounds
		// across equips: the floor is idempotent and survives it, an add is not.
		// ⚠️ `ifAbsent: 0f` — 0 means "the node is absent", which is exactly what
		// ApplyFalloffTech's branch tests. The default 1 would read as "target 1.0" and
		// flatten every weapon's falloff to nothing on all 31 guns.
		// ⛔ BOAT TAIL WAS REMOVED, so nothing ever sets this any more. It stays as a named
		// zero rather than being threaded out of `ApplyFalloffTech`, because that method's branch
		// already treats 0 as "no node" and a future range node will want the same seam. Long
		// Barrel now removes falloff outright, which is what made Boat Tail redundant: a tier-2
		// node fully containing a tier-3 one.
		const float falloffTarget = 0f;

		// ⛔ `ifAbsent: 0f` — SLUG LOADER'S 1.05 IS A PER-PELLET STEP, NOT A MULTIPLY.
		// Factor's default of 1 would read here as "owned, with no bonus", and every one of
		// the 31 weapons would collapse to a single bullet for free. 0 is the only value
		// that cannot be mistaken for a real step, which is why the whole node is gated on
		// `> 0f` rather than on a separate `Has`.
		float slugStep = TechEffects.Factor( this, prefab, "t5_slug", 0f );

		// ⛔ SLUG LOADER'S x3 RANGE GOES THROUGH ApplyFalloffTech, NOT BESIDE IT. That
		// method writes FalloffStart and FalloffEnd ABSOLUTELY from the remembered authored
		// values, so a second write here would either be overwritten by it or overwrite it
		// — and Boat Tail already remaps the same two fields. One helper owning both means
		// the pair composes: with Boat Tail the ramp still starts at contact and now
		// completes three times further out.
		// ⛔ RAILGUN'S "NO FALLOFF" GOES THROUGH THE SAME HELPER TOO, for exactly the reason
		// the block above gives for Slug Loader: that method writes both band distances
		// absolutely from the remembered authored values, so a separate write here would be
		// overwritten by it on the next equip — or overwrite it, which is worse because the
		// symptom appears one weapon switch later.
		bool noFalloff = TechEffects.Has( this, prefab, "t5_railgun" )
			// ⚠️ Marksman Conversion's "no damage falloff" (2026-10-04).
			|| TechStats.Flag( this, prefab, "f.nofalloff" );

		// ⚠️ THE AUGMENTS' RANGE (`s.range`: CQB Barrel and Carbine Conversion half it, Choke doubles it) rides the
		// same `rangeMult` seam as Slug Loader's x3, so the one helper still writes both band distances.
		float rangeMult = TechStats.Mul( this, prefab, "s.range" );

		ApplyFalloffTech( wep.Primary, $"{prefab}|falloff", falloffFloor, falloffTarget,
			SlugRangeFor( wep.Primary, $"{prefab}|shot", slugStep ) * rangeMult, noFalloff );

		ApplyFalloffTech( wep.Secondary, $"{prefab}|falloff2", falloffFloor, falloffTarget,
			SlugRangeFor( wep.Secondary, $"{prefab}|shot2", slugStep ) * rangeMult, noFalloff );

		// ⚠️ OVERPENETRATOR IS A PLAIN MULTIPLY ON A FLOAT, so it needs no helper — but
		// it still goes through `TechBase`, because PenetrationDepth is a raw authored
		// field with no spare multiplier alongside it. `*=` on the live value would take
		// a 9.97 depth to 29.9, then 89.7, on the gun already in hand.
		//
		// ⚠️ Depth and NOT PenetrationDamageMult — see the catalogue note: depth decides
		// how many bodies a round crosses, which is the effect the node advertises.
		// ⚠️ A FLAT ADD NOW, NOT A MULTIPLIER. Penetration is a body count (`BodyDepth` is 1),
		// so the node's `Factor` of 4 means "+4 zombies" and rides the same `penBonus` term Double
		// Tap's Overpenetration already uses — one author for "extra bodies", augment and node as
		// terms rather than one scaling the other.
		float penFactor = 1f;

		// ⛔ RAILGUN AND RICOCHET ROUNDS WANT THE SAME EDIT, AND IT IS MADE ONCE. Both ask
		// for unlimited pierce, both mean `PenetrationDepth = 0` — which
		// `HitScanBulletInfo` documents as unlimited and enforces with two `penBudget > 0f`
		// guards that simply do not run at zero — and both need `Penetration` forced true.
		// Two nodes writing the same two fields is two chances for one to undo the other,
		// and the pair is pick-one in a real game but co-ownable in creative, which is where
		// they are tested.
		//
		// ⚠️ TEN BODIES IS THE HONEST MAXIMUM. The loop that spends the budget is
		// `for ( i < MaxPenetrations )` with `MaxPenetrations = 10`, and there is a second
		// copy of that constant in the physical-bullet path. Both node rows say "every body
		// in the line" rather than "infinite" for that reason.
		bool infinitePen = TechEffects.Has( this, prefab, "t5_railgun" )
			|| TechEffects.Has( this, prefab, "t5_ricochet" )
			// ⚠️ Anti-Materiel and Flashbang Rounds: "pierces every zombie in line" (2026-10-04).
			|| TechStats.Flag( this, prefab, "f.pierceall" );

		// ⛔ `TechBase` IS STILL READ ON BOTH BRANCHES, so the REAL authored depth is
		// captured even on the equip where the node overrides it. Skipping the read when the
		// override is owned would mean the first sighting of that prefab remembered nothing,
		// and a later `nz_tech_reset` would restore a 0 depth — a weapon permanently unable
		// to pierce, from a node that was supposed to give it unlimited pierce.
		// ⛔ DOUBLE TAP'S PIERCE PAIR IS RESOLVED HERE RATHER THAN IN `DtapAugments`, BECAUSE THIS
		// EXPRESSION IS THE FIELD'S ONLY AUTHOR. `PenetrationDepth` is ASSIGNED outright on every
		// deploy, so an augment that wrote it from its own file would be silently reverted by the
		// next weapon switch — the exact failure the `infinitePen` note above warns about for two
		// tech nodes. Same field, same rule: one author, one expression.
		//
		// ⚠️ ADDED AFTER THE MULTIPLY, NOT BEFORE IT. m2 promises "+2 zombies (any weapon)" — a flat
		// promise that must not be scaled by Overpenetrator, or owning both would advertise +2 and
		// deliver +6. Multiplying a flat bonus is how a node and an augment quietly conspire to
		// break a stated number.
		//
		// ⛔ AND IT IS SKIPPED ENTIRELY ON THE INFINITE BRANCH, which reads as backwards until you
		// remember that 0 MEANS UNLIMITED here. `0 + 88` is not "unlimited plus eight", it is a
		// finite 88 — so adding the bonus to a Railgun would TAKE AWAY its unlimited pierce. The
		// augment has nothing to give a weapon that already crosses every body in the line.
		// ⚠️ THE +8 BODIES MINOR. Both pierce resolvers share one scope name so the pair is
		// reported together.
		float penBonus;
		using ( NZombies.CpuScope.Measure( "dtap.pierce" ) )
			penBonus = DtapAugments.PierceDepthBonus( this )
				+ TechEffects.Factor( this, prefab, "t3_pierce", 0f )
				// ⚠️ the augments' "+N penetration" (AP Conversion +3, Heavy Barrel +3, Tungsten Belt +3…), bodies
				+ TechStats.Add( this, prefab, "s.pen+" );

		// ⚠️ m3 SETS THE FIELD RATHER THAN SCALING IT, so `TechBase` is here to REMEMBER the
		// authored 0.75 — not to stop a compound. Without the capture, dropping the augment would
		// leave the gun on 0.92 forever, which is a permanent upgrade from a temporary one.
		float penKeep;
		using ( NZombies.CpuScope.Measure( "dtap.pierce" ) )
			penKeep = DtapAugments.PierceDamageKeep( this );

		// ⚠️ OVERPENETRATOR'S FLOOR RIDES THE SAME TERM, TAKEN AS A MAX. Both the augment and
		// the node answer "how much damage survives a body", so one field with one author and the
		// stronger of the two winning — rather than a second write that would depend on which ran
		// last. Every weapon authors 0.75, so the node's 1 means a pierced line takes full damage
		// all the way down.
		penKeep = MathF.Max( penKeep, TechEffects.Mag( this, prefab, "t3_pierce", "pendmg", 0f ) );

		// ⚠️ SHREDDER AND TUNGSTEN BELT ("no damage lost through bodies") ARE THE SAME FLOOR OF 1, and Collateral
		// ("each zombie passed through adds +25%") is a keep ABOVE 1, so the round grows through the line
		// (2026-10-04). Taken as the max with the rest, for the reason above.
		if ( TechStats.Flag( this, prefab, "f.nopenloss" ) ) penKeep = MathF.Max( penKeep, 1f );
		penKeep = MathF.Max( penKeep, TechEffects.Mag( this, prefab, "t5_sn_collateral", "per", 0f ) );

		if ( wep.Primary is not null )
		{
			var basePen = TechBase( $"{prefab}|pen", wep.Primary.PenetrationDepth );
			wep.Primary.PenetrationDepth = infinitePen ? 0f : basePen * penFactor + penBonus;

			var baseKeep = TechBase( $"{prefab}|penkeep", wep.Primary.PenetrationDamageMult );
			wep.Primary.PenetrationDamageMult = penKeep > 0f ? penKeep : baseKeep;
		}

		if ( wep.Secondary is not null )
		{
			var basePen2 = TechBase( $"{prefab}|pen2", wep.Secondary.PenetrationDepth );
			wep.Secondary.PenetrationDepth = infinitePen ? 0f : basePen2 * penFactor + penBonus;

			var baseKeep2 = TechBase( $"{prefab}|penkeep2", wep.Secondary.PenetrationDamageMult );
			wep.Secondary.PenetrationDamageMult = penKeep > 0f ? penKeep : baseKeep2;
		}

		// ── TIER 4 ───────────────────────────────────────────────────────────────
		//
		// ⛔ TECH DAMAGE SCALES `Damage` ITSELF AND MUST NOT TOUCH EITHER MULTIPLIER FIELD.
		// `DamageMultiplier` is Pack-a-Punch's and is ASSIGNED per equip, so anything written
		// into it here is discarded on the next deploy. (It no longer gates the packed
		// presentation either — `ShootInfo.IsPacked` does.) `RarityMultiplier` is a SEPARATE field for
		// exactly that reason, and its own note says so. Six tier-4 nodes scale damage, so
		// folding them into either field would give an unpacked gun the whole Pack-a-Punch
		// presentation the first time anybody bought Tuned Action.
		//
		// ⚠️ AND A THIRD MULTIPLIER FIELD IS NOT NEEDED. `Damage` is a raw authored field
		// with no spare multiplier beside it, which is precisely the case `_techBase` was
		// built for — PenetrationDepth directly above is the same shape, and ClipSize and
		// MaxReserve are too. `ShootInfo.DamageFor` computes `Damage x pap x rarity`, so
		// scaling the base composes with both in the same chain a third field would have
		// joined, with nothing new to keep in step.
		float dmgFactor = TechEffects.Factor( this, prefab, "t4_tuned" )
			* TechEffects.Factor( this, prefab, "t4_allround" )
			* TechEffects.Factor( this, prefab, "t4_solidslug" )
			* TechEffects.Factor( this, prefab, "t4_overpressure" )
			* (TechEffects.Has( this, prefab, "t4_scatter" )
				? WeaponTech.MagOf( "t4_scatter", "dmg", 0.4f )
				: 1f);

		// ── TIER 5 ───────────────────────────────────────────────────────────────
		//
		// ⛔ EIGHT MORE DAMAGE NODES INTO THE SAME PRODUCT, AND NOT ONE NEW FIELD. The
		// reasoning above holds unchanged at tier 5: `DamageMultiplier` is Pack-a-Punch's
		// tell and `RarityMultiplier` is rarity's, so a capstone folded into either would
		// hand an unpacked gun the whole Pack-a-Punch presentation. `Damage` is the only
		// field these belong on, and `_techBase` is what keeps the write absolute across the
		// five call sites that re-run this method.
		//
		// ⚠️ SIX READ `Factor` AND TWO READ `Mag`, and which is which is decided by what the
		// node's NAME is about: Emplacement, Bolt Gun, Bull Barrel, Explosive Rounds, Railgun
		// and Adrenaline Rounds all spend `Factor` on their damage, while Ten-Round Burst
		// spends it on x3 fire rate and Overclocked on x1.5, so their damage penalties are
		// named magnitudes. Reading `Factor` for those two would turn -33% into +200%.
		//
		// ⚠️ CHIMERA IS DELIBERATELY ABSENT. It is not a factor on anything — it substitutes
		// the authored BASE inside `TechBase`, so it composes with every term here for free.
		// A factor of its own would be applied twice.
		//
		// ⚠️ A PRODUCT, NOT A BRANCH, for the reason the magazine block above gives:
		// `WeaponTech.Unlimited` lifts the pick-one limit in creative, which is exactly where
		// these are tested, so "only one can be owned" is false where it matters most.
		// ⚠️ BODY SHOT'S x1.5 IS A DAMAGE NODE NOW, not two zone floors in Health. See
		// its catalogue note; the head half stays in Health because suppressing the head
		// bonus is not something a damage multiplier can express.
		dmgFactor *= TechEffects.Factor( this, prefab, "t4_bodyshot" );

		// ⚠️ HEAVY MACHINE'S x3 IS ITS `Factor`, NOT A `Mag`, WHICH IS THE OPPOSITE OF WHAT
		// LAST RESORT NEEDED. That node spent its Factor on a clip multiplier and had to carry its
		// headline damage in the Mag table; this one has no clip effect, so the primary number is
		// free for the number the node is actually about.
		dmgFactor *= TechEffects.Factor( this, prefab, "t4_heavy" );

		// ⚠️ AUTOLOADER AND DOUBLE FEED BOTH LAND ON THE DAMAGE. Autoloader's is the pellet
		// count it took away — one pellet carrying half the spread's worth — and Double Feed's is
		// its headline x1.8, which is its `Factor` because the node has no other primary number.
		dmgFactor *= autoload * TechEffects.Factor( this, prefab, "t4_doublefeed" );

		dmgFactor *= TechEffects.Factor( this, prefab, "t4_emplacement" )
			* TechEffects.Factor( this, prefab, "t4_boltgun" )
			* TechEffects.Factor( this, prefab, "t4_bullbarrel" )
			* TechEffects.Factor( this, prefab, "t5_explosive" )
			* TechEffects.Factor( this, prefab, "t5_railgun" )
			// ⛔ ADRENALINE ROUNDS' `Factor` IS NO LONGER A DAMAGE MULTIPLIER (2026-10-04): it is the +1% per stacked hit, read at
			// the shot (`AdrenalineRounds.DamageScale`). Left here it would cut the gun to a hundredth.
			* TechEffects.Mag( this, prefab, "t4_tenburst", "dmg" )
			* TechEffects.Mag( this, prefab, "t4_overclock", "dmg" )
			// ⚠️ COUNTERWEIGHT'S -10% IS A `Mag`, because its `Factor` is the recoil zero. Full
			// Auto's +10% is its `Factor`, because that node's headline number IS the damage and its
			// rate lives in the Mag table — the two are opposite for the same reason.
			* TechEffects.Mag( this, prefab, "t4_counterweight", "dmg" )
			* TechEffects.Factor( this, prefab, "t4_fullauto" )
			* TechEffects.Factor( this, prefab, "t3_damage" )
			// ⛔ THE PER-CLASS AUGMENTS' DAMAGE (2026-10-04): every owned `s.dmg`, once, through `TechStats`.
			* TechStats.Mul( this, prefab, "s.dmg" );

		// ⚠️ WRITTEN ONTO THE AUTHORED `RPM`, WHICH THE TWO TIER-2 RATE NODES DO NOT DO —
		// they scale inside GetRealRPM at READ time. That is deliberate on both sides: a
		// spawn-time write is what the stat panel and `nz_tech_live`'s `authored` line can
		// see, and the read-time nodes then compose on top of it. Note (c) in the report
		// covers what a gun owning several of them ends up at.
		float rpmFactor = TechEffects.Factor( this, prefab, "t4_allround" )
			* (TechEffects.Has( this, prefab, "t4_tuned" )
				? WeaponTech.MagOf( "t4_tuned", "rpm", 1.10f )
				: 1f)
			// ⚠️ NOT `BoundOf`. Scattergun's free `Bound` is spent on its x0.4 damage
			// above; see the constants block for why borrowing it here would be a bug.
			* (TechEffects.Has( this, prefab, "t4_scatter" )
				? WeaponTech.MagOf( "t4_scatter", "rpm", 1f )
				: 1f);

		// ⛔ THREE OF THE FOUR TIER-5 FIRE-RATE NODES LIVE HERE. Ten-Round Burst and
		// Overclocked spend `Factor` on their rate, so they read it; Railgun spends `Factor`
		// on its x1.5 damage, so its x0.3 is a named magnitude — and reading `Factor` there
		// would make the slowest gun in the tier 50% FASTER while the node's own row promised
		// the opposite.
		//
		// ⛔ BOLT GUN'S x0.2 IS DELIBERATELY NOT HERE, AND ITS ABSENCE IS THE DECISION. The
		// authored Micro-Burst pairing is "two rounds at the weapon's NORMAL rate, then five
		// times the normal shot interval" — a stored RPM makes BOTH rounds slow and cannot
		// express it. It belongs in `Weapon.GetRealRPM`, which can read `burstCount`. The
		// catalogue's Lever for that node says `GetRealRPM` for this reason; do not "finish"
		// the node by adding a term here.
		rpmFactor *= TechEffects.Factor( this, prefab, "t4_tenburst" )
			* TechEffects.Factor( this, prefab, "t4_overclock" )
			* TechEffects.Mag( this, prefab, "t5_railgun", "rpm" )
			// ⚠️ `Mag`, NOT `Factor`, FOR BOTH OF THESE, and the block above says why: their
			// `Factor` is the node's headline number — Ricochet's is its BOUNCE COUNT (10) and Bull
			// Barrel's is its damage (2) — so `Factor` here would set a weapon to ten times or twice
			// its fire rate. Every tier-5 site except damage is in that position.
			* TechEffects.Mag( this, prefab, "t5_ricochet", "rpm" )
			* TechEffects.Mag( this, prefab, "t4_bullbarrel", "rpm" )
			// ⚠️ AND HEAVY MACHINE'S HALVING, which is a `Mag` for the mirror-image reason: its
			// `Factor` is the x3 DAMAGE, so reading `Factor` here would treble the fire rate of a
			// node whose entire point is halving it.
			* TechEffects.Mag( this, prefab, "t4_heavy", "rpm" )
			* TechEffects.Mag( this, prefab, "t4_fullauto", "rpm" )
			// ⚠️ DOUBLE FEED'S HALVING IS A `Mag` FOR THE USUAL REASON: its `Factor` is the x1.8
			// damage, so reading `Factor` here would make a node whose point is firing slower fire
			// nearly twice as fast.
			* TechEffects.Mag( this, prefab, "t4_doublefeed", "rpm" );

		// ⚠️ AND AUTOLOADER'S PELLET COUNT BECOMES RATE TOO — the third of the three factors it
		// feeds. A 16-pellet KS23 fires eight times as often, one pellet at a time.
		rpmFactor *= autoload;

		// ⛔ THE PER-CLASS AUGMENTS' FIRE RATE (2026-10-04): every owned `s.rpm`, once. The flat ones (`s.rpm+`: Fast
		// Cycle +100, Scout +1000) are READ-time, in `GetRealRPM`, beside Match Trigger and Hair Trigger.
		rpmFactor *= TechStats.Mul( this, prefab, "s.rpm" );

		// ⚠️ THE AUGMENTS' PELLETS AND BOTTOMLESS'S RESERVE (2026-10-04), resolved once for both fire modes.
		int pelletsSet = (int)MathF.Round( TechStats.Set( this, prefab, "s.pellets=", 0f ) );
		int pelletsAdd = (int)MathF.Round( TechStats.Add( this, prefab, "s.pellets+" ) );
		bool bottomless = TechStats.Flag( this, prefab, "f.infreserve" );

		ApplyShotTech( wep.Primary, $"{prefab}|shot", dmgFactor, rpmFactor,
			TechEffects.Factor( this, prefab, "t4_scatter" ), slugStep,
			TechEffects.Has( this, prefab, "t4_solidslug" ),
			// ⚠️ BOTTOMLESS (handgun tier 4) IS THE ONE SOURCE OF INFINITE RESERVE (2026-10-04); Last Resort was
			// the last before it.
			bottomless,
			autoloadOn,
			TechEffects.Mag( this, prefab, "t4_doublefeed", "ammo" ),
			pelletsAdd, pelletsSet );

		ApplyShotTech( wep.Secondary, $"{prefab}|shot2", dmgFactor, rpmFactor,
			TechEffects.Factor( this, prefab, "t4_scatter" ), slugStep,
			TechEffects.Has( this, prefab, "t4_solidslug" ),
			bottomless,
			// ⚠️ THE SECONDARY IS GATED ON THE PRIMARY'S PELLET COUNT, which is what `autoload`
			// measured. An underbarrel shotgun on a rifle is not what the node converted, and giving
			// it a free single-pellet rewrite would be a second, unadvertised effect.
			autoloadOn,
			TechEffects.Mag( this, prefab, "t4_doublefeed", "ammo" ),
			pelletsAdd, pelletsSet );

		// ⛔ BLOOD PRICE (revolver tier 5, 2026-10-04): NO AMMO AT ALL — a bottomless magazine, kept full, and every shot paid in
		// health instead (`Weapon.ClassTechOnShot`). After `ApplyShotTech`, which restores the authored setting on every equip,
		// so a gun without the node gets its magazine back.
		if ( wep.Primary is not null && TechEffects.Has( this, prefab, "t5_rv_bloodprice" ) )
		{
			wep.Primary.InfiniteAmmo = SWB.Base.InfiniteAmmoType.clip;
			wep.Primary.Ammo = Math.Max( wep.Primary.Ammo, wep.Primary.ClipSize );
		}

		// ⛔ THE RECOIL/ACCURACY PAIR IS WRITTEN EVEN THOUGH NO NODE SCALES IT, and that is
		// what makes Chimera's eighth axis reachable at all. Every other axis rides a field
		// some existing helper already rebuilds from `TechBase`; RecoilUp and
		// SpreadAddHipFire have no spawn-time writer, because Recoil Control, Overpressure,
		// Point Shooting and Bull Barrel are all READ-time multiplies. With no Chimera roll
		// these two lines write the authored value back over itself and cost nothing.
		ApplyRecoilTech( wep.Primary, $"{prefab}|shot" );
		ApplyRecoilTech( wep.Secondary, $"{prefab}|shot2" );

		// ⛔ AFTER `ApplyShotTech`, NOT BESIDE THE DEPTH WRITE ABOVE, AND THE ORDER IS THE
		// WHOLE POINT. Solid Slug sets `Penetration = false` INSIDE that method, so a
		// railgun or ricochet weapon that also owns it would arrive with an unlimited budget
		// on a bool that had just been turned off — and `HitScanBulletInfo` only records a
		// pierced body `if ( shootInfo.Penetration )`, so the node would do nothing at all.
		// Written last, so tier 5 wins the disagreement it is a tier above.
		if ( infinitePen )
		{
			if ( wep.Primary is not null ) wep.Primary.Penetration = true;
			if ( wep.Secondary is not null ) wep.Secondary.Penetration = true;
		}

		// ⚠️ THE RELOAD NODES BOTH SLOW THE GUN DOWN, so their factors are >1 — `ReloadTime`
		// is a DURATION. Fast Hands points the other way and is applied elsewhere, at read
		// time in Weapon.Reload, where it DIVIDES a speed; the two compose without either
		// needing to know about the other.
		float reloadFactor =
			(TechEffects.Has( this, prefab, "t4_drum" )
				? WeaponTech.MagOf( "t4_drum", "reload", 2f )
				: 1f)
			// ⚠️ THE AUGMENTS' RELOAD DURATIONS (2026-10-04), smaller is faster: Speed Loader x0.625, Box Magazine
			// x0.714, Moon Clips x0.667, Ammo Box x1.5.
			* TechStats.Mul( this, prefab, "s.reload" );

		// ⚠️ QUICK SHELLS SCALES ONLY THE PER-ROUND INSERT ("each round loads 100% faster"), not the start or the end.
		float shellFactor = reloadFactor * TechStats.Mul( this, prefab, "s.shell" );

		// ⛔ FIVE FIELDS, BECAUSE `ReloadTime` ALONE IS DEAD ON THE SHELL-RELOADING GUNS.
		// StartReload takes a time OVERRIDE on every per-shell insert (read OnShellReload
		// and OnShellReloadFinish), so the HS10, KS23 and SPAS12 never read ReloadTime at
		// all — and those are exactly the weapons the shotgun-flavoured tier-4 nodes are
		// aimed at. Scaling only the magazine field would have left Drum Magazine's penalty
		// silently absent on the guns it matters most on.
		wep.ReloadTime = ReloadTech( $"{prefab}|reload", wep.ReloadTime, reloadFactor );
		wep.ReloadEmptyTime = ReloadTech( $"{prefab}|reloadempty", wep.ReloadEmptyTime, reloadFactor );
		wep.ShellReloadStartTime = ReloadTech( $"{prefab}|shellstart", wep.ShellReloadStartTime, reloadFactor );
		wep.ShellReloadInsertTime = ReloadTech( $"{prefab}|shellinsert", wep.ShellReloadInsertTime, shellFactor );
		wep.ShellReloadEndTime = ReloadTech( $"{prefab}|shellend", wep.ShellReloadEndTime, reloadFactor );

		// ⛔ `FindMode.EverythingInSelf`, LIKE THE WeaponSource LOOKUP BELOW — a
		// holstered weapon is a DISABLED one and the default Get skips disabled
		// components, so PushStoredUpgrades would have silently left the gun on your
		// back at its base reserve.
		// ⚠️ NZAmmo, not NZWeapon.ReserveAmmo: none of the 31 weapon prefabs carry an
		// NZWeapon (checked — the only scene that does is countdown.scene), so the
		// reserve every SWB weapon actually reloads from is this one.
		var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
		if ( ammo is not null )
		{
			// ⛔ DERIVED FROM THE MAGAZINE, NOT READ FROM THE PREFAB. `nzWeps:GetReserveMags`
			// (sv_ammo.lua) is a tiered step function on the clip — see NZombies.ReserveAmmo. The
			// authored `MaxReserve` is no longer an input at all, which is what makes this
			// self-correcting: changing a weapon's ClipSize now moves its reserve with it, instead
			// of leaving a hand-written number behind. The M14 is why — its clip went 20 -> 8 while
			// its authored 180 stayed put, i.e. twenty-two and a half magazines.
			//
			// ⚠️ THE LIVE CLIP, AFTER `ApplyClipTech` (:1288). Upstream reads the live clip too
			// (`nzWeps:GetWepClipSize` on the entity, with its own note that "its clipsize will
			// already have changed from PaP"), so Extended Mag raises the reserve as well. That also
			// means a clip crossing a tier boundary can LOWER it — documented on ReserveAmmo.
			//
			// ⛔ NO LONGER THROUGH `TechBase`. That existed to remember an authored value so the
			// multiplier below composed on something stable; a pure function of the live clip is
			// already stable and idempotent, so a remembered copy could only ever go stale. Chimera
			// has no reserve axis to lose either — its axes are clip/damage/rpm/mode/range/recoil/
			// reload/pellets — and it substitutes the CLIP, which now flows through to here for free.
			// ⛔ THE WEAPON YOU WERE GIVEN GETS THREE MAGAZINES, NOT TEN. `MagsFor` reads only the
			// clip, so the 8-round starting pistol landed in the smallest-clip tier and came out
			// with the most generous reserve in the game — 80 rounds on the gun the whole economy
			// assumes you are trying to replace.
			//
			// ⚠️ `LoadoutWeapon`, NOT `StartingWeapon`, AND THAT DISTINCTION IS LOAD-BEARING.
			// `StartingWeapon` tracks what is IN HAND and is overwritten by the first pickup — so
			// testing it here would starve whatever you most recently bought and let the pistol keep
			// its ten mags, which is precisely backwards. `LoadoutWeapon` is captured in `OnStart`
			// before any pickup can move it; its own header says that is what it exists for.
			var isLoadout = !string.IsNullOrWhiteSpace( LoadoutWeapon )
				&& string.Equals( prefab, LoadoutWeapon, StringComparison.OrdinalIgnoreCase );

			int baseReserve = NZombies.ReserveAmmo.BaseFor( wep.Primary?.ClipSize ?? 0, isLoadout );
			// ⚠️ AMMO POUCH IS NOT A TERM HERE ANY MORE. Its +10 went into the magazine (2026-09-27),
			// and the magazine reaches this line through `BaseFor`, which counts the reserve in
			// magazines — so the pouch still moves the reserve, the way every clip node does.
			int reserve = TechScaled( baseReserve, TechEffects.Factor( this, prefab, "t1_reserve" )
				// ⚠️ Ammo Box's +50% reserve (LMG tier 4, 2026-10-04), the one `s.reserve` so far.
				* TechStats.Mul( this, prefab, "s.reserve" ) )
				// ⚠️ AND THE FLAT ROUNDS (`s.reserve+`: Side Pouch +30, magazine 1–8 tier 1, 2026-10-04), AFTER the percentages so
				// Deep Pockets' +20% never scales them: Extra Rounds' rule for the magazine (`ApplyClipTech`).
				+ (int)MathF.Round( TechStats.Add( this, prefab, "s.reserve+" ) );

			// ⛔ THE LIVE RESERVE MOVES ONLY WHILE IT IS UNSPENT. Reserve is consumable
			// state and this runs on every equip, so an unconditional write would refill
			// your pouches every time the gun was drawn. A fresh clone is authored full
			// (Reserve == MaxReserve on all 300 prefabs — re-verified after the roster grew
			// from 31), which is what makes a gun off the wall arrive holding the derived
			// reserve rather than needing a Max Ammo first.
			// ⛔ MULE KICK'S MAGAZINES ARE A TERM HERE, NOT A SECOND WRITER, AND THAT IS THE
			// FIX FOR A REAL BUG. `MuleKickAugments.ApplyReserve` used to raise `MaxReserve`
			// itself, AFTER this method had written it - so the two took turns and whichever ran
			// last won. Every later call to `PushStoredUpgrades` (a Pack-a-Punch, an ammo
			// purchase, or simply drawing the gun) wrote the tech figure back WITHOUT the
			// augment and Bandolier's four magazines vanished. Reported as "buying ammo or
			// pack-a-punching sets the ammo back to the default".
			//
			// ⚠ THE SAME SHAPE m1 WIDE MAGS ALREADY USES: that augment does not write
			// `ClipSize` either, it feeds `ApplyClipTech`, whose own note calls itself "the ONLY
			// thing that touches ClipSize". One author per field, augments as terms.
			//
			// ⚠ ADDED AFTER THE TECH SCALE, not before. `t1_reserve` is a multiplier on what
			// the WEAPON carries; the bandolier is a flat number of magazines the PLAYER carries.
			// Folding it in before the multiply would let a tech node scale the augment too.
			//
			// ⚠️ m2 DEEP RESERVES IS A PERCENTAGE AND IS DELIBERATELY ON THE OTHER SIDE OF THAT
			// ARGUMENT. It reads the tech-scaled `reserve` precisely so a reserve node makes it
			// worth more — which is what "+10% reserve" means. The two augments now differ in kind,
			// so `BonusReserve` takes the reserve and the clip and answers in ROUNDS.
			var bonusReserve = NZombies.MuleKickAugments.BonusReserve(
				this, reserve, wep.Primary?.ClipSize ?? 0 );

			if ( bonusReserve > 0 )
				reserve += bonusReserve;

			// ⛔ THE TEST IS "IS IT UNSPENT", AND IT MUST BE ASKED AGAINST MaxReserve.
			//
			// This read `Reserve >= baseReserve` and that was wrong for 174 of the 300 prefabs.
			// Every prefab is authored FULL (Reserve == MaxReserve on all 300 — verified, not
			// assumed), so comparing against the RULE figure instead was really asking "did the
			// author happen to write a number at least as big as the rule?" — and for the 174
			// where they wrote less, the refill was skipped. The gun then arrived holding its
			// authored reserve while MaxReserve took the derived one: the CZ 75 at 144/160, the
			// Stoner at 270/360, the KAP-40 at 56/120. A Max Ammo sets Reserve = MaxReserve and
			// it suddenly reads correctly, which is exactly how this was reported — "the ammo
			// from a wall, box or Pack-a-Punch does not match what it should be when full, but
			// a max ammo gives the right amount".
			//
			// ⚠️ MaxReserve STILL CLAMPS DOWN, so the case the old line existed for is kept: an
			// over-authored gun is full at its own inflated figure, passes this test, and is
			// written down to the rule on its first equip. The M14's authored 180 against a rule
			// 80 still lands on 80.
			//
			// ⚠️ AND A PARTLY SPENT RESERVE IS STILL LEFT ALONE, which is the whole reason a
			// guard is here at all — this runs on EVERY equip, so an unconditional write would
			// refill your pouches each time the gun was drawn.
			if ( ammo.Reserve >= ammo.MaxReserve ) ammo.Reserve = reserve;

			// ⛔ AND CLAMPED AGAIN AGAINST THE NEW CAP. The line above only fires while the reserve
			// is unspent; a PARTLY spent one is deliberately left alone, and on a weapon whose cap
			// just dropped that can leave `Reserve` above `MaxReserve` — the state
			// `WallBuy.CanBuyAmmo` and `Pickup` both read as "already full" while the HUD shows
			// more rounds than the maximum beside it.
			// ⛔ SIEGE EMPTIES THE POCKETS, AND IT MUST HAPPEN AFTER EVERYTHING ABOVE RATHER THAN
			// SHORT-CIRCUITING IT. `ApplyClipTech` folded this very figure into the magazine using
			// `MagsFor` on the same clip `BaseFor` is handed here, so the block above is not dead
			// code — it is the half of the calculation that has to agree with the other half. Zero
			// it earlier and the two would drift the first time either rule changed.
			//
			// ⚠️ `Reserve` FOLLOWS `MaxReserve` DOWN through the clamp on the next line, so a
			// part-spent reserve is not stranded in the HUD on a weapon that cannot load it.
			if ( siege ) reserve = 0;

			ammo.MaxReserve = reserve;
			if ( ammo.Reserve > reserve ) ammo.Reserve = reserve;
		}

		// ⛔ THE NAME IS SET HERE, ON `DisplayName`, AND NOWHERE ELSE. Every consumer
		// reads that one field — the C stats panel, the HUD, the box's "Take X"
		// prompt — so writing it once updates all of them. Formatting the MK at each
		// display site instead would mean finding them all, and missing one is a
		// weapon that claims to be unpacked in exactly one place.
		// ⛔ REBUILT FROM THE REMEMBERED BASE, never from the current DisplayName.
		// Stripping the live value is correct arithmetic and still shipped
		// "M1911 MK2 MK2" — see WeaponSource.BaseName.
		var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf );
		var baseName = src is not null && !string.IsNullOrEmpty( src.BaseName )
			? src.BaseName
			: BaseName( wep.DisplayName );

		// ⛔ THE PACKED NAME REPLACES THE BASE, IT DOES NOT DECORATE IT — "Mustang MK1", not
		// "M1911 Mustang MK1". Upstream does the same, swapping PrintName outright
		// (`wall_buys/sharedwpws.lua:241`).
		// ⚠️ ONLY WHEN PACKED. `PapNames.For` answers unconditionally, so the level has to gate it
		// here or an unbought M1911 on the wall would already advertise itself as a Mustang.
		int papLevel = PapLevelFor( prefab );
		var shownBase = papLevel > 0 ? NZombies.PapNames.For( prefab, baseName ) : baseName;

		wep.DisplayName = PapName( shownBase, papLevel );
	}

	/// <summary>
	/// Extended Mag AND Extra Rounds on one ShootInfo, rebuilt from the remembered
	/// authored clip.
	///
	/// ⛔ THE PERCENTAGE APPLIES TO THE AUTHORED BASE AND THE FLAT ADD LANDS AFTER IT —
	/// `(base * 1.15) + 4`, so a 30-round mag is 38 and not the 39 that `(base + 4) *
	/// 1.15` gives. That order is what makes both catalogue rows true on every gun:
	/// Extended Mag is +15% of the AUTHORED clip whether or not Extra Rounds is owned,
	/// and Extra Rounds is four rounds rather than 4.6. Adding first would let the
	/// tier-1 node silently inflate the tier-2 one, and `nz_tech`'s printed "+4 rounds"
	/// would then be wrong on exactly the weapons that bought both.
	///
	/// ⚠️ THE LOADED MAGAZINE MOVES ONLY WHILE IT IS UNTOUCHED. Every prefab is
	/// authored with a full mag (Ammo == ClipSize on all 31), so without this a gun
	/// straight off the wall would arrive holding up to fifteen rounds fewer than the
	/// clip it advertises — which reads as a bug, not as a bonus. A PARTLY SPENT mag is
	/// left alone: this runs on every equip, and topping it up here would turn drawing
	/// the gun into a free reload.
	/// </summary>
	/// <param name="siege">
	/// `t5_siege` — fold the whole reserve into this magazine.
	///
	/// ⛔ IT IS A PARAMETER RATHER THAN A SECOND CALL, AND THE SECOND CALL WAS AN INFINITE AMMO
	/// EXPLOIT. The obvious shape — apply the clip normally, derive the reserve from it, then apply
	/// the clip again with the reserve as a flat add — runs the refill guard twice per equip: the
	/// first pass sees a part-spent 100 against an authored 30, calls the magazine full and writes
	/// it down to 30; the second sees 30 against 30, calls it full again and writes it up to 330. A
	/// player could refill by holstering and redrawing. One call, one guard.
	///
	/// ⚠️ AND THE MAGAZINE COUNT IS `ReserveAmmo.MagsFor`, the same rule the reserve block
	/// below uses, read off the POST-TECH clip exactly as that block reads it — so Extended Mag
	/// enlarges the belt through both terms, and the total is what the weapon would have carried.
	/// </param>
	void ApplyClipTech( SWB.Base.ShootInfo si, string key, float factor, float flat,
		bool siege = false, bool overfill = false )
	{
		if ( si is null ) return;

		int baseClip = TechBase( key, si.ClipSize );

		// ⛔ -1 IS SWB'S "NO MAGAZINE" SENTINEL, not a one-round clip — `HasAmmo` and
		// `StartReload` both branch on it to mean "feeds straight from the reserve".
		// Scaling it would produce a 0 or 1 round clip on a weapon that has none.
		//
		// ⛔ AND THE FLAT ADD IS BEHIND THE SAME GUARD, deliberately: +4 on the sentinel
		// would read as 3, a three-round magazine bolted onto a weapon that has no
		// magazine at all — and unlike a scaled -1 it looks like a perfectly ordinary
		// clip size, so nothing downstream could tell it was garbage.
		if ( baseClip <= 0 ) return;

		// ⛔ BOTH TERMS DERIVED FROM `baseClip`, NEVER FROM si.ClipSize. This runs on
		// every equip and PushStoredUpgrades fires from four other places, so a `+=` on
		// the live field would walk a 30-round mag 34 -> 38 -> 42 with nothing to stop it.
		// ⛔ MULE KICK'S m1 "WIDE MAGS" LANDS HERE, on the ONE method this file documents as
		// "the ONLY thing that touches ClipSize". It rebuilds from `TechBase`, carries the -1
		// no-magazine sentinel guard above, and runs on every equip — so an augment writing
		// `si.ClipSize` from anywhere else would both duplicate the authorship and be
		// overwritten on the next draw.
		//
		// ⚠️ MULTIPLIED INTO THE SAME EXPRESSION rather than applied after, so it composes
		// with the tier-1 scale and the tier-2 flat add in the order this method's header
		// already argues for — and cannot re-round a value that was already rounded.
		var mule = MuleKickAugments.ClipMultiplier( this );

		int clip = (int)MathF.Round( TechScaled( baseClip, factor ) * mule )
			+ (int)MathF.Round( flat );

		// ⚠️ NEVER BELOW ONE (2026-10-04): the augments' flat cuts (Short Belt -20, Moon Clips -1) can reach a small magazine.
		clip = Math.Max( 1, clip );

		// ⚠️ SIEGE LAST, ON THE FINISHED FIGURE, so the reserve it folds in is the reserve
		// this weapon would actually have had — magazines are counted off the tech-scaled clip,
		// which is what `ReserveAmmo.BaseFor` is handed below.
		if ( siege ) clip *= NZombies.ReserveAmmo.MagsFor( clip ) + 1;

		// ⛔ SIEGE ASKS THE LIVE MAGAZINE, EVERYTHING ELSE ASKS THE AUTHORED ONE, AND THE
		// DIFFERENCE IS NOT COSMETIC. The guard means "was it full"; on an ordinary weapon the
		// authored size is the right yardstick because the live one is what we are about to write.
		// On a siege weapon the live size is ten times the authored one, so `Ammo >= baseClip`
		// reads a part-spent 100-round belt as FULL and tops it back up on every single draw.
		// ⚠️ OVERFILL'S MAGAZINE PAST FULL KEEPS ITS ROUNDS (9–20 rounds, tier 3, 2026-10-04): the reserve paid for them, and an
		// Arsenal purchase on any gun must not cut them back to one magazine.
		if ( si.Ammo >= (siege ? si.ClipSize : baseClip) ) si.Ammo = overfill ? Math.Max( clip, si.Ammo ) : clip;
		si.ClipSize = clip;
	}

	/// <summary>
	/// BOTH falloff nodes on one ShootInfo — Long Barrel raises FalloffMultiplier TO
	/// <paramref name="floor"/>, Boat Tail then ADDS <paramref name="add"/> on top —
	/// rebuilt from the remembered authored value.
	///
	/// ⚠️ NEITHER A MULTIPLY NOR AN ADD. The catalogue stores 0.75 as the TARGET value,
	/// so it is read through TechEffects like every other magnitude but used as a bound:
	/// `MathF.Max( authored, 0.75f )`. The asymmetry that produces is the design — a
	/// weapon already at or above 0.75 gains nothing, and the closer-ranged the gun the
	/// more it gains.
	///
	/// ⚠️ 0 IS THE NEUTRAL `ifAbsent` FOR BOTH — for a MAX because FalloffMultiplier is
	/// a positive fraction so maxing against 0 restores the authored value, and for an
	/// ADD because adding 0 is nothing. That is why there is no "does the player own it"
	/// branch here: the write is unconditional and the field is always a pure function of
	/// the remembered base rather than of whatever it held last equip.
	///
	/// ⛔ ONE EXPRESSION OVER THE REMEMBERED BASE BECAUSE BOAT TAIL IS NOT IDEMPOTENT.
	/// A floor survives being applied twice by accident; an add does not, and this method
	/// runs on every equip. A second helper reading back what this one wrote — or a `+=`
	/// on the live field — would climb 0.73, 1.23, 1.25 and stick at the ceiling on a
	/// weapon that only bought Boat Tail.
	///
	/// ⚠️ THE FLOOR AND THE ADD COMPOSE, WHICH IS THE DESIGN, and the order matters:
	/// floor first, then add. Long Barrel alone lands on 0.75; Boat Tail alone takes the
	/// HS10's authored 0.23 to 0.73; the two together reach exactly the 1.25 ceiling,
	/// mirroring that 0.75 floor. Setting 1.25 outright would subsume Long Barrel and
	/// make buying it first a wasted tier-2 pick — the exact fault the catalogue records
	/// the cut "no damage falloff" node having had.
	///
	/// ⛔ 1.25 IS A SAFETY CEILING, NOT ONLY A BALANCE ONE, AND IT IS A LITERAL HERE
	/// BECAUSE THE CATALOGUE CANNOT HOLD IT. `WeaponTech.Node` has a single `Factor`
	/// field, which Boat Tail spends on its 0.5 add, so there is nowhere to put a second
	/// number; the node's `Lever` string is what documents it. And it has to exist:
	/// ShootInfo.DamageFor does `MathX.Lerp( 1f, FalloffMultiplier, t )` with no upper
	/// bound of its own (read it), so any value above 1 means a gun that hits HARDER the
	/// further away the target is, without limit.
	/// </summary>
	/// <param name="rangeMult">
	/// Slug Loader's x3 on BOTH band distances, 1 when it is not owned or the weapon has
	/// no pellets to convert.
	///
	/// ⚠️ SCALES THE BAND, NOT THE MULTIPLIER. Slug Loader is about turning a shotgun into
	/// a rifle, and a shotgun's problem is that its falloff band ENDS close in — a bigger
	/// FalloffMultiplier would only raise the floor it lands on, leaving the drop-off at
	/// the same distance. Stretching start and end together moves where the damage curve
	/// happens without changing its shape.
	/// </param>
	/// <param name="noFalloff">
	/// Railgun's capstone: the damage curve is GONE, not flattened.
	///
	/// ⛔ THE BAND IS ZEROED, AND THE OBVIOUS ALTERNATIVE IS A TRAP. Passing a floor of 1
	/// instead would COMPOSE with Boat Tail's ceiling and land on 1.25 — "no falloff"
	/// silently becoming a range BONUS ramped across the band, which is a different node.
	/// `ShootInfo.DamageFor` gates the whole lerp behind `FalloffEnd > FalloffStart`, so
	/// 0/0 skips it outright and the field's own note records 0 as meaning exactly that.
	///
	/// ⚠️ IT THEREFORE BEATS BOAT TAIL RATHER THAN STACKING WITH IT — a weapon owning both
	/// gets the flat curve, not the rising one. The catalogue row says so; a capstone
	/// overriding a tier-3 pick is the ladder working, and the reverse would be a 25% the
	/// player paid for and cannot see.
	/// </param>
	void ApplyFalloffTech( SWB.Base.ShootInfo si, string key, float floor, float target,
		float rangeMult, bool noFalloff )
	{
		if ( si is null ) return;

		// ⛔ BOAT TAIL ALSO HAS TO MOVE THE BAND, OR THE MULTIPLIER IS UNREACHABLE. This
		// was reported as "damage does not increase with range" and the multiplier was
		// never the problem — `ShootInfo.DamageFor` gates the whole lerp behind
		// `distance > FalloffStart`, and FalloffStart is authored at 546-2340 units, i.e.
		// FOURTEEN TO FIFTY-NINE METRES. Below that the bonus is exactly zero, and the
		// full +25% only arrives at FalloffEnd, 48-149m. Zombies are fought at a fraction
		// of that, so the node was correct arithmetic nobody could ever observe.
		//
		// So when the add is owned the band is remapped: the ramp starts at CONTACT and
		// completes where falloff used to BEGIN. The range that used to cost you damage
		// is now the range that pays you, which is the node as described.
		//
		// ⚠️ IT SCALES PER WEAPON RATHER THAN USING ONE DISTANCE, and that is the point:
		// the PM63 reaches full bonus at 13.9m and the AWM at 59.4m, because those are
		// each weapon's own authored close-range band. A fixed number would make the node
		// generous on SMGs and pointless on rifles, or the reverse.
		//
		// ⚠️ `FalloffEnd > FalloffStart` still holds — 0 is below every authored start —
		// so DamageFor's guard is satisfied. Beyond the new end `t` clamps at 1 and the
		// damage simply stays at the ceiling, which is the intended "no falloff, plus a
		// bonus" shape for a node that replaced Full Power.
		//
		// ⚠️ Both writes read TechBase, so they are absolute and survive the repeated
		// PushStoredUpgrades that every equip triggers.
		var authoredStart = TechBase( $"{key}|start", si.FalloffStart );
		var authoredEnd = TechBase( $"{key}|end", si.FalloffEnd );

		// ⚠️ THE SLUG STRETCH RIDES BOTH BRANCHES, so it composes with Boat Tail rather
		// than being cancelled by it: with both owned the ramp still begins at contact and
		// now finishes three times further out. Multiplying only the `else` branch would
		// make a slug that also bought Boat Tail shorter-ranged than one that did not.
		// ⚠️ FIRST, SO IT WINS. Both distances zero means DamageFor's guard never fires and
		// there is no curve to compose with — see the `noFalloff` parameter note.
		if ( noFalloff )
		{
			si.FalloffStart = 0f;
			si.FalloffEnd = 0f;
		}
		else if ( target > 0f && authoredStart > 0f )
		{
			si.FalloffStart = 0f;
			si.FalloffEnd = authoredStart * rangeMult;
		}
		else
		{
			si.FalloffStart = authoredStart * rangeMult;
			si.FalloffEnd = authoredEnd * rangeMult;
		}

		// ⛔ BOAT TAIL ASSIGNS, IT DOES NOT ADD, AND THAT IS THE WHOLE FIX. Adding its
		// factor to the authored value left twelve of the 31 weapons still LOSING damage at
		// range, because the authored value IS the decrease: MAC11 0.36 + 0.5 = 0.86, HS10
		// 0.23 + 0.5 = 0.73. Reported from play as "damage is still decreasing with range",
		// and the reporter's own diagnosis was right — the rise and the fall were fighting.
		//
		// Assigning discards the authored falloff outright, so `DamageFor` lerps from 1.0 at
		// contact to the target at the band end. Nothing decreases anywhere.
		//
		// ⚠️ LONG BARREL'S FLOOR APPLIES ONLY WHEN BOAT TAIL IS ABSENT, which is the only
		// case where it can matter — a Boat Tail owner is above any floor by definition.
		//
		// ⚠️ `Min` AGAINST `Bound` IS A SAFETY RAIL, NOT BALANCE: DamageFor lerps toward
		// this value with no upper bound of its own.
		// ⚠️ WRITTEN UNDER `noFalloff` TOO, where it is inert rather than wrong: with the
		// band at 0/0 nothing reads this field. Writing it absolutely regardless is what
		// lets the node be cleared with no restore path of its own to forget.
		si.FalloffMultiplier = target > 0f
			? MathF.Min( target,
				WeaponTech.BoundOf( "t3_inverse_falloff", WeaponTech.FalloffCeiling ) )
			: MathF.Max( TechBase( key, si.FalloffMultiplier ), floor );
	}

	/// <summary>
	/// Slug Loader's range stretch for ONE ShootInfo — x3, or 1 on a weapon with no
	/// pellets to convert.
	///
	/// ⛔ THE PELLET TEST USES THE REMEMBERED AUTHORED COUNT AND THE SAME `_techBase` KEY
	/// `ApplyShotTech` READS. Two independent tests would be two chances to disagree about
	/// whether this weapon is a shotgun, and the visible result of disagreeing is a rifle
	/// that gets triple range for free while its damage correctly gains nothing.
	/// </summary>
	float SlugRangeFor( SWB.Base.ShootInfo si, string key, float slugStep )
	{
		if ( si is null || slugStep <= 0f ) return 1f;

		return TechBase( $"{key}|bullets", si.Bullets ) >= 2f
			? WeaponTech.MagOf( "t5_slug", "range", 3f )
			: 1f;
	}

	/// <summary>
	/// One reload DURATION, rebuilt absolutely from its authored value.
	///
	/// ⛔ A NON-POSITIVE AUTHORED VALUE IS A SENTINEL AND IS RETURNED UNTOUCHED. -1 means
	/// "no empty-reload animation" on `ReloadEmptyTime`, and 0 means "this weapon has no
	/// such phase" on the three shell times — StartReload falls back to `ReloadTime` for
	/// the first and skips the animation entirely for the others. x5 on the -1 gives -5,
	/// which still reads as disabled today but is arithmetic on a flag rather than on a
	/// duration; this is the same class of trap as ClipSize's -1, and that one shipped a
	/// plausible-looking three-round magazine before it was caught.
	/// </summary>
	float ReloadTech( string key, float live, float factor )
	{
		var authored = TechBase( key, live );

		return authored > 0f ? authored * factor : authored;
	}

	/// <summary>
	/// The tier-4 writes that live on a ShootInfo — damage, fire rate, pellet count,
	/// penetration and infinite ammo — all rebuilt from remembered authored values.
	///
	/// ⚠️ ONE HELPER RATHER THAN FIVE, for the reason ApplyClipTech and ApplyFalloffTech
	/// are each one: Primary and Secondary both need every write, and Slug Loader's damage
	/// depends on the pellet count of the SAME ShootInfo, so the pellet lookup and the
	/// damage write cannot be separated without reading the base twice.
	///
	/// ⚠️ `noPen` AND `infiniteAmmo` ARE THE ONLY WRITES HERE THAT ARE NOT ABSOLUTE, and
	/// they do not need to be: each sets a flag one way only, so re-running it on every
	/// equip cannot compound. Writing the authored value back when the node is absent would
	/// mean remembering a bool in a float store for no gain.
	/// </summary>
	/// <param name="oneBullet">
	/// `t4_autoload` — collapse the spread to a single pellet.
	///
	/// ⚠️ IT RIDES SLUG LOADER'S EXISTING `basePellets >= 2` GATE rather than adding a second
	/// one, because the two nodes want the same refusal for the same reason: a weapon that fires one
	/// bullet has no spread to convert, and writing 1 over 1 while taking the node's costs would be
	/// a purchase that did nothing.
	/// </param>
	/// <param name="ammoFactor">
	/// `t4_doublefeed` — rounds consumed per trigger pull.
	///
	/// ⚠️ WRITTEN AT SPAWN TIME ONTO `AmmoPerShot` RATHER THAN HOOKED AT FIRE TIME. That field
	/// is already subtracted by `Weapon.Shoot` and already counted by `DtapAugments`'s overpressure
	/// affordability check, so one absolute write makes every consumer agree — where a read-time
	/// hook would have to be added to each of them and would be missed by the next one.
	/// </param>
	void ApplyShotTech( SWB.Base.ShootInfo si, string key, float dmgFactor, float rpmFactor,
		float bulletFactor, float slugStep, bool noPen, bool infiniteAmmo,
		bool oneBullet = false, float ammoFactor = 1f, int pelletsAdd = 0, int pelletsSet = 0 )
	{
		if ( si is null ) return;

		// ⛔ THE AUTHORED PELLET COUNT, NEVER `si.Bullets`. Scattergun writes that field
		// x6, and while tier 4 is pick-one in a real game `WeaponTech.Unlimited` lifts the
		// limit in creative — which is where these nodes get tested. A KS23 owning both
		// would read 96 pellets and hand Slug Loader a x486 bonus off a number no prefab
		// ever authored. Captured on the first sighting, which is always a fresh clone.
		int basePellets = TechBase( $"{key}|bullets", si.Bullets );

		// ⚠️ THE BONUS SCALES WITH PELLETS CONVERTED, AND ONE PELLET CONVERTS NOTHING:
		// `basePellets - 1` steps of 5%, so the KS23's 16 pellets earn x1.75 and an
		// 8-pellet Olympia x1.35, while a rifle earns x1.00. Total damage is the combined
		// pellet damage times that bonus — the KS23 lands on x28 of one pellet.
		//
		// ⛔ THIS IS THE HIGHEST NUMBER THE TREE PRODUCES AND THE KS23 IS WHERE TO CHECK
		// IT: 16 pellets x 265.8 authored damage x 1.75 is 7,442 in a single slug, before
		// Pack-a-Punch, rarity or any other node. The catalogue's own note flags it.
		float slugBonus = 1f;

		// ⛔ A COLLAPSED SPREAD TAKES THE AUGMENTS' PELLETS INTO THE SHELL IT CONVERTS (2026-10-04). Magnum Shells (+1,
		// shotgun tier 2) can meet Slug Loader and Autoloader (tier 4), and added after the collapse below it was a second
		// whole slug: x2 damage for one pellet. Counted here it is one more pellet's damage in the slug (and one more in
		// `AutoloaderFactor`'s count). The gate stays on the AUTHORED count, so a rifle still converts nothing.
		bool collapse = (slugStep > 0f || oneBullet) && basePellets >= 2;
		int shell = collapse && pelletsSet <= 0 ? Math.Max( 1, basePellets + pelletsAdd ) : basePellets;

		if ( slugStep > 0f && basePellets >= 2 )
			slugBonus = shell * (1f + (slugStep - 1f) * (shell - 1));

		// ⚠️ THE PELLET WRITE IS GATED ON THE SAME TEST AS THE BONUS. A single-bullet
		// weapon that bought Slug Loader keeps its one bullet and gains nothing at all,
		// which is the node as designed rather than an oversight.
		si.Bullets = collapse
			? 1
			: TechScaled( basePellets, bulletFactor );

		// ⚠️ THE PER-CLASS AUGMENTS' PELLETS (2026-10-04), after the old nodes and never below one: Buckshot Belt SETS
		// six (an LMG turned shotgun), Choke takes two and Sawed-Off adds four. Choke and Sawed-Off share tier 4 with
		// Slug Loader and Autoloader, so none of them meets a single-slug gun. Magnum Shells (tier 2) can, and went into the
		// shell above instead.
		if ( pelletsSet > 0 ) si.Bullets = pelletsSet;
		else if ( pelletsAdd != 0 && !collapse ) si.Bullets = Math.Max( 1, si.Bullets + pelletsAdd );

		si.Damage = TechBase( $"{key}|dmg", si.Damage ) * dmgFactor * slugBonus;

		// ⚠️ THROUGH TechScaled BECAUSE RPM IS AN INT. A bare multiply loses x1.10 on any
		// weapon under 10 RPM, and rounds x0.6 the wrong way on the slow ones.
		si.RPM = TechScaled( TechBase( $"{key}|rpm", si.RPM ), rpmFactor );

		// ⚠️ Solid Slug leaves `PenetrationDepth` alone, so a weapon that also bought
		// Overpenetrator keeps its multiplied depth on a field nothing will now read —
		// ShootInfo's own note says the bool is the gate. The two nodes fighting is the
		// design; a depth of 0 written here would look like the depth node was broken.
		if ( noPen ) si.Penetration = false;

		// ⛔ `reserve`, NOT `clip`. `InfiniteAmmo` is an ENUM, and `clip` means "never
		// needs to reload" — which would CANCEL Last Resort's own x5 reload penalty and
		// most of its quarter magazine, leaving a node whose two downsides are unreachable.
		// `reserve` means "can always reload", so the gun never runs dry and reloading it
		// is exactly as miserable as the node advertises. All 31 prefabs author `disabled`
		// (checked), so this is never downgrading a better authored value.
		// ⛔ AND RESTORED WHEN IT IS NOT OWNED (2026-10-04). Bottomless can be taken off with a right click, and a
		// one-way `if` would leave the gun's reserve infinite for the rest of the game: the remembered authored value
		// (every prefab authors `disabled`) is written back instead.
		var authoredInfinite = (SWB.Base.InfiniteAmmoType)TechBase( $"{key}|infammo", (int)si.InfiniteAmmo );
		si.InfiniteAmmo = infiniteAmmo ? SWB.Base.InfiniteAmmoType.reserve : authoredInfinite;

		// ⚠️ REBUILT ABSOLUTELY LIKE EVERY OTHER FIELD HERE, and the remembered base is clamped
		// on the way IN rather than on the way out: `Weapon.Shoot` reads `Math.Max( 1, AmmoPerShot )`,
		// so a prefab authoring 0 means one round — and remembering the raw 0 would make x2 of it
		// zero, i.e. a weapon that never spends ammo.
		si.AmmoPerShot = TechScaled(
			TechBase( $"{key}|ammoshot", Math.Max( 1, si.AmmoPerShot ) ), ammoFactor );
	}

	/// <summary>
	/// The two "how it feels to shoot" fields, rebuilt absolutely from their remembered
	/// authored values — vertical recoil and hipfire spread.
	///
	/// ⚠️ ONE HELPER FOR BOTH BECAUSE THEY ARE ONE AXIS. The Chimera pool draws them from
	/// the SAME donor deliberately: split them and the gun kicks like an AWM while grouping
	/// like a MAC11, which reads as a bug rather than as a gamble. Nothing else writes
	/// either field at spawn time, so with no roll stored this is an idempotent no-op.
	///
	/// ⚠️ IT DOES NOT FIGHT THE READ-TIME ACCURACY NODES. Point Shooting scales the hipfire
	/// term inside `GetRealSpread` and Recoil Control scales the kick inside `FinishRecoil`,
	/// so they multiply whatever these fields hold — including a drawn value.
	/// </summary>
	void ApplyRecoilTech( SWB.Base.ShootInfo si, string key )
	{
		if ( si is null ) return;

		si.RecoilUp = TechBase( $"{key}|recoilup", si.RecoilUp );
		si.SpreadAddHipFire = TechBase( $"{key}|hipspread", si.SpreadAddHipFire );

		// ⚠️ BOTH PATHS ARE SUBSTITUTED, so the axis lands whichever way `UseRecoilBase` is set
		// and a future flip of that switch cannot quietly kill the node again. `RecoilUp` covers
		// authored mode; the two multipliers cover base mode, which is the default.
		//
		// ⚠️ AND THE MODEL'S LEAN FOLLOWS FOR FREE, because it reads the finished kick and its
		// spread compression reads `RecoilVerticalMult` — a Chimera weapon now leans like the gun
		// it stole its recoil from rather than like the one it used to be.
		si.RecoilVerticalMult = TechBase( $"{key}|recoilvmult", si.RecoilVerticalMult );
		si.RecoilHorizontalMult = TechBase( $"{key}|recoilhmult", si.RecoilHorizontalMult );

		// ⚠️ THE FIRST SHOT'S PUNCH AND THE PULL-DOWN, from the same donor (2026-10-03). Both are
		// read by `GetRecoilAngles` in either mode, so these land whichever way `UseRecoilBase` is set.
		si.RecoilKick = TechBase( $"{key}|recoilkick", si.RecoilKick );
		si.RecoilAutoControl = TechBase( $"{key}|recoilauto", si.RecoilAutoControl );
	}

	/// <summary>
	/// Scale an int stat by a tech factor, with a floor of one whole unit.
	///
	/// ⛔ A BARE MULTIPLY IS A DEAD NODE ON HALF THE ROSTER. Extended Mag's +15% is
	/// 34.5 on a 30-round mag, but 2.3 on the Olympia and 6.9 on the HS-10 — and an int
	/// takes both of those straight back to where they started, so the node would do
	/// nothing on exactly the guns where one more round is worth the most. Clips here
	/// run from 2 to 100.
	/// </summary>
	static int TechScaled( int baseValue, float factor )
	{
		int scaled = (int)MathF.Round( baseValue * factor );

		if ( factor > 1f && scaled <= baseValue ) return baseValue + 1;

		// ⛔ AND A SHRINKING FACTOR NEEDS THE SAME FLOOR AT THE OTHER END, WHICH IS A GUN
		// THAT CANNOT FIRE RATHER THAN A NODE THAT DOES NOTHING. Last Resort's x0.25 on the
		// Olympia's 2-round magazine is 0.5, and MathF.Round takes a .5 to the EVEN
		// neighbour — so that rounds to 0, not 1, and the weapon arrives with a magazine it
		// can never load. The doc line above this method has always claimed a floor of one
		// whole unit; before Last Resort no factor below 1 existed to test it.
		//
		// ⚠️ Only when there was something there to begin with. A base of 0 scales to 0,
		// because inventing a round on a field the prefab left empty is the trap
		// GetRealRPM's own guard records.
		if ( baseValue > 0 && scaled < 1 ) return 1;

		return scaled;
	}

	/// <summary>
	/// The AUTHORED value of a field the tech tree scales, per prefab.
	///
	/// ⛔ THERE IS NOWHERE ELSE TO READ IT FROM. ClipSize, MaxReserve and
	/// FalloffMultiplier are the live values, so a derived write has to remember what it
	/// was derived from or it compounds — the same problem WeaponSource.BaseName solves
	/// for the MK suffix, and for the same reason: ApplyStoredUpgrades is re-run on the
	/// weapon IN HAND every time PushStoredUpgrades fires.
	///
	/// ⚠️ THE FIRST SIGHTING IS ALWAYS A FRESH CLONE, which is what makes capturing the
	/// live value here correct: `SpawnWeapon` calls ApplyStoredUpgrades on every weapon
	/// it clones, before anything has scaled it, and PushStoredUpgrades only ever
	/// revisits weapons that arrived that way.
	///
	/// ⚠️ Keyed by prefab like PapLevels and RarityTiers, not by weapon instance — the
	/// authored value belongs to the prefab, and an instance key would grow an entry per
	/// clone for the whole game.
	///
	/// ⚠️ ONE FLOAT-WIDE STORE WITH AN INT WRAPPER, not a second dictionary for the float
	/// fields. "Capture on the first sighting and never again" is the fragile rule here,
	/// and a parallel copy of it is a second place for that rule to drift. Clip sizes and
	/// reserve counts are small integers and round-trip through a float exactly.
	/// </summary>
	readonly Dictionary<string, float> _techBase = new();

	/// <summary>
	/// AUTOLOADER (`t4_autoload`) — how much of the weapon's spread becomes rate, damage and
	/// magazine. 1 when the node is absent or the weapon fires fewer than two pellets.
	///
	/// ⛔ IT READS THE AUTHORED PELLET COUNT THROUGH `TechBase`, NEVER `si.Bullets`, AND THAT IS
	/// THE WHOLE CORRECTNESS ARGUMENT. The node WRITES `Bullets` to 1, so a live read would return
	/// 1 on the second equip and collapse the factor to 0.5 — quietly halving the weapon's damage,
	/// rate and magazine every time it was drawn. It also excludes Scattergun's x6 and Double Tap's
	/// second spread for free, which is what "ignoring Double Tap M1's pellets" asked for.
	///
	/// ⚠️ THE KEY IS `ApplyShotTech`'s OWN, deliberately. Both methods want the same remembered
	/// number, and this one runs first in the pass — so it seeds the slot from the authored value
	/// and `ApplyShotTech` reads back exactly what it would have captured itself.
	///
	/// ⚠️ A NO-OP BELOW TWO PELLETS. `pellets * 0.5` on a rifle is x0.5 of everything for no
	/// benefit at all, so the node refuses rather than cripples — the same gate Slug Loader uses.
	/// </summary>
	float AutoloaderFactor( SWB.Base.Weapon wep, string prefab, out bool active )
	{
		active = false;

		if ( !wep.IsValid() || wep.Primary is null ) return 1f;
		if ( !TechEffects.Has( this, prefab, "t4_autoload" ) ) return 1f;

		int pellets = TechBase( $"{prefab}|shot|bullets", wep.Primary.Bullets );
		if ( pellets < 2 ) return 1f;

		// ⛔ `active` IS A SEPARATE ANSWER FROM THE FACTOR, AND `factor > 1` WOULD NOT DO. A
		// TWO-pellet weapon lands on exactly x1 — nothing to scale — but must still collapse to a
		// single bullet, which is the visible half of the node. Deriving the flag from the number
		// would make the node do nothing at all on the smallest spread weapons on the roster.
		active = true;

		// ⚠️ MAGNUM SHELLS' PELLET JOINS THE COUNT (shotgun tier 2, 2026-10-04), as it joins Slug Loader's slug in
		// `ApplyShotTech`: one more pellet is one more share of rate, damage and magazine, never a second bullet a shot.
		pellets = Math.Max( 1, pellets + (int)MathF.Round( TechStats.Add( this, prefab, "s.pellets+" ) ) );

		// ⚠️ FLOORED AT 1, so a catalogue typo can only ever fail to help rather than quietly
		// halve the weapon's damage, rate and magazine.
		return MathF.Max( 1f, pellets * TechEffects.Factor( this, prefab, "t4_autoload", 0.5f ) );
	}

	float TechBase( string key, float live )
	{
		if ( !_techBase.TryGetValue( key, out var remembered ) )
		{
			_techBase[key] = live;
			remembered = live;
		}

		// ⛔ CHIMERA SUBSTITUTES HERE, AFTER THE CAPTURE AND NEVER INSTEAD OF IT. The store
		// still remembers the REAL authored value, which is what makes the node reversible
		// and what stops the next equip from "capturing" a drawn value as authored — the
		// failure mode of the tempting alternative, a pre-pass that stamps drawn values onto
		// the ShootInfo before this method has ever seen the field.
		//
		// ⚠️ THIS IS THE HIGHEST-BLAST-RADIUS METHOD IN THE TREE — every spawn-time node in
		// tiers 1-4 reads it — so the substitution is a pure lookup with no side effects and
		// no writes. With no Chimera anywhere it is one dictionary-count test.
		return ChimeraBase( key, remembered );
	}

	int TechBase( string key, int live ) => (int)TechBase( key, (float)live );

	/// <summary>
	/// The Chimera-drawn stand-in for an authored value, or the authored value itself.
	///
	/// ⛔ A SUBSTITUTION AND NOT A FACTOR, WHICH IS WHY IT LIVES INSIDE `TechBase` RATHER
	/// THAN BESIDE THE HELPERS. Seven of the eight axes are fields `ApplyClipTech`,
	/// `ApplyShotTech`, `ApplyFalloffTech` or `ReloadTech` already write ABSOLUTELY from the
	/// remembered base on every equip, so a separate Chimera write would be overwritten by
	/// whichever ran second — the exact failure the Slug Loader range note records. Standing
	/// in for the base instead means every other node composes on top of the roll for free,
	/// including `TechScaled`'s floors and both sentinel guards.
	///
	/// ⛔ AND A NON-POSITIVE AUTHORED VALUE IS NEVER SUBSTITUTED. `ClipSize == -1` is SWB's
	/// "no magazine, feeds from reserve" sentinel and `ReloadEmptyTime == -1` means "no empty
	/// reload animation" — dropping a real number onto either turns a flag into a plausible
	/// quantity that nothing downstream can tell is garbage. That is the same class of trap
	/// as the three-round magazine `ApplyClipTech` records, and it costs one comparison.
	/// </summary>
	float ChimeraBase( string key, float authored )
	{
		if ( ChimeraRolls.Count == 0 ) return authored;

		// ⚠️ The prefab path is everything before the first bar and contains none itself, so
		// the rest of the key is the field — `clip`, `shot|dmg`, `falloff|start` — which is
		// exactly how the roll is keyed. See ChimeraRolls.
		var bar = key.IndexOf( '|' );
		if ( bar <= 0 ) return authored;

		if ( !ChimeraRolls.TryGetValue( key[..bar], out var roll ) ) return authored;
		if ( authored <= 0f ) return authored;

		return roll.TryGetValue( key[(bar + 1)..], out var drawn ) ? drawn : authored;
	}

	/// <summary>
	/// When each prefab's Fabricator owes its next magazine — `Time.Now` of the payout.
	///
	/// ⛔ KEYED BY PREFAB, LIKE `_techBase`, AND THAT IS THE WHOLE REASON IT LIVES HERE
	/// RATHER THAN ON THE WEAPON. The weapon is a clone that Pack-a-Punch DESTROYS and
	/// respawns, so a countdown stored on the instance is reset to zero by every upgrade
	/// — and this one is 60 seconds long, so a player who packs a gun even occasionally
	/// would never see a single payout. The prefab key survives the respawn exactly as
	/// PapLevels, RarityTiers and TechOwned do.
	///
	/// ⚠️ AN ABSOLUTE DEADLINE, NOT AN ACCUMULATED REMAINDER. Storing "seconds left" and
	/// subtracting Time.Delta each frame would make the interval depend on the frame rate
	/// and on how long the game spent paused; a deadline is exact and needs no upkeep.
	///
	/// ⚠️ `Time.Now` IS SCENE TIME AND RESTARTS AT ZERO WITH THE SCENE, which is why
	/// ClearTech wipes this and why TickFabricator re-seeds a deadline that has somehow
	/// ended up more than one interval away. Without either, a rewound clock leaves a
	/// deadline permanently in the future and the node silently never fires.
	/// </summary>
	readonly Dictionary<string, float> _fabDue = new();

	/// <summary>
	/// Fabricator — one magazine into each owning weapon's reserve, every 60 seconds,
	/// held or not.
	///
	/// ⚠️ A TICK RATHER THAN A LAZY ACCRUAL COMPUTED AT READ TIME, and the trade is
	/// deliberate. Lazy accrual costs nothing per frame, but there is no read to hang it
	/// on: nothing looks at a HOLSTERED weapon's reserve until it is drawn, so the
	/// payouts would only land on the switch — the HUD would sit still for a minute and
	/// then jump, and a magazine credited while the reserve was full would be wrongly
	/// banked instead of wasted. Ticking makes "every 60 seconds" literally true, which
	/// is what the catalogue row promises. The cost is one component enumeration per
	/// frame, which TickAdsSpeed above already pays for the same list.
	///
	/// ⚠️ `EverythingInSelfAndDescendants`, THE SAME ENUMERATION PushStoredUpgrades USES,
	/// because a holstered weapon is a DISABLED component and the plain modes skip it —
	/// and reaching the holstered gun is the entire point of this node.
	/// </summary>
	void TickFabricator()
	{
		// ⚠️ Nothing bought means nothing to do, and this is the state for most of a
		// game — the enumeration below is skipped outright until the first node is owned.
		if ( TechOwned.Count == 0 ) return;

		foreach ( var wep in Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
		{
			// ⚠️ Resolved from the WEAPON's own stamp, falling back to StartingWeapon,
			// exactly as PushStoredUpgrades does — tech is per prefab, and reading the
			// held weapon's prefab here would pay the holstered gun out of the other
			// gun's tree.
			var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
			var prefab = string.IsNullOrEmpty( src ) ? StartingWeapon : src;

			// ⛔ `ifAbsent: 0f` — the factor is SECONDS, not a multiplier, so the default
			// 1 would mean "a magazine every second" on all 31 weapons that never bought
			// the node. TechEffects.KindOf reads this node's Lever as Absolute for the
			// same reason, so `nz_tech_amp` leaves the 60 alone.
			float interval = TechEffects.Factor( this, prefab, "t3_fabricator", 0f );
			if ( interval <= 0f ) continue;

			var key = $"{prefab}|fab";

			// ⚠️ THE FIRST SIGHTING STARTS THE CLOCK, it does not pay out. Buying the
			// node and being handed a magazine in the same frame would read as the price
			// including one, and then the next one is a full minute away regardless.
			//
			// ⛔ THE SECOND HALF OF THIS TEST IS THE REWOUND-CLOCK GUARD. `Time.Now` is
			// scene time and returns to zero when play restarts; a deadline stranded
			// further out than one whole interval cannot be legitimate, so it is re-seeded
			// rather than left to block every payout for the rest of the game.
			if ( !_fabDue.TryGetValue( key, out var due ) || due > Time.Now + interval )
			{
				_fabDue[key] = Time.Now + interval;
				continue;
			}

			if ( Time.Now < due ) continue;

			// ⚠️ THE SCHEDULE ADVANCES FIRST, and unconditionally. Everything below can
			// decline to pay — no NZAmmo, no magazine, a full reserve — and leaving the
			// deadline in the past in those cases would re-enter this branch every frame.
			// A magazine that does not fit is WASTED, not banked: the catalogue calls this
			// an economy node, and one that stockpiled offline would be an ammo cache.
			_fabDue[key] = Time.Now + interval;

			// ⛔ NZAmmo, NOT NZWeapon — NZWeapon is the legacy placeholder gun and is on
			// none of the weapon prefabs, so the reserve every SWB weapon actually
			// reloads from is this one. Same `EverythingInSelf` reason as above.
			// ⚠️ `IsValid()`, not `is null` — a destroyed s&box component is not null, and
			// this runs every frame against a weapon list that Pack-a-Punch is
			// continually destroying and respawning.
			var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
			if ( !ammo.IsValid() ) continue;

			// ⚠️ THE CURRENT ClipSize, NOT THE REMEMBERED AUTHORED ONE. This is a payout
			// and not a derived field, so it is not at risk of compounding, and reading
			// the live value is what makes Extended Mag and Extra Rounds feed this node —
			// a bigger magazine really is a bigger delivery.
			//
			// ⛔ GUARDED THE SAME WAY ApplyClipTech GUARDS ITS BASE: a non-positive clip
			// is SWB's "no magazine" sentinel, and "one magazine's worth" of a weapon
			// that has no magazine is not a quantity. Unguarded, the -1 would SUBTRACT
			// from the reserve once a minute.
			int clip = wep.Primary?.ClipSize ?? 0;
			if ( clip <= 0 ) continue;

			ammo.Reserve = Math.Min( ammo.Reserve + clip, ammo.MaxReserve );
		}
	}

	/// <summary>"M1911" + level 2 -> "M1911 MK2". Level 0 is left alone.</summary>
	public static string PapName( string baseName, int level )
		=> level > 0 ? $"{baseName} MK{level}" : baseName;

	/// <summary>
	/// Strip a trailing " MK&lt;n&gt;" so re-applying cannot stack suffixes.
	///
	/// ⛔ NEEDED BECAUSE ApplyStoredUpgrades RUNS MORE THAN ONCE PER WEAPON. A fresh spawn
	/// starts from the prefab's clean name, but `PushStoredUpgrades` re-applies to a weapon
	/// already in hand — and without this, packing an MK1 to MK2 would produce
	/// "M1911 MK1 MK2", then "M1911 MK1 MK2 MK3".
	/// </summary>
	public static string BaseName( string name )
	{
		if ( string.IsNullOrEmpty( name ) ) return name;

		int i = name.LastIndexOf( " MK" );
		if ( i < 0 ) return name;

		var tail = name[(i + 3)..];
		return tail.Length > 0 && tail.All( char.IsDigit ) ? name[..i] : name;
	}

	/// <summary>
	/// Force the view onto the nearest live zombie, every frame.
	///
	/// ⚠️ A DIAGNOSTIC, not a gameplay feature. Watching a spawn animation
	/// needs the camera pointed at it for the whole clip, and nobody can hold
	/// an aim over MCP — without this every screenshot of an entrance is a
	/// gamble on where the view happened to be.
	/// </summary>
	public static bool AimLock { get; set; }

	/// <summary>Where on the zombie to look — chest height, so a riser stays in
	/// frame whether it is below the floor or standing.</summary>
	public static float AimLockHeight { get; set; } = 40f;

	/// <summary>
	/// How much of your walk speed you keep while aiming down sights. 0.5 = half.
	///
	/// ⚠️ GLOBAL ON PURPOSE. ADS movement cost is a pacing decision for the whole
	/// game, not a per-weapon stat — a sniper and an SMG should both punish
	/// walking while scoped, and putting it on the weapon means 31 prefabs to edit
	/// and 31 chances to disagree.
	/// </summary>
	[Property] public float AdsSpeedMultiplier { get; set; } = 0.5f;

	/// <summary>
	/// ADRENALINE ROUNDS (`t5_adrenaline`) — the absolute time this player's rush runs out.
	///
	/// ⚠️ A DEADLINE, NOT A COUNTDOWN, which is what makes sixteen pellets landing in one frame
	/// refresh it sixteen times to the SAME value rather than stack into sixteen seconds.
	///
	/// ⚠️ ON THE PLAYER, NOT THE WEAPON, even though the node is bought per prefab. It is a fact
	/// about the body that is moving — so swapping guns keeps the second already earned and simply
	/// stops earning more, rather than holstering a buff away mid-sprint.
	///
	/// ⚠️ NOT `[Property]` AND NOT `[Sync]`. It is set on the shooter and read by that shooter's
	/// own `TickAdsSpeed`; the movement it produces replicates as movement, like every other speed
	/// source in this file.
	/// </summary>
	public float AdrenalineUntil { get; set; }

	/// <summary>Was this player untargetable last frame.</summary>
	private bool _wasUntargetable;

	/// <summary>
	/// Tell the horde the moment this player becomes targetable again.
	///
	/// ⛔ A PUSH ON THE FALLING EDGE, because the pull did not work. `GetTargetables` already
	/// filters untargetable players, so in principle every zombie picks the change up on its
	/// next acquire — and in practice they did not, because a zombie with no target can stall in
	/// a state where `Think` never reaches the retarget check at all. Measured:
	/// `retargets in -173.89s` on a zombie with the player sitting in its own candidate list.
	///
	/// ⚠ IT WATCHES `IsUntargetable`, NOT THE GAS, so it covers every cause at once — walking
	/// out of a cloud, the cloud expiring, and Timeslip m2's Time Out running out. There is
	/// nothing to detect separately and no way for one to be handled and another missed.
	///
	/// ⚠ EDGE, NOT LEVEL. Pushing every frame the player is visible would mean 35 forced
	/// acquires per frame forever, which would cost more than the bug.
	///
	/// ⛔ AND IT NOW WATCHES GOING DOWN AS WELL, ON BOTH EDGES — WHICH IS THE BLEEDOUT BUG.
	/// `GetTargetables` filters THREE things: `IsUntargetable`, `IsDown` and `IsOutOfRound`. This
	/// watched only the first, so a player going down or bleeding out removed themselves from every
	/// candidate list and told nobody. Every zombie already holding them as a target was left with
	/// a target it could no longer re-find — and the paragraph above records exactly what that
	/// costs: *"a zombie with no target can stall in a state where `Think` never reaches the
	/// retarget check at all … retargets in -173.89s on a zombie with the player sitting in its own
	/// candidate list."* User: *"when the player bleeds out the zombies break."*
	///
	/// ⛔ BOTH EDGES, NOT JUST THE FALLING ONE. The original pushed only on becoming visible, on
	/// the reasoning that the filter handles the other direction — which is the same reasoning that
	/// had already failed and produced this method. Becoming INvisible is precisely when a zombie is
	/// holding a stale target and needs telling; the return that skipped it was the bug.
	///
	/// ⚠️ STILL AN EDGE, so the cost is one sweep per transition — a down, a revive, stepping in
	/// or out of gas — not per frame.
	/// </summary>
	private void TickTargetability()
	{
		// ⚠️ THE SAME THREE CONDITIONS `GetTargetables` FILTERS ON, in one place, so the list and
		// the notification cannot disagree about who the horde can see. That they were allowed to
		// disagree at all is what this fixes.
		var hidden = IsUntargetable || IsDown || IsOutOfRound;
		if ( hidden == _wasUntargetable ) return;

		_wasUntargetable = hidden;

		// ⚠️ SAID, WITH THE CAUSE (2026-10-05), on whichever machine notices: the host's log names a client's menu, which nothing
		// else on the host could.
		Log.Info( $"[nz-hide] {GameObject.Name} {(hidden ? "hidden from" : "seen by")} the horde — {HideCause()}" );

		ZombieAI.ForceRetargetAll();
	}

	/// <summary>Why the horde cannot see this player, in words, for the log.</summary>
	string HideCause()
	{
		var why = new List<string>();
		if ( IsOutOfRound ) why.Add( "out of the round" );
		else if ( IsDown ) why.Add( "down" );
		if ( AtMachine ) why.Add( ArsenalMenu.IsOpen ? "at the Arsenal" : "at the Wunderfizz" );
		if ( UntargetableUntil > 0f ) why.Add( $"a window, {(float)UntargetableUntil:0.0}s left" );
		if ( VultureStink.IsInGas( this ) ) why.Add( "in Vulture Aid's gas" );
		if ( HiddenNet && !PlayerPresence.Mine( GameObject ) ) why.Add( "their own machine says hidden: a menu or a window there" );
		return why.Count > 0 ? string.Join( ", ", why ) : "nothing hides them now";
	}

	/// <summary>
	/// How loud the player's OWN footsteps are. 2.5, up from the engine default of 1.
	///
	/// ⚠️ THE FOOTSTEPS THEMSELVES ARE THE ENGINE'S, not ours. `PlayerController` walks the
	/// surface under each foot and plays that surface's own step sound; all we own is the volume
	/// it plays at. There is nothing here to add clips to -- a step on a new material is the
	/// surface asset's business.
	///
	/// ⚠️ A SETTABLE STATIC so it can be dialled in play, nullable-backed for the hotload reason
	/// INSTRUCTIONS.md gives: a static's VALUE survives a hotload but its initialiser does not
	/// re-run, so editing the number here would never reach a running editor.
	/// </summary>
	public static float FootstepVolume
	{
		get => _footstepVolume ??= 2.5f;
		set => _footstepVolume = value;
	}

	static float? _footstepVolume;

	/// <summary>
	/// Push <see cref="FootstepVolume"/> onto the controller.
	///
	/// ⛔ FROM CODE, NOT FROM THE SCENE, and it overrides whatever the scene file says. The value
	/// is serialised into THREE scenes (nzombies, countdown, and the top-down example) and the map
	/// split will keep making more, so a scene-side edit is a number that has to be repeated and
	/// will eventually disagree with itself. One static, applied every frame, cannot.
	///
	/// ⚠️ COMPARED BEFORE WRITING so this is a no-op on all but the first frame -- and so the
	/// console command below takes effect immediately without anything having to poke the player.
	/// </summary>
	void ApplyFootstepVolume()
	{
		var c = Components.Get<PlayerController>();
		if ( !c.IsValid() || c.FootstepVolume == FootstepVolume ) return;

		c.FootstepVolume = FootstepVolume;
	}

	protected override void OnUpdate()
	{
		// ⛔ ATTACHED FROM OnUpdate, NOT OnStart. A component CREATED in OnStart is
		// destroyed by a hotload and never comes back — OnStart does not run again
		// on an already-spawned player, so the slide silently stopped existing the
		// first time any file was saved. `nz_slide` reporting "no player" with the
		// player plainly standing there is what that looks like.
		Components.GetOrCreate<Slide>();

		// ⚠️ ON EVERY BODY, MINE AND EVERYBODY ELSE'S. It both PUBLISHES (owner) and DRAWS
		// (everyone), so a guard here would stop the very bodies it exists to put a gun on.
		Components.GetOrCreate<ThirdPersonWeapon>();
		ApplyFootstepVolume();

		TickSurrounded();
		TickTargetability();

		PushHealth();

		// ⚠ PhD's fall tracking, m1's slam and m5's double jump all live in one static called
		// from here — see `PhdAugments.Tick` for why it is not a component of its own.
		PhdAugments.Tick( this );

		// ⚠ Tortoise's ring — planting, dropping and m5's armor regen. Same reasoning as
		// PhdAugments.Tick for living in a static called from here rather than a component.
		TortoiseAugments.Tick( this );
		TortoiseAugments.TickAutoRepair( this );

		// ⚠ The co-op revive. Runs every frame because it is a hold-to-act interaction.
		ReviveAugments.Tick( this );

		// ⚠️ EVERY FRAME, AND DELIBERATELY NOT GATED on holding the augment. The
		// call does its own cleanup pass first, so unequipping m4 — or going down
		// with it — takes the outlines with it. Gated here, they would be stranded.
		DeathAugments.TickXRay( this );

		// ⚠ NAPALM PITS ARE NOT DRIVEN FROM HERE. `NapalmPit` ticks its own damage on its own
		// timer — driven from the player, two players near one pit would tick it twice as fast.
		VultureAugments.TickGasFeed( this );

		// ⚠️ CREATIVE IS ALWAYS RICH. Testing a machine that costs 2500 with 500
		// points in hand means farming a round before every check, which is how
		// a buyable ends up tested once and never again.
		//
		// ⚠️ TOPPED UP RATHER THAN MADE FREE. Purchases still deduct, so the
		// spend path — TrySpend, the shortfall message, the price escalation — is
		// exercised exactly as it will be in a round, and only the balance is
		// unrealistic. Skipping the charge instead would leave that code untested
		// in the one mode where it is most often exercised.
		if ( NZGame.IsCreative && Points != CreativePoints )
			Points = CreativePoints;

		// ⚠️ SALVAGE TOO, and for the same reason: the Arsenal is a machine you
		// place in Creative and immediately want to press E on, and a mapper checking
		// that a tier-3 vest is reachable should not have to farm 7,700 salvage off
		// zombies that are not spawning.
		//
		// ⚠️ TOPPED UP, NOT MADE FREE — exactly as the points above. TrySpend still
		// deducts, so the shortfall path and every price still run as they will in a
		// round; only the balance is unrealistic.
		if ( NZGame.IsCreative && Salvage != CreativeSalvage )
			Salvage = CreativeSalvage;

		// ⚠️ ABOVE THE GUARD ON PURPOSE, AND IT IS THE ONLY THING HERE THAT MUST BE. Everything
		// else above this line runs for every body because it is harmless to; this runs for every
		// body because a PROXY is exactly what it exists to update.
		TickDownedMirror();
		ApplyOutOfRoundBody();

		// ⛔ EVERYTHING BELOW THIS LINE IS FOR *MY* BODY ONLY, AND NOTHING SAID SO UNTIL NOW.
		//
		// `OnUpdate` runs on EVERY `NZPlayer` in the scene — mine and my copy of everybody
		// else's. Below here it drives INPUT and STATIC MENUS, of which there is exactly one set
		// per machine, so the other player's body was reaching into my UI every frame:
		//
		//   `ArsenalMenu.TickRange( this )` measured THEIR distance from the arsenal and closed
		//   MY menu because they were across the map. User: *"the arsenal still only works if all
		//   players are near it, otherwise it assumes we are too far away and closes."*
		//
		// ⚠️ THE SAME WAS TRUE OF EVERY `Input.` BELOW — the escape key, plating, placeables,
		// dropping points — all read the LOCAL keyboard and were being applied once per body.
		// In single player that is once. With two players it is twice, to two different people.
		//
		// ⚠️ AND IT MUST BE HERE RATHER THAN AT THE TOP. Everything ABOVE this line is per-body
		// on purpose: `PushHealth` exists precisely to push somebody ELSE'S health to its owner,
		// and the animation, targetability and augment ticks each run for the body they are given.
		// A guard at the top of the method would have broken all of them.
		if ( !PlayerPresence.Mine( GameObject ) ) return;

		// ⛔ MY BODY KEEPS ITS REGEN AND ITS STAMINA (2026-10-05) — see `TickVitals`.
		TickVitals();

		// ⛔ ESC IS HANDLED HERE, NOT ONLY IN THE PANEL. The menu's razor also
		// checks it, but a panel that fails to construct takes its ESC handler with
		// it — and then the cursor is up, the game is unresponsive, and the ONE key
		// that should get you out does nothing. Reported exactly that way: "pressing
		// esc is not returning control over the player". The way out of a modal must
		// not depend on the modal working.
		if ( WunderfizzMenu.IsOpen && Input.EscapePressed )
			WunderfizzMenu.Close();

		// ⚠️ HERE FOR THE SAME REASON AS THE ESC HANDLER ABOVE — a walk-away check inside the razor
		// would go down with the panel, and the failure mode is identical: cursor up, player
		// unresponsive, no way out. `nz_fizz_range` tunes the distance.
		WunderfizzMenu.TickRange( this );

		// ⚠️ Its own check, not an else-if. Both menus cannot be open at once today,
		// but an else-if would silently make that assumption load-bearing.
		if ( ArsenalMenu.IsOpen && Input.EscapePressed )
			ArsenalMenu.Close();

		// ⚠️ AND THE SAME WALK-AWAY THE WUNDERFIZZ HAS. The Arsenal is the other machine you walk
		// up to and open a modal on, so it got the other one's exit too -- ESC was the only way
		// out of it, and a menu with one exit is a menu that traps you when that exit fails.
		// `nz_arsenal_range` tunes the distance.
		ArsenalMenu.TickRange( this );

		// ⚠️ AND BASALT'S SHIELD LOCK KEYPAD, FOR BOTH REASONS: an ESC and a walk-away that do not depend on the panel
		if ( KeypadMenu.IsOpen && Input.EscapePressed )
			KeypadMenu.Close();

		KeypadMenu.TickRange( this );

		// ⛔ HERE, NOT IN OnStart. A component created once in OnStart is DESTROYED
		// by a hotload and never comes back — OnStart does not re-run — so editing
		// PowerupMusic.cs silently removed the thing being edited until play was
		// restarted. `GetOrCreate` is idempotent and a no-op after the first frame,
		// which is exactly why SurvivalHud attaches its child panels this way.
		//
		// ⚠️ It needs to be a ticking component at all because `ActivePowerups` is a
		// static: it can say what is running, but nothing there can notice the moment
		// a powerup ENDS and stop the music.
		Components.GetOrCreate<PowerupMusic>();

		// ⚠️ AND THE CAMERA THAT WATCHES THE OTHERS ONCE I BLEED OUT (2026-10-05, `SpectateOthers`), made here for the same reason.
		Components.GetOrCreate<SpectateOthers>();

		// ⛔ NOT WHILE BLED OUT (2026-10-05). The body is gone and the camera is on somebody else (`SpectateOthers`), so a key
		// pressed now would buy, board or build at the spot I fell, out of sight. The plates, placeables and drops already stop for
		// anyone down (`TickWeaponSwitch`), and so does the revive hold (`ReviveAugments.Tick`).
		if ( !IsOutOfRound )
		{
			TickUse();
			TickBarricadeRepair();
			TickBuildTable();
		}

		TickAdsSpeed();
		TickWeaponSwitch();
		TickBleedout();

		// ⚠️ HERE AND NOT ON THE WEAPON. There is no GameObjectSystem or scheduler in
		// this project, and the Fabricator has to keep counting for the gun on your BACK
		// — a holstered weapon is a disabled component and does not tick at all. The
		// player does, and it already owns the per-prefab tech records the timer lives in.
		TickFabricator();

		if ( !AimLock ) return;

		var target = ZombieAI.All
			.Where( z => z.State != ZombieState.Dead )
			.OrderBy( z => z.WorldPosition.DistanceSquared( WorldPosition ) )
			.FirstOrDefault();

		if ( !target.IsValid() ) return;

		var c = Components.Get<PlayerController>();
		if ( !c.IsValid() ) return;

		// Aim from the EYE, not the feet — using the object origin points the
		// camera at the floor when the zombie is close.
		var from = c.EyePosition;
		var to = target.WorldPosition + Vector3.Up * AimLockHeight;

		c.EyeAngles = Rotation.LookAt( (to - from).Normal ).Angles();
	}

	/// <summary>
	/// The use key. Flips a power switch, or buys the barrier you are aiming at.
	///
	/// ⚠️ SWITCH FIRST, THEN DEBRIS — the same order UsePrompt uses to pick its
	/// wording. If the two disagreed, the prompt would offer one thing and the
	/// key would do another, which is worse than either being wrong alone.
	/// </summary>
	/// <summary>
	/// Rebuild a barricade's boards by HOLDING use next to it.
	///
	/// ⛔ SEPARATE FROM TickUse, AND FOR TWO REASONS. TickUse is
	/// `Input.Pressed` — one action per press — and everything in it is AIM-gated
	/// through a manager's `Aimed(player)`. Repair is neither: it is `Input.Down`
	/// so holding keeps going, and it is PROXIMITY-gated so you can board a window
	/// up while watching the room behind you. Folding it into TickUse would have
	/// meant either tapping E six times or facing the wall while a horde arrives.
	///
	/// ⚠️ The per-board rate limit lives in Barricade.Repair, not here. Holding
	/// the key calls this every frame and the barricade decides when a board is
	/// due — so the cooldown cannot drift between the sound, the points and the
	/// plank count.
	/// </summary>
	/// <summary>
	/// Swap between the two weapons.
	///
	/// ⚠️ The bindings already existed — `Slot1`, `Slot2`, `SlotNext`, `SlotPrev`
	/// are in Input.config under the Inventory group, unused until now. Nothing new
	/// had to be bound; the game simply never had a second weapon to switch to.
	///
	/// ⛔ NOTHING WHILE DOWNED. A player crawling on the floor swapping guns is not
	/// a downed player, and the whole revive mechanic depends on being helpless.
	/// </summary>

	/// <summary>
	/// The two keys that are NOT weapon switches: H to plate, B to place a Banana Colada object.
	///
	/// ⛔ SPLIT OUT SO THEY SIT ABOVE `TickWeaponSwitch`'s `inv.Count < 2` GUARD. They used to be
	/// inline below it, which meant neither key worked while you carried a single weapon — and it
	/// presented as "the bind is broken" rather than "the method returned early", because a refusal
	/// is normally logged and here nothing was.
	///
	/// ⚠️ RETURNS TRUE WHEN IT HANDLED THE PRESS, so the caller can stop and a plate or a placeable
	/// still cannot fall through to a slot switch — the reason they were put in that method at all.
	/// </summary>
	bool TickPlateAndPlaceable()
	{
		// ⚠️ ARMOR PLATE ON H, BEFORE THE SLOT KEYS. It is not a weapon switch and
		// must not fall through to one; putting it first also means a plate applied
		// mid-fight cannot be eaten by a slot bind.
		//
		// ⚠️ Refusals are LOGGED with their reason — WhyCannotPlate exists so "H did
		// nothing" can always be answered (no plates, no tier owned, already full).
		if ( Input.Pressed( "ArmorPlate" ) )
		{
			// ⛔ FULLY QUALIFIED. Inside NZPlayer the bare name `Armor` is this class's
			// own float PROPERTY, so `Armor.UsePlate(...)` reads as `float.UsePlate` and
			// will not compile. The armor VALUE and the armor SYSTEM share a name, and
			// this is the one file where that matters.
			var why = NZombies.Armor.WhyCannotPlate( this );
			if ( why is not null ) { Log.Info( $"[nz-armor] cannot plate — {why}" ); return true; }

			NZombies.Armor.UsePlate( this );

			Log.Info( $"[nz-armor] plated — armor {Armor:0}"
				+ $"/{NZombies.Armor.CapFor( this ):0}, {ArmorPlates} plate(s) left" );
			return true;
		}

		// ⚠️ BANANA COLADA ON B, BESIDE THE PLATE KEY AND BEFORE THE SLOT KEYS, for the reason the
		// block above gives: it is not a weapon switch and must not fall through to one.
		//
		// ⛔ THE ACTION IS DECLARED IN `ProjectSettings/Input.config`, NOT HERE, and that file is
		// where `ArmorPlate`, `Grenade` and `Knife` live too — NOT in the `.sbproj`, which has no
		// input block at all. An `Input.Pressed` on an undeclared action silently returns false
		// forever, so a new bind that "does nothing" is almost always a missing entry there rather
		// than a bug in this file.
		//
		// ⚠️ AND REFUSALS ARE LOGGED WITH A REASON, the same contract `WhyCannotPlate` has: "B did
		// nothing" must always be answerable — no perk, no major equipped, or not charged yet.
		if ( Input.Pressed( "Placeable" ) )
		{
			BananaAugments.TryPlace( this );
			return true;
		}

		// ⚠️ DROP POINTS ON 5, HERE FOR THE SAME REASON THE TWO ABOVE ARE: it is not a weapon
		// switch and must not fall through to one. `5` is also `Slot5` in Input.config, which
		// nothing reads — the game has two weapon slots and cycling — so the key is genuinely free;
		// if slots ever reach five, the clash is visible in that file rather than hidden here.
		//
		// ⚠️ Refusals are logged with a reason, the contract `WhyCannotPlate` set: "5 did nothing"
		// has three different answers (broke, down, no player) and they need different fixes.
		if ( Input.Pressed( "DropPoints" ) )
		{
			PointsDrop.Drop( this );
			return true;
		}

		// ⚠️ DROP SALVAGE ON 6, BESIDE 5 AND FOR ITS REASONS: not a weapon switch, so it must not fall through to one. `6` is
		// also `Slot6` in Input.config, which nothing reads. Refusals are logged with a reason (`SalvageDrop.WhyCannot`).
		if ( Input.Pressed( "DropSalvage" ) )
		{
			SalvageDrop.Drop( this );
			return true;
		}

		return false;
	}

	void TickWeaponSwitch()
	{
		if ( IsDown ) return;

		// ⛔ THE TWO NON-WEAPON KEYS ARE READ FIRST, BEFORE THE WEAPON-COUNT GUARD BELOW, AND THAT
		// GUARD IS WHY THEY BOTH DIED INTERMITTENTLY. `inv.Count < 2` returns out of this whole
		// method when you are carrying a single gun — which is most of an early round — so H and B
		// were never read at all. Nothing logged a refusal because no refusal happened: the code
		// that decides never ran.
		//
		// ⛔ AND IT LOOKED LIKE AN INPUT-BINDING BUG, WHICH COST REAL TIME. Both keys sit in this
		// method only because they must not fall through to a slot switch; the comments below say
		// so and say nothing about the count. `Input.config` was rewritten twice chasing it.
		//
		// ⚠️ SO THEY LIVE ABOVE THE GUARD AND STILL RETURN, keeping the original intent — a plate
		// or a placeable must not also switch weapons.
		if ( TickPlateAndPlaceable() ) return;

		var inv = Inventory;
		if ( inv.Count < 2 ) return;

		// ⛔ THE SCROLL WHEEL IS NOT AN ACTION. Input.config binds SlotNext/SlotPrev to
		// mouse4/mouse5 and has no wheel entry at all — s&box exposes the wheel as an
		// axis (`Input.MouseWheel`), not as something a named action can carry. So
		// scrolling did nothing however many times you tried it.
		//
		// ⚠️ Sign ignored, direction only: with two slots "next" and "previous" are
		// the same weapon, and honouring the sign would just make a fast flick
		// double-switch back to where it started.
		// ⛔ NOT WHILE A CURSOR MENU IS UP, OR THE WHEEL NEVER REACHES THE LIST UNDER IT. The
		// wallbuy tool's weapon picker is a scrolling panel in the Q menu, and this read fires
		// every frame regardless of what has the cursor — so scrolling it switched weapons
		// behind the menu instead of moving the list. Reported as "the wallbuy tool does not
		// let me scroll".
		//
		// ⚠️ `Mouse.Visibility` RATHER THAN ASKING THE MENU. DevMenu, LobbyMenu, ArsenalMenu
		// and PerfHud all raise the cursor the same way, so one test covers every menu there is
		// and the next one for free — where naming a panel would cover exactly that panel.
		//
		// ⚠️ `Auto` IS NOT VISIBLE. That is the in-game state the menus restore on close, so
		// testing for it would disable the scroll wheel permanently.
		if ( Mouse.Visibility == MouseVisibility.Visible ) return;

		float wheel = Input.MouseWheel.y;
		if ( MathF.Abs( wheel ) > 0.01f )
		{
			inv.Cycle( wheel > 0 ? 1 : -1, instant: true );
			AfterSlotChange();
			return;
		}


		if ( Input.Pressed( "Slot1" ) ) { inv.SetActiveSlot( 0, instant: true ); AfterSlotChange(); return; }
		if ( Input.Pressed( "Slot2" ) ) { inv.SetActiveSlot( 1, instant: true ); AfterSlotChange(); return; }
		if ( Input.Pressed( "SlotNext" ) ) { inv.Cycle( 1, instant: true ); AfterSlotChange(); return; }
		if ( Input.Pressed( "SlotPrev" ) ) { inv.Cycle( -1, instant: true ); AfterSlotChange(); return; }
	}

	/// <summary>
	/// Runs after a PLAYER-INITIATED weapon-slot change.
	///
	/// ⛔ HERE, NOT IN `NZInventory.SetActive`. Every weapon the GAME hands you also goes
	/// through SetActive — wall buys, box rolls, a Pack-a-Punch collect, Mule Kick's
	/// Insurance restore — and Vulture Aid's Wildcard must not reroll those. This method is
	/// reached only from the five input branches above, so "the player changed slot" is
	/// exactly what it means.
	///
	/// ⚠️ ONE IMPLEMENTATION, FIVE CALL SITES. The wheel and the four binds are five
	/// separate branches by necessity (they compute different targets), but what happens
	/// afterwards must not be five copies — that is §3, and the copy that would get missed is
	/// whichever bind the tester does not happen to use.
	///
	/// ⚠️ AFTER THE SWITCH, NOT INSTEAD OF IT. The slot really changes and THEN the weapon
	/// you arrived at is replaced, which is what makes "you cannot go back" true: the gun you
	/// left behind is still in its slot, but returning to that slot rerolls it too.
	/// </summary>
	void AfterSlotChange()
	{
		if ( VultureAugments.RollWildcard( this ) is { } gave )
			Log.Info( $"[nz-aug-vulture] Wildcard rerolled the slot into {gave}" );
	}

	/// <summary>
	/// Hold E at a building table to build the wonder weapon.
	/// </summary>
	///
	/// ⛔ EVERY WAY OUT RESETS THE CLOCK, AND THERE ARE FIVE. Letting go, walking out of range,
	/// going down, the table being shut, and not having the parts. A hold that survived any one of
	/// them would let a player bank three seconds, wander off, come back and finish instantly —
	/// and the one that would actually be hit is walking out of range, because the table is the
	/// size of a bench and the prompt is generous.
	///
	/// ⚠️ A DOWNED TEAMMATE TAKES THE KEY, exactly as `TickUse` gives it up for the same reason.
	/// Reviving is also a hold on E, and a player crouched over a friend beside the table must not
	/// be building instead of picking them up.
	private void TickBuildTable()
	{
		if ( IsDown || ReviveAugments.TargetFor( this ).IsValid() ) { BuildHold = 0f; return; }

		if ( !Input.Down( "Use" ) ) { BuildHold = 0f; return; }

		var table = BuildTable.Near( WorldPosition );
		if ( table is null ) { BuildHold = 0f; return; }

		// ⚠️ A BENCH WITH THE WEAPON ALREADY ON IT IS NOT A BUILD TARGET. Without this, holding
		// E to take the gun would also be holding E to build, and the press that collects it would
		// start a second four seconds against a table that has nothing left to make.
		if ( table.Built ) { BuildHold = 0f; return; }

		if ( !string.IsNullOrEmpty( table.Unavailable( this ) ) ) { BuildHold = 0f; return; }
		if ( !BuildParts.HasAll() ) { BuildHold = 0f; return; }

		BuildHold += Time.Delta;
		if ( BuildHold < BuildTable.HoldSeconds ) return;

		// ⚠️ RESET BEFORE THE BUILD, not after. `Build` hands over a weapon, which can switch the
		// active slot and run a draw animation; leaving the clock past its limit for those frames
		// would fire it again the moment anything returned early.
		BuildHold = 0f;

		var msg = table.Build( this );
		if ( !string.IsNullOrWhiteSpace( msg ) ) Log.Info( $"[nz-build] {msg}" );
	}

	/// <summary>When `TickVitals` last looked.</summary>
	TimeSince _sinceVitals;

	/// <summary>
	/// Make sure MY body has a HealthRegen and a Stamina, both switched on — twice a second.
	///
	/// ⛔ THE USER: *"sometimes clients have unlimited stamina, wich seems to also corelate with being unable to recover health"*
	/// (2026-10-05). The two share nothing but this body: `OnStart` creates both, and each acts on a component it looks up
	/// itself — both of which now look again every frame instead of trusting their first try. This covers what that cannot: a
	/// component missing, or switched off, is put back and SAID, so the next client log names the cause instead of showing a
	/// motionless bar. Nothing in the project switches either off on purpose.
	/// </summary>
	void TickVitals()
	{
		if ( _sinceVitals < 0.5f ) return;
		_sinceVitals = 0f;

		EnsureVital<HealthRegen>();
		EnsureVital<Stamina>();
	}

	void EnsureVital<T>() where T : Component, new()
	{
		var c = Components.Get<T>( FindMode.EverythingInSelf );

		if ( !c.IsValid() )
		{
			Components.GetOrCreate<T>();
			Log.Warning( $"[nz-vitals] '{GameObject.Name}' had no {typeof( T ).Name} — made one" );
			return;
		}

		if ( c.Enabled ) return;

		c.Enabled = true;
		Log.Warning( $"[nz-vitals] '{GameObject.Name}' had its {typeof( T ).Name} switched off — switched it back on" );
	}

	private void TickBarricadeRepair()
	{
		// ⚠️ Not while downed. A crawling player boarding a window would undo the
		// thing that put them there.
		if ( IsDown ) return;
		if ( !Input.Down( "Use" ) ) return;

		var b = Barricade.RepairableNear( WorldPosition );
		if ( b is null ) return;

		var msg = b.Repair( this );
		if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz] {msg}" );
	}

	private void TickUse()
	{
		if ( !Input.Pressed( "Use" ) ) return;

		// ⛔ A DOWNED TEAMMATE IN REACH TAKES THE KEY, AND `UsePrompt.Text` PUTS THEM FIRST TOO.
		// The revive is a HOLD on this same key, handled in `ReviveAugments.Tick` — so without this
		// the press half of that hold also bought whatever happened to be nearby. A player crouched
		// over a downed friend beside a wallbuy would spend three thousand points on an SMG while
		// trying to pick them up, and the augments' own reach makes standing that close normal.
		//
		// ⚠️ `TargetFor` IS THE SAME QUESTION THE HOLD ASKS. A separate proximity test here
		// would drift from the one that decides whether the revive works, which is the failure this
		// method's own comments record four times over for the buy machines.
		if ( ReviveAugments.TargetFor( this ).IsValid() ) return;

		// ⚠️ BASALT'S CURSED FLAME, LOOKED AT, BEFORE EVERYTHING BELOW — and `UsePrompt.Text` puts it next after the revive
		// too. One question decides both (`HexPlatforms.CursedFlameAimed`); the host decides whether the take happens.
		if ( HexPlatforms.CursedFlameAimed( this ) )
		{
			HexPlatforms.TakeCursedFlame( this );
			return;
		}

		// ⚠️ THEN BASALT'S ALTAR — `UsePrompt.Text` puts it next too: its carrier, looking at it, sets the cursed flame there.
		if ( HexPlatforms.AltarAimed( this ) )
		{
			HexPlatforms.PlaceCursedFlame( this );
			return;
		}

		// ⚠️ AND BASALT'S TWIN SHIELD — `UsePrompt.Text` puts it next too: the light blue flame's carrier brings it down.
		if ( HexPlatforms.TwinAimed( this ) )
		{
			HexPlatforms.TakeDownTwin( this );
			return;
		}

		// ⚠️ AND BASALT'S SHIELD LOCK, LOOKED AT, NEXT — `UsePrompt.Text` puts it next too. E opens its keypad — or, the lock
		// jammed by a wrong code, does nothing, and nothing behind it either.
		if ( HexPlatforms.LockAimed( this ) )
		{
			if ( !HexPlatforms.LockJammedShown ) KeypadMenu.Open();
			return;
		}

		// ⚠️ AND BASALT'S TELEPORTER BUTTONS, LOOKED AT — `UsePrompt.Text` asks the same question and shows nothing, by the
		// user's word (*"pressing E, but not hud message"*), nor anything below them. Before the wall buys: the user marked the
		// buttons' spots with ASP wall buys, and one still lying there must not take the key.
		var hexButton = HexPlatforms.HexButtonAimed( this );
		if ( hexButton >= 0 )
		{
			HexPlatforms.PressHexButton( this, hexButton );
			return;
		}

		// ⚠️ AND BASALT'S BLUE ALTAR, LOOKED AT — `UsePrompt.Text` puts it next too: E sends every player to the boss arena,
		// and the host decides. While it charges E does nothing, nor anything behind it.
		if ( HexPlatforms.BlueAltarAimed( this ) )
		{
			if ( !HexPlatforms.ArenaSendingShown ) HexPlatforms.UseBlueAltar( this );
			return;
		}

		// ⚠️ AND THE BEAST'S CORE, LOOKED AT, ONCE HE IS DEAD — `UsePrompt.Text` puts it next too; the host decides
		if ( HexPlatforms.CoreAimed( this ) )
		{
			HexPlatforms.TakeCore( this );
			return;
		}

		var power = PowerManager.Instance;
		if ( power is not null && power.Aimed( this ) >= 0 )
		{
			Log.Info( $"[nz] {power.Use( this )}" );
			return;
		}

		// ⚠️ Wallbuys before debris, and UsePrompt.Text uses the SAME order. A
		// wallbuy is usually mounted ON a wall the debris trace also likes, so
		// whichever is checked first wins — it must be the same first in both
		// places or the prompt offers a gun and the key opens a door.
		var walls = WallBuyManager.Ensure();
		var buy = walls?.Aimed( this );
		if ( buy is not null )
		{
			buy.TryBuy( this );
			return;
		}

		// ⚠️ AFTER the aim-gated buys, BEFORE debris. A box is proximity-gated, so
		// checking it first would let it swallow a wallbuy you were looking at
		// from across the same corner — and a box you are standing at is a more
		// deliberate act than a door you happen to face.
		// ⚠️ BEFORE the box, because a Pack-a-Punch that already holds YOUR weapon
		// must win the key outright — losing a 30,000-point MK3 to a box that
		// happened to be placed nearby is not a trade anyone would accept.
		var pap = PackAPunch.Near( WorldPosition );
		if ( pap is not null )
		{
			// ⚠️ COLLECT BEFORE INSERT, the same shape as the box's take-before-buy:
			// while a finished gun is sitting there the key must retrieve it rather
			// than start paying for another pack.
			var papMsg = pap.HasFinishedGun ? pap.Collect( this ) : pap.Insert( this );
			if ( !string.IsNullOrEmpty( papMsg ) ) Log.Info( $"[nz] {papMsg}" );
			return;
		}

		var box = MysteryBox.Near( WorldPosition );
		if ( box is not null )
		{
			// ⚠️ TAKE BEFORE BUY. While a weapon is on offer the same key must
			// grab it, not pay for another roll — a box that charges you 950 for
			// pressing E at the thing it just offered you is a trap, not a
			// mechanic.
			var msg = box.HasOffer ? box.Take( this ) : box.Buy( this );
			if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz] {msg}" );
			return;
		}

		// ⚠️ AFTER the box, BEFORE debris — the SAME order UsePrompt.Text uses.
		// The prompt and the key must agree about who wins, or the screen offers
		// one thing and E does another. This file already records that rule twice.
		var fizz = Wunderfizz.Near( WorldPosition );
		if ( fizz is not null )
		{
			var blocked = fizz.Unavailable( this );

			// ⚠️ The reason is LOGGED, not swallowed. A machine that refuses in
			// silence is indistinguishable from one that is broken.
			if ( !string.IsNullOrEmpty( blocked ) ) { Log.Info( $"[nz] {blocked}" ); return; }

			WunderfizzMenu.Open( this, fizz );
			return;
		}

		// ⚠️ AFTER THE WUNDERFIZZ, matching UsePrompt.Text exactly — the prompt and the key must
		// agree about who wins.
		//
		// ⛔ BUYS OUTRIGHT rather than opening a menu, which is the whole difference between the
		// two machines: base perks are sold here, augments only at the Wunderfizz.
		var perkMachine = PerkMachine.Near( WorldPosition );
		if ( perkMachine is not null )
		{
			// ⚠️ The refusal is LOGGED, not swallowed — a machine that says nothing is
			// indistinguishable from a broken one.
			var perkMsg = perkMachine.Buy( this );
			if ( !string.IsNullOrEmpty( perkMsg ) ) Log.Info( $"[nz] {perkMsg}" );
			return;
		}

		// ⚠️ AFTER the machines, matching UsePrompt.Text exactly. A pad is a big flat thing a
		// mapper will stand a machine on, so the machine wins the key — you can step off a pad to
		// reach a machine, but not off a machine to reach the pad beneath it.
		var teleporter = Teleporter.Near( WorldPosition );
		if ( teleporter is not null )
		{
			// ⚠️ The refusal is LOGGED, not swallowed — a pad that says nothing is
			// indistinguishable from a broken one.
			var teleMsg = teleporter.Use( this );
			if ( !string.IsNullOrEmpty( teleMsg ) ) Log.Info( $"[nz] {teleMsg}" );
			return;
		}

		// ⚠️ AFTER THE WUNDERFIZZ, matching UsePrompt.Text exactly. See the note
		// there — the prompt and the key must agree about who wins.
		var arsenal = Arsenal.Near( WorldPosition );
		if ( arsenal is not null )
		{
			var blocked = arsenal.Unavailable( this );

			// ⚠️ The reason is LOGGED, not swallowed — a machine that refuses in
			// silence is indistinguishable from one that is broken.
			if ( !string.IsNullOrEmpty( blocked ) ) { Log.Info( $"[nz] {blocked}" ); return; }

			// ⚠️ OPENS THE MENU rather than buying outright, matching the Wunderfizz.
			// The Arsenal sells four different things in the original, so E cannot mean
			// one of them.
			//
			// ⚠️ The armor page is built, so E -> menu -> click a tier is a complete
			// route again. nz_arsenal_buy still works and skips the menu, which is what
			// makes the purchase testable without a cursor.
			ArsenalMenu.Open( this, arsenal );
			return;
		}

		// ⛔ THE ENDING IS FIRST OF THE PROXIMITY MACHINES, AND UsePrompt.Text MATCHES. It is
		// the one interaction that cannot be undone — everything below it can be done again next
		// round, and a misplaced ammo box swallowing the key from the exit would be discovered
		// only by someone who wanted to leave and could not.
		//
		// ⚠️ A mapper standing one on top of another is the case this ordering exists for. It is
		// not hypothetical: the exit is usually put somewhere memorable, which is exactly where
		// the other machines go.
		// ⚠️ AFTER THE ENDING, AND UsePrompt.Text MATCHES. The exit is the one thing that
		// cannot be undone and keeps the top of the list; misery is a toggle and can be
		// pressed again.
		// ⚠️ FIRST OF THE EE INTERACTABLES, AND UsePrompt.Text MATCHES. A pressable is
		// usually a small button placed ON something else — a wall the player also stands
		// near a machine at — so it has to win the tie or it becomes unpressable.
		var press = Pressable.Near( WorldPosition );
		if ( press is not null )
		{
			var pMsg = press.Press( this );
			if ( !string.IsNullOrEmpty( pMsg ) ) Log.Info( $"[nz-ee] {pMsg}" );
			return;
		}

		var misery = MiseryDevice.Near( WorldPosition );
		if ( misery is not null )
		{
			var mMsg = misery.Toggle( this );
			if ( !string.IsNullOrEmpty( mMsg ) ) Log.Info( $"[nz] {mMsg}" );
			return;
		}

		var ending = BuyableEnding.Near( WorldPosition );
		if ( ending is not null )
		{
			var endMsg = ending.Buy( this );
			if ( !string.IsNullOrEmpty( endMsg ) ) Log.Info( $"[nz] {endMsg}" );
			return;
		}

		// ⚠️ LAST OF THE PROXIMITY MACHINES, AND UsePrompt.Text MATCHES. Deliberately below the box,
		// the Wunderfizz and the Arsenal: an ammo refill is the cheapest, most repeatable thing here,
		// so it must never swallow the key from a machine holding your weapon or offering a roll.
		var ammoBox = AmmoBox.Near( WorldPosition );
		if ( ammoBox is not null )
		{
			// ⚠️ The refusal is LOGGED, not swallowed — a box that says nothing is
			// indistinguishable from a broken one.
			var msg = ammoBox.Buy( this );
			if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz] {msg}" );
			return;
		}

		// ⚠️ AFTER THE AMMO BOX, AND UsePrompt.Text MATCHES. Both are cheap proximity machines and a
		// mapper can stand them side by side, so whichever wins has to win in BOTH places.
		// ⚠️ THE FINISHED WEAPON IS A PRESS, WHILE BUILDING IT IS A HOLD — both on E, and they
		// cannot collide because a built bench stops being a build target (see TickBuildTable).
		var bench = BuildTable.Near( WorldPosition );
		if ( bench is not null && bench.Built )
		{
			var got = bench.Take( this );
			if ( !string.IsNullOrWhiteSpace( got ) ) Log.Info( $"[nz-build] {got}" );
			return;
		}

		// ⚠️ BEFORE THE TRADING TABLE, AND `UsePrompt` MATCHES. A part on the floor is a smaller
		// target with a tighter reach, so standing on one means you meant it — whereas the tables
		// are furniture you end up beside by accident.
		var part = BuildPart.Near( this );
		if ( part is not null )
		{
			var msg = part.Collect( this );
			if ( !string.IsNullOrWhiteSpace( msg ) ) Log.Info( $"[nz-build] {msg}" );
			return;
		}

		var trade = TradeTable.Near( WorldPosition );
		if ( trade is not null )
		{
			var msg = trade.Use( this );
			if ( !string.IsNullOrWhiteSpace( msg ) ) Log.Info( $"[nz] {msg}" );
			return;
		}

		var debris = DebrisManager.Instance;
		if ( debris is null ) return;

		var index = debris.AimedBuyable( this );
		if ( index < 0 ) return;

		Log.Info( $"[nz] {debris.Buy( index, this )}" );
	}

	// ⛔ THE ONE VOICE SITUATION WITH NO SYSTEM BEHIND IT. Eighteen of the nineteen hang off
	// something that already existed; "surrounded" needed a question nothing in the game was asking.
	private float _sinceSurroundCheck;

	/// <summary>
	/// Notice when the horde has closed in.
	///
	/// ⚠️ CHECKED ONCE A SECOND, NOT PER FRAME. Counting every zombie in the level is O(n) over the
	/// whole horde, and the line it feeds has a 20-second cooldown — sixty checks a second to change
	/// an answer that can act at most once every twenty is pure waste.
	///
	/// ⚠️ THE THRESHOLD IS A COUNT WITHIN A RADIUS, not a total. Twenty zombies across the map is a
	/// normal round; four of them inside 250 units is the moment worth remarking on.
	///
	/// ⚠️ IT IGNORES THE DOWNED STATE, because `downed` outranks `surrounded` anyway and the crew
	/// have separate lines for bleeding out.
	/// </summary>
	private void TickSurrounded()
	{
		if ( IsDown ) return;

		_sinceSurroundCheck += Time.Delta;
		if ( _sinceSurroundCheck < 1f ) return;

		_sinceSurroundCheck = 0f;

		var near = 0;

		foreach ( var z in Scene.GetAllComponents<ZombieAI>() )
		{
			if ( z.WorldPosition.Distance( WorldPosition ) > SurroundedRadius ) continue;
			if ( ++near < SurroundedCount ) continue;

			CharacterVoice.Say( "surrounded", this );
			return;
		}
	}

	/// <summary>How close a zombie counts as crowding you, and how many it takes.</summary>
	public static float SurroundedRadius { get; set; } = 250f;
	public static int SurroundedCount { get; set; } = 4;

	private void OnHurt( float amount )
	{
		// ⚠️ EVERY HIT REACHES HERE, INCLUDING THE ONE THAT DOWNS YOU. That is why `pain` is
		// `Chatter` and `downed` is `Urgent` — the down cuts the grunt off rather than queueing
		// behind it.
		CharacterVoice.Say( "pain", this );

		// TODO: the original's screen-edge blood overlay, and health regen
		// after a few seconds without being hit.
		// ⚠️ AND WHAT DEALT IT (2026-10-04, `Health.LastHitSource`): two ~300 downs once logged as "hit for 301" and nothing else.
		Log.Info( $"[NZPlayer] hit for {amount:0} — {Hp.Current:0}/{Hp.Max:0} · {Hp.LastHitSource}" );
	}

	// ── downed state ─────────────────────────────────────────────────────────
	//
	// Modelled on the original's `playerMeta:DownPlayer()` (revive_system/
	// sh_meta.lua) and its round-end check (round/sv_round.lua:334):
	//
	//     if #player.GetAllPlayingAndAlive() < 1 then self:End()
	//
	// i.e. the game ends when nobody is up. Solo, that is you.
	//
	// ⚠️ The original's version also juggles tombstone, perks, upgrades, solo
	// self-revives and weapon snapshots. NONE of those systems exist here yet, so
	// this is the mechanic without them — deliberately, not by oversight. The
	// hooks they would need (OldWeapons, SoloRevive) are absent rather than
	// stubbed, so nothing looks implemented that isn't.

	/// <summary>Seconds to bleed out once downed. The original's `nz_downtime`.</summary>
	[Property, Group( "Downed" )] public float BleedoutTime { get; set; } = 45f;

	/// <summary>The bleedout this match gives: <see cref="BleedoutTime"/> times the lobby's Difficulty (`Difficulty.Bleedout`,
	/// 2026-10-05). What the countdown starts from, and what the HUD's bar is a share of.</summary>
	public float BleedoutSeconds => BleedoutTime * Difficulty.Bleedout;

	/// <summary>Movement multiplier while crawling. Downed players are slow, not frozen.</summary>
	[Property, Group( "Downed" )] public float DownedSpeedMultiplier { get; set; } = 0.12f;

	/// <summary>
	/// Quick Revive.
	///
	/// ⛔ A PLACEHOLDER FOR THE PERK SYSTEM, WHICH DOES NOT EXIST YET. It is a
	/// plain bool rather than a `HasPerk("revive")` call so that nothing pretends
	/// there is a perk framework behind it — when perks land, this property is the
	/// single place that has to start asking them.
	///
	/// ⚠️ It only decides whether going down is POSSIBLE. The self-revive that
	/// Quick Revive grants in solo is the perk's own behaviour and belongs with
	/// the perk, not here.
	/// </summary>
	[Property, Group( "Downed" )] public bool QuickReviveOverride { get; set; }

	/// <summary>
	/// Quick Revive — the PERK, or the editor override for testing.
	///
	/// ⚠️ Was a bare bool waiting for the perk system. Now that perks exist it
	/// reads the owned list, with the property kept as an override so a downed
	/// state can still be tested without buying anything.
	/// </summary>
	public bool HasQuickRevive => QuickReviveOverride || HasPerk( "revive" );

	/// <summary>
	/// Solo self-revive: you have the perk and you are the ONLY player in the game.
	///
	/// ⛔ ALONE, NOT "NOBODY ELSE STANDING" (2026-10-05). The user: *"Quick revive should not self revive when there are other
	/// players in the game, it should only happen when I'm alone"*. This asked `OthersStillUp == 0`, so in co-op the last
	/// Quick Revive holder to fall stood themselves up once every teammate was down — the solo rule working as a co-op escape.
	/// In co-op Quick Revive revives others faster, and the last one down ends the run (`CanBeRevived`).
	/// </summary>
	public bool CanSelfRevive => HasQuickRevive && IsAlone;

	/// <summary>
	/// Am I the only player in the game: no other player's body in the scene, in any state. A teammate who is down, bled out
	/// or out of the round is still in the game; one who leaves takes their body away (`NZPlayers.RemoveFor`).
	///
	/// ⚠️ ENABLED BODIES ARE EVERY BODY DURING A GAME: bleeding out hides only the renderer's object and keeps the root
	/// ticking (`ApplyOutOfRoundBody`); only the lobby switches whole bodies off, and nobody goes down there. So the cheap
	/// component index answers it, which matters because the downed tick asks every frame.
	/// </summary>
	public bool IsAlone => !Scene.GetAllComponents<NZPlayer>().Any( p => p.IsValid() && p != this );

	/// <summary>Seconds spent downed before Quick Revive picks you up.</summary>
	public static float SelfReviveTime { get; set; } = 5f;

	/// <summary>Counts down to a solo self-revive. Read by the HUD.</summary>
	public TimeUntil SelfReviveIn { get; private set; }

	/// <summary>
	/// Other players still on their feet. Solo this is always 0.
	///
	/// ⚠️ Counts players who are UP, not players who exist — three teammates who
	/// are all bleeding out cannot revive anyone, so the last one to fall must
	/// die rather than join them on the floor and stall the game forever.
	/// </summary>
	public int OthersStillUp => Scene.GetAllComponents<NZPlayer>()
		.Count( p => p.IsValid() && p != this && !p.IsDown && p.Hp.IsValid() && !p.Hp.IsDead );

	/// <summary>
	/// Is there anyone who could pick you back up?
	///
	/// Quick Revive, or a teammate still standing. With neither, going down would
	/// be 45 seconds of crawling toward a game over that is already decided — so
	/// the hit kills instead.
	///
	/// ⛔ QUICK REVIVE COUNTS ONLY WHILE IT CAN STILL ACT (2026-09-27): a self-revive left. With the
	/// self-revives spent it counted anyway, so a solo down was exactly the 45-second crawl this rule
	/// exists to skip, ending in the same game over.
	///
	/// ⛔ AND LAST STAND DOES NOT COUNT (2026-09-27, later) — *"if any player has the augment that allows
	/// them to revive themselves by killing an enemy when down, the game does not end when all players are
	/// down, only when all players bleed out"*. Counted here, it let the last player to fall go down
	/// instead of dying, so a run with everybody on the floor went on until a bleedout ended it. Its kill
	/// still stands you up while the run goes on — a teammate up, or your self-revive counting — but with
	/// nobody standing and no self-revive coming, everybody down is game over, Last Stand or not. The host
	/// holds the same line for downs that land together (<see cref="TickEverybodyDown"/>).
	///
	/// ⛔ AND QUICK REVIVE COUNTS ONLY WHEN YOU ARE ALONE (2026-10-05), through `CanSelfRevive`: in co-op it no longer stands
	/// you up, so with nobody standing the hit kills and the run ends, as in CoD.
	/// </summary>
	public bool CanBeRevived => OthersStillUp > 0
		|| (CanSelfRevive && ReviveAugments.HasSelfRevive( this ));

	/// <summary>Counts down while downed. Read by the HUD.</summary>
	public TimeUntil BleedsOutIn { get; private set; }

	/// <summary>0..1 of the bleedout elapsed — 1 means out of time.</summary>
	public float BleedoutFraction => IsDown && BleedoutSeconds > 0f
		? (1f - BleedsOutIn / BleedoutSeconds).Clamp( 0f, 1f )
		: 0f;

	/// <summary>
	/// Downed, not dead — you can still crawl, and a revive puts you back up.
	///
	/// ⚠️ Health is RESET rather than left at zero. The original does the same
	/// (`self:SetHealth(nzMapping.Settings.hp or 100)`) and it is not cosmetic:
	/// Health.Apply early-returns on IsDead, so a downed player left at zero
	/// could never be damaged, revived to a sane value, or bled out by anything
	/// that goes through the damage path.
	/// </summary>
	private void GoDown()
	{
		// ⚠️ COUNTED WHERE THE DOWN HAPPENS, not where a bleedout ends. A player who goes down and
		// bleeds out went down ONCE; incrementing on death as well would double every solo down.
		PlayerStats.For( this )?.RecordDown();

		if ( IsDown ) return;

		// ⚠️ AFTER THE GUARD, so a second hit while already down does not re-trigger it. `downed` is
		// `Urgent`, meaning it cuts off whatever chatter was mid-sentence — which is the point.
		CharacterVoice.Say( "downed", this );

		// ⛔ NO REVIVER, NO DOWNED STATE. Without Quick Revive and with nobody
		// left standing, the bleedout is 45 seconds of crawling toward a game
		// over that is already decided — the outcome cannot change, so the hit
		// kills outright. This is what solo without Quick Revive does in the
		// original games, and it is the difference between a last chance and a
		// countdown you are made to sit through.
		if ( !CanBeRevived )
		{
			Die();
			return;
		}

		IsDown = true;
		BleedsOutIn = BleedoutSeconds;
		BeingRevivedSeconds = 0f;
		_bledOut = false;

		// ⛔ PERKS ARE NOT LOST HERE. They are lost on REVIVE — because Quick
		// Revive has to still be owned while you are down for it to pick you up,
		// and stripping perks at the moment of going down would remove the very
		// perk that is about to act. Reported as exactly that requirement.
		//
		// ⚠️ That also means a player who bleeds out keeps their perks in the
		// list, which is correct: the run is over, nothing reads them again.
		SelfReviveIn = CanSelfRevive ? SelfReviveTime : float.MaxValue;

		Hp?.Reset( Difficulty.MaxHealth );

		// ⚠️ Zombies stop targeting a downed player — the original sets
		// TARGET_PRIORITY_NONE. Without this they crowd the body and the bleedout
		// is spent being eaten, which reads as a bug rather than a grace period.
		// The filter lives in ZombieAI.GetTargetables so retargeting picks it up
		// on its own cadence; nothing has to be pushed at them here.

		// ⛔ THE WEAPON SWAP HAPPENS HERE AND DID NOT EXIST BEFORE. `StripWeapons` was defined
		// and never called from this method, so a downed player kept firing whatever they were
		// holding. Quick Revive's M4 is what buys you out of the pistol.
		ReviveAugments.OnDowned( this );

		// ⚠️ AFTER the weapon swap, so the warp cannot land between stripping and
		// re-arming. Death Perception's M2 Escape Artist only moves you.
		DeathAugments.OnDowned( this );

		Log.Warning( $"[nz] DOWNED — {BleedoutSeconds:0}s to bleed out" );
	}

	/// <summary>
	/// Bleedout. Called every frame while down.
	///
	/// ⚠️ Solo ends the game the moment the timer expires, because there is
	/// nobody who could revive you. When co-op lands this needs to become the
	/// original's "no player left up" check rather than "this player is out" —
	/// they are the same thing only while the player count is one.
	/// </summary>
	void TickBleedout()
	{
		if ( !IsDown ) return;

		// ⚠️ FORCED EVERY FRAME, not set once on going down. PlayerController
		// recomputes IsDucking from input in its own update, so a single write in
		// GoDown is undone as soon as the player is not holding crouch — exactly
		// the trap the crawl SPEED hit. Re-asserting it each frame is what makes
		// it a state rather than a suggestion.
		var c = Components.Get<PlayerController>();
		if ( c.IsValid() )
			c.IsDucking = true;

		// ⛔ CHECKED BEFORE THE BLEEDOUT. Solo Quick Revive must resolve the down
		// before the timer can end the run — otherwise whichever fires first
		// decides, and the bleedout is usually shorter than nothing.
		// ⛔ THE BUDGET IS CHECKED HERE. `CanSelfRevive` answers "solo and holding the perk";
		// this adds "and has one left", which nothing counted before — self-revive was unlimited.
		if ( CanSelfRevive && ReviveAugments.HasSelfRevive( this ) && SelfReviveIn )
		{
			// ⛔ THE SPEND GOES FIRST, BECAUSE IT READS THE PERK IT IS SPENDING. `SpendSelfRevive`
			// logs `used/UsesFor`, and `UsesFor` asks whether M1 Phoenix is equipped — which needs
			// the perk owned. Below the removal it printed "4/3 used", a budget of three that had just
			// allowed a fourth use. The gate above had already read 5 correctly; only the log line
			// disagreed, which is the kind of number that gets believed over the code.
			ReviveAugments.SpendSelfRevive( this );

			// ⛔ m4 PLATE CARRIER IS DECIDED BEFORE STANDING UP AND APPLIED AFTER (2026-09-27). It was
			// asked after `Revive`, on the reasoning that the perk is consumed only further down — but
			// `Revive` runs `LosePerksOnDown`, which had already taken Quick Revive with the rest, so
			// the augment read as unowned and a self-revive never plated anybody unless M2 kept the
			// perks. Filled after, so standing up cannot wipe the armor it gives.
			var plate = ReviveAugments.PlatesOthers( this );

			Log.Info( "[nz] Quick Revive — back up, perk consumed" );
			Revive();

			if ( plate ) ReviveAugments.PlateFor( this, "self" );

			// ⛔ CONSUMED *AFTER* `Revive`, AND THE OTHER ORDER WAS A REAL BUG. This used to run
			// BEFORE, with the reasoning "removed before Revive so the perk loss it performs cannot
			// double-remove it" — which was true and beside the point. `Revive` calls
			// `LosePerksOnDown`, which asks `ReviveAugments.PerksKeptFor`, which asks
			// `Has( p, "M2" )`, which requires `p.HasPerk( "revive" )`. Removing the perk one line
			// earlier made **M2 Grave Keeper read as unowned at the exact moment it is consulted**,
			// so it fell through to the config's `PerksKeptOnDown` (0) and the player lost every
			// perk. The console said `down — lost 5 perk(s), kept 0` with M2 equipped and bought.
			//
			// ⚠️ AND IT IS SELF-CORRECTING IN BOTH DIRECTIONS, which is why the original worry does
			// not apply. With M2 held, `LosePerksOnDown` returns early having removed nothing and
			// this line then spends Quick Revive — keep your perks, still pay the revive. Without
			// M2, the perk loss already took `revive` along with the rest and this is a no-op on a
			// list that no longer contains it. `List.Remove` on a missing item is false, not a throw.
			//
			// ⚠️ NOTHING ELSE IN `Revive` CARES. Its only other augment call is
			// `ReviveAugments.OnRevived`, which reads `DownedWeapons` and never asks about the perk.
			Perks.Remove( "revive" );
			return;
		}

		if ( !BleedsOutIn ) return;

		// ⛔ LATCHED. This runs EVERY FRAME once the timer has expired, and
		// `TimeUntil` stays elapsed forever — so without this it fired
		// continuously. EndGame's own guard made it harmless but not silent: the
		// console showed "[nz] bled out" seven times in one millisecond, which is
		// exactly how a harmless bug gets mistaken for the cause of a real one.
		if ( _bledOut ) return;
		_bledOut = true;

		Log.Warning( "[nz] bled out" );

		// ⛔ ONE PLAYER BLEEDING OUT DOES NOT END A CO-OP GAME. The rule, as stated:
		// *"with more players if a player bleeds out the game does not end, the player respawns
		// next round; if all players get downed they lose and the game ends."* Ending it here
		// unconditionally is correct only in single player, and in the first two-machine test it
		// took the whole session down forty-five seconds after the host went down — while the
		// other player was still up and fighting.
		//
		// ⚠️ "SOMEBODY ELSE IS STILL UP" IS THE TEST, not the player count. Two players with
		// one of them already bled out is the same situation as being alone, and should end the
		// same way.
		if ( AnyoneStillUp() )
		{
			// ⛔ AND THE BODY GOES. Until now they stayed on the floor with a countdown reading
			// zero — a player who looks like they can still be picked up and cannot be, for the
			// rest of the wave. `ApplyOutOfRoundBody` takes the body out of the world on every
			// machine, and the speed term below it stops an invisible player crawling around.
			//
			// ⚠️ SET HERE AND NOWHERE ELSE, in the branch that SPARES the run. The other branch
			// ends it, and game over deliberately leaves every body lying in the world behind the
			// score screen.
			IsOutOfRound = true;

			// ⚠️ THEY STAY DOWN UNTIL THE ROUND ENDS. `RoundManager.BeginPrep` is what brings
			// them back, so the cost of bleeding out is the rest of the round — which is the
			// whole point of the rule and is not something this method should shorten.
			Log.Info( "[nz] … but somebody is still up — back next round" );
			return;
		}

		// ⛔ THE HOST ENDS THE RUN, EVEN WHEN A CLIENT IS THE ONE WHO NOTICED. This line runs on
		// whichever machine owns the body that bled out last, and `RoundManager` is per-machine —
		// so a client ending it here showed itself a score screen while the host carried on, until
		// the host's next `RoundNow` broadcast overwrote the state and took the score screen away.
		//
		// ⚠️ NO SECOND MESSAGE IS NEEDED COMING BACK. `RoundNow` already broadcasts `State` on
		// change, so the host's `EndGame` puts every machine on the score screen by itself.
		if ( Networking.IsActive && !NZGame.IsHost )
			NZNet.GameOverAsk( "Everybody bled out" );
		else
			RoundManager.Instance?.EndGame( "Everybody bled out" );
	}

	/// <summary>
	/// Is anyone other than this player still on their feet?
	///
	/// ⚠️ DOWN AND BLED-OUT ARE DIFFERENT STATES AND BOTH COUNT AS "NOT UP". A player who is
	/// downed can still be revived, so they are not up — but they are also not out, and the run
	/// ends only when nobody at all is standing.
	///
	/// ⚠️ IT LOOKS AT `PlayerSpawner.AllBodies`, which includes DISABLED ones, for the reason
	/// that lookup exists at all: an enabled-only search has been wrong here four times.
	/// </summary>
	bool AnyoneStillUp()
	{
		foreach ( var go in PlayerSpawner.AllBodies() )
		{
			var other = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );

			if ( !other.IsValid() || other == this ) continue;

			// ⛔ SOMEBODY ELSE'S BODY IS JUDGED BY WHAT ITS OWNER PUBLISHED, NOT BY A LATCH ON THIS COPY (2026-09-29). `IsDown`
			// and `IsOutOfRound` on a copy are mirrored from its owner (`TickDownedMirror`); `_bledOut` is written only on the
			// machine it happens on — so on the host a client's copy kept game over's latch, and a host bleeding out ended the
			// run with that client still up ("Everybody bled out", round 4, 2026-09-29 00:17).
			var theirs = Networking.IsActive && PlayerPresence.Theirs( go );
			if ( other.IsDown || other.IsOutOfRound || (!theirs && other._bledOut) ) continue;

			return true;
		}

		return false;
	}

	/// <summary>Has this down already run out its timer? Cleared on revive.</summary>
	bool _bledOut;

	/// <summary>
	/// Has this player bled out and not yet been brought back?
	///
	/// ⚠️ READ BY `RoundManager.BringBackTheBledOut`, which is the only thing that clears it
	/// in co-op — the round is the cost of bleeding out.
	///
	/// ⛔ ONLY MEANINGFUL FOR A BODY THIS MACHINE DRIVES. On a copy of somebody else's it is whatever this machine last wrote —
	/// game over's `ForceDown`, never cleared by a revive that runs on the owner — so for other players read `IsOutOfRound`,
	/// which their owner publishes (2026-09-29).
	/// </summary>
	public bool HasBledOut => _bledOut;

	/// <summary>
	/// Down, with Quick Revive's self-revive counting and one left to spend: back up in a moment without anybody's
	/// help. The OWNER'S answer; every other machine reads <see cref="SelfReviveNet"/>, which the owner publishes
	/// from this.
	///
	/// ⚠️ "COUNTING" IS THE CLOCK BEING SET. `GoDown` starts it only when nobody else was up and parks it at
	/// `float.MaxValue` otherwise, as `ForceDown` and `KillOutright` do, so a clock that far off will never fire.
	/// The rest is `TickBleedout`'s own test for the self-revive, less the clock having run out.
	/// </summary>
	public bool SelfReviveComing => IsDown && !IsOutOfRound
		&& (float)SelfReviveIn < 1e6f
		&& CanSelfRevive && ReviveAugments.HasSelfRevive( this );

	/// <summary>Was every player down with no self-revive coming, at the host's last look? See <see cref="TickEverybodyDown"/>.</summary>
	public static bool EverybodyDown => _everybodyDown;

	/// <summary>For how long, while <see cref="EverybodyDown"/>.</summary>
	public static float EverybodyDownFor => _everybodyDown ? (float)_everybodyDownSince : 0f;

	static bool _everybodyDown;
	static TimeSince _everybodyDownSince;

	/// <summary>
	/// How long everybody has to be down before the host calls it: long enough for a teammate's down and their
	/// self-revive flag to have reached the host, and nothing next to a bleedout.
	/// </summary>
	public const float EverybodyDownGrace = 0.5f;

	/// <summary>
	/// EVERY PLAYER DOWN AND NOBODY'S SELF-REVIVE COMING: THE RUN IS OVER. The host looks every frame of a run,
	/// from `RoundManager.OnUpdate` — *"the game does not end when all players are down, only when all players
	/// bleed out"* (2026-09-27).
	///
	/// ⛔ `GoDown` ALREADY ENDS IT FOR THE LAST ONE TO FALL — SO WHY THIS AS WELL. That test runs on the machine that
	/// owns the falling body, against what it has heard of the others. Two players felled by one blast, each on
	/// their own machine, each still hear the other standing: both go down, neither dies, and the run waited out a
	/// bleedout. The host sees both downs arrive, whichever machines they happened on.
	///
	/// ⚠️ A SELF-REVIVE ON ITS WAY HOLDS THE RUN OPEN. Quick Revive's is the one way back up with nobody standing,
	/// so a down player whose clock is counting (<see cref="SelfReviveNet"/>) keeps it going. Last Stand does
	/// not; see <see cref="CanBeRevived"/>.
	///
	/// ⚠️ ENABLED BODIES ONLY. `PlayerSpawner.AllBodies` also returns the lobby's disabled bodies, which are not in
	/// the run. A player out until the next round keeps an enabled body (only its model and collider go), so
	/// they count, as down.
	/// </summary>
	public static void TickEverybodyDown( RoundManager rm )
	{
		if ( NZGame.IsClient || NZGame.IsCreative || !rm.IsValid()
			|| (rm.State != RoundState.Prep && rm.State != RoundState.Active) )
		{
			_everybodyDown = false;
			return;
		}

		var bodies = 0;

		foreach ( var go in PlayerSpawner.AllBodies() )
		{
			if ( !go.Enabled ) continue;

			var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
			if ( !p.IsValid() ) continue;

			bodies++;

			// ⚠️ MY OWN BODY BY ITS OWN STATE, A TEAMMATE'S BY WHAT THEIR MACHINE PUBLISHED. Solo, nothing is published.
			var coming = !Networking.IsActive || PlayerPresence.Mine( go ) ? p.SelfReviveComing : p.SelfReviveNet;

			if ( !p.IsDown || coming )
			{
				_everybodyDown = false;
				return;
			}
		}

		if ( bodies == 0 )
		{
			_everybodyDown = false;
			return;
		}

		if ( !_everybodyDown )
		{
			_everybodyDown = true;
			_everybodyDownSince = 0f;
			return;
		}

		if ( _everybodyDownSince < EverybodyDownGrace ) return;

		_everybodyDown = false;
		Log.Warning( $"[nz] everybody is down and no self-revive is coming — game over ({bodies} player(s))" );
		rm.EndGame( "Everybody went down" );
	}

	// ══ telling the owner what happened to them ═══════════════════════════════

	/// <summary>The last figures sent, so an unchanged frame sends nothing.</summary>
	(float Current, float Max, bool Down) _lastSentHp = (-1f, -1f, false);

	/// <summary>
	/// Tell this body's owner how much health it has. Host only.
	///
	/// ⛔ THE ZOMBIES LIVE ON THE HOST, SO A CLIENT NEVER FOUND OUT IT WAS BEING EATEN. The host
	/// logged `[ZombieAI] hit Player (Bart) for 30` two dozen times while that player's own screen
	/// sat at full health. Health is not replicated by anything else — there is no `[Sync]` in
	/// this project — so if this does not say it, nobody does.
	///
	/// ⚠️ CHANGE-DETECTED. A player's health is constant for most of a round; "send when
	/// different" costs one comparison a frame and is exactly as prompt as any timer.
	///
	/// ⚠️ AND ONLY FOR SOMEBODY ELSE'S BODY. The host's own health needs no telling, and a
	/// client must not be sending its own figures to anybody.
	/// </summary>
	void PushHealth()
	{
		if ( !Networking.IsActive || NZGame.IsClient ) return;
		if ( !Hp.IsValid() ) return;

		var owner = OwningConnection;
		if ( string.IsNullOrEmpty( owner ) || owner == Connection.Local?.Id.ToString() ) return;

		// ⛔ IT NO LONGER SENDS ANYTHING, AND BOTH HALVES OF WHAT IT CARRIED NOW HAVE OWNERS.
		//
		// The HEALTH half was an absolute computed on the host's copy of this player — a copy with
		// none of their perks and the base maximum — so it capped a Juggernog client at 150 the
		// first time it arrived. `Health.Apply` now relays the raw DAMAGE to the owner instead,
		// which is what `MirrorTo`'s own comment always said the real fix was.
		//
		// The DOWN half is `NZPlayer.DownedNet`, which reaches everybody rather than the victim
		// alone — and reaching everybody is what made the co-op revive possible at all.
		//
		// ⚠️ THE METHOD STAYS, EMPTY, RATHER THAN THE CALL BEING DELETED. This is the fourth
		// thing to have owned player health in a fortnight, and a named place that says "nothing
		// pushes health any more, here is why" is worth more than a silent absence at the call
		// site. `NZNet.PlayerHealth` is likewise left in place — nothing calls it now, and it
		// documents an approach that cannot work.
		_ = _lastSentHp;
	}

	/// <summary>
	/// Match the host's verdict on whether I am down. Owner side of <see cref="PushHealth"/>.
	///
	/// ⚠️ IT GOES THROUGH `GoDown` AND `Revive` RATHER THAN SETTING A FLAG, because everything
	/// that makes being down FEEL like being down lives in those — the weapon strip, the crawl
	/// speed, the voice line, the screen. A mirrored bool with none of that would be a player who
	/// is down on the host and standing on their own screen, which is worse than not mirroring it.
	///
	/// ⛔ AND THE BLEEDOUT IS NOT RE-RUN LOCALLY. The host owns that timer; a second one here
	/// would race it and could end the run twice — see `TickBleedout`, which now refuses while
	/// anyone is still up.
	/// </summary>
	/// <summary>
	/// Publish my down state, or follow somebody else's. Runs on every body, every machine.
	///
	/// ⛔ A PROXY IS SET FLAT, NOT PUT THROUGH `GoDown`. `MirrorDown` argues the opposite — *"a
	/// mirrored bool with none of that would be a player who is down on the host and standing on
	/// their own screen"* — and it is right about the VICTIM'S OWN machine, which is the only one
	/// it runs on. This is the other case entirely: my copy of your body must not strip your
	/// weapons, play your voice line, take your perks or start a second bleedout clock that could
	/// end the run from a machine that does not own the decision. It needs to know one thing, so it
	/// is told one thing.
	///
	/// ⚠️ THE CRAWL IS RE-ASSERTED EVERY FRAME for the same reason `TickBleedout` does it on the
	/// owner: `PlayerController` recomputes `IsDucking` in its own update, so a single write when
	/// the flag arrives is undone on the very next tick and the proxy stands back up.
	/// </summary>
	void TickDownedMirror()
	{
		if ( !Networking.IsActive ) return;

		// ⛔ THE RECORDED OWNER, NOT `PlayerPresence.Mine`, AND THE DIFFERENCE IS A SILENT
		// UN-DOWNING. `Mine` falls back to `!IsProxy`, which is a heuristic — and in the one frame
		// it answers "not mine" for my own body, the line below would copy a default `false` over
		// `IsDown` and stand a crawling player up without running `Revive`: no health, no weapons,
		// no perk accounting, just suddenly upright with a bleedout still ticking.
		//
		// ⚠️ NO OWNER MEANS SAY NOTHING AND BELIEVE NOTHING. A body that has not been claimed
		// yet is exactly the case that heuristic gets wrong, so it is skipped rather than guessed.
		//
		// ⚠️ AND IT IS THE SAME AUTHORITY `Revive` RELAYS THROUGH. If the two asked different
		// questions, a body could publish from one machine and be revived on another.
		// ⚠️ READ OFF THIS COMPONENT RATHER THAN THROUGH `NZPlayers.OwnerOf`, which is the same
		// value — `OwnerOf` does a component lookup to reach exactly this field, and this method
		// runs for every body every frame.
		var owner = OwningConnection ?? "";
		var me = Connection.Local?.Id.ToString();

		if ( string.IsNullOrEmpty( owner ) || string.IsNullOrEmpty( me ) ) return;

		if ( owner == me )
		{
			if ( DownedNet != IsDown ) DownedNet = IsDown;
			if ( OutOfRoundNet != IsOutOfRound ) OutOfRoundNet = IsOutOfRound;

			// ⚠️ COMPUTED THEN COMPARED, because the whole point of an int here is that it stops
			// changing between whole seconds — assigning unconditionally would publish the same
			// value sixty times a second and defeat the quantisation.
			var left = IsDown ? (int)MathF.Ceiling( MathF.Max( 0f, BleedsOutIn ) ) : 0;
			if ( BleedoutLeftNet != left ) BleedoutLeftNet = left;

			// ⚠️ AND WHETHER I AM ABOUT TO STAND MYSELF UP, for the host's everybody-down check (`TickEverybodyDown`).
			var coming = SelfReviveComing;
			if ( SelfReviveNet != coming ) SelfReviveNet = coming;

			// ⚠️ AND WHETHER THE HORDE MAY SEE ME (2026-10-05): my menus at the Arsenal and the Wunderfizz, and the windows written
			// on this machine, reach the host's zombies only through this (`HiddenNet`).
			var hidden = HiddenHere;
			if ( HiddenNet != hidden ) HiddenNet = hidden;

			// ⚠️ COMPUTED FROM MY OWN LOADOUT, WHICH IS THE ONLY COPY OF IT. Each of these is the
			// `…Local` form of a multiplier the host will otherwise ask a body with no perks.
			var plate = DeathAugments.PlateScaleLocal( this );
			var power = DeathAugments.PowerupScaleLocal( this );
			var vult = VultureAugments.DropScaleLocal( this );
			var hsPts = DeathAugments.HeadshotPointScaleLocal( this );
			var firstBlood = DeadshotAugments.FirstBloodScaleLocal( this );

			var hasVult = PerkEffects.HasVulture( this );
			var oneIn = VultureAugments.OneIn( this, PickupDrops.VultureOneIn );
			var extraRoll = VultureAugments.HasExtraAmmoRoll( this );
			var reach = VultureAugments.ReachFor( this, 1f );
			var snail = TimeAugments.HasSnailsPace( this );

			// ⚠️ PUBLISHED FROM THE RING ITSELF rather than recomputed, so what other machines draw
			// is the ring that actually exists here — not a second opinion about where one would go.
			var ring = TortoiseRing;
			var ringAt = ring.IsValid() ? ring.WorldPosition : Vector3.Zero;
			var ringR = ring.IsValid() ? ring.Radius : 0f;
			var ringFlags = ring.IsValid() ? TortoiseAugments.FlagsOf( ring ) : 0;
			var ringStacks = ring.IsValid() ? ring.Stacks : 0;

			if ( RingAt != ringAt ) RingAt = ringAt;
			if ( RingRadius != ringR ) RingRadius = ringR;
			if ( RingFlags != ringFlags ) RingFlags = ringFlags;
			if ( RingStacks != ringStacks ) RingStacks = ringStacks;

			if ( PlateLuck != plate ) PlateLuck = plate;
			if ( PowerupLuck != power ) PowerupLuck = power;
			if ( VultureLuck != vult ) VultureLuck = vult;
			if ( HeadshotPointLuck != hsPts ) HeadshotPointLuck = hsPts;
			if ( FirstBloodLuck != firstBlood ) FirstBloodLuck = firstBlood;

			if ( HasVultureNet != hasVult ) HasVultureNet = hasVult;
			if ( VultureOneIn != oneIn ) VultureOneIn = oneIn;
			if ( VultureExtraRoll != extraRoll ) VultureExtraRoll = extraRoll;
			if ( VultureReach != reach ) VultureReach = reach;
			if ( SnailsPaceNet != snail ) SnailsPaceNet = snail;

			return;
		}

		IsDown = DownedNet;
		IsOutOfRound = OutOfRoundNet;

		if ( !IsDown ) return;

		var c = Components.Get<PlayerController>();
		if ( c.IsValid() ) c.IsDucking = true;
	}

	/// <summary>
	/// A bled-out player has no body in the world. Runs on every machine, networked or not.
	///
	/// ⛔ BLEEDING OUT USED TO LEAVE THE CORPSE CRAWLING. The rule is *"the player respawns next
	/// round"*, and `RoundManager.BringBackTheBledOut` is the second half of it — but between the
	/// two the body simply stayed on the floor with a countdown reading zero, which looks like a
	/// player who can still be saved and cannot be.
	///
	/// ⚠️ KEYED ON `IsOutOfRound`, NOT ON `HasBledOut` — see the note on `OutOfRoundNet`. Game
	/// over sets the second on everybody, and the bodies have to stay.
	///
	/// ⚠️ THE RENDERER'S OBJECT, NOT THE PLAYER'S. Disabling the whole player would stop
	/// `OnUpdate`, which is what publishes this state in the first place, and would leave every
	/// other machine holding whatever it last heard. The root keeps ticking; only the body goes.
	///
	/// ⛔ AND NOT `RenderType`, WHICH `NZPlayers.Control` ASSERTS BACK TWICE A SECOND. It sets
	/// `ShadowRenderType.On` on any body that is not mine — correctly, because a cloned body
	/// arrives hidden and that fix is load-bearing. Writing `Off` here would be undone within
	/// 500ms and would look like an intermittent bug. A disabled GameObject draws nothing whatever
	/// `RenderType` then says.
	///
	/// ⚠️ UNCONDITIONAL, NOT INSIDE THE `Networking.IsActive` GUARD ABOVE IT. Bleeding out
	/// happens solo too, and a despawn that only worked in multiplayer would be untestable here.
	/// </summary>
	/// <summary>
	/// Whether THIS is the reason the body is switched off. Null until the first reconcile.
	///
	/// ⛔ IT EXISTS BECAUSE THE RECONCILE WAS A STANDING ASSERTION, AND THAT WAS A REAL FAULT.
	/// `ApplyOutOfRoundBody` runs every frame for every body — it has to, because a proxy learns
	/// `IsOutOfRound` from a synced property and there is no event to hang it on — and it wrote the
	/// ON direction as readily as the OFF one. So for a living player it re-enabled the collider,
	/// gravity and motion every single frame, forever, whether or not anything had disabled them.
	///
	/// `Noclip.Detach` takes those exact four fields; this method's own note says so in as many
	/// words — *"the same four things Noclip.Detach takes, and in the same order, because they are
	/// the same four things"* — and then fought it for them at frame rate. Noclip switched itself
	/// off again the next tick.
	///
	/// ⚠️ SO IT ONLY RESTORES WHAT IT TOOK. Two transitions instead of a permanent assertion: the
	/// body is taken once when the player goes out, given back once when they return, and in every
	/// other frame this method does nothing at all — which also retires a
	/// `FindMode.EverythingInSelfAndDescendants` walk per body per frame.
	///
	/// ⚠️ NULLABLE SO A HOTLOAD RECONCILES RATHER THAN GUESSES, the same shape
	/// `ZombieAI._capsuleSolid` uses. Unknown + not out of round means "leave it alone": asserting
	/// ON from an unknown state is exactly the behaviour being removed.
	/// </summary>
	bool? _tookBody;

	void ApplyOutOfRoundBody()
	{
		// ⚠️ THE CHEAP TEST FIRST. Nothing to do is the overwhelmingly common case — every frame
		// of every living player — and it must not cost a hierarchy walk to discover that.
		if ( IsOutOfRound == (_tookBody ?? false) ) return;

		var ctrl = Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
		if ( !ctrl.IsValid() ) return;

		_tookBody = IsOutOfRound;

		var show = !IsOutOfRound;

		var body = ctrl.Renderer.IsValid() ? ctrl.Renderer.GameObject : null;
		if ( body.IsValid() && body.Enabled != show ) body.Enabled = show;

		// ⛔ HIDING THE MESH IS NOT REMOVING THE PLAYER, AND THE FIRST VERSION ONLY DID THAT.
		// User: *"the player is still there but invisible and can still be revived."* An invisible
		// body still has a capsule in the doorway, still falls when you take its floor away, and
		// still answers the revive search. All four have to go, not just the one you can see.
		//
		// ⚠️ THE SAME FOUR THINGS `Noclip.Detach` TAKES, and in the same order, because they
		// are the same four things: the collider stops you at a wall, gravity pulls, physics owns
		// the transform, and the controller steers. Anything less leaves one half of a player.
		var col = ctrl.ColliderObject;
		if ( col.IsValid() && col.Enabled != show ) col.Enabled = show;

		var rb = ctrl.Body;

		if ( rb.IsValid() && rb.Gravity != show )
		{
			// ⚠️ ZEROED BEFORE MOTION IS DISABLED, and again on the way back — a body frozen
			// mid-fall keeps its velocity and hands it straight back when it is unfrozen, which
			// would drop a revived player out of the sky at whatever speed they were falling.
			rb.Velocity = Vector3.Zero;
			rb.AngularVelocity = Vector3.Zero;

			rb.Gravity = show;
			rb.MotionEnabled = show;
		}
	}

	public void MirrorDown( bool down )
	{
		if ( down == IsDown ) return;

		// ⛔ A MIRROR MAY PUT YOU DOWN. IT MAY NOT STAND YOU BACK UP.
		//
		// This arrives from `PushHealth`, which sends the host's STALE copy of your health and
		// whether that copy considers you down. A stale copy that still reads 150hp reports
		// `down = false` — so any hit at all revived a downed player, which is precisely the
		// knife-to-revive the user found. Reviving is a real event with a real cause behind it
		// (`ReviveAugments`, Quick Revive, a round reset); it should never be the side effect of
		// somebody else's arithmetic.
		//
		// ⚠️ GOING DOWN IS STILL MIRRORED, because that direction is safe: the host deciding you
		// are down when you are not is a decision, and it is the host's to make. The reverse is
		// only ever a disagreement.
		if ( !down ) return;

		GoDown();
	}

	/// <summary>
	/// Put this player on the floor regardless of whether a revive is possible.
	///
	/// ⛔ SEPARATE FROM GoDown BECAUSE IT MUST BYPASS CanBeRevived. Game over is
	/// exactly the case where nobody can be revived, so routing through GoDown
	/// would take the Die branch and end the run that is already ending.
	///
	/// ⚠️ No bleedout is started. The countdown's only job is to decide whether
	/// the run ends, and it already has.
	/// </summary>
	public void ForceDown()
	{
		IsDown = true;

		// ⛔ LATCHED AS ALREADY-RESOLVED, not cleared. This down starts NO
		// bleedout — the run is already over — but TickBleedout only asks
		// whether IsDown is set and whether the timer has elapsed, and a
		// leftover elapsed timer answers yes to both. That printed
		// "[nz] bled out" for a player who was KILLED outright and never
		// crawled: a log line describing an event that did not happen.
		//
		// ⚠️ Revive() clears it, so a new game after the lobby can bleed out
		// normally again.
		_bledOut = true;

		// ⛔ AND NO SELF-REVIVE (2026-09-27). `TickBleedout` asks about Quick Revive before it looks at
		// the latch above, and the self-revive clock is only ever set in `GoDown` — so a solo Quick
		// Revive player floored by game over read an old, long-elapsed clock as ready and stood up
		// behind the score screen, spending a charge.
		SelfReviveIn = float.MaxValue;
		Hp?.Reset( Difficulty.MaxHealth );
	}

	/// <summary>
	/// Take the player's weapons away.
	///
	/// ⚠️ Destroys the weapon GameObjects rather than emptying the inventory.
	/// The viewmodel is a child of the weapon, so removing the item from the
	/// inventory alone can leave a gun drawn on screen with nothing behind it —
	/// and on game over the point is that the weapon is visibly gone.
	///
	/// ⚠️ `StartGame` re-equips, so this is not permanent across runs.
	/// </summary>
	public void StripWeapons()
	{
		// ⚠️ THROUGH THE INVENTORY, so `Items` empties with the objects. Destroying
		// them directly left dead entries in the slots — the player came back from a
		// game over with two phantom weapons they could not switch to, and a slot
		// count that refused to accept a new gun.
		// ⛔ THE COUNT IS DEDUPED, AND IT USED TO BE DOUBLE. This was `n = Inventory.Count`
		// followed by `n++` for every weapon the sweep below found — and the sweep finds the
		// SAME weapons, because inventory items are descendants of the player. So two guns logged
		// "stripped 4", which reads as two objects nobody accounted for. It cost a real detour:
		// Quick Revive's down-swap logs "2 weapon(s) held for you" beside it, and "4 stripped, 2
		// held" looks exactly like two weapons being destroyed without being remembered.
		//
		// ⚠ ONLY THE COUNT WAS EVER WRONG. `GameObject.Destroy()` is deferred to the end of the
		// frame, so the sweep still sees what `Inventory.Clear()` just destroyed and destroys it
		// again — harmless, and the reason the double-count happened at all.
		var stripped = new HashSet<GameObject>();

		foreach ( var w in Inventory.Weapons.ToList() )
			if ( w.IsValid() ) stripped.Add( w );

		Inventory.Clear();

		// Anything spawned outside the inventory (a hotload survivor) still needs
		// clearing, or it keeps rendering with nothing holding it.
		foreach ( var w in Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ).ToList() )
		{
			if ( !w.IsValid() ) continue;
			stripped.Add( w.GameObject );
			w.GameObject.Enabled = false;
			w.GameObject.Destroy();
		}

		if ( stripped.Count > 0 ) Log.Info( $"[nz] stripped {stripped.Count} weapon(s)" );
	}

	/// <summary>
	/// Killed outright — no down, no bleedout, straight to the score screen.
	///
	/// ⚠️ Health is left at zero here, unlike GoDown. There is nothing further to
	/// survive, and Health.Apply's IsDead early-return is now doing exactly the
	/// right thing: a corpse should not keep taking hits.
	/// </summary>
	void Die()
	{
		Log.Warning( "[nz] KILLED — no reviver available" );

		// ⛔ THE HOST ENDS THE RUN, THE SAME AS THE BLED-OUT PATH ABOVE — and this one was
		// missed. `RoundManager` is per-machine, so a client dying outright put ITSELF on a score
		// screen while the host's game carried on, until the next `RoundNow` broadcast overwrote
		// the state and snatched the screen back. Reaching here at all means nobody could revive
		// them, so the run really is over; it is only the announcement that has one author.
		if ( Networking.IsActive && !NZGame.IsHost )
			NZNet.GameOverAsk( "You were killed" );
		else
			RoundManager.Instance?.EndGame( "You were killed" );
	}

	/// <summary>
	/// Killed outright, by what nobody survives — basalt's risen lava (`HexPlatforms.Lava.cs`). Down, and bled out at once:
	/// no revive and no Quick Revive, and the bleedout's own rules decide the rest — out until the next round while somebody
	/// is still up, the run over when nobody is.
	///
	/// ⚠️ ON THE MACHINE THAT OWNS THE BODY, as every down is (`NZNet.HurtPlayer`). Called again while already down it only
	/// keeps the clock at zero; the bleedout itself, next frame, is `TickBleedout`'s.
	/// </summary>
	public void KillOutright()
	{
		if ( IsOutOfRound || _bledOut ) return;

		if ( !IsDown )
		{
			GoDown();

			// nobody could have revived them, so the down was a death already (`Die`)
			if ( !IsDown ) return;
		}

		// ⚠️ THE SELF-REVIVE FIRST: `TickBleedout` asks it before the bleedout, and would stand them back up in the lava
		SelfReviveIn = float.MaxValue;
		BleedsOutIn = 0f;
	}

	/// <summary>Perk ids this player owns, in the order they were bought.
	///
	/// ⚠️ ON THE PLAYER, not in a static keyed by player. Every "selected mon"
	/// style static holder in this project's sibling codebase became a source of
	/// stale state; a list that dies with the player cannot outlive them.</summary>
	public List<string> Perks { get; private set; } = new();

	/// <summary>
	/// Augments equipped per perk — perk id to its major/minors.
	///
	/// ⛔ A LAZY PROPERTY, NOT A FIELD INITIALISER, and that is not style. A field
	/// added to a component that already exists in a running scene arrives NULL after a
	/// hotload — the initialiser does not re-run for instances that were migrated. Every
	/// read here would then throw inside a razor build, which presents as the whole menu
	/// silently refusing to draw rather than as an error with a line number.
	///
	/// ⚠️ ON THE PLAYER, NOT IN A STATIC KEYED BY PLAYER. SERVER_ROADMAP.md §4 rule 2,
	/// same reason <see cref="Perks"/> and <see cref="Salvage"/> live here.
	/// </summary>
	Dictionary<string, PerkAugments.Loadout> _augments;

	public Dictionary<string, PerkAugments.Loadout> Augments
		=> _augments ??= new();

	/// <summary>What Creative tops the wallet up to every frame.</summary>
	public static int CreativePoints { get; set; } = 100000;

	/// <summary>What Creative tops SALVAGE up to every frame. Same figure as the
	/// points, so neither is the one that runs out first while testing.</summary>
	public static int CreativeSalvage { get; set; } = 100000;

	/// <summary>Does this player already have it?</summary>
	public bool HasPerk( string id ) => Perks.Contains( id );

	/// <summary>
	/// Lose perks down to the configured floor.
	///
	/// ⚠️ Removes from the END, keeping the EARLIEST purchases — see
	/// PlayerSettings.PerksKeptOnDown for why.
	///
	/// ⛔ MAX HEALTH IS PUT BACK IF JUGGERNOG IS AMONG THE LOST. Every other perk
	/// effect is a live multiplier and needs nothing, but Juggernog wrote the
	/// health maximum directly — leaving it would hand the player a permanent 250
	/// HP for one purchase, which is precisely the "losing a perk leaves the buff"
	/// failure the multiplier design was chosen to avoid.
	/// </summary>
	/// <summary>Perks survive a down entirely, from here on.
	///
	/// ⚠️ SET BY THE BUYABLE ENDING'S "perma perks" OPTION and by nothing else, matching
	/// the original's `ply:SetPreventPerkLoss(true)`. It is a run-scoped grant, not a
	/// setting — there is deliberately no config field for it, because the only thing
	/// upstream that turns it on is buying an ending you then keep playing past.</summary>
	public bool PreventPerkLoss { get; set; }

	public void LosePerksOnDown()
	{
		// ⛔ CHECKED BEFORE THE COUNT, NOT FOLDED INTO IT. Expressing this as "keep = all"
		// would work today and break the moment an augment lowers the keep count — the
		// grant is absolute, and a floor that something else can push down is not.
		if ( PreventPerkLoss ) return;

		// ⚠ M2 GRAVE KEEPER SUPPLIES THE COUNT. Reading the config directly meant no augment
		// could ever change it — and returning a count rather than a bool keeps this method's
		// arithmetic and its log line unchanged.
		var keep = Math.Max( 0, ReviveAugments.PerksKeptFor( this ) );
		if ( Perks.Count <= keep ) return;

		var hadJugg = HasPerk( "jugg" );
		var hadMule = HasPerk( "mulekick" );

		var lost = Perks.Count - keep;

		// ⛔ THE AUGMENTS *STAY*, BY REQUEST, AND THIS IS A DELIBERATE DEVIATION FROM THE ORIGINAL.
		// This loop used to run `PerkAugments.ClearFor` over the removed ids, matching GMod's
		// `OnPlayerLostPerk` hook so that a re-bought perk started clean. Now going down costs you
		// the PERK and not the several thousand salvage of augments hung on it; they reset only on
		// game over or restart, in `RoundManager.EndGame` and `StartGame`.
		//
		// ⚠️ AN ORPHANED AUGMENT IS INERT, WHICH IS THE ONLY REASON THIS IS SAFE. Every augment
		// file's `Has` is `p.HasPerk( Perk ) && PerkAugments.Has( ... )` — both halves, always — so
		// while the perk is gone its augments do nothing, and re-buying it turns them back on. That
		// double check was written as a defence against exactly this state being a bug; it is now
		// the mechanism that makes it a feature.
		//
		// ⛔ SO DO NOT "TIDY" ANY AUGMENT'S `Has` DOWN TO THE `PerkAugments.Has` HALF. That would
		// turn every augment the player has ever bought into a permanent free buff the moment they
		// went down. `MuleKickAugments.KeepsWeapons` reads the augment WITHOUT the perk on purpose
		// and documents why; it is the exception, not the pattern.
		Perks.RemoveRange( keep, lost );

		// ⛔ THE THIRD WEAPON IS DESTROYED, NOT DROPPED — UNLESS INSURANCE IS OWNED.
		// Losing Mule Kick costs you whatever was in the slot it granted, permanently, and
		// that is the deal the perk makes. Mule Kick's M4 augment changes it: TrimToCap now
		// hands each path to `MuleKickAugments.Remember` before destroying the object, and
		// re-buying the perk returns it.
		//
		// ⚠️ TRIMMED AFTER THE PERKS ARE REMOVED so the cap has already fallen — and
		// therefore BEFORE `PerkAugments.ClearFor` would wipe the augment that authorises
		// the escrow. That ordering is load-bearing: the perk must be gone (so the cap is
		// right) while the augment is still readable (so Insurance can fire). See
		// `MuleKickAugments.KeepsWeapons`.
		if ( hadMule && !HasPerk( "mulekick" ) && Inventory.IsValid() )
			Inventory.TrimToCap();

		if ( hadJugg && !HasPerk( "jugg" ) && Hp.IsValid() )
		{
			Hp.Max = Difficulty.MaxHealth;
			if ( Hp.Current > Hp.Max ) Hp.Reset( Hp.Max );
		}

		Log.Info( $"[nz] down — lost {lost} perk(s), kept {Perks.Count}"
			+ (Perks.Count > 0 ? $" ({string.Join( ", ", Perks )})" : "") );
	}

	/// <summary>Drop every perk. Health is NOT restored here — see
	/// PerkEffects.Clear, which owns that because it is the only effect that is
	/// not a live multiplier.</summary>
	public void ClearPerks()
	{
		// ⚠️ Augments too. They are keyed by perk id, so leaving them would mean a
		// fresh game's first Juggernog arrived pre-augmented from the last one.
		PerkAugments.ClearAll( this );
		Perks.Clear();
	}

	/// <summary>Give a perk. False if they already had it.</summary>
	/// <summary>Extra perk slots BOUGHT at the Wunderfizz, on top of the config's
	/// allowance.
	///
	/// ⛔ ON THE PLAYER, NOT THE CONFIG. `ActiveConfig.Player` is a SHARED settings
	/// object — writing a purchase into it would hand the slot to everyone in the
	/// server and survive into the next game. Same reason the perk effects are
	/// derived rather than written.</summary>
	/// <summary>Ignition feedback window — the original's `nz.NapalmDecay`,
	/// set to CurTime()+2 on every ignite (enemies/sv_hooks.lua:506).</summary>
	public TimeUntil NapalmFlash { get; set; } = -1f;

	/// <summary>Bumped on every ignite.
	///
	/// ⛔ THE HUD HASHES THIS, NOT THE REMAINING TIME. The fade runs in CSS, so
	/// the panel only needs to rebuild ONCE per ignition — hashing the countdown
	/// would rebuild the tree every frame it was visible. A counter also restarts
	/// the animation when a second ignite lands during the first one's fade,
	/// which a bare bool would not.</summary>
	public int NapalmFlashCount { get; private set; }

	/// <summary>Show the ignition icon.</summary>
	public void FlashNapalm( float seconds = 2f )
	{
		NapalmFlash = seconds;
		NapalmFlashCount++;
	}

	/// <summary>
	/// Which of the four this player is, by id, or null for none.
	///
	/// ⚠️ ON `NZPlayer`, NOT A STATIC, per `SERVER_ROADMAP.md` §4 rule 2 — it is something a
	/// player OWNS, and a static would give every player the same character the moment there are
	/// two. Same reason `BonusPerkSlots` lives here.
	/// </summary>
	/// <summary>
	/// ⛔ THE SETTER REBUILDS THE VIEWMODEL, BECAUSE THE HANDS ARE A FUNCTION OF THIS.
	///
	/// `Weapon.CreateModels` picks the hands mesh ONCE, from `PlayerCharacters.HandsFor( Owner )`,
	/// and `CreateModels` runs from `Weapon.OnStart`. So a character that lands AFTER the weapon
	/// started leaves the weapon's own generic gloves on screen forever. Measured, one session,
	/// both machines at the same instant:
	///
	///     HOST   'Player (Cifosi)'  character 'nikolai'  hands=nikolai_arms.vmdl  ✅
	///     CLIENT 'Player (Milhouse)' character 'dempsey' hands=v_hands.vmdl       ⛔
	///
	/// The client's character was correctly SET — the body knew it was Dempsey. The weapon had
	/// simply already chosen its hands by the time it arrived, and nothing ever asked again.
	/// `nz_hands_fix` rebuilt it and the hands came right, which is what proves it is ordering and
	/// not a missing or broken value.
	///
	/// ⚠️ HERE RATHER THAN AT THE CALL SITES. Four places write this — the lobby pick,
	/// `nz_character`, `RefreshBodies` from the net table, and `nz_hands_fix` — and two of them
	/// already remembered to rebuild afterwards. Fixing the two that did not would leave the fifth
	/// writer, whenever it is added, with the same bug. A property whose value has a visible
	/// consequence should apply it, not trust every caller to.
	///
	/// ⚠️ IT IS SAFE TO FIRE ON A CLONE OR A PROXY: `RefreshCharacterHands` returns unless a weapon
	/// is actually held, and `RebuildViewModel` returns on a proxy. Setting this on somebody else's
	/// body — which `RefreshBodies` does constantly — does nothing at all.
	///
	/// ⚠️ ONLY ON A REAL CHANGE, so the twice-a-second tick that re-asserts the same value cannot
	/// destroy and rebuild a viewmodel forever.
	/// </summary>
	public string CharacterId
	{
		get => _characterId;
		set
		{
			if ( _characterId == value ) return;

			_characterId = value;
			RefreshCharacterHands();
		}
	}

	string _characterId;

	/// <summary>
	/// What `PlayerCharacters.ApplyBody` last dressed this body as, ON THIS MACHINE: a character id, "" for its owner's own
	/// s&box avatar, or null when it has not been dressed (or must be again). `NZPlayers.RefreshBodies` re-applies whenever it
	/// differs from the character the owner picked, so a teammate who never picks one is still dressed (2026-10-05). Not synced.
	/// </summary>
	public string BodyLook { get; set; }

	/// <summary>
	/// WHICH CONNECTION THIS BODY BELONGS TO, WRITTEN DOWN RATHER THAN INFERRED.
	///
	/// ⛔ TWO ATTEMPTS TO ASK THE ENGINE BOTH FAILED, IN OPPOSITE DIRECTIONS. `Network.IsProxy`
	/// describes SIMULATION, so an object nobody owns is not a proxy on any machine and every
	/// reader that meant "theirs" got back "mine". Comparing `Network.OwnerId` to
	/// `Connection.Local.Id` then failed the other way: on the client its own body did not match,
	/// so it decided it had NO body at all — never enabled, never armed, never placed, camera
	/// left at the scene's default position while the host watched a perfectly good copy of it
	/// standing on the spawn point with zombies chasing it.
	///
	/// ⚠️ SO OWNERSHIP IS NO LONGER DERIVED FROM ANYTHING. The host knows exactly whose body
	/// it is making — it is holding the `Connection` — and it writes that down HERE, before
	/// `NetworkSpawn`. A `[Property]` set before the spawn is serialised INTO it, which is the
	/// same mechanism that already carries a zombie's `Variant` across, and the only network
	/// behaviour this file depends on.
	///
	/// ⚠️ A STRING, NOT A `Guid`, ON PURPOSE. It has to survive serialisation and read
	/// plainly in `nz_bodies`, and neither is worth a second unverified assumption tonight.
	///
	/// ⚠️ EMPTY MEANS "NOBODY HAS SAID", NOT "NOBODY". `PlayerPresence.Mine` falls back to the
	/// engine's own answer in that case, so a body that predates this is no worse off than before.
	/// </summary>
	[Property] public string OwningConnection { get; set; } = "";

	/// <summary>
	/// Rebuild the held weapon's viewmodel so new hands appear immediately.
	///
	/// ⛔ THE HANDS ARE CHOSEN WHEN THE VIEWMODEL IS BUILT, ONCE. Nothing re-reads the model
	/// afterwards, so switching character with a weapon already out changes nothing at all until the
	/// next weapon swap — which reads as "the command did not work".
	///
	/// ⚠️ IT DESTROYS THE VIEWMODEL RATHER THAN POKING THE RENDERER. The hands renderer is
	/// bone-merged to the viewmodel and created alongside it; swapping just the `Model` leaves the
	/// merge pointing at a skeleton chosen for the old mesh. Letting it rebuild is one line and
	/// cannot half-apply.
	/// </summary>
	public void RefreshCharacterHands()
	{
		// ⚠️ `Rarity.HeldBy`, NOT the first weapon component found. With two slots the component list
		// usually yields the HOLSTERED gun first — that method documents the point.
		var wep = Rarity.HeldBy( this );
		if ( !wep.IsValid() ) return;

		wep.RebuildViewModel();
	}

	/// <summary>
	/// Show the player's own body instead of the viewmodel. `nz_thirdperson`.
	///
	/// ⛔ A DIAGNOSTIC, NOT A MODE. nZombies is first person — the viewmodel, the ADS solve and
	/// every weapon offset assume it. This exists so a player model can be LOOKED AT: the retargeted
	/// bodies animate, and there is no other way to see whether they do it correctly.
	///
	/// ⚠️ STATIC BECAUSE IT IS A VIEW, NOT STATE A PLAYER OWNS. `SERVER_ROADMAP.md` §4 asks whether
	/// a player would take it with them; a debug camera would not.
	/// </summary>
	public static bool ThirdPerson { get; set; }

	public int BonusPerkSlots { get; set; }

	/// <summary>
	/// How many ammo-box refills this player has bought THIS ROUND, PER WEAPON.
	///
	/// ⛔ KEYED ON THE PREFAB PATH, NOT A SINGLE COUNTER. The escalation is per weapon: refilling
	/// your pistol twice must not make the first refill of your rifle expensive. This started as a
	/// plain int and that was wrong — it taxed the player rather than the gun.
	///
	/// ⛔ ON THE PLAYER, NOT ON THE BOX. The count has to survive walking to a different box —
	/// per-box state means a map with two boxes has no escalation at all. Same reason
	/// `BonusPerkSlots` and `PapLevels` live here: it is something a player owns
	/// (`SERVER_ROADMAP.md` §4 rule 2).
	///
	/// ⚠️ THE SAME JOIN KEY AS `PapLevels`, deliberately — the prefab path. Both are "something
	/// this player has accumulated about that weapon", and a second keying scheme for the same
	/// question is how the two end up disagreeing about which gun you are holding.
	///
	/// ⚠️ CLEARED BY `AmmoBox.OnRoundStart`, called from RoundManager.BeginRound. Without that the
	/// price never comes back down and the "same round" half of the rule is silently dropped.
	/// </summary>
	public Dictionary<string, int> AmmoBoxUses { get; private set; } = new();

	/// <summary>Refills bought for this prefab this round, or 0.</summary>
	public int AmmoBoxUsesFor( string prefab )
		=> !string.IsNullOrEmpty( prefab ) && AmmoBoxUses.TryGetValue( prefab, out var n ) ? n : 0;

	/// <summary>Record a refill against one weapon. Returns the new count.</summary>
	public int AddAmmoBoxUse( string prefab )
	{
		if ( string.IsNullOrEmpty( prefab ) ) return 0;
		return AmmoBoxUses[prefab] = AmmoBoxUsesFor( prefab ) + 1;
	}

	/// <summary>Forget every refill. For a new round.</summary>
	public void ClearAmmoBoxUses() => AmmoBoxUses.Clear();

	// ── ARMOR + SALVAGE ───────────────────────────────────────────────

	/// <summary>
	/// Armor tier owned, 0 = none.
	///
	/// ⛔ ON THE PLAYER, NOT A STATIC. SERVER_ROADMAP.md §4 rule 2 — "a new static
	/// is tuning, or it is a bug; if it holds something a player owns, it belongs
	/// on NZPlayer". The tuning (caps, plate size, bleed-through) is in
	/// ActiveConfig.Armor, which is SHARED; this is the part each player owns.
	///
	/// ⚠️ STARTS AT 0, so a fresh player has no vest and takes full damage. The
	/// original spawned everyone at tier 1 until its own [NZAUGMENT] edit moved it
	/// to 0 for the Arsenal. Tiers come from nz_armor_tier until the Arsenal exists.
	/// </summary>
	public int ArmorTier { get; set; }

	/// <summary>Current armor points. Never above <see cref="ArmorMax"/>.</summary>
	public float Armor { get; set; }

	/// <summary>Plates carried, spent one at a time to refill armor.</summary>
	public int ArmorPlates { get; set; }

	/// <summary>Salvage held. Spent on perk augments; see PerkAugments.</summary>
	public int Salvage { get; set; }

	/// <summary>
	/// Weapon prefab paths Mule Kick's M4 "Insurance" is holding for this player.
	///
	/// ⛔ PATHS, NOT GameObjects. The weapon is destroyed moments after being remembered, so
	/// a reference would hold a corpse. A path re-spawns through the normal give path, which
	/// means `ApplyStoredUpgrades` runs and the Pack-a-Punch tier, rarity and tech come back
	/// with it — no separate snapshot needed.
	///
	/// ⚠️ A LAZY PROPERTY, not a field initialiser: a field added to a component that
	/// already exists in a running scene arrives null after a hotload (§1).
	/// </summary>
	List<string> _insuredWeapons;

	public List<string> InsuredWeapons => _insuredWeapons ??= new();

	/// <summary>
	/// Give a weapon by prefab path WITHOUT making it active, reporting success.
	///
	/// ⚠️ A WRAPPER RATHER THAN A SECOND IMPLEMENTATION. `GiveWeapon` already does the work
	/// but returns the weapon and defaults to making it active — Insurance restores into a
	/// spare slot and must not yank the player's gun out of their hands mid-fight.
	/// </summary>
	public bool GiveWeaponByPath( string prefabPath )
		=> GiveWeapon( prefabPath, makeActive: false ).IsValid();

	/// <summary>
	/// When Vulture Aid may drop another gas cloud.
	///
	/// ⛔ A COOLDOWN RATHER THAN A LIVE-COUNT CAP, unlike `VultureDrops`. Those are pickups
	/// the player collects, so counting how many are on the floor is the right limit; gas is
	/// not collected and expires on its own, so the thing worth limiting is how OFTEN it can
	/// appear. A count would also let a player who never walked into their clouds sit on
	/// four permanently and block the roll.
	///
	/// ⚠️ A TimeUntil seeded at default (already elapsed), so a fresh player can drop one on
	/// their first kill rather than waiting out a cooldown they never spent.
	/// </summary>
	public TimeUntil VultureGasReady { get; set; }

	/// <summary>
	/// Seconds accumulated toward Vulture Aid m3 Gas Feed's next clip.
	///
	/// ⚠️ A PLAIN FLOAT, NOT A `TimeUntil`, because it has to STOP when the player steps out
	/// of the cloud rather than keep counting down. A TimeUntil would keep running while the
	/// player was outside and pay out the instant they stepped back in.
	///
	/// ⚠️ On the player, not on VultureAugments — it is per-player state, and a static there
	/// would be one timer shared by everybody (SERVER_ROADMAP §4).
	/// </summary>
	public float GasFeedProgress { get; set; }

	/// <summary>
	/// Consecutive kills without being hit, for Vigor Rush's M4 "Killstreak".
	///
	/// ⚠️ SEPARATE FROM `DeadshotFocus` even though both are kill streaks, because they
	/// count different things and are broken by different events — any kill vs a headshot
	/// kill, taking damage vs a body-shot kill. One counter serving both would mean either
	/// augment resetting the other's progress.
	/// </summary>
	public int VigorStreak { get; set; }

	/// <summary>
	/// Speed Cola m5 Fluid Motion — when the current reload BEGAN.
	///
	/// ⛔ ON THE PLAYER, NOT A STATIC. Per-player state in a static would give every
	/// player in the lobby one shared burst; the statics in the augment files are tuning
	/// values only.
	///
	/// ⚠️ STARTS FAR IN THE PAST ON PURPOSE. A `TimeSince` defaults to zero, which
	/// reads as "began this instant" — so a freshly spawned player would sprint for the
	/// first second of their life having reloaded nothing.
	/// </summary>
	public TimeSince FluidMotionSince { get; set; } = 999f;

	/// <summary>
	/// Vigor Rush's m5 "Vengeance" window — counts down after taking damage.
	///
	/// ⚠️ A TimeUntil, like `AdrenalUntil`, for the reason recorded there: a written damage
	/// buff means owning the job of writing it back, and a missed write-back is permanent.
	/// A window that expires cannot leak.
	/// </summary>
	public TimeUntil VigorVengeance { get; set; }

	/// <summary>
	/// Consecutive headshot kills, for Deadshot's M4 "Focus".
	///
	/// ⛔ CAPPED WHERE IT IS INCREMENTED, in DeadshotAugments — not here and not only at the
	/// read. An uncapped counter would keep climbing for the rest of a game, so a player who
	/// reached the ceiling and then took a body-shot kill would still be at the ceiling and
	/// the reset would mean nothing.
	///
	/// ⚠️ ON THE PLAYER, not a static keyed by player — SERVER_ROADMAP.md §4 rule 2, same as
	/// Perks, Salvage, ArmorTier and AdrenalUntil.
	/// </summary>
	public int DeadshotFocus { get; set; }

	/// <summary>
	/// Juggernog's m4 "Adrenal Surge" window — counts down after taking damage.
	///
	/// ⛔ A TIMESTAMP, NOT A WRITTEN SPEED. The original stamps a networked float and
	/// lets the movement code read it, and that shape is the right one: writing a speed
	/// means owning the job of writing it back, and a missed write-back is a permanent
	/// buff. A window that simply expires cannot leak.
	///
	/// ⚠️ ON THE PLAYER even though only one augment reads it, because it is per-player
	/// state — SERVER_ROADMAP.md §4 rule 2, same as Perks, Salvage and ArmorTier.
	/// </summary>
	public TimeUntil AdrenalUntil { get; set; }

	/// <summary>
	/// Vulture Aid drops this player currently has lying in the world.
	///
	/// ⛔ A LIVE COUNT, NOT A PER-ROUND TALLY. The original caps it at 4 and
	/// DECREMENTS on removal (sv_hooks.lua:155-170 plus the drop's own CallOnRemove),
	/// so the limit is "four of yours on the floor at once", not "four per round".
	/// Reading it as a round tally would let the perk stop working permanently four
	/// kills into a round.
	///
	/// ⚠️ Released on BOTH collection and expiry — see Pickup.Release. A leak
	/// here silently switches the perk off with nothing on screen to explain it.
	/// </summary>
	public int VultureDrops { get; set; }

	/// <summary>
	/// Armor ceiling for the owned tier. 0 at tier 0.
	///
	/// ⚠️ A PROPERTY, NOT A STORED VALUE, so raising the tier or editing the
	/// config moves the cap with no reconciliation step. ActiveConfig is edited
	/// live by the settings tool, and a cached copy would go stale exactly the way
	/// ActiveConfig.Notify() exists to warn about.
	/// </summary>
	/// <remarks>
	/// ⚠️ NOW INCLUDES THE AUGMENT. This used to be the BASE cap with Jugg m3 applied on top by
	/// Armor.CapFor, so the two disagreed and anything reading this property saw a ceiling the
	/// player could exceed. Armor owns the whole sum; this is the one true ceiling.
	/// </remarks>
	// ⚠️ NZombies.Armor, QUALIFIED -- `Armor` on this type is the float property, and the
	// unqualified name binds to it rather than to the static class. Same reason the plate log
	// above spells it out.
	public float ArmorMax => NZombies.Armor.CapFor( this );

	/// <summary>Armor is owned and has something left in it.</summary>
	public bool HasArmor => ArmorTier > 0 && Armor > 0f;

	/// <summary>Perks this player may hold — config allowance plus bought slots.</summary>
	/// <summary>
	/// How many perks this player may hold.
	///
	/// ⚠️ THE AUGMENT TERM IS DERIVED, NOT STORED. `BonusPerkSlots` is a running total that
	/// Wunderfizz increments and RoundManager clears; Vulture Aid's Fortune's Gin (+2) and
	/// Extra Slot (+1) instead read off the CURRENT loadout, so un-equipping them cannot
	/// leak a permanent free slot the way a matching decrement would if it were ever missed.
	/// </summary>
	public int PerkSlots => Math.Max( 1, ActiveConfig.Player.PerkSlots )
		+ BonusPerkSlots
		+ VultureAugments.BonusSlots( this );

	/// <summary>No room for another perk.</summary>
	public bool PerksFull => Perks.Count >= PerkSlots;

	public bool GivePerk( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) || Perks.Contains( id ) ) return false;

		// ⛔ THE CAP IS ENFORCED HERE, at the one place perks are added, rather
		// than in the Wunderfizz menu. The menu is not the only caller — console
		// commands and any future machine come through here too, and a check that
		// lives in the UI is a check that only covers the UI.
		if ( PerksFull )
		{
			Log.Info( $"[nz-perk] no free slot ({Perks.Count}/{PerkSlots}) — '{id}' refused" );
			return false;
		}

		Perks.Add( id );

		// ⚠️ AFTER THE ADD, NOT BEFORE. Everything above this line can still refuse the purchase —
		// speaking first would have the character react to a perk they did not get.
		CharacterVoice.Say( "perkdrink", this );

		return true;
	}

	/// <summary>
	/// THIS MACHINE'S OWN PLAYER. The one answer to "which player am I".
	///
	/// ⛔ IT REPLACES `GetAllComponents&lt;NZPlayer&gt;().FirstOrDefault()`, WHICH WAS EVERYWHERE.
	/// In single player those are the same object, so it was correct for a year and became wrong
	/// the moment a second body existed — silently, because the scene lists SOMEBODY and every
	/// caller then works perfectly against the wrong person:
	///
	///   `ArsenalMenu.User`      → the arsenal measured the HOST'S distance, so it opened for a
	///                             client only when the host walked up to it
	///   `ArsenalMenu.PlayerSalvage` → a client read the host's salvage
	///   `DownedHud`, `DamageOverlay`, `CameraShake`, `WeaponStatsPanel` → the wrong player's
	///                             screen effects, on somebody else's numbers
	///
	/// ⚠️ `PlayerPresence.Find` IS THE PROJECT'S EXISTING ANSWER, used by the spawner, the net
	/// tick, `nz_see` and `nz_arms`. This is a shorthand for it in the one type every caller
	/// already has in hand — NOT a second way of deciding, which is what a second way would be.
	///
	/// ⚠️ NULL IS A REAL ANSWER, in the lobby and between spawns. Callers already null-check,
	/// because `FirstOrDefault` could return null too.
	/// </summary>
	/// <summary>
	/// The world model of whatever I am holding, as a path. Written by me, read by everyone.
	///
	/// ⛔ A `[Sync]` PROPERTY, WHICH ONLY WORKS BECAUSE THE PLAYER IS `NetworkMode.Object` NOW.
	/// On a `Snapshot` object `[Sync]` does nothing after the join snapshot
	/// (`SBOX_MULTIPLAYER.md` §2) — so this was impossible until spawning moved to
	/// `prefabs/player.prefab`. It is the first thing in this project to replicate without an RPC.
	///
	/// ⚠️ A PATH, NOT THE WEAPON. Weapon prefabs are `NetworkMode.Never` and never reach another
	/// machine; a path can be `Model.Load`ed anywhere, with no prefab and no component.
	/// </summary>
	[Sync] public string WorldModelPath { get; set; } = "";

	/// <summary>How I am posing with it — <see cref="SWB.Shared.HoldTypes"/> as an int.</summary>
	///
	/// ⚠️ AN INT BECAUSE `[Sync]` CARRIES UNMANAGED TYPES AND STRINGS. The enum lives in
	/// `SWB.Shared`, and `ThirdPersonWeapon` casts it back at the one place it is read.
	[Sync] public int HoldTypeId { get; set; }

	/// <summary>
	/// AM I ON THE FLOOR. Written by my own machine, read by everybody else's.
	///
	/// ⛔ BEING DOWN HAS NEVER LEFT THE MACHINE IT HAPPENED ON, and that is what made the co-op
	/// revive impossible rather than merely broken. `ReviveAugments.TargetFor` looks for a nearby
	/// player with `IsDown` set — on the rescuer's machine every teammate is a PROXY whose `IsDown`
	/// was always false, so the search returned nothing, every frame, for every rescuer. There was
	/// no bug to see: holding Use over a crawling teammate simply did nothing at all.
	///
	/// ⚠️ `NZNet.PlayerHealth` ALREADY CARRIED A DOWN FLAG AND IS NOT THIS. It is addressed to
	/// the victim alone — `Connection.Local.Id != who` returns — so it tells YOU that you are down
	/// and tells nobody else. That is the right shape for health, which is private; it is the wrong
	/// shape for a state other players have to act on.
	///
	/// ⚠️ THE OWNER IS THE AUTHOR. Bleedout, self-revive, the perk loss and the weapon strip all
	/// run on the machine that owns the body; this only publishes the RESULT.
	/// </summary>
	[Sync] public bool DownedNet { get; set; }

	/// <summary>
	/// AM I OUT UNTIL THE NEXT ROUND. Written by my own machine, read by everybody else's.
	///
	/// ⛔ SEPARATE FROM `DownedNet` BECAUSE THEY ARE DIFFERENT STATES WITH DIFFERENT RULES. A
	/// downed player is a live situation — reviving them is the whole point. A player who is out
	/// has no marker, no prompt and no body in the world. Folding both into one flag would make
	/// every reader ask "which kind of down is this" at the point of use.
	///
	/// ⛔ AND IT IS *NOT* `HasBledOut`, WHICH WAS THE FIRST ATTEMPT AND WAS WRONG.
	/// `RoundManager.EndGame` calls `ForceDown` on every player, which sets `_bledOut` as an
	/// already-resolved LATCH — so keying the despawn on it would have emptied the world behind the
	/// game-over screen, and that world is deliberate: *"the score screen reads as a defeat rather
	/// than a pause because the world behind it shows one."*
	///
	/// ⚠️ `IsDown` STAYS TRUE THROUGH IT, which is load-bearing elsewhere — `ZombieAI` skips
	/// targets that are down, and a player who is out must stay skipped.
	/// </summary>
	[Sync] public bool OutOfRoundNet { get; set; }

	/// <summary>
	/// WHOLE SECONDS OF BLEEDOUT LEFT. Written by my own machine, read by everybody else's.
	///
	/// ⛔ `BleedsOutIn` IS A `TimeUntil`, WHICH IS A LOCAL CLOCK AND NOTHING ELSE. On my copy of
	/// your body it was never started, so it read zero — and the marker over a downed teammate
	/// showed **0** from the moment they fell to the moment they died. The one number the marker
	/// exists to carry was the one number it could not have.
	///
	/// ⚠️ AN INT, WHICH IS WHY THIS IS AFFORDABLE AT ALL. `[Sync]` sends on change; a float
	/// counting down would send every frame, per downed player. A whole second changes once a
	/// second — and whole seconds are all the marker ever draws.
	///
	/// ⚠️ CEILINGED, to agree with `DownedHud`. Two countdowns of the same timer rounding two
	/// ways is the bug that made the owner's own display stick on 1.
	/// </summary>
	[Sync] public int BleedoutLeftNet { get; set; }

	/// <summary>
	/// AM I ABOUT TO STAND MYSELF UP. Written by my own machine, read by everybody else's — <see cref="SelfReviveComing"/>.
	///
	/// ⛔ THE HOST'S EVERYBODY-DOWN CHECK HAS NO OTHER WAY TO KNOW (<see cref="TickEverybodyDown"/>, 2026-09-27).
	/// Perks, augments and the self-revive clock live on the owner alone, so without this the host would take a
	/// teammate five seconds from standing up for plain down, and end a run their Quick Revive was about to save.
	/// </summary>
	[Sync] public bool SelfReviveNet { get; set; }

	/// <summary>
	/// AM I HIDDEN FROM THE HORDE. Written by my own machine, read by everybody else's, by the host's zombies above all
	/// (2026-10-05, with the Arsenal and Wunderfizz menus).
	///
	/// ⛔ THE CAUSES LIVE ON THE OWNER AND THE ZOMBIES ON THE HOST. A menu open at the Arsenal or the Wunderfizz is the owner's
	/// alone (<see cref="AtMachine"/>), and a Timeslip m2 Time Out bought there or at a perk machine is written by the
	/// purchase, on the buyer's machine, so the host's copy of a client never knew and its zombies kept coming. This carries
	/// the owner's own verdict (<see cref="HiddenHere"/>) across, and <see cref="IsUntargetable"/> ORs it in.
	/// </summary>
	[Sync] public bool HiddenNet { get; set; }

	/// <summary>
	/// WHAT MY AUGMENTS MULTIPLY, for the four things the HOST has to decide on my behalf.
	///
	/// ⛔ `Perks` AND `Augments` ARE PLAIN FIELDS AND CROSS TO NOBODY, so `HasPerk` and
	/// `PerkAugments.Has` are false for every proxy — and four consequences of a kill are rolled on
	/// the HOST, against exactly that proxy: the plate drop, the powerup drop, the Vulture drop and
	/// the headshot points bonus. Death Perception's m1 and m2, Vulture Aid's M1 Carrion and Death
	/// Perception's m5 were all doing nothing for every client.
	///
	/// ⚠️ FOUR NUMBERS, NOT THE WHOLE LOADOUT, AND THAT IS A DELIBERATE LIMIT. Syncing the
	/// equipped-augment list would fix every `Has()` on a proxy at once — and would immediately
	/// DOUBLE the damage terms, because the shooter now pre-multiplies those itself before relaying
	/// a hit (see `Health.AttackerScale`). Two mechanisms for one question is how a perk ends up
	/// applied twice. These four are exactly the reads the shooter cannot perform, because the
	/// thing being decided is an object the host must spawn or a number it must award.
	///
	/// ⚠️ THEY CHANGE ONLY WHEN AUGMENTS DO, so change-detected `[Sync]` sends nothing in a
	/// normal round.
	/// </summary>
	[Sync] public float PlateLuck { get; set; } = 1f;

	/// <inheritdoc cref="PlateLuck"/>
	[Sync] public float PowerupLuck { get; set; } = 1f;

	/// <inheritdoc cref="PlateLuck"/>
	[Sync] public float VultureLuck { get; set; } = 1f;

	/// <inheritdoc cref="PlateLuck"/>
	[Sync] public float HeadshotPointLuck { get; set; } = 1f;

	/// <summary>
	/// Deadshot M2 First Blood's multiplier. A FIFTH of the same kind, and it arrived by accident.
	///
	/// ⛔ IT WAS BRIEFLY APPLIED ON THE CLIENT AND PAID OUT ON EVERY BULLET. First Blood asks
	/// whether the VICTIM is undamaged, and a client's copy of a zombie never takes damage locally
	/// — `Health` has no `[Sync]` whatever — so the test was permanently true. The victim half
	/// belongs to the host; only "does the shooter own M2" had to travel.
	/// </summary>
	[Sync] public float FirstBloodLuck { get; set; } = 1f;

	/// <summary>
	/// Vulture Aid, as the HOST needs to see it — the gate and the four numbers behind it.
	///
	/// ⛔ `PickupDrops.RollVulture` RETURNS ON ITS FIRST LINE FOR EVERY CLIENT. It asks
	/// `PerkEffects.HasVulture( player )` of the host's proxy, where it is false, so the perk's
	/// whole drop path — the base roll, Carrion's tightened one-in-N, Deep Pockets' extra roll and
	/// its bigger drops — was unreachable no matter how correct everything below it was.
	///
	/// ⚠️ AND `VultureReach` IS THE ONE THAT IS NOT ABOUT DROPPING. m5 Long Arms triples pickup
	/// REACH, and reach is tested on the host when it decides who is standing on something. The one
	/// machine that answers that question is the one that does not know you own the augment.
	///
	/// ⛔ WHY NOT JUST SYNC THE AUGMENT LIST AND FIX EVERY `Has()` AT ONCE: because the shooter
	/// now pre-multiplies its own damage terms before relaying a hit, and those terms are safe only
	/// because they evaluate to 1 against a perk-less proxy. Give the proxy its perks and every one
	/// of them applies twice. These scalars are exactly the reads the shooter cannot perform.
	/// </summary>
	[Sync] public bool HasVultureNet { get; set; }

	/// <inheritdoc cref="HasVultureNet"/>
	[Sync] public int VultureOneIn { get; set; } = 9;

	/// <inheritdoc cref="HasVultureNet"/>
	[Sync] public bool VultureExtraRoll { get; set; }

	/// <inheritdoc cref="HasVultureNet"/>
	[Sync] public float VultureReach { get; set; } = 1f;

	/// <summary>
	/// Timeslip M2 Snail's Pace — do I slow zombies by standing near them?
	///
	/// ⛔ THE AURA IS EVALUATED ON THE HOST, sweeping every player and asking each whether they
	/// own M2. Every client answers no, so a client's aura has never slowed anything.
	/// </summary>
	[Sync] public bool SnailsPaceNet { get; set; }

	/// <summary>
	/// Victorious Tortoise's planted ring, as a fact ABOUT ME that every machine can read.
	///
	/// ⛔ THE RING IS `scene.CreateObject()` WITH `NetworkMode.Never`, so it has only ever been
	/// visible to the player standing in it. Nobody has seen a teammate's ring, which for an
	/// augment whose whole point is *"you or any player who is also inside"* is most of the perk.
	///
	/// ⚠️ A SYNCED PROPERTY, NOT A PAIR OF MESSAGES, AND THAT IS THE WHOLE REASON THIS IS SHORT.
	/// A ring is not an event — it is planted, it persists, and it dies when its owner walks out of
	/// it or loses the perk. "Plant" and "drop" as two broadcasts would mean a lifetime to keep in
	/// step and a lost message leaving a ring burned into somebody's screen. A property that every
	/// machine reconciles against each frame cannot desynchronise: whatever it says, is.
	///
	/// ⚠️ RADIUS 0 MEANS NO RING, so one number carries both the existence and the size — and
	/// the size is needed anyway, because `RadiusFor` reads augments a proxy does not have.
	/// </summary>
	[Sync] public Vector3 RingAt { get; set; }

	/// <inheritdoc cref="RingAt"/>
	[Sync] public float RingRadius { get; set; }

	/// <summary>
	/// WHAT THE RING GRANTS — bit 1 Dig In (M1), bit 2 Rallying Stand (M4), bit 4 Entrench (m5).
	///
	/// ⛔ WITHOUT THIS THE MIRRORED RING WAS SCENERY. `TortoiseAugments.DamageScale` reads the
	/// augments off the RING, not off the shooter — that is the whole design, because a teammate
	/// with no Tortoise still benefits from standing in yours. But `MirrorRing` built its copy with
	/// every flag false, so on the host a client's own Dig In ring granted ×0 of its ×1.5, and on a
	/// client the host's ring granted none of its half-damage defence. The zone was drawn on every
	/// screen and worked on exactly one.
	///
	/// ⚠️ FLAGS, NOT THREE BOOLS, because they travel beside `RingStacks` as one small pair and
	/// a ring's grants only ever change when the ring is replanted.
	/// </summary>
	[Sync] public int RingFlags { get; set; }

	/// <summary>
	/// M4's shared kill count for this player's ring.
	///
	/// ⚠️ THE OWNER IS THE ONE AUTHOR. A kill inside somebody else's ring is relayed to them
	/// (`NZNet.TortoiseRally`) rather than counted locally, so this number has a single writer and
	/// every other machine reads it. Counting on both ends would drift within a round.
	/// </summary>
	[Sync] public int RingStacks { get; set; }

	/// <summary>
	/// Seconds of bleedout to SHOW for this player, wherever it is being asked from.
	///
	/// ⚠️ ONE PROPERTY SO NO CALLER HAS TO KNOW WHOSE BODY IT IS HOLDING. My own comes off the
	/// live timer; everyone else's off the published int.
	/// </summary>
	public int BleedoutShown => !Networking.IsActive || PlayerPresence.Mine( GameObject )
		? (int)MathF.Ceiling( MathF.Max( 0f, BleedsOutIn ) )
		: BleedoutLeftNet;

	/// <summary>
	/// Bled out while somebody else was still standing: no body, no revive, back next round.
	///
	/// ⚠️ SET IN EXACTLY ONE PLACE — the branch of `TickBleedout` that spares the run — and
	/// cleared in exactly one, `Revive`. `RoundManager.BringBackTheBledOut` reaches the second.
	/// </summary>
	public bool IsOutOfRound { get; private set; }

	/// <summary>
	/// `nz_revive_out [0/1]` — put yourself out of the round, or bring yourself back.
	///
	/// ⛔ THE STATE IS OTHERWISE UNREACHABLE ALONE. It is set in one place — the branch of
	/// `TickBleedout` that runs when SOMEBODY ELSE IS STILL UP — and solo that branch is never
	/// taken, because bleeding out alone ends the run instead. So the despawn, the frozen movement
	/// and the "back next round" screen could not be looked at without a second machine and a
	/// forty-five second wait.
	///
	/// ⚠️ IT SETS THE REAL FLAG, not a preview. Everything that reads it — the body, the speed
	/// term, the marker on other screens, `nz_revive_state` — behaves exactly as it will in a
	/// round, and `nz_revive_out 0` or any revive puts it back.
	/// </summary>
	[ConCmd( "nz_revive_out" )]
	public static void CmdOutOfRound( int state = -1 )
	{
		var p = Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		p.IsOutOfRound = state < 0 ? !p.IsOutOfRound : state > 0;

		Log.Info( $"[nz] out of round {(p.IsOutOfRound ? "YES — body despawned, frozen, back next round" : "no")}"
			+ $" · down={p.IsDown} bled={p.HasBledOut}" );
	}

	public static NZPlayer Local
	{
		get
		{
			var go = PlayerPresence.Find();
			return go.IsValid() ? go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) : null;
		}
	}

	public void AddPoints( int amount )
	{
		// ⛔ SOMEBODY ELSE'S BODY CANNOT BE PAID HERE. Zombies think only on the host, so a kill
		// by a client is awarded on the HOST, to the host's proxy copy of that client's body — and
		// raising a number on a copy pays nobody. The award is sent to the machine that owns the
		// body, and runs through this same method there.
		//
		// ⚠️ AT `AddPoints` RATHER THAN IN THE ZOMBIE. Every award in the game comes through here
		// — kills, pickups, powerups, boarding a window, augment payouts — so one relay covers them
		// all and the next one added is covered before it is written.
		if ( Networking.IsActive && PlayerPresence.Theirs( GameObject ) )
		{
			var owner = NZPlayers.OwnerOf( GameObject );
			if ( !string.IsNullOrEmpty( owner ) ) NZNet.AwardPoints( owner, amount );
			return;
		}

		Points += amount;

		// ⚠️ THE RUNNING TOTAL IS NOT THE BALANCE. `Points` is what you can spend; the scoreboard
		// wants what you EARNED, so a player who spent everything on doors still reads as having
		// scored. Recorded here for the same reason the popup is — every award routes through this
		// one method, so no call site has to remember.
		PlayerStats.For( this )?.RecordPoints( amount );

		// ⚠️ HERE, NOT AT THE CALL SITES. Points are awarded from the damage path
		// and from console commands today and will be awarded from doors, boxes
		// and perks later — hanging the popup off each of those is how one gets
		// forgotten. This is the one place every gain passes through.
		//
		// ⚠️ NOT LOCAL-PLAYER GUARDED, and correct today only because the HUD is
		// not either: SurvivalHud takes `GetAllComponents<NZPlayer>().First()`. In
		// co-op both need the same fix at the same time — a guard here alone would
		// just move the bug.
		PointsPopups.Add( amount );
	}

	/// <summary>
	/// Spend points. False (and nothing deducted) if they cannot afford it.
	///
	/// ⚠️ The check and the deduction are the SAME call on purpose. Splitting
	/// them into CanAfford + Take invites the caller to do one and forget the
	/// other, which is how you get free doors.
	/// </summary>
	/// <param name="quiet">The caller plays its own sound in place of the cha-ching — a map's wall buy spends to a flame
	/// (`Gameplay.WallBuySound`, 2026-09-28). The refusal still sounds.</param>
	public bool TrySpend( int amount, bool quiet = false )
	{
		if ( amount <= 0 ) return true;

		if ( Points < amount )
		{
			// ⚠️ HERE, NOT AT THE CALL SITE. The original plays deny from
			// `_PLAYER:Buy`, but ours has exactly one refusal point — this — so
			// hanging it here means every future buyable (doors, boxes, perks,
			// walls) gets the refusal sound without remembering to add it.
			NZSound.Play( NZSound.PurchaseDeny );

			// ⚠️ THE SAME ARGUMENT THIS BLOCK ALREADY MAKES FOR THE DENY SOUND — one refusal point,
			// so every future buyable gets the line without remembering to add it.
			CharacterVoice.Say( "nomoney", this );

			return false;
		}

		Points -= amount;

		// ⚠️ ONLY ON A SUCCESSFUL SPEND. A refused purchase must not flash a red
		// number — the player did not lose anything, and the feedback for "cannot
		// afford" is the prompt, not the counter.
		PointsPopups.Add( -amount );

		// Matches the original, where the buy sound hangs off TakePoints rather
		// than off any individual purchase — so it fires for anything that costs
		// points, whatever that turns out to be.
		if ( !quiet ) NZSound.Play( NZSound.Purchase );

		return true;
	}

	/// <summary>
	/// Set points outright.
	///
	/// ⚠️ RoundManager.StartGame USES THIS, so it is no longer test-only — the
	/// comment here said "nothing in the game sets points directly" and that stopped
	/// being true when a survival game began resetting the wallet. It is the only
	/// public way in, because Points has a private setter.
	/// </summary>
	public void SetPoints( int amount )
	{
		Points = Math.Max( 0, amount );
	}

	/// <summary>
	/// Back to full, upright. Used by round restart and revives.
	///
	/// ⚠️ Resets to the CONFIG's max, not this component's MaxHealth property.
	/// OnStart seeds health from ActiveConfig (150 by default), so reading the
	/// property here handed a revived player 100 instead — a silent nerf that
	/// only showed up after the first down.
	/// </summary>
	public void Revive() => Revive( false );

	/// <summary>
	/// Stand up. <paramref name="plate"/> fills the armor as well — the rescuer's m4 Plate Carrier.
	///
	/// ⛔ A REVIVE PERFORMED ON SOMEBODY ELSE'S BODY IS PERFORMED ON A COPY. Everything below
	/// this relay — the health reset, the perk loss, the weapon restore, clearing `_bledOut` — acts
	/// on local state, so a rescuer running it against a proxy stood up a body that only they could
	/// see and left the real player crawling. The synced flag would then overwrite even that a
	/// frame later, so the rescuer's own screen would show the revive fail for no stated reason.
	///
	/// ⚠️ EVERY CALLER IS COVERED BY PUTTING IT HERE. `ReviveAugments.Complete`, the round
	/// manager's round-start refresh, `nz_revive` and Quick Revive all funnel through this method,
	/// and only one of them — self-revive — is ever aimed at a body the caller owns. Relaying at
	/// each call site instead would be four places to keep in step.
	///
	/// ⚠️ THE SAME SHAPE AS `AddPoints` AND `PlayerStats.Record*`: the machine that owns the
	/// state performs the change, because it is the only one that can publish the result.
	/// </summary>
	public void Revive( bool plate )
	{
		if ( Networking.IsActive && PlayerPresence.Theirs( GameObject ) )
		{
			var owner = NZPlayers.OwnerOf( GameObject );

			if ( !string.IsNullOrEmpty( owner ) )
			{
				// ⛔ AND THIS COPY'S LATCH GOES WITH THE ASK (2026-09-29). `EndGame` floors every body — this host's copy of each
				// client too — and `ForceDown` latches `_bledOut` on it, which nothing here ever cleared: this branch returned
				// first. The host then read that client as bled out for the rest of the session: every round `BringBackTheBledOut`
				// "brought them back" — teleported to a spawn, alive — and `AnyoneStillUp` counted them as out, so the host
				// bleeding out ended the game with a teammate still standing. User: *"the client is always respawning at the
				// start of the round, even when it is alive"*. The owner's own state is the truth, and it is on its way.
				_bledOut = false;

				NZNet.ReviveAsk( owner, plate );
				return;
			}
		}

		// ⚠ CAPTURED BEFORE `LosePerksOnDown`, because the weapon restore below must only run
		// when this call actually stood someone up.
		var wasDown = IsDown;

		// ⛔ ONLY WHEN ACTUALLY COMING UP FROM A DOWN. `Revive` is also called by
		// RoundManager at round start and on reset, for players who were never
		// down — without this guard, starting a round would silently strip the
		// perks of everyone standing.
		//
		// ⚠️ Before clearing IsDown, so anything watching the down state sees the
		// loss as part of it rather than a frame later.
		// ⛔ THE REAL WEAPONS COME BACK BEFORE THE PERKS GO (2026-09-27). `LosePerksOnDown` trims the
		// inventory to the new cap when Mule Kick is lost — and it ran while a downed player held only
		// the pistol, so there was nothing to trim; the restore after it then handed back all three
		// guns through `inv.Add`, which does not enforce the cap. Restored first, the third gun is in
		// the inventory when the trim looks, and goes to Insurance's escrow if that is held.
		if ( wasDown ) ReviveAugments.OnRevived( this );

		if ( IsDown ) LosePerksOnDown();

		IsDown = false;
		_bledOut = false;
		IsOutOfRound = false;

		// ⚠️ CLEARED ON THE WAY UP AS WELL AS THE WAY DOWN. The rescuer's "stopped" is not sent
		// on success — finishing IS the stop — so without this the bar would sit full on screen
		// until the grace window expired, on a player who is already standing.
		BeingRevivedSeconds = 0f;

		// ⛔ RESET TO THE CONFIG MAX, THEN LET JUGGERNOG PUT ITS BONUS BACK. This line alone
		// sets Max to the BASE 150, so a player who kept Juggernog through a down came up with
		// 150/150 instead of 250/250 — the perk was still owned and its health silently was not.
		//
		// ⚠ IT WAS UNREACHABLE UNTIL QUICK REVIVE'S M2 GRAVE KEEPER EXISTED. Every path here
		// either lost your perks on the way (the base down rule) or ran at round start where the
		// round's own refresh followed. Keeping a perk through a down is what made it show.
		//
		// ⚠ `RefreshHealth`, NOT THE JUGG MATH INLINED. Two places computing max health is the
		// shape INSTRUCTIONS.md §3 warns diverges, and this one would: M1 Overhealth adds to the same
		// number. It is documented safe to call for a player without the perk, and it heals up
		// only — so the `Reset` above is still what fills you.
		Hp?.Reset( Difficulty.MaxHealth );
		JuggAugments.RefreshHealth( this );

		// ⚠️ Release the forced crouch explicitly. TickBleedout stops asserting it
		// once IsDown is false, but it never sets it back — the controller only
		// stands you up when you are NOT holding crouch, so without this a player
		// revived while the key happens to be down stays stuck ducking.
		var c = Components.Get<PlayerController>();
		if ( c.IsValid() )
			c.IsDucking = false;

		// ⚠ THE REAL WEAPONS COME BACK, and only if this call actually stood someone up —
		// `Revive()` is also reachable from the console on a player who was never down, and
		// re-giving an empty list there would strip them for nothing.
		// ⚠️ GATED ON `wasDown` FOR THE SAME REASON THE AUGMENT HOOK IS. `Revive` also runs at round
		// start and on reset for players who were never down; without this every round would open
		// with the crew thanking someone.
		if ( wasDown ) CharacterVoice.Say( "revived", this );

		// ⛔ THE PLATE IS APPLIED HERE, ON THE PATIENT'S OWN MACHINE, AND NOWHERE ELSE. m4 Plate
		// Carrier is the RESCUER'S augment paying out on the PATIENT — two players, two machines,
		// and `patient.Armor` is local state like every other. `ReviveAugments.PlateCarrier` set it
		// on whichever copy the rescuer happened to be holding, which co-op is never the real one.
		//
		// ⚠️ THE FLAG SAYS "THE RESCUER HAD m4", not "fill the armor". Whether there is a vest
		// to fill is the patient's own business and is decided here, where the tier lives.
		if ( plate ) ReviveAugments.PlateFor( this, "co-op" );
	}

	/// <summary>
	/// `nz_quickrevive` — grant/remove the Quick Revive placeholder.
	///
	/// ⚠️ The switch between "going down is possible" and "the next hit kills",
	/// which is otherwise unreachable in solo without a perk system to buy it
	/// from. `nz_down` with this off should kill outright; with it on, crawl.
	/// </summary>
	[ConCmd( "nz_quickrevive" )]
	public static void CmdQuickRevive( int state = -1 )
	{
		var p = Game.ActiveScene?.GetAllComponents<NZPlayer>().FirstOrDefault();
		if ( !p.IsValid() ) { Log.Info( "[nz] no player" ); return; }

		// ⚠️ The OVERRIDE, not the read-only property. HasQuickRevive now derives
		// from the owned perk list and cannot be assigned.
		p.QuickReviveOverride = state < 0 ? !p.HasQuickRevive : state > 0;
		Log.Info( $"[nz] quick revive {(p.HasQuickRevive ? "ON" : "off")} — "
			+ $"downing is {(p.CanBeRevived ? "possible" : "fatal")} "
			+ $"({p.OthersStillUp} other player(s) up)" );
	}
}