UI/ArsenalMenu.cs

UI static class for the Arsenal menu. Exposes state and helpers for the four tabs (Armor, WeaponTech, WeaponRarity, AmmoType), computes card states/labels/prices, routes click actions to the Arsenal component, manages opening/closing the menu and a host GameObject for rendering.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>The Arsenal's four products, in the order the original's tabs sit.</summary>
public enum ArsenalMode
{
	/// <summary>Buy armor tiers. The only one with anything behind it today.</summary>
	Armor,

	/// <summary>The per-weapon tech tree. Built — 41 nodes across 5 tiers.</summary>
	WeaponTech,

	/// <summary>Raise the held weapon's rarity, 0-4. Built.</summary>
	WeaponRarity,

	/// <summary>Apply an elemental ammo mod. Needs the AAT system.</summary>
	AmmoType,
}

/// <summary>
/// ARSENAL MENU — the four-tab shell.
///
/// ⚠️ FOUR TABS, NOT THREE. The original's `cl_nzaug_arsenal.lua` has
/// ARMOR / WEAPON TECH / WEAPON RARITY / AMMO TYPE. Both ARSENAL_REMAKE.md §0 AND
/// that file's own header comment say "3-mode" — the rarity tab was added later and
/// neither was updated. The code is the source of truth here.
///
/// ⚠️ ARMOR AND WEAPON RARITY ARE BUILT. The other two are placeholders naming
/// what blocks them — a screen pretending to sell something that does not exist would
/// be worse than one that admits it.
///
/// ⛔ Modelled on WunderfizzMenu, including the parts that look like boilerplate and
/// are not: EnsureHost exists because the panel has no home in the scene and the
/// scene file must never be rewritten from a script.
/// </summary>
public static class ArsenalMenu
{
	/// <summary>The machine being used, or null when shut.</summary>
	public static Arsenal Current { get; private set; }

	public static bool IsOpen => Current.IsValid();

	/// <summary>Which tab is showing.</summary>
	public static ArsenalMode Mode { get; set; } = ArsenalMode.Armor;

	/// <summary>Who opened it.</summary>
	public static NZPlayer User
		=> NZPlayer.Local;

	/// <summary>Salvage on the reading player, for the footer.</summary>
	public static int PlayerSalvage => User.IsValid() ? User.Salvage : 0;

	// ── tabs ─────────────────────────────────────────────────────────────────

	/// <summary>Every tab, in the original's order.</summary>
	public static ArsenalMode[] Modes => new[]
	{
		ArsenalMode.Armor,
		ArsenalMode.WeaponTech,
		ArsenalMode.WeaponRarity,
		ArsenalMode.AmmoType,
	};

	/// <summary>Tab label, matching the original's wording.</summary>
	public static string NameFor( ArsenalMode mode ) => mode switch
	{
		ArsenalMode.Armor => "ARMOR",
		ArsenalMode.WeaponTech => "WEAPON TECH",
		ArsenalMode.WeaponRarity => "WEAPON RARITY",
		ArsenalMode.AmmoType => "AMMO TYPE",
		_ => mode.ToString().ToUpper(),
	};

	/// <summary>
	/// Is there a system behind this tab yet.
	///
	/// ⚠️ SAID OUT LOUD ON THE TAB rather than hiding the unbuilt ones. A tab that
	/// is missing looks like a feature that does not exist; a tab that is present and
	/// says why it is empty is a roadmap. It also keeps the four-tab layout honest
	/// while three of them fill in.
	/// </summary>
	public static bool Implemented( ArsenalMode mode ) => mode switch
	{
		ArsenalMode.Armor => true,
		ArsenalMode.WeaponRarity => true,
		ArsenalMode.WeaponTech => true,
		ArsenalMode.AmmoType => true,
		_ => false,
	};

	/// <summary>Why a tab is empty, for the placeholder line.</summary>
	public static string BlockedBecause( ArsenalMode mode ) => mode switch
	{
		ArsenalMode.Armor => "",
		ArsenalMode.WeaponTech => "",
		ArsenalMode.WeaponRarity => "",
		ArsenalMode.AmmoType => "",
		_ => "Not built",
	};

	public static void Select( ArsenalMode mode ) => Mode = mode;

	// ── armor page ─────────────────────────────────────────────────

	/// <summary>Which tiers to draw a card for. 1-based, as the original's are.</summary>
	public static int[] ArmorTiers
		=> Enumerable.Range( 1, System.Math.Max( 1, ActiveConfig.Armor.MaxTier ) ).ToArray();

	/// <summary>Salvage price of a tier, from the machine being used.</summary>
	public static int ArmorPrice( int tier )
		=> Current.IsValid() ? Current.PriceForTier( tier ) : 0;

	/// <summary>Armor ceiling a tier grants — the "450 ARMOR" line on the card.</summary>
	/// <summary>
	/// What a tier's vest holds, for the card that sells it.
	///
	/// ⚠️ ASKED FOR `User`, so the preview includes that player's Jugg m3 rather than quoting a
	/// number they will not get. Armor.CapForTier is the same call the real cap goes through.
	/// </summary>
	public static float ArmorCapFor( int tier ) => Armor.CapForTier( User, tier );

	/// <summary>Hits one bar of a tier absorbs, at the quoted round — what the card should say.</summary>
	public static int ArmorHitsFor( int tier ) => Armor.BarHits( User, tier );

	/// <summary>
	/// What a card should say.
	///
	/// ⚠️ THE ORIGINAL'S FOUR STATES, in its order of precedence
	/// (cl_nzaug_arsenal.lua buildArmor): owned wins, then locked, then unaffordable,
	/// then buyable. Checking affordability before "locked" would tell a player they
	/// cannot afford a tier they are not allowed to buy yet, which is the wrong
	/// problem to report.
	/// </summary>
	public static string ArmorCardState( int tier )
	{
		var p = User;
		if ( !p.IsValid() ) return "locked";

		if ( tier <= p.ArmorTier ) return "owned";
		if ( tier > p.ArmorTier + 1 ) return "locked";
		if ( p.Salvage < ArmorPrice( tier ) ) return "poor";

		return "buyable";
	}

