Files
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:12 +02:00

126 lines
5.1 KiB
TypeScript

// 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<JoinAck>((resolve) => socket.emit(event, payload, resolve)),
/** Resolves once a received state satisfies the predicate. */
async until(predicate: (s: RoomState) => boolean): Promise<RoomState> {
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<void>((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);
});
});