Game/TestLevels.cs
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// DEBUG-ONLY regression-repro levels: the ConCmds that jump into them plus any per-tick scripting
/// they need live HERE. The level DEFINITIONS now live in <c>Assets/levels/test/*.json</c> (loaded
/// like any level); the only shipping hooks are <see cref="OnStageEnter"/> / <see cref="OnStageTick"/>
/// (both no-ops on non-test levels) and <see cref="IsTestLevel"/>.
///
/// The levels live in the normal id registry (so replay/desync machinery treats them like any level)
/// but are excluded from the level-select map, so they're reached only via their ConCmds.
/// </summary>
public static class TestLevels
{
	/// <summary>Id of the crush-repro test level. Load with the <c>crush_repro</c>
	/// console command.</summary>
	public const string CrushReproId = "crush-repro";

	/// <summary>Id of the wrap-repro test level. Load with the <c>wrap_repro</c>
	/// console command.</summary>
	public const string WrapReproId = "wrap-repro";

	/// <summary>Id of the tile-overhang-repro test level. Load with the
	/// <c>tile_overhang_repro</c> console command.</summary>
	public const string TileOverhangReproId = "tile-overhang-repro";

	/// <summary>Id of the reverse-detach-repro test level. Load with the
	/// <c>reverse_detach_repro</c> console command.</summary>
	public const string ReverseDetachReproId = "reverse-detach-repro";

	/// <summary>Id of the edge-hang-repro test level. Load with the <c>edge_hang_repro</c>
	/// console command.</summary>
	public const string EdgeHangReproId = "edge-hang-repro";

	/// <summary>Id of the Shifter underside corner-sliver repro level. Load with the
	/// <c>shifter_underside_repro</c> console command.</summary>
	public const string ShifterUndersideReproId = "shifter-underside-repro";

	/// <summary>Id of the Shifter tight-gap adoption repro level. Load with the
	/// <c>shifter_gap_repro</c> console command.</summary>
	public const string ShifterGapReproId = "shifter-gap-repro";

	/// <summary>Id of the Shifter reverse-field ping-pong repro level. Load with the
	/// <c>shifter_reverse_repro</c> console command.</summary>
	public const string ShifterReverseReproId = "shifter-reverse-repro";

	/// <summary>Id of the riding ceiling-glue misfire repro level. Load with the
	/// <c>ride_cling_repro</c> console command.</summary>
	public const string RideClingReproId = "ride-cling-repro";

	/// <summary>Id of the block-vs-glass-pillar pinch repro level (the glass-pillars witch report).
	/// Load with the <c>pillar_pinch_repro</c> console command.</summary>
	public const string PillarPinchReproId = "pillar-pinch-repro";

	/// <summary>Is this level one of the repro test levels? Used by GameStage to exempt them from
	/// the DEBUG_STARTING_BLOCKS spawn override — their whole point is their exact authored layout,
	/// so the repro ConCmds must work even with that override on.</summary>
	public static bool IsTestLevel( string levelId )
		=> levelId == CrushReproId || levelId == WrapReproId || levelId == TileOverhangReproId
		|| levelId == ReverseDetachReproId || levelId == EdgeHangReproId
		|| levelId == ShifterUndersideReproId || levelId == ShifterGapReproId
		|| levelId == ShifterReverseReproId || levelId == RideClingReproId
		|| levelId == PillarPinchReproId;


	// --- crush repro ---------------------------------------------------------------------


