DiamondBoard.cs
using System;
using System.Collections.Generic;

namespace Diamonds;

/// <summary>Diamond geometry and gameplay, independent of rendering and screen resolution.</summary>
public sealed partial class DiamondBoard
{
	public const float Width = 56;
	public const float Height = 88;
	public const int Valleys = 17;
	public const int LaneCount = Valleys * 2 - 1;
	// Controlled pieces stay inside the tooth tips, including above the open top.
	// Only automatic settlement may carry gems into the outer wall pockets.
	public const int MinControlledLane = 1;
	public const int MaxControlledLane = LaneCount - 2;
	public const float BoardWidth = Valleys * Width;
	// Keep the current vertical capacity and gem scale when narrowing the board.
	public const float BoardHeight = 981;
	public const int ColorCount = 5;
	public const int MaxHealth = 3;
	public const float DamageSpeedThreshold = Height * 8;
	public const float DamagePauseDuration = 0.10f;
	public const float DamageFlashDuration = 0.18f;
	public const float DamageShineDuration = 0.18f;
	public const float BondPulseDuration = 0.6f;
	// Lowest tip starts 30 units above the playfield, giving the spawn dye a short head start.
	public const float SpawnY = -Height * 0.5f - 48;
	const float Epsilon = 0.01f;
	readonly List<Diamond> settled = new();
	readonly Queue<int> shapeBag = new();
	bool openCornerPockets;
	/// <summary>Changing the arena boundary starts a fresh board to avoid trapping existing pieces.</summary>
	public bool FillCornerPockets
	{
		get => !openCornerPockets;
		set
		{
			if ( value == FillCornerPockets ) return;
			openCornerPockets = !value;
			Reset();
		}
	}
	float groundedTime;
	bool hasLanded;
	Piece? pendingSideLanding;
	// Seconds the landing step is drawn over; its impact speed is the glide's, not one arrival frame's.
	float pendingSideDuration;
	bool pendingRotationLanding;
	float repeatTime;
	float rotateRepeatTime;
	bool rotateHeld;
	float softDropTime;
	float spawnLane = LaneCount / 2;
	const float MoveRepeatDelay = 0.12f;
	const float MoveRepeatInterval = 0.045f;
	const float SoftDropStartSpeed = 12;
	const float SoftDropMaxSpeed = 72;
	const float SoftDropRampDuration = 0.2f;
	const float FaceLandingDelay = 0.04f;
	const float CornerLandingDelay = 0.16f;
	int lastDirection;
	readonly Random random;
	readonly Random effectRandom = new();
	const float ShatterDuration = 0.32f;
	const float GameOverHitPause = 0.12f;
	const float GameOverSpeedStep = 0.15f;
	int gameOverHits;
	readonly List<Diamond> shattering = new();
	public readonly record struct ShatterBurst( long Id, Diamond Cell );
	readonly List<ShatterBurst> shatterBursts = new();
	long shatterSequence;
	public IReadOnlyList<ShatterBurst> ShatterBursts => shatterBursts;
	float shatterTime;
	readonly Queue<DiamondMatching.SettlementMove> cascadeMoves = new();
	float cascadeTime;
	float damagePause;
	readonly List<DamageFlash> damageFlashes = new();
	long damageFlashSequence;
	public readonly record struct DamageFlash( float Lane, float Y, int ColorIndex, float Age = 0, float Rotation = 0, int DamageCount = 1, float Brightness = 0.4f, long Id = 0 )
	{
		public float Scale => 1 + 0.5f * Math.Max( 0, DamageCount - 1 );
	}
	public IReadOnlyList<DamageFlash> DamageFlashes => damageFlashes;
	public readonly record struct CornerSpark( DiamondDamage.Contact Contact, float Speed, long Id );
	readonly List<CornerSpark> cornerSparks = new();
	long cornerSparkSequence;
	public IReadOnlyList<CornerSpark> CornerSparks => cornerSparks;
	public readonly record struct FaceFlash( DiamondDamage.FaceContact Contact, float Age = 0,
		float Seed = 0, float Brightness = 0.4f, int DamageCount = 1, long Id = 0 );
	long faceFlashSequence;
	readonly List<FaceFlash> faceFlashes = new();
	public IReadOnlyList<FaceFlash> FaceFlashes => faceFlashes;
	public sealed class DamageShine
	{
		public float Age { get; internal set; }
		public int Direction { get; internal set; }
		public float ShakeAngle { get; internal set; }
		public List<int> SettledIndices { get; } = new();
		public List<int> ShatteringIndices { get; } = new();
	}
	readonly List<DamageShine> damageShines = new();
	public IReadOnlyList<DamageShine> DamageShines => damageShines;
	public readonly record struct BondPulse( IReadOnlyList<DiamondOutline.Edge> Edges, float Age = 0 );
	readonly List<BondPulse> bondPulses = new();
	public IReadOnlyList<BondPulse> BondPulses => bondPulses;
	public bool IsDamagePaused => damagePause > 0;
	readonly List<Impact> impacts = new();
	public readonly record struct Impact( float VelocityX, float VelocityY, int CellCount, DiamondContact.Faces Faces = DiamondContact.Faces.None )
	{
		/// <summary>Lowest health after this hit among its damaged receivers, including bonded propagation; null for harmless contacts.</summary>
		public int? LowestDamagedHealth { get; init; }
		/// <summary>The player's piece making first contact, by falling, stepping or turning.</summary>
		public bool Active { get; init; }
		/// <summary>The end of a diagonal settlement slide.</summary>
		public bool Slide { get; init; }
		/// <summary>A moving diamond reached the floor (or a filled corner pocket).</summary>
		public bool Floor { get; init; }
		public float Speed => MathF.Sqrt( VelocityX * VelocityX + VelocityY * VelocityY );
		public (float X, float Y) ShakeAxis
		{
			get
			{
				// Only single-face vertical landings lean toward the struck face.
				// Tip contacts, crannies, and diagonal slides retain the movement axis.
				if ( MathF.Abs( VelocityX ) > Epsilon || VelocityY <= 0 ||
					(Faces != DiamondContact.Faces.LowerLeft && Faces != DiamondContact.Faces.LowerRight) )
					return (VelocityX / MathF.Max( 1, Speed ), VelocityY / MathF.Max( 1, Speed ));
				float normalLength = MathF.Sqrt( Height * Height + Width * Width );
				float x = (Faces == DiamondContact.Faces.LowerLeft ? -Height : Height) / normalLength;
				float y = 1 + Width / normalLength;
				// Bisect down and the outward lower-face normal (pointing into the support).
				float length = MathF.Sqrt( x * x + y * y );
				return (x / length, y / length);
			}
		}
	}
	/// <summary>Contacts in the most recent update, emitted at contact rather than after the lock delay.</summary>
	public IReadOnlyList<Impact> Impacts => impacts;
	public int ResetVersion { get; private set; }
	public int SpawnVersion { get; private set; }
	/// <summary>Changes when settled cell indices acquire new owners, even if the count stays the same.</summary>
	public int SettledVersion { get; private set; }
	public float SlideSpeed
	{
		get
		{
			if ( Paused || !IsSliding ) return 0;
			var move = cascadeMoves.Peek();
			var cell = move.Cells[0];
			float dx = (cell.To.Lane - cell.From.Lane) * Width * 0.5f;
			float dy = cell.To.Y - cell.From.Y;
			return MathF.Sqrt( dx * dx + dy * dy ) * move.VelocityScale( cascadeTime / move.Duration, CascadeSpeed );
		}
	}

