Weapons/TechEffects.cs

Static helper for the game's weapon tech system. It resolves who owns a tech node for a given weapon or damage instance, exposes accessors to check ownership, read node factors and extra magnitudes (with an Amplify debug override), serializes a tech stamp onto DamageInfo, and provides console diagnostics (nz_tech_amp, nz_tech_live) to inspect effective vs authored weapon values.

Reflection
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// WEAPON TECH — the one place that answers "does this weapon own that node".
///
/// ⛔ EVERY EFFECT SITE GOES THROUGH HERE. Each of the 41 nodes needs the same three
/// steps — find the owner, find the prefab key, check the list — and writing those
/// inline at 41 call sites would be 41 copies of one lookup. PerkEffects already learned
/// this the expensive way: it had FOUR hand-written copies of its owner lookup before
/// they were collapsed into `OwnerOf`, and the risk is not the duplication itself but
/// that a fix lands on some copies and not others (INSTRUCTIONS.md §3).
///
/// ⚠️ THE FACTOR COMES FROM THE CATALOGUE, NOT FROM THE CALL SITE. `Factor` reads
/// `WeaponTech.Node.Factor`, so tuning a node means editing one number in
/// `WeaponTech.cs` and nothing else. A call site that hardcoded 1.15 would be a second
/// source for a value the catalogue already owns, and the catalogue is what `nz_tech`
/// prints — so they would disagree silently and the printed table would be the liar.
///
/// ⚠️ NOTHING HERE IS CACHED. Tech can be bought mid-round and the weapon is a clone
/// that Pack-a-Punch destroys and respawns, so a cached answer is stale in two
/// different ways. These are cheap list lookups on a list that is at most 41 long.
/// </summary>
public static class TechEffects
{
	/// <summary>
	/// The nZombies player holding a weapon, or null.
	///
	/// ⚠️ `InAncestors | Enabled`, the same mode `Weapon.Reload` and
	/// `PerkEffects.OwnerOf` use. The note in Weapon.Reload explains why the
	/// `EverythingIn...` composites are avoided; do not "simplify" this to one of them.
	/// </summary>
	static NZPlayer OwnerOf( Component weapon )
		=> weapon.IsValid()
			? weapon.Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
			: null;

	/// <summary>Does the owner of this weapon have that node, on THIS weapon.</summary>
	public static bool Has( Component weapon, string nodeId )
	{
		if ( !weapon.IsValid() || string.IsNullOrEmpty( nodeId ) ) return false;

		var player = OwnerOf( weapon );
		if ( !player.IsValid() ) return false;

		// ⚠️ Resolved from the WEAPON, not from whatever the player is holding. With two
		// slots those differ, and reading the active weapon's prefab here would apply the
		// tech from one gun to the other — the trap NZPlayer records having cost a free
		// Pack-a-Punch level three separate times.
		var prefab = Rarity.PrefabOf( weapon as SWB.Base.Weapon );
		if ( string.IsNullOrEmpty( prefab ) ) return false;

		return player.HasTech( prefab, nodeId );
	}

	/// <summary>Does a known player+prefab pair own that node. For the spawn-time path.</summary>
	public static bool Has( NZPlayer player, string prefab, string nodeId )
		=> player.IsValid()
			&& !string.IsNullOrEmpty( prefab )
			&& player.HasTech( prefab, nodeId );

	/// <summary>
	/// Chimera's rolled fire mode for this weapon, or null: no roll on its prefab, or the drawn
	/// name did not parse (`NZPlayer.ChimeraMode` is then absent, meaning "keep the authored mode").
	///
	/// ⛔ READ BY `Weapon.EffectiveFiringType`, AND UNTIL 2026-10-03 NOTHING READ IT. The roll stored
	/// a mode from day one and the gun kept firing its own — the stats card even said so in a note.
	/// Resolved from the WEAPON's prefab, like `Has`, so the other slot's roll never leaks across.
	/// </summary>
	public static SWB.Base.FiringType? ChimeraMode( Component weapon )
	{
		if ( !weapon.IsValid() ) return null;

		var player = OwnerOf( weapon );
		if ( !player.IsValid() || player.ChimeraRolls.Count == 0 ) return null;

		var prefab = Rarity.PrefabOf( weapon as SWB.Base.Weapon );
		if ( string.IsNullOrEmpty( prefab ) ) return null;

		return player.ChimeraRolls.TryGetValue( prefab, out var roll )
			&& roll.TryGetValue( NZPlayer.ChimeraMode, out var mode )
				? (SWB.Base.FiringType)(int)mode
				: null;
	}

	// ══ the relayed hit ═══════════════════════════════════════════════════════════════

	/// <summary>The `DamageInfo.Extra` key the firing weapon's prefab path travels under.</summary>
	public const string WeaponKey = "nz_wep";

	/// <summary>The `DamageInfo.Extra` key the firing weapon's penetration depth travels under.</summary>
	public const string PenetrationKey = "nz_pen";

