Player/ReviveAugments.cs

Perk/augment system for the Quick Revive perk and nine related augments. Implements self-revive limits, co-op revive targeting and timing, downed-weapon swapping, plate carrier armor refill, field-medic healing/speed, phase-shift survival and diagnostic console commands.

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

namespace NZombies;

/// <summary>
/// Quick Revive — the co-op revive, the downed pistol, and the nine augments.
///
/// | | effect |
/// |---|---|
/// | base | self-revive **3 times solo**; downed players hold an **M1911**; reviving others takes **1.5s** with the perk, **3s** without it |
/// | M1 **Phoenix** | **5** self-revives, and reviving others is a further **3× faster** — **0.5s** |
/// | M2 **Grave Keeper** | keep **every perk** when downed |
/// | M3 **Guardian Aura** | revive players just by **standing near** them |
/// | M4 **Last Stand** | keep your **real weapons** when downed, and a **kill self-revives** you |
/// | m1 **Rapid Recovery** | health regen delay **−20%** |
/// | m2 **Fast Metabolism** | health regen **20% faster** |
/// | m3 **Field Medic** | reviving someone **heals you fully** and gives **2s** of speed |
/// | m4 **Plate Carrier** | anyone revived comes up with **full armor**, self-revive included |
/// | m5 **Phase Shift** | a fatal hit leaves you at **1 HP** and teleports you to a special spawn (**2 min**) |
///
/// ⛔ THIS PERK ABSORBED TOMBSTONE SODA. Both were deferred, and between them only two augments
/// needed something that does not exist (friendly AI, and Tombstone's own tombstone). Merged, the
/// nine are all buildable — M2 is Tombstone's Grave Keeper, M4 its Last Stand, m5 its Phase Shift.
///
/// ⛔ TWO BASE SYSTEMS DID NOT EXIST AND ARE BUILT HERE. Four augments depended on them:
///
/// - **There was no co-op revive at all.** `NZPlayer.Revive()` was reached only by self-revive and
///   a console command — no way for one player to pick another up. M1's speed half, M3, m3 and m4
///   are all modifiers on an interaction that had no implementation.
/// - **Being downed did not change your weapons.** `StripWeapons` existed and `GoDown` never
///   called it, so a downed player kept firing their LMG. The pistol swap is the base rule M4
///   then buys you out of.
///
/// ⚠️ SELF-REVIVE WAS UNLIMITED. `CanSelfRevive` already made it solo-only (on nobody else standing then; on being the
/// only player in the game since 2026-10-05, `NZPlayer.IsAlone`), but nothing counted uses — you could self-revive
/// forever. Now three, or five with M1.
///
/// ⛔ M3 AND m3 CANNOT BE TESTED SOLO, AND A DUMMY PLAYER IS NOT WORTH BUILDING. They act
/// on another player, so the obvious move is a downed stand-in — but 122 sites in this project
/// resolve "the player" as `GetAllComponents<NZPlayer>().FirstOrDefault()`. A second `NZPlayer` in
/// the scene would silently point an unknowable share of them at the dummy, and enumeration
/// order is not something I can pin down. That trades every other diagnostic in the project
/// for two augments.
///
/// ⚠️ m4 USED TO BE ON THAT LIST AND IS NOT ANY MORE. It fired only from `Complete`, the co-op
/// path, so solo it did nothing — by request it now also runs on self-revive, which makes it the
/// one of the three that a single player can actually verify. Buy an armor tier, spend it, go
/// down, self-revive, and the vest should be full.
///
/// So they stay untested until there is a real second client. What CAN be checked solo is that
/// they are wired and reachable: `nz_aug_revive` prints the aura reach, the heal, the speed
/// window and the armor grant with the augment's own numbers resolved.
/// </summary>
public static class ReviveAugments
{
	const string Perk = "revive";

	// ══ tuning ════════════════════════════════════════════════════════════════

	static int? _selfReviveUses;
	/// <summary>Self-revives allowed per game, solo only. 3.</summary>
	public static int SelfReviveUses { get => _selfReviveUses ?? 3; set => _selfReviveUses = value; }

	static int? _phoenixUses;
	/// <summary>M1 Phoenix — what the self-revive count BECOMES. 5.</summary>
	public static int PhoenixUses { get => _phoenixUses ?? 5; set => _phoenixUses = value; }

	static float? _reviveSeconds;
	/// <summary>
	/// How long picking another player up takes with NO Quick Revive. 3s.
	///
	/// ⚠️ 3, NOT 4. Lowered by request so the perk's halved figure lands on a round 1.5s.
	/// </summary>
	public static float ReviveSeconds { get => _reviveSeconds ?? 3f; set => _reviveSeconds = value; }

	static float? _perkSpeed;
	/// <summary>
	/// Holding Quick Revive — how much faster you pick others up. ×2, so 3s becomes **1.5s**.
	///
	/// ⛔ THE PERK HAD NO CO-OP SPEED BONUS AT ALL, and its own registry line has always said
	/// *"Revive yourself; revive others faster."* Only M1 Phoenix made reviving faster, so the
	/// second half of that sentence was false for anyone who had not rolled that one augment out
	/// of four majors. This is the base effect the description was already promising.
	///
	/// ⚠️ IT COMPOSES WITH M1 RATHER THAN BEING REPLACED BY IT — 3 / 2 / 3 = **0.5s** with both.
	/// `Has( .., "M1" )` already requires the perk, so the two dividers never double-count the
	/// perk itself; they stack because they are separate purchases.
	/// </summary>
	public static float PerkSpeed { get => _perkSpeed ?? 2f; set => _perkSpeed = value; }

