Files
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
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>
2026-10-12 22:36:12 +02:00

258 lines
10 KiB
TypeScript

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<typeof playingRoom>, t: ReturnType<typeof clock>) {
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<typeof playingRoom>, t: ReturnType<typeof clock>, 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<string, { from: string; dir: Dir }>();
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);
});
});