	/// <summary>
	/// WHOSE TREE AND WHICH WEAPON'S, RESOLVED ONCE AND CARRIED — the form every tech read on
	/// the VICTIM'S side must use, because on the host a client's weapon does not exist.
	///
	/// ⛔ `damage.Weapon` IS NULL FOR EVERY HIT A CLIENT RELAYS, AND THAT SILENTLY DISABLED
	/// EIGHT NODES. Weapon prefabs are `NetworkMode.Never` — `NZPlayer.WorldModelPath` says so
	/// and sends a PATH instead — so the weapon GameObject the shooter names has no counterpart
	/// on the host. `Health.FiredBy` returned null there and `TechEffects.Has` therefore answered
	/// "does not own it" for Hollow Points, Body Shot, Precision Rounds, Deadeye, Wide Bore,
	/// Perforator, Bouncy Rounds and Bounty. Nothing logged and nothing threw; a client just had
	/// eight nodes that quietly did nothing.
	///
	/// ⚠️ THE PREFAB PATH IS THE HANDLE, NOT THE WEAPON, and that is not a workaround — the
	/// prefab path is what tech is keyed by in the first place. `TechOwned`, `PapLevels` and
	/// `RarityTiers` are all keyed on it for the reason `NZPlayer.AddTech` records (the weapon is
	/// a clone Pack-a-Punch destroys and respawns), so it is also the only thing that HAS to
	/// travel. `NZNet.HurtRemote` stamps it into `DamageInfo.Extra`.
	///
	/// ⚠️ IT STILL PREFERS THE REAL WEAPON WHEN THERE IS ONE, so the shooter's own reads —
	/// which is nearly every `TechEffects` call in the project — resolve exactly as before and
	/// never consult the stamp.
	///
	/// ⚠️ THE PLAYER IS THE OTHER HALF AND IT REPLICATES. A `TechRef` off the wire is
	/// (attacker, prefab) with no weapon, and `NZPlayer.TechFor` answers from the synced copy on
	/// a machine that does not own the player. See `NZPlayer.TechNet`.
	/// </summary>
	public readonly struct TechRef
	{
		public TechRef( NZPlayer player, string prefab, SWB.Base.Weapon weapon )
		{
			Player = player;
			Prefab = prefab;
			Weapon = weapon;
		}

		/// <summary>The shooter. Present on both machines — players replicate, weapons do not.</summary>
		public readonly NZPlayer Player;

		/// <summary>The firing weapon's prefab path, which is what tech is keyed by.</summary>
		public readonly string Prefab;

		/// <summary>The firing weapon itself. NULL on the host for a hit a client relayed.</summary>
		public readonly SWB.Base.Weapon Weapon;

		/// <summary>Is there a tree to ask at all.</summary>
		public bool Valid => Player.IsValid() && !string.IsNullOrEmpty( Prefab );

		/// <summary>The shooter's body, for a DamageInfo built downstream of this hit.</summary>
		public GameObject AttackerGo => Player.IsValid() ? Player.GameObject : null;

		/// <summary>The weapon's object, or null where the weapon is not on this machine.</summary>
		public GameObject WeaponGo => Weapon.IsValid() ? Weapon.GameObject : null;

		/// <summary>
		/// The stamp to hang on a DamageInfo this hit produces, so the tech survives one more hop.
		///
		/// ⛔ BOUNCY ROUNDS DEALS DAMAGE OF ITS OWN, AND A LINK THAT CARRIED NEITHER A WEAPON
		/// NOR A STAMP WOULD ARRIVE AT `Health.OnDamage` AS AN ANONYMOUS HIT — no Hollow Points
		/// floor on its limbs and no tree to read if it needed one. On the host the chain runs
		/// with `Weapon` null, so the stamp is the only thing the link can carry.
		/// </summary>
		public Dictionary<string, string> Stamp()
			=> string.IsNullOrEmpty( Prefab )
				? null
				: new Dictionary<string, string> { [WeaponKey] = Prefab };
	}

	/// <summary>
	/// A weapon that IS on this machine — the shooter's own reads.
	///
	/// ⚠️ `as`, NOT A COMPONENT LOOKUP, matching `Has( Component, string )` exactly: a
	/// caller that hands in something which is not an `SWB.Base.Weapon` gets no tree, which is
	/// the answer it got before this overload existed.
	/// </summary>
	public static TechRef Of( Component weapon )
	{
		var wep = weapon as SWB.Base.Weapon;

		return wep.IsValid()
			? new TechRef( OwnerOf( wep ), Rarity.PrefabOf( wep ), wep )
			: default;
	}

	/// <summary>
	/// A hit, from either machine: the weapon when it is here, the relay's stamp when it is not.
	///
	/// ⚠️ `damage.Weapon` IS RELIABLY SET ON THE LOCAL BULLET ROUTE — checked, not assumed.
	/// Both bullet paths build their DamageInfo through `SWB.Shared.DamageInfo.FromBullet`
	/// (BulletInfo.HitScan.cs:69 and PhysicalBullet.Mover.cs:105) and both pass the weapon's
	/// GameObject as its second argument. That is the same funnel that stamps `TagsHelper.Bullet`.
	///
	/// ⚠️ AND NOTHING RESOLVES FOR EVERY OTHER SOURCE, which 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 accessor below returns its `ifAbsent` for an invalid ref.
	///
	/// ⚠️ `EverythingInSelf` on the weapon, not a plain `Get` — trap 2 in INSTRUCTIONS.md. A
	/// weapon component is DISABLED while holstered, and a hit resolved a frame after a swap is
	/// not worth being fragile about.
	/// </summary>
	public static TechRef Of( in Sandbox.DamageInfo damage )
	{
		if ( damage is null ) return default;

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

		if ( wep.IsValid() )
			return new TechRef( OwnerOf( wep ), Rarity.PrefabOf( wep ), wep );

		var extra = (damage as SWB.Shared.DamageInfo)?.Extra;
		if ( extra is null
			|| !extra.TryGetValue( WeaponKey, out var prefab )
			|| string.IsNullOrEmpty( prefab ) ) return default;

		// ⚠️ `EverythingInSelfAndAncestors`, matching `ZombieAI`'s resolve of the same object:
		// the attacker GameObject is whatever the damage named, which is not guaranteed to be the
		// object the NZPlayer component sits on.
		var player = damage.Attacker.IsValid()
			? damage.Attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;

		return new TechRef( player, prefab, null );
	}