	static float? _phoenixSpeed;
	/// <summary>M1 Phoenix — how much faster reviving others is, ON TOP of the perk's ×2. ×3.</summary>
	public static float PhoenixSpeed { get => _phoenixSpeed ?? 3f; set => _phoenixSpeed = value; }

	static float? _reviveReach;
	/// <summary>How close you must be to revive someone. 90u.</summary>
	public static float ReviveReach { get => _reviveReach ?? 90f; set => _reviveReach = value; }

	static float? _auraReach;
	/// <summary>
	/// M3 Guardian Aura — how close counts as "standing near". 140u.
	///
	/// ⚠️ WIDER THAN `ReviveReach`, because the augment removes the button rather than the
	/// distance. Same reach would make it feel like nothing changed.
	/// </summary>
	public static float AuraReach { get => _auraReach ?? 140f; set => _auraReach = value; }

	static float? _regenDelayScale;
	/// <summary>m1 Rapid Recovery — multiplier on the regen delay. 0.8, i.e. −20%.</summary>
	public static float RegenDelayScale { get => _regenDelayScale ?? 0.8f; set => _regenDelayScale = value; }

	static float? _regenRateScale;
	/// <summary>
	/// m2 Fast Metabolism — multiplier on the gap between regen ticks. 0.8.
	///
	/// ⚠️ A SMALLER GAP IS FASTER REGEN, so "20% faster" is 0.8 here rather than 1.2. The value
	/// divides an interval, exactly like Speed Cola's reload figure — which `Weapon.Reload`'s own
	/// note records inverting once and getting a slower reload for its trouble.
	/// </summary>
	public static float RegenRateScale { get => _regenRateScale ?? 0.8f; set => _regenRateScale = value; }

	static float? _medicSpeedSeconds;
	/// <summary>m3 Field Medic — how long the speed boost lasts. 2s.</summary>
	public static float MedicSpeedSeconds { get => _medicSpeedSeconds ?? 2f; set => _medicSpeedSeconds = value; }

	static float? _medicSpeed;
	/// <summary>m3 Field Medic — how much faster you move during it. ×1.5.</summary>
	public static float MedicSpeed { get => _medicSpeed ?? 1.5f; set => _medicSpeed = value; }

	static float? _phaseCooldown;
	/// <summary>m5 Phase Shift — the cooldown. 120s.</summary>
	public static float PhaseCooldown { get => _phaseCooldown ?? 120f; set => _phaseCooldown = value; }

	/// <summary>What a downed player is given to hold, unless they have M4.</summary>
	public static string DownedWeapon { get; set; } = "prefabs/weapons/nz_m1911.prefab";

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

	// ══ base + M1 — self-revive ═══════════════════════════════════════════════

	/// <summary>How many self-revives this player gets in total. 3, or 5 with M1.</summary>
	public static int UsesFor( NZPlayer player )
		=> Has( player, "M1" )
			? Math.Max( 0, PhoenixUses )
			: Math.Max( 0, SelfReviveUses );

	/// <summary>
	/// Has this player any self-revives left.
	///
	/// ⚠️ THE COUNT IS CHECKED, NOT THE PERK. `NZPlayer.CanSelfRevive` already handles "solo and
	/// holding Quick Revive"; this adds only the budget, so the two questions stay separate and
	/// the solo rule keeps one author.
	/// </summary>
	public static bool HasSelfRevive( NZPlayer player )
		=> player.IsValid() && player.SelfRevivesUsed < UsesFor( player );

	/// <summary>Spend one. Called by the self-revive path.</summary>
	public static void SpendSelfRevive( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		player.SelfRevivesUsed++;

		Log.Info( $"[nz-aug] revive self-revive {player.SelfRevivesUsed}/{UsesFor( player )} used" );
	}

	// ══ base + M1 + M3 — picking others up ════════════════════════════════════

	/// <summary>
	/// Seconds to revive another player. **3s bare · 1.5s with Quick Revive · 0.5s with M1 too.**
	///
	/// ⛔ THE PERK DIVIDER IS SEPARATE FROM M1'S AND BOTH APPLY. Folding them into one factor
	/// would mean picking a number that is either the perk's or the augment's and calling it both;
	/// they are two purchases and they multiply. `Has` already gates M1 on owning the perk, so the
	/// perk half is never counted twice.
	///
	/// ⚠️ `HasPerk`, NOT `Has`. `Has` additionally demands an augment id — using it here would
	/// have made the base bonus require an augment, which is exactly the gap this closes.
	/// </summary>
	public static float SecondsFor( NZPlayer rescuer )
	{
		var seconds = ReviveSeconds;

		if ( rescuer.IsValid() && rescuer.HasPerk( Perk ) )
			seconds /= MathF.Max( 0.1f, PerkSpeed );

		if ( Has( rescuer, "M1" ) )
			seconds /= MathF.Max( 0.1f, PhoenixSpeed );

		return MathF.Max( 0.05f, seconds );
	}

	/// <summary>Does this player revive without holding the key — M3.</summary>
	public static bool RevivesPassively( NZPlayer player ) => Has( player, "M3" );

	/// <summary>How close this player can start a revive from.</summary>
	public static float ReachFor( NZPlayer player )
		=> RevivesPassively( player ) ? AuraReach : ReviveReach;

