From 73d5d0cb93a43f793b51e46cbc5153913604635f Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 14:21:27 -0400 Subject: [PATCH] test(bot): integration tests for Casual vs human --- .../test/integration/ai-game-casual.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 packages/server/test/integration/ai-game-casual.test.ts diff --git a/packages/server/test/integration/ai-game-casual.test.ts b/packages/server/test/integration/ai-game-casual.test.ts new file mode 100644 index 0000000..4dab3a5 --- /dev/null +++ b/packages/server/test/integration/ai-game-casual.test.ts @@ -0,0 +1,181 @@ +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, attachBotDriver, +} from '../../src/games.js'; +import { attachSocket } from '../../src/ws.js'; +import { createGameSchema } from '../../src/validation.js'; +import { CasualBrain, BotDriver } from '../../src/bot/index.js'; +import type { ServerMessage } from '@blind-chess/shared'; + +let app: ReturnType; +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 { mode, side, highlightingEnabled, vsAi } = parsed.data; + if (vsAi && vsAi.brain === 'recon') { + reply.code(503); return { error: 'ai_offline' }; + } + const creatorSide = chooseSide(side); + const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi }); + if (vsAi && game.aiOpponent) { + const brain = new CasualBrain({ seed: 1 }); + const driver = new BotDriver({ game, brain, color: game.aiOpponent.color }); + await driver.init(); + attachBotDriver(game.id, driver); + } + const joinUrl = vsAi ? null : `http://placeholder/g/${game.id}`; + return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl }; + }); + 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; + send: (m: unknown) => void; + close: () => void; +} + +function makeClient(gameId: string): Promise { + 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 = 2000) => new Promise((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 createAiGame(side: 'w' | 'b', mode: 'blind' | 'vanilla' = 'vanilla'): Promise<{ gameId: string; creatorToken: string }> { + const res = await fetch(`${baseUrl}/api/games`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode, side, highlightingEnabled: false, vsAi: { brain: 'casual' } }), + }); + return await res.json(); +} + +describe('AI game / Casual', () => { + it('human as black: bot moves first as white', async () => { + const { gameId, creatorToken } = await createAiGame('b'); + const human = await makeClient(gameId); + human.send({ type: 'hello', gameId, token: creatorToken }); + const joined = await human.waitFor((m) => m.type === 'joined'); + expect(joined.type === 'joined' && joined.you).toBe('b'); + + // Bot's opening move should arrive as an update (bot moves first as white). + const botMoved = await human.waitFor((m) => + m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'white_moved'), + 2000, + ); + expect(botMoved.type).toBe('update'); + human.close(); + }); + + it('human as white: human moves first, bot replies', async () => { + const { gameId, creatorToken } = await createAiGame('w'); + const human = await makeClient(gameId); + human.send({ type: 'hello', gameId, token: creatorToken }); + await human.waitFor((m) => m.type === 'joined'); + + // Human plays e2e4 (arm + commit). + human.send({ type: 'commit', from: 'e2' }); + await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2'); + human.send({ type: 'commit', from: 'e2', to: 'e4' }); + + // Bot replies as black. + const botMoved = await human.waitFor((m) => + m.type === 'update' && m.newAnnouncements.some((a) => a.text === 'black_moved'), + ); + expect(botMoved.type).toBe('update'); + + // After bot reply, it's white's turn again. + if (botMoved.type === 'update') { + expect(botMoved.view.toMove).toBe('w'); + } + human.close(); + }); + + it('bot alternate exchanges: game doesn\'t end prematurely', async () => { + // One human-bot exchange: human plays white e2-e4, bot replies as black. + const { gameId, creatorToken } = await createAiGame('w'); + const human = await makeClient(gameId); + human.send({ type: 'hello', gameId, token: creatorToken }); + const joined = await human.waitFor((m) => m.type === 'joined'); + expect(joined.type === 'joined' && joined.gameStatus).toBe('active'); + + human.send({ type: 'commit', from: 'e2' }); + await human.waitFor((m) => m.type === 'update' && m.touchedPiece === 'e2'); + human.send({ type: 'commit', from: 'e2', to: 'e4' }); + const botReplied = await human.waitFor((m) => + m.type === 'update' && + (m.gameStatus === 'finished' || m.view.toMove === 'w'), + 2000, + ); + // Game should still be active after one exchange. + expect(botReplied.type === 'update' && botReplied.gameStatus).toBe('active'); + human.close(); + }); + + it('joinUrl is null for AI games', async () => { + const res = await fetch(`${baseUrl}/api/games`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'casual' } }), + }); + const json = await res.json() as { joinUrl: string | null }; + expect(json.joinUrl).toBeNull(); + }); + + it('recon brain returns 503 in Phase 1', async () => { + const res = await fetch(`${baseUrl}/api/games`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'blind', side: 'w', highlightingEnabled: false, vsAi: { brain: 'recon' } }), + }); + expect(res.status).toBe(503); + }); +});