	// Fractional lanes are used only while animating a diagonal slide.
	// Each cell keeps its color through rotation (three bits per cell, up to four cells).
	public readonly record struct Piece( float Lane, float Y, int ShapeIndex, int Rotation = 0, int Colors = 0, int Health = 255 )
	{
		public int CellColor( int index ) => (Colors >> (index * 3)) & 7;
		public int CellHealth( int index ) => (Health >> (index * 2)) & 3;
	}
	public enum Corner { Top, Right, Bottom, Left }
	public readonly record struct Diamond( float Lane, float Y, int ColorIndex, int Health = MaxHealth, Corner FirstCrack = Corner.Top );
	public IReadOnlyList<Diamond> Settled => settled;
	// Rendering uses the same frozen bonds as settling, never transient side contacts.
	public IReadOnlyList<IReadOnlyList<int>> BondedGroups { get; private set; } = Array.Empty<IReadOnlyList<int>>();
	public Piece Active { get; private set; }
	public Piece Next { get; private set; }
	public int NextShapeIndex => Next.ShapeIndex;
	public int Placed { get; private set; }
	public int Destroyed { get; private set; }
	/// <summary>Diamonds shattered by play this game. Unlike <see cref="Destroyed"/>, game-over cleanup is excluded.</summary>
	public int DiamondsBroken { get; private set; }
	/// <summary>Shards crushed for points this game.</summary>
	public int ShardsCrushed { get; private set; }
	/// <summary>Most diamonds of one established bonded group damaged by a single collision this game. Game-over cleanup is excluded.</summary>
	public int LargestDamagedGroup { get; private set; }
	/// <summary>Most distinct colors shattered by play within one turn (a spawn until the next) this game.</summary>
	public int MostColorsBrokenInTurn { get; private set; }
	// Bit per color shattered during the current turn.
	int turnBrokenColors;
	/// <summary>Size of the largest bonded group resting in the arena the moment the game ended; zero until then.</summary>
	public int LargestGroupAtGameOver { get; private set; }
	/// <summary>Unpaused seconds of <see cref="FirstMinuteScore"/>'s window.</summary>
	public const float FirstMinuteSeconds = 60;
	/// <summary>Score reached within the first <see cref="FirstMinuteSeconds"/> of this game.</summary>
	public long FirstMinuteScore { get; private set; }
	public const int ShardCrushPoints = 1;
	public long Score { get; private set; }
	/// <summary>Debug score applied on the next new game or restart.</summary>
	public long StartingScore { get; set; }
	public float ElapsedSeconds { get; private set; }
	float startingFallSpeed = 1.0f, fallRampMinutes = 10.0f, fallRampMultiplier = 3.0f, fallRampExponent = 2.2f;
	public float StartingFallSpeed
	{
		get => startingFallSpeed > 0 ? startingFallSpeed : 1.0f;
		set => startingFallSpeed = value > 0 ? value : 1.0f;
	}
	public float FallRampMinutes
	{
		get => fallRampMinutes > 0 ? fallRampMinutes : 10.0f;
		set => fallRampMinutes = value > 0 ? value : 10.0f;
	}
	public float FallRampMultiplier
	{
		get => fallRampMultiplier >= 1 ? fallRampMultiplier : 3.0f;
		set => fallRampMultiplier = value >= 1 ? value : 3.0f;
	}
	public float FallRampExponent
	{
		get => fallRampExponent > 0 ? fallRampExponent : 2.2f;
		set => fallRampExponent = value > 0 ? value : 2.2f;
	}
	// The reference multiplier is reached at FallRampMinutes; the curve keeps rising afterward.
	public float CurrentNormalFallSpeed => StartingFallSpeed *
		(1 + (FallRampMultiplier - 1) * MathF.Pow( MathF.Max( 0, ElapsedSeconds / (FallRampMinutes * 60) ), FallRampExponent ));
	float? colorMixStartMinutes, colorMixRampMinutes, colorMixChanceAtReference, colorMixRampExponent, fourColorShare;
	public float ColorMixStartMinutes
	{
		get => colorMixStartMinutes ?? 5.0f;
		set => colorMixStartMinutes = MathF.Max( 0, value );
	}
	public float ColorMixRampMinutes
	{
		get => colorMixRampMinutes ?? 10.0f;
		set => colorMixRampMinutes = value > 0 ? value : 10.0f;
	}
	public float ColorMixChanceAtReference
	{
		get => colorMixChanceAtReference ?? 50.0f;
		set => colorMixChanceAtReference = Math.Clamp( value, 0, 100 );
	}
	public float ColorMixRampExponent
	{
		get => colorMixRampExponent ?? 2.0f;
		set => colorMixRampExponent = value > 0 ? value : 2.0f;
	}
	public float FourColorShare
	{
		get => fourColorShare ?? 50.0f;
		set => fourColorShare = Math.Clamp( value, 0, 100 );
	}
	/// <summary>Chance that an eligible piece uses three or four distinct colors; reaches 100% eventually.</summary>
	public float CurrentColorMixChance
	{
		get
		{
			float elapsed = ElapsedSeconds - ColorMixStartMinutes * 60;
			if ( elapsed <= 0 ) return 0;
			float progress = elapsed / (ColorMixRampMinutes * 60);
			return Math.Clamp( ColorMixChanceAtReference * 0.01f * MathF.Pow( progress, ColorMixRampExponent ), 0, 1 );
		}
	}
	public long NextShardPoints { get; private set; } = ShardCrushPoints;
	// A spawn ends the turn, but debris steps after the board in the same frame, over the final move's last gap
	// where crushes peak. Those still belong to the ending turn, so the award resets on the next board update.
	bool shardTurnEnded;
	public void AwardShardCrush( int count )
	{
		if ( count <= 0 || GameOver ) return;
		Score += count * NextShardPoints + (long)count * (count - 1) / 2;
		NextShardPoints += count;
		ShardsCrushed += count;
		if ( ElapsedSeconds <= FirstMinuteSeconds ) FirstMinuteScore = Score;
	}
	public int Chain { get; private set; }
	public bool IsShattering => shattering.Count > 0;
	public bool IsCascading => cascadeMoves.Count > 0;
	public bool IsResolving => IsShattering || IsCascading || IsDamagePaused;
	// Add half the base speed at each chain stage, capped at 3x.
	// Derive this from the current reaction rather than carrying speed into the next turn.
	float GameOverCleanupSpeed => MathF.Min( 3, 1 + GameOverSpeedStep * Math.Max( 0, gameOverHits - 1 ) );
	public float CascadeSpeed => !IsResolving ? 1 : GameOver
		? GameOverCleanupSpeed
		: 1 + 0.5f * Math.Clamp( Chain - 1, 0, 4 );
	public IReadOnlyList<Diamond> Shattering => shattering;
	public float ShatterProgress => 1 - shatterTime / ShatterDuration;
	public bool GameOver { get; private set; }
	public int GameOverHits => gameOverHits;
	public bool Paused { get; private set; }
	public bool IsSliding => !IsDamagePaused && IsCascading && cascadeMoves.Peek().IsSlide;
	/// <summary>The current rigid sliding assembly, for surface friction effects.</summary>
	public IReadOnlyList<DiamondMatching.CellMove> SlidingCells => IsSliding ? cascadeMoves.Peek().Cells : Array.Empty<DiamondMatching.CellMove>();
	public float LandingY => FindLandingY( Active );

	// Player steps and turns still snap on the lattice; rendering trails each
	// successful one briefly so it reads as motion. Collision is unaffected.
	// Taps ease out. Held repeats are linear over exactly one repeat interval, so
	// consecutive steps join into constant-speed motion instead of pulsing.
	public readonly record struct ControlMotion( float Lane, float Y, float Turns, float Duration, float Age = 0, bool Linear = false )
	{
		/// <summary>The fraction of this step the drawn piece has yet to cover.</summary>
		public float Remaining => Age >= Duration ? 0 : Linear ? 1 - Age / Duration : (1 - Age / Duration) * (1 - Age / Duration);
	}
	// Lazily created: hotload keeps existing boards without running new initializers.
	List<ControlMotion> controlMotions;
	float? moveSmoothing, rotateSmoothing, rotateRepeatDelay, rotateRepeatInterval;
	/// <summary>Seconds a sideways step takes to draw; zero snaps.</summary>
	public float MoveSmoothing
	{
		get => moveSmoothing ?? 0.07f;
		set => moveSmoothing = Math.Clamp( value, 0, 0.5f );
	}
	/// <summary>Seconds a quarter turn takes to draw; zero snaps.</summary>
	public float RotateSmoothing
	{
		get => rotateSmoothing ?? 0.09f;
		set => rotateSmoothing = Math.Clamp( value, 0, 0.5f );
	}
	public float RotateRepeatDelay
	{
		get => rotateRepeatDelay ?? 0.22f;
		set => rotateRepeatDelay = Math.Clamp( value, 0.05f, 2 );
	}
	public float RotateRepeatInterval
	{
		get => rotateRepeatInterval ?? 0.12f;
		set => rotateRepeatInterval = Math.Clamp( value, 0.03f, 2 );
	}
	/// <summary>Whether the drawn active piece is still catching up with its lattice position.</summary>
	public bool IsControlAnimating => controlMotions?.Count > 0;
	/// <summary>How far the drawn active piece trails its lattice position, in lanes and units.</summary>
	public (float Lane, float Y) ControlOffset
	{
		get
		{
			float lane = 0, y = 0;
			if ( controlMotions is not null )
				foreach ( var motion in controlMotions )
				{
					lane += motion.Lane * motion.Remaining;
					y += motion.Y * motion.Remaining;
				}
			return (lane, y);
		}
	}
	/// <summary>Clockwise quarter turns the drawn active piece still trails its lattice rotation.</summary>
	public float ControlTurns
	{
		get
		{
			float turns = 0;
			if ( controlMotions is not null )
				foreach ( var motion in controlMotions ) turns += motion.Turns * motion.Remaining;
			// A bump turns the drawn piece ahead of its lattice rotation, so it counts negative.
			return turns - bump.Amount( 0 );
		}
	}
	/// <summary>A turn succeeded during the latest update, with the same piece still active.</summary>
	public bool RotatedThisUpdate { get; private set; }
	/// <summary>A turn was refused (drawn as a bump) during the latest update.</summary>
	public bool RotationRefusedThisUpdate { get; private set; }

