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

namespace BlockParty;

public enum BackgroundDriftBias
{
	None,
	Horizontal,
	Vertical,
	Left,
	Right,
	Down,
	Up,
}

public enum PlayfieldPattern
{
	Checker,
	HorizontalStripes,
	VerticalStripes,
	Diagonal,
	Dots,
	Grid,
	Custom,
}

/// <summary>
/// One playable level: which blocks spawn (an exact list, or N random picks from a pool), the
/// candidate spawn positions, and where the player starts. Levels are static C# data (see
/// <see cref="Levels"/>); the <see cref="Id"/> is the stable identity that replays and per-level
/// leaderboard stats are keyed on, so never rename or reuse an id once runs have been recorded
/// against it (editing a level's layout silently breaks its old replays — the desync check will
/// flag them, but they won't reproduce).
/// </summary>
public sealed class LevelDef
{
	/// <summary>Stable identity, lowercase kebab-case. Baked into replay payloads and leaderboard
	/// stat names.</summary>
	public string Id { get; set; }

	/// <summary>Display name shown on the level-select list and leaderboard filter.</summary>
	public string Name { get; set; }

	/// <summary>Optional filename or extensionless OGG name under <c>Assets/music</c>. Blank uses
	/// <see cref="Audio.DefaultMusic"/>.</summary>
	public string Music { get; set; }

	/// <summary>Mix trim for <see cref="Music"/>, balancing a hotter or quieter track against the rest
	/// of the mix. Multiplies the player's music setting; 1 = the track's own level.</summary>
	public float MusicVolume { get; set; } = 1f;

	/// <summary>Editor-only categorization for non-map levels. Test levels appear in the level
	/// browser's Test view instead of Other. New and unmarked levels default to false.</summary>
	public bool IsTest { get; set; }

	/// <summary>Exact blocks to spawn. When set, <see cref="Pool"/>/<see cref="BlockCount"/> are
	/// ignored. Duplicate types are allowed.</summary>
	public IReadOnlyList<BlockType> Blocks { get; set; }

	/// <summary>Random pool: <see cref="BlockCount"/> entries are drawn without replacement. Duplicate
	/// types must be listed more than once to permit multiple instances. Used only when
	/// <see cref="Blocks"/> is null.</summary>
	public IReadOnlyList<BlockType> Pool { get; set; }

	/// <summary>How many blocks to draw from <see cref="Pool"/>. Must not exceed the pool entry count.
	/// Ignored when <see cref="Blocks"/> is set.</summary>
	public int BlockCount { get; set; }

	/// <summary>Optional per-block starting state for EXACT mode, aligned index-wise with
	/// <see cref="Blocks"/> (entry <c>i</c> configures <c>Blocks[i]</c>). A null list, or a null/default
	/// entry, means the classic "spawn at phase 0, no pressed sides". Lets a level start a block partway
	/// up. Ignored in pool mode (see <see cref="PoolStartPhase"/>).</summary>
	public IReadOnlyList<BlockStart> BlockStarts { get; set; }

	/// <summary>Pool mode only: the starting phase applied to EVERY randomly-drawn block (0 =
	/// classic). Per-instance pressed sides don't map to random draws, so pool mode only supports a
	/// single shared starting phase.</summary>
	public int PoolStartPhase { get; set; }

	/// <summary>Candidate spawn positions (block centres). Must list at least as many as the level
	/// spawns; when there are more, a random subset is chosen (listed order preserved).</summary>
	public IReadOnlyList<Vector2> SpawnPositions { get; set; }

	/// <summary>Optional per-position PINNED BLOCK, aligned index-wise with <see cref="SpawnPositions"/>
	/// (a null entry, or a shorter/absent list, leaves that slot free). A pinned slot ALWAYS spawns
	/// its authored block (exact type, starting phase, pre-pressed sides) — independent of, and in
	/// addition to, the level's random <see cref="Blocks"/>/<see cref="Pool"/> count, which fills
	/// only the free slots. E.g. a phase-2 Bullet enclosed in wall obstacles as a turret.</summary>
	public IReadOnlyList<PinnedBlock> SpawnPins { get; set; }

	/// <summary>Optional per-position enclosed-ability timing, aligned with <see cref="SpawnPositions"/>.
	/// Missing entries use <see cref="Block.DEFAULT_ABILITY_INTERVAL"/> with no randomness or start offset.</summary>
	public IReadOnlyList<BlockAbilityTimer> SpawnAbilityTimers { get; set; }

	/// <summary>Optional initial travel direction per spawn position. Missing entries or
	/// <see cref="Direction.None"/> retain the classic random direction.</summary>
	public IReadOnlyList<Direction> SpawnDirections { get; set; }

	/// <summary>Optional per-position turn rule (see <see cref="TurnMode"/>): never-reverse, or the
	/// CW/CCW track-cycling modes. Null/short entries default to <see cref="TurnMode.Free"/>.</summary>
	public IReadOnlyList<TurnMode> SpawnTurnModes { get; set; }

	/// <summary>Candidate player starts (at least one). A single entry is the classic fixed spawn;
	/// with several, the run picks one from the run Rng at stage enter. Exactly two entries map to
	/// Twin 1 then Twin 2; with three or more, Twins pick two distinct random entries. Selection is
	/// deterministic per seed, and single-spawn levels consume NO extra draw. Defaults to the classic
	/// bottom-left start.</summary>
	public IReadOnlyList<Vector2> PlayerSpawns { get; set; } = new[] { new Vector2( 16, 40 ) };

	/// <summary>Optional character id forced by this level. A known id overrides the player's selected
	/// character when starting the level; null, empty, or an unknown id leaves the selection unchanged.</summary>
	public string ForcedCharacterId { get; set; }

	/// <summary>Solid interior wall regions (logical-space rects). Empty for the classic square arena.
	/// A complex shape is expressed as the union of several rects (e.g. a C-shape = one rect on the
	/// floor). Author responsibility: no obstacle may overlap a block <see cref="SpawnPositions"/> or
	/// <see cref="PlayerSpawns"/>. Not serialized into replays/codes — reconstructed from the level id,
	/// like the block layout.</summary>
	public IReadOnlyList<RectF> Obstacles { get; set; } = System.Array.Empty<RectF>();

	/// <summary>Arena boundary sides (Left/Right/Up/Down) that start the level permanently spiked:
	/// deadly from the first frame, never retract, no warning blink. Every face on a listed side is
	/// spiked — including all segments of a side that a flush obstacle splits (e.g. listing
	/// <see cref="Direction.Down"/> spikes both floor channels either side of a C-shape plateau).</summary>
	public IReadOnlyList<Direction> SpikedWalls { get; set; } = System.Array.Empty<Direction>();

