a6de43edc1
- pnpm workspace: shared/server/client packages - Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy), per-player view filter, zod validation, rate limiting, grace-window disconnect handling - Client: Svelte 5 + Vite, click-to-move board, moderator panel, promotion/draw dialogs - Shared: protocol types, ModeratorText enum, geometricMoves helper (provably zero opponent-info leak) - 43 tests pass (21 shared, 22 server incl. 4 real-WS integration) - Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed, Caddy block for chess.sethpc.xyz - Live at https://chess.sethpc.xyz Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { geometricMoves } from '../src/geometric.js';
|
|
import type { Piece, Square } from '../src/types.js';
|
|
|
|
const set = (...sq: Square[]) => new Set<Square>(sq);
|
|
|
|
describe('geometricMoves: knight', () => {
|
|
it('jumps to all 8 squares from d4 with no own pieces', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'n' }, 'd4', set());
|
|
expect(moves.sort()).toEqual(['b3', 'b5', 'c2', 'c6', 'e2', 'e6', 'f3', 'f5']);
|
|
});
|
|
|
|
it('cannot land on own pieces', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'n' }, 'd4', set('e6', 'b3'));
|
|
expect(moves).not.toContain('e6');
|
|
expect(moves).not.toContain('b3');
|
|
expect(moves.length).toBe(6);
|
|
});
|
|
|
|
it('corner: a1 has only 2 jumps', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'n' }, 'a1', set());
|
|
expect(moves.sort()).toEqual(['b3', 'c2']);
|
|
});
|
|
|
|
it('hierarchy row 2 — knight surrounded by own pawns yields ∅', () => {
|
|
// White knight on b1 with own pawns blocking d2, c3, a3 reachable squares
|
|
const moves = geometricMoves({ color: 'w', type: 'n' }, 'b1', set('d2', 'c3', 'a3'));
|
|
expect(moves).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('geometricMoves: rays (bishop/rook/queen)', () => {
|
|
it('bishop on d4 reaches both diagonals fully when board empty', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set());
|
|
expect(moves.sort()).toEqual(['a1', 'a7', 'b2', 'b6', 'c3', 'c5', 'e3', 'e5', 'f2', 'f6', 'g1', 'g7', 'h8']);
|
|
});
|
|
|
|
it('bishop ray STOPS at own piece (square excluded)', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set('f6'));
|
|
expect(moves).not.toContain('f6');
|
|
expect(moves).not.toContain('g7');
|
|
expect(moves).not.toContain('h8');
|
|
expect(moves).toContain('e5');
|
|
});
|
|
|
|
it('bishop ray EXTENDS THROUGH unknown squares (may host opponent pieces)', () => {
|
|
// No own piece on f6. From the function's POV, f6 is "unknown" — ray continues.
|
|
const moves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set());
|
|
expect(moves).toContain('f6');
|
|
expect(moves).toContain('g7');
|
|
expect(moves).toContain('h8');
|
|
});
|
|
|
|
it('rook on a1 with own pawn at a2 has zero rank moves up', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'r' }, 'a1', set('a2'));
|
|
expect(moves).not.toContain('a2');
|
|
expect(moves).toContain('b1');
|
|
expect(moves).toContain('h1');
|
|
});
|
|
|
|
it('queen combines rook+bishop reach', () => {
|
|
const queenMoves = geometricMoves({ color: 'w', type: 'q' }, 'd4', set());
|
|
const bishopMoves = geometricMoves({ color: 'w', type: 'b' }, 'd4', set());
|
|
const rookMoves = geometricMoves({ color: 'w', type: 'r' }, 'd4', set());
|
|
expect(queenMoves.sort()).toEqual([...bishopMoves, ...rookMoves].sort());
|
|
});
|
|
});
|
|
|
|
describe('geometricMoves: king', () => {
|
|
it('center king: 8 neighbors', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'k' }, 'e4', set());
|
|
expect(moves.sort()).toEqual(['d3', 'd4', 'd5', 'e3', 'e5', 'f3', 'f4', 'f5']);
|
|
});
|
|
|
|
it('corner king: 3 neighbors', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'k' }, 'a1', set());
|
|
expect(moves.sort()).toEqual(['a2', 'b1', 'b2']);
|
|
});
|
|
|
|
it('does NOT include castling targets', () => {
|
|
// White king at e1 with own rooks present should NOT see g1 or c1.
|
|
const moves = geometricMoves({ color: 'w', type: 'k' }, 'e1', set('a1', 'h1'));
|
|
expect(moves).not.toContain('g1');
|
|
expect(moves).not.toContain('c1');
|
|
});
|
|
});
|
|
|
|
describe('geometricMoves: pawn', () => {
|
|
it('white pawn on starting rank: forward 1 + 2 + diagonals', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e2', set());
|
|
expect(moves.sort()).toEqual(['d3', 'e3', 'e4', 'f3']);
|
|
});
|
|
|
|
it('white pawn forward-2 blocked by own piece on e3', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e2', set('e3'));
|
|
expect(moves).not.toContain('e3');
|
|
expect(moves).not.toContain('e4');
|
|
});
|
|
|
|
it('white pawn forward-2 blocked by own piece on e4 (intermediate clear)', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e2', set('e4'));
|
|
expect(moves).toContain('e3');
|
|
expect(moves).not.toContain('e4');
|
|
});
|
|
|
|
it('white pawn off starting rank: only forward 1 + diagonals', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e4', set());
|
|
expect(moves.sort()).toEqual(['d5', 'e5', 'f5']);
|
|
});
|
|
|
|
it('white pawn diagonals included even when empty (illegal-move probe ok)', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e4', set());
|
|
expect(moves).toContain('d5');
|
|
expect(moves).toContain('f5');
|
|
});
|
|
|
|
it('white pawn diagonal blocked by own piece', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'e4', set('f5'));
|
|
expect(moves).not.toContain('f5');
|
|
});
|
|
|
|
it('black pawn from starting rank moves DOWN', () => {
|
|
const moves = geometricMoves({ color: 'b', type: 'p' }, 'e7', set());
|
|
expect(moves.sort()).toEqual(['d6', 'e5', 'e6', 'f6']);
|
|
});
|
|
|
|
it('a-file pawn: only one diagonal (b)', () => {
|
|
const moves = geometricMoves({ color: 'w', type: 'p' }, 'a2', set());
|
|
expect(moves.sort()).toEqual(['a3', 'a4', 'b3']);
|
|
});
|
|
});
|
|
|
|
describe('zero-opponent-leak invariant', () => {
|
|
it('output is identical regardless of opponent position when ownSquares is the same', () => {
|
|
// The function's signature literally cannot accept opponent positions.
|
|
// This test asserts that two callers with different mental models of
|
|
// opponent state but identical ownSquares get identical results.
|
|
const own = set('e2');
|
|
const a = geometricMoves({ color: 'w', type: 'r' }, 'd4', own);
|
|
const b = geometricMoves({ color: 'w', type: 'r' }, 'd4', own);
|
|
expect(a).toEqual(b);
|
|
// Sanity that ownSquares actually affects output: d6 is on the d-file
|
|
// ray, so blocking it shortens the rook's reach.
|
|
const blocked = geometricMoves({ color: 'w', type: 'r' }, 'd4', set('d6'));
|
|
expect(blocked).not.toContain('d6');
|
|
expect(blocked).not.toContain('d7');
|
|
expect(a).toContain('d6');
|
|
});
|
|
});
|