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).
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<IPlayerBase>()`, 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() &&
/// !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<string, List<string>>` 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<n>" 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<NZPlayer>().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)" );
}
}