	/// <summary>Label for a non-buyable card, or "" when it can be bought.</summary>
	public static string ArmorCardLabel( int tier ) => ArmorCardState( tier ) switch
	{
		"owned" => "OWNED",
		"locked" => "LOCKED",
		"poor" => "NO SALVAGE",
		_ => "",
	};

	/// <summary>
	/// Click a tier card.
	///
	/// ⚠️ REFUSES ANYTHING BUT THE NEXT TIER, matching the original's
	/// `t == cur + 1` guard. The card already says LOCKED, but the click has to agree
	/// — a card that looks locked and buys anyway is worse than either.
	///
	/// ⚠️ The purchase itself goes through Arsenal.BuyArmorTier, the same method the
	/// use key called before this page existed, so the menu adds a route and not a
	/// second implementation.
	/// </summary>
	public static void ClickArmor( int tier )
	{
		var p = User;
		if ( !IsOpen || !p.IsValid() ) return;

		if ( tier != p.ArmorTier + 1 )
		{
			Log.Info( $"[nz-arsenal] tier {tier} is not next — you are on {p.ArmorTier}" );
			return;
		}

		var result = Current.BuyArmorTier( p );
		if ( !string.IsNullOrEmpty( result ) ) Log.Info( $"[nz-arsenal] {result}" );
	}

	// ── weapon rarity page ────────────────────────────────────

	/// <summary>The weapon this page acts on, or null. Always the ACTIVE one.</summary>
	public static SWB.Base.Weapon RarityWeapon => Rarity.HeldBy( User );

	/// <summary>Its display name, for the page header.</summary>
	public static string RarityWeaponName
	{
		get
		{
			var w = RarityWeapon;
			return w.IsValid() && !string.IsNullOrWhiteSpace( w.DisplayName )
				? w.DisplayName.ToUpper()
				: "";
		}
	}

	/// <summary>Its current tier, or 0.</summary>
	public static int RarityCurrent => Rarity.TierOf( User, RarityWeapon );

	/// <summary>
	/// Which tiers to draw a card for — 0 through the top to be had, so FIVE cards; SIX, Godly's too, once basalt's
	/// Easter egg is complete (`Rarity.TopTier`).
	///
	/// ⛔ INCLUDES TIER 0, unlike the armor page which starts at 1. The original's
	/// rarity ladder shows Common as a card because it is a real state a weapon is IN
	/// — the "EQUIPPED" marker has to have somewhere to sit on an unupgraded gun.
	/// Armor has no equivalent: tier 0 there is simply "no vest".
	/// </summary>
	public static int[] RarityTiers
		=> Enumerable.Range( 0, Rarity.TopTier + 1 ).ToArray();

	/// <summary>Salvage price of a rarity tier, from the machine in use.</summary>
	public static int RarityPrice( int tier )
		=> Current.IsValid() ? Current.RarityPriceForTier( tier ) : 0;

	/// <summary>Tier name, for the card.</summary>
	public static string RarityName( int tier ) => Rarity.NameFor( tier ).ToUpper();

	/// <summary>Tier colour as CSS hex, for the card.</summary>
	public static string RarityHex( int tier ) => Rarity.HexFor( tier );

	/// <summary>Damage multiplier a tier grants — the "x2.25 DMG" line.</summary>
	public static float RarityMult( int tier ) => Rarity.Mult( tier );

	/// <summary>
	/// What a rarity card should say.
	///
	/// ⚠️ THE ORIGINAL'S FOUR STATES plus "equipped", in its order of precedence
	/// (cl_nzaug_arsenal.lua buildRarity): below current is owned, current is
	/// equipped, exactly one above is buyable-or-poor, anything higher is locked.
	///
	/// ⚠️ "noweapon" IS ITS OWN STATE rather than falling through to locked. With
	/// no gun in hand every card would read LOCKED, which says "come back later" about
	/// something one weapon switch fixes.
	/// </summary>
	public static string RarityCardState( int tier )
	{
		var p = User;
		if ( !p.IsValid() || !RarityWeapon.IsValid() ) return "noweapon";

		var cur = RarityCurrent;

		if ( tier < cur ) return "owned";
		if ( tier == cur ) return "equipped";
		if ( tier > cur + 1 ) return "locked";
		if ( p.Salvage < RarityPrice( tier ) ) return "poor";

		return "buyable";
	}

	/// <summary>Label for a rarity card, or "" when it can simply be bought.</summary>
	public static string RarityCardLabel( int tier ) => RarityCardState( tier ) switch
	{
		"owned" => "OWNED",
		"equipped" => "EQUIPPED",
		"locked" => "LOCKED",
		"poor" => "NO SALVAGE",
		"noweapon" => "",
		_ => "",
	};

	/// <summary>
	/// Does this card show a price.
	///
	/// ⚠️ ONLY ABOVE THE CURRENT TIER, matching the original's `if t > cur`. A
	/// price on a tier you already have reads as a repeat purchase, and Common has no
	/// price at all.
	/// </summary>
	public static bool RarityShowsPrice( int tier )
		=> RarityWeapon.IsValid() && tier > RarityCurrent && RarityPrice( tier ) > 0;

	/// <summary>
	/// Click a rarity card.
	///
	/// ⚠️ REFUSES ANYTHING BUT THE NEXT TIER, matching the original's `t == cur + 1`
	/// guard. The card already says LOCKED, but the click has to agree — a card that
	/// looks locked and buys anyway is worse than either.
	///
	/// ⚠️ Goes through Arsenal.BuyRarityTier, the one implementation, so the menu
	/// adds a route rather than a second set of rules.
	/// </summary>
	public static void ClickRarity( int tier )
	{
		var p = User;
		if ( !IsOpen || !p.IsValid() ) return;

		if ( !RarityWeapon.IsValid() )
		{
			Log.Info( "[nz-arsenal] hold a weapon to change its rarity" );
			return;
		}

		if ( tier != RarityCurrent + 1 )
		{
			Log.Info( $"[nz-arsenal] rarity {tier} is not next — this weapon is "
				+ $"{Rarity.NameFor( RarityCurrent )}" );
			return;
		}

		var result = Current.BuyRarityTier( p );
		if ( !string.IsNullOrEmpty( result ) ) Log.Info( $"[nz-arsenal] {result}" );
	}