	/// <summary>Permanent spikes on obstacle sides. Each entry names one obstacle rect (matched by
	/// value against <see cref="Obstacles"/>) plus the outward side normals to spike; every face on a
	/// listed side is spiked, including all segments of a side split by a flush neighbour. Author tip:
	/// hoist the rect into a local variable so the identical value is shared with <see cref="Obstacles"/>.</summary>
	public IReadOnlyList<ObstacleSpikeSpec> SpikedObstacleSides { get; set; } = System.Array.Empty<ObstacleSpikeSpec>();

	/// <summary>Optional palette overrides. All null by default, meaning the level keeps the classic
	/// teal/grey look. They only apply while this level is being played (the <see cref="GameStage"/>);
	/// the score screen and every menu revert to the defaults. See <see cref="StageBase"/> /
	/// <see cref="GameStage"/> for where each is consumed.</summary>

	/// <summary>Direct displayed colour of the arena + obstacle wall bands and elbow patches. The
	/// renderer preserves the baked art's alpha and relative shading. Null keeps the default grey.</summary>
	public Color? WallColor { get; set; }

	/// <summary>The out-of-bounds colour: fills the camera pillarbox, the edge-blocker masks and the
	/// interior obstacle fill (all read as the same "outside the arena" space). Null uses the
	/// manager's default clear colour.</summary>
	public Color? OutOfBoundsColor { get; set; }

	/// <summary>Colour of the play-area checkerboard field. When set, the field is drawn from a neutral
	/// (white/grey) baked variant tinted to this colour, giving a subtle two-shade dither in the chosen
	/// hue. Null keeps the default teal checkerboard.</summary>
	public Color? CheckerboardColor { get; set; }

	/// <summary>Optional second colour for the playfield pattern. Null derives the existing subtly
	/// darker shade from <see cref="CheckerboardColor"/>.</summary>
	public Color? CheckerboardSecondColor { get; set; }

	/// <summary>Base colour for the three drifting cosmetic background-block layers; the deeper layers
	/// derive stepped-darker shades from it. Null keeps the default teal shades. Ignored when
	/// <see cref="BackgroundBlockColors"/> is set.</summary>
	public Color? BackgroundBlockColor { get; set; }

	/// <summary>Explicit colours for the three drifting background-block layers (front → back). When
	/// set it must list exactly three; it takes precedence over <see cref="BackgroundBlockColor"/> and
	/// lets a level choose all three shades freely (rather than the auto-derived stepped-darker set).
	/// Null falls back to <see cref="BackgroundBlockColor"/> / the default.</summary>
	public IReadOnlyList<Color> BackgroundBlockColors { get; set; }

	/// <summary>Size multiplier for the drifting cosmetic background blocks. 1 keeps the classic
	/// sizes; smaller values give a level a finer-grained backdrop.</summary>
	public float BackgroundBlockScale { get; set; } = 1f;

	/// <summary>Multiplier for the classic 6/5/4 drifting-block counts.</summary>
	public float BackgroundBlockDensity { get; set; } = 1f;

	/// <summary>Opacity of the drifting cosmetic background blocks, from invisible to opaque.</summary>
	public float BackgroundBlockOpacity { get; set; } = 1f;

	/// <summary>Multiplier for background-block acceleration and maximum drift speed.</summary>
	public float BackgroundDriftSpeed { get; set; } = 1f;

	/// <summary>Optional weighted preference for newly chosen drift directions.</summary>
	public BackgroundDriftBias BackgroundDriftBias { get; set; }

	/// <summary>Repeating two-colour pixel pattern used by the playfield.</summary>
	public PlayfieldPattern PlayfieldPattern { get; set; }
	public int PlayfieldCellScale { get; set; } = 1;
	public IReadOnlyList<string> PlayfieldPatternRows { get; set; }

	/// <summary>Optional alternate playfield appearance painted inside each authored logical rectangle.</summary>
	public bool AlternatePlayfieldEnabled { get; set; }
	public Color? AlternateCheckerboardColor { get; set; }
	public Color? AlternateCheckerboardSecondColor { get; set; }
	public PlayfieldPattern AlternatePlayfieldPattern { get; set; }
	public int AlternatePlayfieldCellScale { get; set; } = 1;
	public IReadOnlyList<string> AlternatePlayfieldPatternRows { get; set; }
	public IReadOnlyList<RectF> AlternatePlayfieldRects { get; set; } = System.Array.Empty<RectF>();

	/// <summary>Line-of-sight blocking rectangles (logical-space) — the rects of the solid
	/// <see cref="Obstacles"/> flagged (per-obstacle, in the editor) to block vision. Each casts an
	/// opaque shadow from the player's position, covering whatever is behind it with the wall colour
	/// (see <see cref="VisionOccluder"/>). Empty by default (no obstacle blocks vision).</summary>
	public IReadOnlyList<RectF> VisionBlockers { get; set; } = System.Array.Empty<RectF>();

	/// <summary>FENCE rects — the rects of the <see cref="Obstacles"/> flagged (per-obstacle, in the
	/// editor) as fences: solid ONLY to blocks. A fence stops/slams blocks, blocks can't slip,
	/// teleport or be summoned into it, and it counts toward enclosing a block — but the player,
	/// lasers, hazards (bullets/fireballs/teardrops) and fields (magnet/wind) all pass straight
	/// through it. Fences never merge with normal obstacles or arena walls (no face cutting — the
	/// wall behind one keeps its full spikeable face), only visually with each other, and can never
	/// be spiked. Matched by value against <see cref="Obstacles"/>, like
	/// <see cref="VisionBlockers"/>. Empty by default (no obstacle is a fence).</summary>
	public IReadOnlyList<RectF> Fences { get; set; } = System.Array.Empty<RectF>();

	/// <summary>The level's ONE fence colour: the fence BORDER draws it directly and the bars derive
	/// a brighter version (<see cref="LevelCosmeticsRandomizer.FenceBarColor"/>). Null = the default
	/// warm amber (<see cref="LevelCosmeticsRandomizer.DefaultFenceColor"/>). Rolled alongside the
	/// other palette colours by the cosmetics randomizer (complement of the background hue).</summary>
	public Color? FenceColor { get; set; }