	void AddControlMotion( float lane, float y, float turns, float duration, float age = 0, bool linear = false )
	{
		if ( duration > 0 && age < duration ) (controlMotions ??= new()).Add( new( lane, y, turns, duration, age, linear ) );
	}

	/// <summary>When a held repeat stops, decelerate its remaining glide from the same speed.</summary>
	void EaseOutRepeatMotions()
	{
		// Mid-turn, slowing a glide changes where the turning piece is drawn; keep it linear if that would hit something.
		if ( CanEaseRepeatMotions() ) EaseRepeatMotionsInPlace();
	}

	void EaseRepeatMotionsInPlace()
	{
		if ( controlMotions is null ) return;
		for ( int i = 0; i < controlMotions.Count; i++ )
		{
			var motion = controlMotions[i];
			if ( !motion.Linear ) continue;
			// A quadratic ease-out starts at twice its average speed; stretch it to match.
			float remaining = motion.Remaining;
			controlMotions[i] = new( motion.Lane * remaining, motion.Y * remaining, motion.Turns * remaining, 2 * remaining * motion.Duration );
		}
	}

	void AdvanceControlMotions( float delta )
	{
		AdvanceBump( delta );
		if ( controlMotions is null ) return;
		for ( int i = controlMotions.Count - 1; i >= 0; i-- )
		{
			var motion = controlMotions[i] with { Age = controlMotions[i].Age + delta };
			if ( motion.Age >= motion.Duration ) controlMotions.RemoveAt( i );
			else controlMotions[i] = motion;
		}
	}
	public float LockFlash { get; private set; }
	public DiamondBoard( int? randomSeed = null )
	{
		random = randomSeed.HasValue ? new Random( randomSeed.Value ) : new Random();
		Reset();
	}
	public static float LaneX( float lane ) => Width * 0.5f + lane * Width * 0.5f;
	public static float FloorY( float lane ) => BoardHeight - Height * 0.5f
		- (1 - MathF.Abs( lane % 2 - 1 )) * Height * 0.5f;

	// Half-diamonds protrude from each wall, in phase with the floor valleys.
	public static BoundarySequence SideWallTeeth() => new( walls: true, corners: false );

	// Fill the last diamond-sized pocket at either end of the floor.
	public static BoundarySequence CornerFills( bool fillCornerPockets = true ) => new( walls: false, corners: fillCornerPockets );

	public static BoundarySequence BoundaryDiamonds( bool fillCornerPockets = true ) => new( walls: true, corners: fillCornerPockets );

	/// <summary>Direct foreach loops enumerate the fixed boundary without allocating or caching hotload-sensitive geometry.</summary>
	public readonly struct BoundarySequence : IEnumerable<Diamond>
	{
		readonly bool walls, corners;
		internal BoundarySequence( bool walls, bool corners ) { this.walls = walls; this.corners = corners; }
		public Enumerator GetEnumerator() => new( walls, corners );
		// Preserve IEnumerable compatibility for setup-time LINQ and existing iterator callers.
		IEnumerator<Diamond> IEnumerable<Diamond>.GetEnumerator() => GetEnumerator();
		System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();

		public struct Enumerator : IEnumerator<Diamond>
		{
			readonly int wallCount, start, end;
			int index;
			internal Enumerator( bool walls, bool corners )
			{
				wallCount = 2 * (int)MathF.Ceiling( (BoardHeight + Height * 0.5f) / Height );
				start = walls ? 0 : wallCount;
				end = wallCount + (corners ? 2 : 0);
				index = start - 1;
			}
			public Diamond Current => index < wallCount
				? new( index % 2 == 0 ? -1 : LaneCount, BoardHeight - index / 2 * Height, 0 )
				: new( index == wallCount ? 0 : LaneCount - 1, BoardHeight - Height * 0.5f, 0 );
			object System.Collections.IEnumerator.Current => Current;
			public bool MoveNext() => ++index < end;
			public void Reset() => index = start - 1;
			public void Dispose() { }
		}
	}

	public static float SideWallInset( float y, bool fillCornerPockets = true )
	{
		float inset = fillCornerPockets && y >= BoardHeight - Height && y <= BoardHeight
			? Width * (1 - MathF.Abs( y - (BoardHeight - Height * 0.5f) ) / Height) : 0;
		for ( float center = BoardHeight; center + Height * 0.5f > 0; center -= Height )
			inset = MathF.Max( inset, Width * 0.5f * (1 - MathF.Abs( y - center ) / (Height * 0.5f)) );
		return inset;
	}

	public static bool ClearsSideWalls( float lane, float y, float direction = 0, float drop = 0, bool fillCornerPockets = true )
	{
		foreach ( var tooth in BoundaryDiamonds( fillCornerPockets ) )
		{
			float crossX = direction == 0 ? 0 : Math.Clamp( (tooth.Lane - lane) / direction, 0, 1 );
			float crossY = drop == 0 ? 0 : Math.Clamp( (tooth.Y - y) / drop, 0, 1 );
			if ( !ClearAt( 0 ) || !ClearAt( 1 ) || !ClearAt( crossX ) || !ClearAt( crossY ) ) return false;
			bool ClearAt( float t ) => MathF.Abs( lane + direction * t - tooth.Lane ) * 0.5f
				+ MathF.Abs( y + drop * t - tooth.Y ) / Height >= 1 - Epsilon / Height;
		}
		return true;
	}

	public static float BoundaryLandingY( float lane, float fromY, bool fillCornerPockets = true )
	{
		float result = FloorY( lane );
		foreach ( var tooth in BoundaryDiamonds( fillCornerPockets ) )
		{
			float dx = MathF.Abs( lane - tooth.Lane ) * 0.5f;
			if ( dx >= 1 ) continue;
			float contact = tooth.Y - Height * (1 - dx);
			if ( contact >= fromY - Epsilon ) result = MathF.Min( result, contact );
		}
		return result;
	}

	IEnumerable<Diamond> CollisionObstacles()
	{
		foreach ( var cell in settled ) yield return cell;
		foreach ( var tooth in BoundaryDiamonds( FillCornerPockets ) ) yield return tooth;
	}

	public void Reset()
	{
		ResetVersion++;
		SettledVersion++;
		impacts.Clear();
		damageFlashes.Clear();
		cornerSparks.Clear();
		faceFlashes.Clear();
		damageShines.Clear();
		shatterBursts.Clear();
		bondPulses.Clear();
		damagePause = 0;
		settled.Clear();
		BondedGroups = Array.Empty<IReadOnlyList<int>>();
		shapeBag.Clear();
		Placed = 0;
		Score = Math.Max( 0, StartingScore );
		ElapsedSeconds = 0;
		Destroyed = Chain = 0;
		DiamondsBroken = ShardsCrushed = LargestDamagedGroup = MostColorsBrokenInTurn = LargestGroupAtGameOver = 0;
		FirstMinuteScore = 0;
		gameOverHits = 0;
		shattering.Clear();
		cascadeMoves.Clear();
		cascadeTime = 0;
		shatterTime = 0;
		Next = CreatePiece();
		GameOver = false;
		Paused = false;
		groundedTime = repeatTime = rotateRepeatTime = LockFlash = 0;
		lastDirection = 0;
		rotateHeld = RotatedThisUpdate = RotationRefusedThisUpdate = false;
		spawnLane = LaneCount / 2;
		Spawn();
		// A new game starts its first turn at once.
		NextShardPoints = ShardCrushPoints;
		shardTurnEnded = false;
	}

#if STANDALONE
#endif

