Weapons/ClassTech.cs

ClassTech and TechMarkOutline. Implements per-class weapon augment effects: victim-side damage multipliers and statuses (stuns, suppress, marker/share, silver bullets, point blank, follow-through, bank shot, etc.), kill payouts routing to the killer, holder-side effects (juggernaut, pain reload, lifeline, stamina/dynamo), ammo mod Catalyst, and outline handling for status marks. TechMarkOutline is a component that shows and updates highlight outlines for marked/spotted/local targets.

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

namespace NZombies;

/// <summary>
/// THE PER-CLASS WEAPON TECH'S EFFECTS THAT ARE NOT THE GUN'S OWN STATE (2026-10-04,
/// `Sbox nzombies/Docs/WEAPON_TECH_BY_CLASS.md`): what a hit does to a zombie (the stuns, Suppressive Fire, Spotter,
/// Marker, Silver Bullets, Point Blank, One Shot One Kill), what a kill gives back (Quartermaster, Recycler, the
/// Underbarrel Launcher's charge), and what the gun in your hands does to YOU (Juggernaut, Pain Reload, Lifeline,
/// Holster Reload, Skeleton Stock, Dynamo, Catalyst). The gun's own state — Select Fire's mode, the aim timers, the
/// cylinder Spin the Cylinder rolled — is in `SWB.Base.Weapon` (`Weapon.ClassTech.cs`).
///
/// ⛔ EACH HALF RUNS ON THE MACHINE THAT OWNS IT, the rule every hook in this project learned the hard way:
///   • a hit's effect on a zombie runs on the HOST, in `Health.OnDamage`, where a client's relayed hit arrives with its
///     weapon as a prefab path (`TechEffects.TechRef`) — the zombie's AI, its statuses and its health are all there;
///   • a kill's payout runs on the KILLER'S machine, because only it has the gun (`NZNet.TechKill`);
///   • what the held gun does to its holder runs on the HOLDER'S machine, where the gun exists at all.
///
/// ⚠️ EVERY NUMBER IS THE CATALOGUE'S (`WeaponTech`), read through `TechEffects.Mag`/`TechStats`, so `nz_tech` prints
/// what the game does.
/// </summary>
public static class ClassTech
{
	// ══ the victim's side (HOST, `Health.OnDamage`) ═════════════════════════════════════════════

	/// <summary>
	/// The shooter's extra head multiplier: every owned `s.head` (Marksman Conversion ×1.5, Match Grade ×1.25,
	/// Precision ×1.35), and One Shot One Kill's ×5. Rides the head product, so it multiplies everything else there.
	/// </summary>
	public static float HeadScale( in TechEffects.TechRef tech )
		=> !tech.Valid ? 1f
			: TechStats.Mul( tech, "s.head" ) * TechEffects.Mag( tech, "t5_sn_oneshot", "head" );

	/// <summary>One Shot One Kill's fifth on everything that is not the head. 1 without it.</summary>
	public static float BodyScale( in TechEffects.TechRef tech )
		=> !tech.Valid ? 1f : TechEffects.Mag( tech, "t5_sn_oneshot", "body" );

	/// <summary>
	/// Silver Bullets (×3 bosses, ×2 specials, ×0.75 the rest) and Point Blank (×2 within 150 units): conditions on
	/// WHO was hit and from WHERE, so they are the victim's half and run beside Boss Slayer.
	/// </summary>
	public static float VictimScale( in TechEffects.TechRef tech, GameObject victim, Vector3 hitAt )
	{
		if ( !tech.Valid || !victim.IsValid() ) return 1f;

		var m = 1f;

		if ( TechEffects.Has( tech, "t4_rv_silver" ) )
		{
			var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );

			// ⚠️ BOSS FIRST: Brutus is on `SpecialEnemies.Names` too, so asking "special" first would pay him ×2.
			if ( ai.IsValid() )
				m *= DeathAugments.IsBoss( victim ) ? TechEffects.Mag( tech, "t4_rv_silver", "boss" )
					: SpecialEnemies.IsSpecial( ai.Variant ) ? TechEffects.Mag( tech, "t4_rv_silver", "special" )
					: TechEffects.Mag( tech, "t4_rv_silver", "normal" );
		}

		if ( InPointBlank( tech, hitAt ) )
			m *= TechEffects.Mag( tech, "t5_sg_pointblank", "dmg" );

		// ⚠️ TIERS 1–3's WHO-AND-FROM-WHERE TERMS (2026-10-04, WEAPON_TECH_TIERS_1_3.md), beside the two above and multiplied
		// with them and with everything else in the hit: CQB Barrel's ×1.6, Solid Slug's ×4, Boss Slayer's, Match Grade's.
		//
		// ── Close Shave (SMG, tier 2): ×1.1 within 300 units of the shooter — Point Blank's range test ──
		if ( InRange( tech, "t2_smg_closeshave", hitAt ) )
			m *= TechEffects.Mag( tech, "t2_smg_closeshave", "dmg" );

		// ── Hard Target (AR, tier 3): ×1.25 on bosses and special zombies — Silver Bullets' classification ──
		if ( TechEffects.Has( tech, "t3_ar_hardtarget" ) && BossOrSpecial( victim ) )
			m *= TechEffects.Mag( tech, "t3_ar_hardtarget", "dmg" );

		// ── Range Finder (marksman, tier 3): +1% for every WHOLE 100 units from the shooter's eyes, up to +25% ──
		if ( TechEffects.Has( tech, "t3_br_rangefinder" ) )
		{
			var step = MathF.Max( 1f, TechEffects.Mag( tech, "t3_br_rangefinder", "step", 0f ) );
			var steps = MathF.Floor( tech.Player.EyePos.Distance( hitAt ) / step );
			m *= 1f + MathF.Min( TechEffects.Mag( tech, "t3_br_rangefinder", "cap", 0f ),
				steps * TechEffects.Mag( tech, "t3_br_rangefinder", "per", 0f ) );
		}

