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.
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 <flag> [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 <flag> [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 <flag> [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 <zone> [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") );
}
}