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>
195 lines
8.7 KiB
TypeScript
195 lines
8.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { Role } from '@two-keys/shared';
|
|
import type { Clue, Lock, StoryGame } from '../src/content/schemas';
|
|
import { continueDefuse, defuseAction, solveButton, type DefuseState } from '../src/engines/defuse';
|
|
import type { LabyrinthState } from '../src/engines/labyrinth';
|
|
import { continueChapter, requestHint, submitLock, type StoryState } from '../src/engines/story';
|
|
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
|
|
import { buildState } from '../src/rooms/state';
|
|
import type { Room } from '../src/rooms/store';
|
|
import { clock, defuse, games, labyrinth, playingRoom, storyGames, sync } from './helpers';
|
|
|
|
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'failEnding', 'successEnding'];
|
|
|
|
function payload(room: Room, role: Role, now = 2_000_000): string {
|
|
const player = room.players.find((p) => p.seat === role)!;
|
|
return JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now));
|
|
}
|
|
|
|
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
|
|
if (value && typeof value === 'object') {
|
|
for (const [k, v] of Object.entries(value)) {
|
|
if (!Array.isArray(value)) out.add(k);
|
|
keysOf(v, out);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const locksOf = (game: StoryGame): Lock[] => [
|
|
...game.chapters.map((c) => c.lock),
|
|
...(game.finale.type === 'escape' ? [game.finale.lock] : []),
|
|
];
|
|
|
|
const cluesFor = (game: StoryGame, role: Role): Clue[] => [
|
|
...game.chapters.flatMap((c) => c.clues[role]),
|
|
...(game.finale.type === 'escape' ? game.finale.clues[role] : []),
|
|
];
|
|
|
|
describe.each(storyGames.map((g) => [g.id, g] as const))('sanitized story payloads: %s', (_id, game) => {
|
|
// Walk the whole game, checking both players' payloads at every step.
|
|
it('never contain answers, unrevealed hints, unearned text, or the other role\'s clues', () => {
|
|
const room = playingRoom(game);
|
|
const t = clock(room);
|
|
const s = room.engine as StoryState;
|
|
|
|
const check = () => {
|
|
for (const role of ['A', 'B'] as const) {
|
|
const json = payload(room, role);
|
|
const other: Role = role === 'A' ? 'B' : 'A';
|
|
|
|
for (const key of keysOf(JSON.parse(json))) expect(FORBIDDEN_KEYS, `${role} got key "${key}"`).not.toContain(key);
|
|
|
|
// The other role's private clues never arrive, in any chapter.
|
|
const prefix = (c: Clue) => JSON.stringify(c.body).slice(1, 60);
|
|
const mine = cluesFor(game, role).map(prefix);
|
|
for (const clue of cluesFor(game, other)) {
|
|
expect(json, `${role} can see ${other}'s clue ${clue.id}`).not.toContain(JSON.stringify(clue.title));
|
|
// (Station Echo hands the same Morse chart to each player in turn.)
|
|
if (!mine.includes(prefix(clue))) expect(json).not.toContain(prefix(clue));
|
|
}
|
|
|
|
for (const lock of locksOf(game)) {
|
|
const revealed = s.hints[lock.id] ?? 0;
|
|
lock.hints.forEach((hint, i) => {
|
|
if (i >= revealed) expect(json, `${role} got unrevealed hint ${i + 1} of ${lock.id}`).not.toContain(JSON.stringify(hint));
|
|
});
|
|
if (!s.solved.includes(lock.id) && lock.successText) {
|
|
expect(json, `${role} got successText of unsolved ${lock.id}`).not.toContain(JSON.stringify(lock.successText));
|
|
}
|
|
// Lock objects carry no answer list at all. (Answer *words* may legitimately
|
|
// appear inside a player's own clues, so those can't be grepped for.)
|
|
const sent = (JSON.parse(json).views[role].chapters as { lock: object }[]).map((c) => c.lock);
|
|
for (const l of sent) expect(Object.keys(l)).not.toContain('answers');
|
|
}
|
|
|
|
// Signal patterns only ever ride on signal clues.
|
|
const view = JSON.parse(json).views[role];
|
|
const clues = [...view.chapters.flatMap((c: { clues: Clue[] }) => c.clues), ...(view.finale?.clues ?? [])];
|
|
for (const clue of clues) if (clue.type !== 'signal') expect(clue.signal).toBeUndefined();
|
|
|
|
if (room.phase !== 'finished') {
|
|
expect(json).not.toContain(JSON.stringify(game.finale.successEnding));
|
|
if (game.finale.type === 'accusation') expect(json).not.toContain(JSON.stringify(game.finale.reveal));
|
|
}
|
|
// Future chapters are not sent ahead of time.
|
|
expect(view.chapters.length).toBeLessThanOrEqual(Math.min(s.chapter, game.chapters.length - 1) + 1);
|
|
}
|
|
};
|
|
|
|
check();
|
|
for (const ch of game.chapters) {
|
|
requestHint(game, t.ctx(), ch.lock.id);
|
|
check();
|
|
submitLock(game, t.ctx(), ch.lock.id, 'wrong');
|
|
check();
|
|
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
|
|
check();
|
|
continueChapter(game, t.ctx());
|
|
check();
|
|
}
|
|
});
|
|
|
|
it('sends each player only their own and shared clues', () => {
|
|
const room = playingRoom(game);
|
|
const view = JSON.parse(payload(room, 'A')).views.A;
|
|
const ids = view.chapters[0].clues.map((c: Clue) => c.id);
|
|
const ch = game.chapters[0];
|
|
expect(ids).toEqual([...ch.clues.A, ...ch.clues.shared].map((c) => c.id));
|
|
expect(JSON.parse(payload(room, 'A')).views.B).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('sanitized payloads: other engines', () => {
|
|
it('defuse: the Technician never gets the manual, the Expert never gets the device', () => {
|
|
const game = defuse();
|
|
const room = playingRoom(game);
|
|
const t = clock(room);
|
|
continueDefuse(game, t.ctx());
|
|
const s = room.engine as DefuseState;
|
|
|
|
const tech = payload(room, 'A', t.now);
|
|
const expert = payload(room, 'B', t.now);
|
|
expect(tech).not.toContain('Bomb Defusal Manual');
|
|
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
|
|
expect(tech).toContain(s.device!.serial);
|
|
|
|
expect(expert).toContain('Bomb Defusal Manual');
|
|
expect(expert).not.toContain(s.device!.serial);
|
|
for (const key of ['device', 'serial', 'batteries', 'wires', 'modules', 'strip', 'seed']) {
|
|
expect(keysOf(JSON.parse(expert)), `expert got "${key}"`).not.toContain(key);
|
|
}
|
|
for (const json of [tech, expert]) {
|
|
for (const key of FORBIDDEN_KEYS) expect(keysOf(JSON.parse(json))).not.toContain(key);
|
|
expect(keysOf(JSON.parse(json))).not.toContain('seed');
|
|
}
|
|
});
|
|
|
|
it('defuse: the strip colour is only sent while the button is held', () => {
|
|
const game = defuse();
|
|
const room = playingRoom(game);
|
|
const t = clock(room);
|
|
const s = room.engine as DefuseState;
|
|
s.level = 1;
|
|
// Find a seed whose button has to be held.
|
|
for (s.seed = 1; ; s.seed++) {
|
|
s.status = 'ready';
|
|
continueDefuse(game, t.ctx());
|
|
if (solveButton({ ...s.device!.button!, batteries: s.device!.batteries }).type === 'hold') break;
|
|
}
|
|
const button = () => JSON.parse(payload(room, 'A', t.now)).views.A.device.modules.find((m: { id: string }) => m.id === 'button');
|
|
expect(button().strip).toBeNull();
|
|
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' });
|
|
expect(button()).toMatchObject({ holding: true, strip: s.device!.button!.strip });
|
|
});
|
|
|
|
it('sync: the partner\'s answer is hidden until both have answered', () => {
|
|
const game = sync();
|
|
const room = playingRoom(game);
|
|
const t = clock(room);
|
|
continueSync(game, t.ctx());
|
|
const q = (room.engine as SyncState).questions[0];
|
|
answerSync(game, t.ctx(), 'A', q.id, 2);
|
|
|
|
const b = JSON.parse(payload(room, 'B')).views.B;
|
|
expect(b).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
|
|
expect(JSON.parse(payload(room, 'A')).views.A).toMatchObject({ myAnswer: 2, partnerAnswered: false, reveal: null });
|
|
|
|
answerSync(game, t.ctx(), 'B', q.id, 1);
|
|
expect(JSON.parse(payload(room, 'B')).views.B.reveal).toEqual({ A: 2, B: 1, match: false });
|
|
});
|
|
|
|
it('labyrinth: the Explorer never gets the map, the Navigator never gets the position', () => {
|
|
const game = labyrinth();
|
|
const room = playingRoom(game);
|
|
const s = room.engine as LabyrinthState;
|
|
Object.assign(s, { x: 5, y: 3 }); // standing on the crescent moon
|
|
|
|
const explorer = JSON.parse(payload(room, 'B')).views.B;
|
|
for (const key of ['grid', 'glyphs', 'lanterns', 'x', 'y', 'traps']) {
|
|
expect(keysOf(explorer), `explorer got "${key}"`).not.toContain(key);
|
|
}
|
|
expect(explorer.glyphLabel).toMatch(/crescent moon/);
|
|
expect(explorer.open).toEqual({ n: true, e: false, s: false, w: true });
|
|
expect(JSON.stringify(explorer)).not.toContain('#');
|
|
|
|
const navigator = JSON.parse(payload(room, 'A')).views.A;
|
|
expect(navigator.grid).toEqual(game.grid);
|
|
// The only coordinates the Navigator has are the static map features.
|
|
const { glyphs, lanterns, grid, ...rest } = navigator;
|
|
expect(keysOf(rest)).not.toContain('x');
|
|
expect(keysOf(rest)).not.toContain('y');
|
|
expect(Object.keys(navigator)).not.toEqual(expect.arrayContaining(['open', 'glyphLabel', 'atExit', 'message']));
|
|
});
|
|
});
|