Maps/NZMap.cs

Static helper for map management. Determines the current map key, handles loading local or workshop packages (fetch, mount, assign), rebuilds navmesh, places players, and exposes console commands to report or change the map.

NetworkingFile Access
using Sandbox;
using System.Linq;
using System.Threading.Tasks;

namespace NZombies;

/// <summary>
/// WHICH MAP IS LOADED, and how to change it.
///
/// ⛔ THE ONE ANSWER TO "WHAT MAP IS THIS", replacing two independent guesses. `LobbyCommands.MapName`
/// and `ConfigBrowserState.CurrentMap` both hardcoded "countdown", and both carried a comment saying
/// the two MUST agree or "the browser lists one folder and the save/load commands write to another".
/// They now both read <see cref="Current"/>. Because MapConfig is already stored per map
/// (`{ROOT}/{map}/{name}.json`), making this dynamic is the whole of what per-map configs needed.
///
/// ⚠️ NOT `SceneInformation.Title`, which the old comments suggested. That is author-set free text,
/// not an identity — two maps can ship the same title, and their configs would silently merge.
/// </summary>
public static class NZMap
{
	/// <summary>
	/// What <see cref="Current"/> reports when there is no MapInstance to ask.
	///
	/// ⚠️ "countdown" ON PURPOSE, not "" or "unknown". Every config saved before the map browser
	/// existed lives in the `countdown` folder; a fallback that reported anything else would orphan
	/// all of them the first time the scene was opened without a Map object.
	/// </summary>
	public const string Fallback = "countdown";

	/// <summary>The scene's map slot, or null.</summary>
	public static MapInstance Instance
		=> Game.ActiveScene?.GetAllComponents<MapInstance>().FirstOrDefault( m => m.IsValid() );

	/// <summary>
	/// The map key configs are stored under.
	///
	/// ⛔ A LOCAL PATH COLLAPSES TO ITS FILENAME, a package ident does not.
	/// `scenes/maps/countdown.scene` -> `countdown`, so every config saved before the split still
	/// resolves. `facepunch.construct` stays whole, because the org is part of the identity — two
	/// authors can both publish "construct" and they are not the same map.
	/// </summary>
	public static string KeyFor( string mapName )
	{
		if ( string.IsNullOrWhiteSpace( mapName ) ) return Fallback;

		if ( !mapName.Contains( '/' ) && !mapName.Contains( '\\' ) )
			return mapName;                              // package ident

		var file = mapName.Replace( '\\', '/' ).Split( '/' ).Last();
		var dot = file.LastIndexOf( '.' );
		return dot > 0 ? file[..dot] : file;
	}

	/// <summary>The live map key. What the lobby shows and what configs are filed under.</summary>
	public static string Current => KeyFor( Instance?.MapName ) is var k && !string.IsNullOrEmpty( k )
		? k : Fallback;

	/// <summary>The raw value on the MapInstance — a path or an ident. "" when there is none.</summary>
	public static string CurrentRaw => Instance?.MapName ?? "";

	/// <summary>Is this a cloud package rather than a local asset?</summary>
	public static bool IsPackage( string mapName )
		=> !string.IsNullOrWhiteSpace( mapName )
			&& !mapName.Contains( '/' ) && !mapName.Contains( '\\' ) && mapName.Contains( '.' );

	// ── loading ──────────────────────────────────────────────────────────────────────────────

	/// <summary>True while a load is in flight, so the UI can show progress and refuse a second.</summary>
	public static bool Busy { get; private set; }

	/// <summary>What the loader is doing, for the browser to display.</summary>
	public static string Status { get; private set; } = "";

	/// <summary>Total size of the download in flight, in MB. 0 when not downloading.</summary>
	public static float DownloadSize { get; private set; }

	/// <summary>
	/// How long the current download has been running.
	///
	/// ⚠️ `TimeSince` RATHER THAN A STORED TIMESTAMP, so it reads correctly across a hotload — a
	/// multi-minute download will span several while you are still working on the code.
	/// </summary>
	public static TimeSince DownloadStarted { get; private set; }

	/// <summary>
	/// "547 MB · 4m 12s" — what the loading bar shows under the title. Empty when not downloading.
	///
	/// ⛔ THE TOTAL AND THE CLOCK, NEVER A FRACTION. See the comment at the mount call: the bytes
	/// transferred are genuinely unobtainable from game code, so anything of the form "x of y"
	/// would have to invent x.
	/// </summary>
	public static string DownloadDetail
	{
		get
		{
			if ( DownloadSize <= 0f ) return "";

			var s = (int)DownloadStarted.Relative;
			var clock = s < 60 ? $"{s}s" : $"{s / 60}m {s % 60:00}s";
			return $"{Size( DownloadSize )} · {clock}";
		}
	}

