Static MapLibrary for the gamemode. Reads shipped maps from maps/manifest.json, exposes helpers to get per-map backgrounds/loading images, tracks shipped maps offered in this build, and stores user state lists for favourite workshop maps and downloaded workshop maps with read/write to FileSystem.Data. Also provides console commands to list and toggle these lists.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace NZombies;
/// <summary>
/// The maps we SHIP (`maps/manifest.json`), which are everything the lobby's map browser lists; and two records only the
/// console reads now, starred workshop maps (`nz_map_favs`) and workshop maps already on this machine (`nz_map_downloaded`).
///
/// ⛔ THE BROWSER SHOWS SHIPPED MAPS ONLY, SINCE 2026-10-05: a tab per Easter egg line (the manifest's `"category"`:
/// "primis" for Primis, "immunis" for the new cast; the manifest is the list) and Survival (the rest). Its Workshop, Downloaded and Favorites tabs were the developer's, and went (the user: *"I want to
/// point it towards players"*). `NZMap.Load` still takes a package ident.
/// </summary>
public static class MapLibrary
{
// ── originals ────────────────────────────────────────────────────────────────────────────
/// <summary>A map shipped with the gamemode, from `Assets/maps/manifest.json`.</summary>
/// <param name="Thumb">
/// Image path for the browser card, or "" for the lettered placeholder.
///
/// ⚠️ A PATH FROM THE MANIFEST, NOT A CONVENTION. Deriving it from the map name would be shorter
/// and would silently produce a broken url for every map without one — the card would then be a
/// dead image rather than the placeholder it is supposed to fall back to. An absent key means
/// absent, which is a state the browser already renders correctly.
/// </param>
/// <param name="Background">
/// Full-bleed lobby background for this map, or "" to keep the lobby's own default.
/// </param>
/// <param name="Loading">
/// The loading screen's own image for this map (the manifest's `loading`), or "" to show its lobby background there too
/// (`MapLoading.Background`).
///
/// ⚠️ LAST AND OPTIONAL, so a record made before it existed still reads — as "none", which is the fallback anyway.
/// </param>
/// <param name="Category">
/// The browser tab the map sits in: its Easter egg line, "primis" (Primis, the Black Ops 3 crew) or "immunis" (the new cast).
/// Anything else, absent included, is Survival. "quest" marks a quest planned whose line is not chosen yet (Power), and
/// lists under Survival until it has one. Last and optional, like Loading. (2026-10-05; it was "quest" for one Main quest
/// tab.)
/// </param>
/// <param name="Ready">
/// ⛔ OFFERED TO PLAYERS: the manifest's `"ready": true`. The user, 2026-10-05: *"make sure only the content that is ready is
/// published … only the Ignis aeternus map, and laketown are available to the published version"*. A published copy lists,
/// loads and starts on ready maps alone (<see cref="Offered"/>); the editor offers every map, ready or not.
/// </param>
public record Original( string MapName, string Name, string Author, string Description,
string Thumb, string Background, string Loading = "", string Category = "", bool Ready = false )
{
/// <summary>The Easter egg line this map belongs to, "primis" or "immunis", or "" for one listed under Survival.</summary>
public string Line
{
get
{
var c = (Category ?? "").Trim().ToLowerInvariant();
return c is "primis" or "immunis" ? c : "";
}
}
/// <summary>On one of the story lines rather than Survival.</summary>
public bool IsQuest => Line != "";
}
/// <summary>
/// The lobby background for a map, or "" if it has none.
///
/// ⛔ MATCHED THROUGH NZMap.KeyFor, NOT ON THE RAW KEY. The manifest is keyed by what
/// MapInstance.MapName takes — "scenes/maps/ttt_canyon_labs_d.scene" — while everything at
/// runtime holds the SANITISED name, "ttt_canyon_labs_d". Comparing those directly never matches
/// and the background would silently never appear, which is indistinguishable from the image
/// being wrong.
///
/// ⚠️ Takes the sanitised name, because that is what NZMap.Current gives every caller.
/// </summary>
public static string BackgroundFor( string mapKey )
{
if ( string.IsNullOrWhiteSpace( mapKey ) ) return "";
foreach ( var m in Originals )
if ( NZMap.KeyFor( m.MapName ) == mapKey )
return m.Background ?? "";
return "";
}
/// <summary>
/// The loading screen's own image for a map, or "" if it has none — the manifest's `loading`, matched exactly as
/// `BackgroundFor` matches (through NZMap.KeyFor, on the sanitised name).
/// </summary>
public static string LoadingFor( string mapKey )
{
if ( string.IsNullOrWhiteSpace( mapKey ) ) return "";
foreach ( var m in Originals )
if ( NZMap.KeyFor( m.MapName ) == mapKey )
return m.Loading ?? "";
return "";
}
static List<Original> _originals;
/// <summary>
/// Maps that ship with the gamemode.
///
/// ⚠️ READ FROM A MANIFEST, mirroring `weapons/manifest.json`, so adding a shipped map is a data
/// change. `FileSystem.Mounted` is the same reader `WeaponLibrary` uses — the assets folder is
/// read-only to the running game, which is exactly right for something we ship.
///
/// ⚠️ KEYS PREFIXED WITH '_' ARE PROSE. The manifest carries `_comment` / `_why` / `_gotcha`
/// notes the way the weapon and bodygroup manifests do; skipping them here is what lets those
/// stay in the file instead of living only in someone's memory.
/// </summary>
public static List<Original> Originals
{
get
{
if ( _originals is not null ) return _originals;
_originals = new();
try
{
var json = FileSystem.Mounted.ReadAllText( "maps/manifest.json" );
using var doc = JsonDocument.Parse( json );
foreach ( var e in doc.RootElement.EnumerateObject() )
{
if ( e.Name.StartsWith( '_' ) ) continue;
_originals.Add( new Original(
e.Name,
Str( e.Value, "name", e.Name ),
Str( e.Value, "author", "" ),
Str( e.Value, "description", "" ),
Str( e.Value, "thumb", "" ),
Str( e.Value, "background", "" ),
Str( e.Value, "loading", "" ),
Str( e.Value, "category", "" ),
Bool( e.Value, "ready" ) ) );
}
}
catch ( System.Exception ex )
{
// ⚠️ An unreadable manifest must not take the lobby down: both browser tabs come up empty instead.
// ⚠️ IT NAMES THE LIKELY CAUSE. In a PUBLISHED build this almost always means
// the file was not bundled — loose .json only ships if `Resources` in the .sbproj
// lists it — and the message on its own reads as a corrupt manifest, which sends
// the reader to the file instead of to the package. `nz_publish_check` settles it.
Log.Warning( $"[nz-map] maps/manifest.json unreadable: {ex.Message}"
+ " — in a published build this usually means it was not packaged;"
+ " run nz_publish_check" );
}
return _originals;
}
}
static string Str( JsonElement o, string key, string fallback )
=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? fallback : fallback;
/// <summary>A manifest flag: true only when the key is there and says `true`.</summary>
static bool Bool( JsonElement o, string key )
=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.True;
/// <summary>
/// The maps this copy offers: every one in the editor, only the READY ones in a published copy (`Edition`, the manifest's
/// `"ready": true`). Map select lists these, `NZMap.Load` loads only these, and the published host starts on the first.
/// </summary>
public static IEnumerable<Original> Offered => Edition.IsPublished ? Originals.Where( o => o.Ready ) : Originals;
/// <summary>Is this map offered here? Matched by key (`NZMap.KeyFor`), as the rest of this file matches.</summary>
public static bool IsOffered( string mapName )
{
if ( !Edition.IsPublished ) return true;
var key = NZMap.KeyFor( mapName ?? "" );
return Originals.Any( o => o.Ready && NZMap.KeyFor( o.MapName ) == key );
}
/// <summary>Drop the cached manifest so an edit shows without restarting.</summary>
public static void Reload() => _originals = null;
// ── favourites ───────────────────────────────────────────────────────────────────────────
const string FILE = "map_favourites.json";
static List<string> _favourites;
/// <summary>
/// Starred workshop maps, as package idents.
///
/// ⛔ FileSystem.Data, NOT the assets folder. Assets are read-only to the running game — this is
/// player state, and it lives beside `weapon_placement.json` and the map configs for exactly
/// that reason.
///
/// ⚠️ IDENTS ONLY, no titles or thumbnails cached alongside. A cached title goes stale the
/// moment the author renames the map, and the browser is re-fetching package metadata anyway to
/// draw the card. Store the key, look up the rest.
/// </summary>
public static List<string> Favourites
{
get
{
if ( _favourites is not null ) return _favourites;
_favourites = new();
if ( !FileSystem.Data.FileExists( FILE ) ) return _favourites;
try
{
_favourites = JsonSerializer.Deserialize<List<string>>(
FileSystem.Data.ReadAllText( FILE ) ) ?? new();
}
catch ( System.Exception e )
{
Log.Warning( $"[nz-map] {FILE} unreadable: {e.Message}" );
}
return _favourites;
}
}
public static bool IsFavourite( string ident )
=> !string.IsNullOrWhiteSpace( ident )
&& Favourites.Any( f => f.Equals( ident, System.StringComparison.OrdinalIgnoreCase ) );
/// <summary>Star or unstar a map. Returns the new state.</summary>
public static bool ToggleFavourite( string ident )
{
if ( string.IsNullOrWhiteSpace( ident ) ) return false;
var on = !IsFavourite( ident );
if ( on ) Favourites.Add( ident );
else Favourites.RemoveAll( f => f.Equals( ident, System.StringComparison.OrdinalIgnoreCase ) );
Save();
return on;
}
/// <summary>
/// ⚠️ WRITTEN IMMEDIATELY, not on exit. A favourites list that only persists on a clean
/// shutdown is a favourites list that loses everything the one time the editor crashes — and
/// starring a map is a single click the player will not think to repeat.
/// </summary>
static void Save()
{
try
{
FileSystem.Data.WriteAllText( FILE, JsonSerializer.Serialize( Favourites ) );
}
catch ( System.Exception e )
{
Log.Warning( $"[nz-map] could not write {FILE}: {e.Message}" );
}
}
// ── downloaded ───────────────────────────────────────────────────────────────────────────
const string DOWNLOADED_FILE = "map_downloaded.json";
static List<string> _downloaded;
/// <summary>
/// Workshop maps whose content is already on this machine, as package idents.
///
/// ⛔ TRACKED BY US, BECAUSE NOTHING CAN BE ASKED. s&box has no way to enumerate downloaded
/// packages from game code: `Package` exposes only `FileSize` and `IsMounted()`, reflection is
/// sandboxed, and the `MountInfo`/`Directory` API in the editor is for GAME mounts (Half-Life 2,
/// CS:S) rather than workshop content. So the list is written as maps are mounted.
///
/// ⚠️ THAT MAKES IT A RECORD OF WHAT *THIS GAMEMODE* PULLED, not of everything s&box has ever
/// cached. A map downloaded by another gamemode will not appear until it is loaded here once.
/// Honest and useful beats complete-but-unobtainable — and every entry is guaranteed to be a
/// map that works here, which a raw cache listing could not promise.
/// </summary>
public static List<string> Downloaded
{
get
{
if ( _downloaded is not null ) return _downloaded;
_downloaded = new();
if ( !FileSystem.Data.FileExists( DOWNLOADED_FILE ) ) return _downloaded;
try
{
_downloaded = JsonSerializer.Deserialize<List<string>>(
FileSystem.Data.ReadAllText( DOWNLOADED_FILE ) ) ?? new();
}
catch ( System.Exception e )
{
Log.Warning( $"[nz-map] {DOWNLOADED_FILE} unreadable: {e.Message}" );
}
return _downloaded;
}
}
public static bool IsDownloaded( string ident )
=> !string.IsNullOrWhiteSpace( ident )
&& Downloaded.Any( d => d.Equals( ident, System.StringComparison.OrdinalIgnoreCase ) );
/// <summary>
/// Record that a package's content is now local. Called by NZMap after a successful mount.
///
/// ⚠️ IDEMPOTENT. Loading the same map ten times must not put ten rows in the tab.
/// </summary>
public static void MarkDownloaded( string ident )
{
if ( string.IsNullOrWhiteSpace( ident ) || IsDownloaded( ident ) ) return;
Downloaded.Add( ident );
try
{
FileSystem.Data.WriteAllText( DOWNLOADED_FILE, JsonSerializer.Serialize( Downloaded ) );
}
catch ( System.Exception e )
{
Log.Warning( $"[nz-map] could not write {DOWNLOADED_FILE}: {e.Message}" );
}
}
// ── console ──────────────────────────────────────────────────────────────────────────────
/// <summary>`nz_map_originals` — the shipped maps.</summary>
[ConCmd( "nz_map_originals" )]
public static void ListOriginals()
{
Reload();
Log.Info( $"[nz-map] {Originals.Count} shipped map(s):" );
foreach ( var m in Originals )
Log.Info( $"[nz-map] {m.Name,-16} {m.MapName}"
+ $" {MapConfig.ListFor( NZMap.KeyFor( m.MapName ) ).Count} config(s)" );
}
/// <summary>`nz_map_downloaded` — workshop maps already on this machine.</summary>
[ConCmd( "nz_map_downloaded" )]
public static void ListDownloaded()
{
if ( Downloaded.Count == 0 )
{
Log.Info( "[nz-map] nothing downloaded yet — pick a workshop map to pull one" );
return;
}
Log.Info( $"[nz-map] {Downloaded.Count} downloaded:" );
foreach ( var d in Downloaded )
Log.Info( $"[nz-map] {d} {MapConfig.ListFor( NZMap.KeyFor( d ) ).Count} config(s)"
+ (IsFavourite( d ) ? " ★" : "") );
}
/// <summary>`nz_map_favs` — the starred workshop maps.</summary>
[ConCmd( "nz_map_favs" )]
public static void ListFavourites()
{
if ( Favourites.Count == 0 )
{
Log.Info( "[nz-map] no favourites — nz_map_fav <ident> to star one" );
return;
}
Log.Info( $"[nz-map] {Favourites.Count} favourite(s):" );
foreach ( var f in Favourites )
Log.Info( $"[nz-map] {f} {MapConfig.ListFor( NZMap.KeyFor( f ) ).Count} config(s)" );
}
/// <summary>`nz_map_fav <ident>` — star or unstar. No argument stars the CURRENT map.</summary>
[ConCmd( "nz_map_fav" )]
public static void FavCmd( string ident = "" )
{
// ⚠️ Defaults to the live map so "I like this one" is one word, which is how it will
// actually be used from the console.
if ( string.IsNullOrWhiteSpace( ident ) ) ident = NZMap.CurrentRaw;
if ( string.IsNullOrWhiteSpace( ident ) )
{
Log.Warning( "[nz-map] nz_map_fav <ident>" );
return;
}
Log.Info( $"[nz-map] {ident} {(ToggleFavourite( ident ) ? "starred" : "unstarred")}"
+ $" ({Favourites.Count} total)" );
}
}