	/// <summary>Debug: start a fresh game over a packed stack whose top leaves <paramref name="freeHeights"/> diamond heights clear, continuing from <paramref name="score"/>.</summary>
	public void FillToNearTop( float freeHeights, long score )
	{
		Reset();
		Score = Math.Max( 0, score );
		var colors = new Random();
		// Row r sits r half-heights above the floor valleys, on lanes matching its parity.
		int rows = Math.Max( 0, (int)MathF.Floor( (BoardHeight - Height - MathF.Max( 0, freeHeights ) * Height) / (Height * 0.5f) ) + 1 );
		for ( int row = 0; row < rows; row++ )
		for ( int lane = row % 2; lane < LaneCount; lane += 2 )
		{
			// Filled corner pockets already occupy the outermost floor valleys.
			if ( row == 0 && FillCornerPockets && (lane == 0 || lane == LaneCount - 1) ) continue;
			settled.Add( new( lane, BoardHeight - Height * 0.5f - row * Height * 0.5f, colors.Next( ColorCount ) ) );
		}
		SettledVersion++;
		BondedGroups = DiamondMatching.FindGroups( settled );
	}

	public void TogglePause()
	{
		if ( !GameOver ) Paused = !Paused;
	}

	void Spawn()
	{
		SpawnVersion++;
		shardTurnEnded = true;
		turnBrokenColors = 0;
		Active = Next;
		float minLane = float.MaxValue, maxLane = float.MinValue, bottom = float.MinValue;
		foreach ( var cell in CellsOf( Active ) )
		{
			minLane = MathF.Min( minLane, cell.Lane - Active.Lane );
			maxLane = MathF.Max( maxLane, cell.Lane - Active.Lane );
			bottom = MathF.Max( bottom, cell.Y - Active.Y );
		}
		Active = Active with { Lane = Math.Clamp( spawnLane, MinControlledLane - minLane, MaxControlledLane - maxLane ), Y = SpawnY - bottom };
		Next = CreatePiece();
		groundedTime = 0;
		hasLanded = false;
		pendingSideLanding = null;
		pendingRotationLanding = false;
		softDropTime = 0;
		controlMotions?.Clear();
		bump = default;
		RotatedThisUpdate = RotationRefusedThisUpdate = false;
		// A held key carries into the new piece at its usual cadence; drop steps a hitch left overdue when landing.
		repeatTime = MathF.Max( 0, repeatTime );
		rotateRepeatTime = MathF.Max( 0, rotateRepeatTime );
	}

	Piece CreatePiece()
	{
		int shape = TakeShape();
		var cells = DiamondShapes.All[shape].Cells;
		float mixChance = CurrentColorMixChance;
		// Do not consume an extra random draw while the original color rules apply.
		if ( cells.Count >= 3 && mixChance > 0 && random.NextDouble() < mixChance )
			return CreateVariedPiece( shape, cells );
		int first = random.Next( ColorCount );
		int second = (first + random.Next( 1, ColorCount )) % ColorCount;
		var colors = new int[cells.Count];
		// Four-cell pieces choose 3+1 or 2+2 with equal probability.
		Array.Fill( colors, second );
		var connected = new List<int> { random.Next( colors.Length ) };
		int count = colors.Length == 4 && random.Next( 2 ) == 0 ? 2 : Math.Min( 3, colors.Length );
		if ( count == 2 && colors.Length == 4 )
		{
			var pairs = new List<(int A, int B)>();
			var pairedHalves = new List<(int A, int B)>();
			for ( int a = 0; a < cells.Count; a++ )
			for ( int b = a + 1; b < cells.Count; b++ )
			{
				if ( !Adjacent( a, b ) ) continue;
				pairs.Add( (a, b) );
				var rest = new List<int>();
				for ( int i = 0; i < cells.Count; i++ ) if ( i != a && i != b ) rest.Add( i );
				if ( Adjacent( rest[0], rest[1] ) ) pairedHalves.Add( (a, b) );
			}
			// Prefer two bonded pairs. Trident has no such partition: its other
			// two arms share a color but remain separate until they meet later.
			var choices = pairedHalves.Count > 0 ? pairedHalves : pairs;
			var pair = choices[random.Next( choices.Count )];
			connected.Clear();
			connected.Add( pair.A );
			connected.Add( pair.B );
		}
		while ( connected.Count < count )
		{
			var candidates = new List<int>();
			for ( int i = 0; i < cells.Count; i++ )
			{
				if ( connected.Contains( i ) ) continue;
				foreach ( int j in connected )
					if ( Adjacent( i, j ) )
					{
						candidates.Add( i );
						break;
					}
			}
			connected.Add( candidates[random.Next( candidates.Count )] );
		}
		foreach ( int i in connected ) colors[i] = first;
		return PieceWithColors( shape, colors );

		bool Adjacent( int a, int b ) => Math.Abs( cells[a].Lane - cells[b].Lane ) == 1 && Math.Abs( cells[a].Row - cells[b].Row ) == 1;
	}

	Piece CreateVariedPiece( int shape, IReadOnlyList<DiamondShapes.Cell> cells )
	{
		// Draw distinct colors without replacement from the five-color palette.
		var palette = new int[ColorCount];
		for ( int i = 0; i < palette.Length; i++ ) palette[i] = i;
		for ( int i = 0; i < cells.Count; i++ )
		{
			int swap = i + random.Next( ColorCount - i );
			(palette[i], palette[swap]) = (palette[swap], palette[i]);
		}
		var colors = new int[cells.Count];
		if ( cells.Count == 3 || random.NextDouble() < FourColorShare * 0.01f )
		{
			for ( int i = 0; i < colors.Length; i++ ) colors[i] = palette[i];
		}
		else
		{
			// Three colors in a four-cell piece retain one connected same-color pair.
			var pairs = new List<(int A, int B)>();
			for ( int a = 0; a < cells.Count; a++ )
			for ( int b = a + 1; b < cells.Count; b++ )
				if ( Math.Abs( cells[a].Lane - cells[b].Lane ) == 1 && Math.Abs( cells[a].Row - cells[b].Row ) == 1 )
					pairs.Add( (a, b) );
			var pair = pairs[random.Next( pairs.Count )];
			colors[pair.A] = colors[pair.B] = palette[0];
			int nextColor = 1;
			for ( int i = 0; i < colors.Length; i++ )
				if ( i != pair.A && i != pair.B ) colors[i] = palette[nextColor++];
		}
		return PieceWithColors( shape, colors );
	}

	static Piece PieceWithColors( int shape, IReadOnlyList<int> colors )
	{
		int packed = 0;
		for ( int i = 0; i < colors.Count; i++ ) packed |= colors[i] << (i * 3);
		return new Piece( LaneCount / 2, SpawnY, shape, Colors: packed );
	}

	int TakeShape()
	{
		if ( shapeBag.Count == 0 )
		{
			var indices = new int[DiamondShapes.All.Count];
			for ( int i = 0; i < indices.Length; i++ ) indices[i] = i;
			for ( int i = indices.Length - 1; i > 0; i-- )
			{
				int j = random.Next( i + 1 );
				(indices[i], indices[j]) = (indices[j], indices[i]);
			}
			foreach ( int index in indices ) shapeBag.Enqueue( index );
		}
		return shapeBag.Dequeue();
	}

	public static IEnumerable<Diamond> CellsOf( Piece piece )
	{
		var shape = DiamondShapes.All[piece.ShapeIndex];
		for ( int i = 0; i < shape.Cells.Count; i++ )
		{
			if ( piece.CellHealth( i ) <= 0 ) continue;
			yield return CellOf( piece, shape, i );
		}
	}

	/// <summary>Replace a reusable cell list without allocating an iterator.</summary>
	public static void CopyCellsOf( Piece piece, List<Diamond> target )
	{
		target.Clear();
		var shape = DiamondShapes.All[piece.ShapeIndex];
		for ( int i = 0; i < shape.Cells.Count; i++ )
			if ( piece.CellHealth( i ) > 0 ) target.Add( CellOf( piece, shape, i ) );
	}

	static Diamond CellOf( Piece piece, DiamondShapes.Shape shape, int index )
	{
		var cell = shape.RotatedCell( index, piece.Rotation );
		return new Diamond( piece.Lane + cell.Lane, piece.Y + cell.Row * Height * 0.5f, piece.CellColor( index ), piece.CellHealth( index ) );
	}

	public bool CanOccupy( float lane, float y ) => CanOccupy( Active with { Lane = lane, Y = y } );

	public bool CanOccupy( Piece candidate )
	{
		foreach ( var cell in CellsOf( candidate ) )
			if ( !CanDiamondOccupy( cell.Lane, cell.Y ) ) return false;
		return true;
	}

