Weapons/WeaponTech.cs

Static catalogue for the weapon tech tree. Declares records (Mag, Node, Tier) and arrays describing five tiers of purchasable weapon-tech nodes, with detailed design comments about each node's intent, levers, and interactions; no runtime logic or external I/O.

File Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE WEAPON TECH TREE — the catalogue only.
///
/// ⚠️ DATA, NOT EFFECTS. Nothing reads `Factor` yet and nothing applies these nodes;
/// this file registers what the tree IS so the tiers can be designed and argued about
/// before any of them is wired. `PerkRegistry` did exactly this for the perk roster and
/// says so in its own header — a catalogue that admits it is a catalogue is honest,
/// whereas 41 stub effect functions would look like a tech tree that merely does not
/// work.
///
/// ⛔ A PROPERTY RETURNING A NEW ARRAY, NOT A `static readonly` COLLECTION WITH CONTENT
/// IN ITS INITIALISER. Static initialisers do not re-run on hotload, so a table built
/// that way can never be corrected in a live session — INSTRUCTIONS.md §1, seven
/// occurrences and the most expensive pattern in this project.
///
/// Structure, from the original (`weapons/sh_weptech.lua`) with pool sizes decided
/// here: five tiers, each with a POOL you may take `Picks` from. Pool > Picks always,
/// so choices are exclusive and the tree forces specialisation.
///
/// ⛔ NO LADDER (2026-10-03). The original locks each tier until the one below it is
/// maxed. Here any tier can be bought into at any time, in any order. The user: *"from
/// now on the players should be able to apply any tier of weapon tech without having
/// to unlock the previous one"*. Each tier keeps its own pick limit and its own price.
///
/// ⚠️ PER WEAPON CLASS, keyed on prefab path when it is wired — the same key
/// `RarityTiers` and `PapLevels` use, and for the same reason: the weapon is a clone
/// that Pack-a-Punch destroys and respawns.
/// </summary>
public static class WeaponTech
{
	/// <summary>
	/// One buyable node.
	///
	/// ⚠️ `Factor` IS EXPRESSED AGAINST OUR OWN FIELD, not against the human-readable
	/// effect, and the two can point opposite ways. "−10% reload time" is a factor of
	/// 1.11 because `PerkEffects.ReloadMultiplier` is a SPEED where higher is faster,
	/// while "−20% recoil" is 0.80 because recoil is a magnitude where smaller is
	/// better. Storing the human number and converting at the point of use is how the
	/// Deadshot aim-speed reciprocal became a trap worth a comment block; this stores
	/// the number the wiring will actually multiply by.
	///
	/// `Lever` names the field that factor belongs on, so wiring is mechanical and
	/// reviewable. It is a note today, not a lookup.
	///
	/// ⛔ `Bound` EXISTS BECAUSE ONE NUMBER WAS NOT ENOUGH, AND THE PROOF WAS A HARDCODED
	/// LITERAL. Boat Tail spends `Factor` on its +0.5 and still needs a 1.25 CEILING, so
	/// the wiring had to write `1.25f` inline — the exact thing this record exists to
	/// prevent, since the catalogue is what `nz_tech` prints and a literal in a call site
	/// is a second source the printed table cannot see.
	///
	/// ⚠️ DEFAULTS TO 0, MEANING "no bound", so every existing node is unchanged and a
	/// node that needs no second number declares nothing. Read it through
	/// <see cref="BoundOf"/> with an explicit fallback rather than testing for 0 at the
	/// call site — 0 is a legitimate bound for some future node.
	///
	/// ⚠️ NOT EVERY SECOND NUMBER BELONGS HERE. Hollow Points also needs two values
	/// (limbs to 1.0, extremities to 0.75) and deliberately does NOT use `Bound`: Health
	/// derives the extremity target from the zombie's own authored limbs, which stays
	/// correct on a variant that authors non-default ones. A literal 0.75 in the
	/// catalogue would be a worse answer that merely looked more authoritative.
	/// </summary>
	/// <summary>
	/// One EXTRA magnitude a node needs beyond <c>Factor</c> and <c>Bound</c> — a NAME and
	/// a number.
	///
	/// ⛔ IT EXISTS BECAUSE TIER 5 BROKE THE TWO-SLOT LIMIT, and the alternative was worse
	/// than a new field. Emplacement alone needs FIVE numbers (damage, magazine, walk,
	/// sprint, ADS rate) across four files, and `Node` can hold two — so the tier-4
	/// precedent (`Bound` for one of them, named constants in `NZPlayer.cs` for the rest)
	/// would have put eleven nodes' worth of magnitudes somewhere `nz_tech` cannot print
	/// and `nz_tech_amp` cannot reach. Tier 4 reported that as a gap with seven constants;
	/// tier 5 would have made it twenty.
	///
	/// ⛔ NAMED, NOT POSITIONAL, AND THAT IS THE WHOLE POINT. `Bound` means a different
	/// thing on every node that declares one — a ceiling on Boat Tail, a fire rate on Tuned
	/// Action, a reload on Drum Magazine — so a call site reading the wrong node's `Bound`
	/// gets a plausible number rather than an error. That is the same failure as reading
	/// `Factor` at a non-damage site, which `TechEffects` records costing a bug. A name
	/// cannot be borrowed by accident.
	///
	/// ⚠️ `Multiplier` DECIDES WHETHER `nz_tech_amp` MAY EXAGGERATE IT, and it is a flag
	/// here rather than inferred from prose the way <see cref="TechEffects.KindOf"/> reads
	/// `Lever` — there is no sentence to read. A count (ten rounds a burst) and a distance
	/// (a ten-unit trace radius) have no meaningful tenfold; a multiplier does.
	/// </summary>
	public record Mag( string Name, float Value, bool Multiplier = true );

	/// <param name="Extra">
	/// Named secondary magnitudes, read through <see cref="MagOf"/> — see <see cref="Mag"/>.
	/// Empty on every tier-1..4 node, which is why it is last and variadic: nothing above
	/// tier 5 changes shape.
	/// </param>
	public record Node( string Id, string Name, string Effect, string Lever, float Factor,
		float Bound = 0f, params Mag[] Extra );

	/// <summary>One tier: its pool, how many you may take, and the price per node.</summary>
	public record Tier( int Index, string Name, int Picks, int Cost, Node[] Pool );

	/// <summary>
	/// Every tier, in order.
	///
	/// ⚠️ THE UNDESIGNED TIERS ARE DECLARED WITH EMPTY POOLS ON PURPOSE. Their picks
	/// and costs are settled; only the node lists are still being written, one tier at a
	/// time. An empty pool is visibly unfinished, whereas omitting the tiers would hide
	/// that the shape is already decided — and `nz_tech` prints the gap rather than a
	/// tidy tree that happens to stop early.
	///
	/// ⚠️ Costs are the original's SHIPPED values (350 / 525 / 700 / 1050 / 1750), which
	/// are 70% of what ARSENAL_REMAKE.md §4.5 planned. Picks 3/2/2/1/1 are the
	/// original's too. Pool sizes are ours — 7 / 7 / 5 / 11 / 11, against the original's
	/// shipped 8/8/8/12/11 and its documented plan of 5/4/5/7/7. Tier 3 is deliberately
	/// the SMALLEST pool: every node in it is a distinct mechanic rather than a stat, so
	/// five is already five different things and padding it would mean repeating one.
	/// </summary>
	public static Tier[] Tiers => new Tier[]
	{
		new( 1, "Basics", 3, 350, Tier1 ),
		new( 2, "Output & Mobility", 2, 525, Tier2 ),
		new( 3, "Combat Power", 2, 700, Tier3 ),
		new( 4, "Archetypes", 1, 1050, Tier4 ),
		new( 5, "Extreme", 1, 1750, Tier5 ),
	};

	public const int MaxTier = 5;

	/// <summary>
	/// TIER 1 — "slight changes that help". Seven nodes, take three.
	///
	/// ⚠️ SEVEN DISTINCT AXES, ONE EACH: reload, capacity, ammo economy, control,
	/// accuracy, aim speed, mobility. Two nodes on one axis would make the pick-3 a
	/// smaller decision than it looks, which is the flaw the original's tier 1 had —
	/// it carried both a general spread node AND a sway node, and its tier 2 was four
	/// of its own tier-1 nodes with bigger numbers.
	///
	/// ⚠️ NOTHING HERE HAS A DOWNSIDE, and nothing points at an archetype. Both start
	/// at tier 2. The original's "Precision Rounds" (+20% headshot damage) was in this
	/// tier and is deliberately NOT here: in a game where headshots are already x2.5 it
	/// was the strongest tier-1 node by a distance, and it belongs to the Marksman line.
	///
	/// ⛔ ONE ACCURACY NODE, BECAUSE THERE IS EFFECTIVELY ONE ACCURACY AXIS IN THIS BUILD.
	/// All 31 prefabs carry their real accuracy in `SpreadAddHipFire` (0.077-0.459), which
	/// `GetRealSpread` adds only when NOT aiming — so "reduce spread" can only ever mean
	/// "reduce hipfire spread". The original's Tight Bore and a hipfire node would have
	/// been the same node.
	///
	/// ⛔ THIS BLOCK USED TO SAY THE 31 PREFABS AUTHOR `Spread` AS 0.0, AND THEY DO NOT.
	/// That figure is `ScopeInfo.Spread`, a different property in the same prefab file;
	/// `ShootInfo.Spread` is authored 0.0013-0.0822 on all 31 (checked, per weapon, after
	/// the `SWB.Base.ShootInfo` type marker). Aiming therefore gives a spread that is two
	/// orders of magnitude smaller than hipfire rather than exactly zero — the design
	/// conclusion above is unchanged, but "already exactly 0" is a premise no tier-5 node
	/// should be reasoned from: `SpreadMultSights` is authored 0 on only two weapons, 0.1
	/// on seventeen, 1 on eight and 2 on four. The same wrong claim is repeated in
	/// `Weapon.Getters.cs` at `GetRealSpread`; that file is not this one's to edit.
	/// </summary>
	/// ⚠️ LEGACY SINCE 2026-10-04 (WEAPON_TECH_TIERS_1_3.md): the old shared tier-1 pool. Tier 1 is now `Tier1Sets`
	/// (below); all seven are re-used from here by id, and none is retired.
	static Node[] Legacy1 => new Node[]
	{
		// ⚠️ 1.111, not 0.9 — ReloadMultiplier is a SPEED. See the Node remarks.
		new( "t1_reload", "Fast Hands", "-10% reload time",
			"PerkEffects.ReloadMultiplier path", 1.111f ),

		// ⚠️ ClipSize is an INT. +15% on a 30-round mag is 34.5, but on a 6-round
		// shotgun it is 6.9 — which truncates back to 6, a completely dead node on the
		// guns where one spare round matters most. Wiring needs a "+15% or +1,
		// whichever is greater" floor, not a bare multiply.
		new( "t1_clip", "Extended Mag", "+15% magazine",
			"ShootInfo.ClipSize", 1.15f ),

		// ⛔ THE LEVER IS `NZAmmo`, NOT `NZWeapon.ReserveAmmo`. This row named the wrong
		// field until 2026-08-20 and the mistake was mine: `NZWeapon` is the LEGACY
		// placeholder gun and appears on ZERO of the 31 weapon prefabs (verified by
		// grep) — it survives only in `Assets/scenes/countdown.scene`. Every real weapon
		// reloads from `NZAmmo.Reserve` / `NZAmmo.MaxReserve`, which is also what
		// WallBuy, Pickup and PowerupEffects already use.
		//
		// ⚠️ WORTH KNOWING HOW IT WAS CAUGHT: nothing about the wrong name would have
		// failed to compile, and the node would simply have done nothing on every gun
		// while the catalogue printed a plausible lever. `Lever` is documentation, and
		// this is the failure mode documentation has — see §6.
		new( "t1_reserve", "Deep Pockets", "+20% reserve ammo",
			"NZAmmo.Reserve / MaxReserve", 1.20f ),

		new( "t1_recoil", "Recoil Control", "-20% recoil",
			"ShootInfo.Recoil[Vertical|Horizontal]Mult + RecoilKick", 0.80f ),

		new( "t1_hipspread", "Point Shooting", "-25% hipfire spread",
			"ShootInfo.SpreadAddHipFire", 0.75f ),

		new( "t1_ads", "Quickdraw", "+20% ADS speed",
			"ViewModelHandler aim lerp", 1.20f ),

		// ⚠️ AdsSpeedMultiplier is a PENALTY clamped 0.05-1.0, so this reduces the
		// penalty toward 1 and cannot overshoot into "faster while aiming than not".
		// Its felt value therefore depends on the current default — a mild penalty
		// leaves this node with little to give back.
		new( "t1_strafe", "Featherweight", "+25% ADS strafe speed",
			"NZPlayer.AdsSpeedMultiplier", 1.25f ),
	};

	/// <summary>
	/// TIER 2 — "slight changes that start contributing to an archetype, or cover a
	/// weakness". Seven nodes, take two.
	///
	/// ⛔ NO LEVER HERE REPEATS TIER 1 WITH A BIGGER NUMBER. That was the original's
	/// central failure: four of its eight tier-2 nodes were its own tier-1 nodes at a
	/// higher magnitude (Extended Mag 10% then Heavy Mag 20%, Fast Hands then Speed
	/// Loader, Quickdraw 15% then Snapshot 50%), so its tier 2 pointed at nothing and
	/// was just a second helping of tier 1.
	///
	/// Where a lever IS shared with tier 1 it appears in the opposite FORM — flat where
	/// tier 1 was a percentage — which serves the other end of the roster instead of
	/// stacking on the same guns. The rest are levers tier 1 never touches: headshots,
	/// limbs, falloff, and the points economy.
	///
	/// ⚠️ NODES ARE DELIBERATELY UNEQUAL ACROSS WEAPONS, and that is the design, not a
	/// balance failure. The roster spans 50x in magazine (2 to 100), 38x in fire rate
	/// (55 to 2100) and 37x in total ammo (7 to 262 seconds of fire), so a node that
	/// helps everything equally would be a node that expresses nothing. The test each
	/// node has to pass is: some weapon is excited by it, and no weapon is excited by
	/// ALL of them. "Universally best" is the bug; "unequal" is the point.
	/// </summary>
	/// ⚠️ LEGACY SINCE 2026-10-04 (WEAPON_TECH_TIERS_1_3.md): the old shared tier-2 pool. Tier 2 is now `Tier2Sets`
	/// (below); all seven are re-used from here by id, and none is retired.
	static Node[] Legacy2 => new Node[]
	{
		// ⚠️ THE FLAT COUNTERPART to tier 1's +15%. Olympia 2 -> 6 is a TRIPLED
		// magazine; M60 100 -> 104 is noise. That gap is the node's whole purpose.
		new( "t2_clip_flat", "Extra Rounds", "+4 rounds",
			"ShootInfo.ClipSize (flat)", 4f ),

		// ⛔ +50 AND NOT +65, CHOSEN SO THE CROSSOVER LANDS ON 500 RPM. Flat N equals
		// P% at exactly N/P, so +50 against Rapid Fire's +10% splits at 500: below it
		// this node wins, above it Rapid Fire does. 9 weapons sit below, 22 above.
		// AWM 55 -> 105 nearly doubles its rate; G11 2100 -> 2150 is nothing.
		new( "t2_rpm_flat", "Match Trigger", "+50 RPM",
			"ShootInfo.RPM (flat)", 50f ),

		new( "t2_rpm_pct", "Rapid Fire", "+10% fire rate",
			"ShootInfo.RPM", 1.10f ),

		// ⚠️ 10%, DELIBERATELY MODEST, because this is the one node with no weapon it
		// is dead on — headshots always matter. It is the safe pick rather than the
		// strong one, and it is better on guns you can actually place shots with.
		//
		// ⛔ FOUR WEAPONS ALREADY HAVE BROKEN HEADSHOTS and this stacks on top of them.
		// AWM, G3, SVD and WA2000 carry `HeadMultiplier: 2.0` in their prefabs, which
		// DamageFor applies on top of Health's own HeadshotDamageScale of 2.5 — so they
		// deal x5 headshots while the other 27 deal x2.5. NZOMBIES_REFERENCE.md warns
		// about exactly this compounding. Fix the four prefabs before tuning this node,
		// or its magnitude will be judged against a base that is wrong.
		new( "t2_headshot", "Precision Rounds", "+15% headshot damage",
			"Health.HeadshotDamageScale path", 1.15f ),

		// ⛔ THE PART MULTIPLIERS LIVE ON THE ZOMBIE, NOT THE WEAPON.
		// Health.PartMultiplier holds arm/leg 0.75 and hands/feet 0.5; the weapon's own
		// `LimbMultiplier` is 1.0 on all 31 prefabs and is NOT the lever. So this node
		// is owned by the shooter but applied on the victim, and has to read the
		// attacker at the point of use exactly as Vigor Rush does.
		//
		// ⚠️ Its value scales with how many shots MISS the torso, which makes it a
		// pellet node above all: KS23 throws 16 per shot and the Olympia 8.
		// ⛔ THE WORD "floor" IN THE LEVER IS LOAD-BEARING, NOT DECORATION.
		// TechEffects.KindOf reads this string to decide how nz_tech_amp exaggerates a
		// node. Without it this classified as a Multiplier, and pow(1.0, anything) is
		// 1.0 — so the amplify command listed it among the amplified nodes showing
		// "x1 -> x1" instead of naming it as NOT amplifiable. That is the exact
		// false-report failure KindOf's own comment exists to prevent, and it slipped
		// through because the classification is inferred from prose.
		//
		// ⚠️ 1.0 IS THE LIMB TARGET, not a multiplier. The extremity target is NOT in the
		// catalogue — Health derives it by promoting each part one rung up the body
		// ladder, so extremities score as limbs and limbs score as this target. On stock
		// values that is exactly 0.5 -> 0.75 and 0.75 -> 1.0, and it stays coherent on a
		// zombie variant that authors non-default limbs.
		new( "t2_limbs", "Hollow Points", "Limbs and extremities take full damage",
			"Health arm/leg/extremity mults (floor)", 1f ),

		// ⛔ A FLOOR, NOT AN ADDITION — it raises FalloffMultiplier TO 0.75 when the
		// weapon is below that and does nothing at all when it is above. Written as
		// `MathF.Max( FalloffMultiplier, 0.75f )`.
		//
		// ⚠️ THE FLOOR FORM SOLVES TWO PROBLEMS AN ADDITION HAD. First, DamageFor does
		// `Lerp(1, FalloffMultiplier, t)` with NO UPPER BOUND, so an additive version
		// pushed the AK to 1.25 and made it deal 125% damage at maximum range — a long
		// barrel that made guns better far away than up close. A floor cannot exceed
		// 1.0 by construction, so there is nothing to clamp and nothing to forget to
		// clamp. Second, an additive +0.5 sent ten weapons straight to 1.0, i.e. no
		// falloff whatsoever from a tier-2 pick, which consumed the "Full Power"
		// no-falloff node that wants to live higher up. 0.75 leaves it room.
		//
		// ⚠️ BROADLY APPLICABLE BUT WILDLY UNEQUAL IN MAGNITUDE, which is the point:
		// 29 of 31 weapons are below 0.75, but the HS10 gains 0.52 (0.23 -> 0.75, more
		// than TRIPLING its damage at range) while the AK at 0.75 and the Stoner at 0.74
		// gain nothing. The close-range guns are the ones this rescues.
		new( "t2_falloff", "Long Barrel", "No damage falloff at any range",
			"ShootInfo.FalloffMultiplier (floor)", 1f ),

		// ⚠️ AN ECONOMY NODE, THE ONLY ONE IN THE TREE. Every other node spends
		// salvage to make the gun better; this one spends salvage to earn POINTS faster,
		// which feeds perks, Pack-a-Punch and the box. It replaced a "kills refund a
		// round to the magazine" node that overlapped a planned perk.
		//
		// ⚠️ FLAT +10, AND THE FLAT FORM IS THE WHOLE POINT. Kills pay 50 body / 100
		// headshot / 130 knife, so +10 is +20% on a body kill and only +10% on a
		// headshot. It is therefore proportionally BETTER on the guns that cannot
		// reliably place headshots — the sprayer's economy node, where Precision Rounds
		// is the marksman's. A percentage would have inverted that and rewarded the
		// weapons already earning the most per kill.
		//
		// ⛔ ADD IT BEFORE `PowerupEffects.PointsMultiplier`, inside AwardPoints. That
		// method is the one chokepoint every award routes through. Adding this bonus
		// after the multiplier would make it the one award Double Points does not
		// double, which is exactly the kind of quiet inconsistency nobody notices until
		// a player counts their points.
		//
		// ⚠️ This note used to say "the four call sites", copying a count from
		// AwardPoints' own comment that was itself wrong — there are TWO callers, because
		// kill, headshot and knife all resolve their amount inside AwardKillPoints. The
		// kill-versus-hit split was therefore cheap rather than the four-way problem both
		// comments implied.
		//
		// ⚠️ KILLS ONLY, not the 10-per-hit award — otherwise a high-RPM gun would
		// earn it dozens of times per zombie and it would stop being a kill bonus.
		new( "t2_bounty", "Bounty", "Kills pay +20 points",
			"ZombieAI.AwardPoints (flat, pre-multiplier)", 20f ),
	};

