NZGame.cs

Static game session manager for NZombies. Declares GameMode enum and NZGame class that tracks current mode, host/client predicates, preview and authoring visuals, commands for reporting host state, mode switching logic, and a ShowConfig method that ensures and rebuilds many scene managers and triggers cleanup and light baking.

NetworkingFile Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>Creative builds the config; Survival plays it.</summary>
public enum GameMode
{
	/// <summary>Sitting in the lobby, nothing running.</summary>
	Lobby,

	/// <summary>Building the map config. Tools work, markers are visible,
	/// rounds only advance on command.</summary>
	Creative,

	/// <summary>Playing. Tools are locked out and markers are hidden.</summary>
	Survival,

	/// <summary>Watching, with no body in the world. Free-fly camera.
	///
	/// ⚠️ Appended, not inserted. Nothing persists this enum today, but the
	/// habit is worth keeping — a mode written into a saved config that then
	/// shifts value is a class of bug that only shows up much later.</summary>
	Spectator,
}

/// <summary>
/// What the session is currently doing.
///
/// Small on purpose — this only answers "which mode", so the things that care
/// (tools, markers, the Q menu, the round manager) have one place to ask
/// instead of each keeping their own flag and drifting out of step.
/// </summary>
public static class NZGame
{
	public static GameMode Mode { get; private set; } = GameMode.Lobby;

	public static bool IsCreative => Mode == GameMode.Creative;
	public static bool IsSurvival => Mode == GameMode.Survival;
	public static bool IsSpectator => Mode == GameMode.Spectator;

	/// <summary>
	/// May THIS machine decide things — spend points, open doors, spawn zombies, author a map?
	///
	/// ⛔ IT DEFAULTS TO **TRUE** WHEN THERE IS NO SESSION, AND THAT IS THE WHOLE POINT.
	/// `Networking.IsHost` is FALSE offline, so a bare `if ( Networking.IsHost )` gate locks a
	/// single player out of their own editor and out of every purchase in the game — with no
	/// error, because refusing is what the gate is for. `MULTIPLAYER.md` §5 states this as a
	/// requirement before any gate is written; this is that accessor.
	///
	/// ⚠️ ASK THIS, NOT `Networking`. One predicate means the offline default is decided
	/// once instead of at 40-60 command sites, and a site that gets it wrong is invisible until
	/// somebody plays alone. The idiom it replaces lived at exactly one place in the codebase
	/// (`NZPlayer.GiveWeapon`) and was already written correctly — which is why it is worth
	/// having somewhere everything else can copy rather than re-derive.
	/// </summary>
	public static bool IsHost => !Networking.IsActive || Networking.IsHost;

	/// <summary>
	/// A connected machine that is NOT running the game.
	///
	/// ⚠️ NOT `!IsHost` FOR READABILITY ALONE — it reads better at a call site that wants to
	/// hide a control, and it keeps "offline is host" stated in one place rather than inverted
	/// by hand wherever someone needs the other sense.
	/// </summary>
	public static bool IsClient => !IsHost;