	bool CanDiamondOccupy( float lane, float y )
	{
		if ( lane < MinControlledLane || lane > MaxControlledLane || y > FloorY( lane ) + Epsilon ) return false;
		foreach ( var piece in CollisionObstacles() )
		{
			// Minkowski sum of two identical diamonds: |dx| / W + |dy| / H >= 1.
			float separation = MathF.Abs( LaneX( lane ) - LaneX( piece.Lane ) ) / Width
				+ MathF.Abs( y - piece.Y ) / Height;
			if ( separation < 1 - Epsilon / Height ) return false;
		}
		return true;
	}

	float FindLandingY( Piece piece )
	{
		float result = float.MaxValue;
		foreach ( var cell in CellsOf( piece ) )
			result = MathF.Min( result, FindDiamondLandingY( cell.Lane, cell.Y ) - (cell.Y - piece.Y) );
		return result;
	}

	float FindDiamondLandingY( float lane, float fromY )
	{
		float result = BoundaryLandingY( lane, fromY, FillCornerPockets );
		foreach ( var piece in settled )
		{
			float dx = MathF.Abs( LaneX( lane ) - LaneX( piece.Lane ) );
			if ( dx >= Width ) continue;
			float contact = piece.Y - Height * (1 - dx / Width);
			if ( contact >= fromY - Epsilon ) result = MathF.Min( result, contact );
		}
		return result;
	}

	public bool TryMove( int direction ) => TryMove( direction, null );

	/// <summary>A held repeat passes how long ago its step was due, keeping the glide in sub-frame time.</summary>
	bool TryMove( int direction, float? repeatAge )
	{
		if ( GameOver || Paused || IsResolving || hasLanded || (direction != -1 && direction != 1) ) return false;
		float lane = Active.Lane + direction;
		foreach ( float offset in SideStepOffsets( direction ) )
		{
			if ( !CanTranslate( direction, offset ) ) continue;
			bool repeat = repeatAge.HasValue && MoveSmoothing > 0;
			var step = repeat ? new ControlMotion( -direction, -offset, 0, MoveRepeatInterval, repeatAge.Value, true )
				: new ControlMotion( -direction, -offset, 0, MoveSmoothing );
			// A step while a turn is still drawing carries the turning piece sideways; keep that path clear too.
			if ( TurnDrawing && FirstDrawnHit( Active with { Lane = lane, Y = Active.Y + offset }, step, bump ) is not null ) continue;
			var before = Active;
			Active = Active with { Lane = lane, Y = Active.Y + offset };
			AddControlMotion( step.Lane, step.Y, 0, step.Duration, step.Age, step.Linear );
			// Commit before another repeat can move away from the support. Keep
			// the motion until Update so direct input calls do not lose feedback.
			if ( CommitLanding() )
			{
				pendingSideLanding = before;
				pendingSideDuration = repeat ? MoveRepeatInterval : MoveSmoothing;
			}
			return true;
		}
		return false;
	}

	bool CommitLanding()
	{
		if ( hasLanded || Active.Y < LandingY - Epsilon ) return false;
		hasLanded = true;
		spawnLane = Active.Lane;
		return true;
	}

	// Near sloped edges, a flat horizontal move can collide even though the next
	// pocket fits. Only downward adjustments are allowed, following a slope by
	// up to half a diamond; horizontal input must never lift the piece.
	// Every candidate still has to pass a swept collision check for the full shape.
	List<float> SideStepOffsets( int direction )
	{
		var offsets = new List<float> { 0 };
		void Add( float offset )
		{
			if ( offset < Epsilon || offset > Height * 0.5f + Epsilon ) return;
			if ( !offsets.Contains( offset ) ) offsets.Add( offset );
		}
		foreach ( var cell in CellsOf( Active ) )
		{
			float lane = cell.Lane + direction;
			Add( FloorY( lane ) - cell.Y );
			foreach ( var other in CollisionObstacles() )
			{
				float dx = MathF.Abs( lane - other.Lane ) * 0.5f;
				if ( dx >= 1 ) continue;
				float separation = Height * (1 - dx);
				Add( other.Y - separation - cell.Y );
				Add( other.Y + separation - cell.Y );
			}
		}
		offsets.Sort();
		return offsets;
	}

	public bool TryRotate()
	{
		if ( GameOver || Paused || IsResolving || hasLanded ) return false;
		// Turn in place about the fixed pivot, never shifted or lifted to make it fit: repositioning the piece moved
		// it without the player asking. A turn that does not fit, or whose drawn rotation would pass through
		// anything, is refused and bumps instead: it swings as far as it is clear and back.
		var rotated = Active with { Rotation = (Active.Rotation + 1) & 3 };
		if ( RotateSmoothing <= 0 ) return ApplyTurn( rotated ) || RefuseTurn();
		var hit = FirstDrawnHit( rotated, TurnMotion(), bump );
		if ( hit is null && ApplyTurn( rotated ) ) return true;
		StartBump( hit?.LastClear ?? RotateSmoothing );
		return RefuseTurn();
	}

	bool RefuseTurn()
	{
		RotationRefusedThisUpdate = true;
		return false;
	}

	ControlMotion TurnMotion() => new( 0, 0, 1, RotateSmoothing );

	bool ApplyTurn( Piece candidate )
	{
		if ( !CanOccupy( candidate ) ) return false;
		// The pivot stays fixed, so the drawn piece only turns.
		AddControlMotion( 0, 0, 1, RotateSmoothing );
		Active = candidate;
		RotatedThisUpdate = true;
		// A turn can make first contact before Update.
		// Lock immediately so this frame's movement cannot escape that landing.
		if ( CommitLanding() ) pendingRotationLanding = true;
		return true;
	}


	bool CanTranslate( int direction, float drop )
	{
		float lane = Active.Lane + direction;
		float y = Active.Y + drop;
		if ( !CanOccupy( lane, y ) ) return false;
		// Along a straight path the diamond separation is piecewise linear.
		// Check its two possible interior minima as well as the destination.
		foreach ( var cell in CellsOf( Active ) )
		foreach ( var piece in CollisionObstacles() )
		{
			float crossX = Math.Clamp( (piece.Lane - cell.Lane) / direction, 0, 1 );
			float crossY = drop == 0 ? 0 : Math.Clamp( (piece.Y - cell.Y) / drop, 0, 1 );
			if ( !ClearAt( crossX ) || !ClearAt( crossY ) ) return false;

			bool ClearAt( float t ) => MathF.Abs( cell.Lane + direction * t - piece.Lane ) * 0.5f
				+ MathF.Abs( cell.Y + drop * t - piece.Y ) / Height >= 1 - Epsilon / Height;
		}
		return true;
	}

	void LockPiece()
	{
		int incomingStart = settled.Count;
		settled.AddRange( CellsOf( Active ) );
		SettledVersion++;
		Placed++;
		LockFlash = 0.3f;
		Chain = 0;
		BeginSettlement( incomingStart );
	}

	void BeginSettlement( int incomingStart = -1, bool preserveBonds = false )
	{
#if STANDALONE
#endif
		// Pulses snapshot resting geometry; retire them before the stack moves again.
		bondPulses.Clear();
		if ( !preserveBonds ) BondedGroups = DiamondMatching.FindGroups( settled, incomingStart );
		foreach ( var move in DiamondMatching.PlanSettlement( settled, random, frozenBonds: BondedGroups, fillCornerPockets: FillCornerPockets ) ) cascadeMoves.Enqueue( move );
		cascadeTime = 0;
		if ( !IsCascading ) FinishSettlement();
	}

	void FinishSettlement()
	{
#if STANDALONE
#endif
		var previousGroup = new int[settled.Count];
		for ( int group = 0; group < BondedGroups.Count; group++ )
			foreach ( int index in BondedGroups[group] ) previousGroup[index] = group;
		var restingGroups = DiamondMatching.FindGroups( settled );
		foreach ( var group in restingGroups )
		{
			// Only a union of previously separate bonds celebrates a connection.
			// Internal bonds in a spawned piece and transient contacts do not pulse.
			if ( !group.Exists( index => previousGroup[index] != previousGroup[group[0]] ) ) continue;
			bondPulses.Add( new( DiamondOutline.Build( settled, new IReadOnlyList<int>[] { group } ) ) );
		}
		BondedGroups = restingGroups;
		// Give every fall, slide, and destruction chain a chance to clear the top.
		// Only the final resting stack determines whether another piece can spawn.
		foreach ( var cell in settled )
			if ( cell.Y - Height * 0.5f < -Epsilon )
			{
				GameOver = true;
				foreach ( var group in BondedGroups ) LargestGroupAtGameOver = Math.Max( LargestGroupAtGameOver, group.Count );
				BeginGameOverCleanup();
				return;
			}
		if ( GameOver )
		{
			BeginGameOverCleanup();
			return;
		}
		Spawn();
	}