	/// <summary>Does the shooter own that node, on the weapon that fired. Both machines.</summary>
	public static bool Has( in TechRef tech, string nodeId )
		=> tech.Valid
			&& !string.IsNullOrEmpty( nodeId )
			&& tech.Player.HasTech( tech.Prefab, nodeId );

	/// <summary>The node's factor when owned, or <paramref name="ifAbsent"/>. Both machines.</summary>
	public static float Factor( in TechRef tech, string nodeId, float ifAbsent = 1f )
	{
		if ( !Has( tech, nodeId ) ) return ifAbsent;

		var node = WeaponTech.Find( nodeId );

		return node is null ? ifAbsent : Amped( node );
	}

	/// <summary>One of a node's named extra magnitudes. Both machines.</summary>
	public static float Mag( in TechRef tech, string nodeId, string name, float ifAbsent = 1f )
		=> Has( tech, nodeId ) ? AmpedMag( nodeId, name, ifAbsent ) : ifAbsent;

	/// <summary>
	/// The firing weapon's penetration depth — Bouncy Rounds' link budget.
	///
	/// ⛔ THE ONLY WEAPON *STAT* THAT TRAVELS, AND THE REASON NOTHING ELSE HAS TO. Every other
	/// relayed node needs one question answered — "does this tree own that id" — and the tree
	/// replicates. This one needs a number authored on the weapon's own ShootInfo, and the weapon
	/// is not on this machine; reading it off the prefab would mean loading a prefab on the damage
	/// path. So the shooter sends it, in the same RPC that already carries the damage.
	///
	/// ⚠️ PRIMARY FIRE, MATCHING THE LOCAL READ IT REPLACES. Every prefab in the pack authors
	/// penetration on primary; a secondary-only pierce stat would read the wrong number, and no
	/// weapon has one.
	/// </summary>
	public static float PenetrationOf( in TechRef tech, in Sandbox.DamageInfo damage )
	{
		if ( tech.Weapon.IsValid() )
			return tech.Weapon.GetShootInfo( true )?.PenetrationDepth ?? 0f;

		var extra = (damage as SWB.Shared.DamageInfo)?.Extra;

		return extra is not null
			&& extra.TryGetValue( PenetrationKey, out var s )
			&& float.TryParse( s, System.Globalization.NumberStyles.Float,
				System.Globalization.CultureInfo.InvariantCulture, out var pen )
			? pen
			: 0f;
	}

	/// <summary>
	/// Exaggerate every node's effect, for testing. 1 is normal.
	///
	/// ⛔ THE POINT IS THAT A PLAUSIBLE NUMBER CANNOT BE JUDGED BY EYE. -20% recoil and
	/// stock recoil look identical, so "is this node wired" is unanswerable by feel at
	/// its real magnitude. At x10 the answer takes one magazine. This is the instrument
	/// that proved Double Tap, Vigor Rush and Deadshot were applied to NOTHING while
	/// their stat panel displayed dutifully perked numbers.
	///
	/// ⚠️ A STATIC, SO IT SURVIVES HOTLOAD and stays on until switched off — the right
	/// trade for a test override, which is useless if a code edit silently resets it, but
	/// `nz_tech_amp` prints the state loudly for exactly that reason (§1).
	/// </summary>
	public static float Amplify { get; set; } = 1f;

	/// <summary>
	/// Is this node's factor a MULTIPLIER, and therefore safe to exaggerate.
	///
	/// ⛔ NOT EVERY FACTOR IS A MULTIPLIER, and treating them alike would produce
	/// nonsense rather than an exaggeration. `t2_clip_flat` stores 4 meaning "+4 rounds";
	/// raised to the tenth power that is a million-round magazine. `t3_fabricator` stores
	/// 60 meaning seconds. `t3_recovery` and `t3_deploy` store 0 meaning "set to zero",
	/// which no exponent changes.
	///
	/// ⚠️ DECIDED FROM THE `Lever` STRING, which already documents what kind of lever
	/// each node drives — rather than a second field to keep in step with it. If a Lever
	/// is ever reworded, this reads FALSE and the node is reported as SKIPPED rather than
	/// silently mangled. Failing loudly was the design goal; a heuristic that guesses
	/// wrong in the amplifying direction would look like a bug in the node.
	/// </summary>
	/// <summary>How a node's factor behaves, and therefore how to exaggerate it.</summary>
	public enum FactorKind
	{
		/// <summary>A multiplier. Exaggerated by raising to a power.</summary>
		Multiplier,

		/// <summary>An addend (+4 rounds, +50 RPM, +10 points). Exaggerated linearly.</summary>
		Flat,

		/// <summary>A target value, a duration or a set-to-zero. Not exaggerated at all.</summary>
		Absolute,
	}

