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