	/// <summary>
	/// TIER 3 — "the stepping stone for an archetype: a good compensation in one aspect,
	/// no downsides yet". FIVE nodes, take two.
	///
	/// ⛔ FIVE, NOT SEVEN, AND EVERY ONE A DISTINCT MECHANIC RATHER THAN A NUMBER.
	/// Tiers 1 and 2 move stats; this tier grants CAPABILITIES the weapon did not have.
	/// The five aspects are deliberately non-overlapping — range, penetration, ammo
	/// economy, control, handling — so a sixth node would have had to repeat one
	/// of them, which is precisely how the original's tier 3 ended up carrying a fifth
	/// flavour of "+% fire rate" (Overdrive) among genuinely new mechanics.
	///
	/// ⚠️ TWO NODES WERE CUT FOR BEING THE WRONG TIER, not for being bad ideas. "Sprint
	/// & Gun" becomes a Stamin-Up augment instead, which is where firing on the move
	/// belongs. "Point Blank" (+50% damage at contact) was already an archetype rather
	/// than a step toward one — tier 3 has to point somewhere without committing.
	///
	/// ⚠️ STILL NO DOWNSIDES. Tier 4 and 5 are where a big positive is bought with a
	/// significant negative; tier 3 is the last clean tier, so each of these is a pure
	/// gain that points somewhere without committing.
	///
	/// ⚠️ Taking 2 of 5 gives up 3 — the tightest ratio in the tree so far (tier 1
	/// gives up 4 of 7, tier 2 gives up 5 of 7). Fewer, bigger, more distinct choices.
	/// </summary>
	/// ⚠️ LEGACY SINCE 2026-10-04 (WEAPON_TECH_TIERS_1_3.md): the old shared tier-3 pool. Tier 3 is now `Tier3Sets`
	/// (below); all six are re-used from here by id, and none is retired.
	static Node[] Legacy3 => new Node[]
	{

		// ── PENETRATION ─────────────────────────────────────────
		//
		// ⚠️ PIERCING ALREADY EXISTS — `Penetration` is TRUE on all 31 prefabs, so this
		// deepens a capability rather than granting one. `PenetrationDepth` is 9.97 on 20
		// weapons (about ten inches, roughly one body), a few at 3.97-5.97, one outlier at
		// 157.97. x3 turns "one zombie" into "a short line of them".
		//
		// ⚠️ Depth and NOT `PenetrationDamageMult` (0.75 on all 31): depth decides HOW MANY
		// bodies a round crosses, which is the visible effect in a horde. Raising the damage
		// kept per pierce is invisible until you already pierce several.
		// ⚠️ +4 BODIES, NOT x3 DEPTH, BECAUSE PENETRATION IS A BODY COUNT NOW. `BodyDepth` is 1,
		// so `PenetrationDepth` is literally "how many zombies", and a multiplier made the node
		// worth three times as much on a sniper (10 -> 30) as on a shotgun (2 -> 6). A flat +4 is
		// the same promise to every class, and it is the number the card can print.
		//
		// ⛔ AND THE SECOND HALF IS WHERE THE DAMAGE GOES. Every one of the 496 weapons authors
		// `PenetrationDamageMult` at 0.75, so a round through four bodies delivers 0.32 of its
		// damage to the fourth. Raising it to 1 means a pierced line takes FULL damage all the way
		// down, which is what makes a deep shot worth taking rather than merely long.
		//
		// ⚠️ A FLOOR, NOT AN ASSIGNMENT, so a weapon that one day authors 0.9 is not dragged
		// down to a number typed here — the same argument Hollow Points and Long Barrel both make.
		new( "t3_pierce", "Overpenetrator", "+4 penetration, and no damage lost through bodies",
			"ShootInfo.PenetrationDepth +4, PenetrationDamageMult floor", 4f, 0f,
			new Mag( "pendmg", 1f ) ),

		// ── CONTROL ────────────────────────────────────────────
		//
		// ⚠️ A MECHANIC, NOT A BIGGER PERCENTAGE. Tier 1's Recoil Control shrinks the kick;
		// this COLLAPSES THE WALK-BACK TO A SINGLE FRAME — `RecoilRecoveryTime` is 0.25 on
		// all 31 weapons, so the sight picture returns the instant you stop firing.
		// Sustained fire stays on target instead of climbing and settling.
		//
		// ⛔ THIS COMMENT USED TO SAY "removes the WALK-BACK entirely", AND THAT PHRASING
		// DESCRIBES THE ONE IMPLEMENTATION THAT BREAKS THE NODE. `QueueRecoilRecovery`
		// early-returns when its time is <= 0, BEFORE it accumulates `_recoilToRecover` —
		// and `TickRecoilRecovery` is the only thing that ever moves the camera back. So
		// removing the walk-back does not make recovery instant, it makes recovery NEVER
		// HAPPEN: every shot's kick stays in the eye angles permanently and the sight
		// picture climbs through a magazine and stays up there. The worst recoil in the
		// game, sold as "recoil returns to centre instantly".
		//
		// The wiring therefore sets the recovery time to ONE FRAME (`Time.Delta`), which
		// makes the next tick's step equal the whole pending amount. Do not "simplify"
		// that to zero.
		//
		// ⚠️ Its value scales with fire rate: a 2100 RPM G11 spends its whole magazine
		// inside the recovery window, while the AWM has long recovered before its next shot.
		// Universal on paper, a sprayer's node in practice.
		//
		// ⛔ THE KICK AND THE RECOVERY ARE ONE NUMBER, via Weapon.FinishRecoil — it scales
		// the kick AND queues the walk-back from the SAME value. Anything touching recovery
		// has to go through there, or the gun recovers by more than it kicked and the camera
		// sinks a little further with every shot.
		// ⛔ IT USED TO ZERO THE RECOVERY TIME, AND THAT WAS VERY NEARLY INERT. `TickRecoilRecovery`
		// is gated on `RecoilClimbHeld()` — recovery does not run at all while you are still firing —
		// so an instant walk-back only tidied up AFTER the burst, which is exactly when the recoil
		// had stopped mattering. The node read as weak because it was.
		//
		// ⚠️ HALF THE KICK INSTEAD, which is felt on the first shot. It does make Stabilizer a
		// bigger Recoil Control rather than a distinct mechanic, and tier 3 was written to be
		// mechanics rather than stats — a deliberate trade for a node that does something.
		new( "t3_recovery", "Stabilizer", "-50% recoil",
			"FinishRecoil kick x0.5", 0.5f ),

		// ── HANDLING ────────────────────────────────────────────
		//
		// ⚠️ A SWAP COSTS 0.8 SECONDS TODAY — NZInventory.HolsterTime 0.3 plus this
		// weapon's DrawTime 0.5. That is a long time with a horde on you, and it is the
		// hidden tax on carrying a second weapon at all.
		//
		// ⛔ IT CAN ONLY TOUCH DrawTime, NOT HolsterTime. `HolsterTime` is a STATIC on
		// NZInventory — one global value shared by every weapon and every player — so a
		// per-weapon node cannot change it without changing it for the other slot too. This
		// node therefore means "THIS gun comes up instantly", which is coherent, and the
		// 0.3s put-away of whatever you were holding remains.
		//
		// ⚠️ Pairs with Fabricator above and with Mule Kick: the more weapons you carry and
		// the more reason you have to switch, the more this is worth.
		new( "t3_deploy", "Fast Deploy", "Draws instantly",
			"Weapon.DrawTime = 0", 0f ),

		// ⛔ INTO THE MAGAZINE, NOT THE RESERVE, BY REQUEST (user, 2026-09-27: "there's a weapon tech
		// that adds 10 reserve ammo, it should be adding 10 clip ammo"). It rides Extra Rounds' flat
		// term in `ApplyClipTech`, so the two add (+14 with both), and the reserve — counted in
		// magazines off the live clip — follows the bigger magazine as it does for every clip node.
		//
		// ⚠️ FLAT, SO IT IS WORTH MOST ON THE SMALLEST MAGAZINES, and nothing at all on a weapon with
		// no magazine (ClipSize -1), which `ApplyClipTech` guards before any add.
		new( "t3_ammo", "Ammo Pouch", "+10 rounds per magazine",
			"ShootInfo.ClipSize (flat), beside t2_clip_flat in ApplyClipTech", 10f ),

		new( "t3_damage", "Hot Loads", "+15% damage",
			"ShootInfo.Damage", 1.15f ),

		// ── FOCUS ──────────────────────────────────────
		//
		// ⚠️ THE FACTOR IS THE PER-HIT GROWTH, NOT A MULTIPLIER, which is why it is 0.10 and not
		// 1.10. `Weapon.MarkedFactor` reads it as `1 + growth * streak`, so the catalogue still owns
		// the magnitude and `nz_tech` still prints the number that is actually used.
		//
		// ⛔ NO CAP, BY REQUEST, AND THAT IS THE WHOLE DESIGN. Every other damage node in the tree
		// is a fixed multiplier you either have or do not; this one pays for staying on one target
		// while a horde walks past, which is the opposite instinct to every crowd-clearing node
		// beside it. Two seconds of 800 rpm on one zombie is x3.6.
		// ⛔ NOT "FOCUS" — DEADSHOT'S M4 IS ALREADY CALLED THAT, and it is also a
		// consecutive-hit mechanic (+15% per consecutive headshot KILL, capped at +150%). Two
		// different systems with one name, both about streaks, is the worst kind of collision: a
		// player reading either card would be describing the other. "Fixation" is staying ON one
		// target, which is exactly what this rewards and what Deadshot's does not.
		new( "t3_marked", "Fixation", "+10% damage per consecutive hit on the same zombie, uncapped",
			"Weapon.MarkedFactor — streak resets on a different zombie", 0.10f ),
	};