	/// <summary>GLASS rects — the rects of the <see cref="Obstacles"/> flagged (per-obstacle, in the
	/// editor) as glass: solid ONLY to players. Every character collides with a glass panel —
	/// including the wall-phasing wrap character, which does NOT wrap across it — while blocks,
	/// hazards, lasers, fields and player-launched projectiles all pass straight through. Authored
	/// spikes on its sides ARE allowed (player-lethal, drawn slightly tinted since blocks slide over
	/// them harmlessly). Glass never merges with normal obstacles or arena walls; two flush glass
	/// panels merge visually with each other. Matched by value against <see cref="Obstacles"/>,
	/// like <see cref="Fences"/>. Empty by default.</summary>
	public IReadOnlyList<RectF> Glass { get; set; } = System.Array.Empty<RectF>();

	/// <summary>The level's ONE glass colour: the translucent pane tint, its reflection streaks and
	/// its solid 1px border all derive from it. Null = the default lavender
	/// (<see cref="LevelCosmeticsRandomizer.DefaultGlassColor"/>). Rolled alongside the other palette
	/// colours by the cosmetics randomizer (a quarter-turn from the background hue).</summary>
	public Color? GlassColor { get; set; }

	/// <summary>Optional decorative FALLING-SQUARE ambience (rain / snow / embers): simple coloured
	/// squares emitted from one arena edge that fly under world-vertical gravity and are destroyed by
	/// the first solid they meet (interior obstacles, glass, live blocks and the arena walls — the
	/// emit edge itself only once first cleared, so returning embers splash on their own floor;
	/// never fences), optionally bursting into a small spray along the struck surface's normal.
	/// Purely cosmetic: rendered between the drifting backdrop and the fences, driven entirely by the
	/// BACKGROUND Rng stream, absent from level previews. The settings below are only meaningful when
	/// this is true (the editor hides them otherwise). See <see cref="BackgroundParticle"/>.</summary>
	public bool BackgroundParticlesEnabled { get; set; }

	/// <summary>Arena edge the particles emit from (<see cref="Direction.Up"/> = the top edge, the
	/// default). Spawn positions are drawn uniformly from the edge span, skipping stretches covered
	/// by obstacles/glass flush with that edge — the emission line never moves off the true arena
	/// edge (unlike solar sunlight, which re-emerges under ceiling-flush obstacles).</summary>
	public Direction BackgroundParticleEdge { get; set; } = Direction.Up;

	/// <summary>Square colour. Null = the default soft blue-grey
	/// (<see cref="BackgroundParticleSettings.DefaultColor"/>).</summary>
	public Color? BackgroundParticleColor { get; set; }

	/// <summary>Opacity of the squares (and their impact spray), from near-invisible to opaque.</summary>
	public float BackgroundParticleOpacity { get; set; } = 1f;

	/// <summary>Square side length range in logical pixels; each particle rolls a size in
	/// [Min, Max].</summary>
	public int BackgroundParticleSizeMin { get; set; } = 1;
	public int BackgroundParticleSizeMax { get; set; } = 2;

	/// <summary>Degrees the emit direction tilts from straight-inward (positive = counter-clockwise,
	/// so a top-edge emitter drifts LEFT on screen for positive values), plus the random tilt applied
	/// per particle either side of it.</summary>
	public float BackgroundParticleAngle { get; set; }
	public float BackgroundParticleAngleRange { get; set; }

	/// <summary>Starting speed range in px/s; each particle rolls a speed in [Min, Max].</summary>
	public float BackgroundParticleSpeedMin { get; set; } = 90f;
	public float BackgroundParticleSpeedMax { get; set; } = 140f;

	/// <summary>World-vertical acceleration in px/s², positive = downward — SIGNED and always along
	/// world Y regardless of the emit edge, so bottom-edge embers keep rising with a negative value
	/// while side-edge rain arcs down with a positive one.</summary>
	public float BackgroundParticleGravity { get; set; } = 60f;

	/// <summary>Particles emitted per second.</summary>
	public float BackgroundParticleSpawnRate { get; set; } = 12f;

	/// <summary>Trail length: extra sprites rendered in the grid cells a square most recently
	/// vacated, forming a streak — rain lines. 0 = no trail.</summary>
	public int BackgroundParticleTrailLength { get; set; }

	/// <summary>Trail colour. Null = the square colour (like the impact colour).</summary>
	public Color? BackgroundParticleTrailColor { get; set; }

	/// <summary>True (default) = trail segments fade toward the tail (comet look); false = the whole
	/// streak renders at the particle opacity (solid line).</summary>
	public bool BackgroundParticleTrailFade { get; set; } = true;

	/// <summary>Impact spray: when enabled, a particle destroyed by a solid bursts into short-lived
	/// fading motes sprayed along the struck surface's outward normal — rain splashes. Each impact
	/// rolls a fragment count in [CountMin, CountMax] (a 0 roll = none this time), and each fragment
	/// rolls its speed and square size from their own [Min, Max] ranges. Gravity follows the same
	/// signed world-vertical convention as the main squares; the angle range is the random tilt
	/// either side of the normal. A null colour falls back to the square colour.</summary>
	public bool BackgroundParticleImpactEnabled { get; set; }
	public int BackgroundParticleImpactCountMin { get; set; } = 2;
	public int BackgroundParticleImpactCountMax { get; set; } = 4;
	public float BackgroundParticleImpactSpeedMin { get; set; } = 15f;
	public float BackgroundParticleImpactSpeedMax { get; set; } = 40f;
	public int BackgroundParticleImpactSizeMin { get; set; } = 1;
	public int BackgroundParticleImpactSizeMax { get; set; } = 2;
	public float BackgroundParticleImpactGravity { get; set; } = 150f;
	public float BackgroundParticleImpactAngleRange { get; set; } = 60f;
	public Color? BackgroundParticleImpactColor { get; set; }

	/// <summary>Collectible coin centres (logical-space points). Each spawns a <see cref="Coin"/> —
	/// a 4x6 pickup worth <see cref="ScoreCalc.COIN_SCORE"/> — at stage enter. Spawning consumes no
	/// authoritative Rng. Author responsibility: don't bury a coin inside a solid obstacle or glass
	/// (both are player-solid, so it would be uncollectable). Empty by default.</summary>
	public IReadOnlyList<Vector2> Coins { get; set; } = System.Array.Empty<Vector2>();

	/// <summary>When true (default), the resolved block types are shuffled before being assigned to
	/// the chosen positions (the classic behaviour). False assigns them in listed order for exact
	/// authored placement.</summary>
	public bool ShuffleTypes { get; set; } = true;

	/// <summary>Mounted asset path this level was loaded from, for editor-only browser metadata.</summary>
	[JsonIgnore]
	public string SourcePath { get; set; }

	/// <summary>Best-effort UTC file timestamp used by the editor browser's recent sort.</summary>
	[JsonIgnore]
	public long SourceFileTimeUtc { get; set; }