	// ── ammo type page ───────────────────────────────────────────────────────

	/// <summary>
	/// Every mod, in catalogue order.
	///
	/// ⚠️ ALL SIX ARE SHOWN, WIRED OR NOT. Four still have no effect, and hiding those would
	/// make the page look finished while quietly selling four of six. Each card says so instead
	/// — the same choice the tabs themselves make about unbuilt pages.
	/// </summary>
	public static AmmoMods.Mod[] AmmoCatalogue => AmmoMods.All.Concat( AmmoMods.Planned ).ToArray();

	/// <summary>The mod fitted to the held weapon, or null.</summary>
	public static AmmoMods.Mod AmmoFitted => AmmoMods.Held( User );

	/// <summary>
	/// Its id, or "" — a separate reader because the build hash needs a value it can compare.
	///
	/// ⛔ THE HASH CANNOT TAKE THE RECORD ITSELF. `AmmoMods.All` builds a NEW array of NEW
	/// records every read (deliberately — see its own note about hotload), so two reads of the
	/// same fitted mod are two different objects. Hashing the object would change the hash every
	/// frame and rebuild the whole page continuously; hashing the id is stable.
	/// </summary>
	public static string AmmoFittedId => AmmoFitted?.Id ?? "";

	/// <summary>What a random roll costs here.</summary>
	public static int AmmoPrice
		=> Current.IsValid() && Current.Spot is not null ? Current.Spot.AmmoModPrice : 0;

	/// <summary>What one mod chosen by name costs here (2026-10-04: more than a roll).</summary>
	public static int AmmoChosenPrice
		=> Current.IsValid() && Current.Spot is not null ? Current.Spot.AmmoModChosenPrice : 0;

	/// <summary>The badge image for a mod — the same file the HUD draws.</summary>
	public static string AmmoIcon( AmmoMods.Mod mod ) => mod?.Icon ?? "";

	/// <summary>
	/// The one-line stat under a mod's name.
	///
	/// ⚠️ CHANCE AND COOLDOWN TOGETHER, because neither means anything alone. Thunderwall's
	/// 5% looks the worst on the page until you see its 1s cooldown, which makes it the most
	/// frequent of the six. Blast Furnace has no chance at all and says PASSIVE instead.
	/// </summary>
	/// <summary>
	/// "— no cooldown" or "— 5s", for the tail of the rate line.
	///
	/// ⚠️ A PASSIVE MOD CAN STILL HAVE A COOLDOWN in principle, so the card says which
	/// rather than assuming none. Blast Furnace happens to have neither.
	/// </summary>
	static string DashOrCooldown( AmmoMods.Mod mod )
		=> mod.Cooldown <= 0f ? "" : $"— {mod.Cooldown:0.#}s";

	public static string AmmoRate( AmmoMods.Mod mod )
	{
		if ( mod is null ) return "";

		// ⚠️ `mod.IsPassive`, NOT A CHANCE TEST OF ITS OWN. This read `Chance <= 0` and was
		// wrong for the only passive mod there is — see the property's own note.
		if ( mod.IsPassive ) return "PASSIVE " + DashOrCooldown( mod );

		return $"{mod.Chance * 100f:0.#}% — {mod.Cooldown:0.#}s";
	}

	/// <summary>
	/// What a mod card should say.
	///
	/// ⚠️ "fitted" OUTRANKS "poor". A mod already on the gun should not read NO SALVAGE —
	/// there is nothing to buy, so affordability is not the interesting fact about it.
	///
	/// ⚠️ "noweapon" IS ITS OWN STATE, as on the rarity page: with no gun in hand every card
	/// would otherwise read as unaffordable, which points at the wrong problem.
	/// </summary>
	public static string AmmoCardState( AmmoMods.Mod mod )
	{
		var p = User;
		if ( !p.IsValid() || !RarityWeapon.IsValid() ) return "noweapon";
		if ( mod is null ) return "locked";

		if ( AmmoFittedId == mod.Id ) return "fitted";
		if ( p.Salvage < AmmoChosenPrice ) return "poor";

		return "buyable";
	}

	/// <summary>Label for a mod card, or "" when it can simply be bought.</summary>
	public static string AmmoCardLabel( AmmoMods.Mod mod ) => AmmoCardState( mod ) switch
	{
		"fitted" => "FITTED",
		"poor" => "NO SALVAGE",
		"noweapon" => "",
		_ => "",
	};

	/// <summary>
	/// Is this mod's effect actually built.
	///
	/// ⚠️ SURFACED ON THE CARD, not hidden. Buying Cryofreeze today fits a mod that rolls,
	/// shows its badge on the HUD and does nothing — and 500 salvage for that is worth warning
	/// about before the click rather than explaining afterwards.
	/// </summary>
	public static bool AmmoBuilt( AmmoMods.Mod mod ) => mod?.Built ?? false;

	/// <summary>
	/// Buy a specific mod.
	///
	/// ⚠️ GOES THROUGH `Arsenal.BuyAmmoMod`, the one implementation, so the menu adds a route
	/// rather than a second set of rules — exactly as `ClickRarity` does.
	/// </summary>
	public static void ClickAmmo( string id )
	{
		var p = User;
		if ( !IsOpen || !p.IsValid() ) return;

		var result = Current.BuyAmmoMod( p, id );
		if ( !string.IsNullOrEmpty( result ) ) Log.Info( $"[nz-arsenal] {result}" );
	}