	/// <summary>
	/// TIER 4 — "ARCHETYPES". Nineteen nodes, take ONE, giving up eighteen.
	///
	/// ⛔ THE RULE CHANGES HERE. Tiers 1-3 are pure gains. From tier 4 on, every node is
	/// either a CLEAN upgrade that is deliberately never the best option, or a big
	/// positive bought with a significant negative. There is no third kind.
	///
	/// ⛔ AND THE TIER NOW HAS ONE JOB: A PRO AND A CON THAT DEFINES A CLASS, PLUS THE FIRE
	/// MODES. EVERY node that rewrites `FiringType` lives here, with no exceptions — Micro-Burst,
	/// Full Auto Conversion and Hair Trigger, plus the five that came down from tier 5 (Overclocked,
	/// Bolt Gun, Ten-Round Burst, Autoloader, and Emplacement/Bull Barrel beside them as the same
	/// kind of trade). That is the whole reason for the move: both tiers are PICK-ONE, so with
	/// conversions in each a player could buy two and have one silently cancel the other.
	///
	/// ⚠️ "NO EXCEPTIONS" IS THE PROPERTY THAT MATTERS, AND IT IS WORTH DEFENDING. Pick-one makes
	/// the clash impossible by CONSTRUCTION rather than by a precedence rule nobody can see — so a
	/// future node that writes `FiringType` belongs in this tier, wherever else it would have fitted.
	/// `EffectiveFiringType`'s descending-volume order now only resolves creative stacks.
	///
	/// ⚠️ THREE THINGS A NODE CAN DO, and each of these is exactly one of them: keep
	/// the weapon and make it slightly better overall (Tuned Action, All-Rounder);
	/// turn it into a different weapon (Scattergun, Micro-Burst, Bolt Gun); or make
	/// it incredible in one situation only (Solid Slug against bosses, Emplacement for holding a
	/// lane, Bull Barrel in a doorway).
	///
	/// ⛔ TUNED ACTION AND ALL-ROUNDER HAVE NO DOWNSIDE AND THAT IS A DECISION, NOT AN OVERSIGHT.
	/// They look like they break the rule above them — "a pro and a con" — and they were reviewed
	/// against exactly that and kept. The reasoning is the one this project already applied to
	/// Counterweight: *"it seems cheap when comparing with nothing, but if you compare with the
	/// other upgrades it stops being the case."* A pick-one tier prices every node in what you gave
	/// up to take it, so +15% across the board costs you Bolt Gun, Emplacement and seventeen others.
	/// The cost is the tier, not the node.
	///
	/// ⚠️ WHICH MEANS DO NOT "FIX" THEM. Bolting a penalty onto a node whose whole role is to be
	/// the safe pick would leave the tier with no safe pick at all.
	///
	/// ⚠️ Taking 1 of 19 is the most committal choice in the tree. That is intended —
	/// the archetype IS the choice, and the rest of the tier is the price of it.
	/// </summary>
	/// ⚠️ LEGACY SINCE 2026-10-04: the old shared tier-4 pool. Tier 4 is now `Tier4Sets` (below); the nodes
	/// it kept are re-used from here by id, and the rest are `Retired`.
	static Node[] Legacy4 => new Node[]
	{
		// ── clean, and deliberately never optimal ─────────────────────────
				// ⛔ THE +10% FIRE RATE IS A DECLARED MAGNITUDE NOW, NOT A CONST IN NZPlayer.
		// It lived there as `TunedRpm` and was read through `BoundOf`, so it was invisible
		// to `nz_tech` AND to `nz_tech_amp` — at x10 the damage half became x9.3 and this
		// half stayed x1.10, which is exactly how it was reported: "Tuned Action is not
		// affecting fire rate".
		new( "t4_tuned", "Tuned Action", "+25% damage and +10% fire rate",
			"Damage x1.25, RPM x1.10", 1.25f, 0f,
			new Mag( "rpm", 1.10f ) ),

		new( "t4_allround", "All-Rounder", "+15% damage, fire rate and magazine",
			"Damage / RPM / ClipSize x1.15", 1.15f ),

		// ⚠️ Multiplies `Bullets` and divides Damage, so total damage per shot rises
		// x2.4 (6 x 0.4) while each pellet is weaker — it converts precision into
		// coverage. Dead on weapons that already fire 8-16 pellets.
				// ⚠️ BOTH DOWNSIDES DECLARED. They were `ScatterDamage` and `ScatterRpm` consts
		// in NZPlayer — outside the catalogue, so `nz_tech` printed a node whose two
		// tradeoffs were nowhere in the table it prints.
		new( "t4_scatter", "Scattergun",
			"×6 pellets, but each far weaker, -40% fire rate and half the magazine",
			"Bullets x6, Damage x0.4, RPM x0.6, ClipSize x0.5", 6f, 0f,
			new Mag( "clip", 0.5f ),
			new Mag( "dmg", 0.4f ),
			new Mag( "rpm", 0.6f ) ),

		// ⚠️ 2-ROUND BURST via FiringType, which already supports burst — 1 of 31
		// weapons ships with it, so the mode is proven rather than new.
		//
		// ⛔ THE SECOND ROUND DOUBLES ONLY IF THE FIRST CONNECTED, and that is the one
		// hard part: it needs per-burst state saying "shot 1 hit a zombie". For hitscan
		// that resolves instantly and is easy. For PHYSICAL bullets it does not — the
		// projectile is still travelling when round two leaves the barrel, so the flag
		// would always read false and the bonus would silently never apply on those
		// weapons. Either gate the bonus to hitscan or delay round two until round one
		// resolves; do not let it fail quietly.
		//
		// ⛔⛔ THIS NODE COMBINES WITH EVERY TIER-5 FIRE MODE. It does NOT lose to them and
		// it does NOT override them — each pair has an authored behaviour, and this table
		// is the specification. A wiring pass that resolves fire mode by precedence would
		// throw away a 1,050-salvage pick and look like a bug nobody can reproduce.
		//
		//   + OVERCLOCKED  -> AUTOMATIC BURST. Two rounds, a slight delay, two more, and
		//                     on while the trigger is HELD — never a single stream.
		//                     Overclocked's +50% rate and -20% damage still apply.
		//
		//   + BOLT GUN     -> two rounds at the weapon's NORMAL rate, then a delay of
		//                     0.2x the normal fire RATE, i.e. FIVE TIMES the normal shot
		//                     interval. ⚠️ Rate and interval are reciprocals and this is
		//                     where that bites: "0.2x fire rate" is a LONGER wait, not a
		//                     shorter one. Bolt Gun's own x0.2 stays; the pair just gets
		//                     through the two rounds at full speed first.
		//
		//   + RICOCHET     -> stays a 2-ROUND BURST instead of dropping to semi-auto, so
		//                     Micro-Burst cancels Ricochet's only downside.
		//                     ⚠️ AND THEREFORE RESTORES THE PERFORMANCE RISK semi-auto was
		//                     chosen to cap. Bursts at full rate on a 2100 RPM G11 put the
		//                     trace count back up, so this exact pair is the one to
		//                     measure — not Ricochet alone.
		//
		//   + TEN-ROUND    -> THE BURST RAMPS: each of the ten rounds deals +20% MORE
		//                     THAN THE ONE BEFORE, compounding. This ramp exists ONLY
		//                     with both nodes — Ten-Round Burst alone fires ten flat
		//                     rounds.
		//                     ramp: 1.00 1.20 1.44 1.73 2.07 2.49 2.99 3.58 4.30 5.16
		//                     ⛔ THESE FIGURES OMITTED MICRO-BURST'S OWN +40% and were
		//                     therefore wrong. Corrected: every round carries 1.40 x 0.67
		//                     x 1.20^i, so round one deals 0.94 of a normal shot and round
		//                     TEN deals 4.84. The burst totals 24.3 base-bullet units
		//                     against 10 for ten unmodified rounds (x2.43), and at x3 fire
		//                     rate that is roughly x7.3 DPS while it runs.
		//                     The old numbers (0.67 / 3.46 / 17.4 / x5.2) described a
		//                     pairing that dropped the +40%, which no code ever did —
		//                     another computed total nobody re-derived (see §6).
		//                     ⚠️ COMPOUNDING, NOT ADDITIVE — additive +20% would make round
		//                     ten 2.8x instead of 5.16x, and the tail is the whole point.
		//                     ⚠️ RESETS EVERY BURST. It must not carry across bursts or
		//                     sustained fire climbs without limit.
		//                     ⚠️ THE RAMP IS UNCONDITIONAL, unlike Micro-Burst's own
		//                     "second round doubles if the first hit". That is deliberate:
		//                     it sidesteps the physical-bullet problem noted above, since
		//                     nothing has to know whether the previous round connected. So
		//                     this pairing works on every weapon, while Micro-Burst alone
		//                     does not.
		new( "t4_microburst", "Micro-Burst",
			"2-round burst at +40% damage, and the second doubles if the first hit",
			"FiringType=burst(2), Damage x1.4, see combo matrix", 1.40f ),

		// ⚠️ Head bonus removed ENTIRELY, not reduced — the trade is "stop aiming".
		// The mirror of Deadeye, and the sprayer's version of it.
		// ⚠️ REDESIGNED 2026-08-20. It was "torso and limbs x1.5" — a FLOOR on two zones
		// of Health's part table, leaving extremities and heads alone. It is now a flat
		// x1.5 on DAMAGE plus a forced x1 head, which is simpler in every direction that
		// matters: one damage lever instead of two zone floors, no interaction with the
		// per-zombie part multipliers a variant may author, and the limb and extremity
		// penalties survive underneath it rather than being erased.
		//
		// ⚠️ WHAT IT COMES TO, so nobody has to derive it: torso 1.5, limb 1.125
		// (1.5 x 0.75), extremity 0.75 (1.5 x 0.5), HEAD 1.5. Head and torso land on the
		// same number — which is the node: without it a head is 2.5 and a torso 1.0, so
		// this trades 2.5 down to 1.5 on the head and 1.0 up to 1.5 everywhere else.
		// Aiming stops paying; spraying starts.
		new( "t4_bodyshot", "Body Shot", "×2 damage, but headshots do no extra damage",
			"Damage x2, Health head bonus suppressed", 2f ),

		// ── situational ────────────────────────────────────────
		//
		// ⚠️ SETS `Penetration` FALSE, which every one of the 31 prefabs currently has
		// TRUE. All the damage lands in the first body it touches: excellent against a
		// single large target, actively bad against a queue of them.
		new( "t4_solidslug", "Solid Slug", "×4 damage, but it cannot penetrate at all",
			"Damage x4, Penetration = false", 4f ),

		// ⚠️ A WEAPON YOU ARE NOT MEANT TO USE. Infinite ammo makes it the gun you
		// switch to when the other one is dry mid-horde; a quarter magazine and a 5x
		// reload make it miserable to fight with. Pairs with tier 3's Fast Deploy and
		// Fabricator, which are also about the weapon you are not holding.
				// ⚠️ THE 5x RELOAD DECLARED. Was the `LastResortReload` const. It is a TIME
		// multiplier, so bigger is slower — amplifying it makes the downside worse, which
		// is the correct direction for an instrument that exaggerates a node.
// ⛔ THIS REPLACED LAST RESORT, WHICH WAS FOUR EFFECTS PRETENDING TO BE ONE ARCHETYPE.
		// That node was x3 damage, infinite ammo, half magazine and a 5x reload — a panic weapon, a
		// damage node and an ammo node at once, so no single sentence described how it played.
		// Heavy Machine is the same fantasy said once: it hits three times as hard and fires half
		// as often. Nothing else moves.
		//
		// ⚠️ THE FACTOR IS THE DAMAGE NOW, NOT A SECONDARY. Last Resort spent its Factor on the
		// clip and had to carry its headline x3 in a `Mag`; with the clip gone the primary number
		// is free, which is what `Factor` is for and what `nz_tech_amp` scales by default.
		new( "t4_heavy", "Heavy Machine", "×3 damage, but half the fire rate",
			"Damage x3, RPM x0.5", 3f, 0f,
			new Mag( "rpm", 0.5f ) ),

		// ── COUNTERWEIGHT ──────────────────────────────────────────
		//
		// ⚠️ THE FACTOR IS THE RECOIL MULTIPLIER, AND IT IS ZERO. `FinishRecoil` multiplies the
		// composed kick by it, so the node reads as one line there and needs no special case — the
		// same shape the railgun's recoil zeroing already uses.
		//
		// ⚠️ -10%, NOT -35%. The first draft asked a third of the weapon's damage for perfect
		// control and was judged too steep. It is worth remembering that the kick this removes is
		// now x2.2 of the authored number and its walk-back only returns 80%, so "no recoil" buys
		// more than it did before the recoil rework — the price may want revisiting after play.
		new( "t4_counterweight", "Counterweight", "No recoil at all, but -10% damage",
			"FinishRecoil kick x0, Damage x0.9", 0f, 0f,
			new Mag( "dmg", 0.9f ) ),

		// ── DOUBLE FEED ───────────────────────────────────
		//
		// ⚠️ THE THREE NUMBERS ALMOST CANCEL, AND THAT IS THE NODE. x1.8 damage at x0.6 rate is
		// x1.08 DPS for x0.9 of the ammo — so what is really bought is a much heavier individual
		// round, and what is paid is that every magazine is ten percent shorter.
		//
		// ⚠️ IT IS AN ARCHETYPE CHANGE MORE THAN A STAT CHANGE. A weapon that needed two hits to
		// drop a zombie now needs one, which is worth far more than 8% in a corridor and far less
		// than 8% against a boss.
		//
		// ⚠️ THE EXTRA ROUND IS `AmmoPerShot`, NOT A READ-TIME HOOK. That field is authored,
		// already subtracted by `Weapon.Shoot`, and already counted by Double Tap's overpressure
		// affordability check — so writing it at spawn time makes every consumer agree for free.
		new( "t4_doublefeed", "Double Feed",
			"×1.8 damage, but it feeds two rounds and fires slower",
			"Damage x1.8, RPM x0.6, AmmoPerShot x2", 1.8f, 0f,
			new Mag( "rpm", 0.6f ),
			new Mag( "ammo", 2f ) ),

		// ── FULL AUTO CONVERSION ────────────────────────────────────
		//
		// ⚠️ A SECOND ROUTE TO FULL AUTO, DELIBERATELY. Overclocked also converts, at +50% rate
		// for -20% damage; this is the modest version that costs nothing. Both live in tier 4 and
		// the tier takes ONE, so they compete rather than stack — which is the whole reason the
		// fire-mode nodes were gathered here.
		new( "t4_fullauto", "Full Auto Conversion", "Full-auto, +10% fire rate and +10% damage",
			"FiringType=auto, RPM x1.1, Damage x1.1", 1.1f, 0f,
			new Mag( "rpm", 1.1f ) ),

		// ── HAIR TRIGGER ─────────────────────────────────────────
		//
		// ⛔ FLAT, AND FLAT IS THE POINT: +1000 rpm EXCEEDS EVERY WEAPON ON THE ROSTER, so the
		// fire-rate gate stops being the limiter entirely and the gun fires as fast as the player
		// can click. That is the node — not "faster", but "uncapped". A multiplier would have kept
		// the ceiling attached to the weapon, which is the opposite of what it is for.
		//
		// ⚠️ SO ITS REAL COST IS LOSING AUTOMATIC FIRE, and its real value is how fast you can
		// click. It rewards the player rather than the weapon, which is unusual here and worth
		// watching: on a 900 rpm weapon it is a downgrade, on a 150 rpm revolver it is enormous.
		new( "t4_hairtrigger", "Hair Trigger", "Semi-auto, but fires as fast as you can click",
			"FiringType=semi, RPM +1000 flat", 1000f, 0f ),

		// ⚠️ The move-speed penalty is what makes this a lane-holding node rather than
		// a free magazine. On an M60 (100 rounds) it is 300; on an Olympia it is 6.
				// ⚠️ BOTH DOWNSIDES DECLARED. Were the `DrumReload` and `DrumWalk` consts.
		new( "t4_drum", "Drum Magazine", "×3 magazine, but reloads half as fast and you move 15% slower",
			"ClipSize x3, ReloadTime x2, WalkSpeed x0.85", 3f, 0f,
			new Mag( "reload", 2f ),
			new Mag( "walk", 0.85f ) ),

		// ⛔ "UNLEARNABLE" IS A DELIBERATE INVERSION of what GetRecoilAngles was built
		// for. Its own header records that the horizontal pattern uses a SINE so the
		// spray stays learnable, and warns against both a constant sign (the aim slides
		// one way forever) and an index-scaled drift (every shot swings wider). This
		// node wants exactly that second failure — so it should raise
		// RecoilPatternDrift and the random terms, NOT re-break the sine.
		new( "t4_overpressure", "Overpressure", "×2 damage, but four times the recoil and no pattern to learn",
			"Damage x2, Recoil x4 + random terms", 2f ),

		// ── THE FIRE MODES AND THE TWO BIG ARCHETYPES, MOVED DOWN FROM TIER 5 ──
		//
		// ⛔ A TIER OUGHT TO ANSWER ONE QUESTION, AND THESE TWO WERE BOTH ANSWERING BOTH.
		// Tier 4 and tier 5 each held fire-mode conversions, and both are pick-one — so a
		// player could buy two of them and one would silently cancel the other. Every node
		// that rewrites `FiringType` is now in this tier, where pick-one makes that
		// impossible by construction rather than by a precedence rule nobody can see.
		//
		// ⚠️ WHICH LEAVES TIER 4 = "A PRO AND A CON THAT DEFINES A CLASS" AND TIER 5 =
		// "THE GIMMICKS". Emplacement and Bull Barrel came with them because they are the
		// same kind of thing: a big trade that changes how the weapon is used, not a trick.

		// ⚠️ Damage and magazine bought with almost all of your mobility. The only
		// node in the tree that makes standing still correct.
		//
		// ⛔ FIVE MAGNITUDES, WHICH IS WHY `Mag` EXISTS. `Factor` is the damage; the other
		// four are named. `walk` and `sprint` are read through NZPlayer.TechMoveMultiplier
		// and `ads` through TechEffects.AdsSpeedFactor, so the four files that need them
		// (NZPlayer, Stamina, ViewModelHandler, PlayerCameraHandler) share one number each
		// instead of four copies — the miss that cost Quickdraw a build.
		//
		// ⚠️ `sprint` IS 0.3 OF THE SPRINT SPEED, not 0.3 of walk. Walk 0.5 and sprint 0.3
		// against a base sprint that is roughly twice base walk leaves sprinting only ~1.2x
		// walking, so sprint nearly stops being a distinct state. That is the node, and it
		// is flagged to design rather than softened here.
		new( "t4_emplacement", "Emplacement",
			"×3 damage and ×2 magazine, but you barely move and can hardly aim",
			"Damage, ClipSize, player speeds, ADS", 3f, 0f,
			new Mag( "clip", 2f ),
			new Mag( "walk", 0.5f ),
			new Mag( "sprint", 0.3f ),
			// ⚠️ A RATE DIVISOR, SO SMALLER IS SLOWER. "4x slower" is x0.25 on the aim
			// lerp speed, the same direction Quickdraw's 1.20 points.
			new Mag( "ads", 0.25f ) ),

		// ⚠️ LOSING ADS COSTS MORE THAN IT LOOKS: aiming is what shrinks spread by two
		// orders of magnitude in this build — see the tier-1 header for what the authored
		// numbers actually are — so this node does not merely widen the cone, it removes
		// the only way to close it.
		//
		// ⛔ IT HAS TWO UNDOCUMENTED DOWNSIDES AND ONE UNDOCUMENTED UPSIDE, all from the
		// same lost `IsAiming`: `GetRecoilAngles` applies a 0.4 aim multiplier, so the node
		// silently carries 2.5x the recoil the player is used to; the WA2000 — the one prefab
		// authoring `Scoping` — loses its scope entirely; and NZPlayer's ADS walk penalty can
		// never apply. Flagged to design, because the real cost is not the printed one.
		// ⚠️ RETUNED INTO AN ARCHETYPE RATHER THAN A PURE DAMAGE TRADE. It was x2.5 damage
		// against no ADS and triple spread; it is now a hipfire BRAWLER — more damage, faster, and
		// a bigger magazine, paid for entirely with accuracy. The con is unchanged in kind and
		// halved in degree, because three axes of upside needed less of a single downside.
		new( "t4_bullbarrel", "Bull Barrel",
			"×2 damage, +50% fire rate and magazine, but no ADS and double the spread",
			"Damage x2, RPM x1.5, ClipSize x1.5, ADS disabled, SpreadAddHipFire x2", 2f, 0f,
			new Mag( "rpm", 1.5f ),
			new Mag( "clip", 1.5f ),

			// ⚠️ HIPFIRE spread, because that is where all but a percent of a gun's real
			// inaccuracy lives: `SpreadAddHipFire` is 0.077-0.459 across the 31 prefabs and
			// `GetRealSpread` adds it only when NOT aiming, against a base `Spread` two
			// orders of magnitude smaller (0.0013-0.0822). Losing ADS is what makes the x3
			// reachable at all.
			new Mag( "spread", 3f ) ),

		// ⚠️ SEMI-AUTO AT x0.2 FIRE RATE rather than a bolt-action cycle. Bolt-action
		// exists (`BoltActionPerShot`, true on 1 weapon) but semi at a fifth of the rate
		// is the same feel with none of the animation work.
		//
		// ⛔ BRUTAL ON WEAPONS THAT ARE ALREADY SLOW. The AWM at 55 RPM becomes 11 — one
		// shot every 5.5 seconds — and its 5-round clip becomes 1. The G11 at 2100
		// becomes a 420 RPM DMR with 14 rounds, which is the intended shape. So this is a
		// FAST-weapon node despite reading like a sniper one.
		//
		// ⛔ THE x0.2 IS READ-TIME, NOT A SPAWN-TIME `RPM` WRITE, and the Micro-Burst
		// pairing below is what forces that: "two rounds at the weapon's NORMAL rate, then
		// five times the normal shot interval" cannot be expressed by a stored RPM, which
		// would slow both rounds. It belongs in `Weapon.GetRealRPM`, keyed on `burstCount`.
		// Every other RPM node in this tier (Ten-Round, Overclocked, Railgun) stays
		// spawn-time; this one is the exception and the reason is authored, not stylistic.
		new( "t4_boltgun", "Bolt Gun", "×4 damage, but semi-auto at a fifth the fire rate and a third the magazine",
			"FiringType=semi, GetRealRPM x0.2, Damage x4, ClipSize x0.3", 4f, 0f,
			new Mag( "clip", 0.3f ),
			new Mag( "rpm", 0.2f ) ),

		// ⛔ THE UNINTERRUPTIBLE BURST IS THE COST, not the fire rate. Tapping fire
		// commits ten rounds: a third of an M60 magazine, or an ENTIRE magazine on the ten
		// weapons that hold fewer than ten (Olympia 2, KS23 4, AWM 5, HS10/Python/WA2000 6,
		// ASP/M1911/Makarov/SPAS12 8 — counted off the 31 prefabs). It reads completely
		// differently on every weapon, which is the point.
		//
		// ⛔ THIS ROW USED TO SAY "five whole Olympia magazines" AND THAT IS NOT BUILDABLE.
		// `CanShoot` returns false while `IsReloading` (read it — Weapon.Shoot.cs, the guard
		// above the ammo check), so a burst cannot span a reload: on those ten weapons the
		// burst ends when the magazine does. The wiring must release the latch there or the
		// gun jams with a non-zero counter. Copy fixed rather than code bent.
		//
		// ⚠️ NO DAMAGE RAMP ON ITS OWN. The escalating burst belongs to the MICRO-BURST
		// PAIRING, not to this node — see the combination matrix on Micro-Burst in Tier4.
		// Alone, all ten rounds deal the same -33% damage and the node is purely "three
		// times the rate, and you cannot stop".
		//
		// ⚠️ Needs new code — `FiringType.burst` is hardcoded to three and can be
		// cancelled. A 10-round burst that cannot be released is neither.
		//
		// ⚠️ THE x3 RPM IS FRAMERATE-CAPPED AND THAT IS NOT A BUG TO FIX HERE. Weapon.cs
		// asks `CanPrimaryShoot` once per Update, so the ceiling is one round per frame —
		// 3600 RPM at 60 fps. A G11 at 2100 cannot reach 6300 and gets about x1.7. Test the
		// node on the FAMAS (1000 -> 3000), not the G11.
		new( "t4_tenburst", "Ten-Round Burst", "Uninterruptible 10-round bursts at ×3 fire rate, but -33% damage",
			"RPM x3, Damage x0.67, new burst mode", 3f, 0f,
			new Mag( "dmg", 0.67f ),

			// ⚠️ A COUNT, SO IT IS NOT AMPLIFIABLE. `nz_tech_amp 10` on a burst length is a
			// hundred-round burst on a weapon holding thirty, which measures the jam-on-empty
			// path rather than the node.
			new Mag( "rounds", 10f, Multiplier: false ),

			// ⛔ THE RAMP BELONGS TO THE MICRO-BURST PAIRING AND IS DECLARED HERE ANYWAY,
			// because it is Ten-Round's number: the pairing's matrix lives on t4_microburst
			// where the varying behaviour is, but the +20% step is what THIS node contributes
			// to it. `BurstDamageRamp` returns it; `BurstDamageFactor` raises it to the round
			// index, and its own guard already makes the ramp REPLACE Micro-Burst's
			// second-round doubling rather than stack with it (read it — the resolution is
			// stated there and in the matrix). Micro-Burst's own x1.40 STAYS: the corrected
			// matrix figures (round one 0.94, round ten 4.84, burst 24.3) include it.
			new Mag( "ramp", 1.20f ) ),

		// ⚠️ Worthless on the 15 weapons already `auto`; transformative on the 15 that
		// are `semi`. One of the sharpest splits in the tree, and it comes free from a
		// field that is already authored.
		//
		// ⚠️ AND ON THE 15 THAT ARE ALREADY `auto` IT IS INVISIBLE AS A NODE — +50% rate for
		// -20% damage is a flat x1.2 DPS with no change a player can see or hear. Buying it
		// on an M60 reasonably reads as "nothing happened". That is a UI problem, not a
		// wiring one.
		new( "t4_overclock", "Overclocked", "Full-auto at +50% fire rate, but -20% damage",
			"FiringType=auto, RPM x1.5, Damage x0.8", 1.5f, 0f,
			new Mag( "dmg", 0.8f ) ),

		// ⛔ AUTOLOADER CAME DOWN HERE TOO, AND IT IS WHAT CLOSED THE RULE. It forces
		// full-auto — "it becomes fully automatic" is half of what the node IS — so leaving it
		// in tier 5 left exactly one reachable clash: Autoloader plus any conversion above,
		// resolved by `EffectiveFiringType`'s descending-volume order, which meant Bolt Gun
		// silently ate it. With every `FiringType` writer in one pick-one tier, no pairing of
		// them exists to resolve.
		//
		// ⚠️ IT ALSO FITS THE TIER'S STANDARD BETTER THAN TIER 5's. Turning a shotgun into an
		// automatic rifle is a pro and a con that defines a class, not a trick you do to a gun.


		// ── AUTOLOADER ────────────────────────────────────
		//
		// ⛔ IT TURNS A SHOTGUN INTO A RIFLE. The spread stops being fired all at once and starts
		// being fired one pellet at a time: full-auto, quarter spread, and the pellet count becomes
		// rate, damage AND magazine instead. A 16-pellet KS23 becomes an 8x-rate, 8x-damage,
		// 8x-magazine automatic; the same total lead, delivered on the player's schedule.
		//
		// ⚠️ WHICH IS A CONTROL NODE, NOT A DAMAGE NODE. Burst damage per trigger pull goes DOWN
		// by a factor of the pellet count — no more one-shot-in-the-face — and what is gained is
		// that none of it is wasted on the zombie behind the one you killed.
		//
		// ⛔ A NO-OP BELOW TWO PELLETS, BY REQUEST AND BY ARITHMETIC. `pellets / 2` on a rifle is
		// x0.5 rate, damage and magazine for nothing at all, so the node has to refuse rather than
		// cripple — the same shape Slug Loader's `basePellets >= 2` gate already uses.
		//
		// ⚠️ AND IT COUNTS AUTHORED PELLETS, NOT LIVE ONES. `TechBase` remembers the prefab's
		// number, so Double Tap's M1 second spread and Scattergun's x6 cannot feed themselves back
		// into the divisor — the failure `ApplyShotTech`'s own header records for Slug Loader.
		//
		// ⚠️ THE `Factor` IS THE DIVISOR AS A MULTIPLIER (0.5), so `nz_tech_amp` scales the one
		// number the node is actually about. The spread cut is a `Mag` beside it.
		new( "t4_autoload", "Autoloader",
			"Full-auto single pellets at quarter spread; damage, rate and magazine × half the pellets",
			"Bullets 1, full-auto, spread x0.25, dmg/rpm/clip x (pellets x 0.5)", 0.5f, 0f,
			new Mag( "spread", 0.25f ) ),
	};