		// ── Riot Guard (shotgun, tier 3): ×1.25 within 150 units — Point Blank's own range test, so inside it the two make
		// ×2.5 (the doc's note). Its 15% less damage taken is its `s.taken`, in `OnPlayerDamaged` ──
		if ( InRange( tech, "t3_sg_riotguard", hitAt ) )
			m *= TechEffects.Mag( tech, "t3_sg_riotguard", "dmg" );

		// ── Silver Tips (revolver, tier 2): ×1.15 on bosses and special zombies, multiplied with Silver Bullets' ×3 / ×2 ──
		if ( TechEffects.Has( tech, "t2_rv_silvertips" ) && BossOrSpecial( victim ) )
			m *= TechEffects.Mag( tech, "t2_rv_silvertips", "dmg" );

		// ── Finisher (handgun, tier 3): ×1.5 on a zombie below 25% of its health BEFORE this hit, bosses included ──
		//
		// ⚠️ THE HOST'S FIGURE. A client's copy of a zombie is never hurt (`Health.OnDamage` relays), so its own damage number
		// leaves this term out; the hit itself is scaled here, on the host, where the health is real (Coup de Grâce's rule).
		if ( TechEffects.Has( tech, "t3_hg_finisher" )
			&& Wounded( victim, TechEffects.Mag( tech, "t3_hg_finisher", "health", 0f ) ) )
			m *= TechEffects.Mag( tech, "t3_hg_finisher", "dmg" );

