Files
two-keys/server/test/pack3.test.ts
T
joshuaandClaude Opus 5.5 52745935ee V1.3.0
Game pack 3: 22 new games (19-40), 40 in total.

- New engines: dial (Wavelength / Mind Meld), twin (Twin Rooms /
  Spot the Difference, incl. mirror mode) and blueprint (Describe &
  Draw, incl. peeks, overlap scoring and a rotated Builder canvas)
- Labyrinth: items and doors (Hedge Maze)
- Defuse: manualFrom, roles swapping every level behind a Ready card,
  and duplicate wire modules (Tick Tock: Switch)
- Generic time limits for the new engines; sync/defuse successEnding
- Library: engine-label chips and filters; Noto Color Emoji font
- Validation: Appendix A checks ported, with a per-game summary
- Quote comma/colon-split labels in five new YAMLs (wording unchanged)
- Tests for all new engines and sanitized payloads of all 22 games
- Docs: CLAUDE.md, GAMES.md (pack 3 spoiler guide), How to Play

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:21:24 +02:00

690 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GAMES-PACK-3.md: the dial, twin and blueprint engines, labyrinth items and doors, defuse
// `manualFrom` and role swaps, and sanitized payloads for all 22 new games.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { io as connect } from 'socket.io-client';
import type {
BlueprintView,
DialView,
JoinAck,
NavigatorView,
ExplorerView,
Role,
RoomState,
TechnicianView,
TwinView,
} from '@two-keys/shared';
import { createApp } from '../src/app';
import { loadGameFile } from '../src/content/loader';
import type { Game } from '../src/content/schemas';
import { validateGame } from '../src/content/validate';
import {
architectOf,
continueBlueprint,
displayedToStored,
overlapScore,
paint,
peek,
submitCanvas,
type BlueprintState,
} from '../src/engines/blueprint';
import {
continueDefuse,
defuseAction,
levelReady,
wireModules,
wireSolution,
edgework,
type DefuseState,
} from '../src/engines/defuse';
import {
clueProblem,
continueDial,
giveClue,
giverOf,
initDial,
lockGuess,
moveNeedle,
pointsFor,
type DialState,
} from '../src/engines/dial';
import { tickGame } from '../src/engines/index';
import { moveExplorer, type LabyrinthState } from '../src/engines/labyrinth';
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { continueTwin, gridA, gridB, tapCell, widthOf, type TwinState } from '../src/engines/twin';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, games, getGame, playingRoom } from './helpers';
const PACK_3 = [
'the-treehouse',
'twin-sunday-morning',
'blueprint-first-sketch',
'wavelength-tune-in',
'death-at-the-braai',
'in-sync-road-trip',
'wavelength-date-night',
'twin-cafes',
'curtain-call',
'magicians-dressing-room',
'blueprint-stained-glass',
'hedge-maze-midnight',
'snowbound-edelweiss',
'pharaohs-tomb',
'twin-looking-glass',
'tick-tock-switch',
'hawthorn-park-cipher',
'inventors-paradox',
'blueprint-blind-mirror',
'wavelength-static',
'til-death-do-us-part',
'the-lighthouse',
];
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'changes', 'expect', 'seed', 'rows', 'taps'];
const stateOf = (room: Room, role: Role, now = 2_000_000) => {
const player = room.players.find((p) => p.seat === role)!;
return buildState(room, player, games.get(room.gameId)!, () => true, now);
};
const viewOf = <V>(room: Room, role: Role, now?: number) => stateOf(room, role, now).views[role] as V;
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (!Array.isArray(value)) out.add(k);
keysOf(v, out);
}
}
return out;
}
/** Moves a game a little way in, so there is real state to leak. */
function started(game: Game) {
const room = playingRoom(game);
const t = clock(room);
if (game.engine === 'defuse') {
levelReady(game, t.ctx(), 'A');
levelReady(game, t.ctx(), 'B');
continueDefuse(game, t.ctx());
} else if (game.engine === 'sync') {
continueSync(game, t.ctx());
} else if (game.engine === 'twin') {
continueTwin(game, t.ctx());
tapCell(game, t.ctx(), 'A', 0, 0);
} else if (game.engine === 'blueprint') {
continueBlueprint(game, t.ctx());
paint(game, t.ctx(), 'B', [{ x: 0, y: 0, color: Object.keys(game.patterns[0].palette)[0] }]);
} else if (game.engine === 'dial') {
giveClue(game, t.ctx(), 'A', 'zebra');
moveNeedle(game, t.ctx(), 'B', 42);
}
return { room, t };
}
describe('pack 3 content', () => {
it('has all 22 games', () => {
for (const id of PACK_3) expect(games.get(id), id).toBeDefined();
});
});
describe.each(PACK_3.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
it('never carry answers, fixtures, changes, solver state or hidden pictures for either role', () => {
const { room, t } = started(game);
for (const role of ['A', 'B'] as const) {
const keys = keysOf(stateOf(room, role, t.now));
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
}
});
});
// ---------- dial ----------
describe('dial: Wavelength', () => {
const tuneIn = getGame('wavelength-tune-in', 'dial');
const staticGame = getGame('wavelength-static', 'dial');
it('alternates the clue-giver: A on odd rounds, B on even', () => {
expect([0, 1, 2, 3].map(giverOf)).toEqual(['A', 'B', 'A', 'B']);
});
it('keeps the target from the guesser until they lock in, and the needle from the clue-giver', () => {
const room = playingRoom(tuneIn);
const t = clock(room);
const s = room.engine as DialState;
expect(viewOf<DialView>(room, 'A').target).toBe(s.target);
expect(viewOf<DialView>(room, 'B')).toMatchObject({ target: null, clue: null });
expect(giveClue(tuneIn, t.ctx(), 'B', 'tea').ok).toBe(false); // not B's turn
expect(giveClue(tuneIn, t.ctx(), 'A', 'a warm bath').ok).toBe(true);
expect(moveNeedle(tuneIn, t.ctx(), 'B', 61).ok).toBe(true);
expect(viewOf<DialView>(room, 'B')).toMatchObject({ target: null, clue: 'a warm bath', needle: 61 });
const giver = viewOf<DialView>(room, 'A');
expect(giver.needle).toBeNull();
expect(JSON.stringify(giver)).not.toContain('61');
expect(lockGuess(tuneIn, t.ctx(), 'A', 50).ok).toBe(false); // the giver can't guess
expect(lockGuess(tuneIn, t.ctx(), 'B', s.target + 7).ok).toBe(true);
expect(viewOf<DialView>(room, 'B')).toMatchObject({ status: 'reveal', target: s.target, guess: s.target + 7, points: 3 });
});
it('scores by distance: the first band that fits, 0 outside all', () => {
expect([0, 5, 6, 10, 15, 20, 21].map((d) => pointsFor(tuneIn, d))).toEqual([4, 4, 3, 3, 2, 1, 0]);
expect([2, 3, 5, 8, 9].map((d) => pointsFor(staticGame, d))).toEqual([4, 3, 3, 2, 0]);
});
it('checks clues: length, words, label words (with a trailing S) and repeats', () => {
const s = initDial(staticGame, () => 0);
const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!;
const labelWord = spectrum.left.split(/\s+/)[0];
expect(clueProblem(staticGame, s, '')).toMatch(/Type a clue/);
expect(clueProblem(staticGame, s, 'two words')).toMatch(/One word only/);
expect(clueProblem(staticGame, s, 'x'.repeat(31))).toMatch(/30 characters/);
expect(clueProblem(staticGame, s, `${labelWord.toLowerCase()}s`)).toMatch(/is on the dial/);
expect(clueProblem(staticGame, s, 'Zebra!')).toBeNull();
s.usedWords.push('ZEBRA');
expect(clueProblem(staticGame, s, 'zebras')).toMatch(/already used/);
expect(clueProblem(tuneIn, initDial(tuneIn), 'a b c d e f')).toMatch(/Up to 5 words/);
});
it("hides the right label (and the numbers) from Static's guesser", () => {
const room = playingRoom(staticGame);
const s = room.engine as DialState;
const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!;
const guesser = viewOf<DialView>(room, 'B');
expect(guesser).toMatchObject({ left: spectrum.left, right: null, showNumbers: false });
expect(JSON.stringify(guesser)).not.toContain(spectrum.right);
expect(viewOf<DialView>(room, 'A')).toMatchObject({ right: spectrum.right, showNumbers: true });
});
it('never repeats a spectrum, awards keys as the total crosses each threshold, and ends after the last round', () => {
const room = playingRoom(tuneIn);
const t = clock(room);
const s = room.engine as DialState;
for (let round = 0; round < tuneIn.dial.rounds; round++) {
const giver = giverOf(round);
const guesser: Role = giver === 'A' ? 'B' : 'A';
expect(giveClue(tuneIn, t.ctx(), giver, 'zebra').ok).toBe(true);
expect(lockGuess(tuneIn, t.ctx(), guesser, s.target).ok).toBe(true); // bullseye, 4 points
expect(room.keys.length).toBe(tuneIn.dial.keys.filter((k) => s.total >= k.at).length);
expect(continueDial(tuneIn, t.ctx()).ok).toBe(true);
}
expect(new Set(s.used).size).toBe(tuneIn.dial.rounds);
expect(s.total).toBe(24);
expect(room.keys).toEqual(tuneIn.dial.keys.map((k) => k.name));
expect(room.result).toMatchObject({ success: true, ending: tuneIn.successEnding });
});
it('fails without every key', () => {
const room = playingRoom(tuneIn);
const t = clock(room);
const s = room.engine as DialState;
for (let round = 0; round < tuneIn.dial.rounds; round++) {
giveClue(tuneIn, t.ctx(), giverOf(round), 'zebra');
lockGuess(tuneIn, t.ctx(), giverOf(round) === 'A' ? 'B' : 'A', s.target > 50 ? 0 : 100);
continueDial(tuneIn, t.ctx());
}
expect(room.result?.success).toBe(false);
});
it('rejects a too-small spectra bank and keys nobody can reach', () => {
const game = structuredClone(tuneIn);
game.spectra = game.spectra.slice(0, 10);
game.dial.keys[1].at = 99;
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/spectra bank has 10/);
expect(errors).toMatch(/is the most possible/);
});
});
// ---------- twin ----------
describe('twin: Twin Rooms', () => {
const sunday = getGame('twin-sunday-morning', 'twin');
const glass = getGame('twin-looking-glass', 'twin');
function playing(game: typeof sunday) {
const room = playingRoom(game);
const t = clock(room);
expect(continueTwin(game, t.ctx()).ok).toBe(true);
return { room, t, s: room.engine as TwinState };
}
it("gives each player only their own room, never the changes", () => {
const { room } = playing(sunday);
const scene = sunday.scenes[0];
expect(viewOf<TwinView>(room, 'A').grid).toEqual(gridA(scene));
expect(viewOf<TwinView>(room, 'B').grid).toEqual(gridB(scene));
expect(gridA(scene)).not.toEqual(gridB(scene));
});
it('splits rows on spaces, so multi-code-point emoji stay whole', () => {
for (const game of [sunday, glass, getGame('twin-cafes', 'twin')]) {
for (const scene of game.scenes) for (const row of gridA(scene)) expect(row).toHaveLength(widthOf(scene));
}
});
it('claims a difference when both tap the same square in time', () => {
const { room, t, s } = playing(sunday);
const [c] = sunday.scenes[0].changes;
expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(true);
// The partner hears that a square was tapped, never which.
const b = viewOf<TwinView>(room, 'B', t.now);
expect(b.partnerTap).not.toBeNull();
expect(b.myTap).toBeNull();
expect(JSON.stringify(b.partnerTap)).not.toMatch(/"x"|"y"/);
tapCell(sunday, t.ctx(), 'B', c.x, c.y);
expect(s.found).toEqual([0]);
expect(viewOf<TwinView>(room, 'A', t.now).found).toEqual([{ x: c.x, y: c.y }]);
expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(false); // already found
});
it('a wrong claim costs time; different squares just replace the earlier tap; late taps expire', () => {
const { room, t, s } = playing(sunday);
const scene = sunday.scenes[0];
const isChange = (x: number, y: number) => scene.changes.some((c) => c.x === x && c.y === y);
const plain = gridA(scene).flatMap((row, y) => row.map((_, x) => ({ x, y }))).find((p) => !isChange(p.x, p.y))!;
tapCell(sunday, t.ctx(), 'A', plain.x, plain.y);
tapCell(sunday, t.ctx(), 'B', plain.x, plain.y);
expect(room.penaltySeconds).toBe(sunday.twin.wrongClaimPenaltySeconds);
expect(s.scenePenalty).toBe(sunday.twin.wrongClaimPenaltySeconds);
expect(s.found).toEqual([]);
const [c] = scene.changes;
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
tapCell(sunday, t.ctx(), 'B', plain.x, plain.y); // a different square: A's tap expires
expect(s.taps.A).toBeUndefined();
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
tapCell(sunday, t.ctx((sunday.twin.claimWindowSeconds + 1) * 1000), 'B', c.x, c.y); // too late
expect(s.found).toEqual([]);
});
it('mirror mode: tapping the mirrored square of a difference claims it', () => {
const { room, t, s } = playing(glass);
const scene = glass.scenes[0];
const [c] = scene.changes;
const mirroredX = widthOf(scene) - 1 - c.x;
expect(viewOf<TwinView>(room, 'B').grid).toEqual(gridB(scene).map((row) => [...row].reverse()));
tapCell(glass, t.ctx(), 'A', c.x, c.y);
tapCell(glass, t.ctx(), 'B', mirroredX, c.y);
expect(s.found).toEqual([0]);
expect(viewOf<TwinView>(room, 'B', t.now).found).toEqual([{ x: mirroredX, y: c.y }]);
});
it('earns the key when every difference is found; a timed-out room retries once, then moves on', () => {
const { room, t, s } = playing(sunday);
for (const c of sunday.scenes[0].changes) {
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
tapCell(sunday, t.ctx(), 'B', c.x, c.y);
}
expect(s.status).toBe('won');
expect(room.keys).toEqual([sunday.scenes[0].keyName]);
continueTwin(sunday, t.ctx()); // next room
continueTwin(sunday, t.ctx()); // open it
expect(tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000))).toBe(true);
expect(viewOf<TwinView>(room, 'A', t.now)).toMatchObject({ status: 'failed', canRetry: true });
continueTwin(sunday, t.ctx());
expect(s).toMatchObject({ status: 'playing', attempt: 1, found: [] });
tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000));
expect(viewOf<TwinView>(room, 'A', t.now).canRetry).toBe(false);
continueTwin(sunday, t.ctx());
expect(s).toMatchObject({ scene: 2, status: 'ready' });
});
it("Looking Glass's 25-minute limit ends the game", () => {
const { room, t } = playing(glass);
expect(tickGame(glass, t.ctx(25 * 60_000))).toBe(true);
expect(stateOf(room, 'A', t.now).result).toMatchObject({ outOfTime: true, rank: 'Out of time' });
});
it('rejects a change that changes nothing, and a ragged row', () => {
const game = structuredClone(sunday);
const c = game.scenes[0].changes[0];
c.to = gridA(game.scenes[0])[c.y][c.x];
game.scenes[1].grid[0] += ' 🍄';
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/doesn't change anything/);
expect(errors).toMatch(/rows don't all have/);
});
});
// ---------- blueprint ----------
describe('blueprint: Describe & Draw', () => {
const sketch = getGame('blueprint-first-sketch', 'blueprint');
const mirror = getGame('blueprint-blind-mirror', 'blueprint');
function building(game: typeof sketch) {
const room = playingRoom(game);
const t = clock(room);
continueBlueprint(game, t.ctx());
return { room, t, s: room.engine as BlueprintState };
}
/** Paints the whole picture as the Builder sees it: displayed cell (c, r) on a rotated canvas. */
function paintAsBuilder(game: typeof sketch, room: Room, t: ReturnType<typeof clock>, role: Role, rows: string[]) {
const n = rows.length;
const cells = [];
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
const { x, y } = displayedToStored(n, game.blueprint.rotateBuilder, c, r);
const v = rows[y][x];
cells.push({ x, y, color: v === '.' ? null : v });
}
}
expect(paint(game, t.ctx(), role, cells).ok).toBe(true);
}
it('scores overlap: matched painted cells over cells painted in either grid', () => {
expect(overlapScore(['RR', '..'], ['RR', '..'])).toBe(1);
expect(overlapScore(['RR', '..'], ['..', '..'])).toBe(0); // a blank canvas never scores
expect(overlapScore(['RR', '..'], ['RB', '..'])).toBe(0.5);
expect(overlapScore(['RR', '..'], ['RR', 'R.'])).toBeCloseTo(2 / 3);
expect(overlapScore(['..', '..'], ['..', '..'])).toBe(0);
});
it("keeps the picture from the Builder, and the canvas from the Architect except in a peek", () => {
const { room, t } = building(sketch);
const builder = viewOf<BlueprintView>(room, 'B', t.now);
expect(builder).toMatchObject({ seat: 'builder', target: null, title: null });
expect(JSON.stringify(builder)).not.toContain(sketch.patterns[0].title);
expect(viewOf<BlueprintView>(room, 'A', t.now)).toMatchObject({ seat: 'architect', canvas: null });
expect(peek(sketch, t.ctx(), 'B').ok).toBe(false); // only the Architect peeks
expect(peek(sketch, t.ctx(), 'A').ok).toBe(true);
expect(viewOf<BlueprintView>(room, 'A', t.now).canvas).not.toBeNull();
expect(viewOf<BlueprintView>(room, 'A', t.now + 3500).canvas).toBeNull();
peek(sketch, t.ctx(), 'A');
expect(peek(sketch, t.ctx(), 'A').ok).toBe(false); // two peeks per picture
});
it('rotation: a Builder who paints the rotated picture correctly scores 100%', () => {
expect(mirror.blueprint.rotateBuilder).toBe(90);
// Displayed (c, r) on an n×n grid is stored (x = r, y = n − 1 − c).
expect(displayedToStored(10, 90, 0, 0)).toEqual({ x: 0, y: 9 });
expect(displayedToStored(10, 90, 9, 0)).toEqual({ x: 0, y: 0 });
const { room, t, s } = building(mirror);
paintAsBuilder(mirror, room, t, 'B', mirror.patterns[0].rows);
expect(submitCanvas(mirror, t.ctx(), 'B').ok).toBe(true);
expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true });
expect(room.keys).toEqual([mirror.patterns[0].keyName]);
});
it('swaps the Architect every picture when the game says so', () => {
expect([0, 1, 2, 3].map((i) => architectOf(mirror, i))).toEqual(['A', 'B', 'A', 'B']);
expect([0, 1, 2].map((i) => architectOf(sketch, i))).toEqual(['A', 'A', 'A']);
const { room, t } = building(mirror);
submitCanvas(mirror, t.ctx(), 'B');
continueBlueprint(mirror, t.ctx());
continueBlueprint(mirror, t.ctx());
expect(viewOf<BlueprintView>(room, 'A', t.now)).toMatchObject({ seat: 'builder', target: null });
expect(viewOf<BlueprintView>(room, 'B', t.now).seat).toBe('architect');
expect(paint(mirror, t.ctx(), 'B', [{ x: 0, y: 0, color: null }]).ok).toBe(false);
});
it('a second submit is allowed and the best score counts; the reveal shows the picture to the Builder', () => {
const { room, t, s } = building(sketch);
const rows = sketch.patterns[0].rows;
const colour = Object.keys(sketch.patterns[0].palette)[0];
paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: colour }]);
submitCanvas(sketch, t.ctx(), 'B');
expect(s).toMatchObject({ status: 'building', submits: 1 });
expect(viewOf<BlueprintView>(room, 'B', t.now).target).toBeNull();
paintAsBuilder(sketch, room, t, 'B', rows);
submitCanvas(sketch, t.ctx(), 'B');
expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true });
expect(viewOf<BlueprintView>(room, 'B', t.now)).toMatchObject({ target: rows, title: sketch.patterns[0].title });
});
it('when time runs out, the canvas is scored as it is', () => {
const { t, s } = building(sketch);
expect(tickGame(sketch, t.ctx(sketch.patterns[0].timeSeconds * 1000))).toBe(true);
expect(s).toMatchObject({ status: 'scored', passed: false, bestScore: 0 });
});
it('rejects colours that are not on the palette, and a picture of the wrong size', () => {
const { t } = building(sketch);
expect(paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: 'Q' }]).ok).toBe(false);
expect(paint(sketch, t.ctx(), 'B', [{ x: 6, y: 0, color: null }]).ok).toBe(false);
const game = structuredClone(sketch);
game.patterns[0].rows[0] = 'RR';
game.patterns[1].rows[1] = '.ZZZZ.';
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/rows must be 6×6/);
expect(errors).toMatch(/colour "Z" is not in the palette/);
});
});
// ---------- labyrinth items and doors ----------
describe('labyrinth: items and doors (Hedge Maze)', () => {
const hedge = getGame('hedge-maze-midnight', 'labyrinth');
const [door] = hedge.doors;
const [item] = hedge.items;
/** Walks the Explorer along a path of n/e/s/w steps. */
const walk = (room: Room, t: ReturnType<typeof clock>, steps: string) => {
for (const dir of steps) moveExplorer(hedge, t.ctx(), 'B', dir as 'n');
};
it('a door stays shut until the Explorer carries its item, which survives a trap', () => {
const room = playingRoom(hedge);
const t = clock(room);
const s = room.engine as LabyrinthState;
// Start (1,1) → down to (1,7) → east to the door's neighbour (4,7).
s.x = door.x - 1;
s.y = door.y;
moveExplorer(hedge, t.ctx(), 'B', 'e');
expect({ x: s.x, y: s.y }).toEqual({ x: door.x - 1, y: door.y });
expect(viewOf<ExplorerView>(room, 'B').message).toBe(door.lockedText);
// Pick the key up from the cell next to it.
s.x = item.x - 1;
s.y = item.y;
moveExplorer(hedge, t.ctx(), 'B', 'e');
expect(viewOf<ExplorerView>(room, 'B')).toMatchObject({ inventory: [item.name], message: item.explorerText });
// The Navigator gets no toast about it.
expect(t.ctx().toasts).toEqual([]);
expect(viewOf<NavigatorView>(room, 'A').items).toEqual([{ x: item.x, y: item.y, name: item.name, collected: true }]);
// A trap sends them back with the key.
const [trap] = hedge.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
s.x = trap.x - 1;
s.y = trap.y;
if (hedge.grid[trap.y][trap.x - 1] === '#') s.x = trap.x + 1;
moveExplorer(hedge, t.ctx(), 'B', s.x < trap.x ? 'e' : 'w');
expect(s.falls).toBe(1);
expect(s.inventory).toEqual([item.id]);
s.x = door.x - 1;
s.y = door.y;
moveExplorer(hedge, t.ctx(), 'B', 'e');
expect({ x: s.x, y: s.y }).toEqual({ x: door.x, y: door.y });
expect(s.message).toBe(door.openText);
walk(room, t, 'we');
expect(s.message).not.toBe(door.openText); // only the first time
});
it("never sends the Explorer where items and doors are", () => {
const room = playingRoom(hedge);
const keys = keysOf(viewOf<ExplorerView>(room, 'B'));
for (const key of ['items', 'doors', 'x', 'y', 'grid']) expect(keys).not.toContain(key);
});
it('validation: doors need a known item, items and doors sit on plain floor, and the gate must be passable', () => {
const game = structuredClone(hedge);
game.doors[0].needs = 'nothing';
game.items[0].x = 0; // a wall
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/needs unknown item "nothing"/);
expect(errors).toMatch(/must be on plain floor/);
// The key behind its own gate ((3,7) is only reachable through it): the gate's lantern is out of reach.
const locked = structuredClone(hedge);
locked.items[0] = { ...locked.items[0], x: 3, y: 7 };
const lockedErrors = validateGame(locked).join('\n');
expect(lockedErrors).toMatch(/item "Iron Gate Key" .* is not reachable/);
expect(lockedErrors).toMatch(/lantern at \(\d+,\d+\) is not reachable/);
});
});
// ---------- defuse: shared manual and role swaps ----------
describe('defuse: Tick Tock: Switch', () => {
const sw = getGame('tick-tock-switch', 'defuse');
const overtime = getGame('tick-tock-overtime', 'defuse');
it("uses Overtime's device, modules, manual and solver", () => {
expect(sw).toMatchObject({ manualFrom: 'tick-tock-overtime', ruleset: 'overtime', manual: overtime.manual });
expect(sw.modules).toEqual(overtime.modules);
expect(sw.levels).toHaveLength(5);
expect(sw.levels[4].strikesOverride).toBe(1);
});
it('a missing manualFrom game fails to load', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'two-keys-'));
const file = path.join(dir, 'switch-copy.yaml');
const raw = fs.readFileSync(path.join(__dirname, '../../content/games/tick-tock-switch.yaml'), 'utf8');
fs.writeFileSync(file, raw.replace('id: tick-tock-switch', 'id: switch-copy'));
expect(loadGameFile(file).errors.join('\n')).toMatch(/manualFrom: game "tick-tock-overtime" is missing/);
fs.rmSync(dir, { recursive: true });
});
it('swaps roles every level, behind a swap card both players tap Ready on', () => {
const room = playingRoom(sw);
const t = clock(room);
const s = room.engine as DefuseState;
expect(continueDefuse(sw, t.ctx()).ok).toBe(false); // the swap card first
levelReady(sw, t.ctx(), 'A');
expect(viewOf<TechnicianView>(room, 'B').swapReady).toEqual({ you: false, partner: true });
levelReady(sw, t.ctx(), 'B');
expect(s.status).toBe('active');
expect(viewOf<TechnicianView>(room, 'A').seat).toBe('technician');
expect(viewOf<TechnicianView>(room, 'B').seat).toBe('expert');
// Clear level 1 with the solver's answers... or just jump to level 2.
s.status = 'cleared';
continueDefuse(sw, t.ctx());
expect(s).toMatchObject({ level: 1, status: 'ready', swapReady: [] });
levelReady(sw, t.ctx(), 'A');
levelReady(sw, t.ctx(), 'B');
expect(viewOf<TechnicianView>(room, 'B')).toMatchObject({ seat: 'technician', technician: 'B' });
const expert = stateOf(room, 'A').views.A!;
expect(expert).toMatchObject({ seat: 'expert', manual: sw.manual });
expect(keysOf(expert)).not.toContain('device');
// Only this level's Technician may touch the device.
expect(defuseAction(sw, t.ctx(), 'A', 'wires', { type: 'cut', index: 1 }).ok).toBe(false);
});
it('two wire modules on one level are generated and solved separately, as Wires A and Wires B', () => {
const room = playingRoom(sw);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 3;
levelReady(sw, t.ctx(), 'A');
levelReady(sw, t.ctx(), 'B');
const wires = wireModules(s.device!);
expect(wires.map((w) => [w.instance, w.title])).toEqual([
['wires-1', 'Wires A'],
['wires-2', 'Wires B'],
]);
const tech = technicianOf3(s);
const view = viewOf<TechnicianView>(room, tech, t.now);
expect(view.device!.modules.filter((m) => m.id === 'wires').map((m) => (m as { instance: string }).instance)).toEqual(['wires-1', 'wires-2']);
const cut = (instance: string, i: number) =>
defuseAction(sw, t.ctx(), tech, instance, {
type: 'cut',
index: wireSolution(sw.ruleset, edgework(s.device!), wires[i].module.colors).index,
});
expect(cut('wires-2', 1).ok).toBe(true);
expect(wires[1].module.solved).toBe(true);
expect(wires[0].module.solved).toBe(false);
expect(cut('wires-1', 0).ok).toBe(true);
expect(wires[0].module.solved).toBe(true);
expect(s.strikes).toBe(0);
});
it('only wires may appear twice in a level', () => {
const game = structuredClone(sw);
game.levels[0].modules = ['keypad', 'keypad'];
expect(validateGame(game).join('\n')).toMatch(/only wires can appear more than once/);
});
});
/** Level 4 (index 3) is an even level: B holds the device. */
const technicianOf3 = (s: DefuseState): Role => (s.level % 2 === 1 ? 'B' : 'A');
// ---------- sync: Road Trip ----------
describe('sync: In Sync: Road Trip', () => {
it('plays every round to the end', () => {
const game = getGame('in-sync-road-trip', 'sync');
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as SyncState;
for (let r = 0; r < game.rounds.length; r++) {
continueSync(game, t.ctx()); // intro → question
for (let q = 0; q < s.questions.length; q++) {
const question = s.questions[s.q];
const answer = game.rounds[r].type === 'telepathy' ? 'Road' : 0;
expect(answerSync(game, t.ctx(), 'A', question.id, answer).ok).toBe(true);
expect(answerSync(game, t.ctx(), 'B', question.id, answer).ok).toBe(true);
continueSync(game, t.ctx()); // reveal → next / summary
}
continueSync(game, t.ctx()); // summary → next round (or the end)
}
expect(room.keys).toEqual(game.rounds.map((r) => r.keyName));
expect(room.result).toMatchObject({ success: true, ending: game.successEnding });
});
});
// ---------- over real sockets ----------
describe('dial over sockets', () => {
const app = createApp({ dataFile: null });
let url = '';
beforeAll(async () => {
url = `http://localhost:${await app.listen(0)}`;
});
afterAll(async () => {
await app.close();
});
it('never broadcasts the guesser\'s needle', async () => {
const make = () => {
const socket = connect(url, { transports: ['websocket'], forceNew: true });
const states: RoomState[] = [];
socket.on('room:state', (st: RoomState) => states.push(st));
return { socket, states };
};
const a = make();
const b = make();
const ack = (s: typeof a, event: string, payload: object) =>
new Promise<JoinAck>((resolve) => s.socket.emit(event, payload, resolve));
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
try {
const created = await ack(a, 'room:create', { gameId: 'wavelength-tune-in' });
if (!created.ok) throw new Error(created.message);
await ack(b, 'room:join', { code: created.code });
a.socket.emit('room:pickRole', { role: 'A' });
await wait(100);
a.socket.emit('room:ready');
b.socket.emit('room:ready');
await wait(150);
a.socket.emit('dial:clue', { text: 'zebra' });
await wait(150);
const before = a.states.length;
b.socket.emit('dial:needle', { value: 77 });
await wait(200);
expect(a.states.length).toBe(before);
expect(a.states.every((st) => JSON.stringify(st.views).indexOf('"needle":77') === -1)).toBe(true);
} finally {
a.socket.close();
b.socket.close();
}
});
});