UI/DevMenu.razor

A Razor UI panel for an in-game developer menu. It renders tabs, collapsible categories and tool buttons that invoke various in-game commands and toggles for spawning, diagnostics, placeables and settings, and it handles opening/closing with the Menu key and mouse visibility.

NetworkingFile Access
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@using NZombies;
@inherits PanelComponent

@*
    DEV MENU — press Q.

    Designed in Docs/ui-design/devmenu-artifact.html and ported here once
    settled. Iterating in the browser is a page refresh; iterating here is a
    compile + hotload + play + screenshot, so the design work happens there and
    only the finished layout lands here.

    Structure: three tabs, collapsible categories, one vertical column of tools.
    One category open at a time — Placeables alone has 45 tools, which is
    unusable as a flat grid.
*@

<root class="devmenu">

    @if ( Open )
    {
    <div class="frame">

        <div class="titlebar">
            <div class="title">nZombies — Dev</div>
            <div class="hint">Q to close</div>
        </div>

        <div class="tabs">
            @foreach ( var t in Tabs )
            {
                <div class="tab @(Tab == t ? "active" : "")" onclick=@( () => Tab = t )>@t</div>
            }
        </div>

        <div class="body">
            @foreach ( var cat in Cats.Where( c => c.Tab == Tab ) )
            {
                var isOpen = OpenCat == cat.Name;
                <div class="cat @(isOpen ? "open" : "")">

                    <div class="cat-head" onclick=@( () => OpenCat = isOpen ? "" : cat.Name )>
                        <div class="dot" style="background-color: @cat.Color;"></div>
                        <div class="cat-name">@cat.Name</div>
                        <div class="count">@cat.Tools.Count</div>
                    </div>

                    @if ( isOpen )
                    {
                        <div class="tools">
                            @foreach ( var tool in cat.Tools )
                            {
                                @* ⚠️ Copied into a local before the closure.
                                   A lambda capturing the loop variable itself is
                                   the classic way to end up invoking the wrong
                                   entry — belt and braces alongside the Cats
                                   fix, because both produce the same symptom. *@
                                var t = tool;

                                <div class="tool @(t.Todo ? "todo" : "")"
                                     onclick=@( () => Run( t ) )>
                                    <div class="dot" style="background-color: @cat.Color;"></div>
                                    <div class="tool-name">@t.Label</div>
                                    @if ( !string.IsNullOrEmpty( t.Keys ) )
                                    {
                                        <div class="keys">@t.Keys</div>
                                    }
                                </div>
                            }
                        </div>
                    }
                </div>
            }
        </div>
    </div>

    @* The settings column, beside the frame. A child panel rather than its own
       PanelComponent, so it needs no GameObject and no scene change. *@
    <ToolPanel />
    }
</root>

