// 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 = (room: Room, role: Role, now?: number) => stateOf(room, role, now).views[role] as V; function keysOf(value: unknown, out = new Set()): Set { 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(room, 'A').target).toBe(s.target); expect(viewOf(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(room, 'B')).toMatchObject({ target: null, clue: 'a warm bath', needle: 61 }); const giver = viewOf(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(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(room, 'B'); expect(guesser).toMatchObject({ left: spectrum.left, right: null, showNumbers: false }); expect(JSON.stringify(guesser)).not.toContain(spectrum.right); expect(viewOf(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(room, 'A').grid).toEqual(gridA(scene)); expect(viewOf(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(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(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(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(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(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(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, 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(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(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(room, 'A', t.now).canvas).not.toBeNull(); expect(viewOf(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(room, 'A', t.now)).toMatchObject({ seat: 'builder', target: null }); expect(viewOf(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(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(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, 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(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(room, 'B')).toMatchObject({ inventory: [item.name], message: item.explorerText }); // The Navigator gets no toast about it. expect(t.ctx().toasts).toEqual([]); expect(viewOf(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(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(room, 'B').swapReady).toEqual({ you: false, partner: true }); levelReady(sw, t.ctx(), 'B'); expect(s.status).toBe('active'); expect(viewOf(room, 'A').seat).toBe('technician'); expect(viewOf(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(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(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((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(); } }); });