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()): Set { 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'])); }); });