Doors/RoomNames.cs

Manages room names and when they appear for the local player. Tracks the current flag/zone name, follows room zones, detects walking or teleporting across opened door barriers and teleporter arrivals, handles first-visit logic and sound cues, exposes console commands to list and edit room names, subtitles, ambushes and teleporter arrival flags, and provides a self-test for detection logic.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// NAMED ROOMS — every flag has a name, and walking through where one of its barriers stood puts that name at the top left of
/// the screen (`RoomNameHud`). Asked for as *"each flag has a name — when i go trough a place where a debris used to be, it
/// changes the name on screen to that — it only changes when i go trough another flag"* (2026-09-27).
///
/// ⚠️ THE NAME IS THE FLAG'S, NOT A BARRIER'S. Every barrier carrying a flag opens with it (`DebrisManager.OpenAllOnLink`) and
/// leads into the same part of the map, so one name covers them all — `MapConfig.Rooms`, saved with the config. Flag 0 is where
/// a game starts: its name is on screen from the first countdown, before any door is bought.
///
/// ⚠️ EACH MACHINE, FOR ITS OWN PLAYER — NOTHING CROSSES THE WIRE. Every client holds the whole config (`NZNet.ConfigLoaded`)
/// and which flags are open (`NZNet.LinkOpened`, replayed to a joiner by `SendDoors`), so the test runs where the player's body
/// is, against the same barriers the host has.
///
/// ⛔ ROOM ZONES COME FIRST (2026-09-27). A door names a room only as you come through it, so walking back the way you came never
/// changed it — *"it does not change when i go trough a zone"*. A room zone is a drawn volume with a name (`RoomZone`, the Room
/// zone tool): walking into one shows its name wherever it is, and a teleport or a respawn that lands in one does too
/// (`ZoneFollower`). Leave every zone, and the name stays until you walk into another.
///
/// ⚠️ WHERE NO ZONE IS DRAWN, THE DOORS AND THE PADS — the rule as it was first asked for: *"it only changes when i go trough
/// another flag"*. Walking into where an OPENED barrier of another NAMED flag stood shows that flag's name, and a pad that
/// names its far end shows that; a flag with no name changes nothing, and a new game puts it back to the start.
/// </summary>
public static class RoomNames
{
	/// <summary>Is the name drawn at all. `nz_room_hud 0` hides it — the rooms are still followed.</summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>The flag whose room this machine's player is in. Blank — flag 0 — is where a game starts.</summary>
	public static string Current { get; private set; } = DoorLinks.Unlinked;

	/// <summary>Since the name last changed, for the HUD's entrance.</summary>
	public static TimeSince SinceChanged { get; private set; }

	/// <summary>What the HUD says: the room zone's name, if one is shown, or else the current flag's — blank if it has none.</summary>
	public static string CurrentName => Follower.Shown is { } z ? z.Name?.Trim() ?? "" : NameOf( Current );

	/// <summary>The room zones this machine's player stands in, in the order they walked into them.</summary>
	public static IReadOnlyList<RoomZone> ZonesAround => Follower.Inside;

	/// <summary>
	/// This machine's player among the room zones. ⚠️ MADE ON FIRST USE rather than at its declaration, so nothing depends on
	/// when a static added by a hotload is given its value.
	/// </summary>
	static ZoneFollower Follower => _follower ??= new();
	static ZoneFollower _follower;

	static MapConfig Cfg => ActiveConfig.Current;

	// ══ the names ════════════════════════════════════════════════════════════════════════════

	/// <summary>A flag's room name, blank if it has none. Flag 0 — or blank — is where a game starts.</summary>
	public static string NameOf( string link )
	{
		var list = Cfg?.Rooms;
		if ( list is null ) return "";

		foreach ( var r in list )
			if ( r is not null && DoorLinks.Same( r.Link, link ) )
				return r.Name?.Trim() ?? "";

		return "";
	}

	/// <summary>
	/// Name a flag's room, or unname it with a blank. ⚠️ THE CONFIG IN MEMORY, like every other tool edit — `nz_save` keeps it.
	/// </summary>
	public static void SetName( string link, string name )
	{
		var cfg = Cfg;
		if ( cfg is null ) return;

		cfg.Rooms ??= new();
		link = DoorLinks.Clean( link );
		name = name?.Trim() ?? "";

		// ⚠️ A RENAME KEEPS THE ROOM'S SUBTITLE (2026-09-27) AND ITS AMBUSH (2026-09-28): the entry is rebuilt below, and would
		// drop both
		var old = cfg.Rooms.FirstOrDefault( r => r is not null && DoorLinks.Same( r.Link, link ) );
		var subtitle = old?.Subtitle ?? "";
		var ambush = old?.Ambush ?? "";

		cfg.Rooms.RemoveAll( r => r is null || DoorLinks.Same( r.Link, link ) );
		if ( name.Length > 0 ) cfg.Rooms.Add( new RoomName { Link = link, Name = name, Subtitle = subtitle, Ambush = ambush } );

		// in flag order, so the saved file reads as the lists do
		cfg.Rooms.Sort( ( a, b ) => Compare( a.Link, b.Link ) );
	}

	/// <summary>
	/// The flags a room can be named for, with how many barriers carry each: every door flag on the map, and any other flag
	/// already named — in flag order, the start not among them.
	///
	/// ⚠️ NOT THE SCENERY. A flag of 1000 or more, or not a number, is permanent walls (`DebrisManager.ShapesNav`): basalt's
	/// 56 barriers on `123123123` never open, so a row for them would be a room nobody can walk into.
	/// </summary>
	public static List<(string Link, int Barriers)> MapFlags()
	{
		var counts = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase );
		var debris = Cfg?.Debris ?? new List<Debris>();

		foreach ( var d in debris )
		{
			if ( d is null || DoorLinks.IsUnlinked( d.Link ) || DebrisManager.ShapesNav( d ) ) continue;

			var link = DoorLinks.Clean( d.Link );
			counts[link] = counts.TryGetValue( link, out var n ) ? n + 1 : 1;
		}

