UI/LobbyCommands.cs

Console command handlers for the lobby UI. Exposes commands to preview the stage, ready/unready, enter Creative/Spectator, show lobby/characters, inspect UI/cursor/player presence, and save/load/delete/list map configs; it also ensures a spectator camera exists.

File AccessNetworking
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// LOBBY/COMMANDS — a console equivalent for every lobby button.
///
/// ⚠️ STANDING RULE: every button gets a command. Nobody can click a UI button
/// over MCP, so a button-only feature cannot be tested, demonstrated or
/// debugged remotely — it can only be looked at. Pairing each one with a
/// command means the same code path is reachable both ways, and a passing
/// command test is evidence about the button too.
/// </summary>
public static class LobbyCommands
{
	/// <summary>
	/// `nz_lobby_preview [0|1]` — draw the character stage, or do not.
	/// </summary>
	///
	/// ⚠️ THE PANEL READS THE FLAG EVERY FRAME rather than being told, so this works whether or
	/// not the lobby is open at the moment it is typed — and there is no state to get out of step
	/// with, which is the same argument `LobbyMenu.OnUpdate` makes about the music.
	[ConCmd( "nz_lobby_preview" )]
	public static void PreviewStage( int on = -1 )
	{
		if ( on >= 0 ) LobbyState.PreviewStage = on != 0;

		Log.Info( $"[nz] lobby character stage {(LobbyState.PreviewStage ? "on" : "OFF")}"
			+ " — it is a second render of the whole scene world, so measure with it both ways" );
	}

	/// <summary>The Ready up / Unready button.</summary>
	[ConCmd( "nz_ready" )]
	public static void Ready( bool ready = true )
	{
		if ( !LobbyState.Available ) { Log.Warning( "[nz] no lobby" ); return; }

		// ⛔ THE REFUSAL LIVES HERE, not in the panel. The button greys itself out,
		// but `nz_ready` reaches this directly and a future map vote or auto-start
		// would too — a rule enforced only in the UI is enforced only on the one
		// path the UI can see.
		//
		// ⚠️ Only blocks READYING. Un-readying must always work: a player who
		// readied before the config was unloaded would otherwise be stuck ready
		// with no way back.
		if ( ready && !ActiveConfig.IsPlayable )
		{
			// ⚠️ IN A PLAYER'S WORDS IN A PUBLISHED COPY, which has neither Creative nor Load config (`Edition`)
			Log.Warning( Edition.IsPublished
				? $"[nz] cannot ready up — {Gamemodes.ReadyProblem}. nz_gamemode lists them."
				: $"[nz] cannot ready up — {ActiveConfig.PlayableProblem}. "
					+ "Place them in Creative, or load a config." );
			return;
		}

		LobbyState.SetReady( ready );
		Log.Info( $"[nz] ready -> {ready}" );
	}

	/// <summary>The Creative button — enters the world and hides the lobby.</summary>
	[ConCmd( "nz_creative" )]
	public static void Creative()
	{
		// ⛔ A CLIENT IS NEVER IN CREATIVE. §3: `NZGame.Mode` on a client is only ever Lobby,
		// Spectator or Survival — and SPECTATE IS THE CLIENT'S READ-ONLY CREATIVE VIEW. They can
		// watch the host author; they cannot author. Nothing separate had to be built for that.
		if ( NZGame.IsClient )
		{
			Log.Info( "[nz] only the host can build — use Spectate to watch" );
			return;
		}

		// ⛔ AND ONLY IN THE EDITOR (`Edition`, 2026-10-05). Refused HERE, BEFORE THE LOBBY CLOSES. `SetMode` refuses too, and a
		// lobby shut after its refusal would leave the player looking at the world in Lobby mode, where L does not bring it back.
		if ( !Edition.CanBuild )
		{
			Log.Info( "[nz] no Creative in the published game — maps are built in the editor" );
			return;
		}

		NZGame.SetMode( GameMode.Creative );

		LobbyState.SetOpen?.Invoke( false );
		Log.Info( "[nz] creative — Q for tools, V to noclip, L for lobby" );
	}

