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.
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")}" );
}
}