	/// <summary>
	/// Load a map — a local asset path or a cloud package ident.
	///
	/// ⛔ FETCH AND MOUNT EXPLICITLY FOR A PACKAGE. Proved in the spike: assigning a cloud ident to
	/// `MapName` with no fetch logged NOTHING AT ALL for 100 seconds and then loaded. Whatever it
	/// does internally, it reports nothing — so there is no way to show progress, no way to know it
	/// worked, and no way to tell a slow download from a wrong ident. Driving it ourselves is what
	/// makes the Workshop tab honest.
	///
	/// ⚠️ THE NAVMESH MUST BE REBUILT. countdown ships BAKED nav data
	/// (`countdown_scene_data/navmesh/baked.navdata`) referenced from the scene, so without this a
	/// freshly loaded map inherits countdown's mesh and zombies path across geometry that is no
	/// longer there. `SetDirty()` regenerates from live geometry — verified: dirty True -> False.
	///
	/// ⚠️ CONFIG LAST, AND THROUGH ActiveConfig. `ActiveConfig.Set` is the documented choke point
	/// that also calls `NZGame.ShowConfig()` ("SWAPPING THE DATA IS NOT PUTTING IT IN THE WORLD"),
	/// so routing through it is what stops the new map keeping the old one's placeables. It has to
	/// run AFTER the geometry exists or every placeable is positioned against the map that left.
	/// </summary>
	public static async Task<bool> Load( string mapName )
	{
		if ( Busy ) { Log.Warning( "[nz-map] already loading" ); return false; }
		if ( string.IsNullOrWhiteSpace( mapName ) ) { Log.Warning( "[nz-map] no map given" ); return false; }

		// ⛔ A PUBLISHED COPY LOADS ONLY THE MAPS READY FOR PLAYERS (`MapLibrary.Offered`, 2026-10-05). Here because every way in
		// passes through here: Map select, `nz_map_load` from the console, the host's start (`Gamemodes.Tick`).
		if ( !MapLibrary.IsOffered( mapName ) )
		{
			Log.Warning( $"[nz-map] '{mapName}' isn't ready for players — this copy offers "
				+ string.Join( ", ", MapLibrary.Offered.Select( o => o.Name ) ) );
			return false;
		}

		var map = Instance;
		if ( map is null )
		{
			Log.Warning( "[nz-map] this scene has no MapInstance — is it the gamemode scene?" );
			return false;
		}

		Busy = true;
		try
		{
			if ( IsPackage( mapName ) )
			{
				Status = $"Finding {mapName}…";
				Log.Info( $"[nz-map] fetching package '{mapName}'…" );

				var pkg = await Package.FetchAsync( mapName, false );
				if ( pkg is null )
				{
					Log.Warning( $"[nz-map] no package '{mapName}'" );
					return false;
				}

				// ⛔ THE SIZE IS THE CLOSEST THING TO A PERCENTAGE THAT EXISTS. s&box reports no
				// bytes-transferred for a package mount — established by elimination, since
				// reflection is sandboxed (SB1000) and the compiler rejected DownloadProgress,
				// Progress, IsDownloading, BytesDownloaded, TotalBytes, Downloaded, LocalSize,
				// IsInstalled, Installed, Package.ActiveDownloads and Package.DownloadProgress.
				// Only `FileSize` and `IsMounted()` exist.
				//
				// ⚠️ SO SHOW THE TOTAL INSTEAD OF FAKING A FRACTION. "Downloading Rooftops
				// Classic… 340 MB" answers the question a percentage is really asked for — how
				// long am I waiting — without inventing a number. Rooftops took over 7 minutes;
				// knowing it is 340 MB explains that, where "62%" would merely have been wrong.
				// ⛔ SIZE AND ELAPSED, NOT "x OF y". s&box reports no bytes-transferred anywhere
				// reachable. Established by elimination, not assumption: eleven candidate members on
				// `Package` are rejected by the compiler, reflection is SB1000-sandboxed, and while
				// the bytes DO exist on disk — measured, 449.7 MB landing in the engine's
				// `download/` folder during one mount — game code cannot read it:
				// `FileSystem.Root`, `.Engine` and `.Downloads` do not exist, and the handles that
				// do (`Data`, `Mounted`, `Cache`, `OrganizationData`) are all scoped to the project.
				//
				// ⚠️ SO THE ELAPSED CLOCK IS THE HONEST SUBSTITUTE. It is really measured, it proves
				// the download has not stalled, and with the total beside it you can judge the wait.
				// A fabricated "180 / 547 MB" from elapsed × an assumed rate would look like a
				// measurement and be wrong, which is worse than not offering one.
				DownloadSize = pkg.FileSize;
				DownloadStarted = 0f;
				Status = $"Downloading {pkg.Title}…";
				Log.Info( $"[nz-map] mounting '{pkg.Title}' ({Size( pkg.FileSize )})…" );

				await pkg.MountAsync();

				DownloadSize = 0f;
				Log.Info( $"[nz-map] '{pkg.Title}' mounted" );

				// The content is local now, so it belongs in the Downloaded tab.
				MapLibrary.MarkDownloaded( mapName );

				// ⛔ RE-ACQUIRE AFTER THE MOUNT. A download can run for MINUTES — Rooftops Classic
				// took sixteen — and the `map` captured before it may not survive that: a code
				// hotload or a play-mode restart in the meantime destroys the component, and the
				// next line then dereferences it. That is exactly what happened, as
				// "load failed (NullReferenceException)" after a long mount.
				map = Instance;
				if ( map is null )
				{
					Log.Warning( "[nz-map] the scene's MapInstance went away while downloading — "
						+ "load abandoned" );
					return false;
				}
			}

			Status = "Loading…";
			Log.Info( $"[nz-map] loading '{mapName}'…" );

			await Assign( map, mapName );

			RebuildNav();

			// ⚠️ RESET, NOT KEEP. The outgoing map's spawns and placeables are meaningless here —
			// see ActiveConfig.Reset, which routes through Set and therefore rebuilds the world.
			ActiveConfig.Reset();

			PlacePlayers();

			Log.Info( $"[nz-map] now on '{Current}' — {ActiveConfig.PlayableProblem switch
			{
				"" => "playable",
				var p => p + " (build one in Creative, or load a config)"
			}}" );

			return true;
		}
		catch ( System.Exception e )
		{
			Log.Warning( $"[nz-map] load failed ({e.GetType().Name}): {e.Message}" );
			return false;
		}
		finally
		{
			Busy = false;
			Status = "";
		}
	}