@code
{
    /// <summary>
    /// Closed on start — Q opens it.
    ///
    /// [Property] so it can also be toggled remotely over MCP, which is the
    /// only way to see this menu while driving the editor from another
    /// machine. ⚠️ Setting it that way WRITES INTO THE SCENE, so a debugging
    /// session can leave the menu opening on launch for everyone. If it starts
    /// appearing unbidden, that is why — clear it on the component and re-save.
    ///
    /// </summary>
    [Property] public bool Open { get; set; }
    [Property] public string Tab { get; set; } = "Dev";

    string OpenCat = "Spawning";

    static readonly string[] Tabs = new[] { "Dev", "Placeables", "Settings", "Easter egg" };

    public record Tool( string Label, Action Act = null, bool Todo = false, string Keys = "" );
    public record Cat( string Tab, string Name, string Color, List<Tool> Tools );

    const string PLACE = "LMB place · RMB remove";
    const string CORNER = "LMB corners · R reset";

    /// <summary>Barricades take exactly two clicks and no height — the height is
    /// fixed so the thing stays vaultable.</summary>
    const string BARRICADE = "LMB 2 points · RMB remove · R reset";

    // Colour per CATEGORY, not per tool: the label says what a tool is, the
    // colour says which group it came from. That is what keeps a tool
    // identifiable once you have scrolled past its header.
    //
    // ⚠️ A PROPERTY, NOT A `static readonly` FIELD. s&box hotload copies static
    // values forward BY NAME, so a menu edited during a session kept serving the
    // list built by an EARLIER compile — new labels rendered from the new code
    // while the click handlers still pointed into the old one. That is how
    // "Debris (buyable door)" came to arm the electricity switch.
    //
    // Third time this trap has cost real time today (AmbientBudget,
    // WalkerFootsteps). Rebuilding is cheap: razor only re-renders when
    // BuildHash changes, so this runs a handful of times, not per frame.
    static List<Cat> Cats => new()
    {
        new( "Dev", "Spawning", "#7ad17a", new() {
            new( "Spawn 1", () => ZombieCommands.Spawn() ),
            new( "Horde 5", () => ZombieCommands.Horde( 5 ) ),
            new( "Horde 35", () => ZombieCommands.Horde( 35 ) ),
            // ⚠️ 6 is the original's FLOOR for a dog round, not a round number
            // picked to look tidy: clamp(round x players, 6, 24). One hound is
            // for looking at; six is what the round actually feels like.
            new( "Hound 1", () => HoundCheck.SpawnReal( 1 ) ),
            new( "Hound pack 6", () => HoundCheck.SpawnReal( 6 ) ),
            // ⚠️ ONE, AND THAT IS NOT LAZINESS. A napalm zombie is a two-second fuse and a 200-unit
            // blast; two of them arriving together is not "more of the same", it is a corner of the
            // map you cannot stand in. `nz_napalm <n>` is there when a crowd is actually wanted.
            new( "Napalm 1", () => NapalmZombie.SpawnCmd( 1 ) ),
            new( "Shrieker 1", () => ShriekerZombie.SpawnCmd( 1 ) ),
            new( "Pest 3", () => ZombieCommands.SpawnSpecial( SpecialEnemies.Pest, 3 ) ),
            new( "Clear", () => ZombieCommands.Clear() ),
            // ⛔ IT DROPS ONE IN FRONT OF YOU AND IGNORES PLACED BOSS SPAWNS. A dev button is for
            // looking at the thing, and the placed-spawn version refuses outright on a map with no
            // boss points — which is every map until someone places one. `nz_boss_spawn` is still
            // the command that exercises the config data and the eligibility gating.
            new( "Spawn boss", () => BossCommands.SpawnHere() ),
            // ⚠️ NAMED, BECAUSE THE BUTTON ABOVE IS NOT. `SpawnHere()` with no argument falls back
            // to `SpecialEnemies.BossFallback`, which is the FIRST id in `Names` that is a boss —
            // Brutus today, and silently somebody else the moment that list is reordered. A button
            // for looking at one particular boss has to say which one.
            new( "Oberon", () => BossCommands.SpawnHere( SpecialEnemies.Oberon ) ) } ),

        new( "Dev", "Diagnostics", "#5aa9f0", new() {
            // View toggle — see the map as a player does, without leaving
            // Creative and resetting the game.
            new( "Preview mode", () => ToolCommands.Preview() ),
            // ⚠️ IN DIAGNOSTICS, NOT SPAWNING. It turns and resizes whatever is already standing
            // there; it spawns nothing, and a button that reads as a spawner in the spawn list is
            // one click from confusing.
            new( "Model tuner", () => ModelTuner.ModelCmd() ),
            new( "Nav state", () => NavCommands.Nav() ),
            @* The engine's own navmesh overlay, plus the marker toggle. Both are
               toggles — click again to turn off. Console: nz_nav_draw, nz_markers. *@
            @* DrawMesh is editor-only and renders nothing in play — see NavDots. *@
            new( "Show navmesh (editor)", () => NavCommands.NavDraw() ),
            new( "Navmesh live build", () => NavCommands.NavEditor() ),
            new( "Sample navmesh (dots)", () => NavCommands.NavDots() ),
            @* Grid-probed wireframe — reads as a surface where dots read as noise. *@
            new( "Navmesh grid", () => NavCommands.NavGrid() ),
            new( "Clear navmesh grid", () => NavCommands.NavGridClear() ),
            @* Removes the knife's viewmodel camera, which hides every marker. *@
            new( "Clear viewmodel cam", () => NavCommands.VmClear() ),
            new( "Show markers", () => NavCommands.Markers() ),
            new( "Nav links", () => NavLinkCommands.List() ),
            new( "Nav link markers", () => NavCommands.NavLinkVis() ),
            new( "Links", () => DoorCommands.Links() ),
            new( "Status", () => ZombieCommands.Status() ),
            new( "Animations", () => ZombieCommands.Anims() ),
            new( "Collision", () => ZombieCommands.Collide() ),
            new( "Physics", () => ZombieCommands.PhysicsDump() ),
            new( "Player", () => ZombieCommands.PlayerStatus() ),
            // Reads the RESOLVED model and sequence off each live zombie, so it
            // answers "is that actually a hound" rather than "was one asked for".
            new( "Hound info", () => HoundCheck.Info() ) } ),

        // ⚠️ ALL THREE GO THROUGH `DevActions`, which are ConCmds — a button whose logic
        // lives in a razor lambda cannot be run from the console or scripted.
        //
        // ⛔ "Kill all" IS NOT "Clear". Clear DESTROYS the objects; this kills them through
        // Health.Apply so points, drops, ragdolls and every on-kill augment fire. Both are
        // worth having and which you want depends on what you are testing.
        new( "Dev", "Zombie", "#e0685f", new() {
            new( "Kill all", () => DevActions.KillAll() ),
            new( "Freeze AI", () => DevActions.ToggleFreeze() ),
            new( "Toggle separation", () => DevActions.ToggleSeparation() ),
            new( "Tank (1,000,000 hp)", () => DevActions.Tank() ) } ),

        // ⛔ GODMODE NEEDED THE MECHANIC BUILT, not just wiring. Nothing in the project had
        // any notion of invulnerability — see Health.Invulnerable, whose gate sits at the very
        // top of Apply precisely so nothing below it runs.
        //
        // ⚠️ "Give weapon" IS RANDOM, because a button takes no argument. `nz_give <name>`
        // already covers wanting a specific gun; a button can only usefully add "something
        // else", and it draws from MysteryBox.Pool() so the map's BoxPacks filter is honoured.
        new( "Dev", "Player", "#e0b45a", new() {
            new( "Heal", () => DevActions.Heal() ),
            new( "Godmode", () => DevActions.ToggleGod() ),
            new( "Full armor", () => DevActions.FullArmor() ),
            new( "Give points (100k)", () => DevActions.GivePoints() ),
            new( "Give salvage (100k)", () => DevActions.GiveSalvage() ),
            new( "Give weapon (random)", () => DevActions.GiveRandomWeapon() ),
            new( "Remove all perks", () => PerkEffects.Clear() ),
            new( "Remove all augments", () => AugmentCommands.ClearCmd() ) } ),

        // ⚠️ ITS OWN CATEGORY, not filed under Player, because these act on the WEAPON
        // IN YOUR HANDS rather than on you — and every one of them is keyed on that
        // weapon's prefab path. Grouping them with Heal and Godmode would hide that
        // distinction, which is the thing most likely to confuse a test session.
        new( "Dev", "Weapon tech", "#c48fe0", new() {
            // ⚠️ THE HELD WEAPON ONLY. `nz_tech_clear` wipes every tree on the player,
            // which throws away the build on your other gun mid-test; this is the one
            // for iterating on a single weapon.
            new( "Clear tech (held weapon)", () => WeaponTech.ResetHeld() ),
            new( "What is owned", () => WeaponTech.Owned() ),
            new( "Clear ALL weapons", () => WeaponTech.ClearCmd() ),
            new( "List the catalogue", () => WeaponTech.List() ),
            // Rarity lives here too — same key, same weapon, same test loop.
            new( "Rarity: report", () => Rarity.Report() ),
            new( "Rarity: clear", () => Rarity.ClearCmd() ),
            // ⚠️ HELD WEAPON ONLY, unlike "Rarity: clear" directly above which resets EVERY
            // weapon. With two guns that is the difference between testing one tier against
            // another and throwing both away.
            new( "Rarity: held → Common", () => DevActions.HeldToCommon() ) } ),

        new( "Placeables", "Spawners", "#7ad17a", new() {
            new( "Zombie spawn", () => ToolPanelState.ShowTool( NZTools.ZombieSpawn ), false, PLACE ), new( "Special spawn", () => ToolPanelState.ShowTool( NZTools.SpecialSpawn ), false, PLACE ),
            new( "Boss spawn", () => ToolPanelState.ShowTool( NZTools.BossSpawn ), false, PLACE ), new( "Player spawn", () => ToolPanelState.ShowTool( NZTools.PlayerSpawn ), false, PLACE ) } ),

        new( "Placeables", "Player assisting", "#4fc9a8", new() {
            // ⚠️ Filed under Player assisting beside the perk machines, which is
            // where a mapper looks for it — even though Pack-a-Punch is NOT a perk
            // in the code. The original files it the same way and for the same
            // reason: it is a machine you walk up to and spend points at.
            new( "Pack-a-Punch", () => ToolPanelState.ShowTool( NZTools.PackAPunch ), false, PLACE ),
            @* Random perk for a fixed price. Sits here for the same reason
               Pack-a-Punch does. Console: nz_fizz, nz_fizz_list, nz_fizz_clear. *@
            new( "Der Wunderfizz", () => ToolPanelState.ShowTool( NZTools.Wunderfizz ), false, PLACE ),
            @* One machine, one perk — picked in the tool's own settings panel, because
               seventeen menu entries for one placeable would bury the category. Base
               perks only; augments stay exclusive to the Wunderfizz above.
               Console: nz_perk_machine, nz_perk_machine_list, nz_perk_machine_clear. *@
            new( "Perk machine", () => ToolPanelState.ShowTool( NZTools.PerkMachine ), false,
                "LMB place · perk in settings" ),
            new( "Weapon buy", () => ToolPanelState.ShowTool( NZTools.WallBuy ), false, "LMB place · C weapon/price" ),
            // One click, faces you. In the original these are random_box_spawns
            // and the box MOVES between them after a teddy bear — phase three.
            new( "Mystery box spawn", () => ToolPanelState.ShowTool( NZTools.MysteryBox ), false, PLACE ),
            @* Refills the held weapon's reserve. Price is per Pack-a-Punch tier and climbs with
               each refill of THAT weapon in the same round — tuned in Settings > Ammo box, not
               per spot. Console: nz_ammobox, nz_ammobox_list, nz_ammobox_clear. *@
            new( "Ammo box", () => ToolPanelState.ShowTool( NZTools.AmmoBox ), false, PLACE ),
            @* Walk up, pay, the run is over. Price, prop, prompt, game-over text and the
               keep-playing / reward-perks / perma-perks flags are all per spot, in the tool
               panel. Console: nz_ending, nz_ending_list, nz_ending_price, nz_ending_test. *@
            new( "Buyable ending", () => ToolPanelState.ShowTool( NZTools.BuyableEnding ), false, PLACE ),
            new( "Electricity switch", () => ToolPanelState.ShowTool( NZTools.PowerSwitch ), false, PLACE ),
            @* Armor tiers for salvage. Weapon tech and ammo mods are the other two
               jobs in ARSENAL_REMAKE.md and need systems that do not exist yet.
               Console: nz_arsenal, nz_arsenal_list, nz_arsenal_clear. *@
            new( "Arsenal position", () => ToolPanelState.ShowTool( NZTools.Arsenal ), false, PLACE ),
            @* Leave a weapon, or swap it for the one already there — upgrades, rarity and
               reserve travel with it. Free, and contents last the whole game.
               Console: nz_trade, nz_trade_list, nz_trade_empty, nz_trade_clear. *@
            new( "Trading table", () => ToolPanelState.ShowTool( NZTools.TradeTable ), false, PLACE ),
            @* ⚠️ THE BUILDING TABLE AND ITS PIECES LIVE UNDER **Easter egg > Collection**, which
               had slots reserved for both of them before either existed. They were briefly listed
               here too; one tool in two tabs is one tool somebody re-places because they did not
               realise it was the same one. *@
            new( "Exo station", null, true, PLACE ) } ),

        new( "Placeables", "Map objects", "#a98ce8", new() {
            // Buyable barrier — a door in all but name. Amber marker; the link
            // and price it places with are set by nz_debris_set.
            new( "Debris (buyable door)", () => ToolPanelState.ShowTool( NZTools.Debris ), false, CORNER ),
            @* Kills near it fill it. Filed beside debris because both gate a door FLAG — but this
               flag is an "all", not an "any": every box carrying it must be full before it opens.
               Console: nz_soul, nz_soul_list, nz_soul_clear, nz_soul_feed. *@
            new( "Soul box", () => ToolPanelState.ShowTool( NZTools.SoulBox ), false, PLACE ),
            // Two clicked points become a boarded, vaultable wall. NOT the
            // corner+height flow the walls below use — see BarricadeSpot.
            new( "Barricade", () => ToolPanelState.ShowTool( NZTools.Barricade ), false, BARRICADE ),
            @* Two clicks: the ledge, then where they land. The generated navmesh
               covers everything you can WALK — this is for everything you cannot.
               See NavLinkManager. Console: nz_navlink_*. *@
            new( "Nav link (drop / one-way)", () => ToolPanelState.ShowTool( NZTools.NavLink ), false,
                "LMB ledge · LMB landing · RMB remove" ),
            new( "Traps, buttons, logic", null, true, "LMB · R dupe · C edit" ),
            @* Interact to toggle the run into overdrive: no gap between rounds, spawn delay
               pinned to the map's minimum, every zombie at the top speed tier. Starts off each
               run. A placeholder box until the model lands. Console: nz_misery. *@
            @* Colour, brightness, radius and an optional power gate, all in the tool panel.
               ⛔ THE ONLY PLACEABLE THAT COSTS FRAMERATE — s&box has no lightmaps, so every one is
               dynamic every frame, and shadows are the expensive part. Emissive materials and the
               indirect bake are both FREE; reach for those first and place these where a specific
               spot needs a pool of light they cannot give.
               Console: nz_light_place, nz_light_list, nz_light_set, nz_light_clear. *@
            new( "Light", () => ToolPanelState.ShowTool( NZTools.MapLight ), false,
                "LMB place · ⚠ costs fps" ),
            @* A looping sound at a point — a vent, a generator, a dripping pipe. Picks a .sound
               EVENT, not an audio file; Tools/import_sound.py turns an mp3/wav into one.
               Console: nz_sound_place, nz_sound_list, nz_sound_set, nz_sound_clear. *@
            new( "Sound", () => ToolPanelState.ShowTool( NZTools.SoundSpot ), false, PLACE ),
            @* A patch of haze you walk INTO — volcanic soot, dust in a collapsed wing. Soft-edged
               sphere, not a box, so the edge fades instead of popping.
               ⚠ You cannot see it from outside: VolumetricFogVolume renders nothing on the scene
               path (it wants a baked fog texture from a compiled map), so these blend the ONE
               scene-wide GradientFog by where you stand. Twenty areas cost the same as one.
               Console: nz_fog_place, nz_fog_list, nz_fog_set, nz_fog_clear, nz_fog_report. *@
            new( "Fog", () => ToolPanelState.ShowTool( NZTools.FogArea ), false, PLACE ),
            new( "Misery acceleration device", () => ToolPanelState.ShowTool( NZTools.Misery ), false, PLACE ) } ),

        new( "Placeables", "Walls", "#e8925a", new() {
            // Solid forever, never buyable, invisible to the navmesh. Violet
            // marker; visible/invisible is set in its settings panel or with
            // nz_wall_set. Same corner + height-click flow as Debris.
            new( "Invisible wall", () => ToolPanelState.ShowTool( NZTools.InvisibleWall ), false, CORNER ),
            @* The invisible wall's twin: same corners, same footprint, but NO collision —
               it hurts any player standing in it instead of stopping them. Drawn in creative
               only; in a round it is invisible exactly like an invisible wall. Console:
               nz_dmgwall_list, nz_dmgwall_set, nz_dmgwall_where, nz_dmgwall_enable. *@
            new( "Damage wall", () => ToolPanelState.ShowTool( NZTools.DamageWall ), false, CORNER ),
            @* A name for a space: drawn like the walls — corners, then a height click — with no body at all. Walk into it and
               its name goes up at the top left (RoomNames). ⚠ Not the Easter egg's "Zone" further down, which is a step's
               occupancy area and still a placeholder. Console: nz_room_zones, nz_room_zone_name, nz_room_zone_clear,
               nz_room_zone_where. *@
            new( "Room zone", () => ToolPanelState.ShowTool( NZTools.RoomZone ), false, CORNER ) } ),

        new( "Placeables", "Transportation", "#5ad4d4", new() {
            @* Click the pad, then click where it sends you — one record, not two.
               Everyone standing on the pad travels together.
               Console: nz_teleporter, nz_teleporter_list, nz_teleporter_clear. *@
            new( "Teleporter", () => ToolPanelState.ShowTool( NZTools.Teleporter ), false,
                "LMB pad · LMB destination · R reset" ),
            @* Banana Colada's springboard made part of the map: nothing drawn in a round, its strength and radius
               set in the tool panel. Touch the floor inside it and it throws you straight up.
               Console: nz_springboard, nz_springboard_list, nz_springboard_clear. *@
            new( "Springboard", () => ToolPanelState.ShowTool( NZTools.Springboard ), false,
                "LMB place · LMB inside one re-sets it · RMB remove" ) } ),

        new( "Settings", "Map", "#8fa0d8", new() {
            new( "Gameplay", () => ToolPanelState.ShowGroup( ToolSettings.Gameplay ) ),
            @* Tier count, price per tier and damage per tier — see ToolSettings.PapRows. *@
            new( "Pack-a-Punch", () => ToolPanelState.ShowGroup( ToolSettings.Pap ) ),
            @* Refill price per PaP tier and the per-round escalation. See ToolSettings.AmmoRows. *@
            new( "Ammo box", () => ToolPanelState.ShowGroup( ToolSettings.Ammo ) ),
            @* One name per flag — the HUD's top left says it once a player walks through where that flag's barrier stood.
               See ToolSettings.RoomRows and RoomNames. Console: nz_rooms, nz_room_name, nz_room. *@
            new( "Room names", () => ToolPanelState.ShowGroup( ToolSettings.Rooms ) ),
            new( "Lobby", null, true ),
            new( "Custom sounds", null, true ) } ),

        new( "Settings", "Entities", "#4fc9a8", new() {
            new( "Zombies", () => ToolPanelState.ShowGroup( ToolSettings.Zombies ) ),
            @* When special rounds happen and how hard they are. The ENEMY is
               chosen per-spawner, not here — see NZTools.SpecialSpawn. *@
            new( "Special rounds", () => ToolPanelState.ShowGroup( ToolSettings.Specials ) ),
            new( "Boss rounds", () => ToolPanelState.ShowGroup( ToolSettings.Bosses ) ),
            new( "Players", () => ToolPanelState.ShowGroup( ToolSettings.Players ) ),
            @* Edits whatever is in hand and saves on the spot — see
               ToolSettings.WeaponRows. Console equivalents are nz_wep_*. *@
            new( "Weapon stats", () => ToolPanelState.ShowGroup( ToolSettings.WeaponStats ) ) } ),

        // ── EASTER EGG — the EE toolset. Design: Docs/EASTER_EGG_TOOLSET.md.
        //    PLACEHOLDERS ONLY for now: one entry per interactable in the spec, all `null, true`
        //    (they log "not wired yet"). Wiring — flags, conditions, steps — comes next. ──
        new( "Easter egg", "Interactables", "#e8c84a", new() {
            @* A button or lever carrying an EggStep: Required/Reward/Excluded flags plus
               press-count, hold, cooldown, unlock-delay and round-reset conditions. Order,
               Time window and Kills need the group coordinator and are not built yet.
               Console: nz_press, nz_press_list, nz_press_flags. *@
            new( "Pressable", () => ToolPanelState.ShowTool( NZTools.Pressable ), false, PLACE ),
            @* A symbol or weak point triggered by a BULLET rather than the use key. Same
               conditions as the pressable — they share the Egg* fields, so a step number and
               its flags typed once cover both. Extra: "Needs Pack-a-Punch".
               Console: nz_shoot, nz_shoot_list, nz_shoot_hit, nz_shoot_where. *@
            new( "Shootable", () => ToolPanelState.ShowTool( NZTools.Shootable ), false, PLACE ),
            new( "Lockpad", null, true, PLACE ),
            @* Text or an image on a wall. NO flags, no conditions, no step — purely
               informational, always visible, used to hint at things like a Lockpad's code.
               Console: nz_clue, nz_clue_text, nz_clue_list, nz_clue_clear. *@
            new( "Clue", () => ToolPanelState.ShowTool( NZTools.Clue ), false, PLACE ),
            @* Basalt seal 1: a hexagon of light on a wall, showing a number (I-IV) in one of the four colours -
               rolled again every round, no number and no colour twice. Nothing to set: what it shows is the roll's.
               Console: nz_hex_slot, nz_hex_slots, nz_hex_slots_roll, nz_hex_slots_clear. *@
            new( "Hex slot", () => ToolPanelState.ShowTool( NZTools.HexSlot ), false, PLACE ) } ),

        new( "Easter egg", "Collection", "#7ac0e8", new() {
            @* The bench the wonder weapon is assembled on. Same model as the trading table, so
               the MARKER COLOUR is what tells them apart on a map carrying both.
               Console: nz_buildtable, nz_buildtable_list, nz_buildtable_clear. *@
            new( "Building table", () => ToolPanelState.ShowTool( NZTools.BuildTable ), false, PLACE ),
            @* The three pieces that build it. WHICH piece is a mode on the tool rather than three
               entries — nz_buildpart_set 1..3 — so all three can be laid out in one walk of the
               map. nz_buildpart_list warns if a piece was placed NOWHERE, which is an unwinnable
               map and the one failure nothing else reports.
               Console: nz_buildpart, nz_buildpart_set, nz_buildpart_list, nz_buildpart_clear. *@
            new( "Building piece (1/2/3)", () => ToolPanelState.ShowTool( NZTools.BuildPart ), false, PLACE ),
            new( "Pickable", null, true, PLACE ),
            new( "Drop zone", null, true, CORNER ) } ),

        new( "Easter egg", "Zones & encounters", "#cf8fd4", new() {
            new( "Zone", null, true, CORNER ),
            new( "Soul box (EE)", null, true, PLACE ),
            new( "Boss encounter", null, true, PLACE ),
            new( "Escort", null, true, "LMB start · LMB end" ),
            new( "Defense", null, true, PLACE ) } ),

    };

    void Run( Tool t )
    {
        if ( t.Act is null ) { Log.Info( $"[nz] {t.Label} — not wired yet" ); return; }
        t.Act.Invoke();
    }

    protected override void OnUpdate()
    {
        // 'Menu' is already bound to Q in ProjectSettings/Input.config.
        if ( Input.Pressed( "Menu" ) )
        {
            Open = !Open;
            Mouse.Visibility = Open ? MouseVisibility.Visible : MouseVisibility.Auto;
        }

        // Leaving creative with the menu open would strand it on screen with
        // no way to close it, since Q no longer responds.
        // ⛔ AND NEVER IN A PUBLISHED COPY (`Edition`, 2026-10-05). Its host cannot enter Creative, but a client following an
        // editor host into it can — and it gets no build menu.
        if ( Open && (!NZGame.IsCreative || !Edition.CanBuild) )
        {
            Open = false;
            Mouse.Visibility = MouseVisibility.Auto;
        }

        if ( Open ) Mouse.Visibility = MouseVisibility.Visible;

        // Keep the settings panel pointed at whatever is actually armed —
        // holstering with RMB, or arming from the console, must retarget it.
        ToolPanelState.FollowActiveTool();
    }

    // ⚠️ Razor panels only re-render when this changes. OpenCat must be in it
    // or the accordion will not visibly open.
    protected override int BuildHash() => System.HashCode.Combine( Open, Tab, OpenCat );
}