// End-to-end over real sockets: two clients complete The Clockmaker's Attic. import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { io as connect, type Socket } from 'socket.io-client'; import type { JoinAck, RoomState, StoryView } from '@two-keys/shared'; import { createApp } from '../src/app'; const app = createApp({ dataFile: null }); let url = ''; const sockets: Socket[] = []; beforeAll(async () => { url = `http://localhost:${await app.listen(0)}`; }); afterAll(async () => { sockets.forEach((s) => s.close()); await app.close(); }); function client() { const socket = connect(url, { transports: ['websocket'], forceNew: true }); sockets.push(socket); const seen: RoomState[] = []; let latest: RoomState | undefined; let wake = () => {}; socket.on('room:state', (state: RoomState) => { latest = state; seen.push(state); wake(); }); return { socket, seen, get state() { return latest!; }, ack: (event: string, payload: object) => new Promise((resolve) => socket.emit(event, payload, resolve)), /** Resolves once a received state satisfies the predicate. */ async until(predicate: (s: RoomState) => boolean): Promise { const deadline = Date.now() + 5000; while (!(latest && predicate(latest))) { if (Date.now() > deadline) throw new Error('timed out waiting for room state'); await new Promise((resolve) => { wake = resolve; setTimeout(resolve, 100); }); } return latest; }, }; } describe('two players over sockets', () => { it('create, join, pick roles, play Clockmaker\'s Attic to the end, and reconnect', async () => { const game = app.games.get('clockmakers-attic')!; if (game.engine !== 'story' || game.finale.type !== 'escape') throw new Error('unexpected game shape'); const a = client(); const b = client(); const created = await a.ack('room:create', { gameId: game.id }); if (!created.ok) throw new Error(created.message); expect(created.code).toMatch(/^[A-Z2-9]{5}$/); a.socket.emit('room:pickRole', { role: 'A' }); const joined = await b.ack('room:join', { code: created.code.toLowerCase() }); if (!joined.ok) throw new Error(joined.message); await b.until((s) => s.you.seat === 'B'); // partner gets the other role // A third player can't get in. const c = client(); expect(await c.ack('room:join', { code: created.code })).toMatchObject({ ok: false, code: 'room_full' }); expect(await c.ack('room:join', { code: 'ZZZZZ' })).toMatchObject({ ok: false, code: 'not_found' }); a.socket.emit('room:ready'); b.socket.emit('room:ready'); await a.until((s) => s.phase === 'playing'); await b.until((s) => s.phase === 'playing'); for (const [i, chapter] of game.chapters.entries()) { // Either player can submit; alternate. const [me, partner] = i % 2 ? [b, a] : [a, b]; me.socket.emit('lock:submit', { lockId: chapter.lock.id, value: chapter.lock.answers[0] }); await partner.until((s) => s.keys.length === i + 1); partner.socket.emit('chapter:continue'); await me.until((s) => (s.views[s.you.seat as 'A' | 'B'] as StoryView).currentChapter === i + 1); } // Refresh mid-game: a new socket with the same token gets the same seat and progress. b.socket.close(); await a.until((s) => s.partner?.online === false); const b2 = client(); expect(await b2.ack('room:join', { code: created.code, playerToken: joined.playerToken })).toMatchObject({ ok: true }); const restored = await b2.until((s) => s.phase === 'playing'); expect(restored.you.seat).toBe('B'); expect(restored.keys).toHaveLength(game.chapters.length); expect((restored.views.B as StoryView).finale?.type).toBe('escape'); b2.socket.emit('lock:submit', { lockId: game.finale.lock.id, value: 'forever' }); const done = await a.until((s) => s.phase === 'finished'); expect(done.result).toMatchObject({ success: true, keysEarned: 3, totalKeys: 3, hintsUsed: 0 }); expect(done.result?.ending).toContain('FOREVER'); // Nothing A ever received contained B's clues, or any answers. const everything = JSON.stringify(a.seen); for (const chapter of game.chapters) { for (const clue of chapter.clues.B) expect(everything).not.toContain(JSON.stringify(clue.title)); } expect(everything).not.toContain('"answers"'); expect(everything).not.toContain(JSON.stringify(game.finale.clues.B[0].title)); }); it('couch mode: one device holds both roles and sees both views', async () => { const solo = client(); const created = await solo.ack('room:create', { gameId: 'labyrinth-of-lanterns', mode: 'couch' }); if (!created.ok) throw new Error(created.message); solo.socket.emit('room:ready'); const state = await solo.until((s) => s.phase === 'playing'); expect(state.you.seat).toBe('both'); expect(Object.keys(state.views).sort()).toEqual(['A', 'B']); solo.socket.emit('maze:move', { dir: 'e', asRole: 'B' }); await solo.until((s) => (s.views.B as { moves: number }).moves === 1); }); });