	/// <summary>
	/// `Package.FileSize` as something a person reads.
	///
	/// ⛔ THE INPUT IS MEGABYTES, NOT BYTES. `FileSize` is a float and its unit is documented
	/// nowhere reachable — reflection is sandboxed and the assemblies would not yield it — so it
	/// was pinned by measurement: `thieves.dolls` reports 546.8, and that map takes minutes to
	/// pull. 547 bytes is absurd and 547 KB downloads instantly; only 547 MB fits the observed
	/// time. Treating it as bytes printed "1 KB" for a half-gigabyte map.
	/// </summary>
	static string Size( float mb )
	{
		if ( mb <= 0 ) return "";
		if ( mb < 1f ) return $"{mb * 1024f:0} KB";
		if ( mb < 1024f ) return $"{mb:0} MB";
		return $"{mb / 1024f:0.0} GB";
	}

	/// <summary>How long to wait for a map to report itself loaded before carrying on regardless.</summary>
	const int LoadTimeoutMs = 30_000;

	/// <summary>
	/// Set MapName and WAIT for the map to actually be in the scene.
	///
	/// ⛔ THE ASSIGNMENT IS NOT SYNCHRONOUS, AND EVERYTHING AFTER IT DEPENDS ON THE NEW GEOMETRY.
	/// Without this wait, `RebuildNav` regenerates over the OUTGOING map and `PlacePlayers` reads
	/// the OUTGOING map's spawn points. Measured: two loads in a row both reported placing the
	/// player at 320,288,382 — the same coordinate for two different maps, because each was using
	/// the one before it. It happened to land somewhere valid, which is exactly why it would have
	/// survived unnoticed.
	///
	/// ⚠️ SUBSCRIBE BEFORE ASSIGNING. An already-mounted package loads synchronously and the event
	/// fires during the assignment itself; hooking afterwards misses it and waits out the timeout.
	///
	/// ⚠️ TIMEOUT RATHER THAN WAIT FOREVER. A map whose world fails to build may never raise the
	/// event — the dev vmap `maps/dev/preview_flat.vmap` logs "Failed to load .../world.scene_c"
	/// and fires OnMapLoaded anyway, so the event is not even a reliable success signal. Carrying
	/// on after 30s leaves a broken map visible and reported; hanging leaves the game frozen with
	/// no explanation.
	/// </summary>
	/// ⚠️ POLLED, NOT `Task.WhenAny`. That is not on s&box's whitelist and fails to COMPILE with
	/// SB1000 — the sandbox restricts the API surface game code may touch, which is the same reason
	/// `Package.FindAsync` had to be proved callable before any of this was written.
	///
	/// ⛔ THE CHILDREN SWAPPING IS THE REAL SIGNAL; `OnMapLoaded` IS ONLY A FAST PATH. The event
	/// cannot be trusted alone, in BOTH directions — measured:
	///   • It never fired at all for `scenes/maps/countdown.scene`, so waiting on it alone burned
	///     the full timeout on the gamemode's own default map.
	///   • It DID fire for `maps/dev/preview_flat.vmap`, whose world logged
	///     "Failed to load .../world.scene_c" and produced nothing — a success signal for a map
	///     that did not arrive.
	/// What actually matters is that the MapInstance now holds different objects, so that is what
	/// is watched, with the event allowed to end the wait early when it does work.
	static async Task Assign( MapInstance map, string mapName )
	{
		var loaded = false;
		void OnLoaded() => loaded = true;

		// ⚠️ SNAPSHOT BEFORE ASSIGNING. "Has it changed" needs the old value, and the assignment is
		// what changes it.
		var before = map.GameObject.Children.FirstOrDefault()?.Id;
		var hadAny = map.GameObject.Children.Count > 0;

		map.OnMapLoaded += OnLoaded;
		try
		{
			map.MapName = mapName;

			const int step = 50;
			for ( var waited = 0; waited < LoadTimeoutMs; waited += step )
			{
				if ( loaded ) return;

				var kids = map.GameObject.Children;
				// Swapped when there is something there AND it is not what was there before.
				// `hadAny` covers the first load of the session, where `before` is null anyway.
				if ( kids.Count > 0 && (!hadAny || kids[0]?.Id != before) ) return;

				await Task.Delay( step );
			}

			Log.Warning( $"[nz-map] '{mapName}' never appeared in the scene within "
				+ $"{LoadTimeoutMs / 1000}s — continuing anyway" );
		}
		finally
		{
			map.OnMapLoaded -= OnLoaded;
		}
	}

