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>
690 lines
29 KiB
TypeScript
690 lines
29 KiB
TypeScript
// 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();
|
||
}
|
||
});
|
||
});
|