Static UI state for the map browser. It defines tabs (Primis, Immunis, Survival), tab labels/hints, what row is hovered/previewed, chosen map for published mode, and helper methods to open/close the browser, build rows from MapLibrary, and console commands to list or dump rows.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// What the map browser is showing — a seam, exactly like <see cref="ConfigBrowserState"/>.
///
/// ⚠️ THE SEAM EXISTS BECAUSE RAZOR TYPES ARE GENERATED and cannot be referenced from a plain .cs
/// file. Console commands and the loader talk to this; the panel reads it. Neither references the
/// other's type.
///
/// ⛔ THIS BROWSER SELECTS MAPS. IT DOES NOT MANAGE CONFIGS. Load / Save / Create stay on the
/// lobby's existing Config buttons, which already do it well — a second way to load a config would
/// be a second thing to keep in step with `ConfigBrowserState`. The config COUNT is shown per row,
/// because "does this map have a setup yet" is the one question you need answered while choosing.
///
/// ⛔ EXCEPT ON THE PUBLISHED SIDE, WHERE A MAP IS CHOSEN WITH A GAMEMODE (2026-10-05, <see cref="Chosen"/>). There a gamemode IS
/// a shipped config (`Gamemodes`), the lobby has no Config buttons, and its Gamemode row went at the user's word: *"When I choose
/// a map, it should then replace the maps on the left with the gamemodes available for that map, these being the config games,
/// when I click one it loads that gamemode. Meaning no more gamemode option in the lobby"*.
///
/// ⛔ MADE FOR PLAYERS, 2026-10-05: shipped maps only. The user: *"At the moment it is made with me in mind, but I want to
/// point it towards players"*. ⛔ ONE TAB PER EASTER EGG LINE, then Survival (15:55): Primis, Immunis, Survival —
/// *"Instead of main quest and survival make it / First cast name / second cast name / survival"*. The Workshop search, Downloaded and Favorites tabs went
/// with that; `MapLibrary` still keeps the favourites and downloaded records for the console.
/// </summary>
public static class MapBrowserState
{
public enum Tab
{
/// <summary>Closed.</summary>
None,
/// <summary>
/// The Primis line (the Black Ops 3 crew): the manifest's `"category": "primis"`. Their quest: the lost gods that were sealed away,
/// defeated, their essence collected (`Docs/MAP_LORE.md`). Ignis Aeternus's quest is built; the others are listed ahead
/// of theirs.
/// </summary>
Primis,
/// <summary>
/// The Immunis line, the new cast: the manifest's `"category": "immunis"`. Their quest: who unleashed the dead on them,
/// and whether it can be undone.
/// </summary>
Immunis,
/// <summary>Every other shipped map: hold out for as many rounds as you can.</summary>
Survival,
}
/// <summary>
/// What a tab is called. ⛔ THE ONE PLACE A CAST'S NAME IS WRITTEN for the browser, the lobby's Change map row
/// (<see cref="Summary"/>) and the console, so renaming a cast is one line here (and its manifest value).
/// </summary>
public static string LabelOf( Tab t ) => t switch
{
Tab.Primis => "Primis",
Tab.Immunis => "Immunis",
Tab.Survival => "Survival",
_ => "",
};
/// <summary>The line under the tabs: what the tab's kind of game is. ⚠️ VAGUE for the casts' lines, as the descriptions are.</summary>
public static string HintOf( Tab t ) => t switch
{
Tab.Primis => "Their quest: what was sealed away, and what it leaves behind.",
Tab.Immunis => "Their quest: who did this, and whether it can be undone.",
_ => "Hold out against the horde for as many rounds as you can.",
};
/// <summary>What an empty tab says.</summary>
public static string EmptyOf( Tab t ) => t == Tab.Survival
? "No survival maps shipped with the gamemode."
: "No maps in this story yet.";
/// <summary>The lobby's Change map row, naming every tab: "Primis, Immunis or survival".</summary>
public static string Summary => $"{LabelOf( Tab.Primis )}, {LabelOf( Tab.Immunis )} or survival";
public static Tab Showing { get; private set; } = Tab.None;
/// <summary>
/// Title of the map being downloaded right now, or "".
///
/// ⚠️ THE TITLE, NOT THE IDENT. The overlay says what the player clicked — "Downloading
/// Zombie Village Redone" rather than "crypticvision.zombievillageredone", which is the same
/// information wearing a form nobody recognises as the thing they picked.
/// </summary>
public static string Loading { get; set; } = "";
/// <summary>
/// Open the browser on a tab. <see cref="Tab.None"/>, the default, opens the tab that holds the map you're on, so the
/// loaded map is in the first list you see.
/// </summary>
public static void Open( Tab tab = Tab.None )
{
// ⛔ RE-READ THE MANIFEST ON EVERY OPEN. `MapLibrary.Originals` caches into a static, and a
// static SURVIVES A HOTLOAD BY VALUE — so adding a map to maps/manifest.json changed the
// file and the tab kept listing the old contents, with nothing to suggest the edit had not
// taken. Canyon Labs was invisible in the browser while `nz_map_originals` (which calls
// Reload first) happily printed both maps.
//
// ⚠️ The cost is one small JSON read per open, not per frame — Open() is a user action.
// Both tabs read the manifest now, so it happens for either.
MapLibrary.Reload();
// A tab opens on its own default preview (the loaded map if it lists it, else its first), not on a map pointed at in
// the other tab.
Hovered = "";
BackToMaps();
Showing = tab == Tab.None ? TabOf( NZMap.CurrentRaw ) : tab;
}
public static void Close()
{
Showing = Tab.None;
BackToMaps();
}
/// <summary>
/// The tab a map is listed under: its Easter egg line's (`MapLibrary.Original.Line`), Survival for every other.
///
/// ⚠️ MATCHED THROUGH NZMap.KeyFor, as `MapLibrary.BackgroundFor` matches: the manifest keys by scene path, the live map
/// may be held under another form of the same name.
/// </summary>
public static Tab TabOf( string mapName )
{
var key = NZMap.KeyFor( mapName ?? "" );
var m = MapLibrary.Originals.FirstOrDefault( o => NZMap.KeyFor( o.MapName ) == key );
return m?.Line switch
{
"primis" => Tab.Primis,
"immunis" => Tab.Immunis,
_ => Tab.Survival,
};
}
// ── rows, in the shape the panel draws ───────────────────────────────────────────────────
/// <summary>One row, whichever tab produced it.</summary>
public record Row( string MapName, string Title, string Subtitle, string Thumb, string Background, bool IsCurrent,
int Configs );
public static List<Row> Rows => RowsFor( Showing );
/// <summary>The map the cursor last pointed at in the list, by MapName; "" until it points at one.</summary>
public static string Hovered { get; set; } = "";
/// <summary>
/// The map the page's right side shows: the one last pointed at, else the loaded map if this tab lists it, else the
/// tab's first. Null only for an empty tab.
///
/// ⚠️ IT STAYS ON THE LAST ONE POINTED AT when the cursor leaves the list, as in the console menus it copies: the
/// preview is a selection, not a tooltip.
/// </summary>
public static Row Preview
{
get
{
var rows = Rows;
return rows.FirstOrDefault( r => r.MapName == Hovered )
?? rows.FirstOrDefault( r => r.IsCurrent )
?? rows.FirstOrDefault();
}
}
// ── the published side's second step: the chosen map's gamemodes ─────────────────────────────
/// <summary>
/// The map whose gamemodes the list shows in place of the maps, by MapName; "" while it shows the maps.
///
/// ⚠️ PUBLISHED SIDE ONLY (`Edition.IsPublished`). A map's click opens this (`MapBrowser.PickRow`) instead of loading the map,
/// and a gamemode's click loads both (`Gamemodes.PlayOn`). The editor's side still loads a map on its click: its configs are
/// the Config buttons' business.
/// </summary>
public static string Chosen { get; private set; } = "";
/// <summary>The gamemode the cursor last pointed at in that list, by file name; "" until it points at one.</summary>
public static string HoveredMode { get; set; } = "";
/// <summary>Is the list showing a map's gamemodes rather than the maps?</summary>
public static bool ChoosingMode => !string.IsNullOrEmpty( Chosen );
/// <summary>Show this map's gamemodes in the list.</summary>
public static void ChooseMap( string mapName )
{
Chosen = mapName ?? "";
HoveredMode = "";
}
/// <summary>Back to the maps, on the tab that was showing.</summary>
public static void BackToMaps()
{
Chosen = "";
HoveredMode = "";
}
/// <summary>The chosen map as a row, for its name and picture on the right; null while the list shows the maps.</summary>
public static Row ChosenRow
=> ChoosingMode ? MapLibrary.Originals.Where( m => m.MapName == Chosen ).Select( MakeRow ).FirstOrDefault() : null;
/// <summary>Is the chosen map the one loaded now?</summary>
public static bool ChosenIsCurrent
=> ChoosingMode && NZMap.CurrentRaw.Equals( Chosen, System.StringComparison.OrdinalIgnoreCase );
/// <summary>The chosen map's gamemodes: what it SHIPS, `default` first (`Gamemodes.For`). Empty while the list shows maps.</summary>
public static List<Gamemodes.Mode> Modes
=> ChoosingMode ? Gamemodes.For( NZMap.KeyFor( Chosen ) ) : new List<Gamemodes.Mode>();
/// <summary>Is this the gamemode being played: the chosen map is the one on, and this is its loaded config?</summary>
public static bool IsPlaying( Gamemodes.Mode m ) => ChosenIsCurrent && Gamemodes.IsActive( m );
/// <summary>The gamemode the right side describes: the one last pointed at, else the one being played, else the first. Null
/// for a map with none.</summary>
public static Gamemodes.Mode PreviewMode
{
get
{
var modes = Modes;
return modes.FirstOrDefault( m => m.Name == HoveredMode )
?? modes.FirstOrDefault( IsPlaying )
?? modes.FirstOrDefault();
}
}
/// <summary>
/// The rows for a GIVEN tab.
///
/// ⛔ TAKES THE TAB RATHER THAN READING `Showing`, so `nz_maps_list` and anything else can ask for either list
/// whichever tab is open.
/// </summary>
public static List<Row> RowsFor( Tab tab ) => tab switch
{
// ⛔ WHAT THIS COPY OFFERS (`MapLibrary.Offered`, 2026-10-05): a published copy lists only the maps marked ready
Tab.Primis => MapLibrary.Offered.Where( m => m.Line == "primis" ).Select( MakeRow ).ToList(),
Tab.Immunis => MapLibrary.Offered.Where( m => m.Line == "immunis" ).Select( MakeRow ).ToList(),
Tab.Survival => MapLibrary.Offered.Where( m => !m.IsQuest ).Select( MakeRow ).ToList(),
_ => new List<Row>(),
};
// ⚠️ m.Thumb, not "". Shipped maps once passed an empty string here, so every card drew the lettered placeholder no
// matter what art existed.
static Row MakeRow( MapLibrary.Original m )
=> new( m.MapName, m.Name, m.Description, m.Thumb, m.Background,
NZMap.CurrentRaw.Equals( m.MapName, System.StringComparison.OrdinalIgnoreCase ),
MapConfig.ListFor( NZMap.KeyFor( m.MapName ) ).Count );
// ── console ──────────────────────────────────────────────────────────────────────────────
/// <summary>
/// `nz_maps [primis|immunis|survival|auto|close]` — open the browser. `auto`, the default, opens the tab that holds the map
/// you're on.
///
/// ⚠️ TAKES THE CURSOR, like the Wunderfizz menu and the trade tuner. Without it the rows
/// cannot be clicked, and `Noclip` keys off `Mouse.Visibility` so leaving it hidden would have
/// V toggling noclip under the player while they browse.
/// </summary>
[ConCmd( "nz_maps" )]
public static void OpenCmd( string which = "auto" )
{
var w = (which ?? "").Trim().ToLowerInvariant();
if ( w is "close" or "" )
Close();
else
Open( w switch
{
"primis" or "ultimis" or "p" or "quest" or "main" or "q" => Tab.Primis,
"immunis" or "i" => Tab.Immunis,
"survival" or "s" => Tab.Survival,
_ => Tab.None,
} );
Mouse.Visibility = Showing == Tab.None ? MouseVisibility.Hidden : MouseVisibility.Visible;
Log.Info( $"[nz-maps] browser: {Showing}" );
if ( Showing != Tab.None ) ListCmd();
}
/// <summary>`nz_maps_list` — print the current tab, so a click can be tested over MCP.</summary>
[ConCmd( "nz_maps_list" )]
public static void ListCmd()
{
// ⚠️ THE GAMEMODE STEP'S LIST WHEN THAT IS WHAT SHOWS (2026-10-05)
if ( !ChoosingMode )
{
Dump( Showing );
return;
}
var modes = Modes;
Log.Info( $"[nz-maps] {ChosenRow?.Title ?? Chosen}: {modes.Count} gamemode(s)" );
foreach ( var m in modes )
Log.Info( $"[nz-maps] {(IsPlaying( m ) ? "▶" : " ")} {m.Label,-24} {m.Name}.json" );
}
static void Dump( Tab tab )
{
var rows = RowsFor( tab );
Log.Info( $"[nz-maps] {LabelOf( tab )}: {rows.Count} row(s)" );
foreach ( var r in rows )
Log.Info( $"[nz-maps] {(r.IsCurrent ? "▶" : " ")} {r.Title,-28} {r.MapName,-34} {r.Configs} cfg" );
}
}