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>
126 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|