Game/LevelMap.cs
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

namespace BlockParty;

/// <summary>
/// One node on the level-select map: a level plus its position and connectivity. Positions are in
/// 1080-reference UI pixels with +Y pointing UP (like the game world); the level-select screen flips
/// Y when it lays the nodes out. A node unlocks once its <see cref="Predecessor"/> is beaten (the
/// starter node has none, so it is always selectable); see <see cref="Selectable"/> for the
/// self-healing extension of that rule.
/// </summary>
public sealed class MapNode
{
	/// <summary>The level this node launches (see <see cref="LevelDef.Id"/>).</summary>
	public string LevelId { get; init; }

	/// <summary>Major nodes form the single upward spine and render larger; side nodes hang off a
	/// major in a small grid and render smaller.</summary>
	public bool IsMajor { get; init; }

	/// <summary>Map-space centre, 1080-ref px, +Y up.</summary>
	public Vector2 Pos { get; init; }

	/// <summary>The spine major this node hangs off (itself for majors).</summary>
	public MapNode Major { get; set; }

	/// <summary>Grid cell relative to the owning major ((0,0) is the major itself; +Y up). One cell
	/// is SIDE_SPACING_X × SIDE_SPACING_Y; column 0 is reserved for the spine.</summary>
	public int CellX { get; init; }
	public int CellY { get; init; }

	/// <summary>The node that must be beaten to unlock this one (null on the starter node, which is
	/// always selectable). Also defines the connecting line drawn to this node.</summary>
	public MapNode Predecessor { get; set; }

	// Navigation targets (null = wobble at that edge), derived at query time from the predecessor
	// links — exactly the pairs the map draws lines between — so navigation can never disagree with
	// the drawn connections (and there's no cached wiring to go stale, e.g. across a hotload).
	// Majors chain Up/Down along the spine and step Left/Right into their side grids; side nodes
	// step only along drawn connections — grid-adjacent cells with no connecting line are dead
	// ends — and side groups of different majors never link directly.
	public MapNode Up => ConnectedNeighbour( 0, 1 );
	public MapNode Down => ConnectedNeighbour( 0, -1 );
	public MapNode Left => ConnectedNeighbour( -1, 0 );
	public MapNode Right => ConnectedNeighbour( 1, 0 );

	/// <summary>The node one connection line away in direction (<paramref name="dx"/>,<paramref name="dy"/>):
	/// the predecessor when it lies that way, else the successor whose predecessor is this node. Each
	/// direction holds at most one connected node (cells are unique; the spine is a single chain).</summary>
	private MapNode ConnectedNeighbour( int dx, int dy )
	{
		if ( Predecessor is not null && DirectionTo( Predecessor ) == (dx, dy) )
			return Predecessor;
		foreach ( var n in LevelMap.Nodes )
			if ( n.Predecessor == this && n.DirectionTo( this ) == (-dx, -dy) )
				return n;
		return null;
	}

	/// <summary>Unit step from this node toward a CONNECTED node: spine pairs are vertical (majors
	/// all sit on column 0, so their cell delta is useless); side pairs use their grid cells (a
	/// major sits at (0,0) of its own group).</summary>
	private (int dx, int dy) DirectionTo( MapNode other )
		=> IsMajor && other.IsMajor
			? (0, System.Math.Sign( other.Pos.y - Pos.y ))
			: (other.CellX - CellX, other.CellY - CellY);

	public LevelDef Level => Levels.Get( LevelId );
	public string Name => Level?.Name ?? LevelId;

	/// <summary>Short ordinal tag shown before the level name and in the preview corner. Major (spine)
	/// levels are numbered "1", "2", "3"…; side levels are the major's number plus a letter ("1A", "1B"…),
	/// lettered in the major's authored Sides order (EDIT mode appends, so creation order).</summary>
	public string Tag { get; set; } = "";

	/// <summary>Whether the player has beaten this level (drives the node's brightness tier).</summary>
	public bool Beaten => LevelProgress.IsBeaten( LevelId );