	/// <summary>
	/// The nearest downed player this one could pick up, or null.
	///
	/// ⚠️ EXCLUDES THE RESCUER, obviously, but also anyone who has already bled out — `IsDown` is
	/// still true for a frame after `Die`, and reviving a corpse would put a dead player back on
	/// their feet.
	/// </summary>
	public static NZPlayer TargetFor( NZPlayer rescuer )
	{
		if ( !rescuer.IsValid() || rescuer.IsDown ) return null;

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

		var reach = ReachFor( rescuer );
		var at = rescuer.WorldPosition;

		return scene.GetAllComponents<NZPlayer>()
			// ⛔ `IsOutOfRound` EXCLUDED, AND ITS ABSENCE WAS HALF OF "STILL THERE BUT INVISIBLE".
			// A player who has bled out keeps `IsDown` set — deliberately, because `ZombieAI` skips
			// targets that are down — so the revive search went on finding them after their body
			// was gone. Teammates got a prompt, held E, and stood up somebody who was not there.
			.Where( p => p.IsValid() && p != rescuer && p.IsDown && !p.IsOutOfRound )

			// ⛔ NOT THE ONE JUST PICKED UP, FOR A MOMENT (2026-09-27). A teammate's `IsDown` here is a
			// copy of their own machine's, so it stays true until their "I am up" comes back — and
			// until then they were a fresh target: a second revive began at once, and past the revive
			// time (0.5s with Phoenix, no key at all with Guardian Aura) a second `Complete` counted
			// the revive and paid Field Medic twice. Their machine refuses the second stand-up anyway.
			.Where( p => p != rescuer.JustRevived || rescuer.SinceJustRevived > JustRevivedGrace )
			.Where( p => p.Hp.IsValid() && !p.Hp.IsDead )
			.Where( p => at.Distance( p.WorldPosition ) <= reach )
			.OrderBy( p => at.Distance( p.WorldPosition ) )
			.FirstOrDefault();
	}

	/// <summary>
	/// Point the rescuer at a patient, or at nobody, and tell the patient either way.
	///
	/// ⛔ ONE AUTHOR FOR WHAT WERE THREE IDENTICAL PAIRS OF LINES. `Tick` cleared `RevivingWho`
	/// and `ReviveProgress` together in three places and set them in a fourth; every one of those
	/// is now also a message to another machine, and four copies of a two-line pattern is how one
	/// of them ends up not sending.
	///
	/// ⚠️ IT RETURNS EARLY ON NO CHANGE, so this is safe to call every frame from the two
	/// branches that run every frame — which is what makes it usable as the only entry point.
	/// </summary>
	static void SetReviving( NZPlayer rescuer, NZPlayer target )
	{
		var was = rescuer.RevivingWho;
		if ( was == target ) return;

		rescuer.RevivingWho = target;
		rescuer.ReviveProgress = 0f;

		if ( was.IsValid() ) Announce( was, 0f );
		if ( target.IsValid() ) Announce( target, SecondsFor( rescuer ) );
	}

	/// <summary>
	/// Tell a patient that a revive on them has started or stopped.
	///
	/// ⚠️ LOCAL WHEN THE PATIENT IS THIS MACHINE'S, which is every solo self-test and the case
	/// the RPC cannot serve — `ReviveBeing` is addressed by owner and a body with no session has no
	/// owner. Routing solo through the network would make the bar a multiplayer-only feature and
	/// leave it untestable here.
	/// </summary>
	static void Announce( NZPlayer patient, float seconds )
	{
		if ( !patient.IsValid() ) return;

		if ( !Networking.IsActive || PlayerPresence.Mine( patient.GameObject ) )
		{
			patient.BeingRevivedBy( seconds );
			return;
		}

		var owner = NZPlayers.OwnerOf( patient.GameObject );
		if ( string.IsNullOrEmpty( owner ) ) return;

		NZNet.ReviveBeing( owner, seconds );
	}

	/// <summary>
	/// Per-frame: run the co-op revive.
	///
	/// ⛔ THE PROGRESS LIVES ON THE RESCUER, NOT THE PATIENT, so two players reviving one downed
	/// teammate each build their own progress and the faster one wins. Progress on the patient
	/// would let two rescuers each contribute half and finish in half the time, which is a
	/// different mechanic than the one described.
	///
	/// ⚠️ PROGRESS RESETS WHEN THE TARGET CHANGES OR THE KEY IS RELEASED. A revive you can pause
	/// and resume is a revive you can do while running away, which is the opposite of the risk the
	/// interaction exists to create.
	/// </summary>
	public static void Tick( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		// ⛔ A RESCUER WHO GOES DOWN MID-REVIVE LETS GO OF IT (2026-09-27). This returned on
		// `IsDown` before clearing anything, so `RevivingWho` stayed set for the whole down — and
		// the weapon tucks the gun while it is set (`Weapon.ShouldTuckVar`), so a rescuer downed
		// mid-revive could not fire at all, Last Stand's kill-to-stand-up included, while the
		// patient's bar ran on to nothing. `SetReviving` does nothing when there is nothing to
		// clear, so this is safe every frame and on every body.
		if ( player.IsDown )
		{
			SetReviving( player, null );
			return;
		}

		// ⛔ MY BODY ONLY, AND IT IS CALLED FROM ABOVE `OnUpdate`'S GUARD. `NZPlayer.OnUpdate`
		// runs on every body in the scene — mine and my copy of everyone else's — and this method
		// reads `Input.Down( "Use" )`, of which there is exactly ONE per machine. Without this,
		// holding Use started a revive on every teammate's proxy at once: their progress bars all
		// advanced off MY key, and whichever proxy happened to be near a downed player would
		// "complete" a revive nobody performed.
		//
		// ⚠️ THE CHECK BELONGS HERE RATHER THAN AT THE CALL SITE. Moving the call below the
		// guard would fix today's caller and leave the trap set for the next one; this method
		// simply cannot be correct for a body whose keyboard is on another machine.
		if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) return;