		// ⚠️ A NAMED FLAG NO DOOR CARRIES STILL SHOWS, so a name typed against the wrong flag can be seen and cleared
		foreach ( var r in Cfg?.Rooms ?? new List<RoomName>() )
		{
			if ( r is null || DoorLinks.IsUnlinked( r.Link ) ) continue;

			var link = DoorLinks.Clean( r.Link );
			if ( !counts.ContainsKey( link ) ) counts[link] = debris.Count( d => d is not null && DoorLinks.Same( d.Link, link ) );
		}

		var list = counts.Select( kv => (Link: kv.Key, Barriers: kv.Value) ).ToList();
		list.Sort( ( a, b ) => Compare( a.Link, b.Link ) );
		return list;
	}

	/// <summary>Flag order: the start, then the doors by number, then any other flag by name.</summary>
	static int Compare( string a, string b )
	{
		static (int Kind, long Number, string Text) Key( string link )
		{
			link = DoorLinks.Clean( link );
			if ( link.Length == 0 ) return (0, 0, "");
			return long.TryParse( link, out var n ) ? (1, n, "") : (2, 0, link.ToLowerInvariant());
		}

		var x = Key( a );
		var y = Key( b );
		if ( x.Kind != y.Kind ) return x.Kind.CompareTo( y.Kind );
		if ( x.Number != y.Number ) return x.Number.CompareTo( y.Number );
		return string.CompareOrdinal( x.Text, y.Text );
	}

	/// <summary>How a flag reads in the list and the log: the start is flag 0.</summary>
	public static string FlagText( string link ) => DoorLinks.IsUnlinked( link ) ? "0" : DoorLinks.Clean( link );

	public static string Quoted( string name ) => string.IsNullOrEmpty( name ) ? "(no name)" : $"'{name}'";

	/// <summary>Where this machine's player is, for the panel and the console: "zone 2 · 'Nexus'", or "flag 3 · 'Nexus'".</summary>
	public static string Describe => Follower.Shown is { } z
		? $"zone {Cfg?.RoomZones?.IndexOf( z ) ?? -1} · {Quoted( CurrentName )}"
		: $"flag {FlagText( Current )} · {Quoted( CurrentName )}";

	// ══ following the player ═════════════════════════════════════════════════════════════════

	/// <summary>How long one frame's move may be and still be a walk. Longer is a teleport, and a teleport crosses nothing.</summary>
	const float TeleportStep = 128f;

	/// <summary>The spacing of the points tested along a step. ⚠️ HALF THE THINNEST BARRIER — basalt's thinnest are 8u.</summary>
	const float SampleStep = 4f;

	/// <summary>Feet to head, for the height test: a barrier counts if it overlaps the body anywhere up it.</summary>
	const float BodyHeight = 64f;

	/// <summary>Where this machine's player stood last frame, so a step can be tested along its length.</summary>
	static Vector3? _last;

	/// <summary>The round's state and number as last seen, to tell a new game starting.</summary>
	static RoundState _stateSeen = RoundState.Waiting;
	static int _roundSeen;

	/// <summary>
	/// Once a frame, on every machine, for this machine's player (`RoomNameHud.OnUpdate`): a new game starts the rooms over,
	/// then the step since last frame is tested against the opened barriers.
	/// </summary>
	public static void Tick()
	{
		// ⚠️ A NEW GAME IS A ROUND COMING OUT OF THE LOBBY OR A GAME OVER — or round 0's countdown coming round again, a restart
		// mid-game. Read from the round's own state, which a client holds mirrored, so every machine sees the same moment.
		var rm = RoundManager.Instance;
		if ( rm.IsValid() )
		{
			var playing = rm.State is RoundState.Prep or RoundState.Active;
			if ( playing && (_stateSeen is RoundState.Waiting or RoundState.GameOver || (rm.Round == 0 && _roundSeen > 0)) )
				Restart( "a new game" );

			_stateSeen = rm.State;
			_roundSeen = rm.Round;
		}

		var me = NZPlayer.Local;
		if ( !me.IsValid() ) { _last = null; return; }

		var at = me.WorldPosition;
		var from = _last ?? at;
		_last = at;

		// ⛔ A MOVE OF MORE THAN A STEP IS A TELEPORT, NOT A WALK — a pad, a respawn, basalt's arena — and it is tested only where
		// it lands: it crosses no doorway on the way
		var teleport = (at - from).Length > TeleportStep;

		// ⛔ THE ROOM ZONES FIRST. Walking into a drawn zone names the room wherever it is, and landing in one does too.
		var shown = Follower.Shown;
		var named = CurrentName;
		var zoned = Follower.Step( Cfg?.RoomZones, teleport ? at : from, at );
		if ( Follower.Shown is { } now && now != shown )
			Announce( named, teleport ? "landed in" : "walked into" );

		// ⚠️ THE DOORS AND THE PADS NAME A ROOM ONLY WHERE NO ZONE IS DRAWN — inside one, the zone says where you are
		if ( zoned ) return;

		// a pad that names its far end (`TeleporterSpot.SetsRoom`) — basalt's sends you home to flag 0
		if ( teleport )
		{
			var pad = PadLandedAt( at );
			if ( pad is not null ) Enter( pad.ArrivalRoom, "teleported into" );
			return;
		}

		var link = Crossed( from, at );
		if ( link is not null ) Enter( link, "walked through" );
	}

	/// <summary>Is this flag's name the one on screen — with no room zone shown over it?</summary>
	static bool ShowingFlag( string link ) => Follower.Shown is null && DoorLinks.Same( link, Current );

	/// <summary>
	/// The flag of a barrier this step walked into — opened, named, and not the room already shown — or null.
	///
	/// ⚠️ INTO, NOT INSIDE (<see cref="Crosses"/>): a barrier the step began in is passed over, so standing where two flags'
	/// barriers overlap cannot flick the name between them every frame.
	/// </summary>
	static string Crossed( Vector3 from, Vector3 to )
	{
		var list = Cfg?.Debris;
		if ( list is null || list.Count == 0 ) return null;

		foreach ( var d in list )
		{
			if ( d is null || DoorLinks.IsUnlinked( d.Link ) || !DoorLinks.IsOpen( d.Link ) ) continue;
			if ( ShowingFlag( d.Link ) || NameOf( d.Link ).Length == 0 ) continue;
			if ( Crosses( d, from, to ) ) return d.Link;
		}

		return null;
	}

	/// <summary>
	/// Did a step between these two feet positions walk into where this barrier stands? False if it began inside. Tested every
	/// <see cref="SampleStep"/> along the step, so a quick one cannot skip a thin barrier.
	/// </summary>
	public static bool Crosses( Debris d, Vector3 from, Vector3 to )
	{
		if ( d is null || Inside( d, from ) ) return false;

		var steps = Math.Clamp( (int)MathF.Ceiling( (to - from).Length / SampleStep ), 1, 64 );
		for ( var i = 1; i <= steps; i++ )
			if ( Inside( d, Vector3.Lerp( from, to, (float)i / steps ) ) )
				return true;

		return false;
	}

	/// <summary>
	/// Is a body standing at these feet inside the barrier's shape? Asked of the config (`Debris.Contains`), which still knows a
	/// barrier that has been bought. ⚠️ AT THE BODY'S HEIGHT, NOT THE FEET'S: the point from feet to head nearest the barrier's
	/// middle, so a barrier that stands on the floor counts and one on the floor above does not.
	/// </summary>
	static bool Inside( Debris d, Vector3 feet )
		=> d.Contains( feet.WithZ( Math.Clamp( d.Position.z, feet.z, feet.z + BodyHeight ) ) );

	/// <summary>Is a body standing at these feet inside a room zone? At the body's height, as for a barrier (<see cref="Inside"/>).</summary>
	static bool InZone( RoomZone z, Vector3 feet )
		=> z.Contains( feet.WithZ( Math.Clamp( z.Position.z, feet.z, feet.z + BodyHeight ) ) );

	/// <summary>
	/// Which room zones a body stands in, and whose name it shows. One follows this machine's player (<see cref="Follower"/>);
	/// the selftest makes its own.
	/// </summary>
	public sealed class ZoneFollower
	{
		readonly List<RoomZone> _in = new();

		/// <summary>The zone whose name is shown, or null — then the name is the flag's.</summary>
		public RoomZone Shown { get; private set; }

		/// <summary>The zones the body stands in, in the order it walked into them.</summary>
		public IReadOnlyList<RoomZone> Inside => _in;

		/// <summary>Out of every zone and showing none — a new game.</summary>
		public void Clear()
		{
			_in.Clear();
			Shown = null;
		}

		/// <summary>A flag's name takes over — a doorway or a pad, outside every zone.</summary>
		public void ShowNone() => Shown = null;

		/// <summary>
		/// One step, between two feet positions, tested every <see cref="SampleStep"/> along it in order. Out of a zone — and if it
		/// was the one shown, back to the last named one still around the body; into a named zone, and it is shown. A body that
		/// leaves every zone keeps the last name: it changes only on walking into another. True if the step ends in one.
		/// </summary>
		public bool Step( IReadOnlyList<RoomZone> zones, Vector3 from, Vector3 to )
		{
			// ⚠️ A ZONE GONE FROM THE LIST — removed, or a new list from the host — is forgotten, never held on to
			_in.RemoveAll( z => zones is null || !zones.Contains( z ) );
			if ( Shown is not null && (zones is null || !zones.Contains( Shown )) ) Shown = null;
			if ( zones is null || zones.Count == 0 ) return false;

			var steps = Math.Clamp( (int)MathF.Ceiling( (to - from).Length / SampleStep ), 1, 64 );
			for ( var i = 1; i <= steps; i++ )
			{
				var p = Vector3.Lerp( from, to, (float)i / steps );

				// ⚠️ OUT BEFORE IN, so a step from one zone straight into the next ends showing the next
				for ( var k = _in.Count - 1; k >= 0; k-- )
				{
					var z = _in[k];
					if ( InZone( z, p ) ) continue;

					_in.RemoveAt( k );
					if ( Shown == z && LastNamed() is { } back ) Shown = back;
				}

				foreach ( var z in zones )
				{
					if ( z is null || _in.Contains( z ) || !InZone( z, p ) ) continue;

					_in.Add( z );
					if ( !string.IsNullOrWhiteSpace( z.Name ) ) Shown = z;
				}
			}

			return _in.Count > 0;
		}

		RoomZone LastNamed()
		{
			for ( var k = _in.Count - 1; k >= 0; k-- )
				if ( !string.IsNullOrWhiteSpace( _in[k].Name ) ) return _in[k];

			return null;
		}
	}

	/// <summary>How near a teleporter's far end a jump must land to be its riders arriving. Four riders fan out 48u either way.</summary>
	const float ArrivalReach = 96f;

	/// <summary>Do these feet stand where this pad puts its riders, and does the pad name the room there?</summary>
	public static bool LandsAt( TeleporterSpot t, Vector3 feet )
		=> t is { SetsRoom: true } && (t.B - feet).WithZ( 0f ).Length <= ArrivalReach && MathF.Abs( t.B.z - feet.z ) <= BodyHeight;

	/// <summary>The teleporter whose far end these feet just landed at — naming a room with a name, not the one shown — or null.</summary>
	static TeleporterSpot PadLandedAt( Vector3 feet )
	{
		var pads = Cfg?.Teleporters;
		if ( pads is null ) return null;

		foreach ( var t in pads )
		{
			if ( !LandsAt( t, feet ) ) continue;
			if ( ShowingFlag( t.ArrivalRoom ) || NameOf( t.ArrivalRoom ).Length == 0 ) continue;
			return t;
		}

		return null;
	}

	/// <summary>
	/// Which room a pad's riders arrive in: a flag — 0 for where a game starts — or blank or "-", and the name stays as it was.
	/// ⚠️ THE CONFIG IN MEMORY — `nz_save` keeps it.
	/// </summary>
	public static void SetArrival( TeleporterSpot pad, string flag )
	{
		if ( pad is null ) return;

		var clear = string.IsNullOrWhiteSpace( flag ) || flag.Trim() is "-" or "none";
		pad.SetsRoom = !clear;
		pad.ArrivalRoom = clear ? DoorLinks.Unlinked : DoorLinks.Clean( flag );
	}

	/// <summary>A pad's arrival room for the panel: its flag, or blank when it leaves the name alone.</summary>
	public static string ArrivalText( TeleporterSpot pad ) => pad is { SetsRoom: true } ? FlagText( pad.ArrivalRoom ) : "";

	/// <summary>A pad, for the console: where it stands and the room it sends you into.</summary>
	static string PadText( TeleporterSpot t ) => $"the teleporter at {t.A.x:0}, {t.A.y:0} · "
		+ (t.SetsRoom ? $"its riders arrive in flag {FlagText( t.ArrivalRoom )}, {Quoted( NameOf( t.ArrivalRoom ) )}" : "leaves the name as it was");

	/// <summary>Into a flag's room: its name on screen, and the entrance played if the name reads differently.</summary>
	static void Enter( string link, string why )
	{
		var named = CurrentName;

		Current = DoorLinks.Clean( link );
		Follower.ShowNone();
		Announce( named, why );
	}

	/// <summary>
	/// The name on screen may have changed: if it now reads differently, the entrance plays and the log says where.
	///
	/// ⛔ BY THE NAME, NOT BY WHAT CHANGED IT. Two zones called the same, or a zone and a door with one name, are one room to
	/// the player — *"when i go from one of these to the other i see the name change animation, we do not want this when it's
	/// the same name"* (2026-09-27). What is shown still moves to the new one, so a rename of it shows.
	/// </summary>
	static void Announce( string before, string why )
	{
		if ( SameName( before ) ) return;

		SinceChanged = 0f;
		Log.Info( $"[nz-rooms] {why} {Describe}" );
	}

	/// <summary>Does the top left read the same as it did? Exactly, as typed — it is the text on screen that matters.</summary>
	static bool SameName( string before ) => string.Equals( before ?? "", CurrentName, StringComparison.Ordinal );

	/// <summary>Back to where a game starts — flag 0's name, or none. A new game (<see cref="Tick"/>), or `nz_room 0`.</summary>
	public static void Restart( string why )
	{
		var named = CurrentName;

		Current = DoorLinks.Unlinked;
		Follower.Clear();

		// ⚠️ AND EVERY ROOM IS NEW AGAIN: a new game's first visits have their subtitles (`NoteShown`)
		Visited.Clear();
		FirstVisit = null;

		// ⚠️ THE ENTRANCE ONLY IF THE NAME CHANGED (`Announce`) — but a new game is always worth its line in the log
		if ( !SameName( named ) ) SinceChanged = 0f;
		Log.Info( $"[nz-rooms] {why} — back to the start, {Quoted( CurrentName )}" );
	}

	// ══ the first visit ══════════════════════════════════════════════════════════════════════════════

	/// <summary>
	/// A room's subtitle: the line under its name on each player's first visit this game (`RoomNameHud`) — the subtitle of
	/// the flag whose room goes by this name, blank if none. ⚠️ BY THE NAME, as the entrance is (`Announce`): a zone called
	/// what a flag's room is called is that room, and shows its subtitle.
	/// </summary>
	public static string SubtitleOf( string name )
	{
		name = name?.Trim() ?? "";
		if ( name.Length == 0 ) return "";

		foreach ( var r in Cfg?.Rooms ?? new List<RoomName>() )
			if ( r is not null && string.Equals( r.Name?.Trim(), name, StringComparison.Ordinal ) && !string.IsNullOrWhiteSpace( r.Subtitle ) )
				return r.Subtitle.Trim();

		// ⚠️ THEN THE ZONES, for a zone whose name no flag's room has (basalt's "Lava Bridge")
		foreach ( var z in Cfg?.RoomZones ?? new List<RoomZone>() )
			if ( z is not null && string.Equals( z.Name?.Trim(), name, StringComparison.Ordinal ) && !string.IsNullOrWhiteSpace( z.Subtitle ) )
				return z.Subtitle.Trim();

		return "";
	}

	/// <summary>A flag's subtitle, blank if it has none — for the Room names settings.</summary>
	public static string SubtitleFor( string link )
		=> Cfg?.Rooms?.FirstOrDefault( r => r is not null && DoorLinks.Same( r.Link, link ) )?.Subtitle?.Trim() ?? "";

	/// <summary>
	/// Give a named flag's room its subtitle, or clear it with a blank. False when the flag has no name: a subtitle belongs to
	/// a room. ⚠️ THE CONFIG IN MEMORY, as <see cref="SetName"/> — `nz_save` keeps it.
	/// </summary>
	public static bool SetSubtitle( string link, string subtitle )
	{
		var room = Cfg?.Rooms?.FirstOrDefault( r => r is not null && DoorLinks.Same( r.Link, link ) );
		if ( room is null ) return false;

		room.Subtitle = subtitle?.Trim() ?? "";
		return true;
	}

	/// <summary>What waits in a flag's room when it opens (`FlagAmbush`), blank if nothing does — for the Room names settings.</summary>
	public static string AmbushFor( string link )
		=> Cfg?.Rooms?.FirstOrDefault( r => r is not null && DoorLinks.Same( r.Link, link ) )?.Ambush?.Trim() ?? "";

	/// <summary>
	/// Give a named flag's room its ambush — an enemy name — or clear it with a blank. False when the flag has no name (an ambush
	/// belongs to a room) or the enemy is not one that spawns (`SpecialEnemies.IsKnown`): a typo would otherwise sit in the
	/// config until the door opened on nothing. ⚠️ THE CONFIG IN MEMORY, as <see cref="SetName"/> — `nz_save` keeps it.
	/// </summary>
	public static bool SetAmbush( string link, string enemy )
	{
		var room = Cfg?.Rooms?.FirstOrDefault( r => r is not null && DoorLinks.Same( r.Link, link ) );
		if ( room is null ) return false;

		enemy = enemy?.Trim().ToLowerInvariant() ?? "";
		if ( enemy.Length > 0 && !SpecialEnemies.IsKnown( enemy ) ) return false;

		room.Ambush = enemy;
		return true;
	}

	/// <summary>The names this machine's player has been shown this game — each one's first time plays the flourish.</summary>
	static HashSet<string> Visited => _visited ??= new( StringComparer.Ordinal );
	static HashSet<string> _visited;

	/// <summary>The room this machine's player is visiting for the first time this game, or null; and since when.</summary>
	public static string FirstVisit { get; private set; }
	public static TimeSince SinceFirstVisit { get; private set; }

	/// <summary>
	/// THE FIRST VISIT — *"only the first time each player enters a room"* (2026-09-27). Called by `RoomNameHud` every frame the
	/// name is on screen: a name this player has not been shown this game starts the flourish (its subtitle, the diamonds) and
	/// plays the map's room sound (`Gameplay.RoomSound`), to this player alone. ⚠️ ONLY WHILE THE NAME IS SHOWN, so the lobby
	/// and the game-over screen spend nothing; a new game forgets them all (<see cref="Restart"/>).
	/// </summary>
	public static void NoteShown( string name )
	{
		if ( string.IsNullOrEmpty( name ) || !Visited.Add( name ) ) return;

		FirstVisit = name;
		SinceFirstVisit = 0f;

		var cue = Cfg?.Gameplay?.RoomSound;
		if ( !string.IsNullOrWhiteSpace( cue ) ) NZSound.Play( cue.Trim() );

		var sub = SubtitleOf( name );
		Log.Info( $"[nz-rooms] first visit: {Quoted( name )}" + (sub.Length > 0 ? $" — {sub}" : " (no subtitle)") );
	}

	/// <summary>
	/// Why the name is not on screen now, or null while it is: switched off, the lobby, no player, the game over screen — or the
	/// room has no name to show.
	/// </summary>
	public static string HiddenBecause
	{
		get
		{
			if ( !Enabled ) return "switched off — nz_room_hud 1 shows it";
			if ( NZGame.Mode == GameMode.Lobby || LobbyState.IsOpen?.Invoke() == true ) return "in the lobby";
			if ( !NZPlayer.Local.IsValid() ) return "no player";

			var rm = RoundManager.Instance;
			if ( rm.IsValid() && rm.State == RoundState.GameOver ) return "the game is over";

			if ( CurrentName.Length == 0 )
				return Follower.Shown is not null ? "its room zone has no name — nz_room_zone_name"
					: DoorLinks.IsUnlinked( Current ) ? "the start has no name — nz_room_name 0 <name>" : $"flag {Current} has no name";

			return null;
		}
	}

	// ══ console ══════════════════════════════════════════════════════════════════════════════

	/// <summary>`nz_rooms` — every flag a room can be named for, its name, whether it is open, and where this machine's player is.</summary>
	[ConCmd( "nz_rooms" )]
	public static void ListRooms()
	{
		Log.Info( $"[nz-rooms] start (flag 0): {Quoted( NameOf( DoorLinks.Unlinked ) )}" );

		var flags = MapFlags();
		foreach ( var (link, barriers) in flags )
			Log.Info( $"[nz-rooms] flag {link}: {Quoted( NameOf( link ) )} · {barriers} barrier{(barriers == 1 ? "" : "s")},"
				+ $" {(DoorLinks.IsOpen( link ) ? "open" : "shut")}" );

		if ( flags.Count == 0 ) Log.Info( "[nz-rooms] no door flags on this map's barriers" );

		var pads = Cfg?.Teleporters ?? new List<TeleporterSpot>();
		for ( var n = 0; n < pads.Count; n++ )
			Log.Info( $"[nz-rooms] pad {n}: {PadText( pads[n] )}" );

		Log.Info( $"[nz-rooms] {Cfg?.RoomZones?.Count ?? 0} room zone(s) — nz_room_zones lists them" );

		var why = HiddenBecause;
		Log.Info( $"[nz-rooms] now in {Describe} · {(why is null ? "on screen" : $"hidden: {why}")}" );
	}

	/// <summary>
	/// `nz_room_name &lt;flag&gt; [name...]` — name a flag's room; no name unnames it. Flag 0 is where a game starts.
	///
	/// ⚠️ `params`, as `nz_clue` is, so a name can have spaces: `nz_room_name 3 Lava Pit`. The config in memory — `nz_save`
	/// keeps it.
	/// </summary>
	[ConCmd( "nz_room_name" )]
	public static void Name( params string[] args )
	{
		if ( args is not { Length: > 0 } || string.IsNullOrWhiteSpace( args[0] ) )
		{
			Log.Info( "[nz-rooms] nz_room_name <flag> [name...] — flag 0 is where a game starts; no name unnames it" );
			return;
		}

		var link = DoorLinks.Clean( args[0] );
		var name = string.Join( " ", args.Skip( 1 ) ).Trim();
		SetName( link, name );

		var barriers = DoorLinks.IsUnlinked( link ) ? 0
			: Cfg?.Debris?.Count( d => d is not null && DoorLinks.Same( d.Link, link ) ) ?? 0;

		Log.Info( name.Length == 0
			? $"[nz-rooms] flag {FlagText( link )} has no name now — nz_save keeps it"
			: $"[nz-rooms] flag {FlagText( link )} is '{name}'"
				+ (DoorLinks.IsUnlinked( link ) ? ", on screen as a game starts" : $" · {barriers} barrier{(barriers == 1 ? "" : "s")}")
				+ " — nz_save keeps it" );

		if ( !DoorLinks.IsUnlinked( link ) && barriers == 0 )
			Log.Warning( $"[nz-rooms] ⚠ no barrier carries flag {link}, so nothing walks into it" );
	}

	/// <summary>
	/// `nz_room_subtitle &lt;flag&gt; [text...]` — the line under a named room's name on each player's first visit this game;
	/// no text clears it. Bare, every room's. The flag must be named first (`nz_room_name`). The config in memory — `nz_save`
	/// keeps it.
	/// </summary>
	[ConCmd( "nz_room_subtitle" )]
	public static void SubtitleCmd( params string[] args )
	{
		if ( args is not { Length: > 0 } || string.IsNullOrWhiteSpace( args[0] ) )
		{
			foreach ( var r in Cfg?.Rooms ?? new List<RoomName>() )
			{
				if ( r is null ) continue;
				var line = string.IsNullOrWhiteSpace( r.Subtitle ) ? "no subtitle" : r.Subtitle.Trim();
				Log.Info( $"[nz-rooms] flag {FlagText( r.Link )} {Quoted( r.Name )} — {line}" );
			}

			Log.Info( $"[nz-rooms] {Visited.Count} room(s) visited this game · nz_room_subtitle <flag> [text...] — no text clears it" );
			return;
		}

		var link = DoorLinks.Clean( args[0] );
		var text = string.Join( " ", args.Skip( 1 ) ).Trim();

		if ( !SetSubtitle( link, text ) )
		{
			Log.Warning( $"[nz-rooms] flag {FlagText( link )} has no name — nz_room_name {FlagText( link )} <name> first" );
			return;
		}

		Log.Info( text.Length == 0
			? $"[nz-rooms] flag {FlagText( link )} has no subtitle now — nz_save keeps it"
			: $"[nz-rooms] flag {FlagText( link )} {Quoted( NameOf( link ) )}: {text} — nz_save keeps it" );
	}

	/// <summary>
	/// `nz_room_ambush &lt;flag&gt; [enemy]` — what waits in a named room: that enemy at each of the flag's special spawns, the first
	/// time it opens in a game (`FlagAmbush`); no enemy clears it. Bare, every room's. The config in memory — `nz_save` keeps it.
	/// `nz_ambush <flag>` springs it now.
	/// </summary>
	[ConCmd( "nz_room_ambush" )]
	public static void AmbushCmd( string flag = "", string enemy = "" )
	{
		if ( string.IsNullOrWhiteSpace( flag ) )
		{
			foreach ( var r in Cfg?.Rooms ?? new List<RoomName>() )
				if ( r is not null )
					Log.Info( $"[nz-rooms] flag {FlagText( r.Link )} {Quoted( r.Name )} — "
						+ (string.IsNullOrWhiteSpace( r.Ambush ) ? "no ambush" : $"{r.Ambush.Trim()} at each of {FlagAmbush.SpawnsOf( r.Link ).Count} special spawn(s)") );

			Log.Info( $"[nz-rooms] nz_room_ambush <flag> [enemy] — {string.Join( ", ", SpecialEnemies.Names )} · no enemy clears it" );
			return;
		}

		var link = DoorLinks.Clean( flag );

		if ( !SetAmbush( link, enemy ) )
		{
			Log.Warning( string.IsNullOrWhiteSpace( NameOf( link ) )
				? $"[nz-rooms] flag {FlagText( link )} has no name — nz_room_name {FlagText( link )} <name> first"
				: $"[nz-rooms] '{enemy}' is not an enemy that spawns — one of {string.Join( ", ", SpecialEnemies.Names )}" );
			return;
		}

		var what = AmbushFor( link );
		Log.Info( what.Length == 0
			? $"[nz-rooms] flag {FlagText( link )} {Quoted( NameOf( link ) )} holds nothing now — nz_save keeps it"
			: $"[nz-rooms] flag {FlagText( link )} {Quoted( NameOf( link ) )}: {what} at each of {FlagAmbush.SpawnsOf( link ).Count}"
				+ " special spawn(s), the first time it opens — nz_save keeps it" );
	}

	/// <summary>
	/// `nz_room_zone_subtitle &lt;zone&gt; [text...]` — a room zone's subtitle, for a zone whose name no flag's room has; no text
	/// clears it. `nz_room_zones` numbers them. The config in memory, every machine told — `nz_save` keeps it.
	/// </summary>
	[ConCmd( "nz_room_zone_subtitle" )]
	public static void ZoneSubtitleCmd( params string[] args )
	{
		var zones = Cfg?.RoomZones;
		if ( args is not { Length: > 0 } || !int.TryParse( args[0], out var i ) || zones is null || i < 0 || i >= zones.Count
			|| zones[i] is null )
		{
			Log.Info( "[nz-rooms] nz_room_zone_subtitle <zone> [text...] — nz_room_zones numbers them; no text clears it" );
			return;
		}

		zones[i].Subtitle = string.Join( " ", args.Skip( 1 ) ).Trim();
		RoomZones.Sync();

		var line = zones[i].Subtitle.Length == 0 ? "no subtitle" : zones[i].Subtitle;
		Log.Info( $"[nz-rooms] zone {i} {Quoted( zones[i].Name?.Trim() )}: {line} — nz_save keeps it" );
	}

	/// <summary>
	/// `nz_room [flag]` — where this machine's player is, and whether the top left shows it. With a flag, puts that flag's name
	/// up as though its doorway had just been walked through, to see the HUD without walking; `nz_room 0` is the start.
	/// </summary>
	[ConCmd( "nz_room" )]
	public static void Here( string flag = "" )
	{
		if ( !string.IsNullOrWhiteSpace( flag ) )
		{
			if ( DoorLinks.IsUnlinked( flag ) ) Restart( "nz_room 0" );
			else Enter( flag, "nz_room" );
		}

		var why = HiddenBecause;
		Log.Info( $"[nz-rooms] in {Describe} · {(why is null ? "on screen" : $"hidden: {why}")}" );
	}

	/// <summary>`nz_room_hud [0|1]` — the name on screen, or not. The rooms are followed either way.</summary>
	[ConCmd( "nz_room_hud" )]
	public static void Hud( int on = -1 )
	{
		if ( on >= 0 ) Enabled = on != 0;

		var why = HiddenBecause;
		Log.Info( $"[nz-rooms] the room name is {(Enabled ? "on" : "OFF")} · {(why is null ? "on screen" : $"hidden: {why}")}" );
	}

	/// <summary>
	/// `nz_room_pad [index] [flag]` — which room a teleporter's riders arrive in: a flag, 0 for where a game starts, or `-` and
	/// the name stays as it was. Bare, every pad; an index alone, that one. The config in memory — `nz_save` keeps it.
	/// </summary>
	[ConCmd( "nz_room_pad" )]
	public static void Pad( params string[] args )
	{
		var pads = Cfg?.Teleporters ?? new List<TeleporterSpot>();
		if ( pads.Count == 0 ) { Log.Info( "[nz-rooms] no teleporters on this map" ); return; }

		if ( args is not { Length: > 0 } )
		{
			for ( var n = 0; n < pads.Count; n++ ) Log.Info( $"[nz-rooms] pad {n}: {PadText( pads[n] )}" );
			Log.Info( "[nz-rooms] nz_room_pad <index> <flag> — 0 is the start, - leaves the name alone" );
			return;
		}

		if ( !int.TryParse( args[0], out var i ) || i < 0 || i >= pads.Count )
		{
			Log.Warning( $"[nz-rooms] no pad '{args[0]}' — there are {pads.Count} (0 to {pads.Count - 1})" );
			return;
		}

		if ( args.Length > 1 ) SetArrival( pads[i], args[1] );
		Log.Info( $"[nz-rooms] pad {i}: {PadText( pads[i] )}{(args.Length > 1 ? " — nz_save keeps it" : "")}" );
	}

	/// <summary>
	/// `nz_room_selftest` — the walk-through test, against made-up barriers (straight through, a quick step over a thin one, into
	/// it, standing in it, out of it, past it, a floor above and below, a jump, a turned one, a footprint), then against this
	/// map's own door barriers, each walked through its middle along its thin side. Changes nothing; needs no game.
	/// </summary>
	[ConCmd( "nz_room_selftest" )]
	public static void SelfTest()
	{
		int pass = 0, fail = 0;
		void Check( string what, bool ok )
		{
			if ( ok ) pass++;
			else { fail++; Log.Warning( $"[nz-rooms] FAIL: {what}" ); }
		}

		// a doorway barrier 8u thick, 160 wide and 180 tall, standing on a floor at z 0 — its Position is its middle
		var thin = new Debris { Position = new Vector3( 0f, 0f, 90f ), Size = new Vector3( 8f, 160f, 180f ) };
		Check( "a walk straight through", Crosses( thin, new Vector3( -40f, 0f, 0f ), new Vector3( 40f, 0f, 0f ) ) );
		Check( "a quick 20u step over it", Crosses( thin, new Vector3( -10f, 0f, 0f ), new Vector3( 10f, 0f, 0f ) ) );
		Check( "a step into it", Crosses( thin, new Vector3( -6f, 0f, 0f ), new Vector3( 0f, 0f, 0f ) ) );
		Check( "a jump through it", Crosses( thin, new Vector3( -40f, 0f, 40f ), new Vector3( 40f, 0f, 40f ) ) );
		Check( "not: standing in it", !Crosses( thin, new Vector3( 0f, 0f, 0f ), new Vector3( 1f, 0f, 0f ) ) );
		Check( "not: walking out of it", !Crosses( thin, new Vector3( 0f, 0f, 0f ), new Vector3( 20f, 0f, 0f ) ) );
		Check( "not: walking past its end", !Crosses( thin, new Vector3( -40f, 100f, 0f ), new Vector3( 40f, 100f, 0f ) ) );
		Check( "not: the floor above it", !Crosses( thin, new Vector3( -40f, 0f, 200f ), new Vector3( 40f, 0f, 200f ) ) );
		Check( "not: the floor below it", !Crosses( thin, new Vector3( -40f, 0f, -80f ), new Vector3( 40f, 0f, -80f ) ) );

		// the same barrier turned a quarter: 8u thick along y now, 160 wide along x
		var turned = new Debris { Position = new Vector3( 0f, 0f, 90f ), Size = new Vector3( 8f, 160f, 180f ), Yaw = 90f };
		Check( "turned: through it along y", Crosses( turned, new Vector3( 0f, -40f, 0f ), new Vector3( 0f, 40f, 0f ) ) );
		Check( "turned: not beside it along x", !Crosses( turned, new Vector3( -100f, 30f, 0f ), new Vector3( 100f, 30f, 0f ) ) );

		// a drawn footprint, a slanted quad in the barrier's own space
		var drawn = new Debris
		{
			Position = new Vector3( 0f, 0f, 90f ), Size = new Vector3( 40f, 160f, 180f ),
			Footprint = new List<Vector2> { new( -20f, -80f ), new( -4f, -80f ), new( 20f, 80f ), new( 4f, 80f ) },
		};
		Check( "footprint: through its middle", Crosses( drawn, new Vector3( -40f, 0f, 0f ), new Vector3( 40f, 0f, 0f ) ) );
		Check( "footprint: not through the box corner it leaves out",
			!Crosses( drawn, new Vector3( 12f, -100f, 0f ), new Vector3( 12f, -60f, 0f ) ) );

		// room zones: two side by side with a gap between, a small one inside the first, one with no name, a thin one, one upstairs
		var zoneA = new RoomZone { Position = new Vector3( 0f, 0f, 90f ), Size = new Vector3( 400f, 400f, 180f ), Name = "A" };
		var zoneB = new RoomZone { Position = new Vector3( 500f, 0f, 90f ), Size = new Vector3( 400f, 400f, 180f ), Name = "B" };
		var zoneC = new RoomZone { Position = new Vector3( 0f, 0f, 90f ), Size = new Vector3( 100f, 100f, 180f ), Name = "C" };
		var zoneD = new RoomZone { Position = new Vector3( 500f, 150f, 90f ), Size = new Vector3( 100f, 100f, 180f ) };
		var zoneE = new RoomZone { Position = new Vector3( 1000f, 0f, 90f ), Size = new Vector3( 8f, 400f, 180f ), Name = "E" };
		var zoneF = new RoomZone { Position = new Vector3( 1500f, 0f, 390f ), Size = new Vector3( 400f, 400f, 180f ), Name = "F" };
		var zoneList = new List<RoomZone> { zoneA, zoneB, zoneC, zoneD, zoneE, zoneF };
		var follow = new ZoneFollower();

		follow.Step( zoneList, new Vector3( -150f, 0f, 0f ), new Vector3( -150f, 0f, 0f ) );
		Check( "zones: standing in one shows it", follow.Shown == zoneA );
		follow.Step( zoneList, new Vector3( -150f, 0f, 0f ), new Vector3( 500f, 150f, 0f ) );
		Check( "zones: walking into the next shows it, and one with no name changes nothing",
			follow.Shown == zoneB && follow.Inside.Contains( zoneD ) );
		follow.Step( zoneList, new Vector3( 500f, 150f, 0f ), new Vector3( 250f, 0f, 0f ) );
		Check( "zones: out of every zone, the name stays", follow.Shown == zoneB && follow.Inside.Count == 0 );
		follow.Step( zoneList, new Vector3( 250f, 0f, 0f ), new Vector3( -150f, 0f, 0f ) );
		Check( "zones: walking back the way you came shows the first again", follow.Shown == zoneA );
		follow.Step( zoneList, new Vector3( -150f, 0f, 0f ), new Vector3( 0f, 0f, 0f ) );
		Check( "zones: into a small one inside it shows the small one", follow.Shown == zoneC );
		follow.Step( zoneList, new Vector3( 0f, 0f, 0f ), new Vector3( -150f, 0f, 0f ) );
		Check( "zones: back out of the small one shows the big one again", follow.Shown == zoneA );
		follow.Step( zoneList, new Vector3( -150f, 0f, 0f ), new Vector3( 940f, 0f, 0f ) );
		follow.Step( zoneList, new Vector3( 940f, 0f, 0f ), new Vector3( 1060f, 0f, 0f ) );
		Check( "zones: a quick step through a thin one shows it", follow.Shown == zoneE );
		follow.Step( zoneList, new Vector3( 1060f, 0f, 0f ), new Vector3( 1180f, 0f, 0f ) );
		follow.Step( zoneList, new Vector3( 1180f, 0f, 0f ), new Vector3( 1500f, 0f, 0f ) );
		Check( "zones: not one on the floor above", follow.Shown == zoneE );
		follow.Step( zoneList, new Vector3( 500f, 0f, 0f ), new Vector3( 500f, 0f, 0f ) );
		Check( "zones: a teleport that lands in one shows it", follow.Shown == zoneB );
		follow.Step( new List<RoomZone> { zoneA }, new Vector3( 500f, 0f, 0f ), new Vector3( 500f, 0f, 0f ) );
		Check( "zones: one removed is forgotten", follow.Shown is null && follow.Inside.Count == 0 );

		var slanted = new RoomZone
		{
			Position = new Vector3( 0f, 0f, 90f ), Size = new Vector3( 40f, 160f, 180f ), Name = "S",
			Footprint = new List<Vector2> { new( -20f, -80f ), new( -4f, -80f ), new( 20f, 80f ), new( 4f, 80f ) },
		};
		Check( "zones: a drawn outline holds its middle", slanted.Contains( new Vector3( 0f, 0f, 90f ) ) );
		Check( "zones: not the box corner the outline leaves out", !slanted.Contains( new Vector3( 12f, -70f, 90f ) ) );

		// a teleporter's far end: its riders fan out 24u, 48u either side of B
		var pad = new TeleporterSpot { A = Vector3.Zero, B = new Vector3( 1000f, 0f, 0f ), SetsRoom = true };
		Check( "a pad: landing at its far end", LandsAt( pad, new Vector3( 1000f, 0f, 0f ) ) );
		Check( "a pad: the fourth rider, 48u aside", LandsAt( pad, new Vector3( 1000f, 48f, 0f ) ) );
		Check( "not: 300u from a pad's far end", !LandsAt( pad, new Vector3( 1300f, 0f, 0f ) ) );
		Check( "not: a pad's far end, the floor above", !LandsAt( pad, new Vector3( 1000f, 0f, 200f ) ) );
		Check( "not: a pad that leaves the name alone", !LandsAt( new TeleporterSpot { B = pad.B }, pad.B ) );

		var named = new TeleporterSpot();
		SetArrival( named, "0" );
		Check( "a pad set to 0 names the start", named.SetsRoom && DoorLinks.IsUnlinked( named.ArrivalRoom ) );
		SetArrival( named, "-" );
		Check( "a pad set to - leaves the name alone", !named.SetsRoom );

		// this map's own door barriers, each walked through its middle along its thin side, on the floor it stands on
		int walked = 0;
		foreach ( var d in Cfg?.Debris ?? new List<Debris>() )
		{
			if ( d is null || DoorLinks.IsUnlinked( d.Link ) || DebrisManager.ShapesNav( d ) || !d.IsBlock ) continue;

			var local = d.Size.x <= d.Size.y ? Vector3.Forward : Vector3.Left;
			var reach = MathF.Min( d.Size.x, d.Size.y ) * 0.5f + 32f;
			var centre = d.HasFootprint
				? new Vector3( d.Footprint.Average( p => p.x ), d.Footprint.Average( p => p.y ), 0f )
				: Vector3.Zero;

			var mid = d.Position + d.Rotation * centre;
			var feet = mid.WithZ( d.Position.z - d.Size.z * 0.5f );
			var dir = d.Rotation * local;

			walked++;
			Check( $"flag {d.Link} barrier at {d.Position.x:0},{d.Position.y:0}: walked through its middle",
				Crosses( d, feet - dir * reach, feet + dir * reach ) );
		}

		Log.Info( $"[nz-rooms] selftest: {pass} passed, {fail} failed"
			+ (walked > 0 ? $" — {walked} of this map's door barriers walked through" : " — no door barriers loaded to walk") );
	}
}