	/// <summary>Whether the level can be entered now. The starter node is always selectable; every
	/// other node requires its predecessor to be beaten. Self-healing: a node on the unlock chain of
	/// any beaten node (itself included) is also selectable — beating a level proves the player
	/// already traversed everything before it, so a beaten set with holes (cloud-restored progress,
	/// a hand-edited save, a map edit under an old profile) never leaves reachable progress locked.</summary>
	public bool Selectable
	{
		get
		{
			if ( Predecessor is null || Predecessor.Beaten )
				return true;

			foreach ( var n in LevelMap.Nodes )
				if ( n.Beaten && OnPredecessorChainOf( n ) )
					return true;

			return false;
		}
	}

	/// <summary>True if this node is <paramref name="node"/> or one of its transitive predecessors.</summary>
	private bool OnPredecessorChainOf( MapNode node )
	{
		for ( ; node is not null; node = node.Predecessor )
			if ( node == this )
				return true;
		return false;
	}
}

/// <summary>A drawn connection between a node and its predecessor. The line reads as "traversable"
/// (brighter) once the predecessor is beaten, matching the target node becoming selectable.</summary>
public readonly record struct MapConnection( MapNode From, MapNode To )
{
	/// <summary>Bright when you can progress along it (the target node is selectable — normally
	/// "the source is beaten", but self-healed nodes keep their line lit too, see
	/// <see cref="MapNode.Selectable"/>).</summary>
	public bool Traversable => To.Selectable;
}

/// <summary>
/// The authored level-select map: one linear upward spine of MAJOR levels, each optionally with a
/// grid of smaller SIDE levels hanging off it (any shape — cells to the left/right of the spine can
/// branch up/down as well as outward). This is deliberately a small, hand-authored graph over a
/// curated subset of <see cref="Levels"/> (NOT every level — test/repro levels are intentionally
/// excluded).
///
/// The map is edited in-game: the editor level browser's map view has an EDIT mode (replace a
/// node's level / add a side cell / insert between nodes) that writes <c>Assets/levelmap.json</c> (an array of
/// {LevelId, Sides:[{LevelId, X, Y}]}) via the editor-assembly <c>LevelMapJsonWriter</c>. Hand
/// edits still work: run the <c>reload_map</c> ConCmd afterward. Level <em>layout</em> edits apply
/// live via <see cref="Levels"/> (the <c>reload_levels</c> ConCmd).
/// </summary>
public static class LevelMap
{
	// Layout spacing, 1080-ref px. Node pixel SIZES live in LevelSelectScreen.razor.scss; keep the
	// camera padding in the stage comfortably larger than half a node so nodes never clip the edges.
	// Side cells are a square grid (X == Y spacing) so branched side groups read as a uniform grid;
	// side rows deliberately do NOT align with the 210px major rows.
	public const float MAJOR_SPACING_Y = 210f;
	public const float SIDE_SPACING_X = 170f;
	public const float SIDE_SPACING_Y = 170f;
	// Extra push between the spine and a side group's first column. Majors render wider than side
	// nodes, so without this the major-to-side gap reads tighter than the side-to-side gap; 24px
	// makes both visible gaps equal (170 - 66 - 42 + 24 = 170 - 84 = 86).
	public const float MAJOR_SIDE_EXTRA_X = 32f;


	// The map builds LAZILY on first access and RETRIES until a build finds a spine, rather than
	// eagerly in the static ctor: at boot the static ctor can run BEFORE the mounted filesystem is
	// ready, which would cache an empty map forever. `_built` sticks only once a build finds a spine,
	// so a later access (FS ready) populates it. Same pattern as Levels; getters/ComputeBounds use the
	// backing fields (not the properties) to avoid re-entering EnsureBuilt during a build.
	private static bool _built;
	private static IReadOnlyList<MapNode> _nodes = System.Array.Empty<MapNode>();
	private static IReadOnlyList<string> _orderedLevelIds = System.Array.Empty<string>();
	private static IReadOnlyList<MapConnection> _connections = System.Array.Empty<MapConnection>();
	private static MapNode _first;
	private static float _minX, _maxX, _minY, _maxY;
	private static int _revision;

	/// <summary>Every node, majors and side levels, in build order.</summary>
	public static IReadOnlyList<MapNode> Nodes { get { EnsureBuilt(); return _nodes; } }