	/// <summary>
	/// Which kind a node's factor is.
	///
	/// ⛔ THE THREE KINDS NEED THREE DIFFERENT EXAGGERATIONS, and using one rule for all
	/// of them produces nonsense rather than a bigger effect:
	///
	///   Multiplier  1.15 -> pow, so x4.05. Scaling the deviation would send a factor
	///               BELOW one negative (0.80 becomes -1.0, firing the kick downward).
	///   Flat        +4 rounds -> x10 linearly, so +40. `pow` would be 4^10, a
	///               million-round magazine.
	///   Absolute    Long Barrel's 0.75 is a FLOOR, Fabricator's 60 is SECONDS, and
	///               Stabilizer's 0 means "set to zero". None of those has a meaningful
	///               tenfold. A 7.5 damage floor is not an exaggeration, it is garbage.
	///
	/// ⚠️ DECIDED FROM THE `Lever` STRING, which already documents what each node drives
	/// — rather than a second field to keep in step with it. If a Lever is reworded this
	/// falls back to Absolute, so the node is reported as NOT amplified rather than
	/// silently mangled. A heuristic guessing wrong in the amplifying direction would look
	/// like a bug in the node itself, which is the expensive failure.
	/// </summary>
	public static FactorKind KindOf( WeaponTech.Node node )
	{
		if ( node is null || node.Factor <= 0f ) return FactorKind.Absolute;

		if ( node.Lever.Contains( "flat" ) ) return FactorKind.Flat;

		if ( node.Lever.Contains( "floor" )
			|| node.Lever.Contains( "timer" )
			|| node.Lever.Contains( "= 0" ) ) return FactorKind.Absolute;

		return FactorKind.Multiplier;
	}

	static bool CanAmplify( WeaponTech.Node node )
		=> KindOf( node ) != FactorKind.Absolute;

	/// <summary>
	/// Apply <see cref="Amplify"/> to a multiplier.
	///
	/// ⛔ `pow`, NOT A SCALED DEVIATION. The obvious `1 + (f - 1) * amp` works for a
	/// factor above 1 and BREAKS every factor below it: Recoil Control's 0.80 becomes
	/// 1 - 0.2*10 = -1.0, a NEGATIVE recoil multiplier that would fire the kick downward.
	///
	/// Raising to a power is symmetric and cannot cross zero — it treats "ten times the
	/// effect" as applying the node ten times over, which is what the phrase means
	/// multiplicatively. 1.15 becomes 4.05; 0.80 becomes 0.107; 1.0 stays 1.0.
	///
	/// ⛔ AND THE RESULT IS CLAMPED TO [0.01, 10], WHICH IS NOT COSMETIC. `pow` is right
	/// for the SUBTLE factors this instrument exists for, and absurd for the loud ones:
	/// Wide Bore's x5 raised to the tenth is x9,765,625, and Bolt Gun's x4 is x1,048,576.
	/// Those are not exaggerations, they are overflow with extra steps — a damage figure
	/// like that would clamp against zombie health and read as "the node does nothing",
	/// which is the exact false negative this command exists to rule out.
	///
	/// ⚠️ The clamp also means a tier-4/5 node barely moves under amplification, and
	/// that is correct: those nodes are already dramatic. x4 damage needs no help being
	/// visible. The instrument is for the tier-1 magnitudes that look identical to stock.
	/// </summary>
	static float Amped( WeaponTech.Node node )
	{
		var f = node.Factor;
		if ( Amplify == 1f ) return f;

		return KindOf( node ) switch
		{
			// ⚠️ Linear, and NOT clamped to 10 — the clamp exists to stop a multiplier
			// overflowing, and an addend of +40 rounds or +500 RPM is exactly the absurd
			// value the instrument is for.
			FactorKind.Flat => f * Amplify,

			FactorKind.Multiplier => MathF.Pow( f, Amplify ).Clamp( 0.01f, 10f ),

			_ => f,
		};
	}

	/// <summary>
	/// Amplify a NAMED SECONDARY MAGNITUDE, the way <see cref="Amped"/> amplifies a
	/// node's primary factor.
	///
	/// ⛔ THIS EXISTED NOWHERE AND THAT WAS A REAL BUG, reported from play as "Tuned
	/// Action is not affecting fire rate". It was: at `nz_tech_amp 10` its DAMAGE half
	/// (the primary `Factor`) became x9.3 while its FIRE RATE half (a secondary magnitude)
	/// stayed at x1.10 — so the damage was unmistakable and the fire rate was invisible,
	/// and the node read as half-wired. Every tier-4 and tier-5 node with more than one
	/// magnitude had the same hole.
	///
	/// ⚠️ THE THREE ACCESSORS NOW HAVE ONE RULE EACH, and the split is deliberate:
	///   `Factor`   an effect  -> AMPLIFIED
	///   `MagOf`    an effect  -> AMPLIFIED (this method)
	///   `BoundOf`  a LIMIT    -> NEVER amplified. Boat Tail's 2.0 is a safety rail that
	///              stops a mistuned factor making a gun hit harder at any distance;
	///              scaling a safety cap by ten is the one thing it must not do.
	/// Tier 4 read its secondary magnitudes through `BoundOf`, which is what hid this — a
	/// cap accessor doing a magnitude's job inherits the cap's "do not amplify" rule.
	/// </summary>
	public static float AmpMag( float value, bool multiplier )
	{
		if ( Amplify == 1f ) return value;

		// ⛔ A NON-MULTIPLIER MAG IS NOT AMPLIFIED, and this had to be settled because the
		// two halves disagreed. My first version scaled it linearly; the report line below
		// already said "(fixed — a count or a distance has no meaningful tenfold)". The
		// display was right: every `Multiplier: false` mag in the catalogue today is a COUNT
		// or a DURATION — a burst length of 10, a fabrication interval in seconds — and a
		// hundred-round burst is not an exaggeration of a ten-round one, it is a different
		// node. So the flag means "amplifiable" as well as "multiplicative", and the
		// alternative was an instrument whose printout contradicted its own effect.
		//
		// ⚠️ A genuinely additive-and-amplifiable magnitude would need a third kind. There
		// is none today; the primary `Factor` covers that case via `FactorKind.Flat`.
		if ( !multiplier ) return value;

		// ⚠️ Clamped for the reason Amped is: `pow` is right for subtle values and absurd
		// for loud ones, and an overflowed multiplier reads in play as "the node does
		// nothing" — the precise false negative this command exists to rule out.
		return MathF.Pow( value, Amplify ).Clamp( 0.01f, 10f );
	}