	/// <summary>
	/// TIER 5 — "EXTREME". Twelve nodes, take ONE, giving up eleven.
	///
	/// ⚠️ THE CAPSTONE, and the tier where a weapon stops resembling itself. Same
	/// rule as tier 4 — clean-but-never-best, or great-for-something-horrible — but the
	/// magnitudes are larger on both sides.
	///
	/// ⚠️ STACKS ON TIER 4. Both are pick-one, so a build is a PAIR, and some pairs
	/// are coherent (Scattergun into Wide Bore) while others fight (Deadeye into Body
	/// Shot cancels out). The UI should show what a tier-4 pick pairs well with; nothing
	/// forbids a bad pair, because discovering them is part of it.
	///
	/// ⛔ NOT ONE NODE IN THIS TIER TOUCHES `FiringType`, AND THAT IS WHAT THE RESTRUCTURE WAS
	/// FOR. Overclocked, Bolt Gun, Ten-Round Burst and Autoloader all moved down to tier 4 to sit
	/// with Micro-Burst, because two pick-one tiers each holding conversions meant a player could
	/// buy two and have one silently cancel the other. Ricochet Rounds no longer changes fire mode
	/// at all. Anything added here that wants to belongs in tier 4 instead.
	///
	/// ⚠️ SO MICRO-BURST'S COMBINATION MATRIX IS NOW CREATIVE-ONLY. Its four authored pairings
	/// all name tier-4 nodes today, and tier 4 is pick-one — `WeaponTech.Unlimited` is the only way
	/// to reach them. The matrix stays, on the Micro-Burst node in Tier4, because it still
	/// describes what the code does and because the tiers may move again.
	/// </summary>
	/// ⚠️ LEGACY SINCE 2026-10-04: the old shared tier-5 pool. Tier 5 is now `Tier5Sets` (below).
	static Node[] Legacy5 => new Node[]
	{

		// ⛔ TWO TRACES PER BULLET, NOT ONE — a thin one for geometry and a fat one for
		// zombies, because the extra radius must be invisible to the world or the round
		// would clip walls it should pass. That doubles trace count and the two can
		// disagree about what was hit first, so the world trace has to win on distance.
		//
		// ⛔ `BulletSize` IS DEAD CODE AND THIS ROW USED TO NAME IT AS THE LEVER. The field
		// is declared on ShootInfo and authored as 2 on all 31 prefabs, and NOTHING reads it
		// — grep it. The radius the bullet actually sweeps is a DEFAULT PARAMETER on
		// `Weapon.TraceBullet` (2.0f), and the two 2s are a coincidence. Scaling the field
		// would have been a node that is arithmetically perfect and does nothing, so the
		// node plumbs a radius instead and `radius` below is the number, not `Factor` x2.
		//
		// ⛔ AND IT MUST NOT BE PASSED AS `radius:` TO TraceBullet. That method retries as a
		// zero-radius ray whenever the trace starts solid, so a fat sphere from the eye
		// collapses to a thin ray whenever the player is within ~25cm of geometry — i.e.
		// exactly when fighting in a doorway. A second `.WithTag("zombie")` trace cannot
		// start solid on a wall.
		// ⛔ REDESIGNED: IT ENLARGES THE HEAD, NOT THE BULLET. The old node widened the bullet's
		// trace against zombies, which made everything easier to hit and headshots no easier at all
		// — it blurred aiming rather than rewarding it, and its whole second-trace path is gone
		// with it. It now makes HEADSHOTS easy, which is the same fantasy pointed at what players
		// actually want from a node called Wide Bore.
		//
		// ⛔ AND IT REINTERPRETS THE SHOOTER'S OWN HITS RATHER THAN SCALING THE HEAD COLLIDER.
		// The head is a MODEL HITBOX — one of 46 per-bone shapes whose tags `MergeHitboxTags` folds
		// into `DamageInfo.Tags` — and those are shared scene state. Growing one would change hit
		// registration for every player in the lobby, not just the one who bought the node.
		// Asking "was this hit near the head" on the shooter's own damage is per-player by
		// construction.
		//
		// ⚠️ THE FACTOR IS A RADIUS IN UNITS, not a multiplier, so `nz_tech_amp` leaves it alone.
		// 16 against a ~72-unit zombie is about a head and a half in every direction: upper-chest
		// hits pay as headshots, the whole torso does not.
		// ── BOUNCY ROUNDS ────────────────────────────────────────
		//
		// ⚠️ THE FACTOR IS THE PER-LINK MULTIPLIER, and the LINK COUNT is deliberately not in
		// the catalogue at all — it is the weapon's own penetration, so Overpenetrator's +4 extends
		// the chain without this node knowing it exists. A number here would have to be kept in
		// step with a stat that moves.
		//
		// ⛔ IT CHAINS ON A KILL, WHICH IS WHAT STOPS IT BEING A CROWD CLEARER. A shot that
		// leaves a zombie standing does nothing, so the node pays for the surplus an overkilling
		// round had left over rather than for firing into a pack.
		new( "t5_bouncy", "Bouncy Rounds",
			"Kills jump to the nearest zombie at ×2 damage, again per kill, up to its penetration",
			"BouncyRounds.Chain — links capped by PenetrationDepth", 2f ),

		// ⛔ IT DOES NOTHING WHATEVER AGAINST ONE ZOMBIE, WHICH IS THE POINT. Seven shards at a
		// third each is up to x2.31 in a packed group and exactly x1 in an open room — an enormous
		// ceiling whose only entry fee is standing somewhere frightening.
		//
		// ⚠️ THE COUNT IS THE `Factor` AND THE SHARE IS A `Mag`, so `nz_tech_amp` multiplies the
		// number of fragments rather than quietly pushing each one past the bullet that made it.
		//
		// ⚠️ AND THE SPLIT IS PER PELLET, not per trigger pull and not per pierced body — see
		// `Flechette`, which carries the reasoning for both halves of that.
		new( "t5_flechette", "Flechette",
			"Hits burst into 7 shards, each dealing 33% of the hit to a zombie within 250 units",
			"Flechette.Split — 7 shards at x0.33 of the hit, one split per pellet", 7f, 0f,
			new Mag( "share", 0.33f ) ),

		// ⛔ x4 THAT ARRIVES A SECOND LATE IS NOT x4. The zombie keeps walking on damage it has
		// already taken, so a shot that used to stop something at arm's length stops it a step and
		// a half further in — which is the whole price, and it is why the number can be this big.
		//
		// ⚠️ AND IT WASTES OVERKILL TWICE: the surplus drains into a corpse, and the player
		// cannot see that it is happening in time to re-aim. Tier 5's other nodes reward spraying a
		// crowd; this one rewards firing exactly enough into exactly one thing.
		new( "t5_perforator", "Perforator",
			"×4 damage, but it lands over 1 second instead of on impact",
			"Perforator.Absorb — x4 over 1s in 10 ticks, additive stacks", 4f ),

		// ⛔ THE SAME AMMO, ARRANGED DIFFERENTLY. Magazine becomes clip + reserve and the reserve
		// becomes zero, so the total is unchanged and what is bought is the removal of the reload.
		//
		// ⚠️ WHICH MAKES ITS VALUE A FUNCTION OF HOW OFTEN THE WEAPON RELOADS rather than of
		// how much it holds — a pump shotgun gains far more than an LMG, which is a pleasant
		// inversion given the LMG is the gun that looks like it wants a belt.
		//
		// ⚠️ NO `Factor` WORTH NAMING. The magazine count is `ReserveAmmo.MagsFor`, which
		// already owns that rule for the whole roster; 1 here means "no magnitude of its own".
		new( "t5_siege", "Siege",
			"Your whole reserve is loaded into the magazine: no reserve, and no reloading",
			"ApplyClipTech siege fold — clip x (mags + 1), reserve 0", 1f ),

		new( "t5_widebore", "Wide Bore", "Any hit within 16 units of a zombie's head counts as a headshot",
			"Health.IsHeadshot — hits within Factor units of head height count", 16f, 0f,

			// ⚠️ UNITS, NOT A MULTIPLIER, so `nz_tech_amp` leaves it alone: a 100-unit
			// sphere would hit zombies through walls and read as a broken node rather than
			// an exaggerated one.
			new Mag( "radius", 10f, Multiplier: false ) ),

		// ⚠️ Reuses the grenade AoE rather than inventing one. The -40% direct damage
		// is what stops it being a strict upgrade on high-RPM weapons.
		//
		// ⛔ THE BLAST HAS NO MAGNITUDES HERE, DELIBERATELY, AND THE NODE IS NOT BUILDABLE
		// UNTIL IT DOES. `Grenade.Detonate` is an instance method whose damage, radius and
		// self-damage are instance `[Property]`s — there is no way to ask it for a small
		// blast — and a bespoke one needs FOUR numbers nobody has authored: blast damage,
		// radius, a rate limit, and whether it hurts the shooter. Declaring invented ones
		// here would make `nz_tech` print a design decision that was never made. The
		// damage x0.6 is real and is `Factor`; the blast waits.
		new( "t5_explosive", "Explosive Rounds", "Each shot explodes for half its damage on zombies within 70 units, but -40% damage",
			"Grenade AoE per impact, Damage x0.6", 0.6f ),

		// ⛔ SEMI-AUTO IS THE COST, AND IT IS ALSO THE PERFORMANCE FIX — those are the
		// same decision. Ricochet already exists and is TRUE on all 31 weapons, but
		// ⚠️ SEVEN BOUNCES, AND THE NODE NOW OWNS THAT NUMBER. It was 3, which happened
		// to equal the authored `MaxRicochets` on all 31 prefabs — so the node set no
		// ShootInfo field at all and the bullet path read the weapon's cap. That worked
		// only while the two numbers coincided; raising the node to 7 without moving the
		// cap would have silently kept bouncing 3 times, which is the "arithmetically
		// perfect, does nothing" failure this tier has already paid for twice.
		//
		// bounded: `RicochetChance` 0.33, `RicochetAngle` 30, `MaxRicochets` 3. Forcing a
		// bounce at ANY angle and adding infinite pierce multiplies traces per shot, and
		// on a 2100 RPM G11 that would be 35 shots a second each spawning four segments
		// that pierce every body they meet.
		//
		// Forcing semi-auto caps the trace rate at how fast a human can click — call it
		// 8 a second rather than 35 — so the worst case is bounded by input instead of by
		// a hoped-for damage penalty. A damage cut would have reduced the node's value
		// without reducing a single trace.
		//
		// ⚠️ FULL DAMAGE KEPT, deliberately. On the 15 weapons that are ALREADY `semi`
		// this therefore costs nothing at all, which reads like a violation of the
		// tier's big-positive-for-a-real-negative rule — and mostly is not: a semi weapon
		// firing at click speed still puts out far less than an auto would, so the node
		// caps its own ceiling either way. It does mean this is the cheap pick on the
		// AWM, Python and Olympia and a genuine sacrifice on the MAC11 or G11.
		//
		// ⚠️ Bounces off world geometry only, never off zombies.
		//
		// ⛔ "INFINITE PIERCE" IS TEN BODIES AND THE COPY SAYS SO NOW. `PenetrationDepth = 0`
		// really does mean an unlimited budget — `HitScanBulletInfo` documents it and both
		// its `penBudget > 0f` guards skip when it is zero — but the loop it runs in is
		// `for ( i < MaxPenetrations )` with `MaxPenetrations = 10`, and there is a SECOND
		// copy of that constant in `PhysicalBullet.Mover`. Ten is the honest maximum, and on
		// most of the roster Overpenetrator already reaches it, so promising "infinite" would
		// sell a capstone that changes very little.
		//
		// ⛔ THE PIERCE HALF IS THE SAME EDIT AS RAILGUN'S and is made ONCE, before either
		// node, in `NZPlayer.ApplyStoredUpgrades`. Two nodes writing the same two fields is
		// two chances for one of them to be reverted by the other.
		// ⛔ THE SEMI-AUTO CONVERSION IS GONE, AND THAT WAS THE POINT OF MOVING IT. Fire-mode
		// changes belong to tier 4 now, so a player cannot buy two of them; this one was the leak,
		// because it was a gimmick that happened to convert as a side effect of bounding its own
		// bounce count. The bound is a fire-rate cut instead, which costs the same throughput
		// without spending the tier's fire-mode slot.
		//
		// ⚠️ AND THE BOUNCES WENT UP WITH IT, 7 -> 10. Losing semi-auto is a large throughput
		// gain on an automatic weapon, so the node had room to get better at the thing it is for.
		new( "t5_ricochet", "Ricochet Rounds",
			"Rounds bounce off walls and pierce zombies, up to 10 hits each, but -20% fire rate",
			"Ricochet forced, bounce cap from Factor, RPM x0.8", 10f, 0f,
			new Mag( "rpm", 0.8f ) ),

		// ⚠️ Removes recoil AND spread, which on most weapons means perfect accuracy —
		// paid for with a fire rate at three tenths.
		//
		// ⛔ "NO FALLOFF" IS THE BAND ZEROED, NOT A MULTIPLIER OF 1. `ApplyFalloffTech` sets
		// FalloffStart = FalloffEnd = 0 for this node, so `DamageFor`'s
		// `FalloffEnd > FalloffStart` guard never fires and the lerp is skipped entirely —
		// which is what "the curve is gone" means. Passing a floor of 1 instead would
		// COMPOSE with Boat Tail's ceiling and land at 1.25, quietly turning a capstone's
		// no-falloff into a range BONUS ramped across the whole band.
		//
		// ⛔ AND IT THEREFORE OVERRIDES BOAT TAIL RATHER THAN STACKING WITH IT. A weapon
		// owning both keeps the flat curve, not the rising one. That is a capstone beating a
		// tier-3 pick, which is the ladder working; it is written down because the reverse
		// would be a silent 25% the player paid 700 salvage for and cannot see.
		//
		// ⚠️ Pierce: see Ricochet Rounds above — same edit, made once, and ten bodies.
		new( "t5_railgun", "Railgun",
			"+50% damage, pierces 10 zombies, no falloff, recoil or spread, but -70% fire rate",
			"Penetration unlimited, Falloff band 0, Recoil/Spread 0, RPM x0.3", 1.5f, 0f,
			new Mag( "rpm", 0.3f ),

			// ⛔ THESE TWO WERE MISSING, SO THE NODE KEPT ITS RECOIL AND SPREAD. `Weapon.RailgunFactor`
			// reads them by name and falls back to a neutral 1 when they are absent, so the "no recoil,
			// no spread" half did nothing on every weapon while the Lever above still promised it.
			// Found 2026-09-27 writing the card's text from the code; restored by request.
			//
			// ⚠️ `Multiplier: false` — a zero is a setting, not a magnitude `nz_tech_amp` may raise.
			new Mag( "recoil", 0f, Multiplier: false ),
			new Mag( "spread", 0f, Multiplier: false ) ),

		// ⛔ IT MUST BE PROCEDURAL. The roll draws from EVERY weapon in the game,
		// including ones added after this was written — a baked table would quietly stop
		// including new guns and the node would slowly narrow as the roster grew, with
		// nothing failing and nothing to notice.
		//
		// ⛔ SO IT DRAWS FROM `WeaponLibrary.All`, NOT FROM `WeaponClassStats`. That
		// distinction is the whole implementation risk and the wrong choice is the
		// tempting one:
		//   • `WeaponClassStats` reads `Assets/weapons/manifest.json` and NOTHING else.
		//     That file is HAND-AUTHORED and carries only name / pack / category — no
		//     stats at all. A weapon whose prefab exists but whose manifest entry was
		//     forgotten is invisible to it, which is exactly the silent narrowing above.
		//   • `WeaponLibrary.All` reads the manifest AND THEN sweeps
		//     `ResourceLibrary.GetAll<PrefabFile>()` for every `prefabs/weapons/*.prefab`
		//     the manifest does not know about, naming them from the filename. A newly
		//     ported weapon therefore appears with no edit anywhere. See
		//     `Buyables/WeaponLibrary.cs` — the mystery box rolls from the same list, so
		//     "can the box give it to me" and "can Chimera draw from it" stay one answer.
		//
		// ⚠️ THE STATS THEMSELVES STILL HAVE TO COME FROM THE PREFABS, because neither
		// the manifest nor WeaponClassStats holds all eight axes (fire mode and the
		// recoil/accuracy bundle are absent). So the pool is: roster from WeaponLibrary,
		// values read off each prefab's ShootInfo.
		//
		// ⚠️ BUILD THE POOL ONCE AND CACHE IT, with `WeaponLibrary.Reload()` as the
		// precedent for invalidation — it exists to be called after porting a weapon.
		// Reading 31+ prefabs per roll would be absurd, and reading them once at startup
		// would miss anything hot-loaded.
		//
		// ⛔ THE ULTIMATE GAMBLE, AND DELIBERATELY UNCAPPED. Eight stats — clip, damage,
		// fire rate, fire mode, range, recoil+accuracy, reload, pellets — each replaced
		// by a value drawn INDEPENDENTLY from a random weapon on the roster. One roll,
		// permanent, no re-roll.
		//
		// ⚠️ THE OUTCOME RANGE IS MEASURED, NOT ESTIMATED: 31^8 = 853 billion
		// combinations, best case about 820,400 DPS (1465 damage x 16 pellets x 2100
		// RPM), worst about 23 (25 damage, 1 pellet, 55 RPM, 2-round clip, 4.7s reload).
		// A spread of roughly 35,800x, with a median roll of about 379 — WORSE than most
		// guns it would replace.
		//
		// ⛔ CAPPING IT WAS CONSIDERED AND REJECTED. Both tails are the feature: the good
		// roll has to be able to beat everything else in the tier, and the bad roll has
		// to be able to waste the salvage. Do not quietly add a floor, a ceiling or a
		// re-roll later "for balance" — that would make it a worse version of
		// All-Rounder. If it ever needs softening, remove the node instead.
		// ⛔ IT IS A BASE SUBSTITUTION, NOT A FACTOR, WHICH IS WHY `Factor` IS THE NEUTRAL 1.
		// Seven of the eight axes are fields an existing helper already writes ABSOLUTELY
		// from `NZPlayer.TechBase` on every equip, so a separate Chimera write to any of
		// them would either be overwritten by that helper or overwrite it, every time the
		// gun was drawn. The roll is therefore consulted inside `TechBase` itself: the
		// drawn value stands in for the AUTHORED one, and every other node then composes on
		// top of it for free — including the sentinel guards (`ClipSize == -1`,
		// `ReloadTime <= 0`) and `TechScaled`'s floors.
		//
		// ⛔ THE ROLL HAPPENS IN `NZPlayer.AddTech`, THE ONE PLACE A NODE IS EVER RECORDED.
		// Rolling where the stats are applied would re-roll on every equip, and this node's
		// entire identity is that the gamble is taken once.
		//
		// ⚠️ THE RANGE AXIS IS REAL AND UNOBSERVABLE, and that is stated rather than fixed:
		// `FalloffStart` is authored 546-2340 units (13.9-59.4 m) and `DamageFor` gates the
		// whole curve behind it, so swapping one weapon's band for another's changes nothing
		// a player can see at the distance zombies are fought. Do not spend a day proving
		// that axis works — read the roll row in `nz_tech_live` instead.
		new( "t5_chimera", "Chimera",
			"Rolls clip, damage, rate, fire mode, range, recoil, reload, pellets from random guns",
			"new: independent per-stat reroll", 1f ),

		// ⚠️ THE DOWNSIDE IS PERMANENT AND CUMULATIVE, which is what makes it frightening
		// rather than annoying: every zombie you hit and fail to kill is faster for the
		// rest of its life. Missed kills compound into a faster horde.
		//
		// ⚠️ Zombie-side and per-victim, so it belongs with the status system rather
		// than on the weapon — and it must NOT stack per bullet, or a 16-pellet shotgun
		// would apply it sixteen times in one shot.
		//
		// ⚠️ ONE FLAT, NON-STACKING, PERMANENT STATUS PER ZOMBIE — read that way
		// deliberately. "Cumulative" above means the HORDE accumulates fast zombies, not
		// that one zombie accumulates speed: `StatusEffects.Add` already refreshes rather
		// than stacks, which is what dissolves the 16-pellet shotgun problem at zero cost.
		// Per-zombie stacking would need a per-trigger-pull serial and would let one zombie
		// outrun the player in half a second.
		// ⛔ REPLACED OUTRIGHT, AND IT USED TO DO THE OPPOSITE. The old node was x2 damage that
		// made every zombie you FAILED to kill permanently faster — a punishing, horde-wide,
		// irreversible debuff nobody could see coming. This one is about the PLAYER's speed.
		//
		// ⚠️ THREE SMALL THINGS THAT COMPOUND INTO A PLAYSTYLE. +20% is the smallest damage
		// figure in the tier; the rush and the free ADS are what the node is for. Together they mean
		// you can hold the trigger while backing through a train without ever dropping to aiming
		// speed — the one thing every other tier-5 node makes harder.
		//
		// ⚠️ AND ITS UPTIME IS A SKILL TEST RATHER THAN A TIMER. One second per hit lasts
		// exactly as long as you keep connecting; a weapon that cannot miss holds it for free, which
		// is the intended pairing.
		new( "t5_adrenaline", "Adrenaline Rounds",
			"+20% damage, +25% walk speed for 1s per zombie hit, and no slowdown while aiming",
			"Damage x1.2, +25% walk for 1s per zombie hit, no ADS move penalty", 1.2f, 0f,

			new Mag( "speed", 1.25f ),

			// ⚠️ NOT A MULTIPLIER, so `nz_tech_amp` leaves it alone — a ten-second rush off a
			// debug amp would read as a broken node rather than an exaggerated one, and the
			// magnitude that MATTERS to amplify is the speed beside it.
			new Mag( "seconds", 1f, Multiplier: false ) ),

		// ── THE TWO GIMMICKS, MOVED UP FROM TIER 4 ───────────────────
		//
		// ⚠️ NEITHER OF THESE DEFINES A CLASS, WHICH IS WHAT TIER 4 NOW MEANS. Deadeye is a
		// trick about where you aim and Slug Loader is a trick about what leaves the barrel;
		// both are things you do TO a weapon rather than a way of using one.


		// ── transforms ──────────────────────────────────────────
		//
		// ⚠️ Head scale is applied on the ZOMBIE (Health.HeadshotDamageScale, 2.5) and
		// the body/limb halves are Health's Torso/Arm/Leg/Extremity multipliers — so all
		// three parts of this node are victim-side and must read the attacker, the way
		// Vigor Rush does.
		//
		// ⛔ THE x0.5 BODY PENALTY IS THE REAL COST and it lands hardest on shotguns:
		// pellets scatter across a torso, so a KS23 firing 16 of them cannot choose to
		// hit only heads. Deadeye on a shotgun is a straight downgrade, which is correct.
		new( "t5_deadeye", "Deadeye", "×2.5 headshot damage, but body and limb hits deal half damage",
			"Health head + part multipliers", 2.5f ),

		// ⚠️ THE INVERSE OF SCATTERGUN, and a shotgun-only node: it collapses every
		// pellet into one slug carrying their combined damage, plus a bonus that SCALES
		// WITH HOW MANY PELLETS WERE CONVERTED (+5% each), so the KS23 at 16 pellets
		// gains x1.75 where an 8-pellet Olympia gains x1.35. A single-bullet rifle
		// converts nothing and gains nothing, which is the point.
		//
		// ⚠️ x3 RANGE as well (FalloffStart and FalloffEnd), because a slug with a
		// shotgun's falloff curve would still be a close-range weapon and the whole
		// idea is to turn one into a rifle.
		//
		// ⛔ WATCH THE KS23. It already has the highest raw output on the roster by 3.7x
		// (16 pellets x 265.8 damage). At x1.75 that is 7,442 in a single slug, so this
		// node is where the KS23 needs checking first, not last.
				// ⚠️ THE x3 RANGE DECLARED. Was the `SlugRange` const.
		new( "t5_slug", "Slug Loader",
			"Pellets fire as one slug: their combined damage, +5% per extra pellet, at ×3 range",
			"Bullets=1, Damage, FalloffStart/End", 1.05f, 0f,
			new Mag( "range", 3f ) ),
	};

	// ══ TIERS 4 AND 5, BY SET ══════════════════════════════════════════════════════════════

	/// <summary>
	/// ⛔ TIERS 4 AND 5 ARE OFFERED PER WEAPON (2026-10-04, Sbox nzombies/Docs/WEAPON_TECH_BY_CLASS.md). Every node
	/// here belongs to ONE set: a weapon class ("class:Submachine Guns"), an action ("action:auto"), a magazine
	/// band ("mag:21-40"), or the one-round-at-a-time reload ("reload:shell"). A gun is offered the sets of its
	/// four tags (`WeaponTags`) and nothing else, so an augment that would do nothing on it is never shown. The
	/// user designed every one of these, number for number; read the doc before changing one.
	///
	/// ⚠️ NODES THAT CAME FROM THE OLD POOLS KEEP THEIR IDS (`Legacy( "t4_overclock" )`), so the code that
	/// already reads them keeps working, and a changed one is re-stated with `with`. Slug Loader keeps `t5_slug`
	/// though it now sits in tier 4: an id is a key, and the tier is the pool it is in.
	///
	/// ⚠️ THE GENERIC STATS ARE NAMED MAGNITUDES, read by `TechStats` at each stat's own site: `s.dmg`, `s.rpm`,
	/// `s.clip`, `s.reserve`, `s.recoil`, `s.spread`, `s.reload`, `s.shell`, `s.ads`, `s.swap`, `s.move`,
	/// `s.range`, `s.head`, `s.stamina`, `s.taken` multiply; `s.rpm+`, `s.clip+`, `s.pen+`, `s.pellets+`,
	/// `s.pellets=`, `s.hp+`, `s.reserve+` (2026-10-04, Side Pouch) add or set; `f.*` are switches. So a node made only of stats needs no code of its
	/// own, and the old nodes, whose magnitudes have other names, are never counted twice.
	/// </summary>
	const string SMG = "class:Submachine Guns", AR = "class:Assault Rifles", BR = "class:Battle Rifles",
		SNIPER = "class:Sniper Rifles", LMG = "class:Light Machine Guns", SG = "class:Shotguns",
		HG = "class:Handguns", RV = "class:Revolvers";

	const string AUTO = "action:auto", BURST = "action:burst", SEMI = "action:semi", MANUAL = "action:manual";
	const string MAG8 = "mag:1-8", MAG20 = "mag:9-20", MAG40 = "mag:21-40", MAG60 = "mag:41-60", MAG61 = "mag:61+";
	const string SHELL = "reload:shell";

	/// <summary>An old node, by id, as the legacy pools define it.</summary>
	static Node Legacy( string id ) => Legacy4.Concat( Legacy5 ).First( n => n.Id == id );

	/// <summary>A magnitude that multiplies, and so amplifies under `nz_tech_amp`.</summary>
	static Mag Times( string name, float value ) => new( name, value );

	/// <summary>A count, an addend or a switch: never amplified.</summary>
	static Mag Fixed( string name, float value = 1f ) => new( name, value, false );