	/// <summary>The Spectate button — watch with no body in the map.</summary>
	[ConCmd( "nz_spectate" )]
	public static void Spectate()
	{
		NZGame.SetMode( GameMode.Spectator );
		EnsureSpectatorCamera();

		LobbyState.SetOpen?.Invoke( false );
		Log.Info( "[nz] spectating — WASD to fly, Space/Ctrl for up/down, "
			+ "Shift to hurry, L for the lobby" );
	}

	/// <summary>
	/// Created on demand, like DebrisManager — nothing in the scene file has to
	/// be wired for spectating to work, and NotSaved keeps it out of the map.
	/// </summary>
	static void EnsureSpectatorCamera()
	{
		if ( SpectatorCamera.Instance.IsValid() ) return;

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

		var go = scene.CreateObject();
		go.Name = "Spectator Camera";
		go.Flags |= GameObjectFlags.NotSaved;
		go.Components.Create<SpectatorCamera>();
	}

	/// <summary>Show the lobby again, as L does.</summary>
	[ConCmd( "nz_lobby" )]
	public static void ShowLobby()
	{
		if ( !LobbyState.Available ) { Log.Warning( "[nz] no lobby" ); return; }

		LobbyState.SetOpen( true );
		Log.Info( "[nz] lobby open" );
	}

	/// <summary>
	/// Open the lobby's Character list and print what it holds: nz_characters [0].
	///
	/// ⚠️ THE LIST IS THE ONLY PLACE THE ROSTER IS VISIBLE, and expand/collapse is panel state
	/// that nothing outside the game can reach — so "are the new characters in the lobby?" was
	/// a question that could only be answered by a human clicking. Printing the same `Sets` the
	/// panel loops over answers it without one, and opening the row means the next screenshot
	/// shows the list rather than the collapsed header.
	/// </summary>
	[ConCmd( "nz_characters" )]
	public static void ShowCharacters( bool open = true )
	{
		foreach ( var set in PlayerCharacters.Sets )
		{
			Log.Info( $"[nz-char] {set.Name}" );

			foreach ( var c in set.Members )
				Log.Info( $"[nz-char]    {c.Id,-14} {c.Name}" );
		}

		Log.Info( $"[nz-char] {PlayerCharacters.Everyone.Length} characters in "
			+ $"{PlayerCharacters.Sets.Length} sets" );

		if ( !LobbyState.Available ) { Log.Warning( "[nz] no lobby to open" ); return; }

		LobbyState.SetOpen( true );
		LobbyState.SetCharacterListOpen?.Invoke( open );
		Log.Info( $"[nz] lobby open, character list {(open ? "expanded" : "collapsed")}" );
	}

	/// <summary>
	/// Why the UI is or is not clickable: nz_ui.
	///
	/// ⚠️ `Mouse.Visible` is DEPRECATED in favour of `Mouse.Visibility`, and the
	/// engine docs are explicit that the old bool only ever meant "drawn" —
	/// `MouseVisibility.Hidden` is documented as "locked to the game and CANNOT
	/// interact with UI elements". So a cursor can be on screen and still be
	/// unable to click, which is exactly the state this command exists to spot.
	/// </summary>
	[ConCmd( "nz_ui" )]
	public static void UiState()
	{
		var scene = Game.ActiveScene;

		Log.Info( $"[nz-ui] mode {NZGame.Mode}" );
		Log.Info( $"[nz-ui] lobby open: {LobbyState.IsOpen?.Invoke()}" );
		Log.Info( $"[nz-ui] Mouse.Visibility: {Mouse.Visibility}   Active: {Mouse.Active}" );
		Log.Info( $"[nz-ui] Mouse.Position: {Mouse.Position}   Delta: {Mouse.Delta}" );

		// ⚠️ IF THIS IS 0, THE PANEL'S UPDATE LOOP IS NOT RUNNING and nothing has
		// ever set Mouse.Visibility — while the lobby still draws perfectly,
		// because rendering comes from the panel tree, not from OnUpdate.
		Log.Info( $"[nz-ui] LobbyMenu.OnUpdate ticks: {LobbyState.UpdateTicks}"
			+ (LobbyState.UpdateTicks == 0 ? "   ⚠ NEVER RAN" : "") );

		var body = PlayerPresence.Find();
		Log.Info( $"[nz-ui] player object: {(body is null ? "none" : $"{body.Name} enabled={body.Enabled}")}" );

		if ( !scene.IsValid() ) return;

		foreach ( var sp in scene.GetAllComponents<ScreenPanel>() )
			Log.Info( $"[nz-ui] ScreenPanel on '{sp.GameObject.Name}'  "
				+ $"zindex {sp.ZIndex}  opacity {sp.Opacity}  enabled {sp.Enabled}" );
	}