	void BeginGameOverCleanup()
	{
		if ( settled.Count == 0 ) return;
		// Work upward through pieces that are not still reacting to their last hit.
		// A bond is one target: even an unshaking member must not re-hit its shaking partners.
		var shaking = new HashSet<int>();
		float nextReady = DamageShineDuration;
		foreach ( var shine in damageShines )
		{
			if ( shine.Age >= DamageShineDuration || shine.SettledIndices.Count == 0 ) continue;
			shaking.UnionWith( shine.SettledIndices );
			nextReady = MathF.Min( nextReady, DamageShineDuration - shine.Age );
		}
		foreach ( var group in BondedGroups )
		{
			bool blocked = false;
			foreach ( int member in group ) if ( shaking.Contains( member ) ) { blocked = true; break; }
			if ( blocked ) shaking.UnionWith( group );
		}
		int target = -1;
		for ( int i = 0; i < settled.Count; i++ )
			if ( !shaking.Contains( i ) && (target < 0 || settled[i].Y > settled[target].Y + Epsilon) ) target = i;
		if ( target < 0 )
		{
			// Keep this wait in real time, even when cleanup has reached its speed cap.
			damagePause = nextReady;
			return;
		}
		var cell = settled[target];
		var targets = new Dictionary<int, Corner> { [target] = Corner.Bottom };
		foreach ( var group in BondedGroups )
		{
			if ( !group.Contains( target ) ) continue;
			foreach ( int member in group ) targets[member] = Corner.Bottom;
			break;
		}
		bool destroyed = DiamondDamage.Apply( settled, targets );
		int lowestHealth = MaxHealth;
		foreach ( int index in targets.Keys ) lowestHealth = Math.Min( lowestHealth, settled[index].Health );
		AddDamageFlashes( [new DiamondDamage.Contact( cell.Lane, cell.Y + Height * 0.5f, cell.ColorIndex )], targets.Count );
		AddDamageShines( targets, BondedGroups );
		impacts.Add( new Impact( 0, -Height * 12, targets.Count ) { LowestDamagedHealth = lowestHealth } );
		gameOverHits++;
		damagePause = GameOverHitPause / GameOverCleanupSpeed;
		Chain = 0;
		if ( destroyed ) DestroyBrokenDiamonds();
	}

	// A resting bottom tip at or below the floor peaks: the floor itself, or a filled corner pocket beside it.
	static bool TouchesFloor( IEnumerable<Diamond> cells )
	{
		foreach ( var cell in cells ) if ( cell.Y + Height >= BoardHeight - Epsilon ) return true;
		return false;
	}

	static bool TouchesFloor( IReadOnlyList<DiamondMatching.CellMove> cells )
	{
		foreach ( var cell in cells ) if ( cell.To.Y + Height >= BoardHeight - Epsilon ) return true;
		return false;
	}

	void DestroyBrokenDiamonds()
	{
		bondPulses.Clear();
		// Cancel future geometry before indices or supports change.
		cascadeMoves.Clear();
		cascadeTime = 0;
		shattering.Clear();
		var survivors = new List<Diamond>();
		var remap = new int[settled.Count];
		var brokenRemap = new int[settled.Count];
		for ( int i = 0; i < settled.Count; i++ )
		{
			remap[i] = -1;
			brokenRemap[i] = -1;
			if ( settled[i].Health <= 0 ) { brokenRemap[i] = shattering.Count; shattering.Add( settled[i] ); }
			else { remap[i] = survivors.Count; survivors.Add( settled[i] ); }
		}
		settled.Clear();
		settled.AddRange( survivors );
		SettledVersion++;
		foreach ( var shine in damageShines )
		{
			shine.ShatteringIndices.Clear();
			for ( int i = shine.SettledIndices.Count - 1; i >= 0; i-- )
			{
				int old = shine.SettledIndices[i];
				if ( remap[old] >= 0 ) shine.SettledIndices[i] = remap[old];
				else { shine.ShatteringIndices.Add( brokenRemap[old] ); shine.SettledIndices.RemoveAt( i ); }
			}
		}
		BondedGroups = DiamondMatching.SurvivingGroups( settled, BondedGroups, remap );
		Destroyed += shattering.Count;
		if ( !GameOver )
		{
			DiamondsBroken += shattering.Count;
			foreach ( var cell in shattering ) turnBrokenColors |= 1 << cell.ColorIndex;
			MostColorsBrokenInTurn = Math.Max( MostColorsBrokenInTurn, System.Numerics.BitOperations.PopCount( (uint)turnBrokenColors ) );
		}
		Chain++;
		shatterTime = ShatterDuration;
	}

	/// <summary>Sharp point contacts if the active piece continues straight down, at any speed.</summary>
	public IReadOnlyList<DiamondDamage.Contact> GetLandingPointContacts()
	{
		if ( GameOver || Paused || IsResolving ) return Array.Empty<DiamondDamage.Contact>();
		var contacts = new List<DiamondDamage.Contact>();
		CopyLandingPointContacts( contacts );
		return contacts;
	}

	readonly List<Diamond> landingCombined = new(), landingCells = new( 4 );
	readonly List<DiamondMatching.CellMove> landingMoves = new( 4 );
	readonly DiamondDamage.PointContactScratch landingContactScratch = new();

	/// <summary>Replace caller-owned preview contacts, reusing the board's scratch buffers.</summary>
	public void CopyLandingPointContacts( List<DiamondDamage.Contact> contacts )
	{
		contacts.Clear();
		if ( GameOver || Paused || IsResolving ) return;
		float landing = LandingY;
		float remaining = landing - Active.Y;
		if ( remaining <= Epsilon ) return;

		var combined = landingCombined;
		var moves = landingMoves;
		combined.Clear(); combined.AddRange( settled ); moves.Clear();
		CopyCellsOf( Active, landingCells );
		foreach ( var cell in landingCells )
		{
			var target = cell with { Y = cell.Y + remaining };
			moves.Add( new( combined.Count, cell, target ) );
			combined.Add( target );
		}
		DiamondDamage.CopyPointContacts( combined, moves, contacts, landingContactScratch, FillCornerPockets );
	}

	float FallSpeedAtImpact( float remaining, float hold, bool softDrop )
	{
		if ( !softDrop ) return CurrentNormalFallSpeed;
		float distance = MathF.Max( 0, remaining ) / Height;
		float low = hold, high = SoftDropRampDuration;
		float startDistance = SoftDropDistance( hold );
		if ( distance >= SoftDropDistance( high ) - startDistance ) return SoftDropMaxSpeed;
		// Invert the integrated quadratic ramp at the actual contact, keeping
		// damage and feedback velocity independent of frame size and overshoot.
		for ( int i = 0; i < 20; i++ )
		{
			float middle = (low + high) * 0.5f;
			if ( SoftDropDistance( middle ) - startDistance < distance ) low = middle;
			else high = middle;
		}
		float t = (low + high) * 0.5f / SoftDropRampDuration;
		return SoftDropStartSpeed + (SoftDropMaxSpeed - SoftDropStartSpeed) * t * t;
	}

	static float SoftDropDistance( float time )
	{
		float ramp = Math.Clamp( time, 0, SoftDropRampDuration );
		return SoftDropStartSpeed * ramp + (SoftDropMaxSpeed - SoftDropStartSpeed)
			* ramp * ramp * ramp / (3 * SoftDropRampDuration * SoftDropRampDuration)
			+ SoftDropMaxSpeed * MathF.Max( 0, time - SoftDropRampDuration );
	}