	static (string Set, Node Node)[] Tier4Sets => new (string Set, Node Node)[]
	{
		// ── Submachine Guns ── high fire rate and mobility, low recoil, low penetration
		(SMG, new Node( "t4_smg_runngun", "Run 'n' Gun", "Fire while sprinting, and hip fire is as tight as aiming",
			"sprint fire; hip spread = aimed spread", 1f, 0f, Fixed( "f.sprintfire" ), Fixed( "f.hipaimed" ) )),
		(SMG, new Node( "t4_smg_skeleton", "Skeleton Stock", "+20% move speed, but sprinting uses 15% more stamina",
			"walk/sprint x1.2, stamina drain x1.15", 1.2f, 0f, Times( "s.move", 1.2f ), Times( "s.stamina", 1.15f ) )),
		(SMG, new Node( "t4_smg_cqb", "CQB Barrel", "+60% damage up close, but half the range",
			"Damage x1.6, falloff distances x0.5", 1.6f, 0f, Times( "s.dmg", 1.6f ), Times( "s.range", 0.5f ) )),
		(SMG, new Node( "t4_smg_ap", "AP Conversion", "+3 penetration and +25% damage, but -35% fire rate",
			"Penetration +3, Damage x1.25, RPM x0.65", 1.25f, 0f,
			Fixed( "s.pen+", 3f ), Times( "s.dmg", 1.25f ), Times( "s.rpm", 0.65f ) )),
		(SMG, Legacy( "t4_overclock" )),

		// ── Assault Rifles ── the reliable all-rounder: better at everything, or a lot better at one job
		(AR, Legacy( "t4_allround" )),
		// ⛔ EACH MODE ONE GAIN AND ONE COST (2026-10-04, the user): bursts fire 50% faster with 0.1 s between them, and semi
		// fires 20% slower for double damage. It was bursts +20% damage and semi +50%. `burstgap` is seconds, so Fixed.
		(AR, new Node( "t4_ar_selectfire", "Select Fire",
			"E+R cycles auto, burst and semi. Burst: +50% fire rate, 0.1 s between bursts. Semi: -20% fire rate, double damage",
			"E+R: auto -> burst -> semi; burst RPM x1.5 + 0.1 s before each burst; semi RPM x0.8, Damage x2", 1f, 0f,
			Times( "burstrate", 1.5f ), Fixed( "burstgap", 0.1f ), Times( "semirate", 0.8f ), Times( "semi", 2f ) )),
		(AR, new Node( "t4_ar_marksman", "Marksman Conversion",
			"Semi-auto, x2.5 damage, +50% headshot damage, +2 penetration and no damage falloff; but -60% fire rate, half the magazine and +50% recoil",
			"FiringType=semi, Damage x2.5, head x1.5, Penetration +2, no falloff, RPM x0.4, ClipSize x0.5, Recoil x1.5",
			2.5f, 0f, Fixed( "f.semi" ), Times( "s.dmg", 2.5f ), Times( "s.head", 1.5f ), Fixed( "s.pen+", 2f ),
			Fixed( "f.nofalloff" ), Times( "s.rpm", 0.4f ), Times( "s.clip", 0.5f ), Times( "s.recoil", 1.5f ) )),
		(AR, new Node( "t4_ar_carbine", "Carbine Conversion",
			"+30% fire rate, +15% move speed, faster aiming and reloads, -20% recoil; but half the range and -15% damage",
			"RPM x1.3, move x1.15, ADS x1.3, reload x0.77, Recoil x0.8, falloff x0.5, Damage x0.85", 1.3f, 0f,
			Times( "s.rpm", 1.3f ), Times( "s.move", 1.15f ), Times( "s.ads", 1.3f ), Times( "s.reload", 0.77f ),
			Times( "s.recoil", 0.8f ), Times( "s.range", 0.5f ), Times( "s.dmg", 0.85f ) )),
		(AR, Legacy( "t4_emplacement" )),
		(AR, Legacy( "t4_solidslug" )),

		// ── Battle Rifles (the marksman class) ── precision and damage per shot at range
		(BR, new Node( "t4_br_matchgrade", "Match Grade", "+20% damage, +25% headshot damage, -25% recoil",
			"Damage x1.2, head x1.25, Recoil x0.75", 1.2f, 0f,
			Times( "s.dmg", 1.2f ), Times( "s.head", 1.25f ), Times( "s.recoil", 0.75f ) )),
		(BR, new Node( "t4_br_battleconv", "Battle Rifle Conversion", "Full-auto and +20% fire rate, but -25% damage",
			"FiringType=auto, RPM x1.2, Damage x0.75", 1.2f, 0f,
			Fixed( "f.auto" ), Times( "s.rpm", 1.2f ), Times( "s.dmg", 0.75f ) )),
		(BR, Legacy( "t4_boltgun" )),
		(BR, new Node( "t4_br_heavybarrel", "Heavy Barrel",
			"+60% damage and +3 penetration, but -30% fire rate and you move 10% slower",
			"Damage x1.6, Penetration +3, RPM x0.7, move x0.9", 1.6f, 0f,
			Times( "s.dmg", 1.6f ), Fixed( "s.pen+", 3f ), Times( "s.rpm", 0.7f ), Times( "s.move", 0.9f ) )),
		(BR, new Node( "t4_br_scout", "Scout",
			"Semi-auto and fires as fast as you can click; 70% faster aiming and weapon swapping",
			"FiringType=semi, RPM +1000 flat (Hair Trigger's), ADS x1.7, swap x1.7", 1000f, 0f,
			Fixed( "f.semi" ), Fixed( "s.rpm+", 1000f ), Times( "s.ads", 1.7f ), Times( "s.swap", 1.7f ) )),

		// ── Sniper Rifles ── one shot, one kill
		(SNIPER, new Node( "t4_sn_heavymatch", "Heavy Match Ammo", "+2 penetration, and limbs and extremities take full damage",
			"Penetration +2, limb floor 1 (Hollow Points')", 2f, 0f, Fixed( "s.pen+", 2f ), Fixed( "f.limbsfull" ) )),
		(SNIPER, new Node( "t4_sn_semiauto", "Semi-Auto Action", "x2.5 fire rate, but -40% damage",
			"RPM x2.5, Damage x0.6", 2.5f, 0f, Times( "s.rpm", 2.5f ), Times( "s.dmg", 0.6f ) )),
		(SNIPER, new Node( "t4_sn_antimateriel", "Anti-Materiel",
			"x2 damage and pierces every zombie in line, but -40% fire rate, 15% slower movement, half the magazine (at least 1) and 20% slower aiming",
			"Damage x2, Penetration unlimited, RPM x0.6, move x0.85, ClipSize x0.5 (min 1), ADS x0.8", 2f, 0f,
			Times( "s.dmg", 2f ), Fixed( "f.pierceall" ), Times( "s.rpm", 0.6f ), Times( "s.move", 0.85f ),
			Times( "s.clip", 0.5f ), Times( "s.ads", 0.8f ) )),
		(SNIPER, new Node( "t4_sn_quickscope", "Quickscope Stock",
			"80% faster aiming and swapping, +15% move speed, but -25% damage",
			"ADS x1.8, swap x1.8, move x1.15, Damage x0.75", 1.8f, 0f,
			Times( "s.ads", 1.8f ), Times( "s.swap", 1.8f ), Times( "s.move", 1.15f ), Times( "s.dmg", 0.75f ) )),
		(SNIPER, new Node( "t4_sn_boxmag", "Box Magazine", "x2.5 magazine and 40% faster reloads, but -20% damage",
			"ClipSize x2.5, reload x0.714, Damage x0.8", 2.5f, 0f,
			Times( "s.clip", 2.5f ), Times( "s.reload", 0.714f ), Times( "s.dmg", 0.8f ) )),

		// ── Light Machine Guns ── sustained fire, paid for with movement and downtime
		(LMG, Legacy( "t4_counterweight" ) with
		{
			Effect = "No recoil at all, but wider spread",
			Lever = "FinishRecoil kick x0, spread x1.5",
			// ⚠️ `dmg` STAYS, AT 1: the recoil site still reads it, and the user replaced the damage cut.
			Extra = new[] { Times( "dmg", 1f ), Times( "s.spread", 1.5f ) },
		}),
		(LMG, new Node( "t4_lmg_lightframe", "Light Frame", "Removes the 20% move penalty and aims 50% faster, but half the magazine",
			"move x1.25 (cancels the LMG's x0.8), ADS x1.5, ClipSize x0.5", 1.25f, 0f,
			Times( "s.move", 1.25f ), Times( "s.ads", 1.5f ), Times( "s.clip", 0.5f ) )),
		(LMG, new Node( "t4_lmg_speedloader", "Speed Loader", "Reloads 60% faster, but -25% magazine",
			"reload x0.625, ClipSize x0.75", 1.6f, 0f, Times( "s.reload", 0.625f ), Times( "s.clip", 0.75f ) )),
		(LMG, new Node( "t4_lmg_ammobox", "Ammo Box", "x2 magazine and +50% reserve ammo, but reloads take 50% longer",
			"ClipSize x2, reserve x1.5, reload x1.5", 2f, 0f,
			Times( "s.clip", 2f ), Times( "s.reserve", 1.5f ), Times( "s.reload", 1.5f ) )),
		(LMG, Legacy( "t4_heavy" )),

		// ── Shotguns ── the biggest hit up close
		(SG, new Node( "t4_sg_choke", "Choke", "60% tighter spread and twice the range, but -2 pellets",
			"spread x0.4, falloff x2, Bullets -2", 2f, 0f,
			Times( "s.spread", 0.4f ), Times( "s.range", 2f ), Fixed( "s.pellets+", -2f ) )),
		(SG, new Node( "t4_sg_sawedoff", "Sawed-Off",
			"+4 pellets, 50% faster swapping and aiming, but double the spread and half the magazine",
			"Bullets +4, swap x1.5, ADS x1.5, spread x2, ClipSize x0.5", 4f, 0f,
			Fixed( "s.pellets+", 4f ), Times( "s.swap", 1.5f ), Times( "s.ads", 1.5f ), Times( "s.spread", 2f ),
			Times( "s.clip", 0.5f ) )),
		(SG, new Node( "t4_sg_drumfeed", "Drum Feed", "x2 magazine and the whole magazine reloads at once, but -20% damage",
			"ClipSize x2, one-at-a-time reload becomes whole, Damage x0.8", 2f, 0f,
			Times( "s.clip", 2f ), Fixed( "f.wholereload" ), Times( "s.dmg", 0.8f ) )),
		(SG, Legacy( "t4_autoload" ) with
		{
			Effect = "Full-auto single pellets at quarter spread, -70% recoil and +1 penetration; damage, rate and magazine x half the pellets",
			Lever = "Bullets 1, full-auto, spread x0.25, dmg/rpm/clip x (pellets x 0.5), Recoil x0.3, Penetration +1",
			Extra = new[] { Times( "spread", 0.25f ), Times( "s.recoil", 0.3f ), Fixed( "s.pen+", 1f ) },
		}),
		(SG, Legacy( "t4_doublefeed" )),
		// ⛔ IN TIER 4 BESIDE AUTOLOADER, ON PURPOSE: both rewrite the pellets, so they must never pair. The user.
		(SG, Legacy( "t5_slug" ) with
		{
			Effect = "Pellets fire as one slug: their combined damage, +5% per extra pellet, x3 range and +3 penetration",
			Lever = "Bullets=1, Damage, FalloffStart/End, Penetration +3",
			Extra = new[] { Times( "range", 3f ), Fixed( "s.pen+", 3f ) },
		}),

		// ── Handguns ── never the best gun: the utility and clutch class
		(HG, new Node( "t4_hg_quickdraw", "Quickdraw Holster",
			"Swapping to and from this gun is instant, and you move 15% faster holding it, but -20% damage",
			"draw/holster 0, move x1.15, Damage x0.8", 1.15f, 0f,
			Fixed( "f.instantswap" ), Times( "s.move", 1.15f ), Times( "s.dmg", 0.8f ) )),
		(HG, new Node( "t4_hg_bottomless", "Bottomless", "Infinite reserve ammo, but -40% damage",
			"reserve never spent, Damage x0.6", 0.6f, 0f, Fixed( "f.infreserve" ), Times( "s.dmg", 0.6f ) )),
		(HG, new Node( "t4_hg_machinepistol", "Machine Pistol", "Full-auto and +50% fire rate, but -30% damage and +50% recoil",
			"FiringType=auto, RPM x1.5, Damage x0.7, Recoil x1.5", 1.5f, 0f,
			Fixed( "f.auto" ), Times( "s.rpm", 1.5f ), Times( "s.dmg", 0.7f ), Times( "s.recoil", 1.5f ) )),
		(HG, new Node( "t4_hg_handcannon", "Hand Cannon", "x2.5 damage and +2 penetration, but half the fire rate and half the magazine",
			"Damage x2.5, Penetration +2, RPM x0.5, ClipSize x0.5", 2.5f, 0f,
			Times( "s.dmg", 2.5f ), Fixed( "s.pen+", 2f ), Times( "s.rpm", 0.5f ), Times( "s.clip", 0.5f ) )),
		(HG, Legacy( "t4_hairtrigger" )),

		// ── Revolvers ── high damage, and every augment unique and weird
		(RV, new Node( "t4_rv_moonclips", "Moon Clips", "The whole cylinder reloads at once and 50% faster, but -1 round",
			"one-at-a-time reload becomes whole, reload x0.667, ClipSize -1", 1.5f, 0f,
			Fixed( "f.wholereload" ), Times( "s.reload", 0.667f ), Fixed( "s.clip+", -1f ) )),
		(RV, new Node( "t4_rv_luckysix", "Lucky Six", "Each shot has a 1-in-6 chance to deal x6 damage",
			"Damage x6 on a 1-in-6 roll", 6f, 0f, Fixed( "odds", 6f ) )),
		(RV, new Node( "t4_rv_spin", "Spin the Cylinder",
			"Each reload rolls one bonus for that cylinder: x2 damage, x3 cylinder size, unlimited penetration, or 5 pellets at half damage each",
			"per reload (per 6 shots with Blood Price): Damage x2 | ClipSize x3 | Penetration unlimited | Bullets 5 at Damage x0.5",
			1f, 0f, Fixed( "shots", 6f ) )),
		(RV, new Node( "t4_rv_gunslinger", "Gunslinger", "No aiming at all, but hip fire is perfectly accurate and you move 20% faster",
			"ADS disabled, hip spread 0, move x1.2", 1.2f, 0f,
			Fixed( "f.noads" ), Fixed( "f.hipzero" ), Times( "s.move", 1.2f ) )),
		(RV, new Node( "t4_rv_silver", "Silver Bullets", "x3 damage to bosses and x2 to special zombies, but -25% to normal ones",
			"Damage x3 boss, x2 special, x0.75 other", 3f, 0f,
			Times( "boss", 3f ), Times( "special", 2f ), Times( "normal", 0.75f ) )),

		// ── actions (blue in the Arsenal) ──
		(AUTO, new Node( "t4_act_rapidspring", "Rapid Spring", "+20% fire rate", "RPM x1.2", 1.2f, 0f,
			Times( "s.rpm", 1.2f ) )),
		(BURST, new Node( "t4_act_doubleburst", "Double Burst", "Each burst fires twice the rounds (3 -> 6)",
			"burst rounds x2", 2f, 0f, Fixed( "s.burst", 2f ) )),
		(SEMI, new Node( "t4_act_heavytrigger", "Heavy Trigger", "+30% damage, but -25% fire rate",
			"Damage x1.3, RPM x0.75", 1.3f, 0f, Times( "s.dmg", 1.3f ), Times( "s.rpm", 0.75f ) )),
		(MANUAL, new Node( "t4_act_fastcycle", "Fast Cycle", "+100 RPM", "RPM +100 flat", 100f, 0f,
			Fixed( "s.rpm+", 100f ) )),

		// ── magazine bands (purple in the Arsenal) ──
		(MAG8, new Node( "t4_mag_deepmag", "Deep Mag", "+20 rounds", "ClipSize +20", 20f, 0f, Fixed( "s.clip+", 20f ) )),
		(MAG20, new Node( "t4_mag_topoff", "Top-Off", "Reloading with rounds still in the magazine is 50% faster",
			"reload x0.667 when the magazine is not empty", 0.667f, 0f, Times( "partial", 0.667f ) )),
		(MAG40, new Node( "t4_mag_painreload", "Pain Reload", "Taking damage instantly reloads the gun",
			"on damage taken: magazine refilled from reserve", 1f, 0f )),
		(MAG60, new Node( "t4_mag_shortbelt", "Short Belt", "-20 rounds, but +50% damage",
			"ClipSize -20, Damage x1.5", 1.5f, 0f, Fixed( "s.clip+", -20f ), Times( "s.dmg", 1.5f ) )),
		(MAG61, new Node( "t4_mag_buckshot", "Buckshot Belt",
			"-80% magazine, but each shot fires 6 pellets at 70% damage each, with double the spread",
			"ClipSize x0.2, Bullets 6, Damage x0.7, spread x2", 6f, 0f,
			Times( "s.clip", 0.2f ), Fixed( "s.pellets=", 6f ), Times( "s.dmg", 0.7f ), Times( "s.spread", 2f ) )),

		// ── one round at a time (orange in the Arsenal) ──
		(SHELL, new Node( "t4_rl_quickshells", "Quick Shells", "Each round loads 100% faster",
			"ShellReloadInsertTime x0.5", 2f, 0f, Times( "s.shell", 0.5f ) )),
	};

