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.
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 <name>.
///
/// ⚠️ 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;
}