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>
123 lines
4.6 KiB
TypeScript
123 lines
4.6 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { Chess } from 'chess.js';
|
|
import { handleCommit } from '../../src/commit.js';
|
|
import type { Game } from '../../src/state.js';
|
|
import { RATE_LIMIT } from '../../src/state.js';
|
|
|
|
function makeGame(fen?: string): Game {
|
|
const chess = fen ? new Chess(fen) : new Chess();
|
|
return {
|
|
id: 'testtest',
|
|
mode: 'blind',
|
|
highlightingEnabled: false,
|
|
status: 'active',
|
|
createdAt: Date.now(),
|
|
chess,
|
|
moveHistory: [],
|
|
announcements: [],
|
|
players: {
|
|
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
|
|
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
|
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
|
|
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
|
},
|
|
armed: null,
|
|
drawOffer: null,
|
|
disconnectAt: {},
|
|
};
|
|
}
|
|
|
|
describe('hierarchy decision table', () => {
|
|
let game: Game;
|
|
beforeEach(() => { game = makeGame(); });
|
|
|
|
it('row 1: no_such_piece — empty square', () => {
|
|
const r = handleCommit(game, 'w', { from: 'e4' });
|
|
expect(r.kind).toBe('announce');
|
|
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('no_such_piece');
|
|
});
|
|
|
|
it('row 1b: no_such_piece — opponent piece', () => {
|
|
const r = handleCommit(game, 'w', { from: 'e7' }); // black pawn
|
|
expect(r.kind).toBe('announce');
|
|
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('no_such_piece');
|
|
});
|
|
|
|
it('row 2: no_legal_moves — knight in starting position is OK; use a contrived fen', () => {
|
|
// White knight surrounded by own pieces. Place a knight at b1 with own
|
|
// pieces blocking a3, c3, d2.
|
|
const g = makeGame('4k3/8/8/8/8/P1P5/3P4/1N2K3 w - - 0 1');
|
|
// Knight at b1: jumps a3, c3, d2. All blocked by own pawns.
|
|
const r = handleCommit(g, 'w', { from: 'b1' });
|
|
expect(r.kind).toBe('announce');
|
|
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('no_legal_moves');
|
|
});
|
|
|
|
it('row 3: wont_help — pinned bishop, not in check', () => {
|
|
// White king on e1, white bishop on e2 pinned by black rook on e8.
|
|
const g = makeGame('4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
|
|
const r = handleCommit(g, 'w', { from: 'e2' });
|
|
expect(r.kind).toBe('announce');
|
|
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('wont_help');
|
|
});
|
|
|
|
it('row 5: silent on first commit when piece has legal moves', () => {
|
|
const r = handleCommit(game, 'w', { from: 'e2' });
|
|
expect(r.kind).toBe('silent');
|
|
expect(game.armed).toEqual({ color: 'w', from: 'e2' });
|
|
});
|
|
|
|
it('row 6: applied move when legal commit completes', () => {
|
|
const r = handleCommit(game, 'w', { from: 'e2', to: 'e4' });
|
|
expect(r.kind).toBe('applied');
|
|
expect(game.armed).toBeNull();
|
|
expect(game.chess.history()).toContain('e4');
|
|
});
|
|
});
|
|
|
|
describe('touch-move enforcement', () => {
|
|
it('after silent arm, commit with different from yields must_move_touched_piece', () => {
|
|
const game = makeGame();
|
|
handleCommit(game, 'w', { from: 'e2' }); // arm
|
|
const r = handleCommit(game, 'w', { from: 'd2', to: 'd4' });
|
|
expect(r.kind).toBe('error');
|
|
if (r.kind === 'error') expect(r.code).toBe('must_move_touched_piece');
|
|
});
|
|
|
|
it('after silent arm, illegal destination returns illegal_move and KEEPS touch', () => {
|
|
const game = makeGame();
|
|
handleCommit(game, 'w', { from: 'e2' });
|
|
const r = handleCommit(game, 'w', { from: 'e2', to: 'e5' }); // pawn can't go e2→e5
|
|
expect(r.kind).toBe('announce');
|
|
if (r.kind === 'announce') expect(r.announcements[0]!.text).toBe('illegal_move');
|
|
expect(game.armed).toEqual({ color: 'w', from: 'e2' });
|
|
});
|
|
|
|
it('not your turn returns error', () => {
|
|
const game = makeGame();
|
|
const r = handleCommit(game, 'b', { from: 'e7' });
|
|
expect(r.kind).toBe('error');
|
|
if (r.kind === 'error') expect(r.code).toBe('not_your_turn');
|
|
});
|
|
});
|
|
|
|
describe('promotion', () => {
|
|
it('promotion required: pawn on 7th to 8th without promotion field', () => {
|
|
const g = makeGame('7k/4P3/8/8/8/8/8/4K3 w - - 0 1');
|
|
const r = handleCommit(g, 'w', { from: 'e7', to: 'e8' });
|
|
expect(r.kind).toBe('error');
|
|
if (r.kind === 'error') expect(r.code).toBe('promotion_required');
|
|
});
|
|
|
|
it('promotion succeeds with field', () => {
|
|
const g = makeGame('7k/4P3/8/8/8/8/8/4K3 w - - 0 1');
|
|
const r = handleCommit(g, 'w', { from: 'e7', to: 'e8', promotion: 'q' });
|
|
expect(r.kind).toBe('applied');
|
|
if (r.kind === 'applied') {
|
|
const promo = r.announcements.find((a) => a.text === 'white_promoted');
|
|
expect(promo).toBeDefined();
|
|
expect(promo?.payload?.promotedTo).toBe('q');
|
|
}
|
|
});
|
|
});
|