	/// <summary>
	/// The node's factor when owned, or <paramref name="ifAbsent"/> when not.
	///
	/// ⚠️ `ifAbsent` DEFAULTS TO 1, which is the neutral value for a multiply — the shape
	/// almost every site wants: `x *= TechEffects.Factor( this, "t1_recoil" )`. A site
	/// that ADDS rather than multiplies must pass 0 explicitly, because a silent 1 would
	/// add one unit of something on every weapon that has not bought the node.
	///
	/// ⚠️ Amplification is applied HERE, so every one of the 41 nodes inherits it from
	/// the single accessor they all share. Amplifying at the call sites would mean 41
	/// places to remember.
	/// </summary>
	public static float Factor( Component weapon, string nodeId, float ifAbsent = 1f )
	{
		if ( !Has( weapon, nodeId ) ) return ifAbsent;

		var node = WeaponTech.Find( nodeId );
		if ( node is null ) return ifAbsent;

		return Amped( node );
	}

	/// <summary>Factor for a known player+prefab pair. For the spawn-time path.</summary>
	public static float Factor( NZPlayer player, string prefab, string nodeId, float ifAbsent = 1f )
	{
		if ( !Has( player, prefab, nodeId ) ) return ifAbsent;

		var node = WeaponTech.Find( nodeId );
		if ( node is null ) return ifAbsent;

		return Amped( node );
	}

	/// <summary>
	/// One of a node's NAMED extra magnitudes when this weapon owns it, or
	/// <paramref name="ifAbsent"/> when it does not.
	///
	/// ⛔ THIS IS THE ONLY WAY A SECOND MAGNITUDE SHOULD REACH A CALL SITE, and the reason
	/// is `Factor`'s own failure mode written down one level up: every tier-5 node spends
	/// `Factor` on the number its NAME is about, so `Factor( "t5_railgun" )` at the fire-rate
	/// site returns 1.5 and makes the slowest gun in the tier faster. A name cannot be
	/// mistaken for the wrong number.
	///
	/// ⚠️ AMPLIFIED HERE, LIKE `Factor`, which is what closes the gap tier 4 reported: its
	/// seven secondary magnitudes were `const`s in `NZPlayer.cs` and `nz_tech_amp` could
	/// reach none of them, so amplifying Drum Magazine exaggerated its clip and left its
	/// walk penalty at the real value. Anything read through here amplifies with everything
	/// else — unless the `Mag` declares itself fixed, which counts and radii do.
	///
	/// ⚠️ `ifAbsent` DEFAULTS TO 1 for the same reason `Factor`'s does, and the same warning
	/// applies twice over: an ADDITIVE site must pass 0, and it is also the value used when
	/// the node is owned but the catalogue declares no such name — which warns, once, inside
	/// <see cref="WeaponTech.MagOf"/>.
	/// </summary>
	public static float Mag( Component weapon, string nodeId, string name, float ifAbsent = 1f )
		=> Has( weapon, nodeId ) ? AmpedMag( nodeId, name, ifAbsent ) : ifAbsent;

	/// <summary>A named extra magnitude for a known player+prefab pair. Spawn-time path.</summary>
	public static float Mag( NZPlayer player, string prefab, string nodeId, string name,
		float ifAbsent = 1f )
		=> Has( player, prefab, nodeId ) ? AmpedMag( nodeId, name, ifAbsent ) : ifAbsent;

	static float AmpedMag( string nodeId, string name, float neutral )
	{
		var mag = WeaponTech.MagFor( nodeId, name );

		// ⚠️ Through MagOf on the miss so the "no such magnitude" warning is printed in one
		// place rather than two.
		if ( mag is null ) return WeaponTech.MagOf( nodeId, name, neutral );

		if ( Amplify == 1f || !mag.Multiplier ) return mag.Value;

		// ⚠️ The same `pow` and the same clamp `Amped` uses on a node's Factor — see its
		// comment for why a scaled deviation sends a factor below one negative.
		return MathF.Pow( mag.Value, Amplify ).Clamp( 0.01f, 10f );
	}

	/// <summary>
	/// How fast this weapon comes up to the eye — Quickdraw's bonus and Emplacement's
	/// penalty, as one number. 1 is the authored rate; bigger is faster.
	///
	/// ⛔ IT IS A SHARED ACCESSOR BECAUSE THE ADS RATE HAS THREE CALL SITES AND TWO OF THEM
	/// HAVE ALREADY BEEN MISSED ONCE. `ViewModelHandler` drives the pose rate and the
	/// viewmodel FOV, `PlayerCameraHandler` drives the world zoom, and the comment above the
	/// latter is a record of Quickdraw being wired in one file and forgotten in the other for
	/// a build — it asks in writing for exactly this accessor if a second node ever appears.
	/// Emplacement is that node, and at x0.25 a one-sided miss desyncs the gun pose from the
	/// world zoom by FOUR times, where Quickdraw's 1.2 was barely visible.
	///
	/// ⚠️ `Has` + `Mag`, NEVER `Factor`, for Emplacement: its `Factor` is the x3 DAMAGE, so
	/// `Factor` here would make the emplacement gun aim three times FASTER.
	/// </summary>
	public static float AdsSpeedFactor( Component weapon )
		=> Factor( weapon, "t1_ads" ) * Mag( weapon, "t4_emplacement", "ads" )
			// ⚠️ the per-class augments' aiming speed (2026-10-04): Scout x1.7, Quickscope x1.8, Anti-Materiel x0.8…
			* TechStats.Mul( weapon, "s.ads" );