	/// <summary>Level ids in level-select / registry order: each major then its left chain then its
	/// right chain (side levels interleaved after their major), de-duplicated. Drives
	/// <see cref="Levels.All"/> ordering.</summary>
	public static IReadOnlyList<string> OrderedLevelIds { get { EnsureBuilt(); return _orderedLevelIds; } }

	/// <summary>Every predecessor connection (one per non-root node) for drawing the lines.</summary>
	public static IReadOnlyList<MapConnection> Connections { get { EnsureBuilt(); return _connections; } }

	/// <summary>The starter node — the default focus and always selectable.</summary>
	public static MapNode First { get { EnsureBuilt(); return _first; } }

	/// <summary>Bumped on every rebuild. UI panels fold this into their BuildHash so an in-place map
	/// edit (which can keep the node COUNT unchanged, e.g. a replace) still repaints.</summary>
	public static int Revision { get { EnsureBuilt(); return _revision; } }

	// Bounds over node centres (1080-ref px), for the camera-follow clamp.
	public static float MinX { get { EnsureBuilt(); return _minX; } }
	public static float MaxX { get { EnsureBuilt(); return _maxX; } }
	public static float MinY { get { EnsureBuilt(); return _minY; } }
	public static float MaxY { get { EnsureBuilt(); return _maxY; } }

	private static void EnsureBuilt()
	{
		if ( !_built ) Build();
	}

	/// <summary>Console command: rebuild the level-select map from <c>Assets/levelmap.json</c> so
	/// spine/side-chain edits apply without an editor restart. Also refreshes the level registry
	/// (<see cref="Levels"/>), since <see cref="Levels.All"/>'s order is derived from the spine \u2014 so a
	/// single <c>reload_map</c> covers both. NOTE: an open level-select screen caches its laid-out
	/// nodes, so re-enter it to see the change.</summary>
	[ConCmd( "reload_map" )]
	public static void ReloadMapCmd()
	{
		if ( !Game.IsEditor ) return;
		Build();
		// Levels.All order is derived from the map spine, so refresh the level registry too — this makes
		// reload_map a one-step command (no need to also run reload_levels after a spine edit).
		Levels.Reload();
		Log.Info( $"[BlockParty] Reloaded level map: {Nodes.Count} nodes." );
	}

	/// <summary>Find the node for a level id, or null if it isn't on the map.</summary>
	public static MapNode Find( string levelId )
	{
		if ( string.IsNullOrEmpty( levelId ) )
			return null;
		foreach ( var n in Nodes )
			if ( n.LevelId == levelId )
				return n;
		return null;
	}

	private static void Build() => BuildFrom( LoadSpine() );

	/// <summary>Rebuild the map from freshly-edited specs and refresh the level registry. Used by the
	/// editor-assembly map writer right after it writes <c>Assets/levelmap.json</c>: the mounted
	/// filesystem can briefly serve the stale file it just replaced, so the writer hands the specs
	/// over directly instead of going through <see cref="ReloadMapCmd"/>'s file read.</summary>
	public static void ApplyEditedSpecs( MapSpecJson[] specs )
	{
		BuildFrom( Sanitize( specs ) );
		Levels.Reload();
	}

