Doors/DoorLinks.cs

Manages runtime door/link flags for nZombies map progression. It tracks which named links (strings) are opened, normalises link names, provides queries (IsOpen, AnyOpen, Same), mutators (Open, Close, Reset), and user-facing summaries.

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

namespace NZombies;

/// <summary>
/// DOORS/LINKS — which parts of the map are unlocked.
///
/// A "link" (flag) is a NAME shared between a barrier and the things it opens
/// up. Buy the debris carrying flag "upstairs", and every zombie spawn tagged
/// "upstairs" becomes eligible. That is the entire map-progression mechanic in
/// nZombies: the horde grows because you opened its spawns, not because a round
/// counter said so.
///
/// Ported from nzDoors.OpenedLinks (sh_door_meta_accessors.lua:5) and its
/// IsLinkOpened / OpenLinkedDoors pair.
///
/// ⚠️ RUNTIME STATE, NOT CONFIG. What links exist is authoring data and lives
/// in MapConfig; which are currently OPEN is per-game and must reset when a
/// game does. Saving it into the config would ship a half-opened map.
///
/// ⚠️ FLAGS ARE STRINGS, and BLANK means unlinked — always open. They were ints
/// with 0 as the sentinel; names are what a map author actually thinks in
/// ("power_room", not "7"), and the numeric form is still valid because "3" is
/// a perfectly good name. The original uses nil for unlinked
/// (`v.link == nil or nzDoors:IsLinkOpened(...)`); blank is that idea in a form
/// that survives JSON.
/// </summary>
public static class DoorLinks
{
	/// <summary>⚠️ Case-insensitive. "Upstairs" and "upstairs" being different
	/// flags is a trap with no upside — you would place two barriers, open one,
	/// and never find out why the other stayed shut.</summary>
	static readonly HashSet<string> _open = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>No flag — always open.</summary>
	public const string Unlinked = "";

	/// <summary>
	/// Blank, whitespace, null — and "0" — all mean unlinked.
	///
	/// ⚠️ "0" IS NOT A FLAG NAME. It was the sentinel for the whole life of the
	/// int form, it is what the original means by no link, and it is what
	/// anyone used to this will type into the field. Treating it as a flag
	/// called "zero" would make a spawn tagged 0 wait for a door that can never
	/// be bought — silently, since nothing would ever open it.
	/// </summary>
	public static bool IsUnlinked( string link )
		=> string.IsNullOrWhiteSpace( link ) || link.Trim() == "0";

	/// <summary>
	/// Normalise a typed flag.
	///
	/// Trims, so " door " and "door" cannot become two flags through a text
	/// box, and collapses every unlinked spelling to blank — otherwise "0" and
	/// "" would be two distinct keys that both mean nothing.
	/// </summary>
	public static string Clean( string link )
		=> IsUnlinked( link ) ? Unlinked : link.Trim();

	/// <summary>Is this flag open? An unlinked thing is always open.</summary>
	public static bool IsOpen( string link )
		=> IsUnlinked( link ) || _open.Contains( Clean( link ) );

	/// <summary>
	/// Is a thing with up to three flags reachable?
	///
	/// ⚠️ ANY, not ALL — matches the original's `link == nil or
	/// IsLinkOpened(link) or IsLinkOpened(link2) or IsLinkOpened(link3)`
	/// (nz_spawn_zombie.lua:259). A spawn on the boundary of two areas should
	/// wake up as soon as EITHER is opened.
	/// </summary>
	public static bool AnyOpen( string link, string link2, string link3 )
	{
		// Unlinked on the primary means "no gating at all", regardless of what
		// the other two say.
		if ( IsUnlinked( link ) ) return true;

		return IsOpen( link )
			|| (!IsUnlinked( link2 ) && IsOpen( link2 ))
			|| (!IsUnlinked( link3 ) && IsOpen( link3 ));
	}

	/// <summary>Open a flag. False if it was already open.</summary>
	public static bool Open( string link )
	{
		if ( IsUnlinked( link ) ) return false;
		if ( !_open.Add( Clean( link ) ) ) return false;

		Log.Info( $"[nz] flag '{Clean( link )}' opened" );
		return true;
	}

	/// <summary>Close a flag again — for testing, and for the original's
	/// CloseLinkedDoors.</summary>
	public static bool Close( string link ) => _open.Remove( Clean( link ) );

	/// <summary>Do two flags refer to the same thing?</summary>
	public static bool Same( string a, string b )
		=> string.Equals( Clean( a ), Clean( b ), StringComparison.OrdinalIgnoreCase );

	/// <summary>Everything locked again. Called when a game starts.</summary>
	public static void Reset()
	{
		if ( _open.Count > 0 )
			Log.Info( $"[nz] links reset ({_open.Count} were open)" );

		_open.Clear();
	}

	public static IReadOnlyCollection<string> Opened => _open;

	public static string Summary => _open.Count == 0
		? "none open"
		: string.Join( ", ", _open.OrderBy( x => x ) );

	/// <summary>How a flag reads in the UI and logs.</summary>
	public static string Display( string link )
		=> IsUnlinked( link ) ? "(none)" : Clean( link );
}