	/// <summary>
	/// `nz_tech_amp [n]` — exaggerate every node's effect n-fold. 1 restores normal.
	///
	/// ⚠️ PRINTS WHAT EACH NODE BECOMES, not just the setting, because the whole point
	/// is to know what to look for before you fire. And it names the nodes it CANNOT
	/// amplify rather than leaving them looking broken.
	/// </summary>
	[ConCmd( "nz_tech_amp" )]
	public static void AmpCmd( float amount = -1f )
	{
		if ( amount > 0f ) Amplify = amount.Clamp( 1f, 20f );

		if ( Amplify == 1f )
		{
			Log.Info( "[nz-tech-amp] normal — every node at its real magnitude. "
				+ "`nz_tech_amp 10` to exaggerate." );
			return;
		}

		Log.Info( $"[nz-tech-amp] AMPLIFY x{Amplify:0.##} — every multiplier raised to that "
			+ "power. ⚠ this is a static and survives hotload; `nz_tech_amp 1` to stop." );

		var skipped = new System.Collections.Generic.List<string>();

		foreach ( var tier in WeaponTech.Tiers )
		{
			foreach ( var node in tier.Pool )
			{
				if ( CanAmplify( node ) )
				{
					// ⚠️ A flat node reads as "+4 -> +40", not "x4 -> x40". Printing a plus as
					// a times is how someone concludes a magazine node is broken when it is
					// working exactly as stated.
					var sign = KindOf( node ) == FactorKind.Flat ? "+" : "x";

					Log.Info( $"[nz-tech-amp]   T{tier.Index} {node.Name,-18}"
						+ $" {sign}{node.Factor:0.###} -> {sign}{Amped( node ):0.###}" );
				}
				else skipped.Add( node.Name );

				// ⛔ THE EXTRA MAGNITUDES ARE LISTED TOO, AND THAT IS THE POINT OF `Mag`
				// EXISTING. Tier 4 reported the gap from the other side: its secondary
				// numbers were `const`s in NZPlayer.cs, so amplifying Drum Magazine
				// exaggerated the magazine it prints here and left the walk penalty at its
				// real value — which reads as "half the node is not wired".
				//
				// ⚠️ Printed even for a node whose own Factor cannot be amplified, because
				// the two questions are independent: Chimera's Factor is a neutral 1 and
				// Stabilizer's is a set-to-zero, but either could still carry a live second
				// magnitude.
				//
				// ⚠️ AND A FIXED MAG PRINTS WITHOUT THE `x`, for the reason the flat-node
				// sign above exists: "x10" beside a burst length reads as a tenfold burst
				// rather than a burst of ten.
				foreach ( var m in node.Extra ?? System.Array.Empty<WeaponTech.Mag>() )
					Log.Info( $"[nz-tech-amp]   T{tier.Index}   {node.Name,-16}"
						// ⚠️ CALLS AmpMag RATHER THAN REPEATING ITS FORMULA. This line
						// inlined `pow(...).Clamp(0.01, 10)`, which is the §3 shape: the
						// display and the effect computing the same thing separately, so a
						// change to one silently stops matching the other. The printed
						// number now cannot disagree with what the gun does.
						+ (m.Multiplier
							? $" {m.Name,-8} x{m.Value:0.###}"
								+ $" -> x{AmpMag( m.Value, true ):0.###}"
							: $" {m.Name,-8} {m.Value:0.###}"
								+ " (fixed — a count or a distance has no meaningful tenfold)") );
			}
		}

		if ( skipped.Count > 0 )
			Log.Info( $"[nz-tech-amp]   NOT amplified ({skipped.Count}) — floors, timers and "
				+ $"set-to-zero nodes have no meaningful tenfold: {string.Join( ", ", skipped )}" );
	}