	/// <summary>Console command: jump straight into the phantom-crush regression level (the
	/// crush-repro level). Once it loads, don't
	/// touch anything: the block slams the left-wall lip within ~2s. The wedged player surviving
	/// every slam = healthy; an instant crush death with nothing moving = the stale-stopped-block
	/// velocity bug is back.</summary>
	[ConCmd( "crush_repro" )]
	public static void LoadCrushReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] crush_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( CrushReproId );
		Log.Info( "[BlockParty] Loading crush-repro level. Don't move: surviving the slam = healthy; phantom crush death within ~2s = stale-velocity regression." );
	}

	// --- wrap-escape repro ---------------------------------------------------------------


	/// <summary>Console command: jump straight into the wrap-escape regression level (the wrap-repro
	/// level) as WRAPPER, the wrap character — regardless of which character
	/// is selected on the title screen. Once it loads, don't touch anything: the player free-falls
	/// toward the floor, and spikes start growing there just before they cross it. The player
	/// wrapping to the top of the arena = healthy; the player sailing out through the floor and
	/// dying offscreen ~0.4s later = the inconsistent-spike-gate escape bug (wrap gate counts
	/// growing/retracting spikes as spiked, kill check doesn't). The verdict is logged either way.</summary>
	[ConCmd( "wrap_repro" )]
	public static void LoadWrapReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] wrap_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( WrapReproId, Characters.Wrapper.Id );
		Log.Info( "[BlockParty] Loading wrap-repro level as WRAPPER. Don't move: wrapping to the top = healthy; escaping through the floor into the void = wrap/kill spike-gate mismatch. Watch for the verdict log." );
	}

	// --- spike-tile overhang repro ---------------------------------------------------------

	// The short-face bar: free-standing mid-arena, 40px wide but only 3px tall, so its LEFT and
	// RIGHT faces are 3px — shorter than one 5px tooth tile. Shared between Obstacles and
	// SpikedObstacleSides (matched by value), and read back by the OnStageEnter verdict check.
	private static readonly RectF TileOverhangBar = new( 100f, 100f, 140f, 103f );


	/// <summary>Console command: jump straight into the spike-tile overhang regression level (the
	/// tile-overhang-repro level). The verdict is measured and logged immediately on
	/// load — no interaction needed. Tiles centred on their sub-tile faces (overhang split evenly,
	/// ≤ 1px per corner here) = healthy; the whole 2px excess poking past one corner (visible as
	/// spikes jutting below the bar's bottom corners) = the one-sided flush-clamp bug.</summary>
	[ConCmd( "tile_overhang_repro" )]
	public static void LoadTileOverhangReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] tile_overhang_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( TileOverhangReproId );
		Log.Info( "[BlockParty] Loading tile-overhang-repro level. Verdict logs immediately; also look at the bar's bottom corners for spikes poking past them." );
	}

	// --- reverse-gravity underside-ride detach repro ----------------------------------------


	/// <summary>Console command: jump straight into the underside-ride detach regression level (the
	/// reverse-detach-repro level) as the Original character — regardless of the
	/// title-screen pick. Once it loads, don't touch anything: the reverse field lifts the player onto
	/// the boxed block's underside, then the script kills the block to cut the field mid-hang. The
	/// player detaching and falling back to the pedestal = healthy; the player snapping THROUGH the
	/// block onto its top in one tick = the missing un-reverse detach in HandleMovingPlatforms. The
	/// verdict is logged either way.</summary>
	[ConCmd( "reverse_detach_repro" )]
	public static void LoadReverseDetachReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] reverse_detach_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( ReverseDetachReproId, Characters.OriginalId );
		Log.Info( "[BlockParty] Loading reverse-detach-repro level as Original. Don't move: the field lifts you under the boxed block, then cuts out mid-hang. Falling back down = healthy; teleporting through the block onto its top = underside-ride detach bug. Watch for the verdict log." );
	}

	// --- moving-platform edge-hang repro ----------------------------------------------------

	// Seeded travel speeds: the floor block starts 25 px/s faster than the wall block (mirroring the
	// replay this came from: ~97 vs ~120) and crosses the 120 px/s line — one lean-margin per tick,
	// where the old stale-position ledge release false-fired — within ~0.2s of load. Both accelerate
	// at the shared 50 px/s² from there, so the differential holds until the floor block slams the
	// right arena wall (~165 px/s, ~1.1s in).
	private const float EDGE_HANG_WALL_BLOCK_SPEED = 85f;
	private const float EDGE_HANG_FLOOR_BLOCK_SPEED = 100f;

	/// <summary>Console command: jump straight into the moving-platform edge-hang repro level with the
	/// CURRENTLY SELECTED character (it exercises shared riding code, so test it with several). Layout:
	/// the player starts in the corner of a wall block (standing against its right face, near its
	/// bottom) and a flush floor block whose left edge sits at the player's feet — both heading right,
	/// the floor block seeded faster so it pulls ahead, exactly the replay this reproduces. SHIFTER:
	/// hold Down (walk toward the floor block); the corner squeeze flips gravity to Down and parks you
	/// on the floor block's trailing edge as it outruns you — riding along near the trailing edge =
	/// healthy; rocketing rightward across the block with no input = the stale-position ledge release
	/// is back. OTHER characters: you spawn riding the floor block; hold Left (against its travel) to
	/// walk to its trailing edge — one clean release when you genuinely cross the lip = healthy;
	/// releasing early with a rightward velocity kick while visibly still on the block = regression.
	/// Doing nothing rides the block into the right-wall slam (inertia fling right = intended).</summary>
	[ConCmd( "edge_hang_repro" )]
	public static void LoadEdgeHangReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] edge_hang_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( EdgeHangReproId );
		Log.Info( "[BlockParty] Loading edge-hang-repro level with the selected character. SHIFTER: hold Down in the corner and watch the trailing-edge ride. Others: hold Left while riding the fast floor block to walk to its trailing edge. Clean single release at the lip = healthy; a no-input rightward rocket = stale-position ledge release regression." );
	}

	// --- shifter repros ----------------------------------------------------------------------

	/// <summary>Console command: jump into the Shifter underside corner-sliver repro as GYRO (the
	/// Shifter), regardless of the title-screen pick. Jump to stick to the left slab's underside,
	/// hold Right to walk off its lip, and LET GO of Right once you're off — you up-fall into the
	/// wedged block's bottom-left corner with only a ~1px sliver of overlap, centre past the lean
	/// margin. Deflecting past the corner and rising to the top wall = healthy; freezing in mid-air
	/// at the corner (attach/release churn in shifter_trace) = the missing up-gravity catch refusal.
	/// The verdict is logged either way; overshooting onto the block underside logs a retry hint
	/// (press Down to jump back to the pedestal).</summary>
	[ConCmd( "shifter_underside_repro" )]
	public static void LoadShifterUndersideReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] shifter_underside_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( ShifterUndersideReproId, Characters.Shifter.Id );
		Log.Info( "[BlockParty] Loading shifter-underside-repro as GYRO. Jump to grab the slab's underside, hold Right off its lip, then let go. Rising to the top wall = healthy; freezing at the block's corner = underside catch bug. shifter_trace + crush_trace give the tick detail. Watch for the verdict log." );
	}

	/// <summary>Console command: jump into the Shifter tight-gap adoption repro as GYRO. The player
	/// spawns standing in a 9px slot between two obstacle walls — 1px narrower than the sideways
	/// 10px hitbox. Both walls are inside the 1px contact probe, so the side adoption fires by
	/// itself on the first tick: don't touch anything. Surviving (the wall touch simply doesn't
	/// take) = healthy; an instant crush death on load = the rotate-then-resolve-or-die adoption.
	/// The verdict is logged either way.</summary>
	[ConCmd( "shifter_gap_repro" )]
	public static void LoadShifterGapReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] shifter_gap_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( ShifterGapReproId, Characters.Shifter.Id );
		Log.Info( "[BlockParty] Loading shifter-gap-repro as GYRO. Don't touch anything: the 9px slot adopts a side wall on tick one. Still standing after a few seconds = healthy; instant squish on load = tight-gap adoption death. Watch for the verdict log." );
	}

	/// <summary>Console command: jump into the Shifter reverse-field repro as GYRO. Same boxed
	/// Reverse block layout as reverse_detach_repro, but the player spawns AIRBORNE under the box's
	/// left bottom-lip obstacle. Don't touch anything: the field (airborne-only for the Shifter)
	/// lifts them into the lip's underside, the touch adopts it as the real floor, and the script
	/// then kills the Reverse block to cut the field. Still hanging afterwards = healthy; falling on
	/// the cut = the rest was only field-held; floor↔ceiling bouncing before any settle = the old
	/// in-field adoption ping-pong. The verdict is logged either way.</summary>
	[ConCmd( "shifter_reverse_repro" )]
	public static void LoadShifterReverseReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] shifter_reverse_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( ShifterReverseReproId, Characters.Shifter.Id );
		Log.Info( "[BlockParty] Loading shifter-reverse-repro as GYRO. Don't move: the field lifts you into the lip's underside, you adopt it, then the field is cut. Still hanging = healthy; dropping on the cut (or endless bouncing) = regression. Watch for the verdict log." );
	}

	// --- riding ceiling-glue misfire repro ---------------------------------------------------

	/// <summary>Console command: jump into the riding ceiling-glue misfire repro as JACK (the Spring),
	/// regardless of the title-screen pick. HOLD LEFT + UP from the moment the level loads (while
	/// still falling — a grounded Up press would jump instead). You land on the block and walk left
	/// while it accelerates rightward, carrying your head under the static overhang's left edge at
	/// ~0.9s. That first brush tick samples CollidingUp BEFORE your leftward walk is applied, so the
	/// fresh-grab probe misses at the walked position: the ceiling glue then runs against the RIDDEN
	/// block. One-tick teleport to the block's underside (a ~50px down + rideDX sideways snap) = BUG
	/// REPRODUCED; briefly clinging/surfing the overhang's edge at head height with no snap = healthy.
	/// The verdict is logged either way.</summary>
	[ConCmd( "ride_cling_repro" )]
	public static void LoadRideClingReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] ride_cling_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( RideClingReproId, Characters.Spring.Id );
		Log.Info( "[BlockParty] Loading ride-cling-repro as JACK. Hold LEFT + UP from load (press while falling!). The block carries you under the overhang's edge: a one-frame snap to the block's underside = ceiling glue fired while Riding; hanging/surfing at head height = healthy. Watch for the verdict log." );
	}

	// --- block-vs-glass-pillar pinch repro ---------------------------------------------------


	/// <summary>Console command: jump into the glass-pillar pinch repro (the glass-pillars witch
	/// report) as the WITCH. Geometry is glass-pillars' obstacle #3 verbatim (glass, x 158–173,
	/// y 32–238) plus one Sad block spawned in the bottom-right corner and driven LEFT at a clamped
	/// crawl. Hold RIGHT from load (one continuous hold — a double-tap would blink): you wall-hug
	/// the block's left face while the script pins the reported pose (bottom edge 2px below the
	/// block's top, slight upward velocity), lets the face grind your left edge a sub-residual-EPS
	/// hair into the glass, then flips the block rightward — the Hunter-brake/block-bounce reversal
	/// from the video. On a FIXED build the pinch resolves first: the embed is cleaned to flush
	/// every tick and the corner deflect pops the player 2px onto the block's top (NO TELEPORT —
	/// the flip is never even reached). On an unfixed build the embed survives, the flip fires,
	/// and the sweep takes the pillar's along-move push-out as a wall hit, wall-hug-attaches to it
	/// face-Right, and glue-snaps to its LEFT side = BUG REPRODUCED. The verdict is logged either
	/// way — run <c>crush_trace</c> first for the per-tick resolve detail.</summary>
	[ConCmd( "pillar_pinch_repro" )]
	public static void LoadPillarPinchReproCmd()
	{
		if ( !Game.IsEditor ) return;

		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] pillar_pinch_repro: no GameManager in the scene." );
			return;
		}

		gm.StartLevel( PillarPinchReproId, Characters.Blinker.Id );
		Log.Info( "[BlockParty] Loading pillar-pinch-repro as WITCH. Press and HOLD RIGHT from load (no tapping — that blinks). The script grinds you a hair into the glass pillar, then flips the block away. Staying right of the pillar = healthy; snapping through to its left = the reported bug. Watch for the verdict log." );
	}

	// Measure the built tile sprites of one short face against its authored segment and log any
	// overhang past either corner. Returns true if every tile respects the centring contract,
	// false if one overhangs, or null if the face wasn't found at all (repro broken, no verdict).
	private static bool? CheckTileOverhang( GameStage stage, Obstacle ob, Direction normal )
	{
		float faceX = normal == Direction.Left ? TileOverhangBar.Left : TileOverhangBar.Right;
		var face = stage.FindObstacleFace( ob, normal, new Vector2( faceX, (TileOverhangBar.Bottom + TileOverhangBar.Top) / 2f ) );
		if ( face is null )
		{
			Log.Warning( $"[BlockParty] tile_overhang_repro: no {normal} face found on the bar — level geometry changed?" );
			return null;
		}

		// Vertical faces tile along Y; each tile quad is centred on its sprite's world position,
		// with an along-extent of the Size box's Y (an aspect-fit box the 4x5 side-tile art fills
		// vertically). A face shorter than the tile CANNOT avoid overhanging (the tile stays
		// full-size by design), so the healthy criterion is the fix's contract: the tile is
		// centred, i.e. neither end overhangs by more than the unavoidable (tile - face) / 2.
		// The bug dumped the whole excess past the START corner.
		bool ok = true;
		float faceLen = face.Segment.YMax - face.Segment.YMin;
		foreach ( var s in face.TeethSprites )
		{
			float c = s.GameObject.WorldPosition.y;
			float half = s.Size.y / 2f;
			float allowed = MathF.Max( 0f, (half * 2f - faceLen) / 2f );
			float startOverhang = face.Segment.YMin - (c - half);
			float endOverhang = (c + half) - face.Segment.YMax;
			if ( startOverhang > allowed + 0.01f || endOverhang > allowed + 0.01f )
			{
				ok = false;
				Log.Info( $"[BlockParty] tile_overhang_repro: {normal} face tile at y={c} overhangs its segment (y {face.Segment.YMin}..{face.Segment.YMax}) by {startOverhang:0.##}px past the start corner, {endOverhang:0.##}px past the end (allowed: {allowed:0.##}px per end)." );
			}
		}
		return ok;
	}

	// One-shot latches for the wrap repro's scripted spike graft and console verdict, reset by
	// OnStageEnter. Static (not per-stage) is fine here: only one GameStage exists at a time.
	private static bool _wrapSpikesAdded;
	private static bool _wrapVerdictLogged;

	// Scripted-driver state for the reverse-detach repro, reset by OnStageEnter (static is fine:
	// one GameStage at a time). The hang counter must SETTLE (consecutive ticks at hanging height)
	// before the kill, so the one-off bottom-face press bounce on the way up can't cut the field
	// while the player is still un-attached mid-air.
	private static int _reverseHangTicks;
	private static bool _reverseFieldCut;
	private static bool _reverseVerdictLogged;
	private static int _reverseTicksSinceCut;

	// Hanging under the boxed block (underside y=100) puts the player centre at y≈95; above this
	// height they can only be pressed against / attached to the underside.
	private const float REVERSE_HANG_MIN_Y = 92f;
	// Consecutive ticks at hanging height before the scripted kill (~0.5s: attach is stable by then).
	private const int REVERSE_HANG_SETTLE_TICKS = 30;
	// Verdict bands after the cut: above the block's top region = teleported through it (the glue
	// snap puts the centre at ≈145); back below the hang = detached and falling, as a fixed build
	// should. The gap between the bands can only be crossed moving toward one of them.
	private const float REVERSE_BUG_MIN_Y = 120f;
	private const float REVERSE_HEALTHY_MAX_Y = 85f;
	// Ticks after the cut before giving up on a verdict (~4s; the real outcome lands within ~1 tick
	// for the bug, ~15 for the healthy fall).
	private const int REVERSE_VERDICT_TIMEOUT_TICKS = 240;

	/// <summary>The repro's Reverse block, or null if the level geometry changed out from under it.</summary>
	private static BlockReverse FindReverseBlock( GameStage stage )
	{
		foreach ( var b in stage.GetBlocks() )
			if ( b is BlockReverse br )
				return br;
		return null;
	}

	// Scripted-verdict state for the three Shifter repros, reset by OnStageEnter (static is fine:
	// one GameStage at a time).
	// Underside: armed once the player has walked off the slab's lip toward the wedged block; then
	// either they rise free past the block (healthy), park motionless at its corner sliver (bug), or
	// land solidly on the underside (overshoot — retry hint).
	private static bool _shifterUndersideArmed;
	private static bool _shifterUndersideVerdictLogged;
	private static int _shifterUndersideStillTicks;
	private static float _shifterUndersideLastY;
	private static int _shifterUndersideTicksSinceArm;
	// Gap: the slot adopts a side wall on tick one by itself; death = bug, surviving = healthy.
	private static bool _shifterGapVerdictLogged;
	private static int _shifterGapTicks;
	// Reverse: the airborne spawn must rise (field affects airborne players), ADOPT the box's lip
	// underside for real, and then survive the scripted field cut still attached. Oscillation before
	// the cut = the in-field adoption regressed (old ping-pong); falling after the cut = field exit
	// detached them (old field-held-only rest).
	private static bool _shifterReverseVerdictLogged;
	private static int _shifterReverseTicks;
	private static int _shifterReverseCrossings;
	private static bool _shifterReverseWasAbove;
	private static int _shifterReverseSettleTicks;
	private static bool _shifterReverseFieldCut;
	private static float _shifterReverseLastY;
	private static int _shifterReverseTicksSinceCut;

	// Underside repro geometry (must match shifter-underside-repro.json): the wedged block spans
	// (106,92)-(146,132), so its exposed bottom-left corner sliver is at (106, 92). The player walks
	// off the slab (right edge x=88) and up-falls into it.
	private const float UNDERSIDE_BLOCK_LEFT = 106f;
	private const float UNDERSIDE_BLOCK_BOTTOM = 92f;
	private const int UNDERSIDE_STILL_VERDICT_TICKS = 45;
	private const int UNDERSIDE_TIMEOUT_TICKS = 600;
	private const int GAP_VERDICT_TICKS = 300;
	// Reverse repro bands: the player spawns airborne at y=85 under the box's left bottom-lip
	// obstacle (underside y=92); the field lifts them 2px and the adoption hangs them at centre
	// y≈87. Crossing y=50 repeatedly (arena floor ↔ lip) = ping-pong; y<70 after the field cut =
	// they fell, i.e. the attachment was only field-held.
	private const float REVERSE_SETTLE_MIN_Y = 84f;
	private const int REVERSE_SETTLE_TICKS = 30;
	private const float REVERSE_PINGPONG_MIDLINE_Y = 50f;
	private const int REVERSE_PINGPONG_BUG_CROSSINGS = 4;
	private const float REVERSE_DETACH_BUG_MAX_Y = 70f;
	private const int REVERSE_HOLD_HEALTHY_TICKS = 120;
	private const int REVERSE_PINGPONG_TIMEOUT_TICKS = 600;

	// Scripted-verdict state for the ride-cling repro, reset by OnStageEnter (static is fine: one
	// GameStage at a time). Armed once the player is riding the block at head height near the
	// overhang while actually walking left (the leftward walk IS the stale-probe offset the bug
	// needs — without it the first-contact grab succeeds in every build and there's no verdict).
	private static bool _rideClingVerdictLogged;
	private static bool _rideClingArmed;
	private static int _rideClingTicks;
	private static int _rideClingTicksSinceArm;
	private static float _rideClingLastX;
	private static float _rideClingLastY;

	// Ride-cling repro geometry/tuning (must match ride-cling-repro.json): the block (top y=80)
	// carries the player (standing centre y=85) under the overhang (bottom y=90, left edge x=80).
	// The buggy glue snaps the centre to the block's underside (y=35) in ONE tick — a 50px drop; no
	// legitimate motion in this layout moves 30px in a tick (max freefall here is ~7px/tick).
	private const float RIDE_CLING_SNAP_DY = 30f;
	// Armed: at standing/hanging height (y≈85), near the overhang's left edge, walking left.
	private const float RIDE_CLING_ARM_MIN_Y = 83f;
	private const float RIDE_CLING_ARM_MAX_Y = 87f;
	private const float RIDE_CLING_ARM_MIN_X = 72f;
	private const float RIDE_CLING_ARM_MAX_VELX = -15f;
	// The brush happens ~11 ticks after arming; 2s of no snap after arming = the glue never fired.
	private const int RIDE_CLING_HEALTHY_TICKS = 120;
	// Ticks before giving up when the arm condition never comes (inputs not held).
	private const int RIDE_CLING_TIMEOUT_TICKS = 600;

	// Scripted-driver state for the pillar-pinch repro, reset by OnStageEnter (static is fine: one
	// GameStage at a time). Pose phase: every tick until the block face reaches the arm line, the
	// script re-pins the reported pose (bottom edge 2px below the block's top, slight rise) and
	// re-clamps the block's speed (its own acceleration would blow past the half-pixel window the
	// bug needs) while the human's held RIGHT does the hugging; X is never touched so the real
	// attach/press machinery engages. Armed: watch the grind until the player's left edge is a
	// SUB-RESIDUAL-EPS hair (0.15..0.5px) inside the pillar, then FLIP the block to the right —
	// the Hunter brake / block-bounce reversal from the video. The receding face opens a gap, the
	// held-right sweep gets a Right push-out from the pillar on the LEFT, and an unfixed build
	// wall-hug-attaches face-Right to the pillar and glue-snaps through it next tick.
	private static bool _pillarPinchArmed;
	private static bool _pillarPinchFlipped;
	private static bool _pillarPinchVerdictLogged;
	private static int _pillarPinchTicksSinceFlip;
	private static float _pillarPinchLastX;

	// Pillar-pinch repro geometry (must match pillar-pinch-repro.json): the glass pillar is
	// glass-pillars' obstacle #3 verbatim — x 158..173, y 32..238 — and the 40x40 Sad block rests on
	// the floor (top y=42), driven LEFT from the bottom-right corner.
	private const float PILLAR_PINCH_PILLAR_RIGHT = 173f;
	// Reported pose, held until arm: player bottom = block top - 2 (centre = top + 3), rising gently.
	private const float PILLAR_PINCH_POSE_RISE_VELY = 20f;
	// Arm when the block's left face reaches here: the hugged player's left edge sits at ~173.5,
	// half a pixel off the pillar — the exact "left side meets the pillar's right side" moment.
	private const float PILLAR_PINCH_ARM_BLOCK_LEFT = 181.5f;
	// Clamped block speed: 18 px/s = ~0.31px/tick, so the per-tick pillar embedding lands inside
	// [FLIP_MIN_PEN, RESIDUAL_PEN_EPS) with room to spare (worst first crossing ≈ 0.46px). Deeper
	// than the EPS at a tick start and the residual net crushes first — the (already-verified)
	// designed outcome, not the bug.
	private const float PILLAR_PINCH_BLOCK_SPEED = 18f;
	private const float PILLAR_PINCH_FLIP_MIN_PEN = 0.15f;
	// A hugged player's left edge can legally end up at most a whisker inside the pillar; the centre
	// crossing the pillar's midline (165.5) is reachable only by going THROUGH it. One-tick sideways
	// jumps past this also flag immediately (the buggy glue snap is a ~23px single-tick move).
	private const float PILLAR_PINCH_BUG_MAX_X = 165f;
	private const float PILLAR_PINCH_TELEPORT_DX = 8f;
	// Healthy: two post-flip seconds still on the pillar's right side. Anything unresolved after 10s = stuck.
	private const int PILLAR_PINCH_HEALTHY_TICKS = 120;
	private const int PILLAR_PINCH_TIMEOUT_TICKS = 600;

	// Graft the floor spikes when the falling player is this close above the floor: from here the
	// fall to the wall (~57px, arriving ~0.18s later at ~300px/s) sits comfortably inside the 0.44s
	// grow-in, so the face is still "switching" for the whole crossing.
	private const float WRAP_SPIKE_TRIGGER_Y = 60f;
	private const float WRAP_SPIKE_LIVE_TIME = 2f;

	/// <summary>Test-level scripted setup, called at the end of <c>GameStage.OnEnter</c> (after
	/// blocks and player are spawned). No-op on non-test levels.</summary>
	public static void OnStageEnter( GameStage stage )
	{
		switch ( stage.Level?.Id )
		{
			case CrushReproId:
				// The block must open by slamming LEFT into the wall lip (see BuildCrushRepro) —
				// a random opening direction would delay the repro by several bounces.
				stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Left );
				break;

			case WrapReproId:
				_wrapSpikesAdded = false;
				_wrapVerdictLogged = false;
				break;

			case ReverseDetachReproId:
			{
				_reverseHangTicks = 0;
				_reverseFieldCut = false;
				_reverseVerdictLogged = false;
				_reverseTicksSinceCut = 0;

				// The field is off at phase 0 and there's no way to press the boxed block's sides
				// without input, so spawn it straight at phase 1 (the mimic-swap helper — no phase-up
				// transition or scoring). Its 120px field then covers the pedestal + underside.
				var reverse = FindReverseBlock( stage );
				if ( reverse is null )
					Log.Warning( "[BlockParty] reverse_detach_repro: no Reverse block spawned — level def changed? No repro." );
				else
					reverse.SetInitialPhase( 1 );
				break;
			}

			case EdgeHangReproId:
			{
				// Both blocks head RIGHT with a seeded speed differential (see the constants above) —
				// random opening directions or a standing start would take several bounces to
				// reassemble the corner + faster-floor-block situation the repro is about. The wall
				// block is the HIGH one (y=217 vs the floor block's 177); picked by height rather than
				// spawn index so a reordered level def can't silently swap the roles.
				foreach ( var block in stage.GetBlocks() )
				{
					bool isWallBlock = block.Y > 200f;
					block.DebugSetMoveDirection( Direction.Right );
					block.DebugSetMoveSpeed( isWallBlock ? EDGE_HANG_WALL_BLOCK_SPEED : EDGE_HANG_FLOOR_BLOCK_SPEED );
				}
				break;
			}

			case ShifterUndersideReproId:
			{
				_shifterUndersideArmed = false;
				_shifterUndersideVerdictLogged = false;
				_shifterUndersideStillTicks = 0;
				_shifterUndersideLastY = 0f;
				_shifterUndersideTicksSinceArm = 0;

				// Wedge the block vertically between the flush top/bottom obstacles (it spawns with
				// zero clearance): a vertical travel axis means it never moves horizontally either,
				// so its exposed bottom-left corner stays exactly where the repro needs it.
				stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Up );
				break;
			}

			case ShifterGapReproId:
				_shifterGapVerdictLogged = false;
				_shifterGapTicks = 0;
				break;

			case ShifterReverseReproId:
			{
				_shifterReverseVerdictLogged = false;
				_shifterReverseTicks = 0;
				_shifterReverseCrossings = 0;
				_shifterReverseWasAbove = false;
				_shifterReverseSettleTicks = 0;
				_shifterReverseFieldCut = false;
				_shifterReverseLastY = 0f;
				_shifterReverseTicksSinceCut = 0;

				// Same as reverse_detach_repro: the boxed block's field is off at phase 0 and nothing
				// can press its sides, so spawn it straight at phase 1.
				var reverse = FindReverseBlock( stage );
				if ( reverse is null )
					Log.Warning( "[BlockParty] shifter_reverse_repro: no Reverse block spawned — level def changed? No repro." );
				else
					reverse.SetInitialPhase( 1 );
				break;
			}

			case RideClingReproId:
			{
				_rideClingVerdictLogged = false;
				_rideClingArmed = false;
				_rideClingTicks = 0;
				_rideClingTicksSinceArm = 0;
				_rideClingLastX = 0f;
				_rideClingLastY = 0f;

				// The block must carry the player rightward INTO the overhang's left edge. No speed
				// seed: the timing math relies on the natural accelerate-from-rest ramp (50 px/s²),
				// which keeps the closing speed on the overhang below one tick of walk speed at the
				// brush (~0.87s in) — the guarantee that the first contact tick is a stale one.
				stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Right );
				break;
			}

			case PillarPinchReproId:
			{
				_pillarPinchArmed = false;
				_pillarPinchFlipped = false;
				_pillarPinchVerdictLogged = false;
				_pillarPinchTicksSinceFlip = 0;
				_pillarPinchLastX = 0f;

				// Drive the block LEFT; the tick script re-clamps the speed every tick until the
				// scripted reversal (see PILLAR_PINCH_BLOCK_SPEED for why it must stay this slow).
				stage.GetBlocks()[0].DebugSetMoveDirection( Direction.Left );
				stage.GetBlocks()[0].DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
				break;
			}

			case TileOverhangReproId:
			{
				// Faces are all built by now (arena + obstacles precede this hook); measure the
				// bar's two sub-tile faces and log the verdict immediately.
				var ob = stage.GetObstacles()[0];
				bool? left = CheckTileOverhang( stage, ob, Direction.Left );
				bool? right = CheckTileOverhang( stage, ob, Direction.Right );
				if ( left is null || right is null )
					Log.Warning( "[BlockParty] tile_overhang_repro: INCONCLUSIVE — a short face is missing (see warning above), so the overhang contract wasn't measured. Fix the repro before trusting any verdict." );
				else
					Log.Info( left.Value && right.Value
						? "[BlockParty] tile_overhang_repro: HEALTHY — the sub-tile faces' tiles are centred; no corner overhangs more than the unavoidable (tile - face) / 2."
						: "[BlockParty] tile_overhang_repro: BUG REPRODUCED — a tooth tile dumps its whole excess past one face corner (one-sided flush-clamp in BuildTiledFace); see the bar's bottom corners." );
				break;
			}
		}
	}

	/// <summary>Test-level scripted driver, called once per fixed step from <c>GameStage.Tick</c>
	/// (after entities have ticked). No-op on non-test levels.</summary>
	public static void OnStageTick( GameStage stage )
	{
		switch ( stage.Level?.Id )
		{
			case WrapReproId: TickWrapRepro( stage ); break;
			case ReverseDetachReproId: TickReverseDetachRepro( stage ); break;
			case ShifterUndersideReproId: TickShifterUndersideRepro( stage ); break;
			case ShifterGapReproId: TickShifterGapRepro( stage ); break;
			case ShifterReverseReproId: TickShifterReverseRepro( stage ); break;
			case RideClingReproId: TickRideClingRepro( stage ); break;
			case PillarPinchReproId: TickPillarPinchRepro( stage ); break;
		}
	}

	/// <summary>Ride-cling repro's per-tick verdict: any one-tick drop of 30px+ = BUG REPRODUCED
	/// (the ceiling glue ran on the Riding attachment and snapped the player to the ridden block's
	/// underside — nothing legitimate in this layout teleports). Armed once the player is riding at
	/// head height near the overhang while walking left; two snap-free seconds after arming =
	/// HEALTHY. Never arming (inputs not held) or dying logs INCONCLUSIVE.</summary>
	private static void TickRideClingRepro( GameStage stage )
	{
		var player = stage.Player;
		if ( player is null || player.IsDead )
		{
			if ( !_rideClingVerdictLogged && player is not null )
			{
				_rideClingVerdictLogged = true;
				Log.Warning( "[BlockParty] ride_cling_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
			}
			return;
		}
		if ( _rideClingVerdictLogged ) return;
		_rideClingTicks++;

		// Teleport watch, live from tick 2: the buggy glue moves the centre from y≈85 to y≈35 (and
		// yanks X by the block's displacement since ride-attach) inside a single tick.
		float dY = player.Y - _rideClingLastY;
		float dX = player.X - _rideClingLastX;
		bool teleported = _rideClingTicks > 1 && dY <= -RIDE_CLING_SNAP_DY;
		_rideClingLastX = player.X;
		_rideClingLastY = player.Y;
		if ( teleported )
		{
			_rideClingVerdictLogged = true;
			Log.Info( $"[BlockParty] ride_cling_repro: BUG REPRODUCED — one-tick teleport to the ridden block's underside (dY={dY:0.0}, dX={dX:0.0}, now y={player.Y:0.0}): the ceiling-cling glue ran while _attach == Riding, treating the platform as the clung ceiling (stale CollidingUp + missed fresh-grab probe)." );
			return;
		}

		if ( !_rideClingArmed )
		{
			if ( player.Y > RIDE_CLING_ARM_MIN_Y && player.Y < RIDE_CLING_ARM_MAX_Y
				&& player.X > RIDE_CLING_ARM_MIN_X && player.VelX < RIDE_CLING_ARM_MAX_VELX )
			{
				_rideClingArmed = true;
				Log.Info( "[BlockParty] ride_cling_repro: riding toward the overhang while walking left — the edge brush comes within a quarter second. Keep holding LEFT + UP..." );
			}
			else if ( _rideClingTicks > RIDE_CLING_TIMEOUT_TICKS )
			{
				_rideClingVerdictLogged = true;
				Log.Warning( "[BlockParty] ride_cling_repro: INCONCLUSIVE — never saw the ride-and-walk-left setup (were LEFT + UP held from load, with UP pressed while still falling?). Reload to retry." );
			}
			return;
		}

		_rideClingTicksSinceArm++;
		if ( _rideClingTicksSinceArm >= RIDE_CLING_HEALTHY_TICKS )
		{
			_rideClingVerdictLogged = true;
			Log.Info( $"[BlockParty] ride_cling_repro: HEALTHY — the edge brush passed with no teleport (y={player.Y:0.0}); the glue stayed off the Riding attachment. (Valid only if LEFT + UP stayed held through the brush.)" );
		}
	}

	/// <summary>Shifter underside repro's per-tick verdict: once the player has walked off the slab's
	/// lip (armed), rising well past the wedged block = HEALTHY (the corner-sliver catch was refused
	/// and deflected); parking motionless at the block's bottom-left corner with the centre still past
	/// the lean margin = BUG REPRODUCED; parking on the underside with the centre over the block =
	/// overshoot, retry. Timeout logs INCONCLUSIVE with the parked position.</summary>
	private static void TickShifterUndersideRepro( GameStage stage )
	{
		var player = stage.Player;
		if ( player is null || player.IsDead )
		{
			if ( !_shifterUndersideVerdictLogged && player is not null )
			{
				_shifterUndersideVerdictLogged = true;
				Log.Warning( "[BlockParty] shifter_underside_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
			}
			return;
		}
		if ( _shifterUndersideVerdictLogged ) return;

		// Armed once clearly past the slab's right edge (x=88) while at underside height — i.e. they
		// walked off the lip and are up-falling toward the block's corner.
		if ( !_shifterUndersideArmed )
		{
			if ( player.X > 96f && player.Y > 80f ) _shifterUndersideArmed = true;
			else return;
		}
		_shifterUndersideTicksSinceArm++;

		bool still = Math.Abs( player.Y - _shifterUndersideLastY ) < 0.05f;
		_shifterUndersideLastY = player.Y;
		_shifterUndersideStillTicks = still ? _shifterUndersideStillTicks + 1 : 0;

		if ( player.Y > 150f )
		{
			_shifterUndersideVerdictLogged = true;
			Log.Info( $"[BlockParty] shifter_underside_repro: HEALTHY — the corner-sliver catch was refused and deflected; the player rose free past the block (y={player.Y:0.0})." );
		}
		else if ( _shifterUndersideStillTicks >= UNDERSIDE_STILL_VERDICT_TICKS
			&& player.Y > 82f && player.Y < UNDERSIDE_BLOCK_BOTTOM )
		{
			_shifterUndersideVerdictLogged = true;
			// The lean margin is 2px: parked with the centre still short of Left-2 means the catch
			// held a sliver it should have refused; centre at/over the margin is a legitimate rest.
			if ( player.X < UNDERSIDE_BLOCK_LEFT - 2f )
				Log.Info( $"[BlockParty] shifter_underside_repro: BUG REPRODUCED — pinned at the block's bottom-left corner sliver (x={player.X:0.0}, centre {UNDERSIDE_BLOCK_LEFT - player.X:0.0}px short of its edge): the up-gravity underside catch refusal is missing. shifter_trace shows the attach/release churn." );
			else
				Log.Info( $"[BlockParty] shifter_underside_repro: OVERSHOOT (no verdict) — you landed with your centre over the block (x={player.X:0.0}), a legitimate corner rest in both builds. Press Down to jump back to the pedestal and retry with an earlier let-go of Right." );
		}
		else if ( _shifterUndersideTicksSinceArm > UNDERSIDE_TIMEOUT_TICKS )
		{
			_shifterUndersideVerdictLogged = true;
			Log.Warning( $"[BlockParty] shifter_underside_repro: INCONCLUSIVE — no verdict band hit after the walk-off (parked at x={player.X:0.0}, y={player.Y:0.0}). If you rose past the block on the LEFT of x=106 that's still healthy behaviour; reload to retry, and use shifter_trace/crush_trace for the tick detail." );
		}
	}

	/// <summary>Shifter gap repro's per-tick verdict: the 9px slot triggers the side-wall adoption by
	/// itself on the first tick (both walls sit inside the 1px contact probe). The rotated 10px hitbox
	/// doesn't fit, so an instant squish = BUG REPRODUCED (rotate-then-resolve-or-die), and still
	/// standing after a few seconds = HEALTHY (the adoption was refused and reverted).</summary>
	private static void TickShifterGapRepro( GameStage stage )
	{
		if ( _shifterGapVerdictLogged ) return;
		var player = stage.Player;
		if ( player is null ) return;

		_shifterGapTicks++;
		if ( player.IsDead )
		{
			_shifterGapVerdictLogged = true;
			Log.Info( $"[BlockParty] shifter_gap_repro: BUG REPRODUCED — the side-wall adoption rotated the hitbox into a slot it doesn't fit and resolve-or-die crushed the player (tick {_shifterGapTicks})." );
		}
		else if ( _shifterGapTicks >= GAP_VERDICT_TICKS )
		{
			_shifterGapVerdictLogged = true;
			Log.Info( "[BlockParty] shifter_gap_repro: HEALTHY — the tight-gap side adoption was refused and reverted; the player is still standing in the slot." );
		}
	}

	/// <summary>Shifter reverse repro's per-tick verdict, in two phases. PHASE 1: the airborne spawn
	/// (y=85) must rise — the field affects airborne surface-gravity players — and ADOPT the box's
	/// lip underside (settle at y≈87); repeated midline crossings instead = BUG (the in-field
	/// adoption regressed to the floor↔ceiling ping-pong). PHASE 2: once settled the script kills
	/// the Reverse block, cutting the field; still hanging afterwards = HEALTHY (the touch adopted
	/// the surface for real, so field exit doesn't detach), falling = BUG (the rest was only
	/// field-held).</summary>
	private static void TickShifterReverseRepro( GameStage stage )
	{
		var player = stage.Player;
		if ( player is null || player.IsDead )
		{
			if ( !_shifterReverseVerdictLogged && player is not null )
			{
				_shifterReverseVerdictLogged = true;
				Log.Warning( "[BlockParty] shifter_reverse_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
			}
			return;
		}
		if ( _shifterReverseVerdictLogged ) return;
		_shifterReverseTicks++;

		// Ping-pong watch: oscillating between the arena floor and the lip crosses y=50 repeatedly.
		bool above = player.Y > REVERSE_PINGPONG_MIDLINE_Y;
		if ( _shifterReverseTicks > 1 && above != _shifterReverseWasAbove ) _shifterReverseCrossings++;
		_shifterReverseWasAbove = above;
		if ( !_shifterReverseFieldCut && _shifterReverseCrossings >= REVERSE_PINGPONG_BUG_CROSSINGS )
		{
			_shifterReverseVerdictLogged = true;
			Log.Info( $"[BlockParty] shifter_reverse_repro: BUG REPRODUCED — {_shifterReverseCrossings} midline crossings before any settle: the in-field touch isn't adopting the surface (floor↔ceiling ping-pong)." );
			return;
		}

		// PHASE 1: wait for a settled hang against the lip underside, then cut the field.
		if ( !_shifterReverseFieldCut )
		{
			bool still = Math.Abs( player.Y - _shifterReverseLastY ) < 0.05f;
			_shifterReverseLastY = player.Y;
			_shifterReverseSettleTicks = still && player.Y > REVERSE_SETTLE_MIN_Y ? _shifterReverseSettleTicks + 1 : 0;
			if ( _shifterReverseSettleTicks >= REVERSE_SETTLE_TICKS )
			{
				var reverse = FindReverseBlock( stage );
				if ( reverse is null || reverse.IsDead )
				{
					_shifterReverseFieldCut = true; // fall through and let the post-cut watch report
					Log.Warning( "[BlockParty] shifter_reverse_repro: Reverse block missing/already dead at the scripted kill — level def changed?" );
					return;
				}

				reverse.Die();
				_shifterReverseFieldCut = true;
				Log.Info( $"[BlockParty] shifter_reverse_repro: adopted the lip underside (y={player.Y:0.0}); field cut — watching whether the attachment survives..." );
			}
			else if ( _shifterReverseTicks > REVERSE_PINGPONG_TIMEOUT_TICKS )
			{
				_shifterReverseVerdictLogged = true;
				Log.Warning( $"[BlockParty] shifter_reverse_repro: INCONCLUSIVE — never settled against the lip underside (y={player.Y:0.0}, {_shifterReverseCrossings} crossings). Did the airborne lift not happen (check the field phase), or did the layout change?" );
			}
			return;
		}

		// PHASE 2: the field is gone; a real adoption keeps hanging, a field-held rest drops.
		_shifterReverseTicksSinceCut++;
		if ( player.Y < REVERSE_DETACH_BUG_MAX_Y )
		{
			_shifterReverseVerdictLogged = true;
			Log.Info( $"[BlockParty] shifter_reverse_repro: BUG REPRODUCED — the player fell (y={player.Y:0.0}) when the field died: the underside rest was only field-held instead of a real base adoption." );
		}
		else if ( _shifterReverseTicksSinceCut >= REVERSE_HOLD_HEALTHY_TICKS )
		{
			_shifterReverseVerdictLogged = true;
			Log.Info( $"[BlockParty] shifter_reverse_repro: HEALTHY — still hanging from the lip underside (y={player.Y:0.0}) two seconds after the field died: the in-field touch adopted the surface for real." );
		}
	}

	/// <summary>Wrap repro's per-tick script: grafts spikes onto the bottom wall just before the
	/// free-falling player crosses it (so the crossing happens during the grow-in transition), then
	/// logs a verdict — BUG REPRODUCED if the player left the arena alive, HEALTHY if they wrapped
	/// to the top mid grow-in.</summary>
	private static void TickWrapRepro( GameStage stage )
	{
		var player = stage.Player;
		if ( player is null || player.IsDead ) return;

		if ( !_wrapSpikesAdded )
		{
			if ( player.Y < WRAP_SPIKE_TRIGGER_Y )
			{
				_wrapSpikesAdded = true;
				stage.AddSpikesToFace( stage.FindArenaFace( Direction.Down, player.Pos ), WRAP_SPIKE_LIVE_TIME );
				Log.Info( "[BlockParty] wrap_repro: floor spikes growing in while the player falls through the wall..." );
			}
			return;
		}

		if ( _wrapVerdictLogged ) return;

		// Fully below the arena floor, still alive: refused the wrap, wasn't clamped, wasn't killed.
		if ( player.GetRect().Top < 0f )
		{
			_wrapVerdictLogged = true;
			Log.Info( "[BlockParty] wrap_repro: BUG REPRODUCED — player escaped the arena through the switching spikes and is falling in the void (delayed offscreen death when they land)." );
		}
		// Back in the top half of the arena: the wrap fired during the grow-in, as it should.
		else if ( player.Y > Arena.HEIGHT / 2f )
		{
			_wrapVerdictLogged = true;
			Log.Info( "[BlockParty] wrap_repro: HEALTHY — player wrapped to the top wall while the floor spikes were still growing in." );
		}
	}

	/// <summary>Reverse-detach repro's per-tick script: wait for the field-lifted player to settle
	/// hanging under the boxed Reverse block, kill the block to cut its field mid-hang, then log the
	/// verdict — BUG REPRODUCED if the player snapped up THROUGH the block onto its top (the top-ride
	/// glue ran on a face-Up attachment), HEALTHY if they detached and fell.</summary>
	private static void TickReverseDetachRepro( GameStage stage )
	{
		var player = stage.Player;
		if ( player is null || player.IsDead )
		{
			// Nothing in the level can kill the player, so a death means the repro geometry broke.
			if ( !_reverseVerdictLogged && player is not null )
			{
				_reverseVerdictLogged = true;
				Log.Warning( "[BlockParty] reverse_detach_repro: INCONCLUSIVE — the player died, which this layout should never allow. Fix the repro before trusting any verdict." );
			}
			return;
		}

		// PHASE 1: wait for a SETTLED hang under the block (the one-off bottom-face press on the way
		// up can bounce them briefly, so require consecutive ticks at hanging height), then cut the
		// field by killing its block — the same "flag drops while attached face-Up" edge as the field
		// drifting off a hanging player mid-game.
		if ( !_reverseFieldCut )
		{
			_reverseHangTicks = player.Y > REVERSE_HANG_MIN_Y ? _reverseHangTicks + 1 : 0;
			if ( _reverseHangTicks < REVERSE_HANG_SETTLE_TICKS ) return;

			var reverse = FindReverseBlock( stage );
			if ( reverse is null || reverse.IsDead )
			{
				_reverseFieldCut = true; // no block to kill — fall through and let the timeout report
				Log.Warning( "[BlockParty] reverse_detach_repro: Reverse block missing/already dead at the scripted kill — level def changed?" );
				return;
			}

			reverse.Die();
			_reverseFieldCut = true;
			Log.Info( $"[BlockParty] reverse_detach_repro: field cut while the player hangs under the block (y={player.Y:0.0}). Watching where they end up..." );
			return;
		}

		if ( _reverseVerdictLogged ) return;
		_reverseTicksSinceCut++;

		// Above the block's top region: only reachable by the glue snapping the player through the
		// solid block (the bug puts their centre at ≈145 in a single tick).
		if ( player.Y > REVERSE_BUG_MIN_Y )
		{
			_reverseVerdictLogged = true;
			Log.Info( $"[BlockParty] reverse_detach_repro: BUG REPRODUCED — player teleported through the block onto its top (y={player.Y:0.0}) the tick the field dropped: HandleMovingPlatforms ran the top-ride glue (Bottom = parent.Top) on a face-Up attachment instead of detaching." );
		}
		// Back below the hang: they detached and fell, as a fixed build should.
		else if ( player.Y < REVERSE_HEALTHY_MAX_Y )
		{
			_reverseVerdictLogged = true;
			Log.Info( $"[BlockParty] reverse_detach_repro: HEALTHY — player detached and fell (y={player.Y:0.0}) when the field dropped mid-hang." );
		}
		else if ( _reverseTicksSinceCut > REVERSE_VERDICT_TIMEOUT_TICKS )
		{
			_reverseVerdictLogged = true;
			Log.Warning( $"[BlockParty] reverse_detach_repro: INCONCLUSIVE — player parked at y={player.Y:0.0} after the cut, in neither verdict band. Did the attach never happen (check the hang height), or did the layout change?" );
		}
	}

	/// <summary>Pillar-pinch repro's per-tick driver + verdict. POSE PHASE (block face right of the
	/// arm line): re-pin the reported pose every tick — centre 3px above the block top (bottom edge
	/// 2px below it) with a gentle rise — and re-clamp the block's speed, while the held RIGHT does
	/// the actual hugging; X is left to the real press/attach machinery. ARMED: let the face grind
	/// the player's left edge a sub-residual-EPS hair into the pillar, then FLIP the block rightward
	/// (the video's Hunter-brake / block-bounce reversal). An unfixed build's rightward sweep now
	/// takes a Right push-out FROM THE PILLAR ON THE LEFT, wall-hug-attaches face-Right to it, and
	/// the glue snaps Right = pillar.Left next tick — the one-tick through-the-glass teleport.
	/// Fixed builds treat the along-sweep push-out as progress and stay on the right side.</summary>
	private static void TickPillarPinchRepro( GameStage stage )
	{
		if ( _pillarPinchVerdictLogged ) return;
		var player = stage.Player;
		var blocks = stage.GetBlocks();
		if ( player is null || blocks.Count == 0 )
			return;
		Block block = blocks[0];

		if ( !_pillarPinchArmed )
		{
			if ( player.IsDead )
			{
				_pillarPinchVerdictLogged = true;
				Log.Warning( "[BlockParty] pillar_pinch_repro: INCONCLUSIVE — the player died before the pinch was even set up, which this layout should never allow. Fix the repro before trusting any verdict." );
				return;
			}

			// Pin the FULL flush hug pose, X included: centre exactly Width/2 left of the face, so
			// there is zero horizontal overlap with the block. Pinning Y alone let X drift a fraction
			// INTO the block, which read as floor (OnFloor from the down-probe overlap) — the hug's
			// grounded release kept detaching, the attach gate refused, and the first pillar contact
			// hit the corner deflect UN-exempted, popping the player harmlessly on top instead of
			// grinding (the first repro run's failure). Flush + airborne, the held-RIGHT sweep
			// wall-stops on the face each tick and the real attach machinery keeps the hug live.
			block.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
			player.X = block.Left - player.Width / 2f;
			player.Y = block.Top + 3f;
			player.VelY = PILLAR_PINCH_POSE_RISE_VELY;
			if ( block.Left > PILLAR_PINCH_ARM_BLOCK_LEFT )
				return;

			_pillarPinchArmed = true;
			_pillarPinchLastX = player.X;
			Log.Info( $"[BlockParty] pillar_pinch_repro: armed — hugging the face at (x={player.X:0.0}, y={player.Y:0.0}), left edge {player.X - 4f - PILLAR_PINCH_PILLAR_RIGHT:+0.0;-0.0}px off the pillar. Grinding until a sub-EPS embed, then flipping the block..." );
			return;
		}

		float dX = player.X - _pillarPinchLastX;
		_pillarPinchLastX = player.X;

		// Grind phase: keep the approach speed clamped; flip the moment the player's left edge is
		// meaningfully but sub-residual-EPS inside the pillar (pen = 177 - centre X).
		if ( !_pillarPinchFlipped )
		{
			if ( player.IsDead )
			{
				_pillarPinchVerdictLogged = true;
				Log.Warning( $"[BlockParty] pillar_pinch_repro: INCONCLUSIVE — crushed before the scripted reversal (the embed overshot the residual EPS window; the approach speed drifted?). Reload to retry." );
				return;
			}
			// Popped onto the block top mid-grind: the corner deflect resolved the pinch UPWARD —
			// the reported "depenetrated upwards" outcome, and the FIXED build's expected ending
			// here (the along-sweep push-out rule cleans the embed to flush each tick, so the
			// pinch never survives to the scripted reversal; the next blind pass's deflect target
			// is then clear and fires). On an unfixed build the embed survives the tick instead,
			// keeps the deflect target blocked, and the grind reaches the flip below.
			if ( player.Y > block.Top + 4.5f )
			{
				_pillarPinchVerdictLogged = true;
				Log.Info( $"[BlockParty] pillar_pinch_repro: NO TELEPORT — the pinch resolved with the designed upward deflect onto the block's top (x={player.X:0.0}, y={player.Y:0.0}) before any embed could survive a tick. This is the fixed build's expected outcome. (Valid only if RIGHT was held from load — with no input the pinch never forms and this pop is trivial.)" );
				return;
			}
			float pen = 4f + PILLAR_PINCH_PILLAR_RIGHT - player.X;
			if ( pen < PILLAR_PINCH_FLIP_MIN_PEN )
			{
				block.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
				return;
			}
			block.DebugSetMoveDirection( Direction.Right );
			block.DebugSetMoveSpeed( PILLAR_PINCH_BLOCK_SPEED );
			_pillarPinchFlipped = true;
			Log.Info( $"[BlockParty] pillar_pinch_repro: flipped the block rightward with the player {pen:0.00}px inside the pillar (x={player.X:0.00}). Watching the sweep..." );
			return;
		}

		_pillarPinchTicksSinceFlip++;

		if ( player.IsDead )
		{
			_pillarPinchVerdictLogged = true;
			Log.Warning( $"[BlockParty] pillar_pinch_repro: INCONCLUSIVE — died after the reversal (tick {_pillarPinchTicksSinceFlip}), which the sub-EPS embed should never allow. Re-run with crush_trace." );
			return;
		}

		// Through the glass: nothing legal moves the centre past the pillar's midline (legal motion
		// parks flush at its right face), and nothing legal jumps 8px+ sideways in one tick.
		if ( player.X <= PILLAR_PINCH_BUG_MAX_X || dX <= -PILLAR_PINCH_TELEPORT_DX )
		{
			_pillarPinchVerdictLogged = true;
			Log.Info( $"[BlockParty] pillar_pinch_repro: BUG REPRODUCED — one-tick pass THROUGH the glass pillar (x={player.X:0.0}, dX={dX:0.0}, tick {_pillarPinchTicksSinceFlip} after the flip): the rightward sweep took the pillar's Right push-out as a wall hit, attached a face-Right WallHug to the pillar on the LEFT, and the glue snapped Right = pillar.Left." );
			return;
		}

		if ( _pillarPinchTicksSinceFlip >= PILLAR_PINCH_HEALTHY_TICKS && player.X > PILLAR_PINCH_PILLAR_RIGHT - 3f )
		{
			_pillarPinchVerdictLogged = true;
			Log.Info( $"[BlockParty] pillar_pinch_repro: HEALTHY — {PILLAR_PINCH_HEALTHY_TICKS} ticks after the reversal the player is still on the pillar's right side (x={player.X:0.0}, y={player.Y:0.0}); the along-sweep push-out was treated as progress, not a wall grab." );
			return;
		}

		if ( _pillarPinchTicksSinceFlip > PILLAR_PINCH_TIMEOUT_TICKS )
		{
			_pillarPinchVerdictLogged = true;
			Log.Warning( $"[BlockParty] pillar_pinch_repro: INCONCLUSIVE — no verdict band hit {PILLAR_PINCH_TIMEOUT_TICKS} ticks after the flip (parked at x={player.X:0.0}, y={player.Y:0.0}, block face at {block.Left:0.0}). crush_trace will show what's holding the state." );
		}
	}
}