	/// <summary>True for a player-made level loaded from the local game data folder in a STANDALONE
	/// build (see <see cref="LocalLevels"/>), false for every shipped <c>Assets/levels</c> JSON.</summary>
	[JsonIgnore]
	public bool IsLocal { get; set; }

	/// <summary>True for an installed Steam Workshop level (see <see cref="WorkshopLevels"/>). These
	/// register under a <c>ws{fileId}</c> id, never appear in editor pickers, and key their leaderboard
	/// stat with <see cref="WorkshopContentHash"/>.</summary>
	[JsonIgnore]
	public bool IsWorkshop { get; set; }

	/// <summary>8-hex content hash of the installed workshop level JSON. Part of the leaderboard stat
	/// name, so an author republish starts a fresh board; replays are watchable only when the local
	/// copy's hash matches the run's recorded hash.</summary>
	[JsonIgnore]
	public string WorkshopContentHash { get; set; }

	/// <summary>8-hex hash identifying this exact level revision, recorded into every run
	/// (<see cref="RunData.LevelHash"/>) so a replay greys out instead of silently desyncing when the
	/// level it resolves at watch time no longer matches the one it was played on (an edited level,
	/// a republished workshop item, a re-rolled daily). Workshop levels reuse
	/// <see cref="WorkshopContentHash"/> (the raw installed file, byte-identical on every client);
	/// everything else lazily hashes its canonical wire JSON with LF line endings, independent of
	/// the serializer's platform newline. Cached — defs never mutate once built, with one exception: Music,
	/// which a daily def picks up lazily after generation (DailyLevels.WithMusic) and which changes
	/// per-day when songs are added to the pool. Music and every other PURELY VISUAL field are blanked
	/// out of the hash (EditorLevel.ClearCosmetics) so it covers sim input alone — otherwise the hash
	/// would depend on WHEN it was computed, and re-rolling a palette or growing the song pool would
	/// retroactively retire replays whose layouts are identical. Workshop levels are the exception:
	/// they hash the raw installed FILE, so a cosmetic-only republish still rolls their board.</summary>
	[JsonIgnore]
	public string ContentHash
	{
		get
		{
			if ( IsWorkshop && !string.IsNullOrEmpty( WorkshopContentHash ) )
				return WorkshopContentHash;
			// Both must be populated: hotload may preserve a pre-normalization _contentHash alone.
			if ( _contentHash is null || _contentHashCrlf is null )
			{
				var wire = EditorLevel.FromLevelDef( this );
				wire.Music = "";
				wire.ClearCosmetics();
				string json = wire.ToJsonString().Replace( "\r\n", "\n" );
				_contentHash = WorkshopLevels.ContentHash( json );
				_contentHashCrlf = WorkshopLevels.ContentHash( json.Replace( "\n", "\r\n" ) );
			}
			return _contentHash;
		}
	}

	[JsonIgnore]
	private string _contentHash;
	[JsonIgnore]
	private string _contentHashCrlf;

	/// <summary>Accept both historical serializer newline variants for registry/daily replays.
	/// Workshop hashes still identify the exact installed file, without newline normalization.</summary>
	public bool MatchesContentHash( string hash )
	{
		if ( IsWorkshop && !string.IsNullOrEmpty( WorkshopContentHash ) )
			return WorkshopContentHash == hash;

		return ContentHash == hash || _contentHashCrlf == hash;
	}

	/// <summary>Number of blocks this level spawns: the random set plus any pinned blocks.</summary>
	public int Count => (Blocks?.Count ?? EffectivePoolBlockCount) + PinnedCount;

	/// <summary>How many spawn slots carry a pinned block.</summary>
	public int PinnedCount => SpawnPins?.Count( p => p is not null ) ?? 0;

	private int EffectivePoolBlockCount => System.Math.Clamp( BlockCount, 0, Pool?.Count ?? 0 );