	private static void BuildFrom( MapSpecJson[] spine )
	{
		var nodes = new List<MapNode>();
		var connections = new List<MapConnection>();
		MapNode prevMajor = null;

		// Registry order: each major, then its side levels in authored order (side levels interleaved
		// after their major), de-duplicated. Drives Levels.All. Only PLACED nodes register — a side
		// spec skipped as invalid (orphan/occupied) must not leak a node-less id into the registry.
		var order = new List<string>();
		var seenIds = new HashSet<string>();
		void AddId( string id )
		{
			if ( !string.IsNullOrEmpty( id ) && seenIds.Add( id ) )
				order.Add( id );
		}

		for ( int i = 0; i < spine.Length; i++ )
		{
			var spec = spine[i];
			float y = i * MAJOR_SPACING_Y;

			var major = new MapNode { LevelId = spec.LevelId, IsMajor = true, Pos = new Vector2( 0f, y ), Tag = ( i + 1 ).ToString() };
			major.Major = major;
			nodes.Add( major );
			AddId( spec.LevelId );

			// Spine link: beat the major below to unlock this one (Up/Down navigation derives from it).
			if ( prevMajor is not null )
			{
				major.Predecessor = prevMajor;
				connections.Add( new MapConnection( prevMajor, major ) );
			}

			BuildSideGroup( major, spec.Sides, i + 1, nodes, connections, AddId );

			prevMajor = major;
		}

		_nodes = nodes;
		_connections = connections;
		_orderedLevelIds = order;
		_first = nodes.Count > 0 ? nodes[0] : null;
		ComputeBounds();
		_revision++;
		// Only mark built once we actually found a spine, so an early (pre-mount) call retries later.
		_built = _orderedLevelIds.Count > 0;
	}

	/// <summary>Load the spine from <c>Assets/levelmap.json</c> (an array of
	/// <see cref="MapSpecJson"/>). Returns an empty spine (empty map) if the file is absent or
	/// malformed.</summary>
	private static MapSpecJson[] LoadSpine()
	{
		try
		{
			if ( FileSystem.Mounted.FileExists( "levelmap.json" ) )
			{
				var text = FileSystem.Mounted.ReadAllText( "levelmap.json" );
				return Sanitize( Json.Deserialize<MapSpecJson[]>( text ) );
			}

			if ( FileSystem.Mounted.DirectoryExists( "levels" ) )
			{
				// The mount is ready (level files are visible) but the map manifest is missing — a real
				// problem. During early boot (mount not ready) we stay silent and retry, so no log spam.
				Log.Warning( "[BlockParty] no Assets/levelmap.json found — the level-select map will be empty." );
			}
		}
		catch ( System.Exception ex )
		{
			Log.Warning( $"[BlockParty] levelmap.json failed to load ({ex.Message}); the map will be empty." );
		}

		return System.Array.Empty<MapSpecJson>();
	}

	/// <summary>Drop majors with no level id and normalise null Sides lists so the build never has to
	/// null-check. Shared by the file load and the editor's direct <see cref="ApplyEditedSpecs"/>.</summary>
	private static MapSpecJson[] Sanitize( MapSpecJson[] specs )
	{
		if ( specs is not { Length: > 0 } )
			return System.Array.Empty<MapSpecJson>();
		return specs
			.Where( s => !string.IsNullOrEmpty( s?.LevelId ) )
			.Select( s => { s.Sides ??= new List<SideSpecJson>(); return s; } )
			.ToArray();
	}

	/// <summary>Build one major's side-level grid. Each side spec occupies an integer cell relative to
	/// the major (which sits at (0,0); column 0 is reserved for the spine), so a group can be any
	/// shape — chains, stacks, L-bends. A cell's predecessor is its authored From hint when that
	/// neighbour is placed (EDIT mode records which node's + button added the cell, so the drawn
	/// connection follows the button), else a grid-adjacent node placed EARLIER in the Sides list
	/// (preferring the neighbour toward the spine, then toward the major's row), so groups unlock
	/// outward from the major; a spec with no placed neighbour is skipped as an orphan.
	/// Navigation derives from the predecessor links (see <see cref="MapNode.Up"/>), so it follows
	/// exactly the drawn connections.</summary>
	private static void BuildSideGroup( MapNode major, List<SideSpecJson> sides, int majorNumber,
		List<MapNode> nodes, List<MapConnection> connections, System.Action<string> registerId )
	{
		var cells = new Dictionary<(int x, int y), MapNode> { [(0, 0)] = major };
		int letter = 0;
		foreach ( var side in sides )
		{
			if ( string.IsNullOrEmpty( side?.LevelId ) )
				continue;
			if ( side.X == 0 || cells.ContainsKey( (side.X, side.Y) ) )
			{
				Log.Warning( $"[BlockParty] levelmap: side '{side.LevelId}' at ({side.X},{side.Y}) of '{major.LevelId}' is on the spine column or an occupied cell — skipped." );
				continue;
			}

			// The authored hint wins when it points at a placed neighbour; a stale hint (that node was
			// removed or shifted away) just falls back to the automatic preference below.
			var predecessor = System.Math.Abs( side.FromDx ) + System.Math.Abs( side.FromDy ) == 1
				&& cells.TryGetValue( (side.X + side.FromDx, side.Y + side.FromDy), out var hinted )
					? hinted
					: FindPlacedNeighbour( cells, side.X, side.Y );
			if ( predecessor is null )
			{
				Log.Warning( $"[BlockParty] levelmap: side '{side.LevelId}' at ({side.X},{side.Y}) of '{major.LevelId}' touches no earlier node — skipped." );
				continue;
			}

			var node = new MapNode
			{
				LevelId = side.LevelId, IsMajor = false,
				Pos = new Vector2( side.X * SIDE_SPACING_X + System.Math.Sign( side.X ) * MAJOR_SIDE_EXTRA_X,
					major.Pos.y + side.Y * SIDE_SPACING_Y ),
				CellX = side.X, CellY = side.Y,
				Predecessor = predecessor,
				Tag = $"{majorNumber}{LetterFor( letter++ )}",
			};
			node.Major = major;
			cells[(side.X, side.Y)] = node;
			nodes.Add( node );
			registerId( side.LevelId );
			connections.Add( new MapConnection( predecessor, node ) );
		}
	}

