feat(engine): synchronized-move intersection and selection highlight

This commit is contained in:
claude (duplicate_chess)
2026-05-19 00:50:51 -04:00
parent 88f1da9f70
commit 7473cc69b3
2 changed files with 112 additions and 0 deletions
+51
View File
@@ -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
});
});
+61
View File
@@ -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<string>();
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 };
}