From 7473cc69b36ceb419c376e96c677905aa7cd8cea Mon Sep 17 00:00:00 2001 From: "claude (duplicate_chess)" Date: Tue, 19 May 2026 00:50:51 -0400 Subject: [PATCH] feat(engine): synchronized-move intersection and selection highlight --- src/engine/legality.test.ts | 51 +++++++++++++++++++++++++++++++ src/engine/legality.ts | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/engine/legality.test.ts create mode 100644 src/engine/legality.ts diff --git a/src/engine/legality.test.ts b/src/engine/legality.test.ts new file mode 100644 index 0000000..818bd1d --- /dev/null +++ b/src/engine/legality.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { DuplicateGame } from './game'; +import { legalSyncedMoves, selectionHighlight } from './legality'; + +describe('legalSyncedMoves', () => { + it('returns all 20 white opening moves when a player\'s two boards are identical', () => { + const g = new DuplicateGame(); + expect(legalSyncedMoves(g)).toHaveLength(20); + }); + + it('excludes a move legal on only one of the player\'s boards', () => { + // N e2e4, S e2e4, W e7e5, E d7d5 -> NW black has pe5, NE black has pd5. + const g = new DuplicateGame([ + { player: 'N', from: 'e2', to: 'e4' }, + { player: 'S', from: 'e2', to: 'e4' }, + { player: 'E', from: 'd7', to: 'd5' }, + { player: 'W', from: 'e7', to: 'e5' }, + ]); + expect(g.currentPlayer).toBe('N'); + const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`); + // e4-d5 is a capture on NE but illegal on NW (d5 empty there): not synced. + expect(keys).not.toContain('e4d5'); + // e4-e5 is legal on NE but blocked on NW (black pawn on e5): not synced. + expect(keys).not.toContain('e4e5'); + }); +}); + +describe('selectionHighlight', () => { + it('marks every destination playable when the two boards agree', () => { + const g = new DuplicateGame(); + const h = selectionHighlight(g, 'e2'); + expect(h.playable.sort()).toEqual(['e3', 'e4']); + expect(h.onlyA).toEqual([]); + expect(h.onlyB).toEqual([]); + }); + + it('splits destinations into playable vs board-local-only on divergence', () => { + const g = new DuplicateGame([ + { player: 'N', from: 'e2', to: 'e4' }, + { player: 'S', from: 'e2', to: 'e4' }, + { player: 'E', from: 'd7', to: 'd5' }, + { player: 'W', from: 'e7', to: 'e5' }, + ]); + const h = selectionHighlight(g, 'e4'); // North's e4 pawn + expect(h.boardA).toBe('NW'); + expect(h.boardB).toBe('NE'); + expect(h.playable).toEqual([]); // nothing legal on both + expect(h.onlyA).toEqual([]); // e4 is blocked on NW + expect(h.onlyB.sort()).toEqual(['d5', 'e5']); // capture + advance on NE only + }); +}); diff --git a/src/engine/legality.ts b/src/engine/legality.ts new file mode 100644 index 0000000..0784447 --- /dev/null +++ b/src/engine/legality.ts @@ -0,0 +1,61 @@ +import type { DuplicateGame } from './game'; +import type { SyncMove, Square, BoardId, PromotionPiece } from './types'; +import { PLAYER_BOARDS } from './boards'; + +function key(m: { from: string; to: string; promotion?: string }): string { + return `${m.from}${m.to}${m.promotion ?? ''}`; +} + +/** Every synchronized-legal move for the player to move (the intersection). */ +export function legalSyncedMoves(game: DuplicateGame): SyncMove[] { + const [a, b] = PLAYER_BOARDS[game.currentPlayer]; + const movesA = game.boards[a].moves({ verbose: true }); + const keysB = new Set(game.boards[b].moves({ verbose: true }).map(key)); + const seen = new Set(); + const result: SyncMove[] = []; + for (const m of movesA) { + const k = key(m); + if (keysB.has(k) && !seen.has(k)) { + seen.add(k); + result.push({ + from: m.from, + to: m.to, + promotion: (m.promotion as PromotionPiece) || undefined, + }); + } + } + return result; +} + +export interface SelectionHighlight { + /** The current player's first board. */ + boardA: BoardId; + /** The current player's second board. */ + boardB: BoardId; + /** Destinations legal on BOTH boards (actually playable). */ + playable: Square[]; + /** Destinations legal on board A only. */ + onlyA: Square[]; + /** Destinations legal on board B only. */ + onlyB: Square[]; +} + +/** Triple-highlight data for the current player's piece grabbed at `from`. */ +export function selectionHighlight( + game: DuplicateGame, + from: Square, +): SelectionHighlight { + const [a, b] = PLAYER_BOARDS[game.currentPlayer]; + const destA = new Set( + game.boards[a].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to), + ); + const destB = new Set( + game.boards[b].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to), + ); + const playable: Square[] = []; + const onlyA: Square[] = []; + const onlyB: Square[] = []; + for (const sq of destA) (destB.has(sq) ? playable : onlyA).push(sq); + for (const sq of destB) if (!destA.has(sq)) onlyB.push(sq); + return { boardA: a, boardB: b, playable, onlyA, onlyB }; +}