	static (string Set, Node Node)[] Tier5Sets => new (string Set, Node Node)[]
	{
		// ── Submachine Guns ──
		(SMG, Legacy( "t5_adrenaline" ) with
		{
			Effect = "Each hit gives +1% damage and +1% aiming strafe speed, up to +50% each. A second without a hit drops it all",
			Lever = "per zombie hit: Damage +1%, ADS strafe +1%, cap +50%; reset after 1 s without a hit",
			Factor = 0.01f,
			Extra = new[] { Fixed( "per", 0.01f ), Fixed( "cap", 0.5f ), Fixed( "seconds", 1f ) },
		}),
		(SMG, new Node( "t5_smg_momentum", "Momentum",
			"While the trigger is held, each shot adds +2.5% fire rate. Resets when you let go or reload",
			"RPM +2.5% per shot in one trigger hold, no cap", 0.025f, 0f, Fixed( "per", 0.025f ) )),
		(SMG, new Node( "t5_smg_shredder", "Shredder", "Bullets lose no damage passing through zombies",
			"PenetrationDamageMult 1", 1f, 0f, Fixed( "f.nopenloss" ) )),
		(SMG, new Node( "t5_smg_suppress", "Suppressive Fire", "Each hit slows the zombie 5% for 2 s, stacking to 50%",
			"zombie speed -5% per hit for 2 s, floor 50%", 0.05f, 0f,
			Fixed( "per", 0.05f ), Fixed( "cap", 0.5f ), Fixed( "seconds", 2f ) )),
		(SMG, new Node( "t5_smg_dynamo", "Dynamo", "Spending stamina slowly refills the magazine; shooting refills stamina",
			"1 round per 10 stamina spent; +1 stamina per shot", 1f, 0f,
			Fixed( "rounds", 0.1f ), Fixed( "stamina", 1f ) )),

		// ── Assault Rifles ──
		(AR, new Node( "t5_ar_overwatch", "Overwatch",
			"After aiming for 1 s, moving or not, shots deal +50% damage and pierce 2 more zombies",
			"after 1 s ADS: Damage x1.5, Penetration +2", 1.5f, 0f,
			Fixed( "seconds", 1f ), Times( "dmg", 1.5f ), Fixed( "pen", 2f ) )),
		(AR, new Node( "t5_ar_underbarrel", "Underbarrel Launcher",
			"Every 20 kills charges a grenade. Double-tap aim to fire it: the shot explodes for 10x the weapon's damage in a 200-unit radius",
			"20 kills = 1 grenade; double-tap ADS (0.3 s): blast of 10x weapon damage, 200 u", 10f, 0f,
			Fixed( "kills", 20f ), Fixed( "dmg", 10f ), Fixed( "radius", 200f ), Fixed( "window", 0.3f ) )),
		(AR, new Node( "t5_ar_spotter", "Spotter Rounds",
			"Hits mark the zombie for 3 s: +15% damage from everyone, and it's outlined for your team",
			"mark 3 s: Vulnerability x1.15, outline", 1.15f, 0f, Times( "vuln", 1.15f ), Fixed( "seconds", 3f ) )),
		(AR, new Node( "t5_ar_quartermaster", "Quartermaster",
			"Every kill puts 1 round back in the magazine; every 25 kills refills a magazine's worth of reserve",
			"kill: clip +1; every 25 kills: reserve + one magazine", 1f, 0f, Fixed( "rounds", 1f ), Fixed( "kills", 25f ) )),
		(AR, new Node( "t5_ar_catalyst", "Catalyst",
			"Ammo mods cool down 50% faster and get +5% trigger chance. Blast Furnace deals +50% damage instead",
			"ammo mod cooldown x0.5, chance +0.05; Blast Furnace damage x1.5", 0.5f, 0f,
			Times( "cooldown", 0.5f ), Fixed( "chance", 0.05f ), Times( "furnace", 1.5f ) )),

		// ── Battle Rifles (marksman) ──
		(BR, Legacy( "t5_widebore" )),
		(BR, Legacy( "t5_deadeye" )),
		(BR, new Node( "t5_br_doublestack", "Double Stack", "x2 magazine", "ClipSize x2", 2f, 0f, Times( "s.clip", 2f ) )),
		(BR, new Node( "t5_br_stopping", "Stopping Power", "Every hit staggers the zombie for 0.25 s",
			"stun 0.25 s per hit", 0.25f, 0f, Fixed( "seconds", 0.25f ) )),
		(BR, new Node( "t5_br_finalround", "Final Round",
			"The last round in the magazine deals (magazine size / 2) x the weapon's damage, and pierces every zombie in its path",
			"last round: Damage x (clip x 0.5), Penetration unlimited", 0.5f, 0f, Fixed( "per", 0.5f ) )),

		// ── Sniper Rifles ──
		(SNIPER, new Node( "t5_sn_steadybreath", "Steady Breath",
			"Aiming without firing raises the next shot's damage linearly, up to x5 after 5 s",
			"ADS held without a shot: Damage x (1 + 4 x t/5), resets on a shot or on letting go", 5f, 0f,
			Fixed( "max", 5f ), Fixed( "seconds", 5f ) )),
		(SNIPER, new Node( "t5_sn_salvo", "Salvo",
			"3-round bursts with a short pause between them; x10 fire rate and x3 magazine, but -50% damage",
			"FiringType=burst(3), RPM x10, ClipSize x3, Damage x0.5, 0.3 s between bursts", 10f, 0f,
			Fixed( "f.burst" ), Times( "s.rpm", 10f ), Times( "s.clip", 3f ), Times( "s.dmg", 0.5f ),
			Fixed( "pause", 0.3f ) )),
		(SNIPER, Legacy( "t5_bouncy" )),
		(SNIPER, new Node( "t5_sn_collateral", "Collateral",
			"Each zombie the bullet passes through adds +25% damage, instead of losing some",
			"PenetrationDamageMult 1.25 (grows)", 1.25f, 0f, Times( "per", 1.25f ) )),
		(SNIPER, new Node( "t5_sn_oneshot", "One Shot, One Kill", "Headshots deal x5 damage, but every other hit deals a fifth",
			"Health head x5, body and limbs x0.2", 5f, 0f, Times( "head", 5f ), Times( "body", 0.2f ) )),

		// ── Light Machine Guns ──
		(LMG, Legacy( "t5_siege" )),
		(LMG, new Node( "t5_lmg_bipod", "Bipod", "x2 magazine and x2 damage, but you move far slower",
			"ClipSize x2, Damage x2, move x0.5", 2f, 0f,
			Times( "s.clip", 2f ), Times( "s.dmg", 2f ), Times( "s.move", 0.5f ) )),
		(LMG, new Node( "t5_lmg_juggernaut", "Juggernaut", "You take 20% less damage while holding this gun",
			"damage taken x0.8 while held", 0.8f, 0f, Times( "s.taken", 0.8f ) )),
		(LMG, new Node( "t5_lmg_tungsten", "Tungsten Belt", "x0.1 spread, +3 penetration, and no damage lost through bodies",
			"spread x0.1, Penetration +3, PenetrationDamageMult 1", 0.1f, 0f,
			Times( "s.spread", 0.1f ), Fixed( "s.pen+", 3f ), Fixed( "f.nopenloss" ) )),
		(LMG, new Node( "t5_lmg_walkingfire", "Walking Fire", "Fire while sprinting and 50% less spread, but no aiming",
			"sprint fire, spread x0.5, ADS disabled", 0.5f, 0f,
			Fixed( "f.sprintfire" ), Times( "s.spread", 0.5f ), Fixed( "f.noads" ) )),

		// ── Shotguns ──
		(SG, Legacy( "t5_perforator" )),
		(SG, new Node( "t5_sg_pointblank", "Point Blank", "Hits within 150 units deal x2 damage and stagger the zombie for 0.25 s",
			"within 150 u: Damage x2, stun 0.25 s", 2f, 0f,
			Times( "dmg", 2f ), Fixed( "range", 150f ), Fixed( "seconds", 0.25f ) )),
		(SG, Legacy( "t5_explosive" ) with
		{
			Effect = "Each shot explodes for half its damage on zombies within 100 units, but -40% damage",
			Lever = "Grenade AoE per impact (100 u, with an explosion), Damage x0.6",
			Extra = new[] { Fixed( "radius", 100f ) },
		}),
		(SG, Legacy( "t5_ricochet" ) with
		{
			Effect = "Rounds bounce off walls and pierce zombies, up to 10 hits each, but -20% fire rate and wider spread",
			Lever = "Ricochet forced, bounce cap from Factor, RPM x0.8, spread x1.5",
			Extra = new[] { Times( "rpm", 0.8f ), Times( "s.spread", 1.5f ) },
		}),

		// ── Handguns ──
		(HG, new Node( "t5_hg_stickmag", "Stick Mag", "x5 magazine", "ClipSize x5", 5f, 0f, Times( "s.clip", 5f ) )),
		(HG, new Node( "t5_hg_holsterreload", "Holster Reload", "Switching to this gun instantly reloads your other guns",
			"on draw: every other gun's magazine refilled from its reserve", 1f, 0f )),
		(HG, new Node( "t5_hg_lifeline", "Lifeline", "+150 max health while you hold this gun",
			"max health +150 while held", 150f, 0f, Fixed( "s.hp+", 150f ) )),
		(HG, new Node( "t5_hg_flashbang", "Flashbang Rounds",
			"-75% damage, but unlimited penetration, and every zombie hit is stunned for 1 s (not bosses)",
			"Damage x0.25, Penetration unlimited, stun 1 s (bosses immune)", 0.25f, 0f,
			Times( "s.dmg", 0.25f ), Fixed( "f.pierceall" ), Fixed( "seconds", 1f ) )),
		// ⚠️ THE LINK HOLDS WHILE THE GROUP IS HIT (2026-10-06): a hit on any marked zombie renews every mark (`ClassTech.ShareMarked`)
		(HG, new Node( "t5_hg_marker", "Marker",
			"Hits mark the zombie. Damage dealt to a marked zombie, by anyone, is dealt to every marked zombie and renews all their marks (5 s)",
			"mark 5 s, renewed for the whole group by any hit on a marked zombie; damage to a marked zombie copied to every marked zombie (copies never copy)", 5f, 0f,
			Fixed( "seconds", 5f ) )),

		// ── Revolvers ──
		(RV, new Node( "t5_rv_fan", "Fan the Hammer", "Hold the trigger to fan the hammer: full-auto at x3 fire rate, but x3 spread",
			"FiringType=auto, RPM x3, spread x3", 3f, 0f, Fixed( "f.auto" ), Times( "s.rpm", 3f ), Times( "s.spread", 3f ) )),
		(RV, new Node( "t5_rv_bloodprice", "Blood Price", "No ammo at all: every shot costs 10 health instead. It can down you",
			"no clip or reserve; a shot costs 10 health", 10f, 0f, Fixed( "hp", 10f ) )),
		(RV, new Node( "t5_rv_singleaction", "Single Action",
			"x2 damage, but each shot needs the trigger held for 0.2 s; letting go early fires nothing",
			"Damage x2, a 0.2 s hold before each shot", 2f, 0f, Times( "s.dmg", 2f ), Fixed( "hold", 0.2f ) )),
		(RV, new Node( "t5_rv_highnoon", "High Noon",
			"Aim for 1.5 s to mark up to 6 zombies in view; your next trigger pull shoots them all",
			"ADS 1.5 s: mark up to 6 visible; next shot hits every mark", 6f, 0f, Fixed( "seconds", 1.5f ), Fixed( "count", 6f ) )),
		(RV, new Node( "t5_rv_unload", "Unload", "One trigger pull fires the whole cylinder at x10 fire rate, and it can't be interrupted",
			"one pull = the whole magazine, RPM x10, uninterruptible", 10f, 0f, Times( "rpm", 10f ) )),

		// ── actions (blue) ──
		(AUTO, new Node( "t5_act_extendedfeed", "Extended Feed", "+50% magazine", "ClipSize x1.5", 1.5f, 0f,
			Times( "s.clip", 1.5f ) )),
		(BURST, new Node( "t5_act_escalation", "Escalation", "Each round in a burst deals x1.2 the damage of the one before",
			"burst damage ramp x1.2 per round", 1.2f, 0f, Times( "ramp", 1.2f ) )),
		(SEMI, new Node( "t5_act_precision", "Precision", "+35% headshot damage", "head x1.35", 1.35f, 0f,
			Times( "s.head", 1.35f ) )),
		(MANUAL, new Node( "t5_act_boltstrike", "Bolt Strike",
			"Each cycle of the action stuns zombies within 80 units for 0.5 s (not bosses)",
			"every shot: stun 0.5 s within 80 u of you, bosses immune", 0.5f, 0f,
			Fixed( "radius", 80f ), Fixed( "seconds", 0.5f ) )),

		// ── magazine bands (purple) ──
		(MAG8, new Node( "t5_mag_snapreload", "Snap Reload", "Reloading an empty magazine is instant",
			"empty reload: 0 s", 0f, 0f )),
		(MAG20, new Node( "t5_mag_thrifty", "Thrifty", "Each hit has a 25% chance not to use a round",
			"per hit: 25% to put the round back", 0.25f, 0f, Fixed( "chance", 0.25f ) )),
		(MAG40, new Node( "t5_mag_lastten", "Last Ten", "The last 10 rounds of a magazine deal +40% damage",
			"clip <= 10: Damage x1.4", 1.4f, 0f, Fixed( "rounds", 10f ), Times( "dmg", 1.4f ) )),
		(MAG60, new Node( "t5_mag_recycler", "Recycler", "Each kill puts 2 rounds back in the magazine",
			"kill: clip +2", 2f, 0f, Fixed( "rounds", 2f ) )),
		(MAG61, new Node( "t5_mag_longhaul", "Long Haul",
			"+1% damage for every round fired from the magazine, up to +150%; reloading resets it",
			"Damage +1% per round fired since the reload, cap +150%", 0.01f, 0f, Fixed( "per", 0.01f ), Fixed( "cap", 1.5f ) )),

		// ── one round at a time (orange) ──
		(SHELL, new Node( "t5_rl_stripperclip", "Stripper Clip", "Every round loads at once, in the time of one",
			"one-at-a-time reload: every round in one insert", 1f, 0f, Fixed( "f.wholereload1" ) )),
	};

	// ══ TIERS 1 TO 3, BY SET ══════════════════════════════════════════════════════════════

	/// <summary>
	/// ⛔ TIERS 1–3 ARE OFFERED PER WEAPON TOO (2026-10-04, Sbox nzombies/Docs/WEAPON_TECH_TIERS_1_3.md), by the same tags
	/// as tiers 4 and 5: five a tier for each class, one a tier for each action and each magazine band, so a gun sees up to
	/// seven. ⛔ NO RELOAD SET IN THESE TIERS: the user asked only for fire type and magazine size.
	///
	/// ⚠️ ONE NODE PER EFFECT. A node several classes share (Fast Hands, Rapid Fire, Steel Core, Runner's Grip, Rechamber
	/// Rush…) is ONE object in several rows: the tier's pool holds it once (`PoolOf`), and `SetOf` answers its first class.
	///
	/// ⚠️ THE OLD TWENTY KEEP THEIR IDS, NAMES, NUMBERS AND WIRING (`Old`): their effects are read by id at their own sites,
	/// and none is retired. Every new node carries its numbers as named magnitudes: `s.*`/`f.*` on the ones made only of
	/// stats (`TechStats`, no code of their own), plain names on the mechanics, which stay in `NotWired` until built.
	/// </summary>
	static (string Set, Node Node)[] Tier1Sets
	{
		get
		{
			var old = Legacy1;
			Node fastHands = Old( old, "t1_reload" ), extMag = Old( old, "t1_clip" ), deepPockets = Old( old, "t1_reserve" ),
				recoilControl = Old( old, "t1_recoil" ), pointShooting = Old( old, "t1_hipspread" ),
				quickdraw = Old( old, "t1_ads" ), featherweight = Old( old, "t1_strafe" );

			var floated = new Node( "t1_floated", "Floated Barrel", "-15% spread, hip and aimed",
				"spread x0.85, hip and aimed", 0.85f, 0f, Times( "s.spread", 0.85f ) );
			// ⚠️ `s.swap` IS A SPEED (it divides the draw and the put-away), so "25% faster" is x1.25, as Scout's 70% is x1.7.
			var sling = new Node( "t1_sling", "Quick Sling", "25% faster swapping to and from it",
				"draw and put-away x1.25 (a speed)", 1.25f, 0f, Times( "s.swap", 1.25f ) );
			var furniture = new Node( "t1_furniture", "Light Furniture", "+5% move speed while you hold it",
				"walk/sprint x1.05 while held", 1.05f, 0f, Times( "s.move", 1.05f ) );

			return new (string Set, Node Node)[]
			{
				// ── classes: which five of the eleven basics each gets (the doc's tier-1 table) ──
				(SMG, deepPockets), (SMG, featherweight), (SMG, floated), (SMG, sling), (SMG, furniture),
				(AR, fastHands), (AR, extMag), (AR, recoilControl), (AR, quickdraw), (AR, featherweight),
				(BR, fastHands), (BR, recoilControl), (BR, quickdraw), (BR, floated), (BR, sling),
				(SNIPER, fastHands), (SNIPER, extMag), (SNIPER, deepPockets), (SNIPER, quickdraw), (SNIPER, featherweight),
				(LMG, deepPockets), (LMG, quickdraw), (LMG, floated), (LMG, sling),
				(LMG, new Node( "t1_lmg_carryhandle", "Carry Handle", "+8% move speed while you hold it",
					"walk/sprint x1.08 while held (eases the LMG's x0.8)", 1.08f, 0f, Times( "s.move", 1.08f ) )),
				(SG, fastHands), (SG, deepPockets), (SG, recoilControl), (SG, pointShooting), (SG, furniture),
				(HG, extMag), (HG, pointShooting), (HG, quickdraw), (HG, featherweight), (HG, sling),
				// ⚠️ THE REVOLVER'S +1 ROUND IS EXTENDED MAG, which never adds less than one round.
				(RV, fastHands), (RV, extMag), (RV, recoilControl), (RV, floated), (RV, sling),

				// ── actions (blue in the Arsenal) ──
				(AUTO, new Node( "t1_act_triggergrip", "Trigger Grip", "+10% move speed while firing",
					"walk/sprint x1.1 while the gun is firing", 1.1f, 0f, Times( "move", 1.1f ) )),
				(BURST, new Node( "t1_act_flatburst", "Flat Burst", "Only a burst's first round kicks; the rest add no recoil",
					"recoil kick x0 on every round of a burst after the first", 0f, 0f, Fixed( "recoil", 0f ) )),
				// ⚠️ A TIME, LIKE THE RELOAD NODES': "30% faster" is the walk-back's `RecoilRecoveryTime` x1/1.3.
				(SEMI, new Node( "t1_act_quicksettle", "Quick Settle", "Recoil settles 30% faster after each shot",
					"RecoilRecoveryTime x0.769 (the walk-back 30% faster)", 0.769f, 0f, Times( "recovery", 0.769f ) )),
				(MANUAL, new Node( "t1_act_smoothaction", "Smooth Action", "The action cycles 15% faster",
					"the bolt or pump cycle (shot interval, BoltBackTime) at x1.15 rate", 1.15f, 0f, Times( "cycle", 1.15f ) )),

				// ── magazine bands (purple in the Arsenal) ──
				// ⚠️ `s.reserve+` IS FLAT, added after Deep Pockets' +20% so neither scales the other (NZPlayer's reserve site).
				(MAG8, new Node( "t1_mag_sidepouch", "Side Pouch", "+30 reserve ammo",
					"MaxReserve +30 (flat, after the percentages)", 30f, 0f, Fixed( "s.reserve+", 30f ) )),
				(MAG20, new Node( "t1_mag_stackedmag", "Stacked Mag", "+5 rounds", "ClipSize +5", 5f, 0f,
					Fixed( "s.clip+", 5f ) )),
				(MAG40, new Node( "t1_mag_runningreload", "Running Reload", "You move 10% faster while reloading",
					"walk/sprint x1.1 while reloading", 1.1f, 0f, Times( "move", 1.1f ) )),
				(MAG60, new Node( "t1_mag_frontload", "Front Load", "The first 10 rounds after each reload deal +10% damage",
					"the first 10 rounds after a reload: Damage x1.1", 1.1f, 0f, Fixed( "rounds", 10f ), Times( "dmg", 1.1f ) )),
				(MAG61, new Node( "t1_mag_lightpack", "Light Pack",
					"The less reserve ammo you carry, the faster you move, up to +10% with none left",
					"walk/sprint up to x1.1, linear in the share of max reserve spent", 1.1f, 0f, Times( "move", 1.1f ) )),
			};
		}
	}

	static (string Set, Node Node)[] Tier2Sets
	{
		get
		{
			var old = Legacy2;
			Node extraRounds = Old( old, "t2_clip_flat" ), matchTrigger = Old( old, "t2_rpm_flat" ),
				rapidFire = Old( old, "t2_rpm_pct" ), precision = Old( old, "t2_headshot" ),
				hollowPoints = Old( old, "t2_limbs" ), longBarrel = Old( old, "t2_falloff" ), bounty = Old( old, "t2_bounty" );

			var steelCore = new Node( "t2_steelcore", "Steel Core", "+1 penetration", "Penetration +1", 1f, 0f,
				Fixed( "s.pen+", 1f ) );
			var runnersGrip = new Node( "t2_runnersgrip", "Runner's Grip", "+8% move speed while you hold it",
				"walk/sprint x1.08 while held", 1.08f, 0f, Times( "s.move", 1.08f ) );

			return new (string Set, Node Node)[]
			{
				// ── Submachine Guns ──
				(SMG, rapidFire), (SMG, steelCore),
				(SMG, new Node( "t2_smg_closeshave", "Close Shave", "+10% damage within 300 units",
					"within 300 u of the shooter: Damage x1.1 (Point Blank's range check)", 1.1f, 0f,
					Times( "dmg", 1.1f ), Fixed( "range", 300f ) )),
				(SMG, hollowPoints), (SMG, bounty),

				// ── Assault Rifles ──
				(AR, precision), (AR, extraRounds), (AR, rapidFire), (AR, runnersGrip),
				// ⚠️ CATALYST'S CONVENTION: "50% faster" is its cooldown x0.5, so 15% faster is x0.85.
				(AR, new Node( "t2_ar_modrail", "Mod Rail", "Ammo mods cool down 15% faster",
					"ammo mod cooldown x0.85 (Catalyst's hook)", 0.85f, 0f, Times( "cooldown", 0.85f ) )),

				// ── Battle Rifles (marksman) ──
				(BR, precision), (BR, extraRounds), (BR, longBarrel), (BR, matchTrigger),
				(BR, new Node( "t2_br_slowing", "Slowing Rounds", "Each hit slows the zombie 20% for 1 s",
					"per hit: zombie speed x0.8 for 1 s (Suppressive Fire's slow)", 0.8f, 0f,
					Times( "speed", 0.8f ), Fixed( "seconds", 1f ) )),

				// ── Sniper Rifles ──
				(SNIPER, matchTrigger), (SNIPER, extraRounds),
				(SNIPER, new Node( "t2_sn_aptip", "AP Tip", "+2 penetration", "Penetration +2", 2f, 0f, Fixed( "s.pen+", 2f ) )),
				(SNIPER, precision),
				(SNIPER, new Node( "t2_sn_riflesling", "Rifle Sling", "40% faster swapping to and from it",
					"draw and put-away x1.4 (a speed)", 1.4f, 0f, Times( "s.swap", 1.4f ) )),

				// ── Light Machine Guns ──
				(LMG, new Node( "t2_lmg_beltextender", "Belt Extender", "+15 rounds", "ClipSize +15", 15f, 0f,
					Fixed( "s.clip+", 15f ) )),
				(LMG, steelCore), (LMG, rapidFire), (LMG, bounty),
				(LMG, new Node( "t2_lmg_gunnersplate", "Gunner's Plate", "You take 8% less damage while you hold it",
					"damage taken x0.92 while held (Juggernaut's stat)", 0.92f, 0f, Times( "s.taken", 0.92f ) )),

				// ── Shotguns ──
				(SG, new Node( "t2_sg_shellsaddle", "Shell Saddle", "+2 shells", "ClipSize +2", 2f, 0f, Fixed( "s.clip+", 2f ) )),
				// ⛔ WITH SLUG LOADER OR AUTOLOADER (TIER 4) THE PELLET GOES INTO THE SHELL THEY CONVERT, never beside it: added
				// after the collapse it would be a second whole slug, x2 damage (`NZPlayer.ApplyShotTech`, `AutoloaderFactor`).
				(SG, new Node( "t2_sg_magnum", "Magnum Shells", "+1 pellet", "Bullets +1", 1f, 0f, Fixed( "s.pellets+", 1f ) )),
				(SG, matchTrigger), (SG, hollowPoints), (SG, longBarrel),

				// ── Handguns ──
				(HG, rapidFire), (HG, steelCore), (HG, runnersGrip),
				(HG, new Node( "t2_hg_fielddressing", "Field Dressing", "+25 max health while you hold it",
					"max health +25 while held (Lifeline's stat)", 25f, 0f, Fixed( "s.hp+", 25f ) )),
				(HG, extraRounds),

				// ── Revolvers ── all five weird: the normal ones are its tier 1
				(RV, new Node( "t2_rv_loadeddice", "Loaded Dice", "Each shot deals a random x0.8 to x1.4 damage (x1.1 on average)",
					"per shot: Damage x a uniform roll from 0.8 to 1.4", 1.1f, 0f, Times( "min", 0.8f ), Times( "max", 1.4f ) )),
				(RV, new Node( "t2_rv_showdown", "Showdown", "The first shot after you draw it deals x3 damage",
					"the first shot after the draw: Damage x3", 3f, 0f, Times( "dmg", 3f ) )),
				(RV, new Node( "t2_rv_silvertips", "Silver Tips", "+15% damage to special zombies and bosses",
					"bosses and special zombies: Damage x1.15", 1.15f, 0f, Times( "dmg", 1.15f ) )),
				(RV, new Node( "t2_rv_fanning", "Fanning Practice", "Hip fire is 20% faster than aimed fire",
					"not aiming: RPM x1.2", 1.2f, 0f, Times( "rpm", 1.2f ) )),
				(RV, new Node( "t2_rv_bloodoath", "Blood Oath", "Kills heal 3 health", "kill: +3 health", 3f, 0f,
					Fixed( "hp", 3f ) )),

				// ── actions (blue) ──
				(AUTO, new Node( "t2_act_longpull", "Long Pull",
					"After the 10th shot of a trigger pull, recoil stops climbing and the spread stops widening",
					"from the 10th shot of one trigger hold: no recoil climb, no sustained-fire spread", 10f, 0f,
					Fixed( "shots", 10f ) )),
				(BURST, new Node( "t2_act_extrashot", "Extra Shot",
					"Each burst fires one more round (3 -> 4, or 6 -> 7 with Double Burst)",
					"burst rounds +1, after Double Burst's x2", 1f, 0f, Fixed( "rounds", 1f ) )),
				(SEMI, new Node( "t2_act_pacedshot", "Paced Shot", "A shot fired at least 0.5 s after the last deals +15% damage",
					"a shot 0.5 s or more after the last: Damage x1.15", 1.15f, 0f,
					Fixed( "seconds", 0.5f ), Times( "dmg", 1.15f ) )),
				(MANUAL, new Node( "t2_act_perfectcycle", "Perfect Cycle",
					"A shot fired within 0.3 s of the action finishing deals +20% damage",
					"a shot within 0.3 s of the bolt or pump finishing: Damage x1.2", 1.2f, 0f,
					Fixed( "seconds", 0.3f ), Times( "dmg", 1.2f ) )),

				// ── magazine bands (purple) ──
				(MAG8, new Node( "t2_mag_crescendo", "Crescendo",
					"Each round deals x1.1 the damage of the one before, up to x2; a reload starts it over",
					"Damage x1.1 per round fired since the reload, compounding, cap x2", 1.1f, 0f,
					Times( "ramp", 1.1f ), Fixed( "cap", 2f ) )),
				(MAG20, new Node( "t2_mag_brasssaver", "Brass Saver", "Headshot kills have a 50% chance to put the round back",
					"headshot kill: 50% chance of clip +1", 0.5f, 0f, Fixed( "chance", 0.5f ), Fixed( "rounds", 1f ) )),
				// ⚠️ `taken`, NOT `s.taken`: it lasts 2 s after a reload, and `s.taken` applies whenever the gun is in hand.
				(MAG40, new Node( "t2_mag_reloadshield", "Reload Shield", "For 2 s after a reload, you take 20% less damage",
					"for 2 s after a reload: damage taken x0.8", 0.8f, 0f, Fixed( "seconds", 2f ), Times( "taken", 0.8f ) )),
				(MAG60, new Node( "t2_mag_killfeed", "Kill Feed",
					"Each kill moves 5 rounds from your reserve into the magazine, up to full",
					"kill: 5 rounds from reserve to magazine, never past full", 5f, 0f, Fixed( "rounds", 5f ) )),
				(MAG61, new Node( "t2_mag_spinup", "Spin-Up",
					"Every 20 rounds fired from the belt add +5% fire rate, up to +20%, until you reload",
					"RPM +5% per 20 rounds fired since the reload, cap +20%", 0.05f, 0f,
					Fixed( "rounds", 20f ), Fixed( "per", 0.05f ), Fixed( "cap", 0.2f ) )),
			};
		}
	}

