diff --git a/src/engine/ghosts.test.ts b/src/engine/ghosts.test.ts new file mode 100644 index 0000000..13ed118 --- /dev/null +++ b/src/engine/ghosts.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { DuplicateGame } from './game'; +import { playSymmetric } from './test-helpers'; +import { ghosts } from './ghosts'; + +describe('ghosts', () => { + it('reports no ghosts at the start', () => { + expect(ghosts(new DuplicateGame())).toEqual([]); + }); + + it('forms a ghost when a piece is captured on one board but not its twin', () => { + const g = new DuplicateGame(); + // Symmetric opening so all four boards stay identical... + playSymmetric(g, [ + [{ from: 'e2', to: 'e4' }, { from: 'e7', to: 'e5' }], + [{ from: 'g1', to: 'f3' }, { from: 'b8', to: 'c6' }], + ]); + // ...then North & South each play Nxe5 (capturing the e5 pawn), + // and East captures that knight only on its boards (NE, SE). + g.applyMove({ from: 'f3', to: 'e5' }); // N: Nf3xe5 on NW, NE + g.applyMove({ from: 'f3', to: 'e5' }); // S: Nf3xe5 on SW, SE + g.applyMove({ from: 'c6', to: 'e5' }); // E: Nc6xe5 on NE, SE — captures the white knight + // North's knight on e5 survives on NW but was captured on NE -> NW/e5 is a ghost. + // South's knight on e5 survives on SW but was captured on SE -> SW/e5 is a ghost. + const result = ghosts(g).sort((x, y) => (x.board < y.board ? -1 : 1)); + expect(result).toEqual([ + { board: 'NW', square: 'e5' }, + { board: 'SW', square: 'e5' }, + ]); + }); +}); diff --git a/src/engine/ghosts.ts b/src/engine/ghosts.ts new file mode 100644 index 0000000..8515d02 --- /dev/null +++ b/src/engine/ghosts.ts @@ -0,0 +1,32 @@ +import type { DuplicateGame } from './game'; +import type { GhostMarker, BoardId, Color, Square } from './types'; +import { PLAYERS, PLAYER_BOARDS, PLAYER_COLOR } from './boards'; + +/** Squares occupied by a piece of `color` on `board`. */ +function colorSquares(game: DuplicateGame, board: BoardId, color: Color): Set { + const set = new Set(); + for (const row of game.boards[board].board()) { + for (const cell of row) { + if (cell && cell.color === color) set.add(cell.square); + } + } + return set; +} + +/** + * Ghosts across all four players. A player's non-ghost pieces always occupy + * identical squares on both their boards (they move in lockstep), so a piece is + * a ghost iff the player's other board has no same-colour piece on that square. + */ +export function ghosts(game: DuplicateGame): GhostMarker[] { + const markers: GhostMarker[] = []; + for (const player of PLAYERS) { + const [a, b] = PLAYER_BOARDS[player]; + const color = PLAYER_COLOR[player]; + const sqA = colorSquares(game, a, color); + const sqB = colorSquares(game, b, color); + for (const sq of sqA) if (!sqB.has(sq)) markers.push({ board: a, square: sq }); + for (const sq of sqB) if (!sqA.has(sq)) markers.push({ board: b, square: sq }); + } + return markers; +}