	// Damage happens at first contact, before the incoming cells can bond to the stack.
	bool DamageActiveLanding( Piece before, float velocityY, float velocityX = 0 )
	{
#if STANDALONE
#endif
		var combined = new List<Diamond>( settled );
		var previous = new List<Diamond>( CellsOf( before ) );
		int incomingStart = combined.Count;
		combined.AddRange( CellsOf( Active ) );
		var moves = new List<DiamondMatching.CellMove>();
		for ( int i = incomingStart; i < combined.Count; i++ ) moves.Add( new( i, previous[i - incomingStart], combined[i] ) );
		var bonds = DiamondMatching.FindGroups( combined, incomingStart );
		var contacts = new List<DiamondDamage.Contact>();
		var targets = DiamondDamage.Targets( combined, moves, bonds, velocityX, velocityY, contacts );
		AddCornerSparks( combined, moves, contacts, MathF.Sqrt( velocityX * velocityX + velocityY * velocityY ) );
		if ( targets.Count == 0 ) return false;
		RecordDamagedGroups( targets, bonds );
		AddDamageFlashes( contacts, targets.Count );
		AddDamageShines( targets, bonds );
		bool destroyed = ApplyImpactDamage( combined, targets );
		if ( destroyed )
		{
			damagePause = DamagePauseDuration;
			settled.Clear();
			settled.AddRange( combined );
			BondedGroups = bonds;
			Placed++;
			LockFlash = 0.3f;
			Chain = 0;
			DestroyBrokenDiamonds();
			return true;
		}
		for ( int i = 0; i < incomingStart; i++ ) settled[i] = combined[i];
		return false;
	}

	bool ApplyImpactDamage( List<Diamond> cells, IReadOnlyDictionary<int, Corner> targets )
	{
		bool destroyed = DiamondDamage.Apply( cells, targets );
		if ( targets.Count > 0 )
		{
			int lowestHealth = MaxHealth;
			foreach ( int index in targets.Keys ) lowestHealth = Math.Min( lowestHealth, cells[index].Health );
			// Capture the result before destruction/replanning changes the receiver list.
			impacts[^1] = impacts[^1] with { LowestDamagedHealth = lowestHealth };
		}
		return destroyed;
	}

	void AddCornerSparks( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moves,
		IReadOnlyList<DiamondDamage.Contact> damagingContacts, float speed )
	{
		foreach ( var contact in DiamondDamage.SparkContacts( cells, moves, FillCornerPockets ) )
		{
			// Damaging tips already emit through DamageFlashes. Keep floor and slow
			// contacts independent of damage, health, sunbursts and the damage pause.
			if ( damagingContacts.Contains( contact ) ) continue;
			cornerSparks.Add( new( contact, speed, ++cornerSparkSequence ) );
		}
	}

	void AddDamageFlashes( IReadOnlyList<DiamondDamage.Contact> contacts, int damageCount )
	{
		// Cosmetic randomness must not change the shape bag or settling direction.
		foreach ( var contact in contacts ) damageFlashes.Add( new( contact.Lane, contact.Y, contact.ColorIndex,
			Rotation: (float)effectRandom.NextDouble() * MathF.PI * 2, DamageCount: damageCount,
			Brightness: 0.32f + (float)effectRandom.NextDouble() * 0.16f, Id: ++damageFlashSequence ) );
	}

	// Damage spreads through a whole established bond, so the damaged members of one group are that group's size.
	void RecordDamagedGroups( IReadOnlyDictionary<int, Corner> targets, IReadOnlyList<IReadOnlyList<int>> bonds )
	{
		int largest = 0;
		foreach ( var group in bonds )
		{
			int damaged = 0;
			foreach ( int index in group ) if ( targets.ContainsKey( index ) ) damaged++;
			largest = Math.Max( largest, damaged );
		}
		// Cells outside every listed group still count as one-diamond pieces.
		LargestDamagedGroup = Math.Max( LargestDamagedGroup, Math.Max( largest, Math.Min( targets.Count, 1 ) ) );
	}

	void AddDamageShines( IReadOnlyDictionary<int, Corner> targets, IReadOnlyList<IReadOnlyList<int>> bonds )
	{
		var remaining = new HashSet<int>( targets.Keys );
		foreach ( var group in bonds )
		{
			var shine = new DamageShine { Direction = effectRandom.Next( 2 ) == 0 ? -1 : 1,
				ShakeAngle = (float)effectRandom.NextDouble() * MathF.PI * 2 };
			foreach ( int index in group ) if ( remaining.Remove( index ) ) shine.SettledIndices.Add( index );
			if ( shine.SettledIndices.Count > 0 ) damageShines.Add( shine );
		}
		foreach ( int index in remaining )
		{
			var shine = new DamageShine { Direction = effectRandom.Next( 2 ) == 0 ? -1 : 1,
				ShakeAngle = (float)effectRandom.NextDouble() * MathF.PI * 2 };
			shine.SettledIndices.Add( index );
			damageShines.Add( shine );
		}
	}

	// Return unused real time so the next move can begin within the same frame.
	float AdvanceCascade( float delta )
	{
		var move = cascadeMoves.Peek();
		float timeLeft = (move.Duration - cascadeTime) / CascadeSpeed;
		bool finished = delta >= timeLeft;
		cascadeTime = finished ? move.Duration : cascadeTime + delta * CascadeSpeed;
		float t = cascadeTime / move.Duration;
		// One ease-in spans the entire uninterrupted slope, including intermediate lanes.
		// Falls also accelerate into contact instead of braking to zero before impact.
		float blend = move.Blend( t );
		foreach ( var cell in move.Cells )
		{
			settled[cell.Index] = settled[cell.Index] with
			{
				Lane = cell.From.Lane + (cell.To.Lane - cell.From.Lane) * blend,
				Y = cell.From.Y + (cell.To.Y - cell.From.Y) * blend
			};
		}
		if ( !finished ) return 0;
		var contact = move.Cells[0];
		float velocity = move.VelocityScale( 1, CascadeSpeed );
		var faces = DiamondContact.Faces.None;
		if ( !move.IsSlide )
		{
			var moving = new List<Diamond>( move.Cells.Count );
			var excluded = new HashSet<int>();
			foreach ( var cell in move.Cells ) { moving.Add( cell.To ); excluded.Add( cell.Index ); }
			faces = DiamondContact.Find( moving, settled, excluded, FillCornerPockets );
		}
		impacts.Add( new( (contact.To.Lane - contact.From.Lane) * Width * 0.5f * velocity,
			(contact.To.Y - contact.From.Y) * velocity, move.Cells.Count, faces ) { Slide = move.IsSlide, Floor = TouchesFloor( move.Cells ) } );
		foreach ( var cell in move.Cells ) settled[cell.Index] = settled[cell.Index] with { Lane = cell.To.Lane, Y = cell.To.Y };
		cascadeMoves.Dequeue();
		cascadeTime = 0;
		var damageContacts = new List<DiamondDamage.Contact>();
		var faceContacts = new List<DiamondDamage.FaceContact>();
		var targets = GameOver ? new Dictionary<int, Corner>() : DiamondDamage.Targets( settled, move.Cells, BondedGroups,
			(contact.To.Lane - contact.From.Lane) * Width * 0.5f * velocity, (contact.To.Y - contact.From.Y) * velocity, damageContacts, faceContacts );
		AddCornerSparks( settled, move.Cells, damageContacts, impacts[^1].Speed );
		if ( targets.Count > 0 )
		{
			damagePause = DamagePauseDuration;
			RecordDamagedGroups( targets, BondedGroups );
			AddDamageFlashes( damageContacts, targets.Count );
			foreach ( var face in faceContacts ) faceFlashes.Add( new( face,
				Seed: (float)effectRandom.NextDouble() * MathF.PI * 2,
				Brightness: 0.32f + (float)effectRandom.NextDouble() * 0.16f, DamageCount: targets.Count, Id: ++faceFlashSequence ) );
			AddDamageShines( targets, BondedGroups );
		}
		if ( ApplyImpactDamage( settled, targets ) ) DestroyBrokenDiamonds();
		else if ( !IsCascading && !IsDamagePaused ) FinishSettlement();
		return MathF.Max( 0, delta - timeLeft );
	}

	void AdvanceResolution( float delta )
	{
		// Scale the shatter beat with the chain too (320/213/160/128/107 ms), and never
		// discard a partial frame at a move boundary.
		while ( delta > 0 && IsResolving )
		{
			// Give every damaging chain contact an unscaled beat, even at 3x speed.
			if ( IsDamagePaused )
			{
				float consumed = MathF.Min( delta, damagePause );
				damagePause = MathF.Max( 0, damagePause - consumed );
				delta -= consumed;
				if ( IsDamagePaused ) return;
				if ( !IsCascading && !IsShattering )
				{
					int hitsBefore = gameOverHits;
					FinishSettlement();
					if ( GameOver && (gameOverHits != hitsBefore || IsDamagePaused) ) return;
				}
				continue;
			}
			if ( IsCascading )
			{
				int hitsBefore = gameOverHits;
				delta = AdvanceCascade( delta );
				if ( GameOver && (gameOverHits != hitsBefore || IsDamagePaused) ) return;
				continue;
			}
			// Emit once when the actual shatter starts, after the damage pause.
			// Keep every stage's event even if its entire animation fits this frame.
			if ( shatterTime == ShatterDuration )
				foreach ( var cell in shattering ) shatterBursts.Add( new( ++shatterSequence, cell ) );
			float timeLeft = shatterTime / CascadeSpeed;
			if ( delta < timeLeft )
			{
				shatterTime -= delta * CascadeSpeed;
				return;
			}
			delta -= timeLeft;
			shatterTime = 0;
			shattering.Clear();
			foreach ( var shine in damageShines ) shine.ShatteringIndices.Clear();
			int hitsBeforeShatterSettlement = gameOverHits;
			BeginSettlement( preserveBonds: true );
			// Let each game-over hit reach the screen, even when a frame has
			// enough leftover time to start another hit.
			if ( GameOver && (gameOverHits != hitsBeforeShatterSettlement || IsDamagePaused) ) return;
		}
	}