		var target = TargetFor( player );

		if ( !target.IsValid() )
		{
			SetReviving( player, null );
			return;
		}

		// ⚠️ M3 NEEDS NO KEY. Everyone else holds Use — and `Input.Down`, not `Pressed`, because
		// this is a hold.
		if ( !RevivesPassively( player ) && !Input.Down( "Use" ) )
		{
			SetReviving( player, null );
			return;
		}

		if ( player.RevivingWho != target )
		{
			SetReviving( player, target );

			// ⚠️ INSIDE THE "JUST STARTED" BRANCH, NOT BESIDE THE PROGRESS TICK BELOW. That line runs
			// every frame of the revive; speaking there would fire the line once per frame and lean
			// entirely on the cooldown to hide it.
			CharacterVoice.Say( "reviving", player );
		}

		player.ReviveProgress += Time.Delta;

		if ( player.ReviveProgress < SecondsFor( player ) ) return;

		Complete( player, target );
	}

	/// <summary>
	/// m4 Plate Carrier — does this rescuer's revive fill the patient's armor.
	///
	/// ⛔ A QUESTION, ASKED BEFORE THE STAND-UP, AND THAT IS THE FIX (2026-09-27). It was a method
	/// that asked and filled in one go, called by the self-revive AFTER `Revive` — and `Revive`
	/// runs `LosePerksOnDown`, which takes Quick Revive with the rest, so `Has` read the augment as
	/// unowned and a self-revive never plated anybody unless M2 kept the perks. The answer is taken
	/// while the perk is still owned; `PlateFor` then fills the vest once the player is up.
	///
	/// ⚠️ CO-OP ASKS IT THE SAME WAY: `Complete` sends `Has( rescuer, "m4" )` with the revive, and the
	/// patient's machine runs `PlateFor`.
	/// </summary>
	public static bool PlatesOthers( NZPlayer rescuer ) => Has( rescuer, "m4" );

	/// <summary>
	/// How long a player just picked up is left out of `TargetFor`: long enough for their own machine's
	/// "I am up" to arrive. The same grace the patient's own bar allows its messages.
	/// </summary>
	public const float JustRevivedGrace = NZPlayer.StaleReviveGrace;

	/// <summary>
	/// Fill this player's vest. The rescuer's side of the question has already been answered.
	///
	/// ⛔ SPLIT OUT BECAUSE THE TWO HALVES NOW LIVE ON DIFFERENT MACHINES. "Does the rescuer own
	/// m4" can only be asked where the rescuer is; "is there a vest and how big is it" can only be
	/// answered where the patient is. In co-op those are two computers, so one method that did
	/// both would have to be wrong on one of them — and it was: it set `Armor` on the rescuer's
	/// PROXY copy of the patient, which is a number nobody would ever read.
	///
	/// ⚠️ `NZPlayer.Revive( plate )` IS THE OTHER CALLER, through the RPC. The boolean it
	/// carries is the rescuer's answer; this is the patient's.
	/// </summary>
	public static void PlateFor( NZPlayer patient, string how )
	{
		if ( !patient.IsValid() ) return;
		if ( patient.ArmorTier <= 0 ) return;

		patient.Armor = NZombies.Armor.CapFor( patient );

		Log.Info( $"[nz-aug] revive m4 Plate Carrier ({how}) — armor {patient.Armor:0}"
			+ $" (tier {patient.ArmorTier})" );
	}

	/// <summary>
	/// Finish a revive: stand the patient up and pay the rescuer's augments.
	///
	/// ⚠️ `NZPlayer.Revive()` DOES THE STANDING UP, including the perk loss. This only adds what
	/// the augments promise on top, so the base rule stays in one place.
	/// </summary>
	static void Complete( NZPlayer rescuer, NZPlayer patient )
	{
		// ⚠️ CLEARED WITHOUT ANNOUNCING A STOP. Finishing IS the stop, and the patient clears its
		// own bar in `Revive` a line later — sending "cancelled" here would race the revive itself
		// and could blank the bar a frame before the player stands up.
		rescuer.RevivingWho = null;
		rescuer.ReviveProgress = 0f;

		// ⚠️ REMEMBERED, SO `TargetFor` LEAVES THEM ALONE until their stand-up reaches this machine.
		rescuer.JustRevived = patient;
		rescuer.SinceJustRevived = 0f;

		// ⚠️ THE RESCUER, NOT THE PATIENT, and only here. `NZPlayer.Revive()` is also reached by
		// self-revive, the round manager and a console command — crediting a revive there would
		// hand everyone one at round start and let a solo player farm them off Quick Revive.
		// This method is the only co-op pickup, which is what the column is counting.
		PlayerStats.For( rescuer )?.RecordRevive();

		// ⚠️ THE RESCUER'S AUGMENT PLATES THE PATIENT — it is the reviver who paid for it — so
		// the answer travels WITH the revive rather than as a second action afterwards. Across two
		// machines a separate call would have to find the patient again, and would land on the
		// rescuer's own proxy copy of them. `Revive` is already relayed; this rides along.
		patient.Revive( Has( rescuer, "m4" ) );

		Log.Info( $"[nz-aug] revive — {patient.GameObject.Name} back up"
			+ $" in {SecondsFor( rescuer ):0.##}s" );

		// ── m3 Field Medic ──
		if ( !Has( rescuer, "m3" ) ) return;

		var hp = rescuer.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );

		if ( hp.IsValid() && hp.Current < hp.Max ) hp.Heal( hp.Max - hp.Current );

		rescuer.MedicSpeedUntil = MedicSpeedSeconds;

		Log.Info( $"[nz-aug] revive m3 Field Medic — healed and {MedicSpeedSeconds:0.#}s of"
			+ $" x{MedicSpeed:0.##} speed" );
	}

	/// <summary>m3's speed boost, read by `PerkEffects.SpeedMultiplier`.</summary>
	public static float SpeedBonus( NZPlayer player )
		=> player.IsValid() && player.MedicSpeedUntil > 0f
			? MathF.Max( 0.1f, MedicSpeed )
			: 1f;

	// ══ base + M4 — the downed weapon ═════════════════════════════════════════

	/// <summary>Does this player keep their real guns when downed — M4.</summary>
	public static bool KeepsWeapons( NZPlayer player ) => Has( player, "M4" );

	/// <summary>
	/// Swap to the downed pistol, remembering what to give back.
	///
	/// ⛔ THE PREFAB PATHS ARE REMEMBERED, NOT THE OBJECTS. `StripWeapons` destroys the weapon
	/// GameObjects, so holding references would leave a list of destroyed objects — and every
	/// upgrade is stored against the PREFAB PATH anyway (`ApplyStoredUpgrades`), so re-giving the
	/// path restores the Pack-a-Punch tier, rarity and tech with it. The same reason Mule Kick's
	/// Insurance stores paths.
	/// </summary>
	public static void OnDowned( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		if ( KeepsWeapons( player ) )
		{
			Log.Info( "[nz-aug] revive M4 Last Stand — keeping your weapons while down" );
			return;
		}

		var inv = player.Inventory;
		if ( !inv.IsValid() ) return;

		player.DownedWeapons.Clear();

		foreach ( var w in inv.Weapons.ToList() )
		{
			if ( !w.IsValid() ) continue;

			var wep = w.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
			var path = wep.IsValid() ? Rarity.PrefabOf( wep ) : null;

			if ( !string.IsNullOrEmpty( path ) ) player.DownedWeapons.Add( path );
		}

		player.StripWeapons();
		player.GiveWeapon( DownedWeapon );

		Log.Info( $"[nz-aug] revive — downed with a pistol, {player.DownedWeapons.Count}"
			+ " weapon(s) held for you" );
	}

	/// <summary>
	/// Give the real weapons back on standing up.
	///
	/// ⚠️ THE FIRST ONE GOES ACTIVE AND THE REST DO NOT, so the player comes up holding something
	/// rather than empty-handed with two guns in their pockets.
	/// </summary>
	public static void OnRevived( NZPlayer player )
	{
		if ( !player.IsValid() ) return;
		if ( player.DownedWeapons.Count == 0 ) return;

		player.StripWeapons();

		var first = true;

		foreach ( var path in player.DownedWeapons )
		{
			player.GiveWeapon( path, makeActive: first );
			first = false;
		}

		Log.Info( $"[nz-aug] revive — {player.DownedWeapons.Count} weapon(s) returned" );

		player.DownedWeapons.Clear();
	}

	/// <summary>
	/// M4 Last Stand — a kill while downed stands you straight back up.
	///
	/// ⚠️ IT DOES NOT SPEND A SELF-REVIVE. Those are the solo safety net; this is the augment you
	/// paid for, and charging both would make M4 strictly worse than not having it whenever you
	/// were out of self-revives.
	/// </summary>
	public static void OnZombieKilled( NZPlayer killer )
	{
		if ( !killer.IsValid() || !killer.IsDown ) return;

		// ⛔ NOT ONCE THEY HAVE BLED OUT (2026-09-27). An out-of-round player keeps `IsDown` — zombies
		// skip targets that are down — and keeps Last Stand's real guns, so a kill from there stood them
		// back up mid-round, past "back next round". Bleeding out is the end of the down.
		if ( killer.IsOutOfRound ) return;
		if ( !Has( killer, "M4" ) ) return;

		Log.Info( "[nz-aug] revive M4 Last Stand — killed while down, back up" );

		killer.Revive();
	}

	// ══ M2 — keeping perks ════════════════════════════════════════════════════

	/// <summary>
	/// How many perks survive going down.
	///
	/// ⚠️ RETURNS A COUNT, not a bool, because that is what `LosePerksOnDown` already reads
	/// (`ActiveConfig.Player.PerksKeptOnDown`). M2 returns the perks the player actually holds
	/// rather than a large constant, so the log line says "kept 4 of 4" instead of "kept 99".
	/// </summary>
	public static int PerksKeptFor( NZPlayer player )
		=> Has( player, "M2" )
			? Math.Max( 0, player.Perks.Count )
			: ActiveConfig.Player.PerksKeptOnDown;

	// ══ m1 / m2 — regen ═══════════════════════════════════════════════════════

	/// <summary>m1 Rapid Recovery — multiplier on the wait before regen starts.</summary>
	public static float DelayScale( NZPlayer player )
		=> Has( player, "m1" ) ? MathF.Max( 0.05f, RegenDelayScale ) : 1f;

	/// <summary>m2 Fast Metabolism — multiplier on the gap between regen ticks.</summary>
	public static float RateScale( NZPlayer player )
		=> Has( player, "m2" ) ? MathF.Max( 0.05f, RegenRateScale ) : 1f;

	// ══ m5 — Phase Shift ══════════════════════════════════════════════════════

	/// <summary>
	/// m5 Phase Shift — survive a fatal hit at 1 HP and teleport out.
	///
	/// ⛔ RETURNS TRUE WHEN IT SAVED YOU, so the damage path can stop. Called from `Health.Apply`
	/// before the health reaches zero — after it, the player is already down and standing them
	/// back up would be a different and much messier effect.
	///
	/// ⚠️ A SPECIAL SPAWN, as asked, and `RoundManager.EligibleSpecialSpawns` already respects
	/// which are open — so it cannot drop you behind unbought debris.
	///
	/// ⛔ THE TWO HALVES ARE INDEPENDENT NOW, AND BINDING THEM WAS A BUG. This used to `return
	/// false` when there was nowhere to teleport, which meant a map with no special spawns — the
	/// `countdown` test map among them — got NO PHASE SHIFT AT ALL: the player went down normally
	/// while the console explained why. Surviving a fatal hit is the augment's actual promise and it
	/// does not need a destination. You are saved either way; the teleport is what happens next if
	/// there is somewhere to go.
	///
	/// ⚠️ WHICH MEANS IT CAN NOW SAVE YOU AND LEAVE YOU STANDING IN THE CROWD THAT NEARLY KILLED
	/// YOU, on 1 HP. That is worse than the intended effect and still far better than being downed,
	/// and the fix is placing special spawns (`nz_special_here`) rather than code.
	///
	/// ⚠ THIS RETURNS A VERDICT; `Health.Apply` DOES THE 1 HP WRITE. Health only moves down
	/// through `Apply` — `Current` has a private setter and `Heal` refuses negatives — so the
	/// caller is the only place that can set it, and it does. Returning a bool keeps the decision
	/// (do I have the augment, is the hit fatal, is there anywhere to go) here with the rest of
	/// the perk.
	/// </summary>
	public static bool TryPhaseShift( NZPlayer player, float incoming )
	{
		if ( !Has( player, "m5" ) ) return false;

		// ⚠️ NOT WHILE DOWN (2026-09-27). A fatal hit on a downed player is not the one this saves you
		// from — the teleport would carry them away from the teammates coming to pick them up, and
		// spend the two-minute cooldown doing it.
		if ( player.IsDown ) return false;
		if ( player.PhaseShiftReady > 0f ) return false;

		var hp = player.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		if ( !hp.IsValid() || incoming < hp.Current ) return false;

		var spawns = RoundManager.Instance?.EligibleSpecialSpawns;

		// ⛔ THE COOLDOWN STARTS HERE, BEFORE THE TELEPORT IS EVEN ATTEMPTED, because the save is
		// what it pays for. Charging it only on a successful teleport would give a map with no
		// special spawns an unlimited 1 HP save on every fatal hit — the strongest augment in the
		// game, by accident, on exactly the maps where it looks broken.
		player.PhaseShiftReady = PhaseCooldown;

		if ( spawns is not null && spawns.Count > 0 )
		{
			var to = spawns[Game.Random.Int( 0, spawns.Count - 1 )].Position;

			player.WorldPosition = to;

			Log.Info( $"[nz-aug] revive m5 Phase Shift — survived at 1 HP, teleported to {to}"
				+ $" · next in {PhaseCooldown:0.#}s" );

			return true;
		}

		// ⛔ THE TWO CAUSES ARE SEPARATED BECAUSE THE OLD MESSAGE ASSERTED ONE IT COULD NOT KNOW. It
		// said "no special spawns on this map" while reading `EligibleSpecialSpawns`, which is the
		// PLACED list already filtered by link-open, power and round — so "all of them are gated
		// right now" printed as "this map has none". Those need completely different responses from
		// the player: place some, versus open a door or turn the power on.
		var placed = ActiveConfig.Current?.SpecialSpawns?.Count ?? 0;

		Log.Warning( $"[nz-aug] revive m5 Phase Shift — SAVED you at 1 HP but could not teleport: "
			+ (placed == 0
				? "this map has no special spawns placed at all — use `nz_special_here`"
				: $"all {placed} special spawn(s) are gated right now (door unbought, power off,"
					+ " or round not reached)")
			+ $" · next in {PhaseCooldown:0.#}s" );

		return true;
	}

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

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

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

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

		Log.Info( $"[nz-aug]  base            self-revive {player.SelfRevivesUsed}/{UsesFor( player )}"
			+ $" (only when alone — {(player.IsAlone ? "you are" : "you are NOT, so none")})"
			+ $" · revive others in {SecondsFor( player ):0.##}s within {ReachFor( player ):0}u"
			+ $" · downed weapon {(KeepsWeapons( player ) ? "YOUR OWN" : "M1911")}" );

		// ⚠️ THE WHOLE CHAIN, NOT JUST THE ANSWER. `SecondsFor` above prints one number, and the
		// question being asked of this report is which of the two dividers is actually applying —
		// a player holding the perk with no M1 and a player with M1 and no perk are different bugs
		// that produce different numbers, and neither is distinguishable from a single figure.
		Log.Info( $"[nz-aug]  revive speed    {ReviveSeconds:0.##}s base"
			+ $" ÷{(has ? PerkSpeed : 1f):0.##} perk"
			+ $" ÷{(Has( player, "M1" ) ? PhoenixSpeed : 1f):0.##} M1"
			+ $" = {SecondsFor( player ):0.##}s" );

		Log.Info( $"[nz-aug]  M1 Phoenix      {(Has( player, "M1" ) ? $"{PhoenixUses} self-revives · reviving x{PhoenixSpeed:0.##} faster" : "-")}" );
		Log.Info( $"[nz-aug]  M2 Grave Keeper {(Has( player, "M2" ) ? $"keep all {player.Perks.Count} perk(s)" : $"- (config keeps {ActiveConfig.Player.PerksKeptOnDown})")}" );
		Log.Info( $"[nz-aug]  M3 Guardian     {(Has( player, "M3" ) ? $"no key needed, {AuraReach:0}u" : "-")}" );
		Log.Info( $"[nz-aug]  M4 Last Stand   {(Has( player, "M4" ) ? "keep weapons when down · a kill revives you" : "-")}" );
		Log.Info( $"[nz-aug]  m1 Rapid Recov  {(Has( player, "m1" ) ? $"regen delay x{RegenDelayScale:0.##}" : "-")}"
			+ $"   {ActiveConfig.Player.HealthRegenDelay * PerkEffects.RegenDelayMultiplier( player ) * DelayScale( player ):0.##}s wait" );
		Log.Info( $"[nz-aug]  m2 Fast Metab   {(Has( player, "m2" ) ? $"tick gap x{RegenRateScale:0.##}" : "-")}"
			+ $"   {ActiveConfig.Player.HealthRegenRate * RateScale( player ):0.###}s per tick" );
		Log.Info( $"[nz-aug]  m3 Field Medic  {(Has( player, "m3" ) ? $"full heal + {MedicSpeedSeconds:0.#}s of x{MedicSpeed:0.##} speed" : "-")}"
			+ $"   boost {(player.MedicSpeedUntil > 0f ? $"{(float)player.MedicSpeedUntil:0.0}s left" : "off")}" );
		Log.Info( $"[nz-aug]  m4 Plate Carrier {(Has( player, "m4" ) ? "anyone revived, self included, gets full armor" : "-")}" );
		Log.Info( $"[nz-aug]  m5 Phase Shift  {(Has( player, "m5" ) ? $"survive at 1 HP, {PhaseCooldown:0.#}s cd" : "-")}"
			+ $"   ready in {MathF.Max( 0f, player.PhaseShiftReady ):0.0}s"
			+ $" · {(RoundManager.Instance?.EligibleSpecialSpawns?.Count ?? 0)} special spawn(s)" );

		// ⚠️ NAMED EXPLICITLY, because "it did nothing" on a solo test is the expected answer for
		// three of these and that is not obvious from the list above.
		Log.Info( "[nz-aug]  ⚠ M3 and m3 act on ANOTHER PLAYER — solo they cannot fire at all. m4 now also fires on self-revive." );
		Log.Info( "[nz-aug]     No dummy command: a second NZPlayer would hijack the 122 call sites" );
		Log.Info( "[nz-aug]     that resolve the player as FirstOrDefault<NZPlayer>(). The lines above" );
		Log.Info( "[nz-aug]     at least prove they are wired." );
	}

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

	/// <summary>
	/// Is this player's body actually drawn right now?
	///
	/// ⚠️ READ OFF THE OBJECT, NOT INFERRED FROM THE FLAG. "Out of round" is what SHOULD hide a
	/// body; whether it DID is a separate fact, and on a proxy the two can disagree — which is the
	/// whole reason the despawn needed a synced flag rather than a local one.
	/// </summary>
	static bool BodyShown( NZPlayer p )
	{
		var ctrl = p.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );

		return ctrl.IsValid() && ctrl.Renderer.IsValid() && ctrl.Renderer.GameObject.Enabled;
	}

	/// <summary>
	/// `nz_revive_state` — WHAT EVERY MACHINE BELIEVES ABOUT EVERY PLAYER'S DOWN.
	///
	/// ⛔ THE CO-OP REVIVE FAILS IN FOUR PLACES AND ALL FOUR ARE SILENT. The owner never
	/// published; the flag never arrived; the rescuer is out of reach; or the relay never landed.
	/// Every one of them looks the same from a keyboard: you hold E over a crawling teammate and
	/// nothing whatever happens. Only both machines' answers side by side tell them apart — and
	/// the two columns that matter most are the ones that DISAGREE.
	///
	/// ⚠️ `down` IS THIS MACHINE'S BELIEF AND `net` IS WHAT THE OWNER PUBLISHED. On the owner's
	/// own row they must match, because the owner writes one from the other. On a proxy row a
	/// mismatch is the replication itself failing, which is a different bug from the revive not
	/// working — and before this command they were indistinguishable.
	///
	/// Client output routes to the host, so one capture holds both sides.
	/// </summary>
	/// <summary>A player's profile name for a report, falling back to the object's name.</summary>
	static string Named( NZPlayer p )
	{
		var n = NZPlayers.NameOf( p );
		return string.IsNullOrWhiteSpace( n ) ? (p.IsValid() ? p.GameObject.Name : "?") : n;
	}

	[ConCmd( "nz_revive_state" )]
	public static void StateCmd()
	{
		void Tell( string line )
		{
			if ( NZGame.IsHost || !Networking.IsActive ) Log.Info( line );
			else NZNet.Say( line );
		}

		var who = NZGame.IsHost ? "HOST  " : "CLIENT";
		var me = NZPlayer.Local;

		// ⛔ SAID OUT LOUD, BECAUSE OTHERWISE THIS COMMAND REPORTS A BUG THAT IS NOT ONE. Solo
		// there is no session, so `TickDownedMirror` never runs and `net` stays at its default —
		// printing `down=True net=False` beside each other, which is precisely the shape of the
		// replication failure the column exists to catch. A reader would be right to report it.
		if ( !Networking.IsActive )
			Tell( "[nz-rev] ⚠️ SOLO — no session, so nothing replicates and `net` means nothing here."
				+ " A mismatch below is expected, not a fault." );

		foreach ( var go in PlayerSpawner.AllBodies() )
		{
			var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
			if ( !p.IsValid() ) continue;

			var mine = PlayerPresence.Mine( go );
			// ⚠️ THE PROFILE NAME, matching every other place a player is named. A report that
			// calls somebody by their costume cannot be compared against what the player says they
			// saw on screen.
			var name = NZPlayers.NameOf( p );
			if ( string.IsNullOrWhiteSpace( name ) ) name = go.Name;

			Tell( $"[nz-rev] {who} '{name}' mine={mine,-5}"
				+ $" down={p.IsDown,-5} net={p.DownedNet,-5}"
				+ (p.IsDown != p.DownedNet && !mine ? " ⛔ DISAGREE" : "")
				+ $" bled={p.HasBledOut,-5}"
				+ $" out={p.IsOutOfRound,-5}"
				+ $" body={(BodyShown( p ) ? "shown" : "⛔ despawned"),-11}"
				+ $" bleedout={(p.IsDown ? $"{MathF.Max( 0f, p.BleedsOutIn ):0.#}s" : "—")}"
				+ $" othersUp={p.OthersStillUp}"
				+ $" canBeRevived={p.CanBeRevived}"
				+ $" selfRevive={(mine || !Networking.IsActive ? p.SelfReviveComing : p.SelfReviveNet)}" );
		}

		// ⚠️ AND THE HOST'S EVERYBODY-DOWN CHECK (`NZPlayer.TickEverybodyDown`), which ends a run with nobody standing.
		Tell( $"[nz-rev] {who} everybody down: "
			+ (NZPlayer.EverybodyDown
				? $"YES for {NZPlayer.EverybodyDownFor:0.0}s — game over at {NZPlayer.EverybodyDownGrace:0.0}s"
				: "no (somebody is up, a self-revive is coming, or this is not the host)") );

		if ( !me.IsValid() ) { Tell( $"[nz-rev] {who} ⛔ no local player" ); return; }

		// ⚠️ THE RESCUER'S OWN VIEW, SEPARATELY. "Is anybody in reach of ME" is the question the
		// hold actually asks, and it is not answerable from the rows above — reach depends on the
		// augments this player owns, so it is a different number for each of them.
		var target = TargetFor( me );

		Tell( $"[nz-rev] {who}   my reach {ReachFor( me ):0.#}u"
			+ $" · revive takes {SecondsFor( me ):0.##}s"
			+ $" · passive(M3)={RevivesPassively( me )}"
			+ $" · in reach: {(target.IsValid() ? Named( target ) : "⚠️ nobody")}"
			+ $" · progress {me.ReviveProgress:0.##}s" );
	}

	/// <summary>
	/// `nz_revive_down` — put yourself down, to test the pistol swap and Last Stand.
	/// </summary>
	[ConCmd( "nz_revive_down" )]
	public static void DownCmd()
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }

		var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		if ( !hp.IsValid() ) return;

		hp.OnDamage( new DamageInfo
		{
			Damage = hp.Current + 1f,
			Position = p.WorldPosition,
			Tags = new TagSet(),
		} );

		Log.Info( $"[nz-aug] revive — down {p.IsDown}"
			+ $" · holding {(p.Inventory.IsValid() ? p.Inventory.Count : 0)} weapon(s)" );
	}

	/// <summary>`nz_revive_set` — retune live. Negative or omitted leaves a value alone.</summary>
	[ConCmd( "nz_revive_set" )]
	public static void SetCmd( int uses = -1, int phoenixUses = -1, float seconds = -1f,
		float phoenixSpeed = -1f, float reach = -1f, float auraReach = -1f,
		float delayScale = -1f, float rateScale = -1f, float medicSeconds = -1f,
		float medicSpeed = -1f, float phaseCd = -1f, float perkSpeed = -1f )
	{
		if ( uses >= 0 ) SelfReviveUses = uses;
		if ( phoenixUses >= 0 ) PhoenixUses = phoenixUses;
		if ( seconds >= 0f ) ReviveSeconds = seconds;
		// ⚠️ APPENDED, NOT INSERTED. `nz_revive_set` takes positional arguments, so adding this
		// anywhere but the end would silently re-map every argument after it — somebody's saved
		// "phaseCd 90" line would start setting something else.
		if ( perkSpeed >= 0f ) PerkSpeed = perkSpeed;
		if ( phoenixSpeed >= 0f ) PhoenixSpeed = phoenixSpeed;
		if ( reach >= 0f ) ReviveReach = reach;
		if ( auraReach >= 0f ) AuraReach = auraReach;
		if ( delayScale >= 0f ) RegenDelayScale = delayScale;
		if ( rateScale >= 0f ) RegenRateScale = rateScale;
		if ( medicSeconds >= 0f ) MedicSpeedSeconds = medicSeconds;
		if ( medicSpeed >= 0f ) MedicSpeed = medicSpeed;
		if ( phaseCd >= 0f ) PhaseCooldown = phaseCd;

		ReportCmd();
	}
}