	/// <summary>
	/// Buy a random mod — upstream's actual behaviour.
	///
	/// ⛔ THIS IS THE FAITHFUL BUTTON AND THE SIX CARDS ARE THE DEVIATION. Upstream's machine
	/// has no picker: it rolls, never repeating your last. The picker exists because five of six
	/// mods are otherwise untestable without spamming a 500-salvage gamble, and this button is
	/// here so the real mechanic does not disappear behind the convenience.
	/// </summary>
	public static void ClickAmmoRandom() => ClickAmmo( "" );

	// ── the ammo page's grid and detail panel (2026-10-04) ──
	//
	// ⛔ THE USER'S LAYOUT: *"all the ammo mods in 5 rows of 4 on the left, jsut the icons and name / and when i click one the
	// right side shows me the mod and what it does including cooldown and chance, and a button to buy"*. A click SELECTS; only
	// the panel's button buys.

	/// <summary>
	/// The tile clicked: a mod's id, or "" for RANDOM. Null until a click, and reset each time the machine opens.
	///
	/// ⚠️ A STATIC FOR THE ONE MENU THIS MACHINE SHOWS, like `_techTierView`.
	/// </summary>
	static string _ammoSelected;

	/// <summary>What the panel shows: the clicked tile, else the fitted mod, else RANDOM ("").</summary>
	public static string AmmoSelectedId => _ammoSelected ?? AmmoFittedId;

	/// <summary>The selected mod, built or designed, or null for RANDOM.</summary>
	public static AmmoMods.Mod AmmoSelected
		=> string.IsNullOrEmpty( AmmoSelectedId ) ? null : AmmoCatalogue.FirstOrDefault( m => m.Id == AmmoSelectedId );

	public static void SelectAmmo( string id ) => _ammoSelected = id ?? "";

	/// <summary>A tile's classes: "on" when selected, "fitted" when on the gun, "soon" when not built yet.</summary>
	public static string AmmoTileClass( AmmoMods.Mod mod )
	{
		if ( mod is null ) return "";

		var c = mod.Id == AmmoSelectedId ? "on" : "";
		if ( mod.Id == AmmoFittedId ) c += " fitted";
		if ( !mod.Built ) c += " soon";
		return c;
	}

	public static string AmmoTriggerText( AmmoMods.Mod mod ) => mod?.Trigger switch
	{
		"kill" => "When you kill a zombie",
		"headshot kill" => "When you kill with a headshot",
		"every hit" => "Every hit",
		"5 hits" => "Five hits on one zombie",
		"reload" => "When you reload",
		_ => "When you hit a zombie",
	};

	/// <summary>
	/// The CHANCE row: YOURS, with any upgrade you own (2026-10-05, `AmmoMods.BaseChance`) — Scrapper I reads 6%, not the
	/// catalogue's 4%. Before Elemental Pop and Catalyst, which depend on the moment rather than the mod.
	/// </summary>
	public static string AmmoChanceText( AmmoMods.Mod mod )
		=> mod is null ? "" : mod.IsPassive ? "Always" : $"{AmmoMods.BaseChance( User, mod ) * 100f:0.#}%";

	/// <summary>The COOLDOWN row: yours too (`AmmoMods.BaseCooldown`) — Shatter Blast I reads 3 s, Radioactive Decay II 8 s.</summary>
	public static string AmmoCooldownText( AmmoMods.Mod mod )
	{
		if ( mod is null ) return "";

		var cooldown = AmmoMods.BaseCooldown( User, mod );
		return cooldown <= 0f ? "None" : $"{cooldown:0.#} s";
	}

	/// <summary>The selection's price: a roll's for RANDOM, a chosen mod's otherwise.</summary>
	public static int AmmoSelectedPrice => AmmoSelected is null ? AmmoPrice : AmmoChosenPrice;

	/// <summary>The button's state: "buyable", "fitted", "soon" (not built), "poor" or "noweapon".</summary>
	public static string AmmoBuyState
	{
		get
		{
			var p = User;
			if ( !p.IsValid() || !RarityWeapon.IsValid() ) return "noweapon";

			var mod = AmmoSelected;
			if ( mod is not null && mod.Id == AmmoFittedId ) return "fitted";
			if ( mod is not null && !mod.Built ) return "soon";
			if ( p.Salvage < AmmoSelectedPrice ) return "poor";

			return "buyable";
		}
	}

	public static string AmmoBuyLabel => AmmoBuyState switch
	{
		"fitted" => "FITTED",
		"soon" => "NOT BUILT YET",
		"poor" => "NO SALVAGE",
		"noweapon" => "HOLD A WEAPON",
		_ => AmmoSelected is null ? "ROLL" : "BUY",
	};

	/// <summary>
	/// The panel's button: through the same `Arsenal.BuyAmmoMod` as before. A designed mod is refused here, before anything
	/// is spent; it isn't in `AmmoMods.All`, so the machine would refuse it anyway.
	/// </summary>
	public static void BuyAmmoSelected()
	{
		if ( AmmoBuyState is "soon" or "fitted" ) return;

		if ( AmmoSelected is null ) ClickAmmoRandom();
		else ClickAmmo( AmmoSelected.Id );
	}

	// ── the ammo page's upgrades (2026-10-05) ──
	//
	// ⛔ THE USER'S SPEC: *"when i click an ammo mod i can buy it or i can also upgrade it up to 3 times / each upgrade costing more
	// and being permanent to that ammo mod, so i can equip on any weapon"*. The levels sit in the detail panel as a ladder
	// under the mod; clicking the next one buys it, as clicking a card does on the armor and rarity pages. The catalogue is
	// `AmmoModUpgrades`; the levels live on the player, per MOD (`NZPlayer.AmmoModLevel`).
	//
	// ⚠️ FIVE OF THEM SINCE 2026-10-06 (IV 5,000 and V 10,000 salvage, `AMMO_MODS.md` "Tiers IV and V"): nothing here counts them —
	// the rows come from `AmmoModUpgrades.For`, the prices from `Arsenal.AmmoUpgradePriceFor` — so only the panel's sizes moved.