		return m;
	}

	/// <summary>Is this a zombie below <paramref name="share"/> of its max health. False for anything that is not a zombie.</summary>
	static bool Wounded( GameObject victim, float share )
	{
		var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		return ai.IsValid() && ai.HealthMax > 0f && ai.HealthNow < ai.HealthMax * share;
	}

	/// <summary>Is this hit inside Point Blank's range of the shooter's eyes. False without the node.</summary>
	static bool InPointBlank( in TechEffects.TechRef tech, Vector3 hitAt ) => InRange( tech, "t5_sg_pointblank", hitAt );

	/// <summary>
	/// Is this hit within the node's own `range` of the shooter's eyes — Point Blank's test, for any node that declares a
	/// `range` (Close Shave, 2026-10-04). False without the node.
	/// </summary>
	static bool InRange( in TechEffects.TechRef tech, string nodeId, Vector3 hitAt )
		=> TechEffects.Has( tech, nodeId )
			&& tech.Player.EyePos.Distance( hitAt ) <= TechEffects.Mag( tech, nodeId, "range", 0f );

	/// <summary>
	/// A boss or a special zombie, as Silver Bullets tells them apart (`IsBoss`, then `SpecialEnemies`). Hard Target's
	/// test. False for anything that is not a zombie.
	/// </summary>
	static bool BossOrSpecial( GameObject victim )
	{
		var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		return ai.IsValid() && (DeathAugments.IsBoss( victim ) || SpecialEnemies.IsSpecial( ai.Variant ));
	}

	/// <summary>
	/// A hit landed on a zombie: the statuses the per-class augments put on it. HOST ONLY — `Health.OnDamage`'s own path.
	///
	/// ⛔ ON THE HOST AND NOT ON THE SHOOTER, BECAUSE A REFRESH DOES NOT TRAVEL. `StatusEffects.Apply` relays only a
	/// NEW status; a client re-stunning a zombie it already saw stunned refreshes its own copy and tells nobody, so a
	/// pistol holding a lane (Flashbang Rounds) would stop it once on the host and never again. Here the host refreshes
	/// its own copy, which is the one the AI reads.
	///
	/// ⛔ NO STUN ON A BOSS, ANY OF THEM. Flashbang Rounds is the user's rule (*"does not affect bosses"*); Stopping Power,
	/// Point Blank and Bolt Strike follow it, or a semi-auto rifle would hold Brutus still for the whole fight.
	/// </summary>
	public static void OnZombieHit( in TechEffects.TechRef tech, GameObject attacker, Vector3 hitAt, GameObject victim )
	{
		if ( !tech.Valid || !victim.IsValid() ) return;
		if ( !victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return;

		// ── the stuns: Stopping Power, Flashbang Rounds, Point Blank — the longest wins ──
		var stun = 0f;
		if ( TechEffects.Has( tech, "t5_br_stopping" ) )
			stun = MathF.Max( stun, TechEffects.Mag( tech, "t5_br_stopping", "seconds", 0f ) );
		if ( TechEffects.Has( tech, "t5_hg_flashbang" ) )
			stun = MathF.Max( stun, TechEffects.Mag( tech, "t5_hg_flashbang", "seconds", 0f ) );
		if ( InPointBlank( tech, hitAt ) )
			stun = MathF.Max( stun, TechEffects.Mag( tech, "t5_sg_pointblank", "seconds", 0f ) );

		if ( stun > 0f && !DeathAugments.IsBoss( victim ) )
			StatusEffects.Apply( victim, "stun", attacker, seconds: stun );

		// ── Suppressive Fire: -5% speed a hit for 2 s, down to half ──
		//
		// ⚠️ ONE STATUS WHOSE SPEED STEPS DOWN, NOT A STACK OF STATUSES. A status refreshes rather than stacks (its own
		// rule), so each hit re-applies it one step slower; two seconds without a hit and it is gone, speed and all.
		if ( TechEffects.Has( tech, "t5_smg_suppress" ) )
		{
			var per = TechEffects.Mag( tech, "t5_smg_suppress", "per", 0f );
			var floor = 1f - TechEffects.Mag( tech, "t5_smg_suppress", "cap", 0f );
			var now = StatusEffects.Has( victim, "suppressed" ) ? StatusEffects.SpeedOf( victim, "suppressed" ) : 1f;

			// ⚠️ NEVER 0: `Apply` reads a speed of 0 as "the rule's own".
			var next = MathF.Max( MathF.Max( 0.05f, floor ), now - per );
			StatusEffects.Apply( victim, "suppressed", attacker, speedScale: next,
				seconds: TechEffects.Mag( tech, "t5_smg_suppress", "seconds", 0f ) );
		}

		// ── Slowing Rounds (marksman, tier 2): ×0.8 speed for 1 s a hit, Suppressive Fire's status (see `Slow`) ──
		if ( TechEffects.Has( tech, "t2_br_slowing" ) )
			Slow( victim, attacker, TechEffects.Mag( tech, "t2_br_slowing", "speed" ),
				TechEffects.Mag( tech, "t2_br_slowing", "seconds", 0f ) );

		// ── Spotter Rounds and Marker: marks with a timer ──
		if ( TechEffects.Has( tech, "t5_ar_spotter" ) )
			StatusEffects.Apply( victim, "spotted", attacker, seconds: TechEffects.Mag( tech, "t5_ar_spotter", "seconds", 0f ) );

		if ( TechEffects.Has( tech, "t5_hg_marker" ) )
			StatusEffects.Apply( victim, "marked", attacker, seconds: TechEffects.Mag( tech, "t5_hg_marker", "seconds", 0f ) );
	}

	/// <summary>
	/// MARKER (`t5_hg_marker`): damage dealt to a marked zombie, by anyone, is dealt to every other marked zombie.
	/// HOST ONLY, from `Health.OnDamage` after the hit lands.
	///
	/// ⛔ THE COPIES GO THROUGH `Apply`, NOT `OnDamage`, AND THAT IS WHAT STOPS THEM COPYING. Only `OnDamage` shares,
	/// so a copy cannot share again — two marked zombies would otherwise bounce one hit between them forever (the
	/// doc's rule: *"the copies must not copy again"*).
	///
	/// ⚠️ `ApplyUnpaid`, so a hundred copies do not pay a hundred hit drips; the kills they cause still pay.
	///
	/// ⚠️ THE FULL HIT, `amount`, not what the first zombie had left: *"if i deal 100 damage to a marked zombie, all
	/// marked zombies take 100 damage"*.
	///
	/// ⛔ AND IT RENEWS EVERY MARK IT TOUCHES (2026-10-06), so the link holds while the group is being hit. The user: *"it seems
	/// to disapear after one of them dies or gets shot? this should not be the case"*. Each mark ran out 5 s after ITS OWN last
	/// hit from a Marker gun and a copied hit never renewed it, so focusing one zombie, or moving on from one that died, let the
	/// others' marks lapse within seconds: the link looked broken by the death or the shot. Now any hit on a marked zombie, from
	/// anyone, renews the mark of the zombie hit and of every zombie it reaches; 5 s with no hit on any of them ends the link.
	/// </summary>
	public static void ShareMarked( Health victim, float amount, GameObject attacker )
	{
		if ( !victim.IsValid() || amount <= 0f ) return;

		var seconds = WeaponTech.MagOf( "t5_hg_marker", "seconds", 5f );

		// ⚠️ THE ZOMBIE HIT, IF IT LIVED: a killing hit has already cleared its statuses (`ZombieAI.Die`), and `Has` says so
		if ( !victim.IsDead && StatusEffects.Has( victim.GameObject, "marked" ) ) RenewMark( victim.GameObject, attacker, seconds );

		for ( int i = ZombieAI.All.Count - 1; i >= 0; i-- )
		{
			if ( i >= ZombieAI.All.Count ) continue;

			var z = ZombieAI.All[i];
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( !StatusEffects.Has( z.GameObject, "marked" ) ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
			if ( !hp.IsValid() || hp == victim || hp.IsDead ) continue;

			hp.ApplyUnpaid( amount, attacker );

			// ⚠️ AFTER THE COPY, AND ONLY IF IT LIVED: a copy that kills has cleared the mark, and a corpse is not re-marked
			if ( !hp.IsDead && StatusEffects.Has( z.GameObject, "marked" ) ) RenewMark( z.GameObject, attacker, seconds );
		}
	}

	// ⚠️ NULLABLE-BACKED (INSTRUCTIONS §1)
	static Dictionary<GameObject, float> _markSent;

	/// <summary>
	/// Renew a zombie's Marker mark: here at once, and on the other machines at most once a second.
	///
	/// ⚠️ THE RENEWAL HAS TO TRAVEL, AND NOT ON EVERY HIT. `StatusEffects.Apply` sends only a NEW status, so a renewal stayed on the
	/// host, and every client's copy of the mark (its purple outline) ran out on its own 5-second clock while the host's link
	/// held. One message a second per zombie keeps a 5-second mark from lapsing anywhere, where a message per hit would be a flood
	/// under an automatic weapon.
	/// </summary>
	static void RenewMark( GameObject zombie, GameObject attacker, float seconds )
	{
		StatusEffects.Apply( zombie, "marked", attacker, seconds: seconds );

		if ( !Networking.IsActive || Connection.Local is null ) return;

		var sent = _markSent ??= new Dictionary<GameObject, float>();
		var now = RealTime.Now;
		if ( sent.TryGetValue( zombie, out var at ) && now - at < 1f ) return;

		// ⚠️ THE DEAD ARE DROPPED NOW AND THEN, so a long game does not keep a key for every zombie ever marked
		if ( sent.Count > 64 )
			foreach ( var gone in sent.Keys.Where( k => !k.IsValid() ).ToList() ) sent.Remove( gone );

		sent[zombie] = now;
		NZNet.ZombieStatus( Connection.Local.Id.ToString(), zombie.Id, "marked",
			attacker.IsValid() ? attacker.Id : Guid.Empty, 0f, seconds, -1f, 0f );
	}

	/// <summary>
	/// BOLT STRIKE (`t5_act_boltstrike`): every zombie within <paramref name="radius"/> of <paramref name="at"/> is
	/// stunned. HOST ONLY — the shooter asks through `NZNet.TechStun`. Bosses are never stunned.
	/// </summary>
	public static void StunAround( Vector3 at, float radius, float seconds )
	{
		if ( radius <= 0f || seconds <= 0f ) return;

		for ( int i = ZombieAI.All.Count - 1; i >= 0; i-- )
		{
			if ( i >= ZombieAI.All.Count ) continue;

			var z = ZombieAI.All[i];
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( z.WorldPosition.Distance( at ) > radius ) continue;
			if ( DeathAugments.IsBoss( z.GameObject ) ) continue;

			StatusEffects.Apply( z.GameObject, "stun", null, seconds: seconds );
		}
	}

	/// <summary>
	/// STAND-OFF (`t3_sn_standoff`, sniper tier 3): while its gun is aimed, every zombie within <paramref name="radius"/>
	/// of <paramref name="at"/> is slowed to <paramref name="speed"/>. HOST ONLY — the aiming player renews it a few times a
	/// second (`Weapon.TickStandOff`, through `NZNet.TechSlow` from a client), and each renewal lasts a moment past the next,
	/// so it ends a moment after the sights come down or the zombie walks out.
	///
	/// ⚠️ BOSSES ARE SLOWED TOO, as Suppressive Fire slows them: the "not bosses" rule is the stuns'.
	/// </summary>
	public static void SlowAround( Vector3 at, float radius, float speed, float seconds )
	{
		if ( radius <= 0f || seconds <= 0f ) return;

		for ( int i = ZombieAI.All.Count - 1; i >= 0; i-- )
		{
			if ( i >= ZombieAI.All.Count ) continue;

			var z = ZombieAI.All[i];
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( z.WorldPosition.Distance( at ) > radius ) continue;

			Slow( z.GameObject, null, speed, seconds );
		}
	}

	/// <summary>
	/// SLOWING ROUNDS AND STAND-OFF (tiers 2–3, 2026-10-04): Suppressive Fire's `suppressed` status at a fixed speed, the
	/// doc's "Suppressive Fire's slow". HOST ONLY.
	///
	/// ⛔ ONE STATUS, THE SLOWER WINS, AND NOTHING MULTIPLIES. A deeper `suppressed` already on the zombie (Suppressive
	/// Fire's steps) is left exactly as it is, timer and all: re-applying it here would either raise the zombie's speed
	/// back to this one or, refreshed by a sniper who keeps aiming, hold someone else's deeper slow on it for as long as
	/// they aim. A shallower one is replaced by this speed, and Suppressive Fire's next hit steps down from it.
	/// </summary>
	static void Slow( GameObject victim, GameObject source, float speed, float seconds )
	{
		if ( !victim.IsValid() || seconds <= 0f ) return;
		if ( StatusEffects.Has( victim, "suppressed" ) && StatusEffects.SpeedOf( victim, "suppressed" ) < speed ) return;

		// ⚠️ NEVER 0: `Apply` reads a speed of 0 as "the rule's own" (Suppressive Fire's note).
		StatusEffects.Apply( victim, "suppressed", source, speedScale: MathF.Max( 0.05f, speed ), seconds: seconds );
	}

	// ══ the hit itself (`Health.OnDamage`): tiers 1–3 (2026-10-04, WEAPON_TECH_TIERS_1_3.md) ═══════════════════════

	/// <summary>
	/// COUP DE GRÂCE (`t3_sn_coupdegrace`, sniper tier 3): any hit on a zombie ALREADY below 30% of its health kills it. What
	/// `Health.Apply` is handed in place of <paramref name="amount"/>. Never a boss. HOST ONLY, at `Health.OnDamage`'s `Apply`,
	/// where the zombie's health is real.
	///
	/// ⛔ ITS HEALTH BEFORE THE HIT, NOT AFTER IT (review, 2026-10-04): the doc's "kills a zombie below 30% health" and the
	/// node's own lever. Read after the hit, a 1,500 body hit on a full 2,000-health zombie (25% left) killed it outright, and a
	/// sniper one-shot zombies with 1.43× the health it could. Finisher and Vigor Rush's Executioner test the same way.
	///
	/// ⚠️ THE REST OF ITS HEALTH, NOT A STAND-IN, AND ONLY WHAT THE ZOMBIE LAYS DOWN. `amount` itself stays the hit, so
	/// Marker's copies, Bleeder's bleed and Bouncy Rounds' chain (the same class's tier 5) still carry what the shot dealt
	/// rather than an execution.
	/// </summary>
	public static float CoupDeGrace( in TechEffects.TechRef tech, Health victim, float amount )
	{
		if ( amount <= 0f || !victim.IsValid() || victim.IsDead ) return amount;
		if ( !TechEffects.Has( tech, "t3_sn_coupdegrace" ) ) return amount;

		if ( victim.Current >= victim.Max * TechEffects.Mag( tech, "t3_sn_coupdegrace", "health", 0f ) ) return amount;

		if ( !victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return amount;
		if ( DeathAugments.IsBoss( victim.GameObject ) ) return amount;

		return MathF.Max( amount, victim.Current );
	}

	/// <summary>The tag a Bullseye shot puts on its first zombie's hit (`BulletInfo.HitScan`).</summary>
	public const string BullseyeTag = "bullseye";

	/// <summary>
	/// BULLSEYE (`t3_sn_bullseye`, sniper tier 3): does this hit count as a headshot. Asked where Wide Bore and Lucky Shot
	/// promote one (`Health.OnDamage`, both branches): on the shooter's machine, which sends the verdict on with the hit.
	///
	/// ⛔ ITS OWN TAG, NOT `head`. The gore reads the hitbox's tags to decide what comes off (`Health.GorePartOf`), and a
	/// chest hit that SCORES as a headshot must not pop the head — Wide Bore's and Lucky Shot's rule.
	/// </summary>
	public static bool BullseyeHead( in Sandbox.DamageInfo damage )
		=> damage?.Tags is { } tags && tags.Has( BullseyeTag );

	/// <summary>
	/// FOLLOW-THROUGH (`t3_br_followthrough`, marksman tier 3): the leftover of a kill this same bullet made, added to its
	/// damage on the next zombie it reaches — once. HOST ONLY, onto the hit's raw figure, before this victim's multipliers.
	///
	/// ⛔ THE BULLET IS ITS `ShotId`, AND IT REACHES THE HOST ON A CLIENT'S HIT TOO (`NZNet.HurtRemote`'s `shot`). Only the
	/// host knows a kill — a client's copy of a zombie never takes the damage — so the host carries the leftover from one
	/// hit of the bullet to the next, on the shooter's own body (`NZPlayer.FollowThroughLeft`).
	/// </summary>
	public static float FollowThroughCarry( in TechEffects.TechRef tech, in Sandbox.DamageInfo damage, Health victim )
	{
		if ( !tech.Valid || tech.Player.FollowThroughLeft <= 0f || !victim.IsValid() || victim.IsDead ) return 0f;

		var shot = ShotOf( damage );
		if ( shot == Guid.Empty || shot != tech.Player.FollowThroughShot ) return 0f;
		if ( !victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return 0f;

		var left = tech.Player.FollowThroughLeft;
		tech.Player.FollowThroughLeft = 0f;
		return left;
	}

	/// <summary>
	/// FOLLOW-THROUGH: this hit killed, so keep what it did not need for the bullet's next zombie. <paramref name="bullet"/>
	/// is the hit's raw figure, any carry already in it; <paramref name="taken"/> is what `Apply` returned.
	///
	/// ⚠️ IN THE BULLET'S OWN DAMAGE, NOT IN HEALTH: the share of the hit the zombie did not need, (1 − lost ÷ dealt) of what
	/// the bullet brought. Carried as health, a headshot kill's ×2.5 would ride into the next zombie and meet its head
	/// multiplier again.
	/// </summary>
	public static void FollowThroughKill( in TechEffects.TechRef tech, in Sandbox.DamageInfo damage, float bullet,
		Health victim, float taken )
	{
		if ( taken <= 0f || bullet <= 0f || !victim.IsValid() || !victim.IsDead ) return;
		if ( !TechEffects.Has( tech, "t3_br_followthrough" ) ) return;

		var shot = ShotOf( damage );
		var dealt = victim.LastDamage;
		if ( shot == Guid.Empty || dealt <= 0f ) return;

		tech.Player.FollowThroughShot = shot;
		tech.Player.FollowThroughLeft = bullet * Math.Clamp( 1f - victim.LastLost / dealt, 0f, 1f );
	}

	/// <summary>
	/// FOLLOW-THROUGH: the bullet's id for the host, on a client's relayed hit from a gun that owns the node — "" for every
	/// other hit, so they send nothing more.
	/// </summary>
	public static string FollowThroughShot( in TechEffects.TechRef tech, in Sandbox.DamageInfo damage )
	{
		if ( !TechEffects.Has( tech, "t3_br_followthrough" ) ) return "";

		var shot = ShotOf( damage );
		return shot == Guid.Empty ? "" : shot.ToString( "N" );
	}

	/// <summary>The id every hit of one fired bullet shares (`SWB.Shared.DamageInfo.ShotId`), or empty.</summary>
	static Guid ShotOf( in Sandbox.DamageInfo damage ) => (damage as SWB.Shared.DamageInfo)?.ShotId ?? Guid.Empty;

	// ══ the bullet's path (the SHOOTER, `HitScanBulletInfo`) ═══════════════════════════════════════════

	/// <summary>
	/// How many of the nearest zombies Bank Shot traces a line of sight to before giving up. ⚠️ THE SEARCH'S BOUND, NOT A NODE
	/// NUMBER: a wall hit in a crowd would otherwise trace every zombie on the map.
	/// </summary>
	const int BankShotSightlines = 4;

	/// <summary>
	/// BANK SHOT (`t3_rv_bankshot`, revolver tier 3, 2026-10-04): which way a round that struck the world at
	/// <paramref name="from"/> bounces — at the middle of the nearest living zombie in sight of that point, or null when none is.
	/// <paramref name="passed"/> is what this bullet already pierced, never a target again.
	///
	/// ⚠️ TECHBLAST'S LINE OF SIGHT (`TechBlast.Detonate`): the target's own body ignored, anything stopping short of it by
	/// more than its `HitRadius` blocks. Nearest first, so a zombie standing in the way is the nearer one and is taken instead.
	///
	/// ⛔ ONLY ZOMBIES ON THE OPEN SIDE OF THE SURFACE (<paramref name="normal"/>, review 2026-10-04). The ones beyond the wall —
	/// tearing at the boards outside a window — are often the nearest to the bounce point, and their lines run back through the
	/// wall: they used up the four traces below and the round stopped with a zombie in plain sight in the room.
	/// </summary>
	public static Vector3? BankShotAim( Vector3 from, Vector3 normal, List<GameObject> passed )
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) return null;

		var near = new List<(ZombieAI Z, float D)>();
		foreach ( var z in ZombieAI.All )
		{
			if ( !z.IsValid() || z.State == ZombieState.Dead || Pierced( z.GameObject, passed ) ) continue;

			var body = BodyOf( z );
			if ( Vector3.Dot( body - from, normal ) <= 0f ) continue;

			var d = body.Distance( from );
			if ( d <= SWB.Base.Weapon.TraceRange ) near.Add( (z, d) );
		}

		near.Sort( ( a, b ) => a.D.CompareTo( b.D ) );

		for ( int i = 0; i < near.Count && i < BankShotSightlines; i++ )
		{
			var (z, d) = near[i];
			var at = BodyOf( z );

			var tr = scene.Trace.Ray( from, at )
				.WithoutTags( "player", "trigger" )
				.IgnoreGameObjectHierarchy( z.GameObject )
				.Run();

			if ( tr.Hit && tr.Distance < d - z.HitRadius ) continue;

			return (at - from).Normal;
		}

		return null;
	}

	/// <summary>The middle of a zombie's body, TechBlast's target point.</summary>
	static Vector3 BodyOf( ZombieAI z ) => z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f);

	/// <summary>Did this bullet already pass through this zombie (a hitbox of it is on its ignore list).</summary>
	static bool Pierced( GameObject zombie, List<GameObject> passed )
	{
		if ( passed is null ) return false;

		foreach ( var go in passed )
			if ( go.IsValid() && ZombieAI.RootOf( go ) == zombie ) return true;

		return false;
	}

	// ══ kills (HOST → the killer's machine) ═════════════════════════════════════════════════════

	/// <summary>
	/// A zombie died; <paramref name="tech"/> is the gun that last hit it (`Health.LastTech`). Pays the gun's kill
	/// augments on its owner's machine, the only one that has the gun.
	///
	/// ⚠️ A KILL BY ANYTHING WITHOUT A WEAPON — a grenade, the knife, a trap — has no tech and pays nothing here. That
	/// is the reading of "each kill puts rounds back in the magazine": the magazine of the gun that killed.
	///
	/// ⚠️ <paramref name="headshot"/> IS `ZombieAI.Die`'s, the promoted flag (Lucky Shot, Wide Bore, Bullseye), and it travels
	/// with the kill (`NZNet.TechKill`): Trick Shot pays on a headshot kill alone (2026-10-04).
	/// </summary>
	public static void OnZombieKilled( in TechEffects.TechRef tech, bool headshot = false )
	{
		if ( !tech.Valid ) return;

		// ⚠️ ONLY A GUN WITH A KILL AUGMENT HEARS ABOUT IT, so a client's kills are not a broadcast each. The host reads the
		// client's tree off the wire (`NZPlayer.TechStore`).
		// ⚠️ AND TIERS 1–3's KILL NODES (2026-10-04): Catch Breath's stamina and Rechamber Rush's faster cycle; Shell
		// Recovery's shell, Blood Oath's health, and Trick Shot's round on a headshot kill.
		if ( !TechEffects.Has( tech, "t5_ar_quartermaster" ) && !TechEffects.Has( tech, "t5_mag_recycler" )
			&& !TechEffects.Has( tech, "t5_ar_underbarrel" )
			&& !TechEffects.Has( tech, "t3_smg_catchbreath" ) && !TechEffects.Has( tech, "t3_rechamber" )
			&& !TechEffects.Has( tech, "t3_sg_shellrecovery" ) && !TechEffects.Has( tech, "t2_rv_bloodoath" )
			// ⚠️ AND RAMPAGE'S STACK (auto action, tier 3, 2026-10-04): the gun counts it only while its trigger pull lasts.
			&& !TechEffects.Has( tech, "t3_act_rampage" )
			// ⚠️ AND THE MAGAZINE SETS' (tier 2, 2026-10-04): Kill Feed's rounds from the reserve, Brass Saver's round on a headshot kill.
			&& !TechEffects.Has( tech, "t2_mag_killfeed" )
			&& !(headshot && TechEffects.Has( tech, "t2_mag_brasssaver" ))
			&& !(headshot && TechEffects.Has( tech, "t3_rv_trickshot" )) ) return;

		if ( Networking.IsActive && PlayerPresence.Theirs( tech.Player.GameObject ) )
		{
			var owner = NZPlayers.OwnerOf( tech.Player.GameObject );
			if ( !string.IsNullOrEmpty( owner ) ) NZNet.TechKill( owner, tech.Prefab, headshot );
			return;
		}

		ApplyKill( tech.Player, tech.Prefab, headshot );
	}

	/// <summary>Pay a kill to the gun of this prefab that this player carries. The killer's own machine.</summary>
	public static void ApplyKill( NZPlayer player, string prefab, bool headshot = false )
	{
		var wep = WeaponOf( player, prefab );
		if ( wep.IsValid() ) wep.ClassTechKill( headshot );
	}

	/// <summary>
	/// DEAD OR ALIVE (`t3_rv_deadoralive`, revolver tier 3, 2026-10-04): the points a boss or special zombie's death adds for the
	/// gun that killed it — where Bounty pays (`ZombieAI.AwardPoints`, HOST), so Double Points doubles it too. 0 otherwise.
	/// Silver Bullets' classification (`BossOrSpecial`).
	/// </summary>
	public static int DeadOrAlivePoints( in TechEffects.TechRef tech, GameObject victim )
		=> victim.IsValid() && TechEffects.Has( tech, "t3_rv_deadoralive" ) && BossOrSpecial( victim )
			? (int)MathF.Round( TechEffects.Mag( tech, "t3_rv_deadoralive", "points", 0f ) )
			: 0;

	/// <summary>The carried gun of this prefab, held or holstered, or null.</summary>
	static SWB.Base.Weapon WeaponOf( NZPlayer player, string prefab )
	{
		if ( !player.IsValid() || string.IsNullOrEmpty( prefab ) ) return null;

		foreach ( var w in player.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
			if ( w.IsValid() && Rarity.PrefabOf( w ) == prefab ) return w;

		return null;
	}

	// ══ the holder's side (the HOLDER'S machine) ═════════════════════════════════════════════════

	/// <summary>
	/// The player is about to lose <paramref name="amount"/> health. JUGGERNAUT (`t5_lmg_juggernaut`) cuts it while its
	/// gun is in hand; PAIN RELOAD (`t4_mag_painreload`) reloads the gun in hand, instantly. Returns the amount.
	///
	/// ⚠️ IN `Health.Apply` BEFORE THE AUGMENTS AND ARMOR, beside Tortoise, which is the same kind of reduction: armor
	/// pays only for what got through.
	///
	/// ⛔ PAIN RELOAD HAS NO COOLDOWN (the user: *"no"*). In a crowd it reloads on every claw.
	/// </summary>
	public static float OnPlayerDamaged( NZPlayer victim, float amount )
	{
		if ( !victim.IsValid() || amount <= 0f ) return amount;

		// ⚠️ REFLEX (handgun tier 3, 2026-10-04): a hit arms the next 3 shots of EVERY carried gun that owns it, held or not —
		// the doc says "when you take damage", not "while you hold it" — refreshed to 3, never added up (`ClassTechReflex`).
		// ⚠️ AND RELOAD SHIELD (21–40 rounds, tier 2, 2026-10-04, `Weapon.ReloadShieldTaken`): ×0.8 for 2 s after a reload of any
		// carried gun that owns it landed — the strongest one, never a product: it is one shield, whichever gun raised it. Read
		// HERE, before Pain Reload's reload below raises it again, so no hit is cut by the shield its own reload raised; it
		// multiplies with the gun in hand's `s.taken` (Juggernaut, Gunner's Plate, Riot Guard) and applies with no gun in hand.
		var shield = 1f;
		foreach ( var w in victim.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
		{
			if ( !w.IsValid() ) continue;

			w.ClassTechReflex();
			shield = MathF.Min( shield, w.ReloadShieldTaken() );
		}

		amount *= shield;

		var held = VultureAugments.HeldWeapon( victim );
		if ( !held.IsValid() ) return amount;

		amount *= TechStats.Mul( held, "s.taken" );

		if ( TechEffects.Has( held, "t4_mag_painreload" ) )
		{
			held.CancelAnyReload();
			held.ClassTechRefill();
		}

		return amount;
	}

	/// <summary>LIFELINE (`t5_hg_lifeline`): the max health the gun in hand adds. 0 without it.</summary>
	public static float LifelineBonus( NZPlayer player )
	{
		var held = VultureAugments.HeldWeapon( player );
		return held.IsValid() ? MathF.Max( 0f, TechStats.Add( held, "s.hp+" ) ) : 0f;
	}

	/// <summary>
	/// A gun was just drawn (`NZInventory.Commit`). HOLSTER RELOAD (`t5_hg_holsterreload`): drawing it reloads every
	/// other gun you carry, each from its own reserve. And Lifeline's max health follows the gun in hand.
	///
	/// ⚠️ MY OWN BODY ONLY. Another player's copy here has no guns to fill and no health this machine owns.
	/// </summary>
	public static void OnWeaponDrawn( NZPlayer player, GameObject drawn )
	{
		if ( !player.IsValid() ) return;
		if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) return;

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

		if ( wep.IsValid() && TechEffects.Has( wep, "t5_hg_holsterreload" ) )
		{
			foreach ( var other in player.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
				if ( other.IsValid() && other != wep ) other.ClassTechRefill();
		}

		// ⚠️ EVERY SWITCH, NOT ONLY TO OR FROM A LIFELINE GUN: the bonus has to leave when the gun does.
		JuggAugments.RefreshHealth( player );
	}

	/// <summary>SKELETON STOCK (`t4_smg_skeleton`): how much faster sprinting drains stamina with the gun in hand.</summary>
	public static float StaminaDrainScale( NZPlayer player )
	{
		var held = VultureAugments.HeldWeapon( player );
		return held.IsValid() ? TechStats.Mul( held, "s.stamina" ) : 1f;
	}

	/// <summary>DYNAMO (`t5_smg_dynamo`): stamina was spent sprinting, so the gun in hand gets its share as rounds.</summary>
	public static void OnStaminaSpent( NZPlayer player, float spent )
	{
		if ( spent <= 0f ) return;

		var held = VultureAugments.HeldWeapon( player );
		if ( held.IsValid() && TechEffects.Has( held, "t5_smg_dynamo" ) )
			held.ClassTechDynamo( spent );
	}

	/// <summary>
	/// PISTOL WHIP (`t3_hg_pistolwhip`, handgun tier 3, 2026-10-04): the knife's damage with this gun in hand, ×3; 1 otherwise.
	/// The swinger's machine (`Knife.Strike`), whose figure the hit and Widow's cleave both carry. The knife never puts the gun
	/// away (`Knife.ShowGuns`), so the gun in hand is the one the swing is made with.
	/// </summary>
	public static float MeleeScale( NZPlayer player )
	{
		var held = VultureAugments.HeldWeapon( player );
		return held.IsValid() ? TechEffects.Mag( held, "t3_hg_pistolwhip", "melee" ) : 1f;
	}

	/// <summary>
	/// A slide began (`Slide.Start`). TUMBLEWEED (`t3_rv_tumbleweed`, revolver tier 3, 2026-10-04): a round from the gun in
	/// hand's own reserve into its cylinder (`Weapon.ClassTechTumbleweed`).
	///
	/// ⚠️ MY OWN BODY ONLY, `OnWeaponDrawn`'s rule: the guns are on the owner's machine and nowhere else.
	/// </summary>
	public static void OnSlideStarted( NZPlayer player )
	{
		if ( !player.IsValid() ) return;
		if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) return;

		var held = VultureAugments.HeldWeapon( player );
		if ( held.IsValid() ) held.ClassTechTumbleweed();
	}

	// ══ ammo mods: CATALYST (`t5_ar_catalyst`) ═══════════════════════════════════════════════════

	/// <summary>
	/// The gun in hand's ammo-mod cooldown multiplier. 0.5 with Catalyst, 1 without.
	///
	/// ⚠️ AND MOD RAIL'S ×0.85 (`t2_ar_modrail`, assault rifles tier 2, 2026-10-04): "cool down 15% faster" in Catalyst's
	/// convention. The two MULTIPLY (×0.425 with both), here, so `AmmoMods.CooldownFor` composes them with Timeslip and
	/// Elemental Pop in its one place.
	/// </summary>
	public static float CatalystCooldown( NZPlayer player )
	{
		var held = VultureAugments.HeldWeapon( player );
		return held.IsValid()
			? TechEffects.Mag( held, "t5_ar_catalyst", "cooldown" ) * TechEffects.Mag( held, "t2_ar_modrail", "cooldown" )
			: 1f;
	}

	/// <summary>The gun in hand's flat ammo-mod chance bonus. +0.05 with Catalyst, 0 without.</summary>
	public static float CatalystChance( NZPlayer player )
	{
		var held = VultureAugments.HeldWeapon( player );
		return held.IsValid() ? TechEffects.Mag( held, "t5_ar_catalyst", "chance", 0f ) : 0f;
	}

	/// <summary>Blast Furnace's damage on the gun in hand: ×1.5 with Catalyst, which gives a passive mod nothing else.</summary>
	public static float CatalystFurnace( NZPlayer player )
	{
		var held = VultureAugments.HeldWeapon( player );
		return held.IsValid() ? TechEffects.Mag( held, "t5_ar_catalyst", "furnace" ) : 1f;
	}

	// ══ outlines ══════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// A status was just put on <paramref name="go"/> (`StatusEffects.Add`, on every machine it reaches). Spotter's and
	/// Marker's marks wear an outline for as long as they last — *"it's outlined for your team"*.
	/// </summary>
	public static void OnStatusAdded( GameObject go, string id )
	{
		var color = id switch
		{
			"spotted" => new Color( 1f, 0.45f, 0.15f ),
			"marked" => new Color( 0.75f, 0.35f, 1f ),
			// ⚠️ BLOODHOUND'S MARK (ammo mod, 2026-10-04): blood red, on every machine — the ×2 is everybody's.
			"bloodhound" => new Color( 0.9f, 0.08f, 0.08f ),
			_ => Color.Transparent,
		};

		if ( color.a <= 0f || !go.IsValid() ) return;

		var mark = go.Components.Get<TechMarkOutline>( FindMode.EverythingInSelf )
			?? go.Components.Create<TechMarkOutline>();
		mark.Show( id, color );
	}

	/// <summary>Outline one zombie for this machine alone (High Noon's marks), or take the outline off.</summary>
	public static void SetLocalOutline( GameObject go, Color color, bool on )
	{
		if ( !go.IsValid() ) return;

		var mark = go.Components.Get<TechMarkOutline>( FindMode.EverythingInSelf );
		if ( on ) (mark ?? go.Components.Create<TechMarkOutline>()).Show( "local", color );
		else mark?.Hide( "local" );
	}
}

/// <summary>
/// The outline the per-class augments put on a zombie: Spotter's and Marker's marks (every machine) and High Noon's
/// targets (the shooter's alone). It takes itself off when the marks it is showing have ended.
///
/// ⚠️ IT OWNS ITS `HighlightOutline` AND ONLY THAT ONE. Death Perception's X-Ray Sense outlines zombies too and tracks
/// the ones it made; a zombie that already wears somebody else's outline is left as it is, the rule that file sets.
/// </summary>
public sealed class TechMarkOutline : Component
{
	readonly Dictionary<string, Color> _shown = new();
	HighlightOutline _outline;

	public void Show( string id, Color color )
	{
		_shown[id] = color;
		Refresh();
	}

	public void Hide( string id )
	{
		if ( _shown.Remove( id ) ) Refresh();
	}

	readonly List<string> _gone = new();

	protected override void OnUpdate()
	{
		// ⚠️ A STATUS ENDS ON ITS OWN CLOCK, ON EVERY MACHINE, so the outline asks rather than being told. No allocation:
		// this runs every frame on every marked zombie.
		_gone.Clear();
		foreach ( var id in _shown.Keys )
			if ( id != "local" && !StatusEffects.Has( GameObject, id ) ) _gone.Add( id );

		if ( _gone.Count == 0 ) return;

		foreach ( var id in _gone ) _shown.Remove( id );
		Refresh();
	}

	void Refresh()
	{
		if ( _shown.Count == 0 )
		{
			if ( _outline.IsValid() ) _outline.Destroy();
			_outline = null;
			Destroy();
			return;
		}

		if ( !_outline.IsValid() )
		{
			// ⚠️ SOMEBODY ELSE'S OUTLINE STAYS. See the class note.
			if ( Components.Get<HighlightOutline>( FindMode.EverythingInSelf ).IsValid() ) return;

			WallBuyManager.EnsureHighlight( Scene );
			_outline = Components.Create<HighlightOutline>();
			_outline.InsideColor = Color.Transparent;
			_outline.InsideObscuredColor = Color.Transparent;
			_outline.Width = 0.25f;
		}

		// ⚠️ THE LOCAL MARK WINS, THEN THE LATEST: High Noon's targets are the ones the shooter acts on next.
		var color = _shown.TryGetValue( "local", out var local ) ? local : Last();
		_outline.Color = color;
		_outline.ObscuredColor = color.WithAlpha( 0.5f );
	}

	Color Last()
	{
		var c = Color.White;
		foreach ( var kv in _shown ) c = kv.Value;
		return c;
	}

	protected override void OnDestroy()
	{
		if ( _outline.IsValid() ) _outline.Destroy();
	}
}