	/// <summary>Whether a side-cell set can ALL be placed (every cell reachable from the major at (0,0)
	/// through grid adjacency), and if so in what order. Repeated stable passes over the authored order,
	/// placing any cell that touches an already-placed one — so the returned order preserves the
	/// authored order wherever it was already valid. Used to validate node deletion (a removal must not
	/// strand the cells behind it) and to rewrite the Sides list in a loadable order afterward.
	/// <paramref name="order"/> holds indices into <paramref name="cells"/>.</summary>
	public static bool CanOrderSideCells( IReadOnlyList<(int x, int y)> cells, out List<int> order )
	{
		order = new List<int>( cells.Count );
		var placed = new HashSet<(int x, int y)> { (0, 0) };
		var pending = new List<int>( Enumerable.Range( 0, cells.Count ) );
		bool progress = true;
		while ( pending.Count > 0 && progress )
		{
			progress = false;
			for ( int i = 0; i < pending.Count; )
			{
				var cell = cells[pending[i]];
				bool placeable = cell.x != 0 && !placed.Contains( cell )
					&& ( placed.Contains( (cell.x - 1, cell.y) ) || placed.Contains( (cell.x + 1, cell.y) )
						|| placed.Contains( (cell.x, cell.y - 1) ) || placed.Contains( (cell.x, cell.y + 1) ) );
				if ( !placeable ) { i++; continue; }
				placed.Add( cell );
				order.Add( pending[i] );
				pending.RemoveAt( i );
				progress = true;
			}
		}
		return pending.Count == 0;
	}

