feat: implement and deploy blind_chess MVP
- 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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import Fastify from 'fastify';
|
||||
import websocketPlugin from '@fastify/websocket';
|
||||
import {
|
||||
activeGameCount,
|
||||
chooseSide,
|
||||
createGame,
|
||||
} from '../../src/games.js';
|
||||
import { attachSocket } from '../../src/ws.js';
|
||||
import { createGameSchema } from '../../src/validation.js';
|
||||
import type { ServerMessage } from '@blind-chess/shared';
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let baseUrl = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(websocketPlugin);
|
||||
app.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount() }));
|
||||
app.post('/api/games', async (req, reply) => {
|
||||
const parsed = createGameSchema.safeParse(req.body);
|
||||
if (!parsed.success) { reply.code(400); return { error: 'malformed' }; }
|
||||
const creatorSide = chooseSide(parsed.data.side);
|
||||
const { game, creatorToken } = createGame({
|
||||
mode: parsed.data.mode,
|
||||
creatorSide,
|
||||
highlightingEnabled: parsed.data.highlightingEnabled,
|
||||
});
|
||||
return { gameId: game.id, creatorToken, creatorColor: creatorSide };
|
||||
});
|
||||
app.get('/ws', { websocket: true }, (socket) => {
|
||||
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
|
||||
attachSocket(raw as never);
|
||||
});
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const addr = app.server.address();
|
||||
if (typeof addr !== 'object' || !addr) throw new Error('no address');
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
interface Client {
|
||||
ws: WebSocket;
|
||||
msgs: ServerMessage[];
|
||||
waitFor: (pred: (m: ServerMessage) => boolean, timeoutMs?: number) => Promise<ServerMessage>;
|
||||
send: (m: unknown) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function makeClient(gameId: string): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(baseUrl.replace('http', 'ws') + `/ws?game=${gameId}`);
|
||||
const msgs: ServerMessage[] = [];
|
||||
const waiters: Array<{ pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }> = [];
|
||||
ws.on('message', (data) => {
|
||||
const m = JSON.parse(data.toString()) as ServerMessage;
|
||||
msgs.push(m);
|
||||
for (const w of [...waiters]) {
|
||||
if (w.pred(m)) {
|
||||
clearTimeout(w.timer);
|
||||
waiters.splice(waiters.indexOf(w), 1);
|
||||
w.resolve(m);
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.on('open', () => resolve({
|
||||
ws, msgs,
|
||||
waitFor: (pred, timeoutMs = 1500) => new Promise<ServerMessage>((res, rej) => {
|
||||
const existing = msgs.find(pred);
|
||||
if (existing) return res(existing);
|
||||
const timer = setTimeout(() => rej(new Error('waitFor timeout')), timeoutMs);
|
||||
waiters.push({ pred, resolve: res, reject: rej, timer });
|
||||
}),
|
||||
send: (m) => ws.send(JSON.stringify(m)),
|
||||
close: () => ws.close(),
|
||||
}));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestGame(mode: 'blind' | 'vanilla' = 'blind'): Promise<{ gameId: string; creatorToken: string; creatorColor: 'w' | 'b' }> {
|
||||
const res = await fetch(`${baseUrl}/api/games`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side: 'w', highlightingEnabled: false }),
|
||||
});
|
||||
return await res.json() as { gameId: string; creatorToken: string; creatorColor: 'w' | 'b' };
|
||||
}
|
||||
|
||||
describe('scripted game end-to-end', () => {
|
||||
it('two clients connect, opening exchange, blind view filtering', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('blind');
|
||||
const w = await makeClient(gameId);
|
||||
const b = await makeClient(gameId);
|
||||
|
||||
// White connects with token, black auto-claims.
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
b.send({ type: 'hello', gameId, joinAs: 'auto' });
|
||||
|
||||
const wJoined = await w.waitFor((m) => m.type === 'joined');
|
||||
const bJoined = await b.waitFor((m) => m.type === 'joined');
|
||||
expect(wJoined.type === 'joined' && wJoined.you).toBe('w');
|
||||
expect(bJoined.type === 'joined' && bJoined.you).toBe('b');
|
||||
|
||||
// Blind view: white sees only its 16 pieces.
|
||||
if (wJoined.type !== 'joined') throw new Error('expected joined');
|
||||
expect(Object.keys(wJoined.view.pieces).length).toBe(16);
|
||||
for (const piece of Object.values(wJoined.view.pieces)) {
|
||||
expect(piece?.color).toBe('w');
|
||||
}
|
||||
|
||||
// White plays e2e4 in two messages: arm + commit.
|
||||
w.send({ type: 'commit', from: 'e2' });
|
||||
await w.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2');
|
||||
w.send({ type: 'commit', from: 'e2', to: 'e4' });
|
||||
|
||||
// Black should see a moderator announcement white_moved.
|
||||
const bMoved = await b.waitFor((m) =>
|
||||
m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'),
|
||||
);
|
||||
expect(bMoved.type).toBe('update');
|
||||
|
||||
w.close();
|
||||
b.close();
|
||||
});
|
||||
|
||||
it('not_your_turn error when black tries to move first', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('vanilla');
|
||||
const w = await makeClient(gameId);
|
||||
const b = await makeClient(gameId);
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
b.send({ type: 'hello', gameId, joinAs: 'auto' });
|
||||
await b.waitFor((m) => m.type === 'joined');
|
||||
b.send({ type: 'commit', from: 'e7', to: 'e5' });
|
||||
const err = await b.waitFor((m) => m.type === 'error');
|
||||
expect(err.type === 'error' && err.code).toBe('not_your_turn');
|
||||
w.close();
|
||||
b.close();
|
||||
});
|
||||
|
||||
it('rejects malformed messages', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('blind');
|
||||
const w = await makeClient(gameId);
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
await w.waitFor((m) => m.type === 'joined');
|
||||
w.send({ type: 'commit', from: 'zz', to: 'e4' });
|
||||
const err = await w.waitFor((m) => m.type === 'error');
|
||||
expect(err.type === 'error' && err.code).toBe('malformed');
|
||||
w.close();
|
||||
});
|
||||
|
||||
it('resign: opponent gets game_finished update', async () => {
|
||||
const { gameId, creatorToken } = await createTestGame('vanilla');
|
||||
const w = await makeClient(gameId);
|
||||
const b = await makeClient(gameId);
|
||||
w.send({ type: 'hello', gameId, token: creatorToken });
|
||||
b.send({ type: 'hello', gameId, joinAs: 'auto' });
|
||||
await b.waitFor((m) => m.type === 'joined');
|
||||
w.send({ type: 'resign' });
|
||||
const upd = await b.waitFor((m) =>
|
||||
m.type === 'update' && m.gameStatus === 'finished',
|
||||
);
|
||||
expect(upd.type === 'update' && upd.endReason).toBe('resign');
|
||||
expect(upd.type === 'update' && upd.winner).toBe('b');
|
||||
w.close();
|
||||
b.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Chess } from 'chess.js';
|
||||
import { buildView, ownSquares } from '../../src/view.js';
|
||||
import type { Game } from '../../src/state.js';
|
||||
import { RATE_LIMIT } from '../../src/state.js';
|
||||
|
||||
function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'finished' = 'active'): Game {
|
||||
return {
|
||||
id: 'testtest',
|
||||
mode,
|
||||
highlightingEnabled: false,
|
||||
status,
|
||||
createdAt: Date.now(),
|
||||
chess: fen ? new Chess(fen) : new 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('buildView: security boundary', () => {
|
||||
it('blind/active white view contains zero black pieces', () => {
|
||||
const g = makeGame('blind');
|
||||
const view = buildView(g, 'w');
|
||||
for (const piece of Object.values(view.pieces)) {
|
||||
expect(piece?.color).toBe('w');
|
||||
}
|
||||
expect(Object.keys(view.pieces).length).toBe(16); // all 16 white pieces
|
||||
});
|
||||
|
||||
it('blind/active black view contains zero white pieces', () => {
|
||||
const g = makeGame('blind');
|
||||
const view = buildView(g, 'b');
|
||||
for (const piece of Object.values(view.pieces)) {
|
||||
expect(piece?.color).toBe('b');
|
||||
}
|
||||
expect(Object.keys(view.pieces).length).toBe(16);
|
||||
});
|
||||
|
||||
it('vanilla/active shows both colors', () => {
|
||||
const g = makeGame('vanilla');
|
||||
const view = buildView(g, 'w');
|
||||
expect(Object.keys(view.pieces).length).toBe(32);
|
||||
});
|
||||
|
||||
it('blind/finished reveals both colors (post-game review)', () => {
|
||||
const g = makeGame('blind', undefined, 'finished');
|
||||
const view = buildView(g, 'w');
|
||||
expect(Object.keys(view.pieces).length).toBe(32);
|
||||
});
|
||||
|
||||
it('blind: inCheck is null for non-actor (info leak prevention)', () => {
|
||||
// Black to move and is in check. White's view says null (it's not white's turn,
|
||||
// and revealing inCheck-status of opponent leaks info).
|
||||
const g = makeGame('blind', 'rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3');
|
||||
const view = buildView(g, 'b');
|
||||
// It's white's turn here. Black viewer is not the to-move side.
|
||||
expect(view.inCheck).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownSquares', () => {
|
||||
it('starting position returns 16 own squares', () => {
|
||||
const g = makeGame('blind');
|
||||
expect(ownSquares(g, 'w').size).toBe(16);
|
||||
expect(ownSquares(g, 'b').size).toBe(16);
|
||||
});
|
||||
|
||||
it('contains only own-color squares', () => {
|
||||
const g = makeGame('blind');
|
||||
const wSet = ownSquares(g, 'w');
|
||||
expect(wSet.has('e2')).toBe(true);
|
||||
expect(wSet.has('e7')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user