// The second wave of games: Overtime (defuse), Deep Cuts (sync), the Drowned Catacombs and // the Mirror Maze (labyrinth), and the story games with their own penalties and time limits. import { describe, expect, it } from 'vitest'; import type { Dir, MoveDir, Role } from '@two-keys/shared'; import { buttonSolution, continueDefuse, defuseAction, edgework, generateDevice, isSolvable, remainingAt, solveKeypad, solveMorse, solvePassword, solveSimon, timerText, toSignal, wireSolution, type DefuseState, } from '../src/engines/defuse'; import { validateGame } from '../src/content/validate'; import { DELTAS, cellAt, findCells, gateBlocks, moveExplorer, toAbsolute, type LabyrinthState } from '../src/engines/labyrinth'; import { continueChapter, requestHint, submitLock, tickStory, type StoryState } from '../src/engines/story'; import { answerSync, continueSync, normalizeWord, type SyncState } from '../src/engines/sync'; import { buildState } from '../src/rooms/state'; import type { Room } from '../src/rooms/store'; import { clock, games, getGame, playingRoom } from './helpers'; const overtime = () => getGame('tick-tock-overtime', 'defuse'); const deepCuts = () => getGame('in-sync-deep-cuts', 'sync'); const catacombs = () => getGame('drowned-catacombs', 'labyrinth'); const mirrorMaze = () => getGame('mirror-maze', 'labyrinth'); const viewOf = (room: Room, role: Role, now = 2_000_000) => { const player = room.players.find((p) => p.seat === role)!; return JSON.parse(JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now))); }; describe('defuse: Overtime manual', () => { const game = overtime(); const { fixtures, modules, ruleset } = game; it.each(fixtures.wires)('wires fixture %#', (f) => { expect(wireSolution(ruleset, f, f.wires)).toEqual(f.expect); }); it.each(fixtures.button)('button fixture %#', (f) => { expect(buttonSolution(ruleset, { serial: '', ...f }, f)).toEqual(f.expect); }); it.each(fixtures.keypad)('keypad fixture %#', (f) => { expect(solveKeypad(f.symbols, modules.keypad.columns)).toEqual(f.expect); }); it.each(fixtures.passwords)('passwords fixture %#', (f) => { expect(solvePassword(f.columns, modules.passwords!.words)).toBe(f.expect); }); it.each(fixtures.morse)('morse fixture %#', (f) => { expect(solveMorse(f.signal, modules.morse!.words)).toEqual(f.expect); }); it.each(fixtures.simon)('simon fixture %#', (f) => { expect(solveSimon(f.serial, f.strikes, f.flashes)).toEqual(f.expect); }); it('never generates a device the solver cannot solve, on any level', () => { for (let level = 0; level < game.levels.length; level++) { for (let seed = 1; seed <= 400; seed++) { const device = generateDevice(game, level, seed); expect(isSolvable(game, device), `level ${level} seed ${seed}`).toBe(true); expect(device.indicators!.length).toBeGreaterThanOrEqual(1); expect(device.indicators!.length).toBeLessThanOrEqual(3); if (device.passwords) { expect(device.passwords.columns).toHaveLength(5); for (const column of device.passwords.columns) expect(new Set(column).size).toBe(6); } if (device.simon) { expect(device.simon.sequence.length).toBeGreaterThanOrEqual(3); expect(device.simon.sequence.length).toBeLessThanOrEqual(5); } } } }); /** Solves whatever is on the device, using only the solver. */ function solveAll(room: Room, t: ReturnType) { const s = room.engine as DefuseState; const d = s.device!; const act = (id: string, action: Parameters[4], ms = 100) => expect(defuseAction(game, t.ctx(ms), 'A', id, action).ok, id).toBe(true); if (d.wires) act('wires', wireSolution(ruleset, edgework(d), d.wires.colors)); if (d.keypad) for (const symbol of solveKeypad(d.keypad.symbols, modules.keypad.columns)!) act('keypad', { type: 'press', symbol }); if (d.passwords) act('passwords', { type: 'submit', word: solvePassword(d.passwords.columns, modules.passwords!.words)! }); if (d.morse) act('morse', { type: 'transmit', frequency: solveMorse(toSignal(d.morse.word), modules.morse!.words)!.frequency }); if (d.simon) { for (let stage = 1; stage <= d.simon.sequence.length; stage++) { for (const color of solveSimon(d.serial, s.strikes, d.simon.sequence.slice(0, stage))) act('simon', { type: 'press', color }); } } if (d.button) { const solution = buttonSolution(ruleset, edgework(d), d.button); if (solution.type === 'tap') act('button', { type: 'tap' }); else { act('button', { type: 'hold' }); for (let i = 0; i < 400; i++) { if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break; t.ctx(500); } act('button', { type: 'release' }, 0); } } } it('clears all four levels for a key each and finishes the game', () => { const room = playingRoom(game); const t = clock(room); for (let level = 0; level < game.levels.length; level++) { expect(continueDefuse(game, t.ctx()).ok).toBe(true); solveAll(room, t); const s = room.engine as DefuseState; expect(s.strikes, `level ${level}`).toBe(0); expect(s.status, `level ${level}`).toBe('cleared'); if (level < game.levels.length - 1) continueDefuse(game, t.ctx()); } expect(room.keys).toEqual(game.levels.map((l) => l.keyName)); expect(room.result?.success).toBe(true); }); it('Say It Back: a wrong pad strikes, restarts the stage, and moves the mapping', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as DefuseState; s.level = 2; continueDefuse(game, t.ctx()); const sim = s.device!.simon!; const expected = (strikes: number) => solveSimon(s.device!.serial, strikes, [sim.sequence[0]])[0]; defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) }); expect(sim).toMatchObject({ stage: 2, progress: 0 }); defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) }); expect(sim.progress).toBe(1); const wrong = modules.simon!.colors.find((c) => c !== solveSimon(s.device!.serial, 0, [sim.sequence[1]])[0])!; defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: wrong }); expect(s.strikes).toBe(1); expect(sim).toMatchObject({ stage: 2, progress: 0 }); // One strike in, the first flash now maps to a different pad. expect(expected(1)).not.toBe(expected(0)); defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(1) }); expect(s.strikes).toBe(1); expect(sim.progress).toBe(1); }); it('a wrong password or frequency is a strike, and the last level only allows two', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as DefuseState; s.level = 3; continueDefuse(game, t.ctx()); expect(viewOf(room, 'A', t.now).views.A.maxStrikes).toBe(2); defuseAction(game, t.ctx(), 'A', 'passwords', { type: 'submit', word: 'ZZZZZ' }); expect(s).toMatchObject({ strikes: 1, status: 'active' }); expect(defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: 'loud' }).ok).toBe(false); expect(s.strikes).toBe(1); defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: '3.501' }); expect(s).toMatchObject({ strikes: 2, status: 'exploded', failReason: 'strikes' }); }); it('the Technician gets the blinking pattern and the letters, never the word or the manual', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as DefuseState; s.level = 3; continueDefuse(game, t.ctx()); const d = s.device!; const tech = viewOf(room, 'A', t.now).views.A; const json = JSON.stringify(tech); expect(json).not.toContain('Defusal Manual'); expect(tech.device.indicators).toEqual(d.indicators); const byId = Object.fromEntries(tech.device.modules.map((m: { id: string }) => [m.id, m])); expect(Object.keys(byId).sort()).toEqual([...game.levels[3].modules].sort()); expect(byId.morse.signal).toBe(toSignal(d.morse!.word)); expect(byId.morse).not.toHaveProperty('word'); expect(byId.passwords.columns).toEqual(d.passwords!.columns); // Only the flashes of the current stage, not the whole sequence. expect(byId.simon.flashes).toEqual(d.simon!.sequence.slice(0, 1)); expect(json).not.toContain('"sequence"'); const expert = JSON.stringify(viewOf(room, 'B', t.now).views.B); expect(expert).toContain('Overtime Edition'); expect(expert).not.toContain(d.serial); for (const key of ['device', 'indicators', 'signal', 'columns', 'flashes']) expect(expert).not.toContain(`"${key}"`); }); }); describe('sync: Deep Cuts', () => { const game = deepCuts(); it('telepathy ignores case, spacing, punctuation and a trailing S', () => { expect(normalizeWord(' Ice-Cream ')).toBe(normalizeWord('ice cream')); expect(normalizeWord('Apples')).toBe(normalizeWord('apple')); expect(normalizeWord('kiss')).toBe(normalizeWord('KISS')); expect(normalizeWord('pear')).not.toBe(normalizeWord('peach')); }); /** Plays the open round; `matches` of the questions are answered in sync. */ function playRound(room: Room, t: ReturnType, matches: number, seen: string[]) { const s = room.engine as SyncState; const { type } = game.rounds[s.round]; expect(s.questions, `round ${s.round} attempt ${s.attempt}`).toHaveLength(game.rules.questionsPerRound); for (let i = 0; i < game.rules.questionsPerRound; i++) { const q = s.questions[s.q]; seen.push(`${s.round}:${q.id}`); const hit = i < matches; if (type === 'telepathy') { expect(answerSync(game, t.ctx(), 'A', q.id, 2).ok).toBe(false); // no options to pick from expect(answerSync(game, t.ctx(), 'A', q.id, ' ').ok).toBe(false); answerSync(game, t.ctx(), 'A', q.id, 'Mangos'); answerSync(game, t.ctx(), 'B', q.id, hit ? ' mango' : 'pear'); } else if (type === 'rank') { expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2]).ok).toBe(false); // every item, once expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 1, 3]).ok).toBe(false); answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2, 3]); // Top and bottom agree (the middle doesn't have to), or only the top does. answerSync(game, t.ctx(), 'B', q.id, hit ? [0, 2, 1, 3] : [0, 1, 3, 2]); } else { answerSync(game, t.ctx(), 'A', q.id, 0); answerSync(game, t.ctx(), 'B', q.id, hit ? 0 : 1); } expect(s.status).toBe('reveal'); expect(s.marks[i]).toBe(hit); continueSync(game, t.ctx()); } expect(s.status).toBe('summary'); } it('scores each round type, with its own bar for the key', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as SyncState; const seen: string[] = []; continueSync(game, t.ctx()); playRound(room, t, 3, seen); // telepathy needs 3 expect(room.keys).toEqual(['Telepathy Key']); continueSync(game, t.ctx()); continueSync(game, t.ctx()); expect(viewOf(room, 'A').views.A).toMatchObject({ pointsForKey: 4, round: { type: 'predict', subject: 'A' }, iAmSubject: true }); playRound(room, t, 3, seen); // spotlight needs 4: not enough expect(s.earned).toBe(false); continueSync(game, t.ctx()); // replay playRound(room, t, 4, seen); expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A']); continueSync(game, t.ctx()); continueSync(game, t.ctx()); playRound(room, t, 0, seen); continueSync(game, t.ctx()); playRound(room, t, 0, seen); // both tries missed continueSync(game, t.ctx()); expect(s.round).toBe(3); continueSync(game, t.ctx()); // The spotlight alternates A, B, A, B, A through the ranking round. expect(viewOf(room, 'B').views.B).toMatchObject({ round: { type: 'rank', subject: 'A' }, iAmSubject: false }); expect(viewOf(room, 'B').views.B.question.options).toHaveLength(4); playRound(room, t, 3, seen); expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A', 'Ranking Key']); continueSync(game, t.ctx()); expect(room.phase).toBe('finished'); // Nothing was asked twice in the same way (a spotlight question may return for the other partner). const groups = seen.map((x) => x.replace(/^[12]:/, (m) => (m === '1:' ? 'A:' : 'B:'))); expect(new Set(groups).size).toBe(groups.length); }); it("keeps a typed word private until both are in", () => { 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, 'Banana'); expect(JSON.stringify(viewOf(room, 'B').views.B)).not.toContain('Banana'); expect(viewOf(room, 'B').views.B).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null }); answerSync(game, t.ctx(), 'B', q.id, 'bananas'); expect(viewOf(room, 'B').views.B.reveal).toEqual({ A: 'Banana', B: 'bananas', match: true }); }); }); describe('labyrinth: one-way gates (Drowned Catacombs)', () => { const game = catacombs(); it('lets the Explorer through a gate one way only', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as LabyrinthState; const [gate] = game.oneWay; // (5,9) → east Object.assign(s, { x: gate.x, y: gate.y }); expect(viewOf(room, 'B').views.B).toMatchObject({ compass: 'absolute', gates: [] }); expect(moveExplorer(game, t.ctx(), 'B', gate.dir).ok).toBe(true); expect(s.message).toBe(game.explorerView.flavour.gate); const explorer = viewOf(room, 'B').views.B; expect(explorer.open.w).toBe(false); expect(explorer.gates).toEqual(['w']); expect(moveExplorer(game, t.ctx(), 'B', 'w')).toMatchObject({ ok: false, code: 'gate' }); expect(s).toMatchObject({ x: gate.x + 1, y: gate.y }); expect(viewOf(room, 'A').views.A.oneWay).toEqual(game.oneWay); }); it('rejects a maze where a gate can trap the Explorer for good', () => { const sealed = structuredClone(game); // A dead-end pocket behind a gate, with no trap to fall out through. sealed.grid = ['#####', '#S.E#', '#.###', '#.KK#', '#.#K#', '#####']; sealed.lanterns = [ { x: 2, y: 3, name: 'a' }, { x: 3, y: 3, name: 'b' }, { x: 3, y: 4, name: 'c' }, ]; sealed.glyphs = []; sealed.oneWay = [{ x: 1, y: 3, dir: 'e' }]; expect(validateGame(sealed).join('\n')).toMatch(/stuck for good/); sealed.oneWay = []; expect(validateGame(sealed)).toEqual([]); }); }); describe('labyrinth: no compass, mirrored map (Mirror Maze)', () => { const game = mirrorMaze(); it('turns forward/left/right/back into steps from the way the Explorer faces', () => { expect((['f', 'r', 'b', 'l'] as const).map((rel) => toAbsolute('e', rel))).toEqual(['e', 's', 'w', 'n']); const room = playingRoom(game); const t = clock(room); const s = room.engine as LabyrinthState; expect(s).toMatchObject({ x: 1, y: 1, facing: 'e' }); expect(moveExplorer(game, t.ctx(), 'B', 'e')).toMatchObject({ ok: false, code: 'bad_dir' }); // no compass expect(moveExplorer(game, t.ctx(), 'B', 'l')).toMatchObject({ ok: false, code: 'wall' }); moveExplorer(game, t.ctx(), 'B', 'f'); moveExplorer(game, t.ctx(), 'B', 'f'); // (3,1), still facing east expect(s).toMatchObject({ x: 3, y: 1, facing: 'e' }); moveExplorer(game, t.ctx(), 'B', 'r'); // right of east is south expect(s).toMatchObject({ x: 3, y: 2, facing: 's' }); const explorer = viewOf(room, 'B').views.B; expect(explorer.compass).toBe('relative'); expect(explorer.open).toEqual({ f: true, r: false, b: true, l: false }); expect(JSON.stringify(explorer)).not.toMatch(/"(facing|n|e|s|w|x|y|grid)"/); }); it('serves the Navigator the map flipped left to right', () => { const room = playingRoom(game); const navigator = viewOf(room, 'A').views.A; const width = game.grid[0].length; expect(navigator.banner).toMatch(/traced from a mirror/); expect(navigator.grid).toEqual(game.grid.map((row) => [...row].reverse().join(''))); expect(navigator.grid).not.toEqual(game.grid); game.lanterns.forEach((l, i) => { expect(navigator.lanterns[i]).toMatchObject({ x: width - 1 - l.x, y: l.y }); expect(navigator.grid[l.y][navigator.lanterns[i].x]).toBe('K'); }); game.glyphs.forEach((g, i) => expect(navigator.glyphs[i].x).toBe(width - 1 - g.x)); }); }); describe.each([catacombs(), mirrorMaze()].map((g) => [g.id, g] as const))('labyrinth: %s', (_id, game) => { it('can be completed without a single trap', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as LabyrinthState; const state = (p: { x: number; y: number }, held: boolean[]) => `${p.x},${p.y},${held.map(Number).join('')}`; // Breadth-first over (cell, lanterns held): gates make the order matter. type Node = { x: number; y: number; held: boolean[]; path: Dir[] }; const queue: Node[] = [{ x: s.x, y: s.y, held: game.lanterns.map(() => false), path: [] }]; const seen = new Set([state(queue[0], queue[0].held)]); let route: Dir[] | null = null; while (queue.length && !route) { const node = queue.shift()!; for (const dir of Object.keys(DELTAS) as Dir[]) { const next = { x: node.x + DELTAS[dir].x, y: node.y + DELTAS[dir].y }; const c = cellAt(game.grid, next.x, next.y); if (c === '#' || c === 'T' || gateBlocks(game.oneWay, node.x, node.y, dir)) continue; const held = node.held.map((h, i) => h || (game.lanterns[i].x === next.x && game.lanterns[i].y === next.y)); if (seen.has(state(next, held))) continue; seen.add(state(next, held)); const path = [...node.path, dir]; if (c === 'E' && held.every(Boolean)) route = path; queue.push({ ...next, held, path }); } } expect(route).not.toBeNull(); const relative = game.explorer.compass === 'relative'; for (const dir of route!) { const move: MoveDir = relative ? (['f', 'r', 'b', 'l'] as const).find((rel) => toAbsolute(s.facing!, rel) === dir)! : dir; expect(moveExplorer(game, t.ctx(), 'B', move).ok).toBe(true); } expect(room.phase).toBe('finished'); expect(s.falls).toBe(0); expect(room.keys).toHaveLength(game.lanternsRequired); expect(findCells(game.grid, 'K')).toHaveLength(game.lanternsRequired); }); }); describe('story: per-game hint penalties and time limits', () => { it('charges the hint penalty the game sets', () => { const game = getGame('kestrel-heist', 'story'); const room = playingRoom(game); const t = clock(room); const ctx = t.ctx(); requestHint(game, ctx, game.chapters[0].lock.id); expect(room.penaltySeconds).toBe(240); expect(ctx.toasts[0].message).toBe('Hint 1 revealed (+4:00)'); expect(viewOf(room, 'A').views.A.chapters[0].lock.hintPenaltySeconds).toBe(240); const classic = getGame('vineyard-hollow', 'story'); const other = playingRoom(classic); requestHint(classic, clock(other).ctx(), classic.chapters[0].lock.id); expect(other.penaltySeconds).toBe(120); }); it('ends the game when the clock (elapsed + penalties) passes the limit', () => { const game = getGame('deep-lab-nereid', 'story'); const limit = game.rules.timeLimitMinutes! * 60; const room = playingRoom(game); const t = clock(room); expect(viewOf(room, 'A').timer).toMatchObject({ limitSeconds: limit, limitLabel: 'Water level' }); expect(tickStory(game, t.ctx((limit - 100) * 1000))).toBe(false); requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over expect(tickStory(game, t.ctx())).toBe(true); expect(room.phase).toBe('finished'); expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null, outOfTime: true }); expect(tickStory(game, t.ctx())).toBe(false); }); it('shows the reveal when an accusation game times out, and leaves untimed games alone', () => { const game = getGame('kalahari-expedition', 'story'); if (game.finale.type !== 'accusation') throw new Error('expected accusation'); const room = playingRoom(game); const t = clock(room); expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true); expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: true }); const untimed = getGame('vineyard-hollow', 'story'); const calm = playingRoom(untimed); expect(tickStory(untimed, clock(calm).ctx(24 * 3600_000))).toBe(false); expect(viewOf(calm, 'A').timer).toMatchObject({ limitSeconds: null, limitLabel: null }); }); it('plays a timed escape room through to the end inside the limit', () => { const game = getGame('deep-lab-nereid', 'story'); if (game.finale.type !== 'escape') throw new Error('expected escape'); const room = playingRoom(game); const t = clock(room); for (const ch of game.chapters) { expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toMatchObject({ correct: true }); continueChapter(game, t.ctx()); } expect((room.engine as StoryState).chapter).toBe(game.chapters.length); expect(tickStory(game, t.ctx())).toBe(false); submitLock(game, t.ctx(), game.finale.lock.id, 'surface'); expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null, outOfTime: false }); }); });