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>
258 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|