test(bot): integration tests for Casual vs human
This commit is contained in:
@@ -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<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 { 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<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 = 2000) => 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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user