	static (string Set, Node Node)[] Tier3Sets
	{
		get
		{
			var old = Legacy3;
			Node overpenetrator = Old( old, "t3_pierce" ), stabilizer = Old( old, "t3_recovery" ),
				fastDeploy = Old( old, "t3_deploy" ), ammoPouch = Old( old, "t3_ammo" ), hotLoads = Old( old, "t3_damage" ),
				fixation = Old( old, "t3_marked" );

			// ⚠️ SHARED BY SNIPERS AND SHOTGUNS: one node, one id.
			var rechamber = new Node( "t3_rechamber", "Rechamber Rush",
				"A kill makes the next shot cycle 50% faster, if fired within 1.5 s",
				"kill: the next shot's cycle at x1.5 rate, if fired within 1.5 s", 1.5f, 0f,
				Times( "cycle", 1.5f ), Fixed( "seconds", 1.5f ) );

			return new (string Set, Node Node)[]
			{
				// ── Submachine Guns ──
				(SMG, fixation),
				(SMG, new Node( "t3_smg_slidefire", "Slide Fire", "Sliding and jumping add no spread",
					"no airborne, mid-air or slide spread terms", 1f, 0f )),
				(SMG, new Node( "t3_smg_catchbreath", "Catch Breath", "Each kill refills 5% stamina",
					"kill: stamina +5% of the bar", 0.05f, 0f, Fixed( "share", 0.05f ) )),
				(SMG, new Node( "t3_smg_pocketreload", "Pocket Reload", "Swap away mid-reload and the reload still finishes",
					"a reload cut short by a swap still lands", 1f, 0f )),
				(SMG, ammoPouch),

				// ── Assault Rifles ──
				(AR, overpenetrator), (AR, hotLoads),
				(AR, new Node( "t3_ar_lockedin", "Locked In", "While you aim, sustained fire doesn't widen the spread",
					"aiming: SpreadMultShooting ignored", 1f, 0f )),
				(AR, new Node( "t3_ar_hardtarget", "Hard Target", "+25% damage to bosses and special zombies",
					"bosses and special zombies: Damage x1.25", 1.25f, 0f, Times( "dmg", 1.25f ) )),
				(AR, new Node( "t3_ar_dualrole", "Dual Role", "Aimed shots deal +15% damage; hip fire is 15% faster",
					"aiming: Damage x1.15; not aiming: RPM x1.15", 1.15f, 0f, Times( "dmg", 1.15f ), Times( "rpm", 1.15f ) )),

				// ── Battle Rifles (marksman) ──
				(BR, overpenetrator),
				(BR, new Node( "t3_br_rangefinder", "Range Finder",
					"+1% damage for every 100 units to the target, up to +25% at 2,500",
					"Damage +1% per 100 u to the target, cap +25%", 0.01f, 0f,
					Fixed( "per", 0.01f ), Fixed( "step", 100f ), Fixed( "cap", 0.25f ) )),
				(BR, new Node( "t3_br_steadysling", "Steady Sling", "Walking while aiming adds no spread",
					"aiming: no movement spread terms", 1f, 0f )),
				(BR, new Node( "t3_br_backupsights", "Backup Sights", "Hip fire is as tight as aiming",
					"hip spread = aimed spread (Run 'n' Gun's switch)", 1f, 0f, Fixed( "f.hipaimed" ) )),
				(BR, new Node( "t3_br_followthrough", "Follow-Through",
					"A kill's leftover damage carries on to the next zombie the bullet passes through",
					"a kill's overkill is added to the next body the bullet pierces", 1f, 0f )),

				// ── Sniper Rifles ──
				(SNIPER, new Node( "t3_sn_noscope", "No-Scope", "Hip fire is perfectly accurate",
					"hip spread 0 (Gunslinger's switch)", 0f, 0f, Fixed( "f.hipzero" ) )),
				(SNIPER, new Node( "t3_sn_standoff", "Stand-Off", "While you aim, zombies within 250 units move 20% slower",
					"aiming: zombies within 250 u at speed x0.8 (Suppressive Fire's slow)", 0.8f, 0f,
					Fixed( "range", 250f ), Times( "speed", 0.8f ) )),
				(SNIPER, rechamber),
				(SNIPER, new Node( "t3_sn_coupdegrace", "Coup de Grâce", "Any hit kills a zombie below 30% health (not bosses)",
					"a hit on a zombie below 30% health kills it, bosses immune", 0.3f, 0f, Fixed( "health", 0.3f ) )),
				(SNIPER, new Node( "t3_sn_bullseye", "Bullseye", "Aim for 1 s without firing, and your next hit counts as a headshot",
					"1 s aiming without a shot: the next hit is a headshot", 1f, 0f, Fixed( "seconds", 1f ) )),

				// ── Light Machine Guns ──
				(LMG, fixation),
				(LMG, new Node( "t3_lmg_brace", "Brace", "+20% damage while crouched", "crouched: Damage x1.2", 1.2f, 0f,
					Times( "dmg", 1.2f ) )),
				// ⚠️ A TIME, LIKE BOX MAGAZINE'S "40% FASTER" (x0.714).
				(LMG, new Node( "t3_lmg_quickbelt", "Quick Belt", "Reloading an empty belt is 40% faster",
					"empty reload time x0.714", 0.714f, 0f, Times( "empty", 0.714f ) )),
				(LMG, new Node( "t3_lmg_assaultgrip", "Assault Grip", "Aiming doesn't slow your walk",
					"no aiming walk penalty", 1f, 0f )),
				(LMG, new Node( "t3_lmg_swarmfire", "Swarm Fire", "+5% damage for every zombie within 300 units, up to +40%",
					"Damage +5% per zombie within 300 u of you, cap +40%", 0.05f, 0f,
					Fixed( "per", 0.05f ), Fixed( "range", 300f ), Fixed( "cap", 0.4f ) )),

				// ── Shotguns ──
				(SG, fastDeploy),
				(SG, new Node( "t3_sg_breaching", "Breaching Shells", "+3 penetration", "Penetration +3", 3f, 0f,
					Fixed( "s.pen+", 3f ) )),
				// ⚠️ HALF STATS, HALF MECHANIC. Its `s.taken` needs no code; the +25% within 150 units (Point Blank's range, so
				// inside it the two multiply to x2.5) is `ClassTech.VictimScale`'s (2026-10-04).
				(SG, new Node( "t3_sg_riotguard", "Riot Guard",
					"You take 15% less damage while you hold it, and deal +25% damage within 150 units",
					"damage taken x0.85 while held; within 150 u: Damage x1.25 (Point Blank's range)", 0.85f, 0f,
					Times( "s.taken", 0.85f ), Times( "dmg", 1.25f ), Fixed( "range", 150f ) )),
				(SG, rechamber),
				(SG, new Node( "t3_sg_shellrecovery", "Shell Recovery", "Each kill puts a shell back in the magazine",
					"kill: clip +1", 1f, 0f, Fixed( "rounds", 1f ) )),

				// ── Handguns ──
				(HG, stabilizer),
				(HG, new Node( "t3_hg_reflex", "Reflex", "When you take damage, your next 3 shots deal +50% damage",
					"on damage taken: the next 3 shots Damage x1.5", 1.5f, 0f, Fixed( "shots", 3f ), Times( "dmg", 1.5f ) )),
				(HG, new Node( "t3_hg_finisher", "Finisher", "+50% damage to zombies below 25% health",
					"target below 25% health: Damage x1.5", 1.5f, 0f, Times( "dmg", 1.5f ), Fixed( "health", 0.25f ) )),
				(HG, new Node( "t3_hg_closecall", "Close Call", "Below 30% health, this gun reloads instantly",
					"you below 30% health: this gun's reloads take 0 s", 0.3f, 0f, Fixed( "health", 0.3f ) )),
				(HG, new Node( "t3_hg_pistolwhip", "Pistol Whip", "Your melee deals x3 damage while you hold it",
					"melee damage x3 while held", 3f, 0f, Times( "melee", 3f ) )),

				// ── Revolvers ──
				(RV, new Node( "t3_rv_trickshot", "Trick Shot", "A headshot kill puts the round back in the cylinder",
					"headshot kill: clip +1", 1f, 0f, Fixed( "rounds", 1f ) )),
				(RV, new Node( "t3_rv_deadoralive", "Dead or Alive", "Special zombies and bosses you kill pay +100 points",
					"boss or special kill: +100 points", 100f, 0f, Fixed( "points", 100f ) )),
				(RV, new Node( "t3_rv_desperado", "Desperado", "+30% damage while you're below half health",
					"you below 50% health: Damage x1.3", 1.3f, 0f, Times( "dmg", 1.3f ), Fixed( "health", 0.5f ) )),
				(RV, new Node( "t3_rv_tumbleweed", "Tumbleweed", "Each slide puts a round in the cylinder",
					"slide: clip +1", 1f, 0f, Fixed( "rounds", 1f ) )),
				(RV, new Node( "t3_rv_bankshot", "Bank Shot",
					"A shot that hits a wall bounces once toward the nearest zombie in sight",
					"a wall hit bounces once at the nearest visible zombie (Ricochet Rounds' bounce)", 1f, 0f,
					Fixed( "bounces", 1f ) )),

				// ── actions (blue) ──
				(AUTO, new Node( "t3_act_rampage", "Rampage",
					"Each kill during a trigger pull adds +5% damage until you let go, up to +25%",
					"Damage +5% per kill in one trigger hold, cap +25%", 0.05f, 0f, Fixed( "per", 0.05f ), Fixed( "cap", 0.25f ) )),
				(BURST, new Node( "t3_act_grouping", "Grouping", "A burst that lands every round on one zombie deals +30% damage",
					"every round of a burst on one zombie: the burst's damage x1.3, carried by its last round", 1.3f, 0f,
					Times( "dmg", 1.3f ) )),
				(SEMI, new Node( "t3_act_hothand", "Hot Hand", "Each hit in a row adds +5% damage, up to +25%; a miss resets it",
					"Damage +5% per consecutive hit, cap +25%; a miss resets", 0.05f, 0f,
					Fixed( "per", 0.05f ), Fixed( "cap", 0.25f ) )),
				(MANUAL, new Node( "t3_act_readyround", "Ready Round", "Every third cycle of the action loads a round from your reserve",
					"every 3rd cycle: 1 round from reserve to magazine", 3f, 0f, Fixed( "cycles", 3f ), Fixed( "rounds", 1f ) )),

				// ── magazine bands (purple) ──
				// ⚠️ FRESH MAG, TIGHT TEN, OPENING VOLLEY AND FINAL STRETCH (and Brass Saver, tier 2) ARE READ AS REPLACEMENTS
				// of their first drafts, not additions, and that reading is not confirmed yet (the doc's ⚠️).
				// ⚠️ `tube` (review, 2026-10-04, NOT THE USER'S NUMBER YET): a one-round-at-a-time reload counts once, at its end, when it
				// loaded at least this share of the tube or began empty — not at every insert (`Weapon.MagTechTubeReloaded`).
				(MAG8, new Node( "t3_mag_freshmag", "Fresh Mag",
					"The first shot after a reload fires 5 extra pellets, each at full damage",
					"the first shot after a reload: Bullets +5 at full damage (a round-at-a-time reload: once it loaded half the tube, or from empty)",
					5f, 0f, Fixed( "pellets", 5f ), Fixed( "tube", 0.5f ) )),
				(MAG20, new Node( "t3_mag_overfill", "Overfill",
					"A reload adds a full magazine on top of the rounds still in it; you can reload only while it holds less than a full magazine",
					"a reload adds one magazine on top; refused at a full magazine or more", 1f, 0f, Fixed( "mags", 1f ) )),
				(MAG40, new Node( "t3_mag_tightten", "Tight Ten",
					"The last 10 rounds of a magazine fire 30% faster with 50% less recoil",
					"clip <= 10: RPM x1.3, recoil x0.5", 1.3f, 0f,
					Fixed( "rounds", 10f ), Times( "rpm", 1.3f ), Times( "recoil", 0.5f ) )),
				(MAG60, new Node( "t3_mag_openingvolley", "Opening Volley",
					"The first 10 rounds after each reload fire twice as fast, with no recoil",
					"the first 10 rounds after a reload: RPM x2, recoil x0", 2f, 0f,
					Fixed( "rounds", 10f ), Times( "rpm", 2f ), Fixed( "recoil", 0f ) )),
				(MAG61, new Node( "t3_mag_finalstretch", "Final Stretch",
					"The emptier the belt, the more damage, up to +30% on the last round",
					"Damage up to x1.3, linear in the share of the belt fired", 1.3f, 0f, Times( "dmg", 1.3f ) )),
			};
		}
	}

	/// <summary>An old tier-1–3 node, by id, from its legacy pool.</summary>
	static Node Old( Node[] pool, string id ) => pool.First( n => n.Id == id );

	/// <summary>A set table's nodes, each ONCE, in table order: a node several classes share is one entry in the pool.</summary>
	static Node[] PoolOf( (string Set, Node Node)[] sets )
	{
		var seen = new HashSet<string>();
		var pool = new List<Node>();
		foreach ( var s in sets )
			if ( seen.Add( s.Node.Id ) ) pool.Add( s.Node );

		return pool.ToArray();
	}

	static Node[] Tier1 => PoolOf( Tier1Sets );
	static Node[] Tier2 => PoolOf( Tier2Sets );
	static Node[] Tier3 => PoolOf( Tier3Sets );

	static Node[] Tier4 => Tier4Sets.Select( s => s.Node ).ToArray();
	static Node[] Tier5 => Tier5Sets.Select( s => s.Node ).ToArray();

	/// <summary>
	/// The old tier-4/5 nodes no set took, retired on 2026-10-04 (the user: "Retire them"). Never offered, but
	/// `Find` still knows them, so the code that reads them keeps compiling and any can come back.
	/// </summary>
	static Node[] Retired
	{
		get
		{
			var live = new HashSet<string>( Tier4Sets.Select( s => s.Node.Id ).Concat( Tier5Sets.Select( s => s.Node.Id ) ) );

			// ⚠️ AND TIERS 1–3'S OLD NODES (2026-10-04). None has left the sets, but one that does stays findable, so a gun that
			// owns it keeps its effect: the doc's rule, "a gun keeps every node it already owns, and each keeps working".
			live.UnionWith( Tier1Sets.Select( s => s.Node.Id ).Concat( Tier2Sets.Select( s => s.Node.Id ) )
				.Concat( Tier3Sets.Select( s => s.Node.Id ) ) );

			return Legacy1.Concat( Legacy2 ).Concat( Legacy3 ).Concat( Legacy4 ).Concat( Legacy5 )
				.Where( n => !live.Contains( n.Id ) ).ToArray();
		}
	}

	/// <summary>
	/// ⛔ CATALOGUED BUT NOT WIRED YET, AND THEREFORE NOT OFFERED. A node that is listed, priced and bought but
	/// does nothing is the failure this file's header names; until its effect exists it stays out of every
	/// offer. Emptied one node at a time as the effects land.
	/// </summary>
	// ⚠️ EMPTIED 2026-10-04 FOR TIERS 4 AND 5: every node in `Tier4Sets` and `Tier5Sets` has its effect (`TechStats`,
	// `ClassTech`, `Weapon.ClassTech.cs`).
	//
	// ⛔ TIERS 1–3'S MECHANICS ARE CATALOGUED BEFORE THEY ARE BUILT (2026-10-04, WEAPON_TECH_TIERS_1_3.md), so each waits in
	// one of the four lists below until its code lands: whoever builds one deletes its id there, and nothing else. A node
	// made only of stats (`s.*`/`f.*`, read by `TechStats`) needs no code and is never listed.
	static HashSet<string> NotWired
		=> new( PendingClassesA.Concat( PendingClassesB ).Concat( PendingActions ).Concat( PendingMags ) );

	/// <summary>Tier 1–3 mechanics not built yet: Submachine Guns, Assault Rifles, Battle Rifles (marksman), Sniper Rifles.</summary>
	static string[] PendingClassesA => new string[]
	{
		// ⚠️ EMPTY SINCE 2026-10-04: all sixteen are built (`ClassTech`, `Weapon.ClassTech.cs`, `GetRealSpread`, `Health`).
	};

	/// <summary>Tier 1–3 mechanics not built yet: Light Machine Guns, Shotguns, Handguns, Revolvers.</summary>
	static string[] PendingClassesB => new string[]
	{
		// ⚠️ EMPTY SINCE 2026-10-04: all twenty are built (`ClassTech`, `Weapon.ClassTech.cs`, `Weapon.Reload`, `HitScan`, the knife,
		// the slide, `NZPlayer`'s aiming walk and `ZombieAI`'s kill points).
	};

	/// <summary>Tier 1–3 mechanics not built yet: the action sets, tiers 1, 2 and 3 on each line.</summary>
	static string[] PendingActions => new string[]
	{
		// ⚠️ EMPTY SINCE 2026-10-04: all twelve are built (`Weapon.ActionTech.cs`, called from `Weapon.ClassTech.cs`; the kick, the
		// walk-back and the spread in `Weapon.Getters`, the burst length, the bolt cycle, `HitScan`, `NZPlayer`'s move speed and
		// `ClassTech`'s kill relay).
	};

	/// <summary>Tier 1–3 mechanics not built yet: the magazine sets (Side Pouch and Stacked Mag are stats, so absent).</summary>
	static string[] PendingMags => new string[]
	{
		// ⚠️ EMPTY SINCE 2026-10-04: all thirteen are built (`Weapon.MagTech.cs`, called from `Weapon.ClassTech.cs`; the reloads in
		// `Weapon.Reload`, the kick and the aimed cone in `Weapon.Getters`, `NZPlayer`'s move speed and magazine, and `ClassTech`'s
		// kill relay and damage taken).
	};

	/// <summary>Is this node wired: catalogued AND its effect built.</summary>
	public static bool IsWired( string id ) => !NotWired.Contains( id );

	/// <summary>
	/// The set a node belongs to ("class:Submachine Guns", "action:auto"…), or "" for a retired one. A node several
	/// classes share answers its first class, which colours it like any class card.
	///
	/// ⚠️ ONLY THE NODE'S OWN TIER'S TABLE IS BUILT (tiers 1–3 joined the sets on 2026-10-04): the Arsenal asks this for
	/// every card it draws.
	/// </summary>
	public static string SetOf( string id )
	{
		var table = SetsOfTier( TierOfNode( id ) );
		if ( table is null ) return "";

		foreach ( var s in table ) if ( s.Node.Id == id ) return s.Set;
		return "";
	}

	/// <summary>
	/// One tier's sets, a node under EVERY set it is in, less what is not built yet (the info booklet, 2026-10-05). `SetOf`
	/// answers a node several classes share with its FIRST class only, so grouping a tier's pool by it would pile a shared node
	/// under one class and leave the others without it.
	/// </summary>
	public static (string Set, Node Node)[] SetsIn( int tier )
	{
		var table = SetsOfTier( tier );
		if ( table is null ) return System.Array.Empty<(string Set, Node Node)>();

		var pending = NotWired;
		return table.Where( s => !pending.Contains( s.Node.Id ) ).ToArray();
	}

	/// <summary>One tier's set table, or null.</summary>
	static (string Set, Node Node)[] SetsOfTier( int tier ) => tier switch
	{
		1 => Tier1Sets,
		2 => Tier2Sets,
		3 => Tier3Sets,
		4 => Tier4Sets,
		5 => Tier5Sets,
		_ => null,
	};

	/// <summary>"class", "action", "mag" or "reload" — what colours a card in the Arsenal — or "".</summary>
	public static string SetKindOf( string id )
	{
		var set = SetOf( id );
		var colon = set.IndexOf( ':' );
		return colon > 0 ? set[..colon] : "";
	}