	/// <summary>
	/// Put players somewhere sane on the map that just loaded.
	///
	/// ⛔ WITHOUT THIS YOU FALL TO YOUR DEATH. `PlayerSpawner.PlaceAll` no-ops when the config has
	/// no player spawns — its own log says "leaving players where they are" — and after a map
	/// change "where they are" is the PREVIOUS map's coordinates. Measured: loading Flatgrass from
	/// countdown left the player at z 1181 over a plane at ground level and cost 134 of 150 health
	/// on landing. A brand new map has no config, so this is the NORMAL path, not an edge case.
	///
	/// ⚠️ THE CONFIG STILL WINS. A map that has been set up should start you where its author said,
	/// so the fallback only runs when PlaceAll placed nobody.
	///
	/// ⛔ AND IT DOES NOT WRITE THE SPAWNS INTO THE CONFIG. Seeding them would make the map report
	/// as set up when nobody has set it up, and worse, `nz_save` would then bake ~150 of Flatgrass's
	/// `info_player_start` positions into the player's config as if they had placed them. Standing
	/// somewhere sensible and being told "Not set up" is the honest combination.
	/// </summary>
	static void PlacePlayers()
	{
		if ( PlayerSpawner.PlaceAll() > 0 ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		// ⚠️ `Sandbox.SpawnPoint`, QUALIFIED. `NZombies.SpawnPoint` is the config's own record type
		// and this file has both namespaces in scope — the same collision that forced MapLoader to
		// be renamed NZMap. Bare `SpawnPoint` here compiles to the wrong thing or not at all.
		var point = scene.GetAllComponents<Sandbox.SpawnPoint>()
			.FirstOrDefault( s => s.IsValid() );

		if ( point is null )
		{
			Log.Warning( "[nz-map] this map ships no spawn points — you may be standing "
				+ "wherever the last map left you. Place player spawns in Creative." );
			return;
		}

		// ⛔ EVERY BODY, DISABLED ONES INCLUDED. `GetAllComponents<NZPlayer>` DOES NOT SEE A
		// DISABLED PLAYER, and the lobby disables the body — so on its own this placed 0 players
		// every time, since a map is almost always changed FROM the lobby. It used to union in the
		// local player to paper over that, which fixed single player and left every REMOTE body
		// out; `PlayerSpawner.AllBodies` is now the one lookup that gets this right, and
		// `PlayerStats.All` carries the same note. This is the third place it has bitten.
		var players = PlayerSpawner.AllBodies( scene );

		var moved = 0;
		foreach ( var body in players )
		{
			// ⚠️ LIFTED CLEAR OF THE FLOOR. Dropping a capsule exactly on a spawn's origin can
			// start it interpenetrating the ground, and the controller resolves that by shoving the
			// player through it.
			//
			// ⚠️ AND ROUTED THROUGH `MoveTo`, so a body somebody else owns is asked to move
			// rather than written to — see PlayerSpawner. This map has no placed spawns, so
			// everyone genuinely does share one point here; that is the fallback working, not the
			// stacking bug.
			PlayerSpawner.MoveTo( body, point.WorldPosition + Vector3.Up * 16f, point.WorldRotation );
			moved++;
		}

		Log.Info( $"[nz-map] no config spawns — placed {moved} player(s) on the map's own "
			+ $"spawn point at {point.WorldPosition}" );
	}

	/// <summary>Regenerate the navmesh over whatever geometry is now present.</summary>
	/// <summary>
	/// Regenerate the nav mesh for the map that just loaded.
	///
	/// ⛔ ROUTED THROUGH NavBake SO THE MESH KNOWS WHICH MAP IT IS FOR. This used to be a bare
	/// SetDirty(), which was correct as far as it went and hid a real bug: `nzombies.scene` carried
	/// countdown's baked mesh in `BakedDataPath`, applied at SCENE load, so starting the game
	/// directly on any other map pathed zombies against countdown's geometry. A switch corrected
	/// itself here; a cold start never did, and nothing reported the difference. The path is gone
	/// and NavBake now records which map the live mesh belongs to.
	/// </summary>
	static void RebuildNav() => NavBake.Apply( Current );

	// ── console ──────────────────────────────────────────────────────────────────────────────

	/// <summary>`nz_map` — what map is loaded, and what configs exist for it.</summary>
	[ConCmd( "nz_map" )]
	public static void Report()
	{
		var raw = CurrentRaw;
		Log.Info( $"[nz-map] current: '{Current}'"
			+ (string.IsNullOrEmpty( raw ) ? "   (no MapInstance in this scene)" : $"   from '{raw}'")
			+ (IsPackage( raw ) ? "   [workshop]" : "") );

		var configs = MapConfig.ListFor( Current );
		Log.Info( $"[nz-map]   {configs.Count} config(s): "
			+ (configs.Count == 0 ? "(none — build one in Creative)" : string.Join( ", ", configs )) );
	}

	/// <summary>`nz_map_load &lt;path-or-ident&gt;` — the browser's row click, as a command.</summary>
	[ConCmd( "nz_map_load" )]
	public static void LoadCmd( string mapName = "", bool force = false )
	{
		if ( string.IsNullOrWhiteSpace( mapName ) )
		{
			Log.Info( "[nz-map] nz_map_load <scenes/maps/countdown.scene | facepunch.construct>" );
			return;
		}

		// ⛔ THE SAME PATH THE BROWSER TAKES, NOT A SHORTCUT PAST IT. This called `Load`
		// directly, so a map changed from the console reached nobody — exactly the trap
		// `MULTIPLAYER.md` §4 names: hiding a button leaves its command ungated and unnetworked,
		// and the command is what gets used for testing.
		// ⚠️ `force` EXISTS TO ANSWER ONE QUESTION: can a CONNECTED client load a map at all?
		// Everything else has been eliminated — full teardown, fresh assignment, fifteen seconds
		// of polling, and the world never changed. The one property startup has that a joined
		// client does not is that startup is not networked yet. `nz_map_load <map> 1` on a
		// client's own console tests exactly that and nothing else.
		if ( NZGame.IsClient && !force )
		{
			Log.Info( "[nz-map] only the host can change the map — nz_map_load <map> 1 forces it" );
			return;
		}

		if ( force && NZGame.IsClient )
		{
			Log.Warning( $"[nz-map] FORCED local load on a client — nothing is told about this" );
			_ = Load( mapName );
			return;
		}

		_ = HostLoad( mapName );
	}

	/// <summary>Change the map for everyone: tell the clients, load here, then push the config.</summary>
	static async Task HostLoad( string mapName )
	{
		// ⛔ THE HOST LOADS FIRST AND THE CLIENTS REJOIN AFTERWARDS. A client cannot be told
		// to change map — it can only be handed one by connecting — so the order is not a
		// preference, it is the mechanism: the snapshot they come back to has to already be the
		// new map.
		if ( !await Load( mapName ) ) return;

		// ⚠️ AFTER the load, because `Load` ends by RESETTING the config — this is the first
		// moment `ActiveConfig.Current` describes the new map instead of the old one.
		NZNet.SendConfig();

		NZNet.ChangeMapForEveryone( mapName );
	}

}