UI helper for an in-game Info Booklet. It builds lists and detailed sheets for two books (Perks and Arsenal) and their pages (armor, rarity, ammo mods, weapon tech), reading all data from game registries and config and exposing open/close/choose state and a console command to print the current view.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE INFO BOOKLET: what the game's content is and does, for new players (2026-10-05). The user: *"i want to build a new item in
/// the menu called info booklet … the idea is to catalogue the game content so new players understand it"*. Two books: Perks (every
/// perk, and inside each its augments) and Arsenal (its four pages: armor tiers, rarity, ammo mods, and weapon tech by class, clip
/// size, fire mode and shell reload).
///
/// ⛔ EVERY NAME, WORD AND NUMBER IS READ FROM THE GAME'S OWN CATALOGUES, NEVER COPIED HERE: `PerkRegistry`, `PerkAugments`, the
/// Arsenal's spot and `ActiveConfig.Armor`, `Rarity`, `AmmoMods` and `AmmoModUpgrades`, `WeaponTech.SetsIn`. A retune shows here
/// with nothing to edit, and a booklet that disagreed with the game would teach the wrong thing. Its own words are only the lines
/// saying what each book, page and rule is.
///
/// ⚠️ NOTHING IS WORKED OUT WHILE IT IS CLOSED, AND NOTHING IS HELD BETWEEN DRAWS. The page draws only while <see cref="Showing"/>,
/// and only when its hash moves (`Showing`, and <see cref="Version"/>, a plain int every click bumps), so the entries and the sheet
/// are built once per redraw and dropped. Several catalogues rebuild big tables on every call (`PerkAugments.PoolFor`, `WeaponTech`):
/// that is why they are read here, on a click's redraw, and never from a hash.
///
/// ⚠️ A SEAM, LIKE `DifficultyState`: razor types are generated and plain .cs cannot name them, so the page reads this, and the
/// lobby's row and the console talk to it.
/// </summary>
public static class BookletState
{
public enum Book { Perks, Arsenal }
public enum Page { Armor, Rarity, AmmoMods, WeaponTech }
/// <summary>One line of the left list: an entry to open, or a heading over the entries below it (<paramref name="Heading"/>).</summary>
public record Entry( string Id, string Label, string Icon = "", string Colour = "", string Tag = "", bool Later = false,
bool Heading = false );
/// <summary>One line of an entry's page: an augment, an upgrade, a tech node, a rule.</summary>
public record Item( string Chip, string Name, string Text, string Cost = "", bool Later = false );
/// <summary>A titled block of an entry's page.</summary>
public record Section( string Title, string Note, Item[] Items );
/// <summary>A tier beside the others (armor, rarity); a click opens it.</summary>
public record Card( string Id, string Name, string Big, string Line, string Colour = "" );
/// <summary>What the right side shows for the chosen entry. <paramref name="Colour"/> tints the name (a rarity's).</summary>
public record Sheet( string Name, string Kicker, string Text, string Icon, string Colour, Card[] Cards, string CardOn,
Section[] Sections );
// ⚠️ NULLABLE-BACKED, INSTRUCTIONS §1: a static keeps its value through a hotload, its initialiser does not re-run.
static Book? _book;
static Page? _page;
static string _chosen;
/// <summary>Is the booklet open?</summary>
public static bool Showing { get; private set; }
/// <summary>Moves on every click, for the page's `BuildHash`.</summary>
public static int Version { get; private set; }
/// <summary>The book open: Perks, or Arsenal. Kept between openings.</summary>
public static Book Current => _book ?? Book.Perks;
/// <summary>The Arsenal's page open. Kept between openings.</summary>
public static Page ArsenalPage => _page ?? Page.Armor;
public static Book[] Books => new[] { Book.Perks, Book.Arsenal };
/// <summary>The Arsenal's pages, in the Arsenal's own tab order.</summary>
public static Page[] Pages => new[] { Page.Armor, Page.Rarity, Page.AmmoMods, Page.WeaponTech };
public static void Open()
{
Showing = true;
Version++;
}
public static void Close()
{
Showing = false;
Version++;
}
public static void ChooseBook( Book book )
{
if ( book == Current ) return;
_book = book;
_chosen = null;
Version++;
}
public static void ChoosePage( Page page )
{
_book = Book.Arsenal;
if ( page == ArsenalPage ) return;
_page = page;
_chosen = null;
Version++;
}
/// <summary>Open one entry of the list shown, by id.</summary>
public static void Choose( string id )
{
if ( _chosen == id ) return;
_chosen = id;
Version++;
}
public static string NameOf( Book book ) => book == Book.Perks ? "Perks" : "Arsenal";
public static string NameOf( Page page ) => page switch
{
Page.Armor => "Armor",
Page.Rarity => "Rarity",
Page.AmmoMods => "Ammo mods",
_ => "Weapon tech",
};
/// <summary>The line under the tabs: what this book or page is, and where it is bought.</summary>
public static string Hint => Current == Book.Perks
? "Perks are bought from perk machines and the Wunderfizz. Each one has augments: bought with salvage at the Wunderfizz, once you own the perk."
: ArsenalPage switch
{
Page.Armor => "Vests are bought at the Arsenal with salvage, one tier after another. Armor plates fill them.",
Page.Rarity => "Rarity raises a gun's damage. Bought at the Arsenal for the gun in your hands, or rolled by the Mystery Box.",
Page.AmmoMods => $"Fitted at the Arsenal to the gun in your hands: {Spot.AmmoModChosenPrice:N0} salvage for the one you choose, "
+ $"{Spot.AmmoModPrice:N0} for a random one.",
_ => "Bought at the Arsenal for the gun in your hands. What a gun is offered comes from its class, its clip size, its fire "
+ "mode and, for some, a shell-by-shell reload.",
};
/// <summary>The list for the book and page open.</summary>
public static Entry[] Entries => Current == Book.Perks
? PerkEntries()
: ArsenalPage switch
{
Page.Armor => ArmorEntries(),
Page.Rarity => RarityEntries(),
Page.AmmoMods => AmmoEntries(),
_ => TechEntries(),
};
/// <summary>The entry open in this list: the one clicked, else the list's first.</summary>
public static string ChosenId( Entry[] entries )
{
if ( entries is null ) return "";
if ( _chosen is not null && entries.Any( e => !e.Heading && e.Id == _chosen ) ) return _chosen;
return entries.FirstOrDefault( e => !e.Heading )?.Id ?? "";
}
/// <summary>The right side for an entry of the list shown, or null.</summary>
public static Sheet SheetFor( string id ) => Current == Book.Perks
? PerkSheet( id )
: ArsenalPage switch
{
Page.Armor => ArmorSheet( id ),
Page.Rarity => RaritySheet( id ),
Page.AmmoMods => AmmoSheet( id ),
_ => TechSheet( id ),
};
// ══ perks ════════════════════════════════════════════════════════════════════════
static string PerkIcon( string id ) => $"ui/perks/{id}.png";
static Entry[] PerkEntries()
=> PerkRegistry.All.Select( p => new Entry( p.Id, p.Name, PerkIcon( p.Id ), PerkRegistry.AccentHex( p.Id ) ) ).ToArray();
/// <summary>
/// A perk: what it does, what it costs, and its augments. ⚠️ THE PRICE IS THE WUNDERFIZZ'S RULE (base, plus a step for each perk
/// owned), which a perk machine charges too unless its map sets its own; `Perk.Price` is a list price nothing charges.
/// </summary>
static Sheet PerkSheet( string id )
{
var p = PerkRegistry.Find( id );
if ( p is null ) return null;
var fizz = ActiveConfig.Current?.Wunderfizzes?.FirstOrDefault() ?? new WunderfizzSpot();
var price = fizz.PriceIncrement > 0
? $"Perk · {fizz.BasePrice:N0} points, +{fizz.PriceIncrement:N0} for each perk you own"
: $"Perk · {fizz.BasePrice:N0} points";
var sections = new List<Section>();
AddAugments( sections, p, PerkAugments.AugmentTier.Major, "Major augments", "major" );
AddAugments( sections, p, PerkAugments.AugmentTier.Minor, "Minor augments", "minor" );
return new Sheet( p.Name, price, p.Short, PerkIcon( p.Id ), "", Array.Empty<Card>(), "", sections.ToArray() );
}
static void AddAugments( List<Section> into, PerkRegistry.Perk p, PerkAugments.AugmentTier tier, string title, string word )
{
var augs = tier == PerkAugments.AugmentTier.Major ? PerkAugments.MajorsFor( p.Id ) : PerkAugments.MinorsFor( p.Id );
if ( augs.Length == 0 ) return;
var limit = PerkAugments.LimitOf( tier );
var note = $"You can have {limit} {word}{(limit == 1 ? "" : "s")} on {p.Name} at a time, "
+ $"{PerkAugments.PriceOf( augs[0] ):N0} salvage each.";
into.Add( new Section( title, note, augs.Select( a => new Item( a.Id, a.Name, a.Desc ) ).ToArray() ) );
}
// ══ the arsenal ══════════════════════════════════════════════════════════════════
/// <summary>
/// The prices: a standing Arsenal's own, else the map's first, else the defaults.
///
/// ⚠️ NOT `ArsenalMenu.ArmorPrice` AND ITS KIN, which read the machine being used and give 0 with no menu open, which is always
/// the case in the lobby.
/// </summary>
static ArsenalSpot Spot
=> Arsenal.All.FirstOrDefault( a => a.IsValid() && a.Spot is not null )?.Spot
?? ActiveConfig.Current?.Arsenals?.FirstOrDefault()
?? new ArsenalSpot();
// ── armor ──
/// <summary>A tier's price, from the spot, as `Arsenal.PriceForTier` reads it.</summary>
static int ArmorPrice( ArsenalSpot s, int tier ) => tier switch
{
1 => s.ArmorTier1Price,
2 => s.ArmorTier2Price,
3 => s.ArmorTier3Price,
_ => 0,
};
static string ArmorId( int tier ) => "armor" + tier;
static string Bars( int n ) => n == 1 ? "1 bar" : $"{n} bars";
static Entry[] ArmorEntries()
=> ArsenalMenu.ArmorTiers
.Select( t => new Entry( ArmorId( t ), $"Tier {HudTheme.TierNumeral( t )} vest", Tag: $"{Armor.CapForTier( null, t ):N0} armor" ) )
.ToArray();
/// <summary>
/// An armor tier, beside the others, and what is the same for all of them. ⚠️ THE BASE NUMBERS: no Juggernog, whose augments
/// add to a bar (`Armor.BarHits` with no player).
/// </summary>
static Sheet ArmorSheet( string id )
{
var tiers = ArsenalMenu.ArmorTiers;
var t = tiers.FirstOrDefault( x => ArmorId( x ) == id );
if ( t == 0 ) t = tiers.FirstOrDefault();
var spot = Spot;
var cfg = ActiveConfig.Armor;
var hits = Armor.BarHits( null, t );
var bar = Armor.BarSize( null, t );
var cap = Armor.CapForTier( null, t );
var text = $"{Bars( t )} of {bar:N0} armor, {cap:N0} in all. A bar is made to take {hits} zombie hits at round "
+ $"{cfg.BarHitsRound}'s damage before it breaks; later rounds hit harder.";
if ( t > 1 )
{
var below = t - 1;
text += $" Compared with tier {HudTheme.TierNumeral( below )}: +{cap - Armor.CapForTier( null, below ):N0} armor, "
+ $"+{hits - Armor.BarHits( null, below )} hits a bar.";
}
var cards = tiers.Select( x => new Card( ArmorId( x ), $"Tier {HudTheme.TierNumeral( x )}", $"{Armor.CapForTier( null, x ):N0}",
$"{Bars( x )} · {Armor.BarHits( null, x )} hits a bar · {ArmorPrice( spot, x ):N0} salvage" ) ).ToArray();
var every = new[]
{
new Item( "", "Damage", $"While you have armor, a hit takes its full damage off the armor and {cfg.BleedThrough * 100f:0}% of it off "
+ $"your health. From round {cfg.BleedRampFrom} that share rises, to {cfg.BleedThroughLate * 100f:0}% by round {cfg.BleedRampTo}." ),
new Item( "", "Plates", $"A plate fills the bar you are on. Carry up to {cfg.MaxPlates}. Zombies drop them: "
+ $"{cfg.PlateDropChance * 100f:0.#}% of kills, {cfg.PlateDropChanceSpecial * 100f:0.#}% of special enemies." ),
new Item( "", "Buying", "Tiers are bought in order. A new vest comes empty: plate it. You can pick plates up before you own one." ),
};
return new Sheet( $"Tier {HudTheme.TierNumeral( t )} vest", $"Body armor · {ArmorPrice( spot, t ):N0} salvage", text, "", "", cards,
ArmorId( t ), new[] { new Section( "Every tier", "", every ) } );
}
// ── rarity ──
/// <summary>A tier's price, from the spot, as `Arsenal.RarityPriceForTier` reads it. Common is no purchase.</summary>
static int RarityPrice( ArsenalSpot s, int tier ) => tier switch
{
1 => s.RarityTier1Price,
2 => s.RarityTier2Price,
3 => s.RarityTier3Price,
4 => s.RarityTier4Price,
5 => s.RarityTier5Price,
_ => 0,
};
static string RarityId( int tier ) => "rarity" + tier;
/// <summary>Every tier, Godly included: the booklet catalogues what exists; the Arsenal shows Godly once the egg is done.</summary>
static int[] RarityTiers => Enumerable.Range( 0, Rarity.MaxTier + 1 ).ToArray();
static Entry[] RarityEntries()
=> RarityTiers.Select( t => new Entry( RarityId( t ), Rarity.NameFor( t ), Colour: Rarity.HexFor( t ), Tag: $"×{Rarity.Mult( t ):0.##} damage" ) )
.ToArray();
static Sheet RaritySheet( string id )
{
var tiers = RarityTiers;
var t = tiers.Any( x => RarityId( x ) == id ) ? tiers.First( x => RarityId( x ) == id ) : 0;
var spot = Spot;
var text = $"×{Rarity.Mult( t ):0.##} damage.";
if ( t > 0 ) text += $" Each tier deals {Rarity.Step:0.#}× the damage of the one below.";
text += t == 0 ? " Every gun starts here."
: t >= Rarity.GodlyTier ? " Only once the map's Easter egg is done: then the Arsenal sells it and the Mystery Box can roll it."
: $" The Mystery Box can roll it from round {Rarity.GateForTier( t )}.";
var cards = tiers.Select( x => new Card( RarityId( x ), Rarity.NameFor( x ), $"×{Rarity.Mult( x ):0.##}",
x == 0 ? "Where every gun starts" : $"{RarityPrice( spot, x ):N0} salvage", Rarity.HexFor( x ) ) ).ToArray();
var how = new[]
{
new Item( "", "Damage only", "Rarity changes a gun's damage and nothing else." ),
new Item( "", "One gun", "It is bought for the gun in your hands, and stays with that gun." ),
new Item( "", "Wonder weapons", "A wonder weapon's damage never changes with rarity." ),
};
return new Sheet( Rarity.NameFor( t ), t == 0 ? "Rarity · the start" : $"Rarity · {RarityPrice( spot, t ):N0} salvage", text, "",
Rarity.HexFor( t ), cards, RarityId( t ), new[] { new Section( "How rarity works", "", how ) } );
}
// ── ammo mods ──
static AmmoMods.Mod[] Mods => AmmoMods.All.Concat( AmmoMods.Planned ).ToArray();
static Entry[] AmmoEntries()
=> Mods.Select( m => new Entry( m.Id, m.Name, m.Icon, Tag: m.IsPassive ? "Passive" : "", Later: !m.Built ) ).ToArray();
/// <summary>An upgrade level's price, from the spot, as `Arsenal.AmmoUpgradePriceFor` reads it. IV and V since 2026-10-06.</summary>
static int UpgradePrice( ArsenalSpot s, int level ) => level switch
{
1 => s.AmmoUpgrade1Price,
2 => s.AmmoUpgrade2Price,
3 => s.AmmoUpgrade3Price,
4 => s.AmmoUpgrade4Price,
5 => s.AmmoUpgrade5Price,
_ => 0,
};
/// <summary>
/// An ammo mod: what it does, when it goes off, and its upgrades. ⚠️ THE CATALOGUE'S CHANCE AND COOLDOWN, not
/// `ArsenalMenu.AmmoChanceText`, which reads YOURS, upgrades included.
/// </summary>
static Sheet AmmoSheet( string id )
{
var mods = Mods;
var m = mods.FirstOrDefault( x => x.Id == id ) ?? mods.FirstOrDefault();
if ( m is null ) return null;
var spot = Spot;
var trigger = new[]
{
new Item( "", "When", ArsenalMenu.AmmoTriggerText( m ) ),
new Item( "", "Chance", m.IsPassive ? "Always" : $"{m.Chance * 100f:0.#}%" ),
new Item( "", "Cooldown", m.Cooldown <= 0f ? "None" : $"{m.Cooldown:0.#} s" ),
};
var sections = new List<Section> { new( "How it goes off", "", trigger ) };
var ups = AmmoModUpgrades.For( m.Id );
if ( ups.Length > 0 )
sections.Add( new Section( "Upgrades", "Bought in order at the Arsenal. An upgrade belongs to the mod, on every gun you fit it to.",
ups.Select( u => new Item( u.Numeral, u.Name, u.Effect, $"{UpgradePrice( spot, u.Level ):N0} salvage", !u.Built ) ).ToArray() ) );
var text = m.Built ? m.Short : m.Short + " Not in the game yet.";
return new Sheet( m.Name, (m.IsPassive ? "Passive ammo mod" : "Ammo mod") + $" · {spot.AmmoModChosenPrice:N0} salvage", text, m.Icon, "",
Array.Empty<Card>(), "", sections.ToArray() );
}
// ── weapon tech ──
/// <summary>The four kinds of set, in the user's order: class, clip size, fire mode, shell reload.</summary>
static string[] TechKinds => new[] { "class", "mag", "action", "reload" };
static string KindOf( string set )
{
var colon = set.IndexOf( ':' );
return colon > 0 ? set.Substring( 0, colon ) : "";
}
static string ValueOf( string set )
{
var colon = set.IndexOf( ':' );
return colon > 0 ? set.Substring( colon + 1 ) : set;
}
static string KindName( string kind ) => kind switch
{
"class" => "Class",
"mag" => "Clip size",
"action" => "Fire mode",
"reload" => "Shell reload",
_ => kind,
};
static string SetName( string set ) => KindOf( set ) switch
{
"mag" => ValueOf( set ).Replace( "-", "–" ) + " rounds",
"action" => ValueOf( set ) switch
{
WeaponTags.Auto => "Automatic",
WeaponTags.Burst => "Burst",
WeaponTags.Semi => "Semi-automatic",
WeaponTags.Manual => "Manual",
var other => other,
},
"reload" => ValueOf( set ) == "shell" ? "One shell at a time" : ValueOf( set ),
_ => ValueOf( set ),
};
/// <summary>Which guns a set's tech is for, in words.</summary>
static string SetText( string set ) => KindOf( set ) switch
{
"class" => $"Tech for every gun in the {ValueOf( set )} class.",
"mag" => $"Tech for any gun whose magazine holds {ValueOf( set ).Replace( "-", "–" )} rounds.",
"action" => ValueOf( set ) switch
{
WeaponTags.Auto => "Tech for automatic guns: hold the trigger and they keep firing.",
WeaponTags.Burst => "Tech for burst-fire guns.",
WeaponTags.Semi => "Tech for semi-automatic guns: one shot for each pull.",
WeaponTags.Manual => $"Tech for guns you work by hand: bolt, pump, lever and break actions, and semi-automatics slower than "
+ $"{WeaponTags.ManualRpm:0} rounds a minute.",
_ => "Tech for guns with this fire mode.",
},
"reload" => "Tech for guns that reload one shell at a time.",
_ => "",
};
/// <summary>
/// Every built node of every tier, under each set it is in. ⚠️ `WeaponTech.SetsIn`, NOT THE TIERS' POOLS GROUPED BY `SetOf`,
/// which answers a node several classes share with its first class only and would leave the others without it.
/// </summary>
static List<(string Set, WeaponTech.Tier Tier, WeaponTech.Node Node)> TechRows()
{
var rows = new List<(string Set, WeaponTech.Tier Tier, WeaponTech.Node Node)>();
foreach ( var tier in WeaponTech.Tiers )
foreach ( var s in WeaponTech.SetsIn( tier.Index ) )
rows.Add( (s.Set, tier, s.Node) );
return rows;
}
static Entry[] TechEntries()
{
var rows = TechRows();
var entries = new List<Entry>();
foreach ( var kind in TechKinds )
{
var sets = rows.Where( r => KindOf( r.Set ) == kind ).Select( r => r.Set ).Distinct().ToList();
if ( sets.Count == 0 ) continue;
entries.Add( new Entry( "kind:" + kind, KindName( kind ), Heading: true ) );
foreach ( var set in sets )
entries.Add( new Entry( set, SetName( set ), Tag: $"{rows.Count( r => r.Set == set )} tech" ) );
}
return entries.ToArray();
}
/// <summary>One set: which guns it is for, and its tech by tier, each tier with its own pick and price.</summary>
static Sheet TechSheet( string set )
{
var rows = TechRows();
if ( !rows.Any( r => r.Set == set ) ) set = rows.Select( r => r.Set ).FirstOrDefault() ?? "";
var sections = rows.Where( r => r.Set == set )
.GroupBy( r => r.Tier.Index )
.OrderBy( g => g.Key )
.Select( g =>
{
var tier = g.First().Tier;
return new Section( $"Tier {HudTheme.TierNumeral( tier.Index )} · {tier.Name}",
$"A gun takes {tier.Picks} of all its tier {HudTheme.TierNumeral( tier.Index )} tech, {tier.Cost:N0} salvage each.",
g.Select( r => new Item( "", r.Node.Name, r.Node.Effect ) ).ToArray() );
} )
.ToArray();
return new Sheet( SetName( set ), KindName( KindOf( set ) ) + " tech", SetText( set ), "", "", Array.Empty<Card>(), "", sections );
}
// ══ the console ═════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_booklet [open|close|perks|arsenal|armor|rarity|ammo|tech|<entry id>]` — open it, turn to a book or page or entry, and
/// print what the page shows: the list, then the open entry. Bare, it prints only.
/// </summary>
[ConCmd( "nz_booklet" )]
public static void Cmd( string what = "" )
{
var w = (what ?? "").Trim().ToLowerInvariant();
switch ( w )
{
case "": break;
case "open": Open(); break;
case "close": Close(); break;
case "perks": ChooseBook( Book.Perks ); break;
case "arsenal": ChooseBook( Book.Arsenal ); break;
case "armor": ChoosePage( Page.Armor ); break;
case "rarity": ChoosePage( Page.Rarity ); break;
case "ammo": ChoosePage( Page.AmmoMods ); break;
case "tech": ChoosePage( Page.WeaponTech ); break;
default: Choose( what.Trim() ); break;
}
var entries = Entries;
var chosen = ChosenId( entries );
var where = Current == Book.Perks ? "Perks" : "Arsenal › " + NameOf( ArsenalPage );
Log.Info( $"[nz-booklet] {(Showing ? "open" : "closed")} · {where} · {entries.Count( e => !e.Heading )} entries · showing '{chosen}'" );
Log.Info( $"[nz-booklet] {string.Join( ", ", entries.Select( e => e.Heading ? $"[{e.Label}]" : e.Id ) )}" );
var sheet = SheetFor( chosen );
if ( sheet is null ) return;
Log.Info( $"[nz-booklet] {sheet.Name} — {sheet.Kicker}" );
Log.Info( $"[nz-booklet] {sheet.Text}" );
foreach ( var c in sheet.Cards )
Log.Info( $"[nz-booklet] {(c.Id == sheet.CardOn ? ">" : " ")} {c.Name}: {c.Big} · {c.Line}" );
foreach ( var s in sheet.Sections )
{
Log.Info( $"[nz-booklet] {s.Title.ToUpperInvariant()}{(string.IsNullOrEmpty( s.Note ) ? "" : " — " + s.Note)}" );
foreach ( var i in s.Items )
Log.Info( $"[nz-booklet] {i.Chip,-4} {i.Name}: {i.Text}{(string.IsNullOrEmpty( i.Cost ) ? "" : " (" + i.Cost + ")")}" );
}
}
}