Files
joshuaandClaude Opus 5.5 6669be11e5 V1.2.0
Bring games 9-18 in line with GAMES-NEW.md:
- Library sorts easiest first, filters by category and difficulty; difficulty 5
  shows a Nightmare chip, and a no-hint finish earns Nightmare Survivors.
- Time limits end on "Out of time" (no rank); Deep Lab Nereid's countdown is a
  rising water gauge behind the header.
- Evidence Locker search, chapter filter and starred clues (partner sees a count).
- One-shot accusations ask "One shot. Are you both sure?" first.
- Symbol font fallback, tight monospace blocks for grids/keypads/SQL.
- Rank rounds reorder by drag or up/down buttons; reveal highlights #1 and #4.
- Telepathy capped at 30 characters; maze:move accepts forward/left/right/back;
  gate buttons read "Gate (won't open from this side)".
- Overtime solver tables checked against Appendix A; keypad generator re-rolls.
- More content checks (rank items, password fixtures, glyphs/gates on traps) and
  sanitize tests for all ten new games.
- GAMES.md carries the full spoiler guide for 9-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:44:28 +02:00

472 lines
21 KiB
TypeScript

// 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<typeof clock>) {
const s = room.engine as DefuseState;
const d = s.device!;
const act = (id: string, action: Parameters<typeof defuseAction>[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<typeof clock>, 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 });
});
});