DiamondDamage.cs
using System;
using System.Collections.Generic;
using static Diamonds.DiamondBoard;

namespace Diamonds;

/// <summary>One damage wave per collision, propagated only through pre-existing face bonds.</summary>
public static class DiamondDamage
{
	const float Tolerance = 0.02f;
	public const float SlideDamageSteps = 3;
	public readonly record struct Contact( float Lane, float Y, int ColorIndex );
	public readonly record struct FaceContact( float StartLane, float StartY, float EndLane, float EndY, int ColorIndex );

	public static Dictionary<int, Corner> Targets( IReadOnlyList<Diamond> cells,
		IReadOnlyList<DiamondMatching.CellMove> moving, IReadOnlyList<IReadOnlyList<int>> bonds,
		float velocityX, float velocityY, List<Contact> contacts = null, List<FaceContact> faceContacts = null )
	{
		var hit = new Dictionary<int, Corner>();
		if ( velocityY <= 0 ) return hit;
		// Bottom-to-top points damage only on straight vertical falls. Diagonal
		// slides must qualify through their full leading faces, even at high speed.
		if ( velocityX == 0 && velocityY > DamageSpeedThreshold )
			hit = PointTargets( cells, moving, contacts );
		SlideFaceTargets( cells, moving, hit, faceContacts );
		var movingIndices = new HashSet<int>();
		foreach ( var move in moving ) movingIndices.Add( move.Index );
		var damaged = new Dictionary<int, Corner>( hit );
		foreach ( var group in bonds )
		{
			foreach ( int index in group )
			{
				if ( !hit.TryGetValue( index, out var corner ) ) continue;
				// Indirect fractures follow the initiating strike. Direct contacts retain
				// their own corner, including simultaneous hits on the same bonded group.
				foreach ( int member in group ) if ( !movingIndices.Contains( member ) ) damaged.TryAdd( member, corner );
				break;
			}
		}
		return damaged;
	}

	// SettlementMove spans one uninterrupted slide, already coalesced across lattice
	// steps. A stop, turn, group change or replan starts a new From, so distance from
	// an earlier slide can never survive a landing or the destruction of a support.
	static void SlideFaceTargets( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		Dictionary<int, Corner> hit, List<FaceContact> contacts )
	{
		var movingIndices = new HashSet<int>();
		foreach ( var move in moving ) movingIndices.Add( move.Index );
		var seen = new HashSet<(int, int)>();
		foreach ( var move in moving )
		{
			float lanes = move.To.Lane - move.From.Lane;
			float dy = move.To.Y - move.From.Y;
			if ( lanes == 0 || dy <= 0 || MathF.Abs( dy - MathF.Abs( lanes ) * Height * 0.5f ) > Tolerance ||
				MathF.Abs( lanes ) < SlideDamageSteps ) continue;
			int direction = Math.Sign( lanes );
			for ( int other = 0; other < cells.Count; other++ )
			{
				if ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;
				var receiver = cells[other];
				// Only the lower face facing into the slide closes onto a full opposing
				// face. The supporting slope is tangential; tips and partial faces don't count.
				if ( MathF.Abs( receiver.Lane - move.To.Lane - direction ) * Width * 0.5f >= Tolerance ||
					MathF.Abs( receiver.Y - move.To.Y - Height * 0.5f ) >= Tolerance ) continue;
				hit.TryAdd( other, Corner.Top );
				if ( contacts is not null && seen.Add( (other, direction) ) )
					contacts.Add( new( receiver.Lane, receiver.Y - Height * 0.5f,
						receiver.Lane - direction, receiver.Y, receiver.ColorIndex ) );
			}
		}
	}

