import { describe, expect, it } from 'vitest'; import type { Dir } from '@two-keys/shared'; import { continueDefuse, defuseAction, generateDevice, isSolvable, remainingAt, solveButton, solveKeypad, solveWires, tickDefuse, timerText, type DefuseState, } from '../src/engines/defuse'; import { DELTAS, cellAt, findCells, moveExplorer, type LabyrinthState } from '../src/engines/labyrinth'; import { answerSync, continueSync, type SyncState } from '../src/engines/sync'; import { clock, defuse, labyrinth, playingRoom, sync } from './helpers'; describe('defuse solver', () => { const game = defuse(); it.each(game.fixtures.wires)('wires fixture %#', (f) => { expect(solveWires(f.serial, f.wires)).toEqual(f.expect); }); it.each(game.fixtures.button)('button fixture %#', (f) => { expect(solveButton(f)).toEqual(f.expect); }); it.each(game.fixtures.keypad)('keypad fixture %#', (f) => { expect(solveKeypad(f.symbols, game.modules.keypad.columns)).toEqual(f.expect); }); it('never generates a device the solver cannot solve', () => { for (let seed = 1; seed <= 2000; seed++) { const device = generateDevice(game, 2, seed); expect(isSolvable(game, device), `seed ${seed}`).toBe(true); expect(device.serial).toMatch(/^[A-Z0-9]{5}\d$/); expect(device.wires!.colors.length).toBeGreaterThanOrEqual(3); expect(device.wires!.colors.length).toBeLessThanOrEqual(5); expect(device.keypad!.symbols).toHaveLength(4); } }); it('is deterministic per seed and only builds the modules of the level', () => { expect(generateDevice(game, 2, 42)).toEqual(generateDevice(game, 2, 42)); const lvl1 = generateDevice(game, 0, 42); expect(lvl1.wires).toBeDefined(); expect(lvl1.button).toBeUndefined(); expect(lvl1.keypad).toBeUndefined(); }); it('formats the timer like the device shows it', () => { expect(timerText(180)).toBe('3:00'); expect(timerText(64.2)).toBe('1:05'); expect(timerText(-3)).toBe('0:00'); }); }); describe('defuse level flow', () => { const game = defuse(); /** Solves whatever is on the device, using only the solver. */ function solveAll(room: ReturnType, t: ReturnType) { const s = room.engine as DefuseState; const d = s.device!; if (d.wires) defuseAction(game, t.ctx(100), 'A', 'wires', solveWires(d.serial, d.wires.colors)); if (d.keypad) { for (const symbol of solveKeypad(d.keypad.symbols, game.modules.keypad.columns)!) { defuseAction(game, t.ctx(100), 'A', 'keypad', { type: 'press', symbol }); } } if (d.button) { const solution = solveButton({ ...d.button, batteries: d.batteries }); if (solution.type === 'tap') { defuseAction(game, t.ctx(100), 'A', 'button', { type: 'tap' }); } else { defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' }); // Wait until the server's own timer shows the digit, then let go. for (let i = 0; i < 400; i++) { if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break; t.ctx(500); } defuseAction(game, t.ctx(0), 'A', 'button', { type: 'release' }); } } } it('clears all three 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); // start solveAll(room, t); const s = room.engine as DefuseState; expect(s.strikes, `level ${level}`).toBe(0); expect(s.status).toBe('cleared'); if (level < game.levels.length - 1) continueDefuse(game, t.ctx()); } expect(room.keys).toEqual(game.levels.map((l) => l.keyName)); expect(room.phase).toBe('finished'); expect(room.result?.success).toBe(true); }); it('strikes speed the clock up by 25% and three strikes blow the level without losing keys', () => { const room = playingRoom(game); room.keys.push('Earlier Key'); const t = clock(room); continueDefuse(game, t.ctx()); const s = room.engine as DefuseState; const right = solveWires(s.device!.serial, s.device!.wires!.colors).index; const wrong = right === 1 ? 2 : 1; defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong }); expect(s.strikes).toBe(1); expect(s.clock!.speed).toBeCloseTo(1.25); const before = remainingAt(s, t.now); expect(before - remainingAt(s, t.now + 4000)).toBeCloseTo(5); expect(defuseAction(game, t.ctx(), 'B', 'wires', { type: 'cut', index: right }).ok).toBe(false); // Expert can't cut defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong }); defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong }); expect(s.status).toBe('exploded'); expect(s.failReason).toBe('strikes'); expect(room.keys).toEqual(['Earlier Key']); const oldSeed = s.seed; continueDefuse(game, t.ctx()); // retry → briefing with a new seed expect(s).toMatchObject({ status: 'ready', level: 0, strikes: 0, device: null }); expect(s.seed).not.toBe(oldSeed); }); it('explodes when the clock runs out', () => { const room = playingRoom(game); const t = clock(room); continueDefuse(game, t.ctx()); expect(tickDefuse(t.ctx(10_000))).toBe(false); expect(tickDefuse(t.ctx(game.levels[0].timeSeconds * 1000))).toBe(true); expect(room.engine).toMatchObject({ status: 'exploded', failReason: 'time' }); }); }); describe('sync', () => { const game = sync(); it('awards a key for 3+ matches, offers one replay otherwise, and never repeats a question', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as SyncState; const seen: string[] = []; const playRound = (matches: number) => { for (let i = 0; i < game.rules.questionsPerRound; i++) { const q = s.questions[s.q]; seen.push(q.id); expect(answerSync(game, t.ctx(), 'A', q.id, 0).ok).toBe(true); expect(answerSync(game, t.ctx(), 'A', q.id, 1).ok).toBe(false); // locked in expect(continueSync(game, t.ctx()).ok).toBe(false); // partner still thinking expect(answerSync(game, t.ctx(), 'B', q.id, i < matches ? 0 : 1).ok).toBe(true); expect(s.status).toBe('reveal'); continueSync(game, t.ctx()); } expect(s.status).toBe('summary'); }; continueSync(game, t.ctx()); // intro → first question playRound(2); // fail expect(s.earned).toBe(false); continueSync(game, t.ctx()); // replay with fresh questions expect(s).toMatchObject({ round: 0, attempt: 1, status: 'question' }); playRound(1); // fail again: no more replays continueSync(game, t.ctx()); expect(s).toMatchObject({ round: 1, attempt: 0, status: 'intro' }); continueSync(game, t.ctx()); playRound(3); expect(room.keys).toEqual([game.rounds[1].keyName]); continueSync(game, t.ctx()); // earned → straight to round 3 expect(s.round).toBe(2); continueSync(game, t.ctx()); playRound(5); continueSync(game, t.ctx()); expect(room.phase).toBe('finished'); expect(room.keys).toHaveLength(2); expect(new Set(seen).size).toBe(seen.length); }); }); describe('labyrinth', () => { const game = labyrinth(); const walk = (room: ReturnType, t: ReturnType, path: string) => [...path].map((dir) => moveExplorer(game, t.ctx(), 'B', dir as Dir)); it('validates every move on the server', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as LabyrinthState; expect(s).toMatchObject({ x: 1, y: 1 }); expect(moveExplorer(game, t.ctx(), 'B', 'n')).toMatchObject({ ok: false, code: 'wall' }); expect(moveExplorer(game, t.ctx(), 'A', 'e')).toMatchObject({ ok: false, code: 'wrong_role' }); expect(s).toMatchObject({ x: 1, y: 1, moves: 0 }); expect(moveExplorer(game, t.ctx(), 'B', 'e').ok).toBe(true); expect(s).toMatchObject({ x: 2, y: 1, moves: 1 }); }); it('sends the Explorer back to the start on a trap, with a penalty, keys kept', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as LabyrinthState; walk(room, t, 'ssss'); // (1,5) walk(room, t, 'ee'); // (3,5) const ctx = t.ctx(); moveExplorer(game, ctx, 'B', 'e'); // (4,5) is a trap expect(s).toMatchObject({ x: 1, y: 1, falls: 1 }); expect(room.penaltySeconds).toBe(60); expect(ctx.toasts).toEqual([{ to: 'A', message: expect.stringMatching(/fell into a trap/) }]); }); it('can be completed: three lanterns, then the exit', () => { const room = playingRoom(game); const t = clock(room); const s = room.engine as LabyrinthState; // Breadth-first route planner over safe cells, standing in for a good Navigator. const goTo = (target: { x: number; y: number }) => { const start = `${s.x},${s.y}`; const prev = new Map(); const queue = [{ x: s.x, y: s.y }]; const seen = new Set([start]); while (queue.length) { const p = queue.shift()!; for (const dir of Object.keys(DELTAS) as Dir[]) { const n = { x: p.x + DELTAS[dir].x, y: p.y + DELTAS[dir].y }; const c = cellAt(game.grid, n.x, n.y); if (c === '#' || c === 'T' || seen.has(`${n.x},${n.y}`)) continue; seen.add(`${n.x},${n.y}`); prev.set(`${n.x},${n.y}`, { from: `${p.x},${p.y}`, dir }); queue.push(n); } } const path: Dir[] = []; for (let at = `${target.x},${target.y}`; at !== start; at = prev.get(at)!.from) path.unshift(prev.get(at)!.dir); for (const dir of path) expect(moveExplorer(game, t.ctx(), 'B', dir).ok).toBe(true); }; const [exit] = findCells(game.grid, 'E'); goTo(exit); expect(room.phase).toBe('playing'); // locked without lanterns expect(s.message).toBe(game.explorerView.flavour.exitLocked); for (const lantern of game.lanterns) goTo(lantern); expect(room.keys).toEqual(expect.arrayContaining(game.lanterns.map((l) => l.name))); goTo(exit); expect(room.phase).toBe('finished'); expect(s.falls).toBe(0); }); });