	/// <summary>
	/// What a weapon is offered in a tier: the sets of its tags (`WeaponTags`), less what is not built yet. Tiers 1–3 have
	/// class, action and magazine sets (2026-10-04, WEAPON_TECH_TIERS_1_3.md); tiers 4–5 a reload set as well.
	///
	/// ⚠️ IN THE SET TABLES' ORDER, so the class set comes first and the coloured action, magazine and
	/// reload augments after it. A node two of the gun's sets share is offered once.
	/// </summary>
	public static Node[] OfferFor( string prefab, int tier )
	{
		var table = SetsOfTier( tier );
		if ( table is null ) return System.Array.Empty<Node>();

		var sets = new HashSet<string>( WeaponTags.SetsOf( prefab ) );
		if ( sets.Count == 0 ) return System.Array.Empty<Node>();

		var pending = NotWired;
		var seen = new HashSet<string>();
		var offer = new List<Node>();
		foreach ( var s in table )
			if ( sets.Contains( s.Set ) && !pending.Contains( s.Node.Id ) && seen.Add( s.Node.Id ) ) offer.Add( s.Node );

		return offer.ToArray();
	}

	/// <summary>Is this node offered on this weapon.</summary>
	public static bool Offered( string prefab, string id )
	{
		var tier = TierOfNode( id );
		return tier > 0 && OfferFor( prefab, tier ).Any( n => n.Id == id );
	}

	// ── lookups ──────────────────────────────────────────────────────────────

	/// <summary>One tier by index, or null.</summary>
	public static Tier TierOf( int index )
		=> Tiers.FirstOrDefault( t => t.Index == index );

	/// <summary>
	/// One node by id: every tier, then the retired nodes. Null when unknown.
	///
	/// ⚠️ THROUGH AN INDEX REBUILT ONCE A FRAME. Every tech read in the game goes through here, the catalogue grew to
	/// about 150 nodes on 2026-10-04, and `Tiers` builds its arrays fresh on each call; an index keyed by
	/// `Time.Now` costs one rebuild a frame and still picks up a hotloaded catalogue on the next one (§1).
	/// </summary>
	public static Node Find( string id )
		=> !string.IsNullOrEmpty( id ) && Index().TryGetValue( id, out var n ) ? n : null;

	static System.Collections.Generic.Dictionary<string, Node> _index;
	static System.Collections.Generic.Dictionary<string, int> _tierIndex;
	static float _indexAt = float.NaN;

	static System.Collections.Generic.Dictionary<string, Node> Index()
	{
		var now = Time.Now;
		if ( _index is not null && _tierIndex is not null && _indexAt == now ) return _index;

		var d = new System.Collections.Generic.Dictionary<string, Node>();
		var tierOf = new System.Collections.Generic.Dictionary<string, int>();
		foreach ( var n in Retired ) d[n.Id] = n;
		foreach ( var t in Tiers )
			foreach ( var n in t.Pool )
			{
				d[n.Id] = n;   // a live node wins over a retired one of the same id
				if ( !tierOf.ContainsKey( n.Id ) ) tierOf[n.Id] = t.Index;   // the first tier holding it, as `TierOfNode` always said
			}

		_index = d;
		_tierIndex = tierOf;
		_indexAt = now;
		return d;
	}


	/// <summary>
	/// A node's secondary bound — a cap, a floor, a threshold — or
	/// <paramref name="fallback"/> when it declares none.
	///
	/// ⚠️ TAKES AN EXPLICIT FALLBACK rather than returning 0, because 0 is a
	/// meaningful bound and a call site cannot tell "no bound" from "bound of zero".
	/// </summary>
	/// <summary>
	/// Fallback safety rail for the falloff target, used only if the node stops declaring a
	/// `Bound`. NOT the tuned value — that is the node's `Factor`.
	///
	/// ⛔ A NAMED CONSTANT BECAUSE TWO PLACES NEED THE SAME FALLBACK. `ApplyFalloffTech`
	/// clamps with it and the stats panel scales its Range bar with it, and both were
	/// passing a literal 1.25 into BoundOf. Two literals is two chances to change one —
	/// and the failure is silent and asymmetric: the bar would under-read or never fill
	/// while the percentage beside it stayed correct, so the panel would look merely
	/// badly scaled rather than wrong.
	///
	/// ⚠️ It is only a FALLBACK. The node declares 1.25 as its `Bound`, so this is
	/// reached only if that declaration is ever removed.
	/// </summary>
	public const float FalloffCeiling = 1.25f;

	public static float BoundOf( string nodeId, float fallback )
	{
		var b = Find( nodeId )?.Bound ?? 0f;
		return b > 0f ? b : fallback;
	}

	/// <summary>One of a node's named extra magnitudes, or null when it declares none.</summary>
	public static Mag MagFor( string nodeId, string name )
		=> Find( nodeId )?.Extra?.FirstOrDefault( m => m.Name == name );

	/// <summary>
	/// Which (nodeId, name) pairs have already been reported missing.
	///
	/// ⚠️ AN EMPTY ACCUMULATOR, NOT A TABLE WITH CONTENT IN ITS INITIALISER — the §1 trap
	/// this file's header names is a catalogue that can never be corrected in a live
	/// session, and all this one ever holds is which warnings it has already printed.
	/// </summary>
	static readonly System.Collections.Generic.HashSet<string> _magMissing = new();

	/// <summary>
	/// A named extra magnitude, or <paramref name="fallback"/> when the node declares none.
	///
	/// ⛔ THE FALLBACK IS THE NEUTRAL VALUE FOR THE CALL SITE'S ARITHMETIC — 1 for a
	/// multiply, 0 for an add — AND NEVER A SECOND COPY OF THE MAGNITUDE. Copying the real
	/// number to the call site is the exact second-source problem `Bound` and this whole
	/// catalogue exist to prevent: `nz_tech` would print one value while the gun used
	/// another, and the printed table would be the liar.
	///
	/// ⛔ SO A MISSING NAME WARNS, ONCE. With a neutral fallback, a typo'd name is a node
	/// that is arithmetically perfect and does exactly nothing, on every weapon, with
	/// nothing to see — the failure this tier produced twice (see the report on Boat Tail).
	/// Once per pair rather than per call, because these are read on every equip.
	/// </summary>
	public static float MagOf( string nodeId, string name, float fallback )
	{
		var mag = MagFor( nodeId, name );

		// ⚠️ AMPLIFIED, because a named magnitude is an EFFECT. Without this a node's
		// secondary halves ignored `nz_tech_amp` entirely while its primary factor obeyed
		// it — which reads in play as "the node only does one of the things it says".
		// `BoundOf` deliberately does NOT do this; see TechEffects.AmpMag.
		if ( mag is not null ) return TechEffects.AmpMag( mag.Value, mag.Multiplier );

		if ( _magMissing.Add( $"{nodeId}|{name}" ) )
			Log.Warning( $"[nz-tech] node `{nodeId}` declares no `{name}` magnitude — falling"
				+ $" back to the neutral {fallback:0.###}, so that half of the node does"
				+ " NOTHING. The catalogue is the source: add a Mag to the node." );

		return fallback;
	}

	/// <summary>
	/// The tier a node belongs to, or 0.
	///
	/// ⚠️ THROUGH `Find`'s ONCE-A-FRAME INDEX (2026-10-04). Tiers 1–3 became set tables too, `Tiers` rebuilds all five on
	/// every call, and the Arsenal asks this for every owned node of every tier, every frame it is open (`TechStartTier`).
	/// </summary>
	public static int TierOfNode( string id )
	{
		if ( string.IsNullOrEmpty( id ) ) return 0;

		Index();
		return _tierIndex.TryGetValue( id, out var tier ) ? tier : 0;
	}

	/// <summary>Salvage for a full tree, if every tier were filled and maxed.</summary>
	public static int FullTreeCost => Tiers.Sum( t => t.Picks * t.Cost );

	// ── gating ──────────────────────────────────────────────────────

	/// <summary>
	/// ⛔ CREATIVE HAS NO PICK LIMIT, so every one of the 41 nodes can be bought on one
	/// weapon in one session. That is the only way to test them all without playing a
	/// separate game for every combination the pick limits allow.
	///
	/// ⚠️ IT LIFTS THE LIMITS, IT DOES NOT MAKE THEM FREE. Salvage is still spent, so
	/// the purchase path — range, held-weapon lookup, TrySpend, the store write — is the
	/// same code a real game runs. A creative mode that skipped the charge would be
	/// testing a different function from the one that ships. The whole catalogue costs
	/// 40,425 against the 100,000 creative already grants.
	///
	/// ⚠️ READ AT THE POINT OF USE, not cached into a field. Creative can be entered and
	/// left in one session, and a flag captured at startup is the §11 trap.
	/// </summary>
	public static bool Unlimited => NZGame.IsCreative;

	/// <summary>Is a tier full for this prefab — picks reached.</summary>
	public static bool TierMaxed( NZPlayer player, string prefab, int tier )
	{
		var t = TierOf( tier );
		if ( t is null || !player.IsValid() ) return true;

		// ⚠️ Never "full" in creative, so a tier never closes and you can buy every node
		// in it.
		if ( Unlimited ) return false;

		return player.TechCount( prefab, tier ) >= t.Picks;
	}

	/// <summary>
	/// Why this node cannot be bought, or "" when it can.
	///
	/// ⚠️ ORDER OF PRECEDENCE MATTERS and mirrors the armor and rarity cards: owned,
	/// then tier full, then unaffordable. Checking affordability first would tell a
	/// player they cannot afford something they are not allowed to buy.
	///
	/// ⛔ NO "TIER LOCKED" (2026-10-03): any tier can be bought into without filling the
	/// ones below it. See the class header.
	/// </summary>
	public static string WhyNot( NZPlayer player, string prefab, string nodeId, int price )
	{
		if ( !player.IsValid() ) return "no player";

		var node = Find( nodeId );
		if ( node is null ) return "no such node";
		if ( string.IsNullOrEmpty( prefab ) ) return "this weapon has no source";

		var tier = TierOfNode( nodeId );
		if ( tier <= 0 ) return "node has no tier";

		if ( player.HasTech( prefab, nodeId ) ) return "already owned";

		// ⛔ EVERY TIER IS PER WEAPON (tiers 4–5 since 2026-10-04, and tiers 1–3 since that evening): a node outside this
		// gun's sets is refused here, which is the check the machine, the menu's cards and `nz_tech_buy` all share. After
		// "already owned", so a node the gun owns from before is only refused for buying, never taken away.
		// ⚠️ A node catalogued before its code says so, rather than claiming the gun is the problem.
		if ( !IsWired( nodeId ) ) return "not built yet";
		if ( !Offered( prefab, nodeId ) ) return "not for this weapon";
		if ( TierMaxed( player, prefab, tier ) ) return $"tier {tier} is full";
		if ( player.Salvage < price ) return $"need {price:N0} salvage";

		return "";
	}

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>
	/// `nz_tech` — the whole catalogue, tier by tier.
	///
	/// ⚠️ SAYS "not designed yet" FOR AN EMPTY POOL rather than printing nothing. A
	/// tier that silently lists no nodes is indistinguishable from a tier whose nodes
	/// failed to load, and this table will be half-empty for as long as the design
	/// takes.
	/// </summary>
	[ConCmd( "nz_tech" )]
	public static void List()
	{
		Log.Info( $"[nz-tech] weapon tech tree — {MaxTier} tiers, "
			+ $"{Tiers.Sum( t => t.Pool.Length )} nodes registered, "
			+ $"{Tiers.Sum( t => t.Picks )} picks, {FullTreeCost:N0} salvage for a full tree" );

		foreach ( var t in Tiers )
		{
			Log.Info( $"[nz-tech] TIER {t.Index} — {t.Name.ToUpper()}"
				+ $"  pool {t.Pool.Length} / pick {t.Picks}"
				+ $"  ({t.Cost:N0} salvage each, {t.Picks * t.Cost:N0} for the tier)" );

			if ( t.Pool.Length == 0 )
			{
				Log.Info( "[nz-tech]     (not designed yet)" );
				continue;
			}

			if ( t.Pool.Length <= t.Picks )
				Log.Warning( $"[nz-tech]     ⚠ pool {t.Pool.Length} <= picks {t.Picks}"
					+ " — nothing is excluded, so this tier forces no choice" );

			foreach ( var n in t.Pool )
				// ⚠️ The bound is printed only when a node declares one — a "bound 0" on
				// forty of forty-one rows would be noise, and this table is the thing
				// that is supposed to make the catalogue checkable at a glance.
				//
				// ⛔ AND THE EXTRA MAGNITUDES ARE PRINTED FOR THE SAME REASON THE TABLE
				// EXISTS. Tier 5 needs forty-odd numbers across eleven nodes; before `Mag`
				// they would have been constants in four other files, and this command —
				// the one thing that makes the catalogue checkable — could print exactly
				// one of each node's five.
				Log.Info( $"[nz-tech]     {n.Name,-16} {n.Effect,-24} "
					+ $"x{n.Factor:0.###} on {n.Lever}"
					+ (n.Bound > 0f ? $"  [bound {n.Bound:0.###}]" : "")
					+ (n.Extra is not null && n.Extra.Length > 0
						? "  [" + string.Join( ", ", n.Extra.Select( m =>
							$"{m.Name} {m.Value:0.###}{(m.Multiplier ? "" : " fixed")}" ) ) + "]"
						: "") );
		}
	}

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>
	/// `nz_tech_owned` — what the HELD weapon has actually bought, tier by tier.
	///
	/// ⚠️ REPORTS THE PREFAB KEY, because that is what the tree is stored against. If
	/// two weapons ever appear to share a tree, or a tree vanishes after Pack-a-Punch,
	/// this line is the one that shows why.
	/// </summary>
	[ConCmd( "nz_tech_owned" )]
	public static void Owned()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player — press Play first" ); return; }

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

		var prefab = Rarity.PrefabOf( wep );
		var owned = p.TechFor( prefab );

		Log.Info( $"[nz-tech] {wep.DisplayName} — {owned.Count} node(s)"
			+ $" · {p.Salvage:N0} salvage · key {(string.IsNullOrEmpty( prefab ) ? "NONE" : prefab)}" );

		foreach ( var t in Tiers )
		{
			var n = p.TechCount( prefab, t.Index );

			Log.Info( $"[nz-tech]   TIER {t.Index} {n}/{t.Picks}"
				+ $" {string.Join( ", ", owned.Where( id => TierOfNode( id ) == t.Index )
					.Select( id => Find( id )?.Name ?? id ) )}" );
		}
	}

	/// <summary>
	/// `nz_tech_buy [id]` — buy a node through the REAL machine path.
	///
	/// ⚠️ Goes through Arsenal.BuyTech, so it exercises range, salvage, the pick limits and
	/// the held-weapon lookup. A command that wrote straight into the store would prove
	/// the dictionary works and nothing about the machine.
	/// </summary>
	[ConCmd( "nz_tech_buy" )]
	public static void Buy( string id = "" )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player — press Play first" ); return; }

		if ( string.IsNullOrWhiteSpace( id ) )
		{
			Log.Info( "[nz-tech] nz_tech_buy <id> — `nz_tech` lists every id" );
			return;
		}

		var a = Arsenal.Near( p.WorldPosition ) ?? Arsenal.All.FirstOrDefault( x => x.IsValid() );
		if ( a is null ) { Log.Info( "[nz-tech] no Arsenal on the map" ); return; }

		var why = a.BuyTech( p, id );
		Log.Info( string.IsNullOrEmpty( why ) ? "[nz-tech] bought" : $"[nz-tech] refused — {why}" );
	}

	/// <summary>
	/// `nz_tech_reset` — clear the tech on the weapon IN HAND, leaving other weapons alone.
	///
	/// ⚠️ THE POINT IS ITERATION. Testing a node means buying it, feeling it, then
	/// getting rid of it to try the next — and `nz_tech_clear` wipes every weapon, which
	/// throws away the trees on your other gun as well. This is the one the Q menu calls.
	///
	/// ⚠️ DOES NOT REFUND. The salvage is gone, which is correct: creative grants
	/// 100,000 a frame, so a refund would only hide a double-spend bug if one existed.
	/// </summary>
	[ConCmd( "nz_tech_reset" )]
	public static void ResetHeld()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player — press Play first" ); return; }

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

		var prefab = Rarity.PrefabOf( wep );
		if ( string.IsNullOrEmpty( prefab ) )
		{
			Log.Warning( "[nz-tech] this weapon has no WeaponSource — nothing to clear" );
			return;
		}

		var had = p.TechFor( prefab ).Count;
		var hadRoll = p.ChimeraRolls.Remove( prefab );

		// ⛔ THE CHIMERA ROLL HAS TO GO WITH THE NODES, AND THIS COMMAND BYPASSES
		// `NZPlayer.ClearTech` — it removes one prefab's entry directly, which is the whole
		// point of it. So the roll is removed here too, or the substitution inside `TechBase`
		// would keep standing in for the authored stats of a weapon that owns nothing: the
		// gun would keep another weapon's damage, clip and fire rate with an empty tech list
		// beside it, and the node cannot be re-bought to explain it.
		p.TechOwned.Remove( prefab );

		Log.Info( $"[nz-tech] cleared {had} node(s) from {wep.DisplayName}"
			+ (hadRoll ? " including its Chimera roll, which is gone for good" : "")
			+ " — other weapons untouched" );
	}

	/// <summary>`nz_tech_clear` — wipe every tree on this player.</summary>
	[ConCmd( "nz_tech_clear" )]
	public static void ClearCmd()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player — press Play first" ); return; }

		p.ClearTech();
		Log.Info( "[nz-tech] cleared every weapon tech tree" );
	}

	/// <summary>`nz_tech_tier [1-5]` — one tier, with what you give up.</summary>
	[ConCmd( "nz_tech_tier" )]
	public static void TierCmd( int tier = 1 )
	{
		var t = TierOf( tier );
		if ( t is null ) { Log.Info( $"[nz-tech] no tier {tier}" ); return; }

		Log.Info( $"[nz-tech] TIER {t.Index} — {t.Name.ToUpper()}: take {t.Picks} of "
			+ $"{t.Pool.Length}, giving up {System.Math.Max( 0, t.Pool.Length - t.Picks )}"
			+ $" — {t.Cost:N0} salvage each" );

		if ( t.Pool.Length == 0 )
		{
			Log.Info( "[nz-tech]   (not designed yet)" );
			return;
		}

		foreach ( var n in t.Pool )
			Log.Info( $"[nz-tech]   {n.Id,-14} {n.Name,-16} {n.Effect}" );
	}

	/// <summary>
	/// `nz_tech_net` — EVERY player's tree AS THIS MACHINE SEES IT, and where it read it from.
	///
	/// ⛔ THE ONE QUESTION THE BUG THIS EXISTS FOR COULD NOT ANSWER BY EYE. Weapon tech
	/// resolved on the VICTIM was dead for a client's own shots and there was no symptom: no
	/// error, no log, just a node that did slightly less than it said. Run this on the HOST while
	/// a client owns tech — the client's row has to show `wire` and list their nodes. If it shows
	/// `wire 0 prefab(s)` the sync is the fault; if it shows the nodes and the effect is still
	/// missing, the damage path is.
	///
	/// ⚠️ RUN IT ON BOTH MACHINES. Each one prints its own picture of everybody, and the
	/// whole class of bug here is the two pictures disagreeing. `local` means this machine owns
	/// that player and is answering from `TechOwned`; `wire` means it is answering from the
	/// synced string, which is what the host does for every client.
	/// </summary>
	[ConCmd( "nz_tech_net" )]
	public static void NetCmd()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Info( "[nz-tech] no scene" ); return; }

		var me = Connection.Local?.Id.ToString() ?? "(solo)";
		Log.Info( $"[nz-tech] tech as seen from this machine — local connection {me}" );

		var any = false;
		foreach ( var p in scene.GetAllComponents<NZPlayer>() )
		{
			if ( !p.IsValid() ) continue;

			any = true;

			// ⚠️ THE SAME TEST `TechStore` USES, not an ownership test — the point is to print
			// what the damage path will actually read, not what it ought to.
			var source = p.TechOwned.Count > 0 ? "local" : "wire ";
			var net = p.TechNet ?? "";

			Log.Info( $"[nz-tech]   {p.GameObject.Name,-22} {source}"
				+ $"  mine={PlayerPresence.Mine( p.GameObject )}"
				+ $"  owned={p.TechOwned.Count} prefab(s)  TechNet={net.Length}b" );

			// ⚠️ DECODED THROUGH `TechFor`, which is the accessor everything else uses, so a
			// decode bug shows up here rather than being hidden behind a second reader.
			foreach ( var prefab in PrefabsIn( p ) )
				Log.Info( $"[nz-tech]     {prefab,-34} "
					+ string.Join( ", ", p.TechFor( prefab ) ) );

			if ( !string.IsNullOrEmpty( net ) )
				Log.Info( $"[nz-tech]     raw: {net}" );
		}

		if ( !any ) Log.Info( "[nz-tech]   (no players in the scene)" );
	}

	/// <summary>
	/// Every prefab key this player's tree holds, from whichever store is answering.
	///
	/// ⚠️ IT REBUILDS THE KEY LIST FROM `TechNet` RATHER THAN EXPOSING `TechStore`. That
	/// property is private on purpose — nothing outside NZPlayer should be able to pick which
	/// store it reads — and a diagnostic is not a reason to widen it.
	/// </summary>
	static System.Collections.Generic.IEnumerable<string> PrefabsIn( NZPlayer p )
	{
		if ( p.TechOwned.Count > 0 )
		{
			foreach ( var key in p.TechOwned.Keys ) yield return key;
			yield break;
		}

		foreach ( var entry in (p.TechNet ?? "").Split( ';',
			System.StringSplitOptions.RemoveEmptyEntries ) )
		{
			var bar = entry.IndexOf( '|' );
			if ( bar > 0 ) yield return entry[..bar];
		}
	}
}