	/// <summary>The selected mod's upgrades, I to V. None for RANDOM, which is not a mod.</summary>
	public static AmmoModUpgrades.Up[] AmmoUpgrades
		=> AmmoSelected is null ? System.Array.Empty<AmmoModUpgrades.Up>() : AmmoModUpgrades.For( AmmoSelected.Id );

	/// <summary>A mod's level for the reading player, 0 to `AmmoModUpgrades.MaxLevel` (5).</summary>
	public static int AmmoLevel( AmmoMods.Mod mod ) => mod is null ? 0 : AmmoModUpgrades.Level( User, mod.Id );

	/// <summary>
	/// Every mod's level added up, for the build hash.
	///
	/// ⚠️ A SUM, NOT THE SELECTED MOD'S LEVEL: the grid's pips show every mod's, and `nz_ammomod_level` can move one that is
	/// not selected.
	///
	/// ⚠️ 0 WHILE THE MENU IS SHUT: the hash is read every frame of the game, and opening the menu moves the hash anyway.
	/// </summary>
	public static int AmmoLevelTotal => IsOpen ? AmmoCatalogue.Sum( m => AmmoLevel( m ) ) : 0;

	/// <summary>What a level costs at this machine.</summary>
	public static int AmmoUpgradePrice( int level ) => Current.IsValid() ? Current.AmmoUpgradePriceFor( level ) : 0;

	/// <summary>
	/// An upgrade row's state: "owned", "buyable" (the next level, affordable), "poor" (the next, not affordable) or "locked"
	/// (a level after the next).
	///
	/// ⚠️ THE ARMOR CARDS' ORDER (`ArmorCardState`): owned, then locked, then unaffordable, so a level you may not buy yet
	/// never reads as one you cannot afford.
	/// </summary>
	public static string AmmoUpgradeState( AmmoModUpgrades.Up up )
	{
		var p = User;
		if ( up is null || !p.IsValid() ) return "locked";

		var have = AmmoModUpgrades.Level( p, up.ModId );
		if ( up.Level <= have ) return "owned";
		if ( up.Level > have + 1 ) return "locked";
		if ( p.Salvage < AmmoUpgradePrice( up.Level ) ) return "poor";

		return "buyable";
	}

	/// <summary>A row's classes: its state, and "soon" while its effect is not built.</summary>
	public static string AmmoUpgradeClass( AmmoModUpgrades.Up up )
		=> AmmoUpgradeState( up ) + (up is null || up.Built ? "" : " soon");

	/// <summary>The row's corner word. "UPGRADE" on the next level, which is what makes that row read as the button it is.</summary>
	public static string AmmoUpgradeLabel( AmmoModUpgrades.Up up ) => AmmoUpgradeState( up ) switch
	{
		"owned" => "OWNED",
		"buyable" => "UPGRADE",
		"poor" => "NO SALVAGE",
		_ => up is null ? "" : $"AFTER {HudTheme.ToRoman( up.Level - 1 )}",
	};

	/// <summary>Does a row show its price: every level not owned yet, the later ones too, so the whole ladder's cost shows.</summary>
	public static bool AmmoUpgradeShowsPrice( AmmoModUpgrades.Up up ) => AmmoUpgradeState( up ) != "owned";

	/// <summary>
	/// A click on a row. The next level buys through `Arsenal.BuyAmmoUpgrade`, the one implementation, as `ClickAmmo` goes
	/// through `BuyAmmoMod`; an owned or later level only says why not.
	///
	/// ⚠️ "poor" STILL GOES TO THE MACHINE, which refuses it with the amount short, as a click on an unaffordable armor card does.
	/// </summary>
	public static void ClickAmmoUpgrade( AmmoModUpgrades.Up up )
	{
		var p = User;
		if ( !IsOpen || !p.IsValid() || up is null ) return;

		var state = AmmoUpgradeState( up );

		if ( state == "owned" )
		{
			Log.Info( $"[nz-arsenal] {up.Name} ({up.Numeral}) is already yours" );
			return;
		}

		if ( state == "locked" )
		{
			Log.Info( $"[nz-arsenal] {up.Name} ({up.Numeral}) comes after {HudTheme.ToRoman( up.Level - 1 )}" );
			return;
		}

		var result = Current.BuyAmmoUpgrade( p, up.ModId );
		if ( !string.IsNullOrEmpty( result ) ) Log.Info( $"[nz-arsenal] {result}" );
	}

	/// <summary>A grid tile's pip <paramref name="pip"/>, 1 to `AmmoModUpgrades.MaxLevel`: lit up to the mod's level.</summary>
	public static string AmmoPipClass( AmmoMods.Mod mod, int pip ) => pip <= AmmoLevel( mod ) ? "pip on" : "pip";

	// ── weapon tech page ─────────────────────────────────────

	/// <summary>
	/// The prefab the tech page acts on — the HELD weapon's source.
	///
	/// ⚠️ Everything on this page keys off this one string. Null or empty means no
	/// weapon in hand, which the page reports rather than drawing 41 locked cards.
	/// </summary>
	public static string TechPrefab => Rarity.PrefabOf( RarityWeapon );

	/// <summary>Every tier, for the page to iterate.</summary>
	public static WeaponTech.Tier[] TechTiers => WeaponTech.Tiers;