	public void Update( float delta, int direction, bool softDrop, bool rotate = false )
	{
#if STANDALONE
#endif
		impacts.Clear();
		RotatedThisUpdate = RotationRefusedThisUpdate = false;
		cornerSparks.Clear();
		shatterBursts.Clear();
		if ( shardTurnEnded )
		{
			NextShardPoints = ShardCrushPoints;
			shardTurnEnded = false;
		}
		if ( Paused ) return;
		// Game-over cleanup keeps updating the board, but the run's time has ended.
		if ( !GameOver ) ElapsedSeconds += MathF.Max( 0, delta );
		delta = Math.Clamp( delta, 0, 0.1f );
		frameDelta = delta;
		AdvanceControlMotions( delta );
		for ( int i = faceFlashes.Count - 1; i >= 0; i-- )
		{
			var flash = faceFlashes[i] with { Age = faceFlashes[i].Age + delta };
			if ( flash.Age >= DamageFlashDuration ) faceFlashes.RemoveAt( i );
			else faceFlashes[i] = flash;
		}
		for ( int i = damageShines.Count - 1; i >= 0; i-- )
		{
			var shine = damageShines[i];
			int previousSample = (int)(shine.Age * 60);
			shine.Age += delta;
			if ( shine.Age >= DamageShineDuration ) damageShines.RemoveAt( i );
			// One cosmetic sample per group, shared by its gems, outlines and shine.
			// Sampling on the effect clock also freezes the offset while paused.
			else if ( (int)(shine.Age * 60) != previousSample )
				shine.ShakeAngle = (float)effectRandom.NextDouble() * MathF.PI * 2;
		}
		for ( int i = bondPulses.Count - 1; i >= 0; i-- )
		{
			var pulse = bondPulses[i] with { Age = bondPulses[i].Age + delta };
			if ( pulse.Age >= BondPulseDuration ) bondPulses.RemoveAt( i );
			else bondPulses[i] = pulse;
		}
		for ( int i = damageFlashes.Count - 1; i >= 0; i-- )
		{
			var flash = damageFlashes[i] with { Age = damageFlashes[i].Age + delta };
			if ( flash.Age >= DamageFlashDuration ) damageFlashes.RemoveAt( i );
			else damageFlashes[i] = flash;
		}
		LockFlash = MathF.Max( 0, LockFlash - delta );
		if ( IsResolving )
		{
			AdvanceResolution( delta );
			return;
		}
		if ( GameOver ) return;
		// Holding rotate repeats like sideways movement, with its own slower cadence.
		if ( !rotate )
		{
			rotateHeld = false;
			rotateRepeatTime = 0;
		}
		else if ( !rotateHeld )
		{
			rotateHeld = true;
			TryRotate();
			rotateRepeatTime = RotateRepeatDelay;
		}
		// Repeats only count down while the piece is controllable, so the landing wait never banks steps that
		// would all fire on the next piece's first frame.
		else if ( !hasLanded )
		{
			rotateRepeatTime -= delta;
			while ( rotateRepeatTime <= 0 && !hasLanded )
			{
				TryRotate();
				rotateRepeatTime += RotateRepeatInterval;
			}
		}
		if ( direction == 0 )
		{
			if ( lastDirection != 0 ) EaseOutRepeatMotions();
			lastDirection = 0;
			repeatTime = 0;
		}
		else if ( direction != lastDirection )
		{
			EaseOutRepeatMotions();
			TryMove( direction );
			lastDirection = direction;
			repeatTime = MoveRepeatDelay;
		}
		else if ( !hasLanded )
		{
			repeatTime -= delta;
			while ( repeatTime <= 0 && !hasLanded )
			{
				// A blocked step ends the glide, so let it come to rest smoothly.
				if ( !TryMove( direction, -repeatTime ) ) EaseOutRepeatMotions();
				repeatTime += MoveRepeatInterval;
			}
		}

		float landing = LandingY;
		// While a turn is drawing, pressing or releasing soft drop changes where the turning piece is drawn; the change
		// waits (at most until the drawn turn finishes) unless the turn stays clear at the new speed.
		if ( softDrop != softDropTime > 0 && TurnDrawing && !FallChangeClear( softDrop ) ) softDrop = !softDrop;
		// Integrate the increasingly steep speed ramp exactly, including its cap.
		float previousHold = softDropTime;
		softDropTime = softDrop ? MathF.Min( SoftDropRampDuration, softDropTime + delta ) : 0;
		float distance = CurrentNormalFallSpeed * delta;
		if ( softDrop )
			distance = SoftDropDistance( previousHold + delta ) - SoftDropDistance( previousHold );
		float remaining = landing - Active.Y;
		var beforeFall = Active;
		Active = Active with { Y = MathF.Min( landing, Active.Y + Height * distance ) };
		KeepDrawnTurnClear();
		var contacts = Active.Y >= landing - Epsilon
			? DiamondContact.Find( CellsOf( Active ), settled, fillCornerPockets: FillCornerPockets ) : DiamondContact.Faces.None;
		bool firstContact = CommitLanding();
		// Report a step or turn into contact once the drawn piece arrives there.
		if ( pendingRotationLanding && delta > 0 && !IsControlAnimating )
		{
			pendingRotationLanding = false;
			// Rotation is not a vertical fall: report contact once without assigning
			// the held-drop speed or applying point-impact damage to the receivers.
			impacts.Add( new( 0, 0, DiamondShapes.All[Active.ShapeIndex].Cells.Count, contacts ) { Active = true, Floor = TouchesFloor( CellsOf( Active ) ) } );
		}
		else if ( pendingSideLanding is Piece beforeSideStep && delta > 0 && !IsControlAnimating )
		{
			pendingSideLanding = null;
			// A held repeat glides at constant speed and a tap eases out over its duration, so use the glide's average
			// speed; dividing by the arrival frame alone made gentle steps read as slams, harder at higher frame rates.
			// A snapped step (no smoothing) still moves within the one frame.
			float duration = MathF.Max( pendingSideDuration, delta );
			float velocityX = (Active.Lane - beforeSideStep.Lane) * Width * 0.5f / duration;
			float velocityY = (Active.Y - beforeSideStep.Y) / duration;
			impacts.Add( new( velocityX, velocityY, DiamondShapes.All[Active.ShapeIndex].Cells.Count, contacts ) { Active = true, Floor = TouchesFloor( CellsOf( Active ) ) } );
			if ( DamageActiveLanding( beforeSideStep, velocityY, velocityX ) ) return;
		}
		else if ( firstContact )
		{
			// Solve speed at the actual contact time, not from the clipped frame displacement.
			float speed = FallSpeedAtImpact( remaining, previousHold, softDrop );
			impacts.Add( new( 0, speed * Height, DiamondShapes.All[Active.ShapeIndex].Cells.Count, contacts ) { Active = true, Floor = TouchesFloor( CellsOf( Active ) ) } );
			if ( DamageActiveLanding( beforeFall, speed * Height ) ) return;
		}
		if ( Active.Y >= landing - Epsilon )
		{
			// The landing wait starts once the drawn piece has caught up with a step
			// or turn, so it never jumps into place and still pauses visibly on contact.
			if ( !IsControlAnimating ) groundedTime += delta;
			// A face support makes the direction unambiguous even if another cell's tip touches.
			bool onFace = (contacts & (DiamondContact.Faces.LowerLeft | DiamondContact.Faces.LowerRight)) != 0;
			if ( groundedTime >= (onFace ? FaceLandingDelay : CornerLandingDelay) )
			{
				LockPiece();
			}
		}
		else groundedTime = 0;
	}
}