	/// <summary>Whether a new side cell can be INSERTED on the predecessor connection between cell
	/// (<paramref name="px"/>,<paramref name="py"/>) — an existing side cell, or (0,0) for the major —
	/// and the occupied cell one (<paramref name="dx"/>,<paramref name="dy"/>) step away. That cell's
	/// half of the group (cells on the same side of the spine, at or beyond it along the connection's
	/// axis) shifts one cell further along (dx,dy) and the new level takes the vacated cell, so the
	/// chain grows by one in place. Fails when a shifted cell would land on the spine column or the
	/// result is no longer placeable. Outputs every original cell's shifted position (same indices as
	/// <paramref name="cells"/>) and a placement order over cells.Count + 1 entries, where index
	/// cells.Count is the inserted cell. Shared by the editor map writer (<c>map_insert_side</c>) and
	/// the EDIT-mode UI, so the + only shows where the insert will actually succeed.</summary>
	public static bool CanInsertSideCell( IReadOnlyList<(int x, int y)> cells, int px, int py, int dx, int dy,
		out List<(int x, int y)> shifted, out List<int> order )
	{
		shifted = null;
		order = null;
		int cx = px + dx, cy = py + dy;
		if ( System.Math.Abs( dx ) + System.Math.Abs( dy ) != 1 || cx == 0 )
			return false;
		if ( !cells.Contains( (cx, cy) ) || ( (px, py) != (0, 0) && !cells.Contains( (px, py) ) ) )
			return false;

		// The half of the group that moves: same side of the spine as the target cell, at or beyond it
		// along the connection's axis. Shifting a whole half-plane preserves its internal adjacency and
		// can't collide with the cells that stay; the new cell re-bridges the one connection the shift
		// split, and CanOrderSideCells below catches any OTHER split adjacency left stranded.
		int side = System.Math.Sign( cx );
		shifted = new List<(int x, int y)>( cells.Count );
		foreach ( var cell in cells )
		{
			bool moves = System.Math.Sign( cell.x ) == side
				&& ( dx != 0 ? dx * cell.x >= dx * cx : dy * cell.y >= dy * cy );
			var moved = moves ? (x: cell.x + dx, y: cell.y + dy) : cell;
			if ( moved.x == 0 )
				return false;   // shifted onto the spine column
			shifted.Add( moved );
		}

		var combined = new List<(int x, int y)>( shifted ) { (cx, cy) };
		return CanOrderSideCells( combined, out order );
	}

	/// <summary>Whether the side cell at (<paramref name="cx"/>,<paramref name="cy"/>) can be REMOVED,
	/// closing any gap it leaves — the inverse of <see cref="CanInsertSideCell"/>'s insert shift.
	/// Tries no shift first (a leaf — the rest is placeable untouched), then the removed node's own
	/// predecessor axis (<paramref name="pdx"/>,<paramref name="pdy"/>) — a unit step from its
	/// predecessor toward it, (0,0) when unknown — so a junction closes along the connection the map
	/// draws, then the remaining axes in <see cref="FindPlacedNeighbour"/> preference order: the half
	/// of the group at or beyond the vacated cell's outward neighbour (same side of the spine, along
	/// that axis) slides one cell back into the gap. The preference only reorders the candidates — the
	/// SET tried is fixed, so whether removal is possible (the EDIT-mode X) never depends on how a
	/// caller derived the predecessor. Unlike the outward insert shift, pulling INWARD can collide — a
	/// candidate is rejected when a slid cell would land on the spine column or a cell that stays, or
	/// the result is no longer placeable. <paramref name="cells"/> is the group WITHOUT the removed
	/// cell; <paramref name="shifted"/> holds every remaining cell's new position (same indices) and
	/// <paramref name="order"/> a placement order over them. Shared by the editor map writer
	/// (<c>map_remove_level</c>) and the EDIT-mode UI, so the X only shows where the delete will
	/// actually succeed.</summary>
	public static bool CanRemoveSideCell( IReadOnlyList<(int x, int y)> cells, int cx, int cy,
		int pdx, int pdy, out List<(int x, int y)> shifted, out List<int> order )
	{
		if ( CanOrderSideCells( cells, out order ) )
		{
			shifted = new List<(int x, int y)>( cells );
			return true;
		}

		int side = System.Math.Sign( cx );
		var candidates = new (int dx, int dy)[]
			{ ( pdx, pdy ), ( side, 0 ), ( 0, System.Math.Sign( cy ) ), ( 0, 1 ), ( 0, -1 ), ( -side, 0 ) };
		var tried = new HashSet<(int dx, int dy)>();
		foreach ( var d in candidates )
		{
			if ( System.Math.Abs( d.dx ) + System.Math.Abs( d.dy ) != 1 || !tried.Add( d ) )
				continue;

			// Split the group against the half-plane at or beyond the vacated cell's outward
			// neighbour along this axis — the same half CanInsertSideCell pushes out, pulled back.
			int bx = cx + d.dx, by = cy + d.dy;
			var stays = new HashSet<(int x, int y)>();
			foreach ( var cell in cells )
				if ( System.Math.Sign( cell.x ) != side
					|| ( d.dx != 0 ? d.dx * cell.x < d.dx * bx : d.dy * cell.y < d.dy * by ) )
					stays.Add( cell );

			shifted = new List<(int x, int y)>( cells.Count );
			foreach ( var cell in cells )
			{
				var moved = stays.Contains( cell ) ? cell : (x: cell.x - d.dx, y: cell.y - d.dy);
				if ( moved != cell && ( moved.x == 0 || stays.Contains( moved ) ) )
				{
					shifted = null;   // pulled onto the spine column or a cell that stays
					break;
				}
				shifted.Add( moved );
			}

			if ( shifted is not null && CanOrderSideCells( shifted, out order ) )
				return true;
		}

		shifted = null;
		order = null;
		return false;
	}