	/// <summary>Exposed point contacts for a downward preview, regardless of speed, including floor peaks.</summary>
	public static IReadOnlyList<Contact> PointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		bool fillCornerPockets = true )
	{
		var contacts = new List<Contact>();
		PointTargets( cells, moving, contacts, includeFloor: true, fillCornerPockets: fillCornerPockets );
		return contacts;
	}

	// Caller-owned storage: previews reuse this without sharing state with real damage waves.
	internal sealed class PointContactScratch
	{
		public readonly Dictionary<int, Corner> Hit = new();
		public readonly HashSet<int> Moving = new();
		public readonly HashSet<(int, Corner)> Corners = new();
	}

	internal static void CopyPointContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		List<Contact> contacts, PointContactScratch scratch, bool fillCornerPockets = true )
	{
		contacts.Clear();
		PointTargets( cells, moving, contacts, includeFloor: true, scratch: scratch, fillCornerPockets: fillCornerPockets );
	}

	/// <summary>New exposed tip collisions for cosmetic sparks, including harmless side tips.</summary>
	public static IReadOnlyList<Contact> SparkContacts( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moving,
		bool fillCornerPockets = true )
	{
		var contacts = new List<Contact>( PointContacts( cells, moving, fillCornerPockets ) );
		var movingIndices = new HashSet<int>( moving.Select( m => m.Index ) );
		foreach ( var move in moving )
		for ( int other = 0; other < cells.Count; other++ )
		{
			if ( movingIndices.Contains( other ) || cells[other].Health <= 0 ) continue;
			var receiver = cells[other];
			float dx = (receiver.Lane - move.To.Lane) * Width * 0.5f;
			if ( MathF.Abs( MathF.Abs( dx ) - Width ) > Tolerance || MathF.Abs( receiver.Y - move.To.Y ) > Tolerance ) continue;
			int side = Math.Sign( dx );
			if ( (move.To.Lane - move.From.Lane) * side <= 0 ||
				!ExposedPoint( cells, move.Index, side, 0, movingIndices ) ||
				!ExposedPoint( cells, other, -side, 0, movingIndices ) ) continue;
			var contact = new Contact( (move.To.Lane + receiver.Lane) * 0.5f, receiver.Y, receiver.ColorIndex );
			if ( !contacts.Contains( contact ) ) contacts.Add( contact );
		}
		return contacts;
	}

	static Dictionary<int, Corner> PointTargets( IReadOnlyList<Diamond> cells,
		IReadOnlyList<DiamondMatching.CellMove> moving,
		List<Contact> contacts, bool includeFloor = false, PointContactScratch scratch = null, bool fillCornerPockets = true )
	{
		var hit = scratch?.Hit ?? new Dictionary<int, Corner>();
		var movingIndices = scratch?.Moving ?? new HashSet<int>();
		var contactCorners = scratch?.Corners ?? new HashSet<(int, Corner)>();
		hit.Clear(); movingIndices.Clear(); contactCorners.Clear();
		for ( int i = 0; i < moving.Count; i++ ) movingIndices.Add( moving[i].Index );
		for ( int i = 0; i < moving.Count; i++ )
		{
			var move = moving[i];
			var cell = move.To;
			if ( cell.Y <= move.From.Y ) continue;
			// Odd lanes sit over upward floor peaks; even lanes nest between two faces.
			if ( includeFloor && MathF.Abs( cell.Lane % 2 - 1 ) * Width * 0.5f < Tolerance &&
				MathF.Abs( cell.Y - FloorY( cell.Lane ) ) < Tolerance &&
				ExposedPoint( cells, move.Index, 0, 1, movingIndices ) && ExposedFloorPeak( cells, cell, movingIndices, fillCornerPockets ) &&
				contactCorners.Add( (move.Index, Corner.Bottom) ) )
				contacts.Add( new( cell.Lane, cell.Y + Height * 0.5f, cell.ColorIndex ) );
			// Only receiving diamonds take damage; the floor cannot receive gem damage.
			for ( int other = 0; other < cells.Count; other++ )
			{
				if ( movingIndices.Contains( other ) ) continue;
				var receiver = cells[other];
				// Only a moving bottom tip striking a receiving top tip can damage.
				// Side tips meeting at the end of a downhill slide are harmless.
				if ( !BottomToTopContact( cell, receiver ) || BottomToTopContact( move.From, receiver, 0.0001f ) ) continue;
				if ( !ExposedPoint( cells, move.Index, 0, 1, movingIndices ) ||
					!ExposedPoint( cells, other, 0, -1, movingIndices ) ) continue;
				const Corner corner = Corner.Top;
				hit.TryAdd( other, corner );
				if ( contacts is not null && contactCorners.Add( (other, corner) ) )
					contacts.Add( new( (cell.Lane + receiver.Lane) * 0.5f, (cell.Y + receiver.Y) * 0.5f, receiver.ColorIndex ) );
			}
		}
		return hit;
	}

	// Either face adjoining the struck point can extend it into a smooth edge.
	// Check the whole physical assembly, including other colors, but exclude the
	// opposite collision side: new contacts must not hide the points being tested.
	static bool ExposedPoint( IReadOnlyList<Diamond> cells, int index, int pointX, int pointY, HashSet<int> moving )
	{
		var cell = cells[index];
		bool isMoving = moving.Contains( index );
		for ( int other = 0; other < cells.Count; other++ )
		{
			if ( other == index || moving.Contains( other ) != isMoving || cells[other].Health <= 0 ) continue;
			float dx = (cells[other].Lane - cell.Lane) * Width * 0.5f;
			float dy = cells[other].Y - cell.Y;
			if ( MathF.Abs( MathF.Abs( dx ) - Width * 0.5f ) >= Tolerance ||
				MathF.Abs( MathF.Abs( dy ) - Height * 0.5f ) >= Tolerance ) continue;
			if ( (pointX != 0 && Math.Sign( dx ) == pointX) ||
				(pointY != 0 && Math.Sign( dy ) == pointY) ) return false;
		}
		return true;
	}

	// A peak flanked by a resting gem or filled corner slope is part of a smooth
	// edge: the mover's lower face lands flush on that neighbor's upper face.
	static bool ExposedFloorPeak( IReadOnlyList<Diamond> cells, Diamond cell, HashSet<int> moving, bool fillCornerPockets )
	{
		for ( int other = 0; other < cells.Count; other++ )
			if ( !moving.Contains( other ) && cells[other].Health > 0 && FlanksPeak( cells[other] ) ) return false;
		// Matches DiamondBoard.CornerFills without allocating an iterator per preview frame.
		return !fillCornerPockets || (!FlanksPeak( new( 0, BoardHeight - Height * 0.5f, 0 ) ) &&
			!FlanksPeak( new( LaneCount - 1, BoardHeight - Height * 0.5f, 0 ) ));
		bool FlanksPeak( Diamond flank ) => MathF.Abs( MathF.Abs( flank.Lane - cell.Lane ) * Width * 0.5f - Width * 0.5f ) < Tolerance &&
			MathF.Abs( flank.Y - cell.Y - Height * 0.5f ) < Tolerance;
	}

	static bool BottomToTopContact( Diamond mover, Diamond receiver, float tolerance = Tolerance )
	{
		float dx = MathF.Abs( mover.Lane - receiver.Lane ) * Width * 0.5f;
		return dx < tolerance && MathF.Abs( receiver.Y - mover.Y - Height ) < tolerance;
	}

	public static bool Apply( List<Diamond> cells, IReadOnlyDictionary<int, Corner> targets )
	{
		bool destroyed = false;
		foreach ( var target in targets )
		{
			int index = target.Key;
			cells[index] = cells[index] with
			{
				Health = Math.Max( 0, cells[index].Health - 1 ),
				FirstCrack = cells[index].Health == MaxHealth ? target.Value : cells[index].FirstCrack
			};
			destroyed |= cells[index].Health == 0;
		}
		return destroyed;
	}
}