	/// <summary>
	/// Resolve the concrete spawn set for a fresh run, drawing from the authoritative <see cref="Rng"/>.
	/// MUST be called immediately after the run's reseed and consume Rng identically for a given level,
	/// so a replay (which reseeds and re-runs this) reproduces the same layout. The outputs are aligned:
	/// block <c>i</c> is <paramref name="types"/>[i] with start state <paramref name="configs"/>[i],
	/// initial travel <paramref name="directions"/>[i], and enclosed-ability timing
	/// <paramref name="abilityTimers"/>[i] at <paramref name="positions"/>[i].
	/// </summary>
	/// <remarks>Pool entries are consumed as they are selected, so a type can only repeat when the
	/// authored pool contains that type more than once. Malformed definitions requesting more entries
	/// than are available are capped to the pool size instead of aborting stage entry.</remarks>
	public void ResolveSpawns( out List<BlockType> types, out List<Vector2> positions, out List<BlockStart> configs,
		out List<BlockAbilityTimer> abilityTimers, out List<Direction> directions, out List<TurnMode> turnModes )
	{
		// 1. Resolve the block instances (type + optional start config), as parallel lists.
		var instTypes = new List<BlockType>();
		var instConfigs = new List<BlockStart>();
		if ( Blocks is not null )
		{
			for ( int i = 0; i < Blocks.Count; i++ )
			{
				instTypes.Add( Blocks[i] );
				instConfigs.Add( BlockStartAt( i ) );
			}
		}
		else
		{
			int drawCount = EffectivePoolBlockCount;
			if ( drawCount != BlockCount )
				Log.Warning( $"[BlockParty] level '{Id}': requested {BlockCount} blocks from {Pool?.Count ?? 0} pool entries; spawning {drawCount}." );

			// Consume pool entries as they are drawn. Repeated types therefore require repeated entries.
			var availableTypes = Pool is null ? new List<BlockType>() : new List<BlockType>( Pool );
			// Pool mode: every drawn block shares PoolStartPhase (per-instance pressed sides don't map
			// to random draws). Reuse ONE config instance (blocks never mutate it).
			var poolStart = PoolStartPhase != 0 ? new BlockStart { Phase = PoolStartPhase } : null;
			for ( int i = 0; i < drawCount; i++ )
			{
				int poolIndex = Rng.Int( 0, availableTypes.Count );
				instTypes.Add( availableTypes[poolIndex] );
				availableTypes.RemoveAt( poolIndex );
				instConfigs.Add( poolStart );
			}
		}
		int n = instTypes.Count;

		// 2. Candidate slots. Pinned slots ALWAYS spawn their authored block; free slots form the
		//    candidate pool for the random instances and are randomly reduced to the instance count.
		//    The no-pin path is byte-identical to the classic random discard (an all-free list maps
		//    index i -> value i, so the chosen free index equals a plain Rng.Int over the full count).
		var slotPos = new List<Vector2>( SpawnPositions ?? (IReadOnlyList<Vector2>)System.Array.Empty<Vector2>() );
		var slotPin = new List<PinnedBlock>( slotPos.Count );
		var slotTimer = new List<BlockAbilityTimer>( slotPos.Count );
		var slotDirection = new List<Direction>( slotPos.Count );
		var slotTurnMode = new List<TurnMode>( slotPos.Count );
		int free = 0;
		for ( int i = 0; i < slotPos.Count; i++ )
		{
			slotPin.Add( PinAt( i ) );
			slotTimer.Add( AbilityTimerAt( i ) );
			slotDirection.Add( SpawnDirectionAt( i ) );
			slotTurnMode.Add( SpawnTurnModeAt( i ) );
			if ( slotPin[i] is null ) free++;
		}

		while ( free > n )
		{
			int idx = RandomFreeIndex( slotPin );
			slotPos.RemoveAt( idx );
			slotPin.RemoveAt( idx );
			slotTimer.RemoveAt( idx );
			slotDirection.RemoveAt( idx );
			slotTurnMode.RemoveAt( idx );
			free--;
		}

		// 3. Shuffle the random instances (types + configs together, consuming Rng exactly like the
		//    classic types.Shuffle()) and assign them to the surviving free slots in listed order;
		//    pinned slots emit their authored block. Output stays slot-ordered.
		if ( ShuffleTypes ) ShufflePaired( instTypes, instConfigs );

		types = new List<BlockType>( slotPos.Count );
		configs = new List<BlockStart>( slotPos.Count );
		positions = new List<Vector2>( slotPos.Count );
		abilityTimers = new List<BlockAbilityTimer>( slotPos.Count );
		directions = new List<Direction>( slotPos.Count );
		turnModes = new List<TurnMode>( slotPos.Count );

		int r = 0;
		for ( int s = 0; s < slotPos.Count; s++ )
		{
			if ( slotPin[s] is PinnedBlock pin )
			{
				types.Add( pin.Type );
				configs.Add( new BlockStart { Phase = pin.Phase, PressedSides = pin.PressedSides } );
				positions.Add( slotPos[s] );
				abilityTimers.Add( slotTimer[s] );
				directions.Add( slotDirection[s] );
				turnModes.Add( slotTurnMode[s] );
				continue;
			}

			if ( r >= n ) continue; // more free slots than random blocks (shouldn't happen post-reduce)
			types.Add( instTypes[r] );
			configs.Add( instConfigs[r] );
			positions.Add( slotPos[s] );
			abilityTimers.Add( slotTimer[s] );
			directions.Add( slotDirection[s] );
			turnModes.Add( slotTurnMode[s] );
			r++;
		}

		// Random instances with no free slot left (authoring error: more blocks than free spawns) are
		// appended type-only so the caller's "fewer positions than blocks" warning fires.
		for ( ; r < n; r++ )
		{
			types.Add( instTypes[r] );
			configs.Add( instConfigs[r] );
		}
	}

	private BlockStart BlockStartAt( int i )
		=> BlockStarts is not null && i < BlockStarts.Count ? BlockStarts[i] : null;

	private PinnedBlock PinAt( int i )
		=> SpawnPins is not null && i < SpawnPins.Count ? SpawnPins[i] : null;

	private BlockAbilityTimer AbilityTimerAt( int i )
		=> SpawnAbilityTimers is not null && i < SpawnAbilityTimers.Count ? SpawnAbilityTimers[i] : null;

	private Direction SpawnDirectionAt( int i )
		=> SpawnDirections is not null && i < SpawnDirections.Count ? SpawnDirections[i] : Direction.None;

	private TurnMode SpawnTurnModeAt( int i )
		=> SpawnTurnModes is not null && i < SpawnTurnModes.Count ? SpawnTurnModes[i] : TurnMode.Free;

	/// <summary>Pick a FREE (unpinned) slot index to discard. With no pins present this returns
	/// exactly <c>Rng.Int(0, count)</c> (byte-identical to the classic random discard).</summary>
	private static int RandomFreeIndex( List<PinnedBlock> pins )
	{
		int free = 0;
		foreach ( var p in pins ) if ( p is null ) free++;
		int pick = Rng.Int( 0, free );
		for ( int i = 0; i < pins.Count; i++ )
		{
			if ( pins[i] is not null ) continue;
			if ( pick-- == 0 ) return i;
		}
		return pins.Count - 1; // unreachable (callers only discard while a free slot exists)
	}

	/// <summary>Fisher-Yates over two parallel lists in lockstep, consuming Rng identically to
	/// <see cref="Extensions.Shuffle{T}"/> on a single list (so it preserves the classic type order).</summary>
	private static void ShufflePaired( List<BlockType> a, List<BlockStart> b )
	{
		int n = a.Count;
		while ( n > 1 )
		{
			n--;
			int k = Rng.Int( 0, n + 1 );
			(a[k], a[n]) = (a[n], a[k]);
			(b[k], b[n]) = (b[n], b[k]);
		}
	}
}

/// <summary>An authored block bound to one spawn position (see <see cref="LevelDef.SpawnPins"/>):
/// it always spawns there with this exact type, starting phase and pre-pressed sides, independent
/// of the level's random block set.</summary>
public sealed class PinnedBlock
{
	public BlockType Type { get; set; }

	/// <summary>Phase to spawn at (0..<see cref="Block.NUM_PHASES"/>-1). Clamped on apply.</summary>
	public int Phase { get; set; }

	/// <summary>Sides that start already pressed for the spawn phase.</summary>
	public IReadOnlyList<Direction> PressedSides { get; set; } = System.Array.Empty<Direction>();
}

/// <summary>
/// Optional per-block starting state (see <see cref="LevelDef.BlockStarts"/> /
/// <see cref="LevelDef.PoolStartPhase"/>): the phase the block spawns at and the sides that start
/// pre-pressed for that phase. Applied via <see cref="Block.SetInitialPhase(int, IReadOnlyList{Direction})"/>
/// — pre-pressing marks the buttons down without scoring, so the block simply starts partway through
/// its current phase.
/// </summary>
public sealed class BlockStart
{
	/// <summary>Phase to spawn at (0..<see cref="Block.NUM_PHASES"/>-1). Clamped on apply.</summary>
	public int Phase { get; set; }

	/// <summary>Sides that start already pressed for the spawn phase (below max phase). Empty = classic.</summary>
	public IReadOnlyList<Direction> PressedSides { get; set; } = System.Array.Empty<Direction>();
}