	/// <summary>
	/// The held gun's cards in a tier: the sets of its tags (`WeaponTech.OfferFor`, 2026-10-04; tiers 1–3 joined them that
	/// evening). Class cards first, then the coloured action, magazine and reload ones.
	///
	/// ⚠️ THEN WHAT THE GUN OWNS IN THIS TIER THAT ITS SETS DO NOT OFFER (review, 2026-10-04): a node bought under the old
	/// whole-tier offer and kept through a hotload. It still works and still counts toward the tier's picks (`TechCount`), so
	/// it is drawn — OWNED, `WhyNot`'s first answer — where a right click can take it off. A retired node has no tier and frees
	/// its pick instead.
	/// </summary>
	public static WeaponTech.Node[] TechOffer( int tier )
	{
		var prefab = TechPrefab;
		var offer = WeaponTech.OfferFor( prefab, tier );

		var p = User;
		if ( !p.IsValid() || string.IsNullOrEmpty( prefab ) ) return offer;

		var kept = p.TechFor( prefab )
			.Where( id => WeaponTech.TierOfNode( id ) == tier && !offer.Any( n => n.Id == id ) )
			.Select( WeaponTech.Find )
			.Where( n => n is not null )
			.ToArray();

		return kept.Length == 0 ? offer : offer.Concat( kept ).ToArray();
	}

	/// <summary>
	/// The colour class of a card: "set-action" (blue), "set-mag" (purple), "set-reload" (orange), or "" for a class
	/// augment, in every tier. The user: *"the fire mode ones are blue, the clip size ones are purple, and the shell
	/// load ones are orange"*.
	/// </summary>
	public static string TechSetClass( string nodeId ) => WeaponTech.SetKindOf( nodeId ) switch
	{
		"action" => "set-action",
		"mag" => "set-mag",
		"reload" => "set-reload",
		_ => "",
	};

	/// <summary>How many nodes are owned in a tier on the held weapon.</summary>
	public static int TechOwnedIn( int tier )
	{
		var p = User;
		return p.IsValid() ? p.TechCount( TechPrefab, tier ) : 0;
	}

	/// <summary>
	/// The tier the page opens on — the lowest that still has picks left, or the last
	/// tier once every tier is full.
	///
	/// ⚠️ NOT "WHERE YOU ARE": there is no ladder (2026-10-03), so any tier can be bought
	/// into at any time and no tier is the one you are on. This only picks a useful page
	/// to open, and the selector no longer marks it.
	/// </summary>
	public static int TechStartTier
	{
		get
		{
			var p = User;
			if ( !p.IsValid() ) return 1;

			// ⛔ IN CREATIVE NO TIER IS EVER FULL (TierMaxed returns false), so the loop
			// below would answer "tier 1" forever. Open on the highest tier holding a node
			// instead: how far you have actually invested.
			if ( WeaponTech.Unlimited )
			{
				var deepest = 1;

				foreach ( var t in WeaponTech.Tiers )
					if ( p.TechCount( TechPrefab, t.Index ) > 0 ) deepest = t.Index;

				return deepest;
			}

			foreach ( var t in WeaponTech.Tiers )
				if ( !WeaponTech.TierMaxed( p, TechPrefab, t.Index ) ) return t.Index;

			return WeaponTech.MaxTier;
		}
	}

	// ⚠️ 0 MEANS "FOLLOW MY PROGRESS", not tier zero. Any other value is a tier the
	// player has deliberately clicked to look at, and it sticks until they pick another
	// or the menu is reopened.
	//
	// ⚠️ A static, so it survives hotload — harmless for a view selection, but Open()
	// resets it so a fresh visit always lands on TechStartTier rather than wherever you
	// were browsing last session (INSTRUCTIONS.md §1).
	static int _techTierView;

	/// <summary>Which tier the page is showing.</summary>
	public static int TechTier
		=> _techTierView >= 1 && _techTierView <= WeaponTech.MaxTier
			? _techTierView
			: TechStartTier;

	/// <summary>Look at a specific tier. 0 goes back to following progress.</summary>
	public static void SelectTechTier( int tier ) => _techTierView = tier;

	/// <summary>The tier object the page is showing, or null.</summary>
	public static WeaponTech.Tier TechShownTier => WeaponTech.TierOf( TechTier );

	/// <summary>
	/// Total nodes owned on the held weapon, for the panel's BuildHash.
	///
	/// ⚠️ A COUNT AND NOT THE LIST, because HashCode.Combine needs a value that
	/// changes when a purchase lands. The list instance is the same object before and
	/// after Add, so hashing it would never change and the page would never repaint.
	/// </summary>
	public static int TechOwnedTotal
	{
		get
		{
			var p = User;
			return p.IsValid() ? p.TechFor( TechPrefab ).Count : 0;
		}
	}

	/// <summary>
	/// The pick counter for a tier header.
	///
	/// ⛔ SAYS "UNLIMITED" IN CREATIVE RATHER THAN "0/3". The limit is not being
	/// enforced there, and a header reading PICK 0/3 beside seven simultaneously
	/// purchasable rows is a display asserting a rule the machine has stopped applying —
	/// the same class of false display that let three perks look implemented while doing
	/// nothing. If the number is not the rule, it must not be shown as the rule.
	/// </summary>
	public static string TechPickLabel( int tier )
	{
		var t = WeaponTech.TierOf( tier );
		if ( t is null ) return "";

		var owned = TechOwnedIn( tier );

		return WeaponTech.Unlimited
			? $"{owned} OWNED · UNLIMITED"
			: $"PICK {owned}/{t.Picks}";
	}

	/// <summary>
	/// What a tech card should say. "" when it is simply buyable.
	///
	/// ⚠️ Reuses WeaponTech.WhyNot so the CARD and the PURCHASE cannot disagree — the
	/// card is a rendering of the same rule the machine enforces, not a second copy of
	/// it. That divergence is what §3 is about, and a card that says "buy" on something
	/// the machine refuses is the worst version of it.
	/// </summary>
	public static string TechCardState( string nodeId )
	{
		var p = User;
		if ( !p.IsValid() || string.IsNullOrEmpty( TechPrefab ) ) return "noweapon";

		var tier = WeaponTech.TierOfNode( nodeId );
		var price = WeaponTech.TierOf( tier )?.Cost ?? 0;

		return WeaponTech.WhyNot( p, TechPrefab, nodeId, price ) switch
		{
			"" => "buyable",
			"already owned" => "owned",
			var w when w.EndsWith( "is full" ) => "full",
			var w when w.StartsWith( "need " ) => "poor",
			_ => "locked",
		};
	}