	/// <summary>
	/// Force the cursor mode by hand: nz_cursor [0=Auto 1=Visible 2=Hidden].
	///
	/// The decisive test. If setting this to 1 makes the lobby clickable, the
	/// panel was fine all along and the fault is that nothing was setting the
	/// mode — either OnUpdate is not running, or it is being overwritten by
	/// something later in the frame. If clicks STILL do nothing with the cursor
	/// forced Visible, the panel itself is not hit-testable and the search moves
	/// to pointer-events and layout.
	/// </summary>
	[ConCmd( "nz_cursor" )]
	public static void Cursor( int mode = 1 )
	{
		Mouse.Visibility = mode switch
		{
			1 => MouseVisibility.Visible,
			2 => MouseVisibility.Hidden,
			_ => MouseVisibility.Auto,
		};

		Log.Info( $"[nz-ui] Mouse.Visibility -> {Mouse.Visibility}   (Active: {Mouse.Active})" );
	}

	/// <summary>Is there a body in the map, and should there be: nz_player_presence.</summary>
	[ConCmd( "nz_player_presence" )]
	public static void Presence()
	{
		var go = PlayerPresence.Find();

		Log.Info( $"[nz] mode {NZGame.Mode} — player should exist: {PlayerPresence.ShouldExist}" );
		Log.Info( go is null
			? "[nz]   no player object found at all"
			: $"[nz]   '{go.Name}' enabled: {go.Enabled}" );
	}

	// ── config ───────────────────────────────────────────────────────────────

