feat(engine): synchronized-move intersection and selection highlight
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user