Health component used by players and zombies. Implements damage handling, hit description, multiplier logic (headshots, body parts, perks, tech effects), relaying client hits to host, mirroring health, and firing OnDamaged/OnKilled events.
using Sandbox;
using System;
namespace NZombies;
/// <summary>
/// HEALTH — one component, used by zombies and players alike.
///
/// Deliberately dumb: a number in, events out. It does NOT know about flinch
/// animations, ragdolls, points or downed states — zombies and players want
/// completely different reactions to being hit, and merging that in is what
/// makes a health component painful to live with later. Reactions belong in
/// ZombieAI and the player code, driven off the events here.
///
/// Implements Sandbox.Component.IDamageable so the engine's own bullet path
/// reaches it: BaseCombatWeapon traces, builds a DamageInfo and delivers it
/// here. We get hitboxes, damage tags and networking for free instead of
/// re-implementing tracing.
///
/// ⚠️ DamageInfo appears ONLY at that engine boundary. Everything of ours goes
/// through Apply(float, bool) — so we never have to construct a DamageInfo,
/// whose exact shape is engine-owned and changes between versions.
/// </summary>
public sealed class Health : Component, Component.IDamageable
{
[Property] public float Max { get; set; } = 100f;
/// <summary>Current health, clamped to [0, Max].</summary>
[Property, ReadOnly] public float Current { get; private set; }
/// <summary>
/// Ignore incoming damage for this long after a hit lands.
///
/// The original gives a victim 0.5s of immunity after being hit, and the
/// reference doc (§9.4) is explicit that it is not optional: it is what
/// makes being surrounded survivable rather than instant death, because
/// six zombies in contact would otherwise all land in the same tick.
/// Zero disables it — zombies themselves don't get one.
/// </summary>
[Property] public float ImmunityAfterHit { get; set; } = 0f;
/// <summary>
/// Take no damage at all. Off.
///
/// ⛔ ADDED FOR THE DEV MENU'S GODMODE BUTTON, WHICH COULD NOT SIMPLY BE WIRED BECAUSE NOTHING
/// IN THE PROJECT HAD A GODMODE. It was one of four disabled placeholders in the Dev tab; the
/// other three had existing calls behind them and this one had no mechanic at all.
///
/// ⚠️ THE GATE IS AT THE VERY TOP OF `Apply`, WHICH IS NORMALLY THE WRONG PLACE. §4 warns that
/// an early-out up there silently skips everything below it - and here that is exactly the
/// intent: no reduction order, no armor, no augments, no events. "Takes nothing" has to mean
/// nothing, and anything subtler would be a damage multiplier rather than immunity.
///
/// ⚠️ ON THE SHARED COMPONENT, so a zombie could be given it too. That is useful (an
/// unkillable target to shoot at) and is what `nz_dev_tank` does with a million health instead -
/// deliberately, because a tank that still TAKES damage lets you read the numbers.
/// </summary>
[Property, Group( "Damage" )] public bool Invulnerable { get; set; }
public bool IsDead => Current <= 0f;
public float Fraction => Max > 0f ? Current / Max : 0f;
/// <summary>Damage that was actually applied — never fires for a blocked,
/// zero or post-death hit. (amount, wasHeadshot)</summary>
public Action<float, bool> OnDamaged { get; set; }
/// <summary>Headshot damage multiplier. 2.5 matches the original gamemode.</summary>
[Property, Group( "Damage" )] public float HeadshotDamageScale { get; set; } = 2.5f;
// ── per-body-part multipliers ────────────────────────────────────────────
//
// ⚠️ BEYOND THE ORIGINAL. nZombies scales the head only; every other hitgroup
// there drives gibbing/stun, not damage. These default to 1.0 so the game
// behaves exactly like the original until a value is changed.
//
// ⚠️ Head is NOT in this table — it stays on HeadshotDamageScale so the
// headshot flag, the points award (100 vs 50) and the damage bonus can never
// disagree about what counts as a headshot.
[Property, Group( "Damage" )] public float TorsoMultiplier { get; set; } = 1f;
[Property, Group( "Damage" )] public float ArmMultiplier { get; set; } = 0.75f;
[Property, Group( "Damage" )] public float LegMultiplier { get; set; } = 0.75f;
/// <summary>Hands and feet — the extremities, softest of all.</summary>
[Property, Group( "Damage" )] public float ExtremityMultiplier { get; set; } = 0.5f;
/// <summary>
/// The weapon that fired this hit, for the tech nodes that are owned by the SHOOTER but
/// applied here on the VICTIM — as a <see cref="TechEffects.TechRef"/>, which is the form
/// that survives a relay.
///
/// ⛔ THIS USED TO RETURN THE WEAPON COMPONENT AND THAT WORKED ON EXACTLY ONE MACHINE.
/// `damage.Weapon` is null for every hit a client sends, because weapon prefabs are
/// `NetworkMode.Never`, so eight nodes silently read as unowned here. `TechEffects.Of` falls
/// back to the prefab path the relay stamps into `DamageInfo.Extra`; see `TechRef` for the
/// whole story and for why the prefab path is the right handle rather than a workaround.
///
/// ⚠️ THE WEAPON, NOT JUST THE PLAYER, because tech is per weapon PREFAB — a player
/// carrying two guns has two independent trees. Reading the attacker's ACTIVE weapon instead
/// would apply one gun's tech to the other, the trap TechEffects.Has records as having cost a
/// free Pack-a-Punch level three times. `TechRef` carries the prefab for precisely this.
///
/// ⚠️ NOTHING RESOLVES FOR A NON-BULLET SOURCE, and that is the right answer rather than
/// a gap: the knife, grenades, traps, StatusEffects and the test commands build their
/// DamageInfo by hand and set neither a weapon nor a stamp, so they cannot pick up a rifle's
/// tree. Every `TechEffects` accessor returns its `ifAbsent` for an invalid ref.
///
/// ⚠️ KEPT AS A NAMED METHOD PURELY TO BE MEASURABLE. A `using var` needs a block, and
/// this cannot be timed at the call site either: the result is assigned to a `var`, and a
/// using-BLOCK around a declaration scopes the variable out of the rest of OnDamage.
/// </summary>
static TechEffects.TechRef FiredBy( in DamageInfo damage )
{
using var _cpu = CpuScope.Measure( "dmg.firedby" );
return TechEffects.Of( damage );
}
/// <summary>
/// Which body part these tags describe, as a damage multiplier.
///
/// ⛔ TAG NAMES ARE NOT ASSUMED. Verified on walker_honorguard_dmx (46
/// hitboxes) with `nz_hitbox_tags`; the vocabulary is exactly:
///
/// head j_head, j_neck
/// chest j_spinelower, j_spineupper, j_spine4
/// arm clavicle -> shoulder -> elbow -> wrist -> fingers (+ left/right)
/// leg knee, ankle, ball (+ left/right)
///
/// There is no stomach/pelvis/hand/foot tag — the extra names below are
/// tolerated in case another walker variant uses them. A tag this does not
/// recognise falls through to 1.0: a silent no-op, never a wrong number.
///
/// ⚠️ `j_neck` is tagged HEAD, so neck shots pay and scale as headshots.
/// ⚠️ Every limb also carries a `left`/`right` tag — per-limb tracking (for
/// gibbing, the original's real use of hitgroups) can key off that later.
///
/// ⚠️ `weapon` IS PASSED IN, not resolved here, so OnDamage pays for exactly one
/// <see cref="FiredBy"/> lookup per hit — and every pellet of a 16-pellet shotgun
/// blast is its own DamageInfo arriving through this path.
/// </summary>
float PartMultiplier( in DamageInfo damage, in TechEffects.TechRef tech )
{
// ⚠️ Same reason as dmg.firedby -- the call site is `amount *= PartMultiplier( ... )` in an
// else-if, so it is measured here. It therefore NESTS inside dmg.headscale.
using var _cpu = CpuScope.Measure( "dmg.part" );
// ── HOLLOW POINTS (`t2_limbs`) ────────────────────────────────────────
//
// ⛔ A FLOOR PER PART, NOT A MULTIPLY. The node states two TARGET values —
// limbs to 1.0, extremities to 0.75 — and a multiply cannot reach a target
// without already knowing what it is multiplying. `MathF.Max` can, and it
// carries the property the whole roster of zombie variants needs: these are
// `[Property]` fields, so a boss may legitimately author arms at 1.2, and a
// floor is incapable of lowering that. Scaling the deficit toward 1 was the
// other candidate and was rejected for the same reason Long Barrel is a
// floor: it has no fixed point, so a value already ABOVE the target gets
// dragged back down toward it.
//
// ⛔ AND THE CATALOGUE ONLY HOLDS ONE OF THE TWO TARGETS. `t2_limbs` stores
// 1.0, which is the LIMB target; the extremity target of 0.75 is not in
// `WeaponTech.cs` at all. Hardcoding 0.75 here would be a second source for
// a magnitude the catalogue is supposed to own, and `nz_tech` would print a
// table that disagrees with the code — the exact failure TechEffects.Factor
// exists to prevent.
//
// ⛔ BOTH PARTS NOW FLOOR AT THE SAME TARGET, AND THE DERIVATION IS GONE. The node
// used to promote each part one rung up the body ladder — extremities scored as limbs,
// limbs scored as the catalogue target — which on stock values was 0.5 -> 0.75 and
// 0.75 -> 1.0. Requested instead: *"hollow point could make the minimum damage 1x"*. One
// target for both, so the card can say "limbs and extremities take full damage" and be
// literally true, and the `Min( Arm, Leg )` reasoning below it is no longer needed.
//
// ⚠️ IT IS STILL A FLOOR, WHICH IS WHAT KEEPS A BOSS COHERENT. A zombie variant may
// legitimately author an arm at 1.2, and `MathF.Max` cannot lower that — a multiply or an
// assignment would drag it back down to the target, which is the trap the block above
// records for Body Shot and Deadeye.
//
// ⚠️ `ifAbsent: 0f` IS LOAD-BEARING. Factor defaults to 1 for the multiply
// sites; a silent 1 here would floor every limb on every weapon in the game
// to 1.0 and delete the limb penalty outright, node or no node.
var limbFloor = MathF.Max( TechEffects.Factor( tech, "t2_limbs", 0f ),
// ⚠️ HEAVY MATCH AMMO (sniper tier 4, 2026-10-04): the same full-damage floor, as a switch.
TechStats.Flag( tech, "f.limbsfull" ) ? 1f : 0f );
var extremityFloor = limbFloor;
// ── BODY SHOT (`t4_bodyshot`) and DEADEYE (`t5_deadeye`) ──────────────
//
// ⛔ A TARGET FROM BELOW AND A TARGET FROM ABOVE, NOT MULTIPLIES, for the reason
// Hollow Points records above: a scale has no fixed point. Body Shot written as
// `x1.5` would drag a boss that legitimately authors a 2.0 torso to 3.0, and
// Deadeye written as `x0.5` would take stock hands and feet from 0.5 to 0.25 —
// twice the penalty the node advertises, on the one part a player cannot choose
// to avoid. A floor and a ceiling both land ON the stated number, and both are
// idempotent, which matters on a method that runs once per pellet.
//
// ⚠️ `ifAbsent: 0f` IS LOAD-BEARING HERE TOO, exactly as it is for limbFloor —
// Factor's default of 1 would floor every part on every weapon in the game.
//
// ⛔ BODY SHOT NO LONGER TOUCHES THIS TABLE AT ALL. It used to floor torso and
// limbs at 1.5 from here; it is now a flat x1.5 on `ShootInfo.Damage`, applied
// once at spawn in NZPlayer beside every other damage node. Two consequences
// worth stating because both are improvements rather than side effects:
// • the limb and extremity penalties SURVIVE underneath it (limb 1.125,
// extremity 0.75) instead of being flattened to 1.5, so Hollow Points still
// has something to fix and the two nodes stop overlapping;
// • nothing here reads a per-zombie part multiplier a variant may have
// authored, so a boss with a 2.0 torso is no longer a special case.
var bodyFloor = 0f;
// ⛔ DEADEYE'S BODY NUMBER IS ITS SECOND ONE, SO `Factor` IS THE WRONG ACCESSOR.
// The catalogue's 2.5 is the HEAD scale this node adds in OnDamage; reading it
// here would make a body shot two and a half times BETTER instead of half as
// good — a penalty wired as its own inverse, which nothing in the numbers would
// flag. `Bound` is the field that exists for a node's second number.
//
// ⚠️ THE 0.5 BELOW IS A FALLBACK, NOT THE MAGNITUDE. `t5_deadeye` declares no
// `Bound` yet, so today the fallback is what runs — the same state Boat Tail was
// in before its 1.25 moved into the catalogue. Declaring `Bound = 0.5f` on the
// node makes this literal unreachable and puts the number in the table `nz_tech`
// prints; until then it is a magnitude the printed catalogue cannot see.
//
// ⚠️ UNLIKE `Factor`, `Bound` IS NOT AMPLIFIED by `nz_tech_amp`, so under
// amplification Deadeye's head bonus grows and its body penalty does not. Boat
// Tail's ceiling behaves the same way; it is a property of Bound, not of here.
var bodyCeiling = TechEffects.Has( tech, "t5_deadeye" )
? WeaponTech.BoundOf( "t5_deadeye", 0.5f )
: 0f;
// ⛔ HANDS AND FEET HAVE NO TAG OF THEIR OWN. Verified on the model: every
// finger, wrist and wristtwist is tagged plain `arm`, and ankle/ball are
// plain `leg`. Splitting the extremities out therefore has to key on the
// BONE, which the tags alone cannot tell you — a tags-only version would
// silently score hands at the full arm value and look like it worked.
var bone = damage.Hitbox?.Bone.Name;
if ( bone is not null && IsExtremity( bone ) )
return Shaped( ExtremityMultiplier, extremityFloor, bodyCeiling );
var tags = damage.Tags;
if ( tags is null ) return 1f;
foreach ( var t in tags.TryGetAll() )
{
switch ( t )
{
// ⚠️ TORSO TAKES NO HOLLOW POINTS FLOOR. That node's own description
// names limbs and extremities only; flooring the torso as well would
// widen it into a flat damage node and duplicate Vigor Rush. Body Shot
// DOES name the torso, which is why it is a second, separate floor
// rather than a bigger value for the first one.
case "chest" or "torso" or "stomach" or "spine" or "pelvis" or "body":
return Shaped( TorsoMultiplier, bodyFloor, bodyCeiling );
case "arm" or "leftarm" or "rightarm" or "upperarm" or "forearm" or "hand":
return Shaped( ArmMultiplier,
MathF.Max( limbFloor, bodyFloor ), bodyCeiling );
case "leg" or "leftleg" or "rightleg" or "thigh" or "calf" or "foot":
return Shaped( LegMultiplier,
MathF.Max( limbFloor, bodyFloor ), bodyCeiling );
}
}
// ⚠️ AN UNRECOGNISED PART IS STILL A NO-OP, AND DELIBERATELY NOT CEILINGED. It
// is the one branch that does not know what it hit, so Deadeye's penalty is not
// charged to it — which does mean an unknown tag scores above a Deadeye torso on
// some future walker variant. `nz_hitbox_tags` is the tool that finds those; a
// guessed penalty here would hide them instead.
return 1f;
}
/// <summary>
/// One authored part multiplier, after the tier-4 archetypes.
///
/// ⚠️ 0 MEANS "ABSENT" FOR BOTH BOUNDS — the convention limbFloor in
/// PartMultiplier already uses, not a sentinel invented here.
///
/// ⛔ THE CEILING IS APPLIED LAST, SO DEADEYE BEATS BODY SHOT when a weapon somehow
/// holds both. Tier 4 is pick-one (`WeaponTech.Tiers`: Picks = 1) so a real game
/// cannot reach that pair — but `WeaponTech.Unlimited` lifts the pick cap in
/// creative, where its own comment says every one of the 41 nodes can be bought on
/// one weapon, so this is reachable and must not be undefined. Letting the PENALTY
/// win is the safe direction: two contradictory nodes must never read as a buff.
/// </summary>
static float Shaped( float part, float floor, float ceiling )
{
if ( floor > 0f ) part = MathF.Max( part, floor );
if ( ceiling > 0f ) part = MathF.Min( part, ceiling );
return part;
}
/// <summary>
/// Hand or foot, by bone name.
///
/// Hands: j_index/mid/pinky/ring/thumb/wrist/wristtwist_*
/// Feet: j_ankle_*, j_ball_*
///
/// ⚠️ `j_wrist` matches `j_wristtwist` too, which is intended — both are hand.
/// ⚠️ Deliberately does NOT match j_elbow or j_knee: those are mid-limb and
/// should stay on the arm/leg value.
/// </summary>
static bool IsExtremity( string bone )
{
return bone.Contains( "index" ) || bone.Contains( "mid_" ) || bone.Contains( "pinky" )
|| bone.Contains( "ring" ) || bone.Contains( "thumb" ) || bone.Contains( "wrist" )
|| bone.Contains( "ankle" ) || bone.Contains( "ball" );
}
/// <summary>
/// Opt out of the headshot bonus. The original exempts walker_necromorph,
/// special_bot and walker_xeno — set this on those enemy types.
/// </summary>
[Property, Group( "Damage" )] public bool ImmuneToHeadshotBonus { get; set; }
/// <summary>Fired once, on the hit that took it to zero. (wasHeadshot)</summary>
public Action<bool> OnKilled { get; set; }
private TimeUntil _immuneUntil;
/// <summary>Napalm Nectar hits landed on THIS body, toward its ignite.</summary>
// ⛔ `_napalmHits` REMOVED with the block above. A field nobody reads is worse than no
// field (§12), and the cooldown that replaced it is per PLAYER and lives on `NZPlayer`.
protected override void OnAwake() => Current = Max;
/// <summary>
/// Was the most recent damage melee?
///
/// ⚠️ Read it in OnDamaged/OnKilled — it is set immediately before those
/// fire. A separate flag rather than a parameter because OnDamaged and
/// OnKilled are already public API with several subscribers, and widening
/// their signature for one weapon type would break every one of them.
///
/// The melee tag comes from the weapon's DamageInfo, the same route "head"
/// takes via BaseCombatWeapon.MergeHitboxTags.
/// </summary>
public bool LastHitWasMelee { get; private set; }
/// <summary>
/// May the hit that just landed pay points.
///
/// ⛔ FALSE ONLY WHEN THE SHOOTER SAID SO. Penetration and pellet count multiplied the
/// per-hit award into thousands of points a trigger pull — see `ShotPoints`, which decides
/// this on the machine that fired, because only that machine knows which pellet of which shot
/// a given hit belongs to.
///
/// ⚠️ LATCHED, NOT ASKED LATER, for exactly the reason `LastHitWasMelee` is: `OnKilled`
/// fires straight after `OnDamaged`, and this is the last moment the hit is knowable.
///
/// ⚠️ DEFAULTS TO TRUE FOR EVERYTHING THAT IS NOT A BULLET. A knife, a grenade, a trap or a
/// Nuke carries no `nopay` tag and must go on paying normally — none of them can be fanned out
/// across twelve pellets.
/// </summary>
public bool LastHitPays { get; private set; } = true;
/// <summary>
/// Does this damage pay the per-hit points drip.
///
/// ⚠️ TWO REFUSALS, ONE ANSWER. `nopay` is `ShotPoints`' verdict on a bullet; `blast` is the
/// Tech blast, which has been stamping that tag and waiting for a reader since it was written.
/// Its own header names this as the missing half — *"the fix is two lines in those files"* —
/// and describes precisely this latch. They are the same requirement from two directions: one
/// trigger pull must not pay for every body it reaches.
/// </summary>
public static bool Pays( in DamageInfo damage )
=> damage.Tags is not { } tags
|| (!tags.Has( "nopay" ) && !tags.Has( NZombies.TechBlast.BlastTag ));
/// <summary>
/// Who dealt the last hit. Null for the world, a trap, or anything anonymous.
///
/// ⚠️ SAME CONVENTION AS LastHitWasMelee — set immediately before OnDamaged
/// fires, so a handler reading it during OnDamaged or OnKilled sees the hit that
/// caused it. ZombieAI latches it there for exactly that reason.
///
/// ⚠️ SET BY WHICHEVER PATH ACTUALLY KNOWS. OnDamage reads damage.Attacker;
/// Apply takes `from`, and only writes it when `from` is valid — because OnDamage
/// calls Apply with a DELIBERATE null, and passing the attacker through there
/// would switch Victorious Tortoise on for grenades and bullets, which its own
/// comment says must not happen. The guard is what lets both paths write this
/// without one erasing the other.
///
/// ⚠️ This does NOT fix FIX_LIST entry 5 (kill points pay the first player
/// found, not the killer). It is the missing ingredient for that fix, but
/// AwardPoints still resolves its own player and changing it is its own job.
/// </summary>
public GameObject LastAttacker { get; private set; }
/// <summary>
/// WHAT DEALT THE LAST HIT, in words, for the player's hit line (`NZPlayer.OnHurt`): the kind (bullet, knife, blast, fall,
/// tick, cost), who, and which machine sent it when it came over the network. Set in `Apply` before `OnDamaged` fires.
///
/// ⛔ ADDED FOR TWO DOWNS NOBODY COULD NAME (2026-10-04): a 301 and a 291 in a three-player game, logged as nothing more
/// than "hit for 301". A zombie's claw has its own line; these had none, and the log could not say what they were.
/// </summary>
public string LastHitSource { get; private set; } = "";
/// <summary>A hit that reached `Apply` directly — a claw, a wall, a tick — described from what `Apply` was given.</summary>
public string DescribeHit( GameObject from, bool blast = false, bool tick = false, bool cost = false )
{
var kind = cost ? "cost" : tick ? "tick" : blast ? "blast" : !from.IsValid() ? "hit"
: from.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ? "claw" : "hit";
return $"{kind} from {WhoIs( from )}";
}
/// <summary>A hit that came through `OnDamage` — a bullet, the knife, a blast — described from its `DamageInfo`.</summary>
string DescribeDamage( in DamageInfo damage )
{
var tags = damage.Tags;
var kind = IsMelee( damage ) ? "knife"
: tags is not null && tags.Has( ExplosionTag ) ? "explosion"
: tags is not null && tags.Has( "fall" ) ? "fall"
: tags is not null && tags.Has( SWB.Shared.TagsHelper.Bullet ) ? "bullet"
: "hit";
// ⚠️ THE PREFAB'S FILE NAME BY HAND, not `System.IO.Path`: s&box's whitelist refuses file APIs in the editor's compile
// only (INSTRUCTIONS.md, "A clean dotnet build does not mean s&box will load it").
var gun = FiredBy( damage ).Prefab ?? "";
var slash = gun.LastIndexOf( '/' );
if ( slash >= 0 ) gun = gun[(slash + 1)..];
var dot = gun.LastIndexOf( '.' );
if ( dot > 0 ) gun = gun[..dot];
return $"{kind}{(gun.Length > 0 ? $" ({gun})" : "")} from {WhoIs( damage.Attacker )}";
}
/// <summary>"yourself", "zombie …", "player …", an object's name, or "nothing" for an anonymous hit.</summary>
string WhoIs( GameObject from )
{
if ( !from.IsValid() ) return "nothing";
if ( from == GameObject ) return "yourself";
if ( from.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return $"zombie '{from.Name}'";
if ( from.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return $"player '{from.Name}'";
return $"'{from.Name}'";
}
/// <summary>
/// The weapon GameObject that landed the most recent hit, or null.
///
/// ⚠️ Same convention as LastAttacker — set immediately before OnDamaged fires, so
/// a handler reading it during OnDamaged or OnKilled sees the hit that caused it.
///
/// ⚠️ NULL FOR EVERYTHING THAT IS NOT A BULLET. Only DamageInfo.FromBullet sets
/// the field, so the knife, grenades, traps and status effects leave it null — which
/// is what makes it safe to hand straight to TechEffects, since tech is per weapon
/// prefab and a grenade must not read a rifle's tree.
/// </summary>
public GameObject LastWeapon { get; private set; }
/// <summary>
/// The same weapon as <see cref="LastWeapon"/>, in the form that survives a relay.
///
/// ⛔ `LastWeapon` IS A GameObject AND A CLIENT'S WEAPON HAS NO GameObject HERE. Weapon
/// prefabs are `NetworkMode.Never`, so on the host that field is null for every hit a client
/// sent — which made `ZombieAI`'s Bounty award pay nothing for a client's kills. This carries
/// the prefab path instead, which is what tech is keyed by anyway.
///
/// ⚠️ SET BESIDE `LastWeapon` AND CLEARED BY NOTHING, matching it exactly: the last hit
/// is the one that killed, and a kill is scored in the same call stack.
/// </summary>
public TechEffects.TechRef LastTech { get; private set; }
/// <summary>
/// THE AMMO MOD OF THE GUN WHOSE BULLET HIT LAST (2026-10-04), or "" — Bleeder's, Midas's and the kill mods' answer to "was
/// this that mod's ammo" (`Bleeder`, `KillMods`). Set beside <see cref="LastTech"/> on every hit through `OnDamage`: the
/// host's own gun, or the mod a client's hit was sent with (`AmmoMods.HitModOf`). "" for anything that is not a bullet.
/// </summary>
public string LastMod { get; private set; } = "";
/// <summary>
/// Was the last hit's damage a STAND-IN — Insta-Kill's or Executioner's `Max × 10`, the idiom that kills through the
/// normal damage path — rather than what the shot dealt (2026-10-04). See <see cref="LastHitDamage"/>.
/// </summary>
public bool LastHitForced { get; private set; }
/// <summary>The health the last hit actually took, overheal included — never more than was left.</summary>
public float LastLost { get; private set; }
/// <summary>
/// THE KILLING HIT AS THE KILL EFFECTS SIZE FROM IT (2026-10-04, the review): `LastDamage`, overkill and all — unless that
/// was a stand-in (<see cref="LastHitForced"/>), and then the health it really took. Sized off a stand-in, Shatter Blast
/// and Headhunter dealt fifty and ten times a zombie's whole health to the room on every Executioner kill.
/// </summary>
public float LastHitDamage => LastHitForced ? LastLost : LastDamage;
/// <summary>
/// Where the most recent damage landed, in world space.
///
/// ⚠️ Same convention as LastHitWasMelee — set immediately before OnDamaged
/// fires, for the same reason: widening a public callback's signature for one
/// consumer would break every existing subscriber.
///
/// ⚠️ Zero when the damage carries no position (scripted or area damage).
/// Read it through <see cref="HitPositionOr"/> rather than directly.
/// </summary>
public Vector3 LastHitPosition { get; private set; }
/// <summary>
/// Which part of the body the most recent hit struck, for the gore (`ZombieAI.Gore.cs`): "head", "arm left", "arm right",
/// "leg left", "leg right", "explosion", or "" for anything else. See <see cref="GorePartOf"/>.
///
/// ⚠️ SAME CONVENTION AS LastHitPosition — set immediately before OnDamaged fires, so a handler reading it during OnDamaged
/// or OnKilled sees the hit that caused it.
/// </summary>
public string LastHitPart { get; private set; } = "";
/// <summary>Where a relayed hit carries its body part: `NZNet.HurtRemote` stamps it into `DamageInfo.Extra`.</summary>
public const string PartKey = "nz_part";
/// <summary>The tag an explosion's damage carries (the grenade's), so the gore can tell an explosive kill.</summary>
public const string ExplosionTag = "explosion";
/// <summary>
/// Which part of the body this damage struck, as <see cref="LastHitPart"/> names it.
///
/// ⛔ THE HITBOX'S TAGS, NOT THE HEADSHOT FLAG. `IsHeadshot` is joined by Lucky Shot and Wide Bore further on, so a chest
/// hit can SCORE as a headshot — and must still not pop a head. The tags say where the bullet actually landed: `head` for
/// j_head and j_neck, `arm`/`leg` with `left`/`right` for every bone down to the fingers and toes (see PartMultiplier).
///
/// ⚠️ A RELAYED HIT HAS NO HITBOX HERE, so the machine that saw it names the part and `NZNet.HurtRemote` carries it in
/// `Extra` — read first, because the relay's own `head` tag is the promoted flag, not the hitbox.
/// </summary>
public static string GorePartOf( in DamageInfo damage )
{
var extra = (damage as SWB.Shared.DamageInfo)?.Extra;
if ( extra is not null && extra.TryGetValue( PartKey, out var relayed ) ) return relayed ?? "";
var tags = damage.Tags;
if ( tags is null ) return "";
if ( tags.Has( ExplosionTag ) || tags.Has( NZombies.TechBlast.BlastTag ) ) return "explosion";
if ( tags.Has( "head" ) ) return "head";
if ( tags.Has( "arm" ) ) return tags.Has( "left" ) ? "arm left" : tags.Has( "right" ) ? "arm right" : "arm";
if ( tags.Has( "leg" ) ) return tags.Has( "left" ) ? "leg left" : tags.Has( "right" ) ? "leg right" : "leg";
return "";
}
/// <summary>
/// How much the most recent hit actually dealt, after every multiplier.
///
/// ⛔ EXISTS FOR DEADSHOT'S M3, which splashes a fraction of the KILLING HIT. The death
/// handler in ZombieAI knows a zombie died and where, but not how hard — this is the
/// same latch-it-for-the-death-path shape `LastHitWasMelee`, `LastHitPosition` and
/// `LastWeapon` already use, and for the same reason: widening a public callback's
/// signature for one consumer would break every existing subscriber.
///
/// ⚠️ THE POST-MULTIPLIER FIGURE, not `damage.Damage`. A 10% splash off the raw bullet
/// value would ignore Pack-a-Punch, rarity, the head product and Focus — which is most
/// of what makes a late-game headshot worth splashing.
/// </summary>
public float LastDamage { get; private set; }
/// <summary>
/// How long since this body last actually lost health. STATE, NOT A CALLBACK.
///
/// ⛔ `HealthRegen` USED TO LEARN THIS BY CHAINING `OnDamaged`, AND ON A CLONED BODY THE
/// CHAIN WAS SILENTLY CUT. `OnDamaged` is an Action PROPERTY: `NZPlayer.OnStart` ASSIGNS it and
/// `HealthRegen.OnStart` wraps whatever is already there, so the two only work if NZPlayer runs
/// first. On the scene's own player it does — NZPlayer creates HealthRegen, so the order is
/// guaranteed. On a body made with `GameObject.Clone()` every component already exists, the
/// order is whatever the serialisation happened to be, and if HealthRegen starts first its
/// wrapper is thrown away by NZPlayer's assignment a moment later.
///
/// ⚠️ THE SYMPTOM WAS A PLAYER WHO COULD NOT BE HURT, AND IT DID NOT LOOK LIKE THIS AT ALL.
/// With the chain cut, `_sinceDamage` never reset, so regen believed the player had been calm
/// for the whole round and ticked 10% every 0.05s — every hit was undone within half a second.
/// The host's log read `hit for 30 — 120/150` forty times in a row, never lower, while the
/// host's OWN body went 120 → 90 → 60 → 30 → down on the same zombies. It reads as a
/// networking bug and is nothing of the sort.
///
/// ⚠️ SO THE FACT LIVES HERE, WHERE THE DAMAGE HAPPENS. A reader cannot lose a value it
/// pulls, and there is no ordering left to get wrong.
/// </summary>
public TimeSince SinceLastDamage { get; private set; }
/// <summary>
/// Regeneration waits until this moment (`Time.Now`), whatever <see cref="SinceLastDamage"/> says — 0, so never, unless
/// something burns (2026-10-06: the Fire Margwa's line, `MargwaBurn`).
///
/// ⛔ A BLOCK BESIDE THE DAMAGE CLOCK, NOT A WRITE TO IT. That clock only restarts when health is LOST, and a burn held at
/// 1 HP (it never kills on its own) takes none — so regen would start in the middle of it. The user: *"teh main goal is to
/// make sure the player is unable to regen HP"*.
/// ⚠️ THE OWNER'S MACHINE: `HealthRegen` runs there, on the player's own body.
/// </summary>
public float NoRegenUntil { get; set; }
/// <summary>Throttle for the relayed-hit line — see the relay branch in <see cref="OnDamage"/>.</summary>
RealTimeSince _sinceHitLog;
/// <summary>
/// Take a figure decided somewhere else. NO side effects: no `OnDamaged`, no `OnKilled`, no
/// regen restart, no damage number.
///
/// ⛔ EVERY ONE OF THOSE IS THE POINT OF LEAVING THEM OUT. The host already ran them when the
/// hit landed — it played the voice line, awarded the points, decided the down. Re-running
/// them here would double every consequence of a single hit, and `OnKilled` in particular
/// would put the player down a second time from the mirror of the first.
///
/// ⛔ REGEN *IS* RESTARTED, AND THE NOTE THAT USED TO SIT HERE SAYING OTHERWISE IS OBSOLETE.
///
/// It argued that "the host is already mirroring the result of ITS regen, so restarting the
/// clock here would have both machines healing the same body on two different schedules."
/// That was true until this method was given its refuse-to-heal guard: a mirror can now only
/// ever LOWER health, so the host's regen result cannot cross at all. The owner's local regen
/// is the ONLY thing that heals a client — which makes restarting its clock not optional but
/// the entire mechanism.
///
/// ⚠️ WITHOUT IT A MIRRORED HIT ARRIVES ALREADY "SECONDS SINCE I WAS LAST HURT", so regen
/// begins on the same frame and the bar snaps back up. The blood overlay reads the same clock
/// and cleared with it. *"health visually regens instantly still, including the bloody
/// overlay."*
/// </summary>
public void MirrorTo( float current, float max )
{
// ⛔ AND IT MAY NOT TOUCH `Max` AT ALL. That number is the OWNER'S — Juggernog raises it,
// and Juggernog is bought and held on the machine whose player bought it. The host's copy
// has never heard of the perk, so mirroring its `Max` silently pulled a Jugged client back
// down to the base figure. User: *"unsure if juggernog is working for the client."*
//
// ⚠️ IT WAS ASSIGNED UNCONDITIONALLY, ABOVE THE GUARD BELOW — so even a mirror that
// correctly refused to change `Current` still overwrote `Max` on its way past.
// ⛔ A MIRROR MAY TAKE HEALTH AWAY. IT MAY NEVER GIVE IT BACK.
//
// `NZPlayer.PushHealth` sends the HOST'S COPY of this player's health as an absolute
// value — and that copy is stale, because regen, armour and revives all run on the
// machine that OWNS the body and never travel back up. So the host holds a number from
// whenever it last damaged you, and pushing it as truth HEALS you:
//
// client at 30hp (or downed at 0) · host's copy still reads 150
// a zombie hits you for 30 · host pushes 120
// → you jump from 30 to 120, and a DOWNED player stands up
//
// User: *"the clients sometimes heal when taking damage, resulting in being able to
// revive a downed client by knifing them."*
//
// ⚠️ THIS IS A GUARD, NOT THE FIX. The real answer is that the host should relay the
// DAMAGE AMOUNT rather than a total — the same lesson `NZNet.AwardPoints` records, where
// carrying the amount is what lets the owner's own systems run. Until player health has
// one owner, refusing to heal is what makes a stale number harmless instead of dangerous.
if ( current >= Current ) return;
Current = Math.Clamp( current, 0f, Max );
// ⛔ AND THE DAMAGE CLOCK RESTARTS, OR REGEN UNDOES THIS INSTANTLY. `HealthRegen` reads
// `SinceLastDamage` and refills once it passes the delay — a mirrored hit never touched
// that clock, so it arrived already "seconds since I was last hurt" and the bar snapped
// straight back up. User: *"when damaged a client sees their hp bar refill instantaneously
// when in reality it hasn't."* It was not a display bug; the client really was healing.
//
// ⛔ `SinceLastDamage`, NOT `LastDamage`. The first attempt at this set `LastDamage` — which
// is the AMOUNT of the last hit, not the clock — so it silently zeroed a figure other code
// reads and did nothing whatever for regen. Two names one letter apart, both on this class,
// and the compiler is happy with either.
//
// ⚠️ THE SAME FIELD `OnDamage` SETS, so a mirrored hit and a local one leave regen in the
// same state rather than in two that have to agree.
SinceLastDamage = 0f;
}
/// <summary>
/// The last hit position, or a sensible point on this body when there wasn't
/// one. Damage numbers need somewhere to appear even for a scripted hit.
/// </summary>
public Vector3 HitPositionOr( float fallbackHeight = 48f )
=> LastHitPosition.IsNearlyZero()
? WorldPosition + Vector3.Up * fallbackHeight
: LastHitPosition;
/// <summary>
/// Is this the local player's OWN body — the one thing a client is still authoritative over?
///
/// ⚠️ BOTH HALVES ARE NEEDED. "Has an `NZPlayer`" alone would also match the other
/// player's body; "is mine" alone would also match a zombie nobody owns. Together they name
/// exactly one object per machine.
/// </summary>
bool IsOwnPlayerBody
=> GameObject.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null
&& PlayerPresence.Mine( GameObject );
/// <summary>A player, and not this machine's. Their health is theirs to change.</summary>
bool IsRemotePlayerBody
=> GameObject.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null
&& PlayerPresence.Theirs( GameObject );
/// <summary>
/// Is this one player shooting another? Those hits are dropped.
///
/// ⚠️ BOTH ENDS MUST BE A PLAYER. A zombie hitting a player is not friendly fire, and a
/// player hitting a zombie is the game — so the test is the PAIR, not either half.
///
/// ⚠️ `EverythingInSelf`, MATCHING `IsOwnPlayerBody` DIRECTLY ABOVE. The body carries the
/// `NZPlayer`; searching ancestors would start matching weapons and held props that happen to
/// be parented under it, and searching descendants would match nothing useful.
///
/// ⚠️ THE ATTACKER IS THE SHOOTER'S BODY, NOT THE WEAPON. `DamageInfo.FromBullet` is
/// called with `weapon.Owner.GameObject` as its first argument, so this comparison is between
/// two player bodies and needs no unwrapping.
/// </summary>
bool IsFriendlyFire( in DamageInfo damage )
{
if ( GameObject.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is null )
return false;
var attacker = damage.Attacker;
if ( !attacker.IsValid() || attacker == GameObject ) return false;
return attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null;
}
/// <summary>
/// EVERYTHING THE ATTACKER CONTRIBUTES TO A HIT, as one multiplier.
///
/// ⛔ THE SHOOTER'S PERKS HAVE NEVER REACHED A ZOMBIE THEY DID NOT PERSONALLY HOST. Bullets
/// are relayed — a client's hit is applied by the host through `NZNet.HurtRemote` — and every
/// term below reads `damage.Attacker`, which on the host is the PROXY copy of that client: no
/// perks, no augments, and `FiredBy` resolves to nothing because weapons are
/// `NetworkMode.Never`. So all of it silently evaluated to 1. `HurtRemote`'s own note admits
/// as much: *"the perks do not come with it … that is a real gap."*
///
/// Dead for every client, on every shot: **Deadshot** M1 Deadeye, M2 First Blood and m1 Lucky
/// Shot; **Death Perception**'s headshot and boss multipliers; **Vigor Rush** M3/M4/m5;
/// **Victorious Tortoise** M1 and M4; and the `t2_headshot`, `t5_deadeye` and `t4_bodyshot`
/// weapon nodes.
///
/// ⚠️ THE CLIENT PRE-MULTIPLIES AND THE HOST NEEDS NO CHANGE, WHICH IS WHY THIS IS SAFE.
/// Every term here is attacker-dependent, so on the host's perk-less proxy they all return 1 —
/// the host multiplying by one after the client has already applied the real figure cannot
/// double anything. The alternative, gating each term on a "this was relayed" flag, would have
/// been six edits inside the most delicate method in the project.
///
/// ⚠️ VICTIM-SIDE TERMS ARE DELIBERATELY ABSENT. `HeadshotDamageScale` is a global constant,
/// `PartMultiplier` is about which limb was hit, and Brutus's helmet MUTATES as it is read —
/// all three stay where they are and are applied once, by the host, exactly as before.
///
/// ⚠️ `headBonus` IS RECOMPUTED HERE RATHER THAN PASSED, from the same two conditions the
/// host uses. The client owns both: `ImmuneToHeadshotBonus` is on its own copy of the zombie,
/// and it is holding the weapon.
///
/// ⚠️ <paramref name="mod"/> IS THE BULLET'S AMMO MOD (2026-10-06), resolved once by the relay, for Midas V's term below.
/// </summary>
float AttackerScale( in DamageInfo damage, bool headshot, in TechEffects.TechRef tech, string mod )
{
// ⛔ FIRST BLOOD USED TO BE THE FIRST LINE HERE AND IT WAS A x3 ON EVERY CLIENT BULLET.
//
// Its rule is `if ( max <= 0f || current < max ) return 1f;` — it pays only on an UNDAMAGED
// victim. That is a fact about the VICTIM, and `Health` carries no `[Sync]` at all: a
// client's copy of a zombie is never damaged locally, because `OnDamage` returns at the
// relay branch above before `Apply` ever subtracts. So on a client `current >= max` is
// permanently true and the augment fired on every shot of every magazine.
//
// ⚠️ IT IS BACK ON THE HOST, where the victim's health is real — `OnDamage` still computes
// and applies it there. Only the attacker half needed to travel, and that is a synced
// scalar now (`NZPlayer.FirstBloodLuck`), the same shape as the four drop multipliers.
//
// ⛔ AND TORTOISE'S RING SCALE CAME OUT FOR THE MIRROR-IMAGE REASON. It resolves
// `RingOf( player )` by sweeping THIS MACHINE'S ring components — a fact about the WORLD,
// not about the attacker — so it does NOT return 1 on the host and the client pre-applying
// it was a genuine double whenever the shooter stood in a ring the host also knows about.
//
// ⚠️ WHICH RESTORES THE INVARIANT THIS METHOD'S SAFETY ARGUMENT DEPENDS ON: every term
// below reads ONLY the attacker, so every one of them returns 1 against the host's
// perk-less proxy and the host multiplying by one after the client cannot double anything.
// Two terms had quietly stopped obeying it. Any term added here must be checked against it.
var m = 1f;
// ⚠️ BODY SHOT STILL GATES THE PERK TERMS FROM HERE, AND THAT IS NOT A DOUBLE. The node
// means "no headshot bonus", and the two multipliers below are the SHOOTER'S — they exist
// on no other machine, so the host cannot suppress them on this client's behalf. Reading
// the node is free of side effects; it decides whether a term the client alone can apply
// gets applied.
var headBonus = headshot && !ImmuneToHeadshotBonus
&& !TechEffects.Has( tech, "t4_bodyshot" );
// ⛔ AND PRECISION ROUNDS AND DEADEYE CAME OUT OF THIS PRODUCT, WHICH WAS THE OTHER HALF
// OF FIXING THEM. They were multiplied HERE because the host could not read them — the
// method's own safety argument is that every term returns 1 against the host's copy of
// the attacker, and with the tree unreplicated that was true of tech too. It is no longer:
// `NZPlayer.TechNet` syncs the tree, so the host now applies both itself at the head
// product in `OnDamage`, and leaving them here would have made every client headshot land
// for the SQUARE of its node. Anything added to this method must be checked against that
// same invariant — two terms had already stopped obeying it once before.
if ( headBonus )
m *= PerkEffects.HeadshotScaleFor( damage.Attacker )
* NZombies.DeadshotAugments.HeadshotScale( damage.Attacker );
// ⚠️ NAPALM'S m1 ACCELERANT IS A TERM HERE TOO. It rides the `burn` vulnerability already
// applied to the victim and contributes only the delta up to x2.5 — but the question it
// asks ("does the ATTACKER own m1") is the attacker's, so it belongs with the rest.
//
// ⚠️ MELEE-EXCLUDED, MATCHING THE HOST'S OWN GUARD. The knife path computes its damage
// separately and this term is inside a `!LastHitWasMelee` block there.
if ( !IsMelee( damage ) )
m *= NZombies.FireAugments.BurnDamageBonus( damage.Attacker, GameObject );
m *= NZombies.VigorAugments.DamageScale( damage.Attacker, WorldPosition );
m *= NZombies.DeathAugments.BossScaleAgainst( damage.Attacker, GameObject );
// ⚠️ MIDAS V GOLD STANDARD (ammo mod upgrade, 2026-10-06): a bullet from a Midas gun whose owner has level V deals ×(1 +
// points earned / 1,000,000), the shooter's own scoreboard figure, which only this machine holds exactly.
// ⛔ IT KEEPS THE INVARIANT ABOVE BY AN EXPLICIT TEST, NOT BY LUCK. Two of its three inputs DO reach the host — the level is
// synced (`NZPlayer.AmmoUpgradeNet`) and the mod travels with this hit, the trap that caught the tech nodes — and only the
// third, the earned figure, happens to be 0 on the proxy. `KillMods.GoldStandardScale` answers 1 for any body that is not
// this machine's, and the host scales its own bullets in `OnDamage`, beside Vigor's terms.
m *= NZombies.KillMods.GoldStandardScale( damage.Attacker, mod );
return m;
}
/// <summary>Engine entry point — the bullet path calls this.</summary>
public void OnDamage( in DamageInfo damage )
{
// ⚠️ OUTER SCOPE -- contains most of the dmg.* scopes below, so they do NOT sum with it.
// Outer minus the sum of its inners is cost not yet attributed to anything, which is
// how the next hot spot gets found.
using var _cpu = CpuScope.Measure( "dmg.ondamage" );
// ⛔ NO FRIENDLY FIRE, AND IT NEVER HAD A GUARD — IT SIMPLY HAD NO SYMMETRY EITHER, SO
// ONLY HALF OF IT WAS VISIBLE. Reported as *"o host dá dano ao client mas o client não ao
// host"*, which reads as a networking bug and is not one: players were shootable by
// design-accident, and damage applies on the machine that resolved the bullet. The host
// has authority over a client's body so its shots landed; a client has none over the
// host's, so its shots evaporated. Removing the damage removes the asymmetry with it.
//
// ⚠️ HERE BECAUSE THIS METHOD IS THE ONE CHOKEPOINT, exactly as the relay below is: the
// block at the top of this method already states that bullets, the knife, grenades, fire
// pits and the augments all arrive through `OnDamage`. A guard at the bullet path alone
// would have left a teammate killable with a grenade.
//
// ⚠️ ZOMBIES ARE UNAFFECTED BY CONSTRUCTION. They hurt a player through `Apply`, not
// through here — the ⚠ note a few lines down says so — so this cannot make anyone
// invulnerable to the thing that is supposed to kill them.
//
// ⚠️ AND SELF-DAMAGE STILL LANDS. `attacker == GameObject` is excluded, so your own
// grenade and your own fall still hurt you — which PhD Flopper exists to negate and would
// be pointless against a blanket player-immunity.
if ( IsFriendlyFire( damage ) ) return;
// ⚠️ THE WALL-IMPACT GUARD THAT WAS HERE IS GONE, AND ITS ABSENCE IS THE FIX RATHER THAN A
// REGRESSION. It refused `"fall"`-tagged damage whose motion was horizontal — a filter on
// the ENGINE's impact damage, keyed on a tag `Armor.Bypasses` itself calls unverified.
// `ShoveGuard` now turns that engine damage off outright and generates landing damage from
// downward speed alone, so nothing produces sideways impact damage for a guard to catch.
// Filtering a source that no longer exists is a test that can only ever be wrong.
// ── SOMEBODY ELSE'S BODY ───────────────────────────────────
//
// ⛔ A PROXY'S HEALTH IS A COPY, AND KILLING A COPY KILLS NOBODY. Zombies replicate
// from the host, so a client shooting one runs this method against its own copy: that
// zombie would drop dead on the client's screen, stay alive and chasing on the host's,
// and then be resurrected the moment the next transform update arrived. Worse, the local
// `Die()` would eventually destroy an object the client does not own.
//
// ⚠️ RELAYED RATHER THAN IGNORED. Refusing the damage here would be consistent and
// would mean a client's bullets did nothing at all. The hit is real — it happened on a
// body whose position the host itself sent — so it is forwarded to the machine entitled
// to act on it.
//
// ⚠️ THIS IS THE ONE CHOKEPOINT FOR EVERY SOURCE. Bullets, the knife, grenades, fire
// pits and the augments all arrive through `OnDamage`; putting the relay here rather than
// at each caller is what stops the next damage source from silently being local-only.
//
// ⚠️ NOT YET TRUE OF DAMAGE TO PLAYERS. Zombies hurt a player through `Apply`, not
// through here, and a player's health is still per-machine — that is section 9's job
// (downs and revives) and it is deliberately not being half-done in passing.
// ⛔ `IsProxy` WAS TOO NARROW HERE FOR THE SAME REASON IT WAS WRONG FOR ZOMBIES: a
// zombie is `NetworkSpawn()`ed with no owner, so it is nobody's proxy, so a client's
// bullets were quietly applying to its own copy after all. The rule is the authority
// model stated plainly — **the host owns everything except each client's own body.**
if ( NZGame.IsClient && !IsOwnPlayerBody )
{
// ⚠️ WIDE BORE PROMOTES ALONGSIDE LUCKY SHOT, and for the same reason it is done
// HERE: everything downstream reads this one boolean. `||` short-circuits, so a real
// headshot never pays for the distance test.
// ⚠️ AND BULLSEYE (sniper tier 3, 2026-10-04), the same kind of promotion, decided on this machine like these two.
var head = IsHeadshot( damage ) || WideBoreHead( damage ) || ClassTech.BullseyeHead( damage );
// ⚠️ m1 LUCKY SHOT PROMOTES *HERE*, BEFORE THE FLAG IS SENT, so everything the
// promotion is supposed to reach actually does: the head damage product, Focus's
// streak, Trophy's points, Recycler's refund and the 100-point headshot award are all
// decided from this one boolean on one machine or the other.
if ( !head && NZombies.DeadshotAugments.RollLuckyShot( damage.Attacker ) )
head = true;
var relayTech = FiredBy( damage );
// ⚠️ AND THE GUN'S AMMO MOD, RESOLVED ONCE (2026-10-06): it goes with the hit below, and Midas V's scale in `AttackerScale`
// reads it. `AmmoMods.On` builds the catalogue to find it, and this runs per pellet.
var relayMod = AmmoMods.BulletModOf( damage, relayTech );
// ⛔ THE SHOOTER'S OWN MULTIPLIERS ARE APPLIED HERE OR NOWHERE. See `AttackerScale`.
var scale = AttackerScale( damage, head, relayTech, relayMod );
NZNet.HurtRemote( GameObject.Id,
damage.Attacker.IsValid() ? damage.Attacker.Id : Guid.Empty,
damage.Damage * scale, // ⚠️ `relayed` below is this same figure — see its note
damage.Position,
head,
IsMelee( damage ),
// ⚠️ THE POINTS VERDICT TRAVELS WITH THE HIT, because the host is what pays and only
// this machine knows which pellet of which shot this is. `ShotPoints` decided it a
// few frames of call stack ago, in the bullet loop.
Pays( damage ),
// ⛔ AND SO DOES THE WEAPON — AS A PREFAB PATH, WHICH IS THE ONLY FORM OF IT THAT
// CAN CROSS. Weapon prefabs are `NetworkMode.Never`, so the host has no object to
// find and eight nodes read as unowned there until this argument existed. See
// `TechEffects.TechRef`.
relayTech.Prefab ?? "",
// ⚠️ ONE STAT GOES WITH IT, FOR BOUNCY ROUNDS' LINK BUDGET, and it is the only
// one: everything else the host asks is "does this tree own that id", which the
// tree answers. `GetShootInfo` is a field read, so this costs nothing per pellet.
NZombies.TechEffects.PenetrationOf( relayTech, damage ),
// ⚠️ AND WHERE IT LANDED, FOR THE GORE. The host has no hitbox for a relayed hit, so only this machine can say an arm
// or the head was struck (`GorePartOf`, `ZombieAI.Gore.cs`).
GorePartOf( damage ),
// ⚠️ AND THE AMMO MOD OF THE GUN THAT FIRED IT (2026-10-04), which only this machine knows: Bleeder bleeds it on
// the host, and Midas and the kill mods read it off the corpse (`LastMod`). Bullets only.
relayMod,
// ⚠️ AND WHICH BULLET IT WAS, for Follow-Through alone (marksman tier 3, 2026-10-04): the host carries a kill's
// leftover to the same bullet's next zombie (`ClassTech.FollowThroughCarry`). "" from every other gun.
ClassTech.FollowThroughShot( relayTech, damage ) );
// ⚠️ THE PER-HEADSHOT SIDE EFFECTS BELONG TO THE SHOOTER AND SO RUN ON THE SHOOTER.
// Concussion stuns the zombie — which reaches the host now that `StatusEffects` relays
// — and Focus's streak is the shooter's own state, which only this machine holds.
if ( head )
{
NZombies.DeadshotAugments.TryConcuss( damage.Attacker, GameObject );
NZombies.DeadshotAugments.OnHeadshotHit( damage.Attacker );
}
// ⛔ NAPALM NECTAR IS GATED ON `HasNapalm( attacker )`, WHICH IS FALSE ON A PROXY — so
// the base ignite and every augment that modifies it did nothing whatever for a client.
// Not one of the ten: no burn, no Wildfire, no Scorched Earth pit, no Chain Reaction.
//
// ⚠️ THE BURN ITSELF REACHES THE HOST because `StatusEffects` relays now; this only
// has to decide to light it, on the machine that knows the perk is owned.
//
// ⚠️ MELEE-EXCLUDED, matching the host's own `!LastHitWasMelee` guard — the knife
// ignites through `FireAugments.OnKnifeHit`, which already runs on the attacker.
// ⚠️ NOT FOR THE PRISMA ON OBERON (2026-10-07): it marks him and hurts him not at all — a client's ignite or ammo mod
// off its round would (`PrismaChain.Spares`; the host zeroes the hit itself)
if ( !IsMelee( damage ) && !(NZombies.PrismaChain.FlatDamage( relayTech ) && NZombies.PrismaChain.Spares( GameObject )) )
{
if ( PerkEffects.HasNapalm( damage.Attacker ) )
{
NZombies.FireAugments.TryIgnite( damage.Attacker, GameObject );
NZombies.FireAugments.TryPit( damage.Attacker, damage.Position );
}
// ⛔ AND EVERY AMMO MOD WAS DEAD FOR CLIENTS FOR A SECOND, INDEPENDENT REASON.
// `AmmoMods.OnZombieHit` resolves the player from the attacker — a proxy — and then
// asks `VultureAugments.HeldWeapon( player )`. Weapons are `NetworkMode.Never`, so
// the host's copy of a client is holding NOTHING, and the method returns before it
// reaches the roll. Cryofreeze, Dead Wire, Fireworks, Radioactive Decay, Blast
// Furnace and Elemental Pop's M1 — none of them have ever fired for a client.
//
// ⚠️ AND THE COOLDOWN LIVES ON THE PLAYER (`AmmoModReady`), which is another thing
// only the owner's own copy has. Rolling here is what makes it real state rather
// than a dictionary on a ghost.
NZombies.AmmoMods.OnZombieHit( damage.Attacker, GameObject, AmmoMods.IsBullet( damage ) );
}
// ⛔ THE PER-HIT HOOK BLOCK IS ONE SEAM, NOT THREE PERK BUGS. Four hooks sit
// consecutively further down this method, below the `return` above, and every one of
// them reads `damage.Attacker` to ask what that player owns — so for a client's bullet
// all four ran on the host against a proxy with no perks and no weapon. Mule Kick's
// M2 Overflow, Timeslip's M3 Fault Lines and M4 Chrono Rounds, and Vigor Rush's m4
// Last Round: four augments across three perks, one cause, one place.
//
// ⚠️ THE BLOCK'S OWN COMMENT SAYS IT WAS GATHERED HERE so "the two bullet paths"
// could not diverge. The divergence it did not anticipate is the MACHINE, not the path.
//
// ⚠️ NOT MELEE-GATED, matching the host's own placement — these are outside the
// `!LastHitWasMelee` block there because a knife hit rolls them too.
//
// ⚠️ `relayed` IS THE FINAL FIGURE, which `TryLastRound` requires: it splashes what the
// shot actually dealt, so it has to see the multipliers this branch just applied.
var relayed = damage.Damage * scale;
if ( relayed > 0f )
{
NZombies.MuleKickAugments.TryOverflow( damage.Attacker );
NZombies.TimeAugments.OnZombieHit( damage.Attacker, GameObject );
NZombies.TimeAugments.TryPit( damage.Attacker, damage.Position );
NZombies.VigorAugments.TryLastRound( damage.Attacker,
damage.Position, relayed, GameObject );
}
// ⛔ THE SHOOTER STILL GETS ITS NUMBER, AND WITHOUT THIS THE RELAY IS UNUSABLE.
// Returning early skips everything below — including the damage number — so a client
// shot a zombie and got no flinch, no figure and no hitmarker: *"zombies do not get
// hit by client shots, or maybe do but there's no way to tell"*. Feedback the player
// cannot see is the same as no feedback, and a mechanic nobody can observe cannot be
// tested by anybody.
//
// ⚠️ IT IS THE CLIENT'S OWN FIGURE, NOT THE HOST'S VERDICT, and those can differ —
// the host applies its copy of the attacker's perks, which do not replicate yet. This
// is a hit CONFIRMATION, not a claim about the zombie's health. When player state
// syncs the two converge; until then the number is honest about what this machine
// computed and the health bar is the host's business.
LastHitPosition = damage.Position;
// ⛔ THE NUMBER MUST BE SCALED FOR *WHERE* IT LANDED, and it was not. `damage.Damage`
// is the weapon's base figure; the head and limb multipliers are applied further down
// this method — which the relay branch returns before ever reaching. So a client saw
// the same figure for a headshot, a torso hit and a foot. User: *"when hitting a zombie
// clientside, the damage numbers always display the base damage no matter where i
// hit."*
//
// ⚠️ THE SAME TWO SCALARS THE HOST USES, read from the same fields, so the number a
// client sees is the shape of the real one rather than a second invented rule. The
// attacker's PERKS are still not in it — those live on the host's copy and do not
// replicate — which the note above already says this figure is honest about.
// ⚠️ THE NUMBER NOW INCLUDES THE SHOOTER'S OWN MULTIPLIERS, because the hit does. It
// used to be honest about a figure that had no perks in it; that figure is no longer
// what the zombie takes, and a damage number that disagrees with the damage is worse
// than one that is merely incomplete.
// ⚠️ AND THE TORTOISE RING THE SHOOTER STANDS IN (2026-09-27). The host applies it — it was
// taken out of `AttackerScale` because it would double there — so it is not in `scale`, and a
// client in a Dig In ring saw two-thirds of what it dealt. Every ring is mirrored here, so
// this machine can read it; for the NUMBER only, never for the hit, which the host scales.
// ⚠️ AND THE PER-CLASS AUGMENTS' HEAD, BODY AND VICTIM TERMS (2026-10-04) — One Shot One Kill, Silver Bullets, Point
// Blank — which the host applies; here for the number only, like the ring.
var shown = damage.Damage * scale * (head
? HeadshotDamageScale * ClassTech.HeadScale( relayTech )
: PartMultiplier( damage, relayTech ) * ClassTech.BodyScale( relayTech ))
* ClassTech.VictimScale( relayTech, GameObject, damage.Position )
* NZombies.TortoiseAugments.DamageScale( damage.Attacker );
DamageNumbers.Report( this, shown, head );
// ⛔ SO "NO DAMAGE NUMBERS" STOPS BEING TWO DIFFERENT BUGS. The client reported no
// numbers at all, and that has two causes with opposite fixes: the hit never reached
// this method (nothing to draw) or it did and the HUD did not draw it. One line here
// separates them permanently — if this prints and no number appears, the fault is in
// `DamageNumbers`; if it never prints, the shot never landed.
//
// ⚠️ THROTTLED, because an automatic weapon would otherwise write ten lines a
// second. Two a second is enough to tell "hits are landing" from "nothing is".
if ( _sinceHitLog > 0.5f )
{
_sinceHitLog = 0f;
Log.Info( $"[nz-net] relayed a hit on '{GameObject.Name}' for {damage.Damage:0}"
+ $"{(head ? " (head)" : "")} — damage number reported locally" );
}
return;
}
// ── PhD FLOPPER ──────────────────────────────────────────────────────
// ⛔ BLOCKED HERE, IN OnDamage, AND DELIBERATELY NOT IN Apply. Zombies hurt
// the player through `Health.Apply` (ZombieAI:3495); grenades, bullets and
// anything else arrive through THIS method. Blocking in Apply would make
// the player immune to zombies too — which is not a perk, it is a cheat.
//
// ⚠️ POSITIVE CHECK ON THE ATTACKER, not "nobody uses OnDamage but the
// grenade". If zombies are ever routed through OnDamage — for hitboxes,
// say — an absence-based test would silently make PhD block zombie damage.
// Asking "is the attacker a zombie" stays correct through that change.
if ( PhdBlocks( damage ) )
{
Log.Info( $"[nz-perk] PhD Flopper absorbed {damage.Damage:0.#}" );
return;
}
LastHitWasMelee = IsMelee( damage );
LastHitPays = Pays( damage );
LastHitForced = false;
LastHitPosition = damage.Position;
LastAttacker = damage.Attacker;
LastHitPart = GorePartOf( damage );
// ⚠️ THE WEAPON IS LATCHED TOO, and it was not until 2026-08-20. `DamageInfo`
// has carried a `Weapon` field all along and this method only ever read
// `Attacker`, so anything asking "what killed this" after the fact could name
// the player but not the gun. Bounty had to fall back to whatever the killer
// happens to be HOLDING, which with Mule Kick reads the wrong weapon's tech tree
// whenever a gun is swapped away between the shot and the death.
//
// ⚠️ Populated for BULLETS ONLY, because `DamageInfo.FromBullet` is the only
// thing that sets it — the knife, grenades, traps and status effects hand-build
// their DamageInfo and leave it null. That is correct rather than a gap: tech is
// per weapon prefab, so a grenade must not read a rifle's tree.
LastWeapon = damage.Weapon;
// ⚠️ AND BULLSEYE (sniper tier 3, 2026-10-04): a tag the shooter's own bullet carries, so a client's arrives already
// decided, in `head`, like Wide Bore's.
var headshot = IsHeadshot( damage ) || WideBoreHead( damage ) || ClassTech.BullseyeHead( damage );
// ⛔ DEADSHOT'S m1 "LUCKY SHOT" PROMOTES A HIT TO A HEADSHOT *HERE*, at the one
// place headshots are decided, and that is what makes the promotion complete rather
// than cosmetic. Everything downstream reads this local: the head damage product,
// Focus's streak, Trophy's points, Recycler's refund, Cranial Detonation's blast and
// the 100-point headshot kill award. Scaling the damage instead would have handed
// over the multiplier and none of the rest.
//
// ⚠️ SHORT-CIRCUITED ON A REAL HEADSHOT, so the roll only ever promotes. Rolling on
// a genuine headshot as well would be a 90% chance of DEMOTING one, which is the
// opposite augment.
if ( !headshot && NZombies.DeadshotAugments.RollLuckyShot( damage.Attacker ) )
headshot = true;
var amount = damage.Damage;
// ⚠️ THE FIRING WEAPON IS RESOLVED ONCE, then handed to both the head product
// and PartMultiplier. Four tech nodes now read it on a single hit, and every
// pellet of a 16-pellet shotgun blast is its own DamageInfo through this method.
var tech = FiredBy( damage );
// ⚠️ FOLLOW-THROUGH (marksman tier 3, 2026-10-04): a kill earlier along this same bullet left damage over, and it
// joins this hit's raw figure, so this victim's own multipliers scale it like the rest of the round. Kept as the
// bullet's figure for the kill test after `Apply`.
amount += ClassTech.FollowThroughCarry( tech, damage, this );
var bullet = amount;
// ⛔ DAMAGE YOU DO TO YOURSELF TAKES NONE OF YOUR OWN DAMAGE BONUSES (2026-10-03). A fall (`ShoveGuard.Land`) and your
// own grenade arrive here with YOU as the attacker, so every attacker-side term below read them as your hit on a target:
// Vigor Rush's M3 Point Blank saw a victim at zero range and DOUBLED it — a 24-damage landing took 49 in the round-88
// game — and its M4/m5, Deadshot's First Blood and Tortoise's ring bonus would scale it the same way. Those multiply the
// damage you deal; none of them is meant to make you hurt yourself harder. Victim-side terms are untouched.
var selfInflicted = damage.Attacker.IsValid() && damage.Attacker.Root == GameObject.Root;
// ⛔ LATCHED FOR THE DEATH AWARD, WHICH RUNS AFTER THE DamageInfo IS GONE. `ZombieAI`
// pays Bounty out of the weapon that killed, and it used to rebuild that from `LastWeapon`
// — which is `damage.Weapon`, so it was null for every client hit and then fell through to
// `Rarity.HeldBy`, which resolves a component on a body whose weapons are not on this
// machine either. Two dead ends for the same reason; one ref fixes both.
LastTech = tech;
// ⚠️ AND THE HIT'S AMMO MOD (2026-10-04) — this machine's own gun, or the one a client's hit was sent with.
LastMod = AmmoMods.HitModOf( damage, tech );
// ⛔ DEADSHOT'S M2 "FIRST BLOOD" IS READ BEFORE ANYTHING SUBTRACTS, which is the
// whole requirement. It tests whether the victim is UNDAMAGED, so it has to be
// sampled while `Current` is still the pre-hit value — evaluating it after the
// subtraction would make it fire only on kills that left the zombie at full health,
// i.e. never.
//
// ⚠️ NOT HEADSHOT-GATED, unlike the original's. Any first hit on a fresh zombie
// triples, which is what makes it a different major from Deadeye rather than a
// bigger one.
//
// ⚠️ STORED, NOT APPLIED YET. It multiplies below alongside the head product so the
// two compose in a documented order rather than one silently preceding the other.
//
// ⛔ NOT FOR DAMAGE YOU DO TO YOURSELF — see `selfInflicted`. A fall at full health is a "first hit on an undamaged
// target" in every sense this test can see, and would have tripled.
var firstBlood = selfInflicted ? 1f : NZombies.DeadshotAugments.FirstBloodScale(
damage.Attacker, Current, Max );
// ⛔ INSTA-KILL: ANY hit from a player kills a zombie outright.
//
// ⚠️ ZOMBIES ONLY. This is the shared Health component — the PLAYER carries
// one too, and scaling damage here without the check would have Insta-Kill
// kill YOU in one hit, which is the opposite of the powerup.
//
// ⚠️ Applied as damage rather than a direct kill so the whole death path
// still runs: the melee flag, the headshot bonus, ragdolls, sounds and the
// round's alive count all hang off `OnDamage` finishing normally.
//
// ⛔ UNLESS THE VARIANT SAYS OTHERWISE. The napalm, the shrieker, Brutus and Oberon carry an
// `InstaKillMultiplier` and take that many times the hit instead of dying from it — see the
// property. Everything else is killed exactly as before.
//
// ⚠️ APPLIED ONCE, ON THE MACHINE THAT OWNS THE ZOMBIE. The client relay branch above returns
// before this line, so a client's hit reaches the host raw (times the shooter's own
// multipliers only) and is multiplied here, once — the same route the kill has always taken.
// Scaling it on both ends would have made it nine.
if ( amount > 0f && PowerupEffects.InstaKill )
{
var ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
if ( ai.IsValid() )
{
// ⚠️ A STAND-IN WHEN IT IS `Max × 10` (not a variant's real multiple): see `LastHitDamage`.
var scale = PowerupEffects.InstaKillScale( ai.Variant );
amount = scale is float s ? amount * s : Max * 10f;
LastHitForced |= scale is null;
}
}
// ⛔ VIGOR RUSH'S M2 "EXECUTIONER" USES THE SAME OVERKILL-DAMAGE IDIOM AS INSTA-KILL
// ABOVE, AND FOR THE SAME REASON its own comment gives: applied as damage rather
// than a direct kill so the whole death path still runs — the melee flag, the
// headshot bonus, ragdolls, sounds, the round's alive count and every on-kill augment
// all hang off `OnDamage` finishing normally. A `Current = 0` here would skip all of
// it.
//
// ⚠️ THE THRESHOLD IS TESTED AGAINST THE PRE-HIT HEALTH, which is what "below 20%"
// means. `Current` has not been touched yet at this line.
//
// ⚠️ ZOMBIES ONLY, checked the same way — this is the shared Health component and the
// player carries one too.
if ( amount > 0f
&& NZombies.VigorAugments.Executes( damage.Attacker, Current, Max )
&& Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() )
{
amount = Max * 10f;
LastHitForced = true;
}
// ⛔ HEADSHOT DAMAGE IS SCALED HERE, NOT PER-WEAPON. The original gamemode
// does exactly this, once, for every weapon:
//
// sh_constructor.lua:
// if hitgroup == HITGROUP_HEAD and v != "walker_necromorph"
// and v != "special_bot" and v != "walker_xeno" then
// dmginfo:ScaleDamage(2.5)
// end
//
// ⚠️ 2.5, NOT 2.0 — ARC9's per-weapon BodyDamageMults says 2, but nZombies
// overrides it gamemode-side, so ported weapons must NOT carry their own
// head multiplier or the two compound.
//
// ⚠️ Every OTHER hitgroup in the original drives gibbing, stun and
// decapitation — NOT damage. There is no arm/leg damage multiplier to port.
// ⚠️ Death Perception SCALES THIS MULTIPLIER (x1.5), it does not add to
// it — 2.5x becomes 3.75x. Applied here rather than on the weapon so it
// rides on whatever head scale is in play, and read off the ATTACKER
// because this component is on the zombie.
// ── PRECISION ROUNDS (`t2_headshot`) ─────────────────────────────────
//
// ⚠️ A THIRD MULTIPLY ON THE SAME PRODUCT, alongside Death Perception, for
// the same reason: it rides on whatever head scale is in play instead of
// replacing it, and it is resolved from the WEAPON rather than from this
// component — Health is on the zombie, so a `Components.Get` here finds
// nothing and the node would silently do nothing.
//
// ⛔ IT STACKS ON A LIVE DATA BUG AND ITS MAGNITUDE CANNOT BE JUDGED UNTIL
// THAT IS FIXED. AWM, G3, SVD and WA2000 author `HeadMultiplier: 2.0` in
// their prefabs, which `ShootInfo.DamageFor` applies IN ADDITION to the 2.5
// here — so those four already deal x5 headshots while the other 27 deal
// x2.5, and WeaponTech's own `t2_headshot` row warns about the same
// compounding. Not fixed here: the fault is in four prefabs, and papering
// over it in this method would hide it from whoever does fix it.
//
// ── DEADEYE (`t5_deadeye`) ───────────────────────────────────────────
//
// ⚠️ A FOURTH MULTIPLY ON THE SAME PRODUCT, for the reason the two blocks above
// give: it rides on whatever head scale is in play rather than replacing it, and
// it is resolved from the WEAPON because this component is on the zombie.
//
// ⚠️ AN IMMUNE ZOMBIE GIVES DEADEYE NOTHING while still charging its x0.5 body
// penalty, so Deadeye against a necromorph, special_bot or xeno is pure
// downside. That falls out of the existing immunity rule rather than being
// chosen here, and it is coherent: the node's whole purchase is head damage.
//
// ⛔ THE PIERCE HALF OF THIS NODE IS STILL NOT WIRED, BUT IT IS NO LONGER BLOCKED.
// Penetration is a ShootInfo field, not one of Health's, so the head and body
// halves land here and the pierce lands nowhere — owning Deadeye today still gets
// two thirds of what the catalogue promises.
//
// ⚠️ WHAT CHANGED: `NZPlayer.ApplyTech` now writes `PenetrationDepth` from
// `basePen * penFactor + penBonus`, so granting Deadeye its pierce is one term in
// that expression rather than a new system. Double Tap's m2 already rides it.
//
// ⛔ WHAT IS MISSING IS A NUMBER, NOT A MECHANISM. The node row says only "and
// pierce" — `WeaponTech.cs:517` names no magnitude, and `Penetration` is already
// TRUE on all 31 prefabs, so "pierce" has to mean "more pierce" by some amount
// nobody has specified. Inventing one here would put an unsourced value in a
// catalogue whose whole discipline is that its numbers come from somewhere.
//
// ── BODY SHOT (`t4_bodyshot`) ────────────────────────────────────────
//
// ⛔ THE WHOLE HEAD PRODUCT IS SKIPPED, NOT JUST HeadshotDamageScale. The node
// says the bonus is lost ENTIRELY, and neutralising only the 2.5 would leave
// Death Perception's x1.5 and Precision Rounds' multiplier still riding on the
// head — so the head would stay the best place to shoot on a node whose entire
// trade is "stop aiming". A player owning Precision Rounds AND Body Shot on one
// weapon therefore loses the tier-2 pick outright; that is the price of a
// contradictory build, not a bug, and WeaponTech's tier-5 header says in as many
// words that nothing forbids a bad pair.
//
// ⚠️ IT REUSES THE ImmuneToHeadshotBonus PATH rather than adding a branch,
// because for this hit the two mean the same thing: no head bonus. A head hit
// then takes NEITHER the head product NOR PartMultiplier — head is deliberately
// absent from that table — and lands at x1, below the x1.5 torso. That inversion
// IS the node.
// ⚠️ THIS HALF IS UNCHANGED BY THE REDESIGN and is now the node's ONLY presence
// in this file: the head multiplier is forced to 1 by skipping the whole product
// below. Neutralising just `HeadshotDamageScale` would leave Death Perception and
// Precision Rounds still riding on the head, so the head would stay the best place
// to shoot on a node whose entire trade is that it should not be.
var headBonus = headshot && !ImmuneToHeadshotBonus
&& !TechEffects.Has( tech, "t4_bodyshot" );
// ⚠️ DEADSHOT'S M1 AND M4 ARE A FIFTH MULTIPLY ON THE SAME PRODUCT, for the reason
// the four blocks above give: they ride whatever head scale is in play rather than
// replacing it, so a player keeps their base 2.5, Death Perception and every node
// they own. Both come through one call because both are headshot-only.
// ⚠️ THE WHOLE if/else CHAIN IS WRAPPED, not just the if-branch: a using-block around
// only the branch would separate the `else` from its `if` and stop compiling. So dmg.part
// nests inside this scope in the else case -- those two do not sum.
// ⛔ A FLAT WEAPON SKIPS THE WHOLE PRODUCT, BOTH BRANCHES. Neutralising only the headshot
// half would leave `PartMultiplier` scaling limb shots down, so the gun would still care
// where it hit — just in the other direction. The Prisma deals its number wherever it
// lands, which is the point of a round that plants a fuse rather than doing the killing.
//
// ⚠️ THE WHOLE if/else CHAIN IS INSIDE THE GUARD for the reason the note below gives
// about the `using`: splitting an `if` from its `else` does not compile.
var flat = NZombies.PrismaChain.FlatDamage( tech );
// ⛔ THE PRISMA DEALS OBERON NOTHING (2026-10-07, the user: *"make it so the prisma is unable to damage the boss oberon, only applying the debuff that does not damage but allows it to get more damaged"*). Zeroed here, before
// every multiplier, so nothing below can turn it back into damage; its fuse still lights (`PrismaChain.OnHit`, off the
// round's own figure), and the Napalm ignite and the ammo mods do not roll off it (the non-melee block). With nothing
// dealt there is no hit reaction and no points, which is the request: it marks him, it does not hurt him.
var spared = flat && NZombies.PrismaChain.Spares( GameObject );
if ( spared ) amount = 0f;
using ( CpuScope.Measure( "dmg.headscale" ) )
{
if ( flat ) { /* no head bonus, no part multiplier */ }
else
// ⚠️ AND THESE TWO NOW APPLY TO A CLIENT'S SHOTS AS WELL, which is what makes this
// the ONLY place they are applied. The relay used to pre-multiply them on the shooter
// because the host could not read the tree; it can, so `AttackerScale` no longer
// does — the two edits are one change and must not be separated.
if ( headBonus )
amount *= HeadshotDamageScale
* PerkEffects.HeadshotScaleFor( damage.Attacker )
* NZombies.DeadshotAugments.HeadshotScale( damage.Attacker )
* TechEffects.Factor( tech, "t2_headshot" )
* TechEffects.Factor( tech, "t5_deadeye" )
// ⚠️ THE PER-CLASS AUGMENTS' HEAD (2026-10-04): every `s.head`, and One Shot One Kill's x5.
* ClassTech.HeadScale( tech );
else if ( !headshot )
amount *= PartMultiplier( damage, tech )
// ⚠️ and One Shot One Kill's fifth on everything else.
* ClassTech.BodyScale( tech );
}
// ⚠️ VIGOR RUSH'S M3, M4 AND m5 MULTIPLY HERE, beside First Blood, because all four
// are conditions on the finished hit rather than on where it landed. The perk's own
// base multiplier is elsewhere — in the bullet-tagged block below — deliberately:
// the base is bullets only, whereas these apply to whatever the perk applied to.
// ⛔ NOT ON YOURSELF — `selfInflicted` (M3 doubled every fall).
using ( CpuScope.Measure( "dmg.vigor" ) )
if ( !selfInflicted )
amount *= NZombies.VigorAugments.DamageScale( damage.Attacker, WorldPosition );
// ⚠️ MIDAS V GOLD STANDARD (ammo mod upgrade, 2026-10-06) BESIDE THEM, the shooter's own term on the finished hit: a bullet
// from a Midas gun whose owner has level V deals ×(1 + points earned this game / 1,000,000). The user: *"tier V: your damage
// scales with the ammount of points you have had in the entire game / 100% for each 1000000, measured from the points in
// the scoreboard"*. `LastMod` is this hit's mod, latched above, so only that gun's bullets.
// ⛔ ONCE A HIT. The host's own bullets (solo included) take it HERE; a client's arrive with it already in, from the
// shooter's machine (`AttackerScale`), and read ×1 here, because `KillMods.GoldStandardScale` answers only for this
// machine's own body. Before the helmets and Last Round, as a client's figure has it.
// ⛔ And like Vigor's, never on yourself — `selfInflicted`.
using ( CpuScope.Measure( "dmg.ammomods" ) )
if ( !selfInflicted )
amount *= KillMods.GoldStandardScale( damage.Attacker, LastMod );
// ⚠ TORTOISE'S RING BONUS SITS BESIDE VIGOR'S for the same reason: both are damage terms
// read off the ATTACKER, and both must be in before the subtraction. M1's x1.5 and M4's
// kill stacks resolve together inside that one call. ⛔ And like Vigor's, never on yourself.
float ringScale;
using ( CpuScope.Measure( "dmg.tortoise" ) )
ringScale = selfInflicted ? 1f : NZombies.TortoiseAugments.DamageScale( damage.Attacker );
amount *= ringScale;
// ⛔ BRUTUS'S HELMET TABLE, AND DEATH PERCEPTION'S BOSS MULTIPLIER, BOTH LAND HERE. Both are
// conditions on the finished hit rather than on where it landed, which is the same argument
// the two blocks above give for sitting at this point in the chain.
//
// ⛔ THE HELMET CALL MUTATES AND MUST HAPPEN EXACTLY ONCE PER HIT. `ScaleOn` decrements the
// helmet as well as returning the multiplier, because splitting those into two calls would
// either chip it twice or compute the scale against a helmet that already absorbed this same
// bullet. Do not "tidy" it into a query plus an apply.
//
// ⚠️ AND IT IS GIVEN THE PRE-SCALE `amount`, NOT the raw DamageInfo value. The helmet is
// meant to absorb the shot the player actually fired - so it should see rarity, Pack-a-Punch,
// Double Tap and every tech node, which are all already folded in by this line. What it must
// NOT see is its own 1.5% reduction, which is why this is the argument and not the result.
using ( CpuScope.Measure( "dmg.helmet" ) )
amount *= NZombies.BrutusHelmet.ScaleOn( GameObject, amount, headshot );
// ⛔ THE MARGWA'S TABLE (2026-10-06), the helmet's shape and its exactly-once rule: an open mouth takes 0.75, everything
// else 0.01, and the hit that finds a mouth at the right health takes that head off. Judged by WHERE it landed (the
// latched `damage.Position` against the open head's bone, upstream's own test), so a blast or a body shot reads as body.
using ( CpuScope.Measure( "dmg.margwa" ) )
amount *= NZombies.MargwaBoss.ScaleOn( GameObject, damage.Position, damage.Attacker );
// ⛔ AVOGADRO'S TABLE (2026-10-06), the same exactly-once rule: x0.1 for everything, and a melee hit x2 that stuns him —
// one counted every five seconds, x0 between (`AvogadroBoss.ScaleFor`).
using ( CpuScope.Measure( "dmg.avogadro" ) )
amount *= NZombies.AvogadroBoss.ScaleOn( GameObject, IsMelee( damage ), damage.Attacker );
// ⛔ THE DIRECTOR'S TABLE (2026-10-06): x0.095 for everything, and a player's hit asks for his rage (`DirectorBoss.ScaleFor`
// sets a flag; the rage itself starts outside this call).
using ( CpuScope.Measure( "dmg.director" ) )
amount *= NZombies.DirectorBoss.ScaleOn( GameObject, damage.Attacker );
// ⛔ THE PANZER SOLDAT'S ARMOUR (2026-10-06), the helmet's exactly-once rule AND its argument: the pre-scale `amount`,
// which the faceplate or the power core takes raw when the hit lands on it (`PanzerBoss.ScaleFor`).
using ( CpuScope.Measure( "dmg.panzer" ) )
amount *= NZombies.PanzerBoss.ScaleOn( GameObject, amount, damage.Position, damage.Attacker );
// ⛔ THE THIRD BATCH OF BOSSES (2026-10-06): one hook for all of them — each one built on `BossBase` answers its own hit
// through one virtual (Brenner's x0.25 off anything but bullets and blades, Zaballa's x0.75, the Thrasher's spore sacs …),
// under the helmet's exactly-once rule, since it may count the hit (`BossBase.ScaleOn`).
using ( CpuScope.Measure( "dmg.boss3" ) )
amount *= NZombies.BossBase.ScaleOn( GameObject, amount, damage );
// ⛔ THE NAPALM ZOMBIE'S ARMOUR, WHICH IS OPEN ONLY WHILE IT IS CHARGING. Same shape as the
// helmet line above and for the same reason — a victim-side condition on the finished hit —
// but this one is a pure QUERY and mutates nothing, so it does not carry the
// exactly-once warning that one does.
//
// ⚠️ IT SCALES THE BLAST AND THE FIRE TOO, not just bullets, because `OnDamage` is the one
// entry every source funnels through. That is correct: a napalm zombie standing in another's
// fire pool should be as hard to burn as it is to shoot.
//
// ⚠️ IT TAKES THE ATTACKER because Cryofreeze doubles it, and the mod lives on the gun the
// attacker is holding. Damage with no attacker — a fire pool, a fall — simply reads as not
// cryo, which is right.
using ( CpuScope.Measure( "dmg.napalm" ) )
amount *= NZombies.NapalmZombie.ScaleOn( GameObject, damage.Attacker );
// ⚠️ M1 BOSS SLAYER WAS CATALOGUED AND UNWIRED UNTIL A BOSS EXISTED; Brutus is that boss and
// this is the line that pays it — x2 as of 2026-09-13, down from x3. `BossScaleAgainst`
// returns 1 for a non-boss and for a player who does not own it, so it costs a component
// lookup and nothing else.
using ( CpuScope.Measure( "dmg.boss" ) )
amount *= NZombies.DeathAugments.BossScaleAgainst( damage.Attacker, GameObject );
// ⚠️ SILVER BULLETS AND POINT BLANK (2026-10-04): who was hit and from how close — conditions on the finished hit,
// beside Boss Slayer for the reason the blocks above give.
using ( CpuScope.Measure( "dmg.tech" ) )
amount *= ClassTech.VictimScale( tech, GameObject, HitPositionOr() );
// ⚠️ AFTER the head product and the limb table, so First Blood triples whatever the
// hit was already worth rather than replacing the location bonus. A headshot on a
// fresh zombie therefore gets both — which is the intended reward for opening on a
// new target with a good shot.
amount *= firstBlood;
// ⚠️ m3 CONCUSSION FIRES ON THE HIT, NOT ON THE KILL, so it can stagger a survivor
// — which is the point of a stun. It is rolled before the damage lands because a
// killing blow would leave nothing to stun, and `TryConcuss` resolves the ZombieAI
// itself rather than trusting this component's object.
// ⚠️ ONE SCOPE COVERS BOTH DEADSHOT CALLS IN THIS BLOCK. RollLuckyShot and FirstBloodScale
// ABOVE ARE NOT COVERED: one sits in an `if` condition and the other in a declaration, and
// hoisting either into a local would change when its RNG runs. They show up as
// unattributed time inside dmg.ondamage rather than being silently counted here.
using ( CpuScope.Measure( "dmg.deadshot" ) )
if ( headshot )
{
NZombies.DeadshotAugments.TryConcuss( damage.Attacker, GameObject );
// ⚠ M4 FOCUS BUILDS ON THE HIT, beside Concussion, for the same reason and in
// the same block: both are per-headshot side effects rather than damage terms, and
// both have to run before anything can early-return on a death - a headshot that
// kills still counts toward the streak.
//
// ⚠ BELOW the `headBonus` term above, so this shot is scaled by the streak it
// arrived with and the increment lands for the next one.
NZombies.DeadshotAugments.OnHeadshotHit( damage.Attacker );
}
// ⚠️ MULE KICK'S M2 "OVERFLOW" ROLLS ON ANY DAMAGING HIT, headshot or not, and on
// hits that do not kill — it is a sustain augment, not a reward for accuracy. It sits
// beside Concussion because both are per-hit side effects rather than damage terms,
// and both must run before anything can early-return on a death.
using ( CpuScope.Measure( "dmg.mulekick" ) )
if ( amount > 0f )
NZombies.MuleKickAugments.TryOverflow( damage.Attacker );
// ══ TIMESLIP TONIC — M4 CHRONO ROUNDS AND M3 FAULT LINES ══════════════════
//
// ⚠ BESIDE MULE KICK'S OVERFLOW, because all three are per-hit side effects rather than
// damage terms, and this is the one place every damaging route funnels through. Hooking
// the two bullet paths separately is what left an earlier Vigor Rush helper uncalled
// entirely — its own comment warned about exactly that.
//
// ⚠ BEFORE THE SUBTRACTION, so a killing shot still stacks and still rolls. A zombie
// that dies to this hit having its speed reduced is harmless; a pit that failed to spawn
// because the shot happened to kill would make the 1% quietly lower than 1%.
using ( CpuScope.Measure( "dmg.time" ) )
if ( amount > 0f )
{
NZombies.TimeAugments.OnZombieHit( damage.Attacker, GameObject );
NZombies.TimeAugments.TryPit( damage.Attacker, HitPositionOr() );
}
// ⚠️ THE PER-CLASS WEAPON TECH'S HIT EFFECTS (2026-10-04): the stuns, Suppressive Fire's slow, Spotter's and Marker's
// marks — per-hit side effects like the two above, here on the host, where a client's hit arrives too.
using ( CpuScope.Measure( "dmg.tech" ) )
if ( amount > 0f )
ClassTech.OnZombieHit( tech, damage.Attacker, HitPositionOr(), GameObject );
// ⚠️ VIGOR RUSH'S m4 "LAST ROUND" SPLASHES THE FINISHED AMOUNT, so it carries every
// multiplier the shot earned — including the perk's own and any augment above. The
// original splashed `dmg:GetDamage()`, which in its damage model is the same thing.
//
// ⚠️ AFTER the amount is final and BEFORE the subtraction, so a killing shot still
// splashes what it dealt.
using ( CpuScope.Measure( "dmg.vigor" ) )
if ( amount > 0f )
// ⛔ WITHOUT THE RING (2026-09-27). Each splash hit comes back through `OnDamage` with the same
// attacker, standing in the same ring, and gets the ring's bonus there — so splashing the
// ringed amount gave a HOST shooter the bonus twice (×2.25 in Dig In), while a client's
// splash, built from `relayed`, which has no ring in it, got it once.
NZombies.VigorAugments.TryLastRound( damage.Attacker,
HitPositionOr(), ringScale > 0f ? amount / ringScale : amount, GameObject );
// ── VIGOR RUSH ───────────────────────────────────────────
//
// ⛔ APPLIED HERE, AND UNTIL NOW APPLIED NOWHERE. Vigor Rush's multiplier
// was read by exactly one thing — WeaponStatsPanel — so the damage figure on
// the stats screen doubled while every bullet did its normal damage. Double
// Tap and Deadshot were dead the same way; see PATTERNS OF MISTAKES §2.
//
// ⛔ BULLETS ONLY, AND THE TAG IS WHAT MAKES THAT EXACT. `TagsHelper.Bullet`
// is stamped in ONE place in the whole codebase — `DamageInfo.FromBullet`,
// which both bullet paths funnel through and nothing else calls. So this test
// is not a convention that could drift: the knife, grenades, traps, status
// effects and the test commands all build their DamageInfo by hand and cannot
// accidentally acquire it. The original's Vigor Rush is "double bullet damage",
// and widening it to everything would quietly make it the best perk in the game.
//
// ⚠️ ATTACKER-SIDE, because this component is on the ZOMBIE — the same
// reason Death Perception and Napalm Nectar below read damage.Attacker. A
// Components.Get<NZPlayer> here finds nothing and the perk silently does
// nothing, which is the trap PerkEffects.HeadshotScaleFor records.
//
// ⚠️ BEFORE the vulnerability and incendiary multipliers, so a burning
// zombie's bonus scales the perked damage rather than the base. Multiplication
// commutes, so the order does not change the result — it is grouped this way
// because the shooter's own perks belong together, above the victim's state.
if ( amount > 0f && damage.Tags is not null
&& damage.Tags.Has( SWB.Shared.TagsHelper.Bullet ) )
{
using ( CpuScope.Measure( "dmg.perk" ) )
amount *= PerkEffects.BulletDamageFor( damage.Attacker );
}
// ── NAPALM NECTAR ────────────────────────────────────────────────────
// Ported from enemies/sv_hooks.lua:485-560. See Docs/NAPALM_NECTAR.md.
//
// ⚠️ ALREADY BURNING IS CHECKED FIRST, and applies to EVERY source — the
// original's `NapalmVulnMult` is a property of the victim, not of who is
// shooting it. A second player shooting a burning zombie gets the bonus
// too, which is the point of lighting things up in co-op.
using ( CpuScope.Measure( "dmg.status" ) )
amount *= StatusEffects.VulnerabilityOf( GameObject );
// ⚠️ CRYOFREEZE III'S SHATTER IS DECIDED HERE, BEFORE THE AMMO MODS ROLL (2026-10-05, the review), as the freeze's own ×1.3
// just was: a host's bullet that procs Cryofreeze freezes this zombie inside the roll below, and must not then shatter
// it at `Apply` — "the NEXT hit", and a client's proccing hit never could (`Cryofreeze.Shatters`).
var shatters = Cryofreeze.Shatters( this );
// ══ NAPALM NECTAR ═════════════════════════════════════════════════
//
// ⛔ ONE CALL NOW. This was thirty lines implementing TWO unrelated mechanics — a 1-in-6
// double-damage roll with no fire in it, and a per-zombie hit counter (5 hits then 1-in-3)
// that did the igniting — with the counter living on this component as `_napalmHits`. It
// froze while the target burned and reset on each ignition, so the real cost was ~7 hits on
// ONE zombie and spraying a crowd built five counters and lit nothing.
//
// All of it moved to `FireAugments`, so the base ignite and the nine augments that modify
// it are in one file — and so the knife can ignite through the same cooldown.
//
// ⚠ m1 ACCELERANT RIDES THE VULNERABILITY APPLIED ABOVE, not this call. That line has
// already multiplied in the `burn` rule's x2, so the augment contributes only the delta up
// to x2.5. See `FireAugments.BurnDamageBonus`.
// ⚠️ NOT FOR THE PRISMA ON OBERON (`spared`, 2026-10-07): its ignite and its ammo mod would hurt him by the back door
if ( !LastHitWasMelee && !spared )
{
using ( CpuScope.Measure( "dmg.fire" ) )
amount *= NZombies.FireAugments.BurnDamageBonus( damage.Attacker, GameObject );
using ( CpuScope.Measure( "dmg.fire" ) )
if ( PerkEffects.HasNapalm( damage.Attacker ) )
{
NZombies.FireAugments.TryIgnite( damage.Attacker, GameObject );
NZombies.FireAugments.TryPit( damage.Attacker, HitPositionOr() );
}
// ⚠️ AMMO MODS ROLL HERE, INSIDE THE SAME NON-MELEE BLOCK. An ammo mod is
// ammo, so a knife must not proc one — and the melee exclusion already
// wraps this block for Napalm's benefit, which is why the call belongs in here
// rather than beside it.
//
// ⚠️ OUTSIDE THE NAPALM GATE, though. That `if` above tests for the perk;
// ammo mods are bought from a machine and belong to nobody's perk.
using ( CpuScope.Measure( "dmg.ammomods" ) )
AmmoMods.OnZombieHit( damage.Attacker, GameObject, AmmoMods.IsBullet( damage ) );
}
// ⚠️ THE PRISMA'S FUSE, BESIDE THE AMMO MODS AND NOT INSIDE THEM. It is the weapon's
// own behaviour rather than a mod fitted to it, so it has no proc chance, no cooldown and
// no interaction with what is in the ammo-mod slot — a Prisma with Dead Wire does both.
// `PrismaChain.OnHit` checks the weapon and returns immediately for every other gun.
//
// ⚠️ `damage.Damage`, THE ROUND'S OWN FIGURE, NOT `amount`. A special's fuse ticks a share of
// the WEAPON'S damage (`ZombieVariant.ResonanceShare`), and by here `amount` is already this
// victim's helmet, burn and Insta-Kill arithmetic. A client's relayed hit arrives with the
// shooter's own multipliers in it (`AttackerScale`) — the one way the two machines' figures
// can differ.
PrismaChain.OnHit( tech, GameObject, damage.Damage );
// ── BLEEDER (ammo mod, 2026-10-04) ──────────────────────────
//
// ⚠️ HERE, ABOVE PERFORATOR: `amount` is what this hit deals, every multiplier in it, and that is what the bleed pays
// again over eight seconds — before Perforator turns the hit into its instant tenth. `LastMod` is the hit's own mod.
if ( amount > 0f && LastMod == "bleeder"
&& Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() )
{
using ( CpuScope.Measure( "dmg.ammomods" ) )
Bleeder.Start( this, amount, damage.Attacker );
}
// ── PERFORATOR (`t5_perforator`) ────────────────────────────
//
// ⛔ THE LAST LINE THAT CAN STILL CHANGE WHAT THE BULLET DEALS, WHICH IS WHY IT IS HERE.
// Everything above has finished scaling `amount` — hit group, perks, augments, burn
// vulnerability, ammo mods — and the next statement commits it. The node replaces the
// PAYOUT, not the arithmetic, so it has to sit exactly in the gap between the two.
//
// ⚠️ `Absorb` RETURNS WHAT TO DEAL NOW and queues the rest on the victim, so the
// assignment below is the whole integration. A version that queued the pool and zeroed
// `amount` would fire no `OnDamaged` at all: no flinch, no hit reaction, and no points for
// shooting — see `Perforator`'s header for why one tick has to stay behind.
//
// ⚠️ BULLETS ONLY, BY THE SAME TAG TEST BOUNCY USES BELOW. Bouncy's links and
// Flechette's shards reach this method carrying the same weapon, and letting them bleed too
// would be defensible — but it would also mean a bounce link deals a tenth up front, so
// `IsDead` is false, so the chain stops at link one. Two tier-5 nodes silently cancelling
// each other is worse than the literal reading of "BULLETS deal damage over time".
if ( amount > 0f
&& damage.Tags is not null
&& damage.Tags.Has( SWB.Shared.TagsHelper.Bullet )
&& TechEffects.Has( tech, "t5_perforator" )
&& Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() )
{
using ( CpuScope.Measure( "dmg.tech" ) )
amount = Perforator.Absorb( this, amount,
TechEffects.Factor( tech, "t5_perforator", 1f ), damage.Attacker );
}
// ⛔ MARKER (handgun tier 5, 2026-10-04): IS IT MARKED, ASKED BEFORE `Apply`. A killing hit clears every status on the
// corpse (`ZombieAI.Die` → `StatusEffects.ClearAll`), so asked afterwards the answer was "no" for exactly the hit that
// killed it — and early on, when one pistol shot kills, that is every hit.
var marked = amount > 0f && StatusEffects.Has( GameObject, "marked" );
// ⚠️ Armor exemption resolved HERE, the only place the DamageInfo exists.
// ⚠️ COUP DE GRÂCE (sniper tier 3, 2026-10-04) CHANGES ONLY WHAT THE ZOMBIE LAYS DOWN: a hit on a zombie already below
// 30% takes the rest of its health (`ClassTech.CoupDeGrace`), and `amount` stays the hit for what follows.
// ⚠️ AND CRYOFREEZE III'S SHATTER THE SAME WAY (2026-10-05): a zombie a level-III Cryofreeze froze, already under 25%, dies
// to any hit from anyone, never a boss (`Cryofreeze.Shatter`, decided above the roll). Here on the host, where a client's
// hit arrives too.
var taken = Apply( Cryofreeze.Shatter( this, ClassTech.CoupDeGrace( tech, this, amount ), shatters ), headshot, null,
Armor.Bypasses( damage ), source: DescribeDamage( damage ) );
// ⚠️ FOLLOW-THROUGH (marksman tier 3, 2026-10-04): a kill keeps what it did not need for its bullet's next zombie.
ClassTech.FollowThroughKill( tech, damage, bullet, this, taken );
// ⛔ MARKER: a hit on a marked zombie, by anyone, lands on every marked zombie — the full hit, after this one has landed.
// The copies go through `Apply`, so they never copy again (`ClassTech.ShareMarked`).
if ( marked )
ClassTech.ShareMarked( this, amount, damage.Attacker );
LastHitWasMelee = false;
LastHitPays = true;
// ⚠️ AND THE PART, LATCHED FOR THIS HIT ALONE (2026-09-28). The gore reads it inside `Apply` (`OnDamaged`, the death), and
// damage that does not come through here — a burn, a bleed, a Nuke — found the last bullet's part still set: a zombie shot in
// the head once, then burned to death, lost its head to the fire.
LastHitPart = "";
// ── ADRENALINE ROUNDS — GONE FROM HERE ───────────────────────
//
// ⛔ THE NODE WAS REDESIGNED AND IS NO LONGER A ZOMBIE STATUS AT ALL. It used to speed up
// whatever it failed to kill, which is why it lived on this line — "did the hit FAIL to
// kill" is only knowable after `Apply`. It now speeds up the PLAYER instead, so it is
// stamped on the shooter in `HitScan` and nothing about it belongs in the victim's
// damage path. See `AdrenalineRounds`.
//
// ⚠️ BOUNCY ROUNDS INHERITED THE SPOT, and inherited the argument with it: it asks the
// mirror-image question ("did it kill"), which is knowable at exactly the same moment.
// ⚠️ AND EVERY GATE THE OLD BLOCK REASONED ITS WAY TO SURVIVES IN IT VERBATIM —
// `taken > 0` so pellets 2..16 of a killing blast do not each fire, and the `ZombieAI`
// test because `Health` is shared and a downed team-mate carries one.
// ── BOUNCY ROUNDS (`t5_bouncy`) ──────────────────────────────
//
// ⛔ THE MIRROR IMAGE OF THE BLOCK ABOVE, AND IT HAS TO BE HERE FOR THE SAME REASON.
// Adrenaline asks "did this FAIL to kill"; this asks "did it kill", and neither question
// exists until `Apply` has committed the damage and fired `OnKilled`.
//
// ⛔ AND IT CANNOT LIVE IN `HitScan`, WHICH IS WHERE IT STARTED. On a CLIENT this
// machine's copy of a zombie never takes the damage at all — `OnDamage` relays and
// returns hundreds of lines above — so `IsDead` read on the shooter was permanently
// false. The kill is knowable on the machine that owns the zombie and nowhere else.
//
// ⚠️ `amount`, NOT `taken`, IS WHAT THE CHAIN DOUBLES. `taken` is clipped to the
// health that was left, so a round that overkills by 900 would hand the next link 100 —
// and overkill is precisely what this node is built to spend. `amount` is also the fully
// scaled figure, which is the other half of what moving here bought: `HitScan` could only
// offer the damage before hit groups, perks and ammo mods touched it.
//
// ⚠️ FOUR GATES, CHEAPEST FIRST, AND EACH IS LOAD-BEARING:
// `Tags.Has(Bullet)` — only a fired round chains. A link's own damage carries the
// `bounce` tag and no bullet tag, which is what makes the chain
// terminate at its cap instead of restarting at every link; the
// same test excludes Flechette shards and the Tech blast.
// `taken > 0f` — `Apply` returns 0 for an already-dead body, so pellets 2..16 of
// the blast that killed it cannot each start their own chain.
// `IsDead` — the trigger itself.
// `ZombieAI` — Health is shared and the PLAYER carries one. Without this, a
// downed team-mate would chain lightning into the horde.
//
// ⚠️ THE CAP IS READ OFF THE PRIMARY ShootInfo, because by here the pellet is gone and
// with it the `isPrimary` that produced it. Every prefab in the pack authors penetration on
// primary fire; a secondary-only pierce stat would read the wrong number, and no weapon has
// one.
if ( taken > 0f && IsDead
&& damage.Tags is not null
&& damage.Tags.Has( SWB.Shared.TagsHelper.Bullet )
&& TechEffects.Has( tech, "t5_bouncy" )
&& Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() )
{
// ⚠️ THE CAP COMES THROUGH `PenetrationOf` RATHER THAN OFF THE WEAPON, because on
// a relayed hit there is no weapon here to read it from — the shooter sends the
// number with the hit. It is the one weapon STAT that travels; see its note.
using ( CpuScope.Measure( "dmg.tech" ) )
BouncyRounds.Chain( tech, GameObject, amount,
TechEffects.PenetrationOf( tech, damage ) );
}
}
/// <summary>Would PhD Flopper absorb this hit entirely.
///
/// ⚠️ Its own method because the inline version mixed && and || and got the
/// precedence wrong on the first attempt — a condition that read correctly and
/// meant something else. Two plain questions in order is not clever, and that
/// is the point.</summary>
bool PhdBlocks( in DamageInfo damage )
{
// ⚠️ Runs before anything else in OnDamage, on every hit, player or zombie.
using var _cpu = CpuScope.Measure( "dmg.phd" );
var victim = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
if ( !victim.IsValid() ) return false;
if ( !PerkEffects.ImmuneToNonZombieDamage( victim ) ) return false;
// No attacker at all — a grenade, the world, a future fall. Not a zombie.
if ( !damage.Attacker.IsValid() ) return true;
var fromZombie = damage.Attacker.Components
.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid();
return !fromZombie;
}
/// <summary>
/// Does this damage come from a melee swing?
///
/// Tag-based like the headshot check, so any future knife just adds "melee"
/// to its damage rather than needing a code path here.
/// </summary>
public static bool IsMelee( in DamageInfo damage )
{
var tags = damage.Tags;
if ( tags is null ) return false;
return tags.Has( "melee" ) || tags.Has( "knife" );
}
/// <summary>
/// Apply damage. Returns the amount actually taken — 0 when already dead or
/// still inside the immunity window, which callers use to decide whether to
/// play a reaction at all.
/// </summary>
/// <summary>⚠️ `from` is the ATTACKER, and optional so every existing caller
/// still compiles. It exists because Victorious Tortoise needs to know WHERE a
/// hit came from, and zombie melee arrives through this method with no
/// DamageInfo to read it out of. Multiplayer wants the same thing later —
/// "who dealt this" is worth having on the one path that all damage crosses.</summary>
/// <summary>
/// DAMAGE THAT MUST NOT PAY THE PER-HIT POINTS DRIP — a tick of something the shot that
/// started it has already been paid for.
///
/// ⛔ `LastHitPays` IS PRIVATE AND THAT IS DELIBERATE, so this exists rather than opening it:
/// there is exactly one legitimate reason to write it from outside (a follow-up payment on a
/// hit already awarded), and a method that says so is harder to misuse than a setter that says
/// nothing. `Perforator`'s bleed is the first caller; a burn tick is the obvious second.
///
/// ⚠️ RESTORED TO TRUE AFTERWARDS, matching what `OnDamage` does around its own write.
/// The flag is LATCHED for `ZombieAI` to read during `OnDamaged`/`OnKilled`, so leaving it
/// false would silently unpay the next knife, trap or grenade to touch this body.
/// </summary>
public float ApplyUnpaid( float amount, GameObject from = null )
{
LastHitPays = false;
var taken = Apply( amount, false, from );
LastHitPays = true;
return taken;
}
/// <summary>⚠️ `ignoreArmor` exists because Apply takes a float and cannot see
/// the damage type. The original exempts fall/drown/poison/radiation from armor,
/// and only OnDamage — which has the DamageInfo — can tell. Defaults false so
/// every existing caller keeps armor applied, which is the safe direction: a
/// missed exemption over-protects, a missed armor call silently disables the
/// system.</summary>
public float Apply( float amount, bool headshot = false, GameObject from = null,
bool ignoreArmor = false, bool blast = false, Vector3 blastAt = default, bool tick = false, bool cost = false,
string source = null )
{
// ⚠️ `cost`: HEALTH SPENT, NOT A HIT TAKEN (2026-10-04) — Blood Price's 10 a shot. None of what answers a hit runs:
// no Bulwark or ring to shrink the price, no PhD or Vigor hit trigger on every shot, no Phase Shift to refuse it. It
// still lands, still restarts regen, and still downs you, which the user asked for (*"It can down you"*).
//
// ⚠️ `blast`: AN ENEMY'S AREA HIT — Oberon's leap, black hole and bombs (`OberonBoss.Blast`), gone off at `blastAt`. Armor
// spends on it as on any enemy's hit — *"armor needs to react to the other attacks too"* (2026-09-27) — and so do Bulwark,
// Vigor and the rest; Victorious Tortoise judges "from behind" by where it went off, not by where he stands (*"fix that
// too"*); what answers a CLAW does not: Widow's Wine's web, and Juggernog's Retaliate, which would have stunned him for
// five seconds with every one of his own bombs.
//
// ⚠️ `tick`: ONE TICK OF DAMAGE OVER TIME — a damage wall's (`DamageWallVolume`), the boss arena's flood
// (`HexPlatforms.TickHazards`). It lands whatever the victim's post-hit window says and opens none: that window is the
// crowd's, and lava at 10 every 0.2s would otherwise land one tick in three while shielding its victim from the zombies.
// ⚠️ OUTER SCOPE, and `using var` matters here specifically: Apply has SEVEN early returns.
// A using-BLOCK around the body would have to be closed before every one of them.
using var _cpu = CpuScope.Measure( "dmg.apply" );
// ⛔ ON A CLIENT, A ZOMBIE IS THE HOST'S — ITS COPY HERE TAKES NO DAMAGE AT ALL. The host owns everything but each
// client's own body (`OnDamage`'s rule, which already sends a client's hits on to the host and never lays them on the
// copy). But a status's tick (`StatusEffects`, run on every machine) came straight here, so a client's copy lost health
// on its own, and at zero ran the whole of `ZombieAI.Die` on the client: non-solid, a second Prisma burst, a soul box
// fed, drops rolled, and a `ZombieDied` that felled every other client's copy. A client's Oberon died that way to
// radiation ticks, and no client's shot could hit him after (the co-op audit, 2026-09-27). Players' bodies are untouched:
// the owner's own, and the relay below for somebody else's.
if ( NZGame.IsClient && GameObject.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is null ) return 0f;
// ⛔ A BODY ON A TELEPORT TAKES NOTHING, WHOSEVER IT IS (the co-op audit, 2026-09-27 — *"fix it so clients also dont
// get hit mid teleport"*). Each machine made only its own riders invulnerable, so on the host a zombie that reached a
// client's rider hit it — the swing sounded, the cursed flame went out. The host keeps the list (`Teleporter.InTransit`),
// and a hit refused here is never sent on.
if ( Teleporter.InTransit( GameObject ) ) return 0f;
// ⛔ SOMEBODY ELSE'S HEALTH IS THEIRS TO CHANGE, AND EVERY LINE BELOW THIS COMPUTES THE
// WRONG ANSWER FOR IT. Zombies live on the host, so a client being eaten is resolved
// against the HOST'S COPY of that client — a copy that has never heard of the perks they
// bought. Juggernog is the loudest case: the copy's `Max` is the base 150, so its `Current`
// after a hit is a number out of 150, and pushing that as an absolute pulled a 250hp
// player down to it. User: *"juggernog seems to somehow reduce the health for clients."*
//
// It is not only the total. `Apply` below runs Widow's Wine, Victorious Tortoise, the
// Juggernog augments and the armour plates — all read off the victim — so on a proxy every
// one of them is asking a body with no perks and no vest. The hit was mis-sized before it
// was ever mis-stored.
//
// ⚠️ THE RAW AMOUNT TRAVELS, AND THE OWNER'S OWN `Apply` RESIZES IT. That is the whole
// point: their perks, their armour, their maximum, their regen clock. The same shape as
// `NZNet.AwardPoints` and `PlayerStats.RecordStat`, and the exact thing `MirrorTo`'s own
// comment said the real fix would be — *"the host should relay the DAMAGE AMOUNT rather
// than a total"*.
//
// ⚠️ BEFORE `IsDead` AND `Invulnerable`. Those read the local copy, which after this
// change never takes damage and so is never dead — but a stale copy that HAD gone to zero
// would silently swallow every hit and the player would be immortal.
if ( Networking.IsActive && IsRemotePlayerBody )
{
var owner = NZPlayers.OwnerOf( GameObject );
if ( !string.IsNullOrEmpty( owner ) )
{
// ⚠️ `from` TRAVELS AS AN ID. Tortoise needs its position, Retaliate needs to stun
// it and WebSnare needs to cancel the hit — all three are dead without it.
// ⚠️ AND WHAT IT WAS, IN WORDS (2026-10-04), for the owner's hit line: `from` alone cannot say "a blast with no
// attacker" once it has crossed as an empty id.
NZNet.HurtPlayer( owner, amount, headshot,
from.IsValid() ? from.Id : Guid.Empty, blast, blastAt, tick, source ?? DescribeHit( from, blast, tick, cost ) );
// ⚠️ REPORTED AS DEALT so the caller's bookkeeping is unchanged. `ZombieAI` gates
// its impact sound on a non-zero return — a swing that connected must sound like
// one here even though the arithmetic happens elsewhere.
return amount;
}
}
if ( IsDead ) return 0f;
// ⚠️ BEFORE THE IMMUNITY WINDOW AND EVERYTHING ELSE. See `Invulnerable` - the whole point
// is that nothing below this line runs.
if ( Invulnerable ) return 0f;
if ( !tick && ImmunityAfterHit > 0f && _immuneUntil > 0f ) return 0f;
amount = MathF.Max( 0f, amount );
if ( amount <= 0f ) return 0f;
// ⛔ BASALT'S BOSS FIGHT: OBERON TAKES NOTHING BETWEEN PHASES, AND NO HIT CARRIES HIM PAST THE NEXT PHASE'S LINE
// (`HexPlatforms.BossCap`) — here, where every route in ends: a bullet, the Shrieker's death pulse, a nuke's share.
amount = NZombies.HexPlatforms.BossCap( GameObject, Current, Max, amount );
if ( amount <= 0f ) return 0f;
// ⚠️ GUARDED, not unconditional — see LastAttacker. OnDamage passes null
// here on purpose, having already recorded the real attacker itself.
if ( from.IsValid() )
{
// ⛔ SOMEBODY ELSE'S DAMAGE — a bleed, a burn, a Marker copy — TAKES THE LAST BULLET'S MOD WITH IT (2026-10-04,
// the review). `LastMod` is the mod of the last bullet, and the credit is about to move to `from`: kept, a client's
// Bleeder tick finishing a zombie the host had shot with Midas was paid the host's +50%.
if ( from != LastAttacker ) LastMod = "";
LastAttacker = from;
}
// ⚠️ WHAT DEALT IT, FOR THE PLAYER'S HIT LINE (2026-10-04): the caller's own words when it has them (`OnDamage`'s
// DamageInfo, a forwarded hit's sender), otherwise what this method was given.
LastHitSource = source ?? DescribeHit( from, blast, tick, cost );
// ⚠️ ONE NZPlayer LOOKUP FOR BOTH the perk reduction and armor. They used
// to be separate reads; two places answering "which player is this" is the
// shape INSTRUCTIONS.md §3 warns diverges — and here they cannot, because
// there is now exactly one answer.
var victim = Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
// ⛔ WIDOW'S WINE CANCELS THE HIT, AND IT RUNS BEFORE EVERYTHING ELSE THAT COULD
// BILL FOR IT. Placed after Tortoise it would scale damage that is about to be
// discarded; placed after armor it would spend a plate on a hit the perk prevented.
// Returning zero here means the claw never lands — the grenade paid for it.
//
// ⚠️ THE FULL AMOUNT IS NEGATED, not reduced. That matches the original, whose
// hook ends in `return false` on the damage event.
//
// ⚠️ `_immuneUntil` IS DELIBERATELY NOT SET. A negated hit is not an
// invulnerability window; the next zombie in the same tick should still get its own
// chance to trigger the perk, and if the charges are gone it should hurt.
if ( !blast && from.IsValid() && victim.IsValid() && PerkEffects.WebSnare( victim, from ) )
return 0f;
// ⚠️ VICTORIOUS TORTOISE APPLIES HERE, in Apply rather than OnDamage,
// because zombie melee — the only thing that hits you from behind — comes
// through Apply. Putting it in OnDamage would cover grenades and miss
// zombies, which is precisely backwards for a perk about being surrounded.
//
// ⚠️ A BLAST BY WHERE IT WENT OFF (`blastAt`): a bomb behind you is behind you, wherever he stands.
if ( blast && victim.IsValid() )
amount *= PerkEffects.TortoiseScale( victim, blastAt );
else if ( from.IsValid() && victim.IsValid() )
amount *= PerkEffects.TortoiseScale( victim, from.WorldPosition );
// ⛔ AUGMENTS RUN AFTER THE BASE PERKS AND BEFORE ARMOR, and the position is
// the same argument Tortoise makes above. Juggernog's M3 Bulwark cuts the hit
// first so armor is billed only for what got through; reversed, a Bulwark player
// would burn plates at the full rate while taking less damage.
//
// ⚠️ ONE CALL FOR ALL AUGMENTS, dispatched inside AugmentEffects. Seventeen perks
// each reaching into this method would be seventeen edits here and an ordering
// that depended on file order.
//
// ⚠️ `from` may be NULL — Retaliate needs an attacker, Bulwark and Adrenal Surge
// do not, so the null case is handled in there rather than by skipping the call.
// ⚠️ THE GUN IN HAND (2026-10-04): Juggernaut's cut and Pain Reload's reload, beside the perks and before armor.
if ( victim.IsValid() && !cost )
amount = ClassTech.OnPlayerDamaged( victim, amount );
if ( victim.IsValid() && !cost )
amount = AugmentEffects.OnPlayerDamaged( victim, from, amount, clawed: !blast );
// ⛔ ARMOR RUNS AFTER THE PERK, AND THE ORDER IS LOAD-BEARING. Tortoise cuts
// the hit first, then armor pays for what is left — so a halved back-hit only
// costs half as much armor. Reversed, armor would be billed for damage the
// perk had already prevented, and a Tortoise player would burn through plates
// at the same rate as everyone else while taking less damage.
//
// ⚠️ Armor also DEPLETES here, not just reduces — see Armor.Absorb. This
// line both mutates the player's armor and returns the reduced amount.
// ⛔ ONLY AN ENEMY SPENDS YOUR PLATES. Armor was billed for anything that reached this
// method — fall damage, damage walls, easter-egg traps, your own grenade. `ignoreArmor`
// was meant to cover that and could not: it is set only on the DamageInfo route, and
// defaults FALSE for every direct `Apply` caller, which is exactly how a damage wall
// reaches a player.
//
// ⚠️ THE TEST BELONGS HERE, NOT IN `Armor.Bypasses`. This is the one line all damage
// passes through; `Bypasses` sees only half of it. Writing it there first looked right
// and would have left every trap in the game still eating plates.
//
// ⚠️ A ZOMBIE'S CLAW PASSES ITS ROOT GameObject (`DoAttackDamage`: `hp.Apply( damage,
// false, GameObject )`), which is where the "zombie" tag is — so real hits resolve.
// Checked, because getting this backwards disables armor entirely rather than
// over-applying it.
using ( CpuScope.Measure( "dmg.absorb" ) )
if ( victim.IsValid() && !ignoreArmor && ZombieAI.IsEnemyDamage( from ) )
amount = Armor.Absorb( victim, amount );
// ⛔ LEECH'S OVERHEAL TAKES THE HIT NEXT (2026-10-04, `Over`). It is health above the max, under the plates: armor bills
// first, this pays what armor let through, and only the rest reaches `Current`. Phase Shift below is asked about that
// rest alone, so a hit the overheal soaks up is never "fatal". The hit is still reported whole — the number, the
// reaction and the regen clock all saw a hit land.
var overTaken = 0f;
if ( Over > 0f && amount > 0f )
{
overTaken = MathF.Min( Over, amount );
Over -= overTaken;
}
// ══ QUICK REVIVE m5 PHASE SHIFT ═════════════════════════════════════
//
// ⛔ IT READS THE REDUCED AMOUNT, WHICH IS WHY IT IS DOWN HERE AND NOT AT THE TOP OF THIS
// METHOD. Placed above the reductions it tested the RAW damage against remaining health, so a
// 200-damage claw that armor and Bulwark were about to cut to 30 counted as fatal to a player
// on 50 HP — and the augment DROPPED THEM TO 1 HP on a hit they would have walked off with
// 20. An augment that makes you worse off the more armor you own.
//
// ⚠ AND IT RUNS AFTER WIDOW'S WINE'S CANCEL, for the reason the Widow comment above states
// about itself: nothing should bill for a hit another perk prevents for free. Above it, a
// snared claw spent a two-minute cooldown on damage that never landed.
//
// ⛔ IT SETS HEALTH TO EXACTLY 1, IT DOES NOT NEGATE THE HIT. Negating leaves you at
// whatever health you already had, which for a player on 80 HP is not "kept at 1 HP" by any
// reading. The write goes straight to `Current` because this method IS the only way health
// moves down — `Current` has a private setter and `Heal` clamps negatives away.
//
// ⚠ NO ARMOR IS SPENT AND NO REDUCTION IS BILLED. `Armor.Absorb` above already ran and
// already depleted, which is correct: you were hit, the plates took it, and the augment only
// catches what got through.
//
// ⚠ `OnDamaged` STILL FIRES, with the real amount lost. You were hit, so the regen delay
// must restart — otherwise a phase shift is followed by regen that thinks it has been calm.
using ( CpuScope.Measure( "dmg.revive" ) )
if ( victim.IsValid() && !cost && NZombies.ReviveAugments.TryPhaseShift( victim, amount - overTaken ) )
{
var lost = MathF.Max( 0f, Current - 1f ) + overTaken;
Current = 1f;
LastDamage = lost;
LastLost = lost;
if ( !tick && ImmunityAfterHit > 0f ) _immuneUntil = ImmunityAfterHit;
if ( lost > 0f )
{
SinceLastDamage = 0f;
OnDamaged?.Invoke( lost, headshot );
}
return lost;
}
// ⚠️ LATCHED BEFORE THE SUBTRACTION, so a killing blow still reports what it dealt.
// Recording it after would be identical today and would break the moment anything
// clamped `amount` against remaining health.
LastDamage = amount;
var beforeHit = Current;
Current = Math.Clamp( Current - (amount - overTaken), 0f, Max );
// ⚠️ WHAT IT REALLY TOOK, for a stand-in killing blow (`LastHitDamage`).
LastLost = MathF.Max( 0f, beforeHit - Current ) + overTaken;
// ⚠️ STAMPED AT THE ONE PLACE HEALTH IS LOST, so every route in — bullets, the knife,
// a zombie's melee, a fire pit — restarts the regen delay without having to remember to.
//
// ⛔ BUT ONLY IF HEALTH ACTUALLY MOVED, AND IT DID NOT USED TO CHECK. `HealthRegen` refuses
// to heal until `SinceLastDamage` passes the delay, so ANY caller reaching this line resets
// that wait — including one that cost the player nothing. A hit fully absorbed by armour, or
// a `DamageWallVolume` whose `Spot.Damage` is authored 0 (the call site clamps with
// `MathF.Max( 0f, ... )`, so zero is an expected value there), lands here with `amount` 0,
// takes no health, and silently pushes the regen delay out again. Standing in a 0-damage
// volume would block regeneration forever with nothing on screen to explain it.
//
// ⚠️ A *HEALTH* REGEN TIMER HAS NO BUSINESS RESTARTING FOR A HIT THAT COST NO HEALTH.
// That is the whole argument — armour absorbing a swing is armour doing its job, not a
// reason to punish the health bar as well.
//
// ⚠️ THE IMMUNITY WINDOW IS NOT AFFECTED. A swallowed hit returns far above this line, so
// this changes nothing about being surrounded.
if ( Current < beforeHit || overTaken > 0f ) SinceLastDamage = 0f;
if ( !tick && ImmunityAfterHit > 0f )
_immuneUntil = ImmunityAfterHit;
// ⚠️ ON A ZOMBIE VICTIM THIS IS THE HIT REACTION -- flinch, pain voice, aggro. ZombieAI
// subscribes OnDamaged, so the delegate is very much not free.
using ( CpuScope.Measure( "dmg.react" ) )
OnDamaged?.Invoke( amount, headshot );
// ⚠️ A DIRECT CALL, not an event. `OnDamaged` is an Action PROPERTY, so a
// second consumer assigning it silently clobbers ZombieAI's handler — and
// a static event would survive hotload with stale subscribers attached.
// PointsPopups.Add is called the same way from the points path; this
// follows it.
// ⛔ NOT FOR SOMEBODY ELSE'S SHOT. A damage number is feedback to the person who pulled
// the trigger, and on the host it was being drawn for every client's hit as well:
// `NZNet.HurtRemote` is `[Rpc.Host]`, so a client's relayed bullet lands here on the host
// and reaches this line exactly like a local one. The host's screen filled with numbers
// for shots it did not fire. User: *"damage numbers from all players show on all players,
// i want players to only see their own."*
//
// ⚠️ PHRASED AS "NOT THEIRS" RATHER THAN "MINE", WHICH IS THE SAFER HALF. Grenades,
// napalm pits, Retaliate and the augments all arrive here with an attacker that is not a
// player at all — requiring a positive match to my own body would silently delete their
// numbers too. This removes only what another player caused.
//
// ⚠️ THE CLIENT'S OWN NUMBER IS UNAFFECTED — it is reported in `OnDamage`'s relay branch,
// on the shooter's machine, before the hit is ever sent.
var byAnotherPlayer = LastAttacker.IsValid()
&& LastAttacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
is { } shooter
&& PlayerPresence.Theirs( shooter.GameObject );
if ( !byAnotherPlayer )
using ( CpuScope.Measure( "dmg.numbers" ) )
DamageNumbers.Report( this, amount, headshot );
// ⚠️ ZombieAI.Die RUNS INSIDE THIS, so on a killing hit dmg.die dwarfs everything else in
// dmg.apply. Read it against dmg_hits: at high penetration most hits are not kills.
using ( CpuScope.Measure( "dmg.die" ) )
if ( Current <= 0f )
OnKilled?.Invoke( headshot );
return amount;
}
/// <summary>Set both current and max — for spawning something with a
/// round-scaled pool rather than the prefab's default.</summary>
public void Reset( float max )
{
Max = max;
Current = max;
_immuneUntil = 0f;
}
/// <summary>
/// Set what is left of the pool, within it — basalt's beast carried from one of his comings to the next
/// (`HexPlatforms.BossFight.cs`), which a `Reset` alone would hand back whole.
/// </summary>
public void SetCurrent( float current ) => Current = Math.Clamp( current, 0f, Max );
/// <summary>
/// Restore health.
///
/// ⚠️ Use this, NOT Apply with a negative amount — Apply clamps its argument
/// to zero, so Apply(-50) silently heals nothing. It also fires OnDamaged,
/// which healing must not: HealthRegen listens to that to know when it was
/// last hit, so healing through Apply would reset its own delay forever.
/// </summary>
public void Heal( float amount )
{
if ( IsDead ) return;
Current = Math.Clamp( Current + MathF.Max( 0f, amount ), 0f, Max );
}
/// <summary>
/// OVERHEAL — health above <see cref="Max"/>, which Leech gives (2026-10-04, `KillMods`): *"kills recover 5hp, including
/// overheal, meaning if my max hp is 150, then i can kill and regen up to a total 0f 200hp"*, and it *"does not drain
/// away"*.
///
/// ⛔ A POOL OF ITS OWN, NOT `Current` ABOVE `Max`. Everything that writes `Current` clamps it to `Max` — a hit, `Heal`,
/// `SetCurrent`, Juggernog's refresh — and each would have quietly taken it away. Only damage spends this (`Apply`, after
/// armor), only Leech fills it, and `Reset` leaves it alone, so a perk's refill does not cost it. A new run clears it
/// (`RoundManager`).
///
/// ⚠️ THE OWNER'S, like the rest of a player's health. Not mirrored to the other machines.
/// </summary>
public float Over { get; private set; }
/// <summary>Heal, and past <see cref="Max"/> into <see cref="Over"/>, up to <paramref name="overCap"/> over. Returns what was added.</summary>
public float HealOver( float amount, float overCap )
{
if ( IsDead || amount <= 0f ) return 0f;
var toMax = MathF.Min( amount, MathF.Max( 0f, Max - Current ) );
Current += toMax;
var toOver = MathF.Min( amount - toMax, MathF.Max( 0f, overCap - Over ) );
Over += toOver;
return toMax + toOver;
}
/// <summary>Drop the overheal — a new run.</summary>
public void ClearOver() => Over = 0f;
/// <summary>
/// Was this a headshot? The engine merges the struck hitbox's tags into the
/// damage (BaseCombatWeapon.MergeHitboxTags), so "head" arrives here without
/// us tracing for it.
/// </summary>
public static bool IsHeadshot( in DamageInfo damage )
{
// ⚠️ Hitbox tag resolution, per hit. Separate from the head-damage product, which is
// measured as dmg.headscale.
using var _cpu = CpuScope.Measure( "dmg.headshot" );
var tags = damage.Tags;
return tags is not null && tags.Has( "head" );
}
/// <summary>
/// WIDE BORE (`t5_widebore`) — was this hit close enough to the head to count as one?
///
/// ⛔ AN INSTANCE METHOD, BECAUSE THE STATIC ONE CANNOT SEE THE VICTIM. `IsHeadshot` reads
/// only the DamageInfo, which carries where the bullet landed but nothing about whose head is
/// where. This runs on the victim, so `GameObject` is the zombie and the head is a measurement
/// rather than a lookup.
///
/// ⚠️ HEAD HEIGHT IS A FRACTION OF `BodyHeight`, NOT A BONE NAME. The roster is ported from
/// several packs through ModelDoc and shares no skeleton convention — this project has been
/// bitten by guessing bone axes before. `StatusEffects.FlameHeightFraction` (0.92) is the
/// established answer to "where is this zombie's head", used to place the burning effect, and
/// reusing it means the two cannot disagree about the same zombie.
///
/// ⚠️ IT SCALES WITH THE ZOMBIE, because `BodyHeight` is what `nz_zscale` moves — a giant's
/// head is measured at a giant's height rather than at a constant sitting in its chest.
///
/// ⚠️ AND IT IS ONLY EVER A PROMOTION. A hit already carrying the head tag is a headshot
/// whatever this says; this turns a body shot into a headshot and never the reverse.
/// </summary>
bool WideBoreHead( in DamageInfo damage )
{
// ⚠️ THE CHEAP TEST FIRST, AND IT IS NOT AN OPTIMISATION FLOURISH. This runs on every
// body shot in the game — `||` only skips it for hits that already carry the head tag — so
// the component lookup on self goes before `FiredBy`, which walks to find the weapon.
// Players are the common non-zombie victim and leave immediately.
var zombie = Components.Get<NZombies.ZombieAI>( FindMode.EverythingInSelf );
if ( zombie is null ) return false;
var radius = NZombies.TechEffects.Factor( FiredBy( damage ), "t5_widebore", 0f );
if ( radius <= 0f ) return false;
var head = WorldPosition
+ Vector3.Up * (zombie.BodyHeight * NZombies.StatusEffects.FlameHeightFraction);
return damage.Position.DistanceSquared( head ) <= radius * radius;
}
}