	/// <summary>The already-placed node grid-adjacent to cell (<paramref name="x"/>,<paramref name="y"/>)
	/// that should act as its predecessor: prefer one step toward the spine, then one step toward the
	/// major's row, then the remaining neighbours — so a straight outward chain unlocks outward exactly
	/// like the old left/right chains did.</summary>
	private static MapNode FindPlacedNeighbour( Dictionary<(int x, int y), MapNode> cells, int x, int y )
	{
		var candidates = new (int x, int y)[]
		{
			( x - System.Math.Sign( x ), y ),   // toward the spine
			( x, y - System.Math.Sign( y ) ),   // toward the major's row (self when y == 0; skipped below)
			( x + System.Math.Sign( x ), y ),   // away from the spine
			( x, y - 1 ),
			( x, y + 1 ),
		};
		foreach ( var cell in candidates )
			if ( cell != (x, y) && cells.TryGetValue( cell, out var node ) )
				return node;
		return null;
	}

	/// <summary>Map a 0-based index to a spreadsheet-style letter suffix (0→A … 25→Z, 26→AA …). Side
	/// chains are short so the single-letter case is the norm; the roll-over just keeps tags unique.</summary>
	private static string LetterFor( int index )
	{
		var sb = new System.Text.StringBuilder();
		index++; // shift to 1-based for bijective base-26
		while ( index > 0 )
		{
			index--;
			sb.Insert( 0, (char)( 'A' + index % 26 ) );
			index /= 26;
		}
		return sb.ToString();
	}

	private static void ComputeBounds()
	{
		_minX = _maxX = _minY = _maxY = 0f;
		bool first = true;
		foreach ( var n in _nodes )
		{
			if ( first )
			{
				_minX = _maxX = n.Pos.x;
				_minY = _maxY = n.Pos.y;
				first = false;
				continue;
			}
			_minX = System.Math.Min( _minX, n.Pos.x );
			_maxX = System.Math.Max( _maxX, n.Pos.x );
			_minY = System.Math.Min( _minY, n.Pos.y );
			_maxY = System.Math.Max( _maxY, n.Pos.y );
		}
	}
}

/// <summary>The on-disk JSON shape of one level-map spine entry (<c>Assets/levelmap.json</c> is an
/// array of these): a major level id plus its side-level grid cells. Written by the editor's map
/// EDIT mode (see <c>LevelMapJsonWriter</c>); hand edits need a <c>reload_map</c> afterward.</summary>
public sealed class MapSpecJson
{
	public string LevelId { get; set; }
	public List<SideSpecJson> Sides { get; set; }
}

/// <summary>One side level: its grid cell relative to the owning major (X≠0 columns of
/// SIDE_SPACING_X, ±Y rows of SIDE_SPACING_Y with +Y up; (0,0) is the major, column 0 the spine).
/// Order in the Sides list matters: it drives the tag letters, and every cell must be grid-adjacent
/// to the major or an earlier cell (its predecessor for unlock gating).</summary>
public sealed class SideSpecJson
{
	public string LevelId { get; set; }
	public int X { get; set; }
	public int Y { get; set; }

	/// <summary>Optional predecessor hint: one unit step from this cell toward the neighbour it
	/// connects from — EDIT mode's + buttons record their anchor node here, so the drawn connection
	/// (and unlock gating) follows the button that was clicked rather than the automatic spine-first
	/// preference. (0,0) or a step onto an unplaced cell means "no hint".</summary>
	[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
	public int FromDx { get; set; }
	[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
	public int FromDy { get; set; }
}