	/// <summary>
	/// Label for a tech card, or "" when buyable.
	///
	/// ⚠️ AN OWNED CHIMERA SAYS PERMANENT, because a right click takes every other owned node
	/// off and refuses that one (Arsenal.RemoveTech) — a card that read OWNED like the rest would
	/// leave the refusal to the console, where a player never sees it.
	/// </summary>
	public static string TechCardLabel( string nodeId ) => TechCardState( nodeId ) switch
	{
		"owned" => nodeId == "t5_chimera" ? "PERMANENT" : "OWNED",
		"locked" => "LOCKED",
		"full" => "TIER FULL",
		"poor" => "NO SALVAGE",
		_ => "",
	};

	/// <summary>
	/// Buy a node — a LEFT click on its card.
	///
	/// ⚠️ Goes through Arsenal.BuyTech, the one implementation, so the menu adds a
	/// route rather than a second set of rules.
	/// </summary>
	public static void ClickTech( string nodeId )
	{
		var p = User;
		if ( !IsOpen || !p.IsValid() ) return;

		var result = Current.BuyTech( p, nodeId );
		if ( !string.IsNullOrEmpty( result ) ) Log.Info( $"[nz-tech] {result}" );
	}

	/// <summary>
	/// Take a node off for half its price back — a RIGHT click on its card (user, 2026-10-03:
	/// "do the same for weapon tech, adding the ability to remove with right clicking", the same
	/// move the Wunderfizz's augments made). On a card you do not own it does nothing.
	///
	/// ⚠️ Through Arsenal.RemoveTech, the one implementation, exactly as ClickTech buys.
	/// </summary>
	public static void RightClickTech( string nodeId )
	{
		var p = User;
		if ( !IsOpen || !p.IsValid() ) return;

		var result = Current.RemoveTech( p, nodeId );
		if ( !string.IsNullOrEmpty( result ) ) Log.Info( $"[nz-tech] {result}" );
	}

	/// <summary>
	/// The tech page's footnote: how to take a node off, and what that gives back on the tier on
	/// screen. "" off the tech page or with nothing in hand.
	///
	/// ⛔ SAID ON SCREEN BECAUSE A RIGHT CLICK IS INVISIBLE until something says it exists. The
	/// Wunderfizz learned that once already: nothing on its screen said how to take an augment
	/// off until its footnote (WunderfizzMenu.AugmentNote) did.
	/// </summary>
	public static string TechNote
	{
		get
		{
			if ( Mode != ArsenalMode.WeaponTech || string.IsNullOrEmpty( TechPrefab ) ) return "";

			var t = TechShownTier;
			if ( t is null ) return "";

			var note = $"Right-click a node you own to take it off: {Arsenal.TechRefundForTier( t.Index ):N0} salvage back.";

			// ⚠️ ONLY WHERE CHIMERA IS ON SCREEN — on the other four tiers it is a rule about a
			// card the player cannot see.
			return t.Pool.Any( n => n.Id == "t5_chimera" ) ? note + " Chimera is permanent." : note;
		}
	}

	// ── host ─────────────────────────────────────────────────────────────────

	static GameObject _host;