	// ── diagnostics ──────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_tech_live` — what the held weapon's tech is ACTUALLY doing to it.
	///
	/// ⛔ READS THE GUN, NOT THE OWNED LIST. `nz_tech_owned` already prints what was
	/// bought; that is DATA. This prints what the gun IS, which is the WORLD, and §13 is
	/// the rule that changing one is not changing the other. A node can be owned, and
	/// priced, and listed, and still be wired to nothing — which is exactly how Double
	/// Tap, Vigor Rush and Deadshot survived for months while their stat panel agreed
	/// with them.
	///
	/// ⚠️ IT PRINTS TWO LINES ON PURPOSE — `authored` (the prefab's stored fields) and
	/// `effective` (what GetRealSpread and GetRealRPM actually return). Roughly half the
	/// nodes never touch a stored field, so one line alone would report a working node as
	/// dead. See the note at the second Log.Info for why that failure mode is the more
	/// expensive one.
	/// </summary>
	[ConCmd( "nz_tech_live" )]
	public static void Live()
	{
		var scene = Game.ActiveScene;
		if ( scene is null )
		{
			Log.Info( "[nz-tech] no active scene — press Play first" );
			return;
		}

		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player" ); return; }

		var wep = Rarity.HeldBy( p );
		if ( !wep.IsValid() ) { Log.Info( "[nz-tech] no weapon in hand" ); return; }

		var prefab = Rarity.PrefabOf( wep );
		var si = wep.Primary;
		if ( si is null ) { Log.Warning( "[nz-tech] no Primary ShootInfo" ); return; }

		Log.Info( $"[nz-tech-live] {wep.DisplayName}"
			+ $"  clip {si.ClipSize}  rpm {si.RPM}  dmg {si.Damage:0.#}"
			+ $"  pellets {si.Bullets}" );

		// ⛔ THE COMPUTED VALUES, NOT ONLY THE AUTHORED FIELDS. Roughly half the nodes
		// never touch a stored field at all — they scale at the point of READ, inside
		// GetRealSpread or GetRealRPM or FinishRecoil. Printing `si.SpreadAddHipFire`
		// alone would therefore show an UNCHANGED number on a weapon whose Point Shooting
		// is working perfectly, and a reader would conclude the node was dead.
		//
		// That is the §2 trap running backwards: a measurement that reports failure on
		// working code is as useless as one that reports success on broken code, and it is
		// more expensive, because it sends someone to fix what is not broken. So both are
		// printed side by side and labelled: `authored` is what the prefab says,
		// `effective` is what the gun actually uses this frame.
		Log.Info( $"[nz-tech-live]   authored: hipspread {si.SpreadAddHipFire:0.###}"
			+ $"  recoilUp {si.RecoilUp:0.##}"
			+ $"  recovery {si.RecoilRecoveryTime:0.##}"
			+ $"  falloffMult {si.FalloffMultiplier:0.##}"
			+ $"  pen {si.PenetrationDepth:0.#}" );

		// ⚠️ GetRealSpread and GetRealRPM are the SAME methods the bullets go through, so
		// these two figures cannot disagree with what the gun does — which is the whole
		// point. GetRealSpread returns 0 with no valid Owner, so a machine holding the
		// weapon will read zero rather than lying.
		Log.Info( $"[nz-tech-live]   effective: spread {wep.GetRealSpread( si.Spread ):0.####}"
			+ $"  shot interval {wep.GetRealRPM( si.RPM ):0.####}s"
			+ $"  ({(wep.GetRealRPM( si.RPM ) > 0f ? 60f / wep.GetRealRPM( si.RPM ) : 0f):0} rpm)" );

		// ⛔ THE FIRE-MODE ROW, AND IT IS NOT POLISH. FIVE nodes write `FiringType` and a
		// sixth draws it — Micro-Burst, Bolt Gun, Overclocked, Ricochet Rounds, Ten-Round
		// Burst and Chimera — and before this line NOTHING in the project could observe any
		// of them: neither this command nor `WeaponStatsPanel` printed `FiringType`, let
		// alone `EffectiveFiringType`. A read-time enum leaves no trace in either row above,
		// so all six were unfalsifiable by every instrument we own.
		//
		// ⚠️ AUTHORED AND EFFECTIVE SIDE BY SIDE, the same reason the two rows above are:
		// Micro-Burst converts a weapon at read time and leaves the authored field alone, so
		// printing one of them would report a working node as dead. `burst(N)` because the
		// length is a node's magnitude too — 3 authored, 2 for Micro-Burst, 10 for Ten-Round
		// — and a burst of the wrong length looks exactly like a burst.
		var mode = wep.EffectiveFiringType( si );

		Log.Info( $"[nz-tech-live]   fire mode: authored {si.FiringType}"
			+ $"  effective {mode}"
			+ (mode == SWB.Base.FiringType.burst
				? $"({wep.BurstRoundsFor( si )})  ramp x{wep.BurstDamageRamp( si ):0.###}"
				: "") );

		// ⛔ READS `NZAmmo`, NOT `NZWeapon`. This line printed
		// `NZWeapon.ReserveAmmo` and would therefore have shown BLANK on every real
		// weapon — NZWeapon is the legacy placeholder gun and is on none of the 31
		// prefabs. A diagnostic printing an empty field is worse than one printing
		// nothing at all: it reads as "the value is zero", which is a wrong answer
		// rather than a missing one.
		//
		// ⚠️ `EverythingInSelf` because a HOLSTERED weapon is a DISABLED component and
		// the default Get skips those — the trap NZPlayer records having cost a bug three
		// separate times.
		var ammo = wep.Components.Get<NZAmmo>( FindMode.EverythingInSelf );

		Log.Info( $"[nz-tech-live]   reload {wep.ReloadTime:0.##}s"
			+ $"  draw {wep.DrawTime:0.##}s"
			+ $"  reserve {(ammo.IsValid() ? $"{ammo.Reserve}/{ammo.MaxReserve}" : "no NZAmmo")}"
			+ $"  adsStrafe {p.AdsSpeedMultiplier:0.##}" );

		// ⛔ THE DAMAGE CURVE, MEASURED AT REAL DISTANCES. "Damage still decreases with
		// range" cannot be diagnosed from `FalloffMultiplier` alone, because THREE fields
		// decide the curve and two of them are distances: below `FalloffStart` the
		// multiplier is not applied at all, and it only reaches its full value at
		// `FalloffEnd`. A multiplier of 1.25 sitting behind a start of 1755 units is a
		// bonus that exists and can never be observed.
		//
		// ⚠️ CALLS THE REAL `DamageFor`, the same method both bullet paths use, so these
		// figures cannot disagree with what a shot actually deals. Passing null hitTags
		// deliberately excludes the head and limb multipliers — this row is about RANGE.
		//
		// ⚠️ 1 unit = 1 inch, so the metre labels are the distances a player can judge.
		// A rising row means Boat Tail is working; a falling row means falloff is intact.
		Log.Info( $"[nz-tech-live]   falloff band: start {si.FalloffStart:0}u"
			+ $" ({si.FalloffStart * 0.0254f:0.#}m)  end {si.FalloffEnd:0}u"
			+ $" ({si.FalloffEnd * 0.0254f:0.#}m)  mult x{si.FalloffMultiplier:0.###}" );

		var probe = new[] { 0f, 5f, 10f, 20f, 40f, 80f };
		var curve = new System.Collections.Generic.List<string>();

		foreach ( var metres in probe )
		{
			var units = metres / 0.0254f;
			curve.Add( $"{metres:0}m {si.DamageFor( units, null ):0.#}" );
		}

		Log.Info( $"[nz-tech-live]   damage by range: {string.Join( "  ", curve )}" );

		// ⚠️ WeaponTuning.Apply runs on DEPLOY, i.e. AFTER ApplyStoredUpgrades has
		// stamped the tech on — so a saved override on any of these three fields silently
		// wins over the node. Printing the override list here is cheaper than discovering
		// that from behaviour.
		var over = WeaponTuning.Overrides( wep );
		var clash = new System.Collections.Generic.List<string>();

		foreach ( var kv in over )
			// ⛔ `Damage` AND `Bullets` WERE MISSING FROM THIS LIST, and they are the two
			// fields tier 4 writes most. Six tier-4 nodes scale Damage and two rewrite
			// Bullets; both are `[Property]` numerics on ShootInfo and therefore valid
			// `nz_wep_set` targets, and `WeaponTuning.Apply` runs from Weapon.OnEnabled on
			// EVERY deploy — after ApplyStoredUpgrades. So a saved Damage override silently
			// reverted Tuned Action, All-Rounder, Scattergun and Slug Loader on the next
			// weapon switch, and a saved Bullets override reverted Scattergun's pellets and
			// Slug Loader's single slug, with NO warning from the one diagnostic built to
			// catch exactly this.
			//
			// ⛔ AND `Reload` WAS MISSING FROM BOTH THE LIST AND THE ENUMERATION ABOVE IT,
			// while `ReloadTech` has always written FIVE reload fields — ReloadTime,
			// ReloadEmptyTime and the three ShellReload times. Last Resort's x5 and Drum
			// Magazine's x2 were therefore silently revertible by a saved override, on
			// exactly the shell-reloading shotguns those nodes are aimed at, with no warning
			// from the diagnostic built to catch it. Tier 5 adds a sixth reader (Chimera's
			// reload axis), which is what made the omission worth finding.
			//
			// ⚠️ Enumerated rather than guessed: the fields tech writes are Damage, RPM,
			// ClipSize, Bullets, FalloffStart, FalloffEnd, FalloffMultiplier,
			// PenetrationDepth, Penetration, RecoilRecoveryTime, RecoilUp, SpreadAddHipFire
			// and the five Reload/ShellReload durations. Every one is matched by a substring
			// below.
			if ( kv.Key.Contains( "Falloff" ) || kv.Key.Contains( "Clip" )
				|| kv.Key.Contains( "RPM" ) || kv.Key.Contains( "Penetration" )
				|| kv.Key.Contains( "Recoil" ) || kv.Key.Contains( "Spread" )
				|| kv.Key.Contains( "Damage" ) || kv.Key.Contains( "Bullets" )
				|| kv.Key.Contains( "Reload" ) )
				clash.Add( $"{kv.Key}={kv.Value:0.###}" );

		if ( clash.Count > 0 )
			Log.Warning( $"[nz-tech-live]   ⛔ WeaponTuning OVERRIDES a field tech writes, and "
				+ $"it runs AFTER on every deploy: {string.Join( ", ", clash )}"
				+ " — `nz_wep_reset` clears them" );

		var owned = p.TechFor( prefab );

		// ⛔ THE ROLL ROW IS THE ONLY FALSIFIER CHIMERA HAS, WHICH IS WHY IT IS HERE AND NOT
		// IN THE UI BACKLOG. The node's median roll is deliberately WORSE than the gun it
		// replaces, so "did the substitution happen" cannot be answered by feel: a weapon
		// that got worse is the expected outcome, and so is a weapon that changed nothing on
		// the axes that drew their own values back. Printing the drawn numbers beside the
		// `authored` row above turns that into a comparison anyone can make in one line.
		//
		// ⚠️ `mode` is stored as the ENUM'S INT in a float store — see NZPlayer's roll
		// dictionary for why one store rather than two — so it is printed back as the enum.
		if ( p.ChimeraRolls.TryGetValue( prefab, out var roll ) )
			Log.Info( "[nz-tech-live]   chimera roll: " + string.Join( "  ", roll
				.Where( kv => kv.Key != NZPlayer.ChimeraRolled )
				.Select( kv => kv.Key == NZPlayer.ChimeraMode
					? $"mode {(SWB.Base.FiringType)(int)kv.Value}"
					: $"{kv.Key} {kv.Value:0.###}" ) ) );

		else if ( owned.Contains( "t5_chimera" ) )
			// ⛔ OWNED WITH NO ROLL IS A REAL FAILURE STATE, not a missing row: the roll
			// happens in AddTech and nowhere else, so this means the node was recorded by
			// some path that bypassed it — or the pool returned nothing to draw from.
			Log.Warning( "[nz-tech-live]   ⛔ Chimera is OWNED and NO ROLL IS STORED — the"
				+ " weapon is running its authored stats. The roll happens in NZPlayer.AddTech;"
				+ " something recorded the node without going through it." );

		if ( owned.Count == 0 )
		{
			Log.Info( "[nz-tech-live]   no tech owned on this weapon" );
			return;
		}

		foreach ( var id in owned )
		{
			var node = WeaponTech.Find( id );
			if ( node is null ) continue;

			Log.Info( $"[nz-tech-live]   owned: {node.Name,-18} x{node.Factor:0.###}"
				+ $"  -> {node.Lever}" );
		}

		Log.Info( "[nz-tech-live]   ⚠ owning a node is not proof it is wired. Read-time "
			+ "nodes move the `effective` line and leave `authored` alone; spawn-time nodes "
			+ "(clip, reserve) move `authored`. A node that moves NEITHER is not wired." );
	}
}