	/// <summary>The Save config button. Name is optional — configs are per map,
	/// and one map can have many.</summary>
	[ConCmd( "nz_save" )]
	public static void SaveConfig( string name = "default", bool force = false )
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz] only the host can save a config" ); return; }

		// ⛔ NOT IN A PUBLISHED COPY (`Edition`, 2026-10-05). It plays the configs it ships and reads no other, so a save
		// would write a file nothing there ever opens.
		if ( !Edition.CanBuild )
		{
			Log.Info( "[nz] configs are saved in the editor — the published game plays the gamemodes it ships" );
			return;
		}

		var map = CurrentMap();

		// ⛔ REFUSE TO SAVE ONE MAP'S CONFIG ONTO ANOTHER MAP. `Map` is stamped on load, so a config
		// that came from ttt_basalt_d and a `CurrentMap()` of countdown means the scene and the
		// config have drifted apart — and saving then OVERWRITES the other map's file with this
		// map's contents. That happened: countdown's `default.json` went from 18,638 bytes of
		// spawns to 4,451 bytes holding Basalt's lava wall, in one keystroke, reported only as a
		// cheerful "saved config 'default' for countdown".
		//
		// ⚠️ THE DRIFT IS EASY AND INVISIBLE. A workbench scene that MapInstances a map is not the
		// same as the game having LOADED that map, so `NZMap.Current` can legitimately say something
		// else while the loaded config says Basalt.
		//
		// ⚠️ `--force` RATHER THAN A FLAT BAN, because deliberately copying a config onto another
		// map is a real thing to want — it just should never happen by accident.
		var owner = ActiveConfig.Current.Map;

		if ( !string.IsNullOrEmpty( owner ) && !string.Equals( owner, map,
			System.StringComparison.OrdinalIgnoreCase ) && !force )
		{
			Log.Warning( $"[nz] REFUSED: the loaded config belongs to '{owner}' but the current map "
				+ $"is '{map}'. Saving would overwrite {map}'s config with {owner}'s contents." );
			Log.Info( $"[nz] load {map}'s own config first, or `nz_save {name} true` to force it." );
			return;
		}

		ActiveConfig.Current.Save( map, name );
	}

	/// <summary>The Load config button.</summary>
	[ConCmd( "nz_load" )]
	public static void LoadConfig( string name = "default" )
	{
		// ⛔ THE CONFIG IS THE HOST'S. §3: a client's lobby has three controls and this is not
		// one of them. Gated on the ACTION because `nz_load` reaches here directly — hiding the
		// button leaves the console path open, which §4 says is the bulk of the real work.
		if ( NZGame.IsClient )
		{
			Log.Info( "[nz] only the host can load a config" );
			return;
		}

		// ⚠️ IN A PUBLISHED COPY, A GAMEMODE: the same shipped file, through the same path as the lobby's Gamemode row
		// (`Gamemodes.Select`, which also hands it to everyone).
		if ( Edition.IsPublished )
		{
			Gamemodes.Select( name );
			return;
		}

		var map = CurrentMap();
		if ( !ActiveConfig.LoadAndApply( map, name ) )
		{
			Log.Warning( $"[nz] nothing saved as '{name}' for {map} — nz_configs to list" );
			return;
		}

		// ⚠️ ONLY ON SUCCESS. Broadcasting a config the host failed to load would hand every
		// client whatever was already active, under the impression it was the new one.
		NZNet.SendConfig();
	}

	/// <summary>What is saved for this map.</summary>
	/// <summary>
	/// Delete a saved config: nz_delete &lt;name&gt;.
	///
	/// ⚠️ No confirmation and no undo — the browser's armed delete-MODE is what
	/// stands in for that, ported from the original. A per-row delete button is
	/// one mis-click from losing a map's whole setup.
	/// </summary>
	[ConCmd( "nz_delete" )]
	public static void DeleteConfig( string name = "" )
	{
		if ( string.IsNullOrWhiteSpace( name ) ) { Log.Warning( "[nz] name required" ); return; }

		// ⛔ NOT IN A PUBLISHED COPY (`Edition`), which deletes nothing: its gamemodes ship with it
		if ( !Edition.CanBuild )
		{
			Log.Info( "[nz] configs are deleted in the editor — the published game's gamemodes ship with it" );
			return;
		}

		var map = CurrentMap();

		Log.Info( MapConfig.Delete( map, name )
			? $"[nz] deleted config '{name}' for {map}"
			: $"[nz] nothing saved as '{name}' for {map}" );
	}

	[ConCmd( "nz_configs" )]
	public static void ListConfigs()
	{
		var map = CurrentMap();
		var list = MapConfig.ListFor( map );

		Log.Info( $"[nz] configs for {map}: "
			+ (list.Count == 0 ? "(none)" : string.Join( ", ", list )) );
		// ⚠️ Same reason as ActiveConfig.Set's log: MapConfig.Name defaults to
		// "default", so an empty config would report a name that was never
		// loaded. Say NONE when nothing playable is active.
		Log.Info( ActiveConfig.IsPlayable
			? $"[nz] active: '{ActiveConfig.Current.Name}'  "
				+ $"{ActiveConfig.Current.PlayerSpawns.Count} player / "
				+ $"{ActiveConfig.Current.ZombieSpawns.Count} zombie spawns"
			: $"[nz] active: NONE — {ActiveConfig.PlayableProblem}" );
	}

	/// <summary>
	/// Which map are we on?
	///
	/// ⚠️ Hardcoded for now. The map is welded into the scene rather than loaded
	/// through MapInstance, so there is nothing to ask. This is the one place
	/// that needs changing when that restructure happens — configs key off it.
	/// </summary>
	/// <summary>
	/// The map configs are stored under.
	///
	/// ✅ NO LONGER HARDCODED. This was the string literal "countdown", with a comment saying the
	/// real fix was one shared accessor — <see cref="NZMap.Current"/> is it, reading the
	/// scene's MapInstance. Kept as a forwarding property rather than deleted because it is the
	/// name the lobby and its commands already use.
	///
	/// ⛔ DO NOT reintroduce a second answer here. `ConfigBrowserState.CurrentMap` forwards to the
	/// same place; two guesses is how the browser ends up listing one folder while save/load
	/// writes to another.
	/// </summary>
	public static string MapName => NZMap.Current;

	static string CurrentMap() => MapName;
}