/// <summary>Cadence for a slam-driven ability while its spawn is blocked on every side. For lane blocks
/// (Wind/Magnet) the same timings drive the lane on/off loop: Interval = lane-on (eyes open) time,
/// <see cref="ClosedTime"/> = lane-off (eyes shut) time.</summary>
public sealed class BlockAbilityTimer
{
	public float Interval { get; set; } = Block.DEFAULT_ABILITY_INTERVAL;
	public float Randomness { get; set; }
	public float StartOffset { get; set; }
	public float ClosedTime { get; set; } = Block.DEFAULT_ABILITY_CLOSED_TIME;
}

/// <summary>
/// One obstacle's permanent-spike authoring: the obstacle rect (matched by value against
/// <see cref="LevelDef.Obstacles"/>) and the outward side normals that start the level spiked.
/// See <see cref="LevelDef.SpikedObstacleSides"/>.
/// </summary>
public sealed class ObstacleSpikeSpec
{
	/// <summary>The obstacle to spike — must equal one of the level's <see cref="LevelDef.Obstacles"/>
	/// rects by value (share the same local variable when authoring).</summary>
	public RectF Rect { get; set; }

	/// <summary>Outward side normals to permanently spike (Left/Right/Up/Down). A side flush against an
	/// arena wall has no face and is silently ignored.</summary>
	public IReadOnlyList<Direction> Sides { get; set; } = System.Array.Empty<Direction>();

	public ObstacleSpikeSpec() { }

	public ObstacleSpikeSpec( RectF rect, params Direction[] sides )
	{
		Rect = rect;
		Sides = sides;
	}
}

/// <summary>
/// The static level registry. Ordered as shown on the level-select screen; <see cref="Classic"/>
/// (the original 5-block layout) is always first. Daily-challenge levels are NOT in this registry —
/// they're generated per day (see <see cref="DailyLevels"/>).
/// </summary>
public static class Levels
{
	public const string ClassicId = "classic";
	public const string TutorialId = "tutorial";

	// The registry loads LAZILY on first access and RETRIES until a load actually finds levels, 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 registry forever (Classic null -> NRE in Leaderboard). `_loaded`
	// sticks only once a load finds something, so early accesses retry and a later access (FS ready)
	// populates everything. Reload() uses the raw _byId lookup (GetRaw) to avoid re-entering EnsureLoaded.
	private static bool _loaded;
	private static bool _browserBuilt;
	private static LevelDef _classic;
	private static IReadOnlyList<LevelDef> _all = System.Array.Empty<LevelDef>();
	private static IReadOnlyList<LevelDef> _browserLevels = System.Array.Empty<LevelDef>();
	private static int _maxBlockCount;
	private static Dictionary<string, LevelDef> _byId = new();

	/// <summary>The original game: one of each block type, shuffled across the top row.</summary>
	public static LevelDef Classic { get { EnsureLoaded(); return _classic; } }

	/// <summary>Every level in level-select / registry order (map-ordered; repro levels excluded).</summary>
	public static IReadOnlyList<LevelDef> All { get { EnsureLoaded(); return _all; } }

	/// <summary>Every loaded level, for the editor's level browser. Mapped levels are included here for
	/// callers that need the complete set, but the picker filters them out of its Other and Test views.</summary>
	public static IReadOnlyList<LevelDef> BrowserLevels
	{
		get
		{
			EnsureLoaded();
			// Self-heal after a hot-reload that ADDED this field without re-running Reload: the registry
			// can be populated (from the pre-reload assembly) while _browserLevels is still its empty default.
			// _browserBuilt is itself a new field (false after such a reload) so this fires exactly once.
			if ( !_browserBuilt && _byId.Count > 0 ) Reload();
			return _browserLevels ?? System.Array.Empty<LevelDef>();
		}
	}

	/// <summary>Most blocks any level can spawn — bounds the replay-code block-tally field.</summary>
	public static int MaxBlockCount { get { EnsureLoaded(); return _maxBlockCount; } }

	/// <summary>Every loaded level (main + test/repro), unordered — for tooling such as the JSON dump
	/// (the game normally goes through <see cref="Get"/> / <see cref="All"/>).</summary>
	public static IReadOnlyCollection<LevelDef> Registry { get { EnsureLoaded(); return _byId.Values; } }

	private static void EnsureLoaded()
	{
		if ( !_loaded ) Reload();
	}

