UI/MapBrowserState.cs

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.

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