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.
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 );
}