	/// <summary>
	/// (Re)load every level from <c>Assets/levels/*.json</c> (main) + <c>Assets/levels/test/*.json</c>
	/// (repro), building the id lookup, the map-ordered <see cref="All"/> registry and
	/// <see cref="MaxBlockCount"/>. Called lazily on first access (retrying until the mount is ready)
	/// and again on the <c>reload_levels</c> ConCmd, so JSON edits apply without an editor restart. Run
	/// <c>reload_map</c> FIRST if the level-select spine (<c>Assets/levelmap.json</c>) changed, since
	/// <see cref="All"/> order derives from it. NOTE: the high-score dropdown caches level names —
	/// re-enter it after a reload.
	/// </summary>
	public static void Reload()
	{
		var defs = LoadLevelJsonFiles( out var rootIds );

		// Build into a LOCAL dict and swap it in at the end, so a concurrent read (e.g. the async
		// leaderboard prefetch that touches Levels at boot) never observes a half-populated registry.
		var byId = new Dictionary<string, LevelDef>();
		foreach ( var d in defs )
			if ( d is not null && !string.IsNullOrEmpty( d.Id ) )
			{
				if ( byId.TryGetValue( d.Id, out var existing ) )
				{
					Log.Warning( $"[BlockParty] duplicate level id '{d.Id}' in '{d.SourcePath}' ignored; " +
						$"already loaded from '{existing.SourcePath}'." );
					continue;
				}
				byId.Add( d.Id, d );
			}

		// STANDALONE: fold in the player's local level files (the data folder's levels/ directory)
		// so a saved creation can be re-opened by id and browsed in the picker's Local view. Shipped
		// ids always win — a local file must never shadow map/leaderboard content. Only once the
		// MOUNTED levels are in: `_loaded` latches on a non-empty registry, and local files alone
		// must not end the boot-time retry while the asset mount is still warming up.
		if ( byId.Count > 0 )
		{
			foreach ( var d in LocalLevels.LoadAll() )
			{
				// The ws{fileId} namespace belongs to installed workshop levels (folded in below):
				// a local file there would win this fold-in and silently break that level's hashed
				// boards/replays. LocalLevels.Save refuses creating these; a hand-dropped or pre-guard
				// file gets refused here too, loudly.
				if ( WorkshopLevels.IsWorkshopId( d.Id ) )
				{
					Log.Warning( $"[BlockParty] local level file '{d.SourcePath}' uses reserved workshop id '{d.Id}' — ignored (rename the file/id)." );
					continue;
				}

				if ( !byId.ContainsKey( d.Id ) )
				{
					d.SourceFileTimeUtc = RecentTimeFor( d.Id );
					byId.Add( d.Id, d );
				}
			}

			// Installed workshop levels (data folder workshop/{fileId}.json copies) register too, so
			// their boards and replays resolve without a Steam round-trip. Existing ids win, same rule.
			foreach ( var d in WorkshopLevels.LoadAll() )
				if ( !byId.ContainsKey( d.Id ) )
					byId.Add( d.Id, d );
		}

		LevelDef Lookup( string id ) => byId.GetValueOrDefault( string.IsNullOrEmpty( id ) ? ClassicId : id );

		// Level-select / registry order comes from the map manifest (each major then its side chains),
		// so side levels interleave after their major; map-absent (test/repro) levels stay out of All.
		var all = LevelMap.OrderedLevelIds
			.Select( Lookup )
			.Where( l => l is not null )
			.ToList();

		// Editor browser list: stable alphabetical order for every loaded level. The picker filters this
		// down to non-map levels for its Other/Test views and can optionally re-sort by file timestamp.
		var rootSeen = new HashSet<string>();
		var browserLevels = new List<LevelDef>();
		foreach ( var l in rootIds
			.Select( Lookup )
			.Where( l => l is not null )
			.OrderBy( l => l.Name ?? l.Id, System.StringComparer.OrdinalIgnoreCase ) )
		{
			if ( rootSeen.Add( l.Id ) ) browserLevels.Add( l );
		}
		// Then the levels/test repro/test folder, also alphabetical. Workshop levels stay out of every
		// picker view — they're browsed and played from the workshop screen only.
		browserLevels.AddRange( byId.Values
			.Where( l => l is not null && !l.IsWorkshop && !rootIds.Contains( l.Id ) && rootSeen.Add( l.Id ) )
			.OrderBy( l => l.Name ?? l.Id, System.StringComparer.OrdinalIgnoreCase ) );

		_byId = byId;
		_classic = Lookup( ClassicId );
		_all = all;
		_browserLevels = browserLevels;
		_maxBlockCount = MaxBlockCountOf( byId.Values );

		// Mark loaded only once we actually found levels, so an early pre-mount call retries later.
		_loaded = byId.Count > 0;
		_browserBuilt = byId.Count > 0;

		// Warn only when the levels folder is mounted but yielded nothing (a real problem) — NOT during
		// early boot when the mount isn't ready yet (that path retries silently, so no log spam).
		if ( byId.Count == 0 && FileSystem.Mounted.DirectoryExists( "levels" ) )
			Log.Warning( "[BlockParty] no valid level JSON under Assets/levels." );
	}

	/// <summary>Whether the id belongs to a SHIPPED (mounted <c>Assets/levels</c>) level, as opposed to
	/// a player's local file or nothing at all. Standalone saves refuse these ids — a local file with a
	/// shipped id is skipped at load, so writing one would only strand an unloadable file.</summary>
	public static bool IsShippedLevel( string id )
	{
		EnsureLoaded();
		return !string.IsNullOrEmpty( id ) && _byId.TryGetValue( id, out var def ) && !def.IsLocal && !def.IsWorkshop;
	}

	// Most blocks any NON-workshop level spawns. Workshop levels are excluded because this bounds the
	// replay-code block-tally field globally — ws share codes embed their level snapshot (see
	// ReplayCodeCodec.Encode), so the codec bounds those from the snapshot instead, and a downloaded
	// 100-block level must not raise the accepted bound for every pasted code.
	private static int MaxBlockCountOf( IEnumerable<LevelDef> defs )
	{
		int max = 0;
		foreach ( var l in defs )
			if ( l is not null && !l.IsWorkshop && l.Count > max ) max = l.Count;
		return max;
	}

	/// <summary>Register (or refresh) an installed workshop level in the live registry, id lookup ONLY:
	/// workshop levels never join <see cref="All"/> (map order), the editor browser list, or the
	/// <see cref="MaxBlockCount"/> bound. Copy-on-write like <see cref="ApplySavedLevel"/>, but without
	/// its browser/timestamp side effects.</summary>
	public static void RegisterWorkshop( LevelDef def )
	{
		if ( def is null || string.IsNullOrWhiteSpace( def.Id ) || !def.IsWorkshop ) return;
		EnsureLoaded();

		// A shipped/local id can never be overwritten; ws ids are digits-only after the prefix and
		// LocalLevels refuses them, so a collision here means something is very wrong.
		if ( _byId.TryGetValue( def.Id, out var existing ) && !existing.IsWorkshop )
		{
			Log.Warning( $"[BlockParty] workshop level id '{def.Id}' collides with a non-workshop level; ignored." );
			return;
		}

		_byId = new Dictionary<string, LevelDef>( _byId ) { [def.Id] = def };
	}

	/// <summary>Apply a level that the editor assembly just wrote to disk. The asset mount can lag behind
	/// the physical project file, so an immediate <see cref="Reload"/> may still read the old definition.</summary>
	public static void ApplySavedLevel( LevelDef def )
	{
		if ( def is null || string.IsNullOrWhiteSpace( def.Id ) ) return;
		EnsureLoaded();
		def.SourcePath = string.IsNullOrWhiteSpace( def.SourcePath ) ? MountedLevelPathForId( def.Id ) : def.SourcePath;
		def.SourceFileTimeUtc = RecentTimeFor( def.Id );

		var byId = new Dictionary<string, LevelDef>( _byId ) { [def.Id] = def };
		_byId = byId;
		if ( _classic?.Id == def.Id ) _classic = def;

		_all = _all.Select( level => level.Id == def.Id ? def : level ).ToArray();

		var browser = _browserLevels.ToList();
		int browserIndex = browser.FindIndex( level => level.Id == def.Id );
		if ( browserIndex >= 0 ) browser[browserIndex] = def;
		else browser.Add( def );
		browser = browser
			.OrderBy( level => level.Name ?? level.Id, System.StringComparer.OrdinalIgnoreCase )
			.ToList();
		_browserLevels = browser;

		_maxBlockCount = MaxBlockCountOf( byId.Values );
		_browserBuilt = true;
	}