	/// <summary>
	/// Make sure something is actually drawing the menu.
	///
	/// ⛔ THE PANEL HAS NO HOME IN THE SCENE, exactly as WunderfizzMenu records:
	/// every other HUD here is a scene object with a ScreenPanel on it, and the scene
	/// file must not be rewritten from a script — so this one builds its own. Without
	/// it the razor exists, compiles, and never renders: the menu "opens" in state and
	/// nothing appears.
	///
	/// ⚠️ Rebuilt whenever the object is gone, not once. A GameObject created from
	/// code does not survive a hotload, and a menu that silently stops appearing after
	/// a code edit is a bug this project has already had twice.
	/// </summary>
	static void EnsureHost()
	{
		if ( _host.IsValid() ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		_host = scene.CreateObject();
		_host.Name = "Arsenal UI";
		_host.Flags |= GameObjectFlags.NotSaved;

		_host.Components.Create<ScreenPanel>();
		_host.Components.Create<ArsenalPanel>();

		Log.Info( "[nz-arsenal] created the menu's screen panel" );
	}

	public static void Open( NZPlayer player, Arsenal arsenal )
	{
		if ( !arsenal.IsValid() ) return;

		EnsureHost();

		Current = arsenal;
		Mode = ArsenalMode.Armor;

		// ⚠️ The tech page goes back to following progress on every visit — see the note
		// on _techTierView. Without this a player who browsed tier 5 once would reopen the
		// machine on a locked tier and have to find their way back.
		_techTierView = 0;

		// ⚠️ AND THE AMMO PAGE OPENS ON THE FITTED MOD (or RANDOM), not on whatever was clicked last visit.
		_ammoSelected = null;

		// ⚠️ The cursor is what makes a menu usable, and Noclip keys off
		// Mouse.Visibility — a menu that does not set it leaves V toggling noclip
		// under the player while they click.
		Mouse.Visibility = MouseVisibility.Visible;

		Log.Info( $"[nz-arsenal] menu open — {PlayerSalvage:N0} salvage, "
			+ $"{Modes.Length} tabs ({Modes.Count( Implemented )} implemented)" );
	}

	public static void Close()
	{
		if ( !IsOpen ) return;

		Current = null;
		Mouse.Visibility = MouseVisibility.Hidden;

		Log.Info( "[nz-arsenal] menu closed" );
	}

	/// <summary>
	/// How far you can get from the Arsenal before the menu shuts itself.
	///
	/// ⛔️ WIDER THAN `Arsenal.UseRange` (96), ON PURPOSE. Closing at exactly the range that opens
	/// it means standing on the boundary flickers the menu -- and with it the CURSOR -- open and
	/// shut every frame you shift your weight. The gap between 96 and this is hysteresis: you must
	/// actually walk away, not merely stop being in range.
	///
	/// ⚠️ 1.5x rather than a bare number so the two stay related if UseRange is ever tuned. Same
	/// shape and same multiplier as WunderfizzMenu.CloseRange, deliberately -- two machines you
	/// walk up to should not feel different to walk away from.
	/// </summary>
	public static float CloseRange { get; set; } = Arsenal.UseRange * 1.5f;

	/// <summary>
	/// Shut the menu when the player walks away from the Arsenal they opened.
	///
	/// ⛔️ MEASURED TO `Current`, NOT TO THE NEAREST ARSENAL. On a map with two of them,
	/// nearest-machine would keep the menu alive as you walked from one to the other -- browsing
	/// machine A's screen while stood at machine B, buying from the wrong one.
	///
	/// ⚠️ A DESTROYED ARSENAL ALSO CLOSES IT. `Current` is a component reference and the object
	/// can go -- a config reload or a rebuild -- leaving a menu open over a machine that is not
	/// there, with every price reading 0 and the buttons doing nothing.
	///
	/// ⚠️ CALLED FROM NZPlayer's TICK, beside the ESC handler, for the reason written there: the
	/// way out of a modal must not depend on the modal working. A walk-away check living in the
	/// razor would go down with the panel, and the failure mode is the one already reported once --
	/// cursor up, player unresponsive, no way out.
	/// </summary>
	public static void TickRange( NZPlayer player )
	{
		if ( !IsOpen ) return;

		if ( !Current.IsValid() )
		{
			Log.Info( "[nz-arsenal] arsenal gone — menu closed" );
			Close();
			return;
		}

		// ⚠️ No player means no distance to measure, so LEAVE IT OPEN. Closing on a null player
		// would shut the menu during the frame a respawn swaps the object out.
		if ( !player.IsValid() ) return;

		var dist = player.WorldPosition.Distance( Current.WorldPosition );
		if ( dist <= CloseRange ) return;

		Log.Info( $"[nz-arsenal] walked away — {dist:0} > {CloseRange:0} units, menu closed" );
		Close();
	}

	/// <summary>
	/// `nz_arsenal_range [units]` -- read or set the walk-away distance.
	///
	/// ⚠️ REFUSES TO GO BELOW UseRange. A close range under the open range is a menu that shuts
	/// the instant it opens, which reads as the machine being broken rather than as a bad setting.
	/// </summary>
	[ConCmd( "nz_arsenal_range" )]
	public static void RangeCmd( float units = -1f )
	{
		if ( units >= 0f )
			CloseRange = System.MathF.Max( units, Arsenal.UseRange );

		Log.Info( $"[nz-arsenal] walk-away range {CloseRange:0} units"
			+ $" (opens within {Arsenal.UseRange:0})"
			+ (units >= 0f && units < Arsenal.UseRange
				? $" — {units:0} was raised to the open range" : "") );
	}

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

	/// <summary>Open it without walking to a machine: nz_arsenal_menu</summary>
	[ConCmd( "nz_arsenal_menu" )]
	public static void MenuCmd()
	{
		if ( IsOpen ) { Close(); return; }

		var p = User;
		if ( !p.IsValid() ) { Log.Warning( "[nz-arsenal] no player" ); return; }

		// ⚠️ Uses the NEAREST machine, and refuses when there is none rather than
		// opening a menu with no machine behind it — every purchase reads its prices
		// off the Spot, so a machineless menu would show zeroes.
		var a = Arsenal.Near( p.WorldPosition ) ?? Arsenal.All.FirstOrDefault( x => x.IsValid() );
		if ( a is null )
		{
			Log.Info( "[nz-arsenal] none on the map — nz_arsenal to place one" );
			return;
		}

		Open( p, a );
	}

	/// <summary>Switch tab from the console: nz_arsenal_tab [armor|tech|rarity|ammo]</summary>
	[ConCmd( "nz_arsenal_tab" )]
	public static void TabCmd( string which = "" )
	{
		if ( !string.IsNullOrWhiteSpace( which ) )
		{
			Mode = which.ToLower() switch
			{
				"armor" => ArsenalMode.Armor,
				"tech" => ArsenalMode.WeaponTech,
				"rarity" => ArsenalMode.WeaponRarity,
				"ammo" => ArsenalMode.AmmoType,
				_ => Mode,
			};
		}

		Log.Info( $"[nz-arsenal] tab {NameFor( Mode )}"
			+ $"{(Implemented( Mode ) ? "" : $" — {BlockedBecause( Mode )}")}" );
	}

	/// <summary>
	/// What the menu's UI is actually made of: nz_arsenal_ui
	///
	/// ⚠️ Reports each link SEPARATELY — host object, ScreenPanel, the panel
	/// component, the open flag and the cursor. "No UI appears" has five causes that
	/// look identical on screen, and the Wunderfizz already proved guessing between
	/// them costs more than printing them.
	/// </summary>
	[ConCmd( "nz_arsenal_ui" )]
	public static void UiState()
	{
		var scene = Game.ActiveScene;

		var host = _host.IsValid() ? _host : scene?.Directory
			.FindByName( "Arsenal UI" ).FirstOrDefault();

		Log.Info( $"[nz-arsenal-ui] host {(host.IsValid() ? "alive" : "MISSING")}"
			+ $" · open {IsOpen}"
			+ $" · tab {NameFor( Mode )}"
			+ $" · cursor {Mouse.Visibility}" );

		if ( !host.IsValid() )
		{
			Log.Info( "[nz-arsenal-ui] nothing is drawing it — open the menu once, "
				+ "EnsureHost builds the panel on demand" );
			return;
		}

		Log.Info( $"[nz-arsenal-ui]   ScreenPanel "
			+ $"{(host.Components.Get<ScreenPanel>().IsValid() ? "yes" : "MISSING")}"
			+ $" · ArsenalPanel "
			+ $"{(host.Components.Get<ArsenalPanel>().IsValid() ? "yes" : "MISSING")}" );
	}
}