	/// <summary>
	/// `nz_host` — who is this machine, and what may it do?
	///
	/// ⛔ THE OFFLINE CASE IS PRINTED EXPLICITLY. "IsHost true, networking inactive" and
	/// "IsHost true, and I am the host of a session" are different worlds that a single boolean
	/// cannot tell apart — and the first bug this predicate can cause is being right for the
	/// wrong reason.
	/// </summary>
	[ConCmd( "nz_host" )]
	public static void HostReport()
	{
		Log.Info( $"[nz-net] networking {( Networking.IsActive ? "ACTIVE" : "inactive (solo)" )}"
			+ $" · Networking.IsHost {Networking.IsHost}"
			+ $" · NZGame.IsHost {IsHost}" );

		Log.Info( $"[nz-net] mode {Mode} · {( IsHost ? "this machine decides" : "this machine asks the host" )}" );

		if ( Networking.IsActive )
			Log.Info( $"[nz-net] {Connection.All.Count} connection(s)" );

		// ⚠️ WHERE THE SESSION CAME FROM, because "I am hosting" and "I am hosting because the
		// game did it for me at startup" are different answers to the same question — and only one
		// of them explains why `nz_host_start` refused. See NZStartup.
		Log.Info( $"[nz-net] auto-host nz_autohost {NZStartup.AutoHost}"
			+ $" → {(NZStartup.ShouldAutoHost ? "WILL host on start" : "will not host")}"
			+ (Game.IsEditor ? " (editor)" : "")
			+ $" · {(NZStartup.DidAutoHost ? "this session's lobby was made by it" : "it did not make this session")}" );
	}


	/// <summary>
	/// Hide every authoring visual while STAYING in Creative — see the map as a
	/// player will.
	///
	/// ⚠️ A view setting, not a mode. Tools keep working and the config is still
	/// editable; only the overlays stop drawing. Switching to Survival to check
	/// the look would start a game and reset the links, which is the opposite of
	/// what you want mid-build.
	///
	/// ⚠️ THIS IS NOW THE ONLY WAY TO HIDE THEM. Markers used to vanish whenever
	/// no tool was armed, which was a side effect of holstering rather than
	/// something anyone asked for — and it hid the config exactly when you were
	/// looking at the map rather than editing it. Holstering no longer hides
	/// anything; see MapEditor.OnUpdate.
	/// </summary>
	public static bool PreviewMode
	{
		get => _previewMode;
		set
		{
			if ( _previewMode == value ) return;
			_previewMode = value;

			// ⚠️ THE REBUILD FOLLOWS THE FLAG, FROM HERE -- the same reasoning SetMode gives for
			// calling PlayerPresence.Apply itself. Two separate commands write this flag
			// (nz_preview and nz_markers) and the one that got forgotten would leave preview mode
			// half-applied.
			//
			// ⛔️ NEEDED ONLY BECAUSE NAV LINK MARKERS ARE REAL OBJECTS. Every other authoring
			// visual is a DebugOverlay draw issued each frame under ShowAuthoringVisuals, so it
			// vanishes on its own the moment this flips. Marker GameObjects do not -- they have to
			// be destroyed, and Rebuild is what destroys them.
			NavLinkManager.Instance?.Rebuild();
		}
	}

	static bool _previewMode;

	/// <summary>Should creative-only overlays draw right now?</summary>
	/// <remarks>⚠️ AND NOT IN PHOTO MODE (`MapPhotoMode`, `nz_photo`): the map alone, for screenshots.</remarks>
	public static bool ShowAuthoringVisuals => IsCreative && !PreviewMode && !MapPhotoMode.On;

	public static void SetMode( GameMode mode )
	{
		// ⚠️ THE JOIN HOOK IS ENSURED HERE because `SetMode` is the one thing that runs on
		// every machine, early, whatever it is doing — the console shows "[nz] mode -> Lobby" on
		// the host and on every client. A listener that only exists on the machine that happened
		// to open a menu is a listener that misses the join it was written for.
		NZNetListener.Ensure();

		// ⛔ NO CREATIVE IN A PUBLISHED COPY (`Edition`, 2026-10-05): *"in the editor I want to be able to edit maps as usual,
		// but not in the published version"*. Refused here because every way in passes through here: the lobby's button,
		// `nz_creative` and `nz_mode creative`.
		// ⚠️ ONLY THIS MACHINE'S OWN CHOICE. A client following an editor host into Creative (`NZNet.ModeChanged`) still
		// follows, or it would sit in the lobby while the host builds. Its build menu stays shut (`DevMenu`).
		if ( mode == GameMode.Creative && IsHost && !Edition.CanBuild )
		{
			Log.Info( "[nz] no Creative in the published game — maps are built in the editor" );
			return;
		}

		if ( Mode == mode ) return;

		Mode = mode;
		Log.Info( $"[nz] mode -> {mode}" );

		// ⚠️ THE MATCH'S DIFFICULTY ENDS WITH THE GAME (2026-10-05): the lobby and the editor play the gamemode as it is, and the
		// next game takes the lobby's choice afresh (`Difficulty.StartMatch`). Here because every machine passes: the host's mode
		// reaches the clients just below. Spectator is still in the game, and keeps it.
		if ( mode is GameMode.Lobby or GameMode.Creative ) Difficulty.Clear( $"mode -> {mode}" );

		// ⛔ AND EVERY OTHER MACHINE FOLLOWS. The mode is a decision about the SESSION — whether
		// a game is running — not about this pc, and until now it was purely local. That is why a
		// client sat in the lobby while the host played:
		//
		//     CLIENT  ── 'Player (Cifosi)' ──  2 enabled … ⛔ NO — nothing below matters
		//
		// `RefreshBodies` shows other people's bodies only when THIS machine's mode says bodies
		// should exist, and `Enabled` does not replicate (measured) — so a client still in Lobby
		// hides everybody, including a host who is mid-round. The lobby countdown happened to
		// broadcast a start, which hid this for as long as that was the only way in; `nz_round_start`,
		// the dev menu and a creative session all bypass it.
		//
		// ⚠️ HOST DECIDES, EVERYONE FOLLOWS — the same shape as the round mirror. A client
		// changing its own mode locally is still allowed and still works; it simply cannot tell
		// anybody else to.
		if ( IsHost && Networking.IsActive ) NZNet.ModeChanged( (int)mode );

		// ⚠️ The BODY follows the mode, from here. Doing it at each call site
		// means the one that gets forgotten leaves a player standing in the map
		// during the lobby, taking mouse-look while a menu draws over the top —
		// which is exactly the bug this was written for.
		PlayerPresence.Apply();

		// ⚠️ AFTER Apply, because it looks for the player Apply just put in the
		// map.
		if ( mode == GameMode.Creative ) ShowConfig();

		// ⛔ THE LOBBY MENU OPENS HERE NOW, NOT IN `RoundManager.ReturnToLobby`, BECAUSE THAT
		// METHOD RUNS ON THE HOST ALONE. Reported as *"não voltamos ao lobby automaticamente? ou
		// pelo menos o host"* — and the host half always worked, which is what made it confusing.
		//
		// `ReturnToLobby` changes the mode (which DOES reach everyone, via `ModeChanged` above)
		// and then calls `LobbyState.SetOpen` locally. So a client followed the host into Lobby
		// mode and sat there with no lobby: its body was pulled from the map by `Apply` and no
		// menu was ever drawn over the top. Mode-without-menu looks exactly like being stuck.
		//
		// ⚠️ THE MODE IS THE TRIGGER, NOT THE GAME-OVER. Every route into the lobby wants the
		// menu — a finished run, `nz_mode lobby`, a host who quits to it — and putting it at the
		// one call site that happened to be written first is what left the other routes bare. The
		// same argument the `ModeChanged` block above makes about the countdown hiding the mode
		// bug, one layer up.
		//
		// ⚠️ LEAVING the lobby is NOT the mirror of this and is deliberately left alone:
		// `LobbyCommands` closes the menu when it sends you to Creative or Spectator, and the
		// round start closes it by its own path. A blanket "close on any other mode" here would
		// fight those.
		//
		// ⚠️ NULL-CONDITIONAL BECAUSE THE MENU MAY NOT EXIST YET. `LobbyState.SetOpen` is
		// assigned by `LobbyMenu`'s own razor when it mounts; a machine that reaches Lobby mode
		// before its UI has built simply has nothing to open, and `LobbyMenu` opens itself on
		// start for that case.
		if ( mode == GameMode.Lobby ) LobbyState.SetOpen?.Invoke( true );

		// ⛔ THE FLOOR IS SWEPT HERE BECAUSE THIS METHOD RUNS ON EVERY MACHINE, which is the same
		// argument the lobby menu two lines up already makes for itself. Pickups are
		// `NetworkMode.Never` — each machine spawns its own copy and the network carries the event
		// rather than the object — so there is no host that could destroy everybody's litter.
		// `RoundManager.ReturnToLobby` is the host's alone and was the obvious place; a client
		// would have walked into a lobby still holding a round of dropped plates and corpses.
		//
		// ⚠️ BOTH DIRECTIONS, AND SURVIVAL IS NOT REDUNDANT. `StartGame` sweeps too, but only on
		// the host — this is what gives a CLIENT a clean map at the start of a run. Entering
		// Creative is deliberately left alone: a leftover horde there is somebody testing.
		if ( mode is GameMode.Lobby or GameMode.Survival )
			WorldCleanup.Sweep( Game.ActiveScene, mode == GameMode.Lobby ? "lobby" : "new game" );
	}

	/// <summary>
	/// Put the saved config into the world — markers, barriers and switches.
	///
	/// ⛔️ ENTERING CREATIVE USED TO SHOW AN EMPTY MAP. Every piece of this is
	/// created on demand by whatever touches it first, and in practice that was
	/// arming a tool from the Q menu: no MapEditor meant no markers, and no
	/// DebrisManager meant the barriers themselves were never built. So a map
	/// with a full config looked like a map with nothing saved — which is the
	/// worst possible way to be wrong about a config, because the obvious next
	/// move is to place everything again.
	///
	/// ⚠️ Rebuilds rather than merely ensuring. The managers are NotSaved and do
	/// not survive a play restart, so "does one exist" is not the same question
	/// as "is the config standing in the world".
	///
	/// ⚠️ Public because <see cref="SetMode"/> early-returns when the mode is
	/// unchanged and Mode is STATIC — a play restart comes back already in
	/// Creative, so the player has to be able to ask for this itself.
	/// </summary>
	public static void ShowConfig()
	{
		var scene = Game.ActiveScene;
		if ( scene is null ) return;

		MapEditor.Ensure();
		DebrisManager.Ensure( scene )?.Rebuild();
		PowerManager.Ensure( scene )?.Rebuild();
		InvisibleWallManager.Ensure( scene )?.Rebuild();

		// ⚠️ THE SAME TWO HOOKS AS THE INVISIBLE WALL, AND NO OTHERS. Adding a third in
		// SetMode is what broke this the first time.
		DamageWallManager.Ensure( scene )?.Rebuild();

		// ⛔ BOTH OF THESE MUST BUILD BEFORE THE BAKE ON THE LAST LINE OF THIS METHOD. The probes
		// capture whatever exists when they render, so a placed light created after the bake
		// contributes nothing to the bounce — which is exactly the bug that made the lava's light
		// vanish, from the other direction.
		MapLightManager.Ensure( scene )?.Rebuild();
		// ⚠️ AND THE MAP'S LIGHT STRIPS, each fixture a copy of their material of its own — dark until the power where the map asks
		// (`StripLights`, 2026-09-28). ⚠️ Before the bake too: a map baked at runtime with its strips dark bakes them dark.
		StripLights.Ensure( scene )?.Rebuild();
		SoundSpotManager.Ensure( scene )?.Rebuild();
		FogAreaManager.Ensure( scene )?.Rebuild();
		AshParticles.Ensure( scene );
		// ⚠️ AND EMBERS AMONG THE ASH, where the map asks for them (`Gameplay.FogEmbers`, 2026-09-28)
		FogEmbers.Ensure( scene );
		// ⚠️ AND OFF THE LAVA ITSELF, near the player, where the map asks (`Gameplay.LavaEmbers`, 2026-09-28)
		LavaEmbers.Ensure( scene );
		LavaFog.Ensure( scene )?.Rebuild();
		// ⚠️ AND THE HEAT HAZE OVER THE LAVA, where the map asks (`Gameplay.LavaHaze`, 2026-09-28)
		LavaHaze.Ensure( scene )?.Rebuild();
		// ⚠️ AND BASALT'S CARVINGS ON ITS PILLARS (`Gameplay.Engravings`, 2026-09-28)
		BasaltEngravings.Ensure( scene )?.Rebuild();
		MiseryDeviceManager.Ensure( scene )?.Rebuild();
		ClueManager.Ensure( scene )?.Rebuild();
		HexSlotManager.Ensure( scene )?.Rebuild();

		// ⚠️ AND BASALT'S TILE 1, plain until step 1 is done — or undressed, on any other map
		HexPlatforms.OnConfigShown( scene );
		PressableManager.Ensure( scene )?.Rebuild();
		ShootableManager.Ensure( scene )?.Rebuild();
		BarricadeManager.Ensure( scene )?.Rebuild();
		NavLinkManager.Ensure( scene )?.Rebuild();
		MysteryBoxManager.Ensure( scene )?.Rebuild();
		PackAPunchManager.Ensure( scene )?.Rebuild();
		WunderfizzManager.Ensure( scene )?.Rebuild();
		PerkMachineManager.Ensure( scene )?.Rebuild();
		TeleporterManager.Ensure( scene )?.Rebuild();

		// ⛔️ THE NAV MESH IS PART OF PUTTING A CONFIG INTO THE WORLD. NZMap.RebuildNav only runs on a
		// map SWITCH, so a cold start straight onto a map left whatever mesh the scene loaded with —
		// which until now was countdown's baked one, hardcoded in nzombies.scene, on every map.
		//
		// ⚠️ EnsureFor, NOT Apply. This method runs on every mode change and config load; marking
		// the mesh dirty each time would keep a canyon-sized map permanently regenerating.
		NavBake.EnsureFor( NZMap.Current );

		// ⚠️ AND THIS IS ALSO THE RESET. A soul box's fill lives on its component, so rebuilding
		// them is what empties them for a new game — the same trick the trading table relies on.
		SoulBoxManager.Ensure( scene )?.Rebuild();
		ArsenalManager.Ensure( scene )?.Rebuild();

		// ⛔️ THE AMMO BOX WAS MISSING FROM HERE and that was a real gap: placed boxes existed in the
		// config and never appeared in the world, because nothing but their own console command ever
		// called Rebuild. Every placeable has to be listed at all three rebuild sites — here,
		// RoundManager.StartGame, and the map editor's own refresh.
		AmmoBoxManager.Ensure( scene )?.Rebuild();
		BuyableEndingManager.Ensure( scene )?.Rebuild();

		// ⚠️ AND REBUILDING IS ALSO HOW A TRADING TABLE EMPTIES. Rebuild destroys the objects, and
		// what a table holds is state on the component — so a new game gets bare tables without
		// needing a reset path of its own.
		TradeTableManager.Ensure( scene )?.Rebuild();
		BuildTableManager.Ensure( scene )?.Rebuild();
		BuildPartManager.Ensure( scene )?.Rebuild();
		WallBuyManager.Ensure()?.Rebuild();

		// ⛔ THE INDIRECT BAKE GOES LAST, AND IT HAS TO BE HERE RATHER THAN ON A TIMER, BECAUSE
		// EVERYTHING IT CAPTURES IS BUILT ABOVE THIS LINE. `NZAtmosphere` used to trigger it the
		// moment the map's AmbientLight appeared — which is map-LOAD time, before a single placeable
		// exists. On Basalt the map's only real light source is a LAVA damage wall, created by
		// `DamageWallManager.Rebuild()` twenty lines up, so the automatic bake was reliably
		// capturing a room with no lava in it and binding black probes over a working ambient.
		//
		// ⚠️ THAT IS WHY IT KEPT "DECAYING". A hand-run `nz_light_bake` looked perfect and then the
		// map went dark again later — not decay at all, but a second, earlier-ordered bake
		// overwriting it with a pre-config snapshot.
		//
		// ⚠️ AND IT IS CHEAP TO PUT HERE: the bake is skipped entirely unless the map's config asks
		// for it, and ShowConfig already runs exactly when the world has changed enough to need one.
		LightBake.BakeIfConfigured();
	}
}