	/// <summary>Remove a physically deleted level from the live registry. Called after
	/// <c>reload_levels</c> because the mounted asset filesystem can briefly retain a deleted file.</summary>
	public static void RemoveDeletedLevel( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) ) return;
		EnsureLoaded();
		if ( !_byId.ContainsKey( id ) ) return;

		var byId = new Dictionary<string, LevelDef>( _byId );
		byId.Remove( id );
		_byId = byId;
		if ( _classic?.Id == id ) _classic = null;
		_all = _all.Where( level => level.Id != id ).ToArray();
		_browserLevels = _browserLevels.Where( level => level.Id != id ).ToArray();
		_maxBlockCount = MaxBlockCountOf( byId.Values );
		_loaded = byId.Count > 0;
		_browserBuilt = byId.Count > 0;
	}

	/// <summary>Refresh editor-browser sort timestamps from <see cref="EditorPrefs"/>. The editor assembly
	/// owns the physical file stat calls and writes that metadata into editor settings.</summary>
	public static void RefreshBrowserLevelTimesFromPrefs()
	{
		EnsureLoaded();
		foreach ( var level in _byId.Values )
		{
			if ( level is null || string.IsNullOrEmpty( level.Id ) ) continue;
			level.SourceFileTimeUtc = RecentTimeFor( level.Id );
		}
	}

	// Load every level JSON: main levels from Assets/levels/*.json, repro/test levels from
	// Assets/levels/test/*.json. Both feed the id lookup; only main levels reach All (via the map).
	// The ids loaded from the ROOT folder are reported via <paramref name="rootIds"/> so the editor
	// level browser can list root levels while excluding the test/repro folder.
	private static List<LevelDef> LoadLevelJsonFiles( out HashSet<string> rootIds )
	{
		var result = new List<LevelDef>();
		int before = result.Count;
		LoadLevelDir( "levels", result );
		rootIds = new HashSet<string>();
		for ( int i = before; i < result.Count; i++ )
			if ( result[i] is not null && !string.IsNullOrEmpty( result[i].Id ) )
				rootIds.Add( result[i].Id );
		LoadLevelDir( "levels/test", result );
		return result;
	}

	private static void LoadLevelDir( string dir, List<LevelDef> into )
	{
		try
		{
			if ( !FileSystem.Mounted.DirectoryExists( dir ) )
				return;
			foreach ( var name in FileSystem.Mounted.FindFile( dir, "*.json" ) )
			{
				var path = $"{dir}/{name}";
				try
				{
					var e = EditorLevel.FromJsonString( FileSystem.Mounted.ReadAllText( path ), out var err );
					if ( e is null ) { Log.Warning( $"[BlockParty] level '{path}' skipped: {err}" ); continue; }
					var def = e.ToLevelDef();
					def.SourcePath = path;
					def.SourceFileTimeUtc = RecentTimeFor( def.Id );
					into.Add( def );
				}
				catch ( System.Exception ex )
				{
					Log.Warning( $"[BlockParty] level '{path}' skipped: {ex.Message}" );
				}
			}
		}
		catch ( System.Exception ex )
		{
			Log.Warning( $"[BlockParty] scanning '{dir}': {ex.Message}" );
		}
	}

	private static string MountedLevelPathForId( string id )
		=> TestLevels.IsTestLevel( id ) ? $"levels/test/{id}.json" : $"levels/{id}.json";

	private static long RecentTimeFor( string id )
		=> !string.IsNullOrEmpty( id )
			&& EditorPrefs.Current.LevelBrowserLevelTimes is not null
			&& EditorPrefs.Current.LevelBrowserLevelTimes.TryGetValue( id, out var ticks )
			? ticks
			: 0;


	/// <summary>Console command: reload every level from the <c>Assets/levels</c> JSON files (run
	/// <c>reload_map</c> first if the spine changed). Applies edits without an editor restart.</summary>
	[ConCmd( "reload_levels" )]
	public static void ReloadLevelsCmd()
	{
		if ( !Game.IsEditor ) return;
		Reload();
		Log.Info( $"[BlockParty] Reloaded {All.Count} level definitions." );
	}

	/// <summary>Console command: start any level by id, using the title-screen character. Works for
	/// every loaded level (main or test/repro), not just the ones on the map. With no id (or an
	/// unknown id) it logs the available ids instead of guessing.</summary>
	[ConCmd( "play_level" )]
	public static void PlayLevelCmd( string levelId = "" )
	{
		if ( !Game.IsEditor ) return;
		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] play_level: no GameManager in the scene." );
			return;
		}

		var def = string.IsNullOrWhiteSpace( levelId ) ? null : Get( levelId.Trim() );
		if ( def is null )
		{
			var ids = string.Join( ", ", Registry.Select( d => d.Id ).OrderBy( s => s ) );
			Log.Warning( $"[BlockParty] play_level: unknown level '{levelId}'. Available: {ids}" );
			return;
		}

		gm.StartLevel( def.Id );
		Log.Info( $"[BlockParty] Loading level '{def.Id}'." );
	}

	/// <summary>Resolve a level id to its definition. Null/empty maps to <see cref="Classic"/> (a
	/// context with no level, e.g. a daily challenge); an unknown id returns null (an
	/// unreplayable/unknown run).</summary>
	public static LevelDef Get( string id )
	{
		// A transient (unsaved) editor TEST level shadows the registry by its id, so a level-editor
		// test run resolves the in-progress edits without writing them to disk (see RegisterTransient).
		if ( _transient is not null && !string.IsNullOrEmpty( id ) && _transient.Id == id )
			return _transient;

		EnsureLoaded();
		return GetRaw( id );
	}

	// Transient level used only while the level editor is test-playing an unsaved level. It is NOT in
	// the persistent registry (never appears in All / the map / dropdowns) and is cleared when the test
	// ends. Get() checks it first so the live run resolves the in-progress edits by id.
	private static LevelDef _transient;

	/// <summary>Register a transient level that <see cref="Get"/> resolves by its id (shadowing any
	/// registered level of the same id), without touching the persistent registry. Used by the level
	/// editor's test-play so the run reflects the unsaved edits. Call <see cref="ClearTransient"/> when
	/// the test ends.</summary>
	public static void RegisterTransient( LevelDef def ) => _transient = def;

	/// <summary>Clear the transient test-play level (see <see cref="RegisterTransient"/>).</summary>
	public static void ClearTransient() => _transient = null;

	// Raw id lookup with NO EnsureLoaded (used inside Reload, which is already loading, to avoid
	// re-entrancy). Null/empty -> classic; unknown -> null.
	private static LevelDef GetRaw( string id )
		=> _byId.GetValueOrDefault( string.IsNullOrEmpty( id ) ? ClassicId : id );

	/// <summary>Index of a level id in <see cref="All"/> (for UI dropdowns); classic/unknown → 0.</summary>
	public static int IndexOf( string id )
	{
		var def = Get( id );
		var all = All;
		for ( int i = 0; i < all.Count; i++ )
			if ( all[i] == def )
				return i;
		return 0;
	}
}