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>
This commit is contained in:
2026-10-15 10:21:24 +02:00
co-authored by Claude Opus 5.5
parent 4ff95e6018
commit 52745935ee
59 changed files with 7028 additions and 107 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/server",
"version": "1.2.2",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
+25 -1
View File
@@ -33,7 +33,9 @@ export function loadGameFile(file: string): LoadedFile {
} catch (err) {
return { file: name, game: null, errors: [`YAML parse error: ${(err as Error).message}`] };
}
const parsed = gameSchema.safeParse(raw);
const borrowed = borrowManual(file, raw);
if (borrowed.error) return { file: name, game: null, errors: [borrowed.error] };
const parsed = gameSchema.safeParse(borrowed.raw);
if (!parsed.success) {
const errors = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
return { file: name, game: null, errors };
@@ -45,6 +47,28 @@ export function loadGameFile(file: string): LoadedFile {
return { file: name, game, errors };
}
/** What a defuse game with `manualFrom` takes from that game's file. */
const MANUAL_FIELDS = ['ruleset', 'device', 'modules', 'manual', 'fixtures'] as const;
/** `manualFrom: <game-id>` reuses that game's device, modules, manual, ruleset and fixtures (and so its solver). */
function borrowManual(file: string, raw: unknown): { raw: unknown; error?: string } {
const own = raw as Record<string, unknown> | null;
if (!own || typeof own !== 'object' || own.engine !== 'defuse' || typeof own.manualFrom !== 'string') return { raw };
const source = path.join(path.dirname(file), `${own.manualFrom}.yaml`);
let base: Record<string, unknown>;
try {
base = yaml.load(fs.readFileSync(source, 'utf8')) as Record<string, unknown>;
} catch {
return { raw, error: `manualFrom: game "${own.manualFrom}" is missing or unreadable` };
}
if (base?.engine !== 'defuse' || base.manualFrom) {
return { raw, error: `manualFrom: "${own.manualFrom}" must be a defuse game with its own manual` };
}
const merged = { ...own };
for (const field of MANUAL_FIELDS) if (field in base) merged[field] = base[field];
return { raw: merged };
}
export function loadAllFiles(dir = gamesDir()): LoadedFile[] {
return fs
.readdirSync(dir)
+153 -7
View File
@@ -1,10 +1,12 @@
// Builds everything that leaves the server. Views are assembled field by field (never by
// copying a content object and deleting from it), so answers can't leak by accident.
import type {
BlueprintView,
ChapterView,
ClueView,
DefuseModuleView,
DefuseView,
DialView,
Dir,
EngineView,
GameMeta,
@@ -15,10 +17,13 @@ import type {
Role,
StoryView,
SyncView,
TwinView,
} from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { maxStrikes, remainingAt, toSignal, type DefuseState } from '../engines/defuse';
import { totalKeys } from '../engines/index';
import { architectOf, patternRemaining, peeking, type BlueprintState } from '../engines/blueprint';
import { maxStrikes, remainingAt, technicianOf, toSignal, wireModules, type DefuseState } from '../engines/defuse';
import { giverOf, maxTotal, spectrumOf, type DialState } from '../engines/dial';
import { timeLimitSeconds, totalKeys } from '../engines/index';
import {
DELTAS,
OPPOSITE,
@@ -30,10 +35,31 @@ import {
toAbsolute,
type LabyrinthState,
} from '../engines/labyrinth';
import { NUDGE_AFTER_WRONG, hintPenalty, timeLimitSeconds, type StoryState } from '../engines/story';
import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story';
import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync';
import {
canRetry,
flipX,
gridA,
gridB,
liveTap,
mirrored,
sceneRemaining,
type TwinState,
} from '../engines/twin';
import type { Room } from '../rooms/store';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
import type {
BlueprintGame,
Clue,
DefuseGame,
DialGame,
Game,
LabyrinthGame,
Lock,
StoryGame,
SyncGame,
TwinGame,
} from './schemas';
export function gameSummary(game: Game): GameSummary {
return {
@@ -47,7 +73,7 @@ export function gameSummary(game: Game): GameSummary {
theme: { ...game.theme },
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
totalKeys: totalKeys(game),
timeLimitMinutes: game.engine === 'story' ? (game.rules.timeLimitMinutes ?? null) : null,
timeLimitMinutes: timeLimitSeconds(game) === null ? null : timeLimitSeconds(game)! / 60,
};
}
@@ -147,6 +173,8 @@ const MORSE_BAND = { minKhz: 3500, maxKhz: 3600 };
export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: number): DefuseView {
const level = game.levels[s.level];
const technician = technicianOf(game, s.level);
const ready = s.swapReady ?? [];
const common = {
engine: 'defuse' as const,
level: { index: s.level, total: game.levels.length, title: level.title, timeSeconds: level.timeSeconds },
@@ -157,16 +185,21 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu
clock: s.clock
? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' }
: null,
technician,
swapRoles: game.swapRolesEachLevel,
swapReady: { you: ready.includes(role), partner: ready.includes(otherRole(role)) },
};
// The Expert gets the manual and nothing about the device.
if (role === 'B') return { ...common, seat: 'expert', manual: game.manual };
if (role !== technician) return { ...common, seat: 'expert', manual: game.manual };
// The Technician gets the device and never the manual.
const d = s.device;
if (!d) return { ...common, seat: 'technician', device: null };
const modules: DefuseModuleView[] = [];
if (d.wires) modules.push({ id: 'wires', solved: d.wires.solved, wires: [...d.wires.colors] });
for (const { instance, title, module } of wireModules(d)) {
modules.push({ id: 'wires', instance, title, solved: module.solved, wires: [...module.colors] });
}
if (d.button) {
modules.push({
id: 'button',
@@ -286,6 +319,9 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
y: g.y,
dir: mirror && (g.dir === 'e' || g.dir === 'w') ? OPPOSITE[g.dir] : g.dir,
})),
// Dimmed once picked up; the Navigator still only hears about it from the Explorer.
items: game.items.map((it) => ({ x: fx(it.x), y: it.y, name: it.name, collected: (s.inventory ?? []).includes(it.id) })),
doors: game.doors.map((d) => ({ x: fx(d.x), y: d.y, name: d.name })),
banner: game.navigatorView.banner ?? null,
keysCarried,
totalKeys: total,
@@ -321,6 +357,110 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
message: s.message || (glyph ? '' : game.explorerView.flavour.blank),
moves: s.moves,
falls: s.falls,
// Names only: where items and doors are stays on the Navigator's map.
inventory: (s.inventory ?? []).map((id) => game.items.find((it) => it.id === id)?.name ?? id),
};
}
// ---------- dial ----------
export function dialView(game: DialGame, s: DialState, role: Role): DialView {
const giver = giverOf(s.round);
const iAmGiver = role === giver;
const revealed = s.status === 'reveal';
const { left, right } = spectrumOf(game, s);
// The guesser may only see some labels, and never the target before locking in.
const sees = iAmGiver || revealed ? 'both' : game.dial.guesserSeesLabels;
return {
engine: 'dial',
status: s.status,
round: s.round,
rounds: game.dial.rounds,
giver,
iAmGiver,
left: sees === 'none' ? null : left,
right: sees === 'both' ? right : null,
showNumbers: iAmGiver || revealed || game.dial.guesserSeesNumbers,
range: [...game.dial.range],
bands: game.dial.scoring.map((b) => ({ within: b.within, points: b.points })),
rules: { ...game.dial.clue },
usedWords: [...s.usedWords],
clue: iAmGiver || s.status !== 'clue' ? s.clue : null,
target: iAmGiver || revealed ? s.target : null,
// The live needle stays with the guesser: no "warmer/colder" for the clue-giver.
needle: iAmGiver ? null : s.needle,
guess: revealed ? s.guess : null,
points: revealed ? s.points : null,
total: s.total,
maxTotal: maxTotal(game),
keys: game.dial.keys.map((k) => ({ at: k.at, name: k.name, earned: s.total >= k.at })),
history: s.history.map((h) => ({ ...h })),
};
}
// ---------- twin ----------
export function twinView(game: TwinGame, s: TwinState, role: Role, now: number): TwinView {
const scene = game.scenes[s.scene];
// Each player gets only their own room; `changes` never leave the server.
const own = role === 'A' ? gridA(scene) : gridB(scene);
const grid = mirrored(game, role) ? own.map((row) => [...row].reverse()) : own;
const tap = s.status === 'playing' ? liveTap(game, s.taps[role], now) : null;
const theirs = s.status === 'playing' ? liveTap(game, s.taps[otherRole(role)], now) : null;
const left = (at: number) => Math.max(0, game.twin.claimWindowSeconds - (now - at) / 1000);
return {
engine: 'twin',
status: s.status,
scene: {
index: s.scene,
total: game.scenes.length,
title: scene.title,
keyName: scene.keyName,
differences: scene.changes.length,
},
grid,
showCoordinates: game.twin.showCoordinates,
found: s.found.map((i) => ({ x: flipX(game, scene, role, scene.changes[i].x), y: scene.changes[i].y })),
myTap: tap ? { x: flipX(game, scene, role, tap.x), y: tap.y, secondsLeft: left(tap.at) } : null,
partnerTap: theirs ? { secondsLeft: left(theirs.at) } : null,
remaining: Math.max(0, sceneRemaining(game, s, now)),
timeSeconds: scene.timeSeconds,
claimWindowSeconds: game.twin.claimWindowSeconds,
wrongClaimPenaltySeconds: game.twin.wrongClaimPenaltySeconds,
canRetry: canRetry(s),
isRetry: s.attempt > 0,
};
}
// ---------- blueprint ----------
export function blueprintView(game: BlueprintGame, s: BlueprintState, role: Role, now: number): BlueprintView {
const p = game.patterns[s.pattern];
const architect = architectOf(game, s.pattern);
const isArchitect = role === architect;
const scored = s.status === 'scored';
return {
engine: 'blueprint',
status: s.status,
seat: isArchitect ? 'architect' : 'builder',
architect,
pattern: { index: s.pattern, total: game.patterns.length, size: p.size, keyName: p.keyName },
palette: Object.entries(p.palette).map(([key, c]) => ({ key, name: c.name, hex: c.hex })),
// The Builder never gets the picture or its name until it's scored.
target: isArchitect || scored ? [...p.rows] : null,
title: isArchitect || scored ? p.title : null,
// The Architect only sees the canvas during a peek, or once it's scored.
canvas: !isArchitect || scored || peeking(s, now) ? [...s.canvas] : null,
rotateBuilder: game.blueprint.rotateBuilder,
peeksLeft: Math.max(0, p.peeks - s.peeksUsed),
peekUntil: isArchitect && peeking(s, now) ? s.peekUntil : null,
submitsLeft: Math.max(0, game.blueprint.submitsPerPattern - s.submits),
threshold: p.threshold,
bestScore: s.bestScore,
lastScore: s.lastScore,
passed: s.passed,
remaining: Math.max(0, patternRemaining(game, s, now)),
timeSeconds: p.timeSeconds,
};
}
@@ -334,5 +474,11 @@ export function engineView(game: Game, room: Room, role: Role, now: number): Eng
return syncView(game, room.engine as SyncState, role);
case 'labyrinth':
return labyrinthView(game, room.engine as LabyrinthState, role);
case 'dial':
return dialView(game, room.engine as DialState, role);
case 'twin':
return twinView(game, room.engine as TwinState, role, now);
case 'blueprint':
return blueprintView(game, room.engine as BlueprintState, role, now);
}
}
+133 -3
View File
@@ -30,6 +30,12 @@ const clueSchema = z.object({
.optional(),
});
/** A hard limit on the whole game's clock (elapsed + penalties). Running out ends the game. */
const timeLimitRules = {
timeLimitMinutes: z.number().positive().optional(),
timeLimitLabel: z.string().min(1).optional(),
};
// Strict: an unquoted comma in `{ id, label: a, b }` adds a stray `b` key instead of failing.
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) }).strict();
@@ -93,9 +99,7 @@ const storySchema = z.object({
rules: z
.object({
hintPenaltySeconds: z.number().int().nonnegative().optional(),
/** Hard limit on the clock (elapsed + penalties). Running out ends the game. */
timeLimitMinutes: z.number().positive().optional(),
timeLimitLabel: z.string().min(1).optional(),
...timeLimitRules,
})
.default({}),
suspects: z
@@ -116,6 +120,10 @@ const defuseSchema = z.object({
engine: z.literal('defuse'),
/** Which manual the solver implements. Each manual's rules live in engines/defuse.ts. */
ruleset: z.enum(['classic', 'overtime']).default('classic'),
/** The game whose device, modules, manual, ruleset and fixtures this one reuses (filled in by the loader). */
manualFrom: z.string().optional(),
/** Level 1: A is the Technician; level 2: B; and so on. */
swapRolesEachLevel: z.boolean().default(false),
device: z.object({
serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }),
batteries: range,
@@ -156,6 +164,8 @@ const defuseSchema = z.object({
simon: z.object({ colors: z.array(z.string()).min(2), stages: range }).optional(),
}),
manual: z.string().min(1),
/** Shown when the last level is cleared. */
successEnding: z.string().min(1).optional(),
fixtures: z.object({
wires: z.array(
z.object({
@@ -239,6 +249,8 @@ const syncSchema = z.object({
predict: z.array(questionSchema).default([]),
telepathy: z.array(telepathySchema).default([]),
rank: z.array(rankSchema).default([]),
/** Shown when every key is earned. */
successEnding: z.string().min(1).optional(),
});
// ---------- labyrinth ----------
@@ -256,6 +268,23 @@ const labyrinthSchema = z.object({
lanternsRequired: z.number().int().positive().default(3),
/** One-way gates: you may step from (x, y) in `dir`, never back the other way. */
oneWay: z.array(z.object({ x: z.number().int(), y: z.number().int(), dir })).default([]),
/** Picked up on entry and kept after traps. */
items: z
.array(z.object({ x: z.number().int(), y: z.number().int(), id: z.string().min(1), name: z.string().min(1), explorerText: z.string().min(1) }))
.default([]),
/** Impassable until the Explorer carries the item `needs`. */
doors: z
.array(
z.object({
x: z.number().int(),
y: z.number().int(),
needs: z.string().min(1),
name: z.string().min(1),
lockedText: z.string().min(1),
openText: z.string().min(1),
}),
)
.default([]),
explorer: z
.object({
/** `relative` swaps N/E/S/W for forward/left/right/back from the way the Explorer faces. */
@@ -283,17 +312,118 @@ const labyrinthSchema = z.object({
successEnding: z.string().min(1),
});
// ---------- dial (Wavelength) ----------
const dialSchema = z.object({
...header,
engine: z.literal('dial'),
rules: z.object(timeLimitRules).default({}),
dial: z.object({
rounds: z.number().int().positive(),
range: z.tuple([z.number().int(), z.number().int()]),
/** The hidden target is a random integer at least this far inside the range. */
targetMargin: z.number().int().nonnegative(),
/** Distance from the target → points; the first band that fits wins, outside all = 0. */
scoring: z.array(z.object({ within: z.number().nonnegative(), points: z.number().int().positive() })).min(1),
clue: z.object({
maxWords: z.number().int().positive(),
maxChars: z.number().int().positive(),
banLabelWords: z.boolean(),
noRepeatWords: z.boolean(),
}),
guesserSeesLabels: z.enum(['both', 'left', 'none']),
guesserSeesNumbers: z.boolean(),
/** Cumulative points thresholds. */
keys: z.array(z.object({ at: z.number().int().positive(), name: z.string().min(1) })).min(1),
}),
spectra: z.array(z.object({ id: z.string().min(1), left: z.string().min(1), right: z.string().min(1) })).min(1),
successEnding: z.string().min(1),
});
// ---------- twin (Twin Rooms) ----------
const twinSchema = z.object({
...header,
engine: z.literal('twin'),
rules: z.object(timeLimitRules).default({}),
twin: z.object({
claimWindowSeconds: z.number().positive(),
wrongClaimPenaltySeconds: z.number().nonnegative(),
/** B's room is shown flipped left to right. */
mirrorB: z.boolean().default(false),
showCoordinates: z.boolean().default(false),
}),
scenes: z
.array(
z.object({
id: z.string().min(1),
title: z.string().min(1),
timeSeconds: z.number().int().positive(),
keyName: z.string().min(1),
differences: z.number().int().positive().optional(),
/** A's room: one row per string, cells separated by single spaces (never split by character). */
grid: z.array(z.string().min(1)).min(1),
/** SERVER ONLY: B's room is the grid with these cells replaced. */
changes: z.array(z.object({ x: z.number().int(), y: z.number().int(), to: z.string().min(1) })).min(1),
}),
)
.min(1),
successEnding: z.string().min(1),
});
// ---------- blueprint (Describe & Draw) ----------
const blueprintSchema = z.object({
...header,
engine: z.literal('blueprint'),
rules: z.object(timeLimitRules).default({}),
blueprint: z.object({
scoring: z.literal('overlap'),
/** The Builder's canvas is shown rotated clockwise; the server stores it in target orientation. */
rotateBuilder: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).default(0),
swapRolesEachPattern: z.boolean().default(false),
submitsPerPattern: z.number().int().positive().default(1),
}),
patterns: z
.array(
z.object({
id: z.string().min(1),
/** SERVER ONLY for the Builder until the pattern is scored. */
title: z.string().min(1),
size: z.number().int().min(2).max(16),
timeSeconds: z.number().int().positive(),
threshold: z.number(),
peeks: z.number().int().nonnegative(),
keyName: z.string().min(1),
palette: z.record(
z.string().regex(/^[A-Z]$/, 'palette keys are single capital letters'),
z.object({ name: z.string().min(1), hex }),
),
/** '.' = empty. SERVER ONLY for the Builder. */
rows: z.array(z.string()).min(2),
}),
)
.min(1),
successEnding: z.string().min(1),
});
export const gameSchema = z.discriminatedUnion('engine', [
storySchema,
defuseSchema,
syncSchema,
labyrinthSchema,
dialSchema,
twinSchema,
blueprintSchema,
]);
export type StoryGame = z.infer<typeof storySchema>;
export type DefuseGame = z.infer<typeof defuseSchema>;
export type SyncGame = z.infer<typeof syncSchema>;
export type LabyrinthGame = z.infer<typeof labyrinthSchema>;
export type DialGame = z.infer<typeof dialSchema>;
export type TwinGame = z.infer<typeof twinSchema>;
export type BlueprintGame = z.infer<typeof blueprintSchema>;
export type Game = z.infer<typeof gameSchema>;
export type Clue = z.infer<typeof clueSchema>;
export type Lock = z.infer<typeof lockSchema>;
+137 -4
View File
@@ -10,8 +10,21 @@ import {
wireSolution,
} from '../engines/defuse';
import { DELTAS, analyseMaze, cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
import { maxTotal } from '../engines/dial';
import { groupOf, pointsForKey } from '../engines/sync';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
import { gridA } from '../engines/twin';
import type {
BlueprintGame,
Clue,
DefuseGame,
DialGame,
Game,
LabyrinthGame,
Lock,
StoryGame,
SyncGame,
TwinGame,
} from './schemas';
export const MIN_SYNC_QUESTIONS = 15;
@@ -26,9 +39,99 @@ export function validateGame(game: Game): string[] {
return validateSync(game);
case 'labyrinth':
return validateLabyrinth(game);
case 'dial':
return validateDial(game);
case 'twin':
return validateTwin(game);
case 'blueprint':
return validateBlueprint(game);
}
}
/** A one-line summary for the validate-content report. */
export function describeGame(game: Game): string {
switch (game.engine) {
case 'story':
return `${game.chapters.length} chapters + ${game.finale.type}`;
case 'defuse':
return `${game.levels.length} levels${game.manualFrom ? `, manual from ${game.manualFrom}` : ''}${game.swapRolesEachLevel ? ', roles swap' : ''}`;
case 'sync':
return `${game.rounds.length} rounds: ${game.rounds.map((r) => r.type).join(', ')}`;
case 'labyrinth': {
const traps = findCells(game.grid, 'T').length;
return `${game.grid[0].length}×${game.grid.length}, ${traps} traps, ${game.lanterns.length} lanterns, ${game.oneWay.length} one-way, ${game.doors.length} doors`;
}
case 'dial':
return `${game.dial.rounds} rounds, ${game.spectra.length} spectra, keys at ${game.dial.keys.map((k) => k.at).join('/')} of ${maxTotal(game)}`;
case 'twin':
return `${game.scenes.length} scenes, ${game.scenes.reduce((n, s) => n + s.changes.length, 0)} differences${game.twin.mirrorB ? ', B mirrored' : ''}`;
case 'blueprint':
return `${game.patterns.length} pictures, ${[...new Set(game.patterns.map((p) => p.size))].join('/')} grid${game.blueprint.rotateBuilder ? `, Builder rotated ${game.blueprint.rotateBuilder}°` : ''}`;
}
}
export const MIN_SPECTRA = 30;
function validateDial(game: DialGame): string[] {
const errors: string[] = [];
const { dial, spectra } = game;
// Enough for a session that never repeats, with room to spare.
const needed = Math.max(MIN_SPECTRA, dial.rounds * 2);
if (spectra.length < needed) errors.push(`spectra bank has ${spectra.length}, needs at least ${needed}`);
if (new Set(spectra.map((x) => x.id)).size !== spectra.length) errors.push('duplicate spectrum id');
const [lo, hi] = dial.range;
if (hi - lo <= 2 * dial.targetMargin) errors.push('targetMargin leaves no room for a target');
const bands = dial.scoring.map((b) => b.within);
if (bands.some((w, i) => i > 0 && w <= bands[i - 1])) errors.push('scoring bands must widen');
if (dial.scoring.some((b, i) => i > 0 && b.points >= dial.scoring[i - 1].points)) errors.push('wider scoring bands must score less');
const max = maxTotal(game);
for (const k of dial.keys) if (k.at > max) errors.push(`key "${k.name}" needs ${k.at} points, but ${max} is the most possible`);
if (dial.keys.some((k, i) => i > 0 && k.at <= dial.keys[i - 1].at)) errors.push('key thresholds must go up');
return errors;
}
function validateTwin(game: TwinGame): string[] {
const errors: string[] = [];
const ids = new Set<string>();
for (const scene of game.scenes) {
const where = `scene ${scene.id}`;
if (ids.has(scene.id)) errors.push(`duplicate scene id "${scene.id}"`);
ids.add(scene.id);
const grid = gridA(scene);
const width = grid[0].length;
if (grid.some((row) => row.length !== width)) errors.push(`${where}: rows don't all have ${width} cells`);
if (grid.some((row) => row.some((cell) => cell === ''))) errors.push(`${where}: a row has a double space`);
const seen = new Set<string>();
for (const c of scene.changes) {
if (c.x < 0 || c.y < 0 || c.y >= grid.length || c.x >= width) {
errors.push(`${where}: change at (${c.x},${c.y}) is off the grid`);
continue;
}
if (grid[c.y][c.x] === c.to) errors.push(`${where}: change at (${c.x},${c.y}) doesn't change anything`);
if (seen.has(`${c.x},${c.y}`)) errors.push(`${where}: two changes at (${c.x},${c.y})`);
seen.add(`${c.x},${c.y}`);
}
if ((scene.differences ?? scene.changes.length) !== scene.changes.length) {
errors.push(`${where}: says ${scene.differences} differences but has ${scene.changes.length} changes`);
}
}
return errors;
}
function validateBlueprint(game: BlueprintGame): string[] {
const errors: string[] = [];
for (const p of game.patterns) {
const where = `pattern ${p.id}`;
if (p.rows.length !== p.size || p.rows.some((r) => r.length !== p.size)) errors.push(`${where}: rows must be ${p.size}×${p.size}`);
const used = new Set(p.rows.join('').replaceAll('.', ''));
for (const c of used) if (!(c in p.palette)) errors.push(`${where}: colour "${c}" is not in the palette`);
if (used.size === 0) errors.push(`${where}: the picture is empty`);
if (!(p.threshold > 0 && p.threshold <= 1)) errors.push(`${where}: threshold must be above 0 and at most 1`);
}
if (new Set(game.patterns.map((p) => p.id)).size !== game.patterns.length) errors.push('duplicate pattern id');
return errors;
}
function validateStory(game: StoryGame): string[] {
const errors: string[] = [];
const ids = new Set<string>();
@@ -118,8 +221,15 @@ function validateDefuse(game: DefuseGame): string[] {
});
for (const level of game.levels) {
const from = game.manualFrom ? ` (in ${game.manualFrom})` : '';
for (const id of level.modules) {
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules`);
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules${from}`);
}
// Only wires can appear twice on one device (as Wires A and Wires B).
for (const id of new Set(level.modules)) {
if (id !== 'wires' && level.modules.filter((m) => m === id).length > 1) {
errors.push(`level ${level.id}: only wires can appear more than once, "${id}" does`);
}
}
}
if (modules.passwords) {
@@ -228,16 +338,39 @@ function validateLabyrinth(game: LabyrinthGame): string[] {
if (ends.includes('T')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} sits on a trap`);
}
// Items and doors live on plain floor; a door needs an item that exists.
const itemIds = new Set(game.items.map((it) => it.id));
if (itemIds.size !== game.items.length) errors.push('duplicate item id');
for (const it of [...game.items, ...game.doors]) {
const c = cellAt(grid, it.x, it.y);
if (c !== '.') errors.push(`"${it.name}" at (${it.x},${it.y}) must be on plain floor (.), not "${c}"`);
}
for (const d of game.doors) {
if (!itemIds.has(d.needs)) errors.push(`door "${d.name}" needs unknown item "${d.needs}"`);
}
const taken = new Set<string>();
for (const p of [...game.items, ...game.doors]) {
if (taken.has(`${p.x},${p.y}`)) errors.push(`two items or doors share (${p.x},${p.y})`);
taken.add(`${p.x},${p.y}`);
}
if (starts.length === 1) {
const reach = safeReachable(grid, game.oneWay);
// Doors block until their item is carried, so reachability plays the maze out with items.
const reach =
game.doors.length > 0
? analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors).safeCells
: safeReachable(grid, game.oneWay);
for (const k of keys) {
if (!isReachable(reach, k)) errors.push(`lantern at (${k.x},${k.y}) is not reachable from S without a trap`);
}
for (const e of exits) {
if (!isReachable(reach, e)) errors.push(`exit at (${e.x},${e.y}) is not reachable from S without a trap`);
}
for (const it of game.items) {
if (!isReachable(reach, it)) errors.push(`item "${it.name}" at (${it.x},${it.y}) is not reachable from S without a trap`);
}
if (errors.length === 0) {
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns);
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors);
if (!winnable) errors.push('no trap-free route collects every lantern and then reaches the exit');
for (const p of stuck) errors.push(`the Explorer can get stuck for good at (${p.x},${p.y})`);
}
+173
View File
@@ -0,0 +1,173 @@
import type { Role } from '@two-keys/shared';
import type { BlueprintGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
type Pattern = BlueprintGame['patterns'][number];
export const PEEK_MS = 3000;
export const EMPTY = '.';
export interface BlueprintState {
pattern: number;
status: 'ready' | 'building' | 'scored';
startedAt: number | null;
/** The Builder's canvas, row by row, in target orientation ('.' = empty). */
canvas: string[];
submits: number;
bestScore: number | null;
lastScore: number | null;
passed: boolean | null;
peeksUsed: number;
peekUntil: number | null;
}
const state = (ctx: Ctx) => ctx.room.engine as BlueprintState;
const blank = (size: number) => Array.from({ length: size }, () => EMPTY.repeat(size));
export function initBlueprint(game: BlueprintGame): BlueprintState {
return {
pattern: 0,
status: 'ready',
startedAt: null,
canvas: blank(game.patterns[0].size),
submits: 0,
bestScore: null,
lastScore: null,
passed: null,
peeksUsed: 0,
peekUntil: null,
};
}
/** Pattern 1: A describes. With `swapRolesEachPattern`, the Architect alternates A, B, A... */
export const architectOf = (game: BlueprintGame, pattern: number): Role =>
game.blueprint.swapRolesEachPattern && pattern % 2 === 1 ? 'B' : 'A';
/**
* Matched cells (both painted, same colour) over cells painted in either grid. Cells empty in
* both don't count, so a blank canvas never scores.
*/
export function overlapScore(target: readonly string[], canvas: readonly string[]): number {
let matched = 0;
let union = 0;
target.forEach((row, y) => {
[...row].forEach((t, x) => {
const p = canvas[y]?.[x] ?? EMPTY;
if (t === EMPTY && p === EMPTY) return;
union++;
if (t === p) matched++;
});
});
return union === 0 ? 0 : matched / union;
}
/**
* The Builder's canvas shown rotated 90° clockwise: displayed (column c, row r) on an n×n grid is
* stored cell (x = r, y = n − 1 − c). The other angles follow by repeating it.
*/
export function displayedToStored(n: number, rotate: number, c: number, r: number): { x: number; y: number } {
let p = { x: c, y: r };
for (let turn = 0; turn < ((rotate / 90) % 4); turn++) p = { x: p.y, y: n - 1 - p.x };
return p;
}
export function patternRemaining(game: BlueprintGame, s: BlueprintState, now: number): number {
const p = game.patterns[s.pattern];
return s.startedAt === null ? p.timeSeconds : p.timeSeconds - (now - s.startedAt) / 1000;
}
export const peeking = (s: BlueprintState, now: number) => s.peekUntil !== null && now < s.peekUntil;
export function paint(
game: BlueprintGame,
ctx: Ctx,
role: Role,
cells: { x: number; y: number; color: string | null }[],
): ActionResult {
const s = state(ctx);
if (s.status !== 'building') return fail('not_building', 'The canvas is closed.');
if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder paints.');
const p = game.patterns[s.pattern];
const rows = s.canvas.map((row) => [...row]);
for (const { x, y, color } of cells) {
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= p.size || y >= p.size) {
return fail('bad_cell', 'That square is off the canvas.');
}
if (color !== null && !(color in p.palette)) return fail('bad_color', "That colour isn't on the palette.");
rows[y][x] = color ?? EMPTY;
}
s.canvas = rows.map((row) => row.join(''));
return OK;
}
export function peek(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (s.status !== 'building') return fail('not_building', 'Nothing to peek at right now.');
if (role !== architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Architect can peek.');
if (peeking(s, ctx.now)) return fail('peeking', 'You are already peeking.');
if (s.peeksUsed >= game.patterns[s.pattern].peeks) return fail('no_peeks', 'No peeks left for this picture.');
s.peeksUsed++;
s.peekUntil = ctx.now + PEEK_MS;
return OK;
}
function score(game: BlueprintGame, ctx: Ctx, s: BlueprintState, p: Pattern) {
const value = overlapScore(p.rows, s.canvas);
s.submits++;
s.lastScore = value;
s.bestScore = Math.max(s.bestScore ?? 0, value);
const passed = s.bestScore >= p.threshold;
if (passed || s.submits >= game.blueprint.submitsPerPattern) {
s.status = 'scored';
s.passed = passed;
s.peekUntil = null;
if (passed) ctx.addKey(p.keyName);
} else {
ctx.room.wrongGuesses++;
ctx.toast('all', `${Math.round(value * 100)}%. You need ${Math.round(p.threshold * 100)}%. One more try!`);
}
}
export function submitCanvas(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (s.status !== 'building') return fail('not_building', 'Nothing to submit right now.');
if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder submits.');
if (tickBlueprint(game, ctx)) return OK;
score(game, ctx, s, game.patterns[s.pattern]);
return OK;
}
/** Starts a picture, or moves on to the next one. */
export function continueBlueprint(game: BlueprintGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
s.status = 'building';
s.startedAt = ctx.now;
return OK;
}
if (s.status === 'building') return fail('busy', 'The clock is running!');
if (s.pattern < game.patterns.length - 1) {
const next = s.pattern + 1;
Object.assign(s, initBlueprint(game), { pattern: next, canvas: blank(game.patterns[next].size) });
return OK;
}
const keys = ctx.room.keys.length;
const total = game.patterns.length;
ctx.finish(
keys === total,
keys === total ? game.successEnding : `**${keys} of ${total} pictures.** A few brushstrokes off. Play again?`,
);
return OK;
}
/** Called on a timer; returns true if the room changed. When the clock runs out, the canvas is scored as it is. */
export function tickBlueprint(game: BlueprintGame, ctx: Ctx): boolean {
const s = state(ctx);
if (!s || s.status !== 'building' || patternRemaining(game, s, ctx.now) > 0) return false;
ctx.toast('all', "⏰ Time's up! Scoring the canvas as it is.");
// Out of time ends the picture even with a submit left.
s.submits = Math.max(s.submits, game.blueprint.submitsPerPattern - 1);
score(game, ctx, s, game.patterns[s.pattern]);
return true;
}
+65 -20
View File
@@ -209,10 +209,17 @@ export function mulberry32(seed: number): () => number {
};
}
export interface WiresModule {
colors: string[];
solved: boolean;
}
export interface DefuseDevice {
serial: string;
batteries: number;
wires?: { colors: string[]; solved: boolean };
wires?: WiresModule;
/** A level that lists `wires` more than once gets one module per extra entry, generated and solved separately. */
moreWires?: WiresModule[];
button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean };
keypad?: { symbols: string[]; pressed: string[]; solved: boolean };
indicators?: { label: string; lit: boolean }[];
@@ -266,7 +273,9 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
for (const id of game.levels[levelIndex].modules) {
if (id === 'wires') {
const count = int(mods.wires.count.min, mods.wires.count.max);
device.wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
const wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
if (device.wires) (device.moreWires ??= []).push(wires);
else device.wires = wires;
} else if (id === 'button') {
device.button = {
label: pick(mods.button.labels),
@@ -307,12 +316,22 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
return device;
}
/**
* Every wire module on the device with the id `defuse:action` uses for it: plain `wires` when
* there is one, `wires-1`, `wires-2`... (shown as "Wires A", "Wires B"...) when there are several.
*/
export function wireModules(d: DefuseDevice): { instance: string; title: string; module: WiresModule }[] {
const all = d.wires ? [d.wires, ...(d.moreWires ?? [])] : [];
if (all.length === 1) return [{ instance: 'wires', title: 'Wires', module: all[0] }];
return all.map((module, i) => ({ instance: `wires-${i + 1}`, title: `Wires ${String.fromCharCode(65 + i)}`, module }));
}
/** A generated device must always be solvable by the manual. */
export function isSolvable(game: DefuseGame, device: DefuseDevice): boolean {
if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) return false;
if (device.wires) {
const { index } = wireSolution(game.ruleset, edgework(device), device.wires.colors);
if (index < 1 || index > device.wires.colors.length) return false;
for (const { module } of wireModules(device)) {
const { index } = wireSolution(game.ruleset, edgework(device), module.colors);
if (index < 1 || index > module.colors.length) return false;
}
if (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false;
if (device.passwords && !solvePassword(device.passwords.columns, game.modules.passwords?.words ?? [])) return false;
@@ -332,6 +351,8 @@ export interface DefuseState {
strikes: number;
/** `remaining` game-seconds as of `anchor`; ticks down at `speed` per real second. */
clock: { remaining: number; anchor: number; speed: number } | null;
/** Roles that tapped Ready on the swap card (games that swap roles every level). */
swapReady?: Role[];
}
const newSeed = () => Math.floor(Math.random() * 0xffffffff);
@@ -347,7 +368,11 @@ export const maxStrikes = (game: DefuseGame, level: number): number =>
game.levels[level]?.strikesOverride ?? game.strikes.max;
const deviceModules = (d: DefuseDevice) =>
[d.wires, d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
[d.wires, ...(d.moreWires ?? []), d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
/** Who holds the device on a level: A, unless roles swap every level (then B on levels 2, 4...). */
export const technicianOf = (game: DefuseGame, level: number): Role =>
game.swapRolesEachLevel && level % 2 === 1 ? 'B' : 'A';
export function remainingAt(s: DefuseState, now: number): number {
if (!s.clock) return 0;
@@ -391,6 +416,7 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
if (s.level === game.levels.length - 1) {
ctx.finish(
true,
game.successEnding ??
'**Device disarmed.** The ticking stops. Somewhere inside the box, a tiny speaker plays a wedding march.\n\nOne of you had the hands, one of you had the manual. Neither of you could have done it alone. 💣🔑',
);
}
@@ -400,16 +426,8 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
s.device = generateDevice(game, s.level, s.seed);
// Never arm a device the solver can't solve.
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
s.seed = newSeed();
s.device = generateDevice(game, s.level, s.seed);
}
s.strikes = 0;
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
s.status = 'active';
return OK;
if (game.swapRolesEachLevel) return fail('swap_ready', 'You both need to tap Ready on the swap card.');
return startLevel(game, ctx, s);
}
if (s.status === 'active') return fail('busy', 'The clock is running!');
if (s.status === 'cleared') {
@@ -417,7 +435,33 @@ export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
s.level++;
}
// Retry (same level, no key lost) or next level: fresh seed, back to the briefing.
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null });
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null, swapReady: [] });
return OK;
}
/** The swap card: once both have tapped Ready, the clock starts. Tapping again takes it back. */
export function levelReady(game: DefuseGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (!game.swapRolesEachLevel) return fail('no_swap', 'Roles stay the same in this game.');
if (s.status !== 'ready') return fail('not_ready', 'The level has already started.');
const ready = new Set(s.swapReady ?? []);
if (ready.has(role)) ready.delete(role);
else ready.add(role);
s.swapReady = [...ready];
return ready.size === 2 ? startLevel(game, ctx, s) : OK;
}
function startLevel(game: DefuseGame, ctx: Ctx, s: DefuseState): ActionResult {
s.device = generateDevice(game, s.level, s.seed);
// Never arm a device the solver can't solve.
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
s.seed = newSeed();
s.device = generateDevice(game, s.level, s.seed);
}
s.strikes = 0;
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
s.status = 'active';
s.swapReady = [];
return OK;
}
@@ -429,7 +473,7 @@ export function defuseAction(
action: DefuseAction,
): ActionResult {
const s = state(ctx);
if (role !== 'A') return fail('wrong_role', 'Only the Technician can touch the device.');
if (role !== technicianOf(game, s.level)) return fail('wrong_role', 'Only the Technician can touch the device.');
if (s.status !== 'active' || !s.device) return fail('not_active', 'The device is not live.');
if (remainingAt(s, ctx.now) <= 0) {
explode(ctx, s, 'time');
@@ -438,8 +482,9 @@ export function defuseAction(
const d = s.device;
const edge = edgework(d);
if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') {
if (action.index === wireSolution(game.ruleset, edge, d.wires.colors).index) d.wires.solved = true;
const wires = wireModules(d).find((w) => w.instance === moduleId)?.module;
if (wires && !wires.solved && action.type === 'cut') {
if (action.index === wireSolution(game.ruleset, edge, wires.colors).index) wires.solved = true;
else strike(game, ctx, s);
} else if (moduleId === 'button' && d.button && !d.button.solved) {
const b = d.button;
+176
View File
@@ -0,0 +1,176 @@
import { normalizeAnswer, otherRole, type DialRoundRecord, type Role } from '@two-keys/shared';
import type { DialGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export interface DialState {
/** 0-based. */
round: number;
status: 'clue' | 'guess' | 'reveal';
spectrumId: string;
/** SERVER ONLY until the guesser locks in. */
target: number;
clue: string | null;
/** The guesser's live needle. Never shown to the clue-giver. */
needle: number | null;
guess: number | null;
points: number | null;
total: number;
/** Spectra already dealt this session. */
used: string[];
/** Normalized clue words used so far (for `noRepeatWords`). */
usedWords: string[];
history: DialRoundRecord[];
}
const state = (ctx: Ctx) => ctx.room.engine as DialState;
/** A on odd rounds (1, 3, ...), B on even ones. */
export const giverOf = (round: number): Role => (round % 2 === 0 ? 'A' : 'B');
/** Uppercase, accents and punctuation gone, one trailing S dropped. */
export function normalizeClueWord(word: string): string {
const w = normalizeAnswer(word);
return w.length > 1 ? w.replace(/S$/, '') : w;
}
/** The words of a label, so "Old-fashioned" bans OLD, FASHIONED and OLDFASHIONED. */
export function labelWords(label: string): string[] {
const parts = label.split(/[\s\-–—/]+/).map(normalizeClueWord);
return [...new Set([...parts, normalizeClueWord(label.replace(/\s+/g, ''))])].filter(Boolean);
}
export function pointsFor(game: DialGame, distance: number): number {
return game.dial.scoring.find((b) => distance <= b.within)?.points ?? 0;
}
export const maxTotal = (game: DialGame): number =>
Math.max(...game.dial.scoring.map((b) => b.points)) * game.dial.rounds;
export const spectrumOf = (game: DialGame, s: DialState) => game.spectra.find((x) => x.id === s.spectrumId)!;
function deal(game: DialGame, s: DialState, random: () => number) {
const fresh = game.spectra.filter((x) => !s.used.includes(x.id));
const pool = fresh.length > 0 ? fresh : game.spectra;
const spectrum = pool[Math.floor(random() * pool.length)];
const [lo, hi] = game.dial.range;
const margin = game.dial.targetMargin;
s.spectrumId = spectrum.id;
s.used.push(spectrum.id);
s.target = lo + margin + Math.floor(random() * (hi - lo - 2 * margin + 1));
s.status = 'clue';
s.clue = null;
s.needle = null;
s.guess = null;
s.points = null;
}
export function initDial(game: DialGame, random: () => number = Math.random): DialState {
const s: DialState = {
round: 0,
status: 'clue',
spectrumId: '',
target: 0,
clue: null,
needle: null,
guess: null,
points: null,
total: 0,
used: [],
usedWords: [],
history: [],
};
deal(game, s, random);
return s;
}
/** Why a clue isn't allowed, or null if it is. */
export function clueProblem(game: DialGame, s: DialState, text: string): string | null {
const rules = game.dial.clue;
const clue = text.trim();
const words = clue.split(/\s+/).filter((w) => normalizeClueWord(w) !== '');
if (words.length === 0) return 'Type a clue first.';
if (clue.length > rules.maxChars) return `Keep it to ${rules.maxChars} characters.`;
if (words.length > rules.maxWords) {
return rules.maxWords === 1 ? 'One word only!' : `Up to ${rules.maxWords} words.`;
}
const normalized = words.map(normalizeClueWord);
if (rules.banLabelWords) {
const { left, right } = spectrumOf(game, s);
const banned = new Set([...labelWords(left), ...labelWords(right)]);
const hit = words.find((_, i) => banned.has(normalized[i]));
if (hit) return `"${hit}" is on the dial. Try another word.`;
}
if (rules.noRepeatWords) {
const hit = words.find((_, i) => s.usedWords.includes(normalized[i]));
if (hit) return `"${hit}" was already used this game.`;
}
return null;
}
export function giveClue(game: DialGame, ctx: Ctx, role: Role, text: string): ActionResult {
const s = state(ctx);
if (s.status !== 'clue') return fail('not_open', 'The clue is already in.');
if (role !== giverOf(s.round)) return fail('wrong_role', "It's your partner's turn to give the clue.");
// The rejection reaches the clue-giver as a friendly toast.
const problem = clueProblem(game, s, text);
if (problem) return fail('bad_clue', problem);
s.clue = text.trim().replace(/\s+/g, ' ');
if (game.dial.clue.noRepeatWords) s.usedWords.push(...s.clue.split(' ').map(normalizeClueWord));
s.status = 'guess';
return OK;
}
const clampDial = (game: DialGame, value: number) => {
const [lo, hi] = game.dial.range;
return Math.min(hi, Math.max(lo, Math.round(value)));
};
/** The guesser's live needle: kept for a refresh, never broadcast. */
export function moveNeedle(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult {
const s = state(ctx);
if (s.status !== 'guess' || role === giverOf(s.round)) return fail('not_open', 'Not your dial right now.');
s.needle = clampDial(game, value);
return OK;
}
export function lockGuess(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult {
const s = state(ctx);
if (s.status !== 'guess') return fail('not_open', 'Nothing to lock in yet.');
if (role === giverOf(s.round)) return fail('wrong_role', 'Only the guesser turns the dial.');
const guess = clampDial(game, value);
const distance = Math.abs(guess - s.target);
const points = pointsFor(game, distance);
const before = s.total;
s.guess = guess;
s.needle = guess;
s.points = points;
s.total += points;
s.status = 'reveal';
// Keys land the moment the running total reaches each threshold.
for (const key of game.dial.keys) if (before < key.at && s.total >= key.at) ctx.addKey(key.name);
const { left, right } = spectrumOf(game, s);
s.history.push({ left, right, clue: s.clue ?? '', giver: giverOf(s.round), target: s.target, guess, points });
return OK;
}
export function continueDial(game: DialGame, ctx: Ctx, random: () => number = Math.random): ActionResult {
const s = state(ctx);
if (s.status !== 'reveal') return fail('not_done', 'Finish this round first.');
if (s.round < game.dial.rounds - 1) {
s.round++;
deal(game, s, random);
return OK;
}
const keys = ctx.room.keys.length;
const total = game.dial.keys.length;
ctx.finish(
keys === total,
keys === total
? game.successEnding
: `**${s.total} points, ${keys} of ${total} keys.** ${keys > 0 ? 'Nearly on the same wavelength.' : 'Static on the line this time.'} Play again?`,
);
return OK;
}
/** The partner of the clue-giver this round. */
export const guesserOf = (round: number): Role => otherRole(giverOf(round));
+56 -2
View File
@@ -1,8 +1,11 @@
import type { Game } from '../content/schemas';
import { continueDefuse, initDefuse } from './defuse';
import { continueBlueprint, initBlueprint, tickBlueprint } from './blueprint';
import { continueDefuse, initDefuse, tickDefuse } from './defuse';
import { continueDial, initDial } from './dial';
import { initLabyrinth } from './labyrinth';
import { continueChapter, initStory } from './story';
import { continueChapter, initStory, tickStory } from './story';
import { continueSync, initSync } from './sync';
import { continueTwin, initTwin, tickScene } from './twin';
import type { ActionResult, Ctx } from './types';
export function initEngine(game: Game): unknown {
@@ -15,6 +18,12 @@ export function initEngine(game: Game): unknown {
return initSync(game);
case 'labyrinth':
return initLabyrinth(game);
case 'dial':
return initDial(game);
case 'twin':
return initTwin();
case 'blueprint':
return initBlueprint(game);
}
}
@@ -28,6 +37,12 @@ export function totalKeys(game: Game): number {
return game.rounds.length;
case 'labyrinth':
return game.lanterns.length;
case 'dial':
return game.dial.keys.length;
case 'twin':
return game.scenes.length;
case 'blueprint':
return game.patterns.length;
}
}
@@ -42,5 +57,44 @@ export function continueGame(game: Game, ctx: Ctx): ActionResult {
return continueSync(game, ctx);
case 'labyrinth':
return { ok: false, code: 'no_stages', message: 'Nothing to continue.' };
case 'dial':
return continueDial(game, ctx);
case 'twin':
return continueTwin(game, ctx);
case 'blueprint':
return continueBlueprint(game, ctx);
}
}
/** The hard limit on elapsed + penalty time, for the games that have one. */
export function timeLimitSeconds(game: Game): number | null {
if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null;
const minutes = game.rules.timeLimitMinutes;
return minutes ? Math.round(minutes * 60) : null;
}
export function timeLimitLabel(game: Game): string | null {
if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null;
return timeLimitSeconds(game) === null ? null : (game.rules.timeLimitLabel ?? null);
}
/** Whether a room of this game has clocks that can run out with nobody touching anything. */
export function hasClock(game: Game): boolean {
return timeLimitSeconds(game) !== null || game.engine === 'defuse' || game.engine === 'twin' || game.engine === 'blueprint';
}
/** Called on a timer; returns true if the room changed. */
export function tickGame(game: Game, ctx: Ctx): boolean {
const { room } = ctx;
if (room.phase !== 'playing') return false;
if (game.engine === 'story') return tickStory(game, ctx);
const limit = timeLimitSeconds(game);
if (limit !== null && room.startedAt !== null && (ctx.now - room.startedAt) / 1000 + room.penaltySeconds >= limit) {
ctx.finish(false, "**Time's up.** The clock beat you this time. Run it back?", null, true);
return true;
}
if (game.engine === 'defuse') return tickDefuse(ctx);
if (game.engine === 'twin') return tickScene(game, ctx);
if (game.engine === 'blueprint') return tickBlueprint(game, ctx);
return false;
}
+50 -7
View File
@@ -75,15 +75,26 @@ export function analyseMaze(
grid: string[],
gates: readonly Gate[],
lanterns: readonly Point[],
): { winnable: boolean; stuck: Point[] } {
items: readonly (Point & { id: string })[] = [],
doors: readonly (Point & { needs: string })[] = [],
): { winnable: boolean; stuck: Point[]; safeCells: Set<string> } {
const [start] = findCells(grid, 'S');
if (!start) return { winnable: false, stuck: [] };
if (!start) return { winnable: false, stuck: [], safeCells: new Set() };
// One bit per lantern, then one per item.
const full = (1 << lanterns.length) - 1;
const pickups: Point[] = [...lanterns, ...items];
const itemBit = (itemId: string) => {
const i = items.findIndex((it) => it.id === itemId);
return i < 0 ? 0 : 1 << (lanterns.length + i);
};
const id = (p: Point, mask: number, safe: boolean) => `${p.x},${p.y},${mask},${safe ? 1 : 0}`;
const pickUp = (p: Point, mask: number) => {
const i = lanterns.findIndex((l) => l.x === p.x && l.y === p.y);
const i = pickups.findIndex((l) => l.x === p.x && l.y === p.y);
return i >= 0 ? mask | (1 << i) : mask;
};
/** A door stays shut until its item is carried (an unknown item never opens it). */
const doorShut = (p: Point, mask: number) =>
doors.some((d) => d.x === p.x && d.y === p.y && !(itemBit(d.needs) && mask & itemBit(d.needs)));
// `safe` tracks whether the run so far is trap-free.
type Node = { p: Point; mask: number; safe: boolean };
@@ -96,14 +107,14 @@ export function analyseMaze(
while (queue.length > 0) {
const node = queue.shift()!;
const from = id(node.p, node.mask, node.safe);
if (cellAt(grid, node.p.x, node.p.y) === 'E' && node.mask === full) {
if (cellAt(grid, node.p.x, node.p.y) === 'E' && (node.mask & full) === full) {
goals.push(from);
continue;
}
for (const dir of CLOCKWISE) {
const step = { x: node.p.x + DELTAS[dir].x, y: node.p.y + DELTAS[dir].y };
const c = cellAt(grid, step.x, step.y);
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir)) continue;
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir) || doorShut(step, node.mask)) continue;
const next: Node =
c === 'T'
? { p: start, mask: node.mask, safe: false }
@@ -129,8 +140,12 @@ export function analyseMaze(
}
const stuck = new Map<string, Point>();
for (const [nodeId, node] of seen) if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()] };
const safeCells = new Set<string>();
for (const [nodeId, node] of seen) {
if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
if (node.safe) safeCells.add(key(node.p));
}
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()], safeCells };
}
export interface LabyrinthState {
@@ -142,6 +157,10 @@ export interface LabyrinthState {
moves: number;
falls: number;
message: string;
/** Item ids carried. Kept after traps. */
inventory?: string[];
/** Doors passed at least once, as "x,y" (their openText shows only the first time). */
opened?: string[];
}
export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
@@ -153,9 +172,13 @@ export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
moves: 0,
falls: 0,
message: '',
inventory: [],
opened: [],
};
}
export const doorAt = (game: LabyrinthGame, x: number, y: number) => game.doors.find((d) => d.x === x && d.y === y);
export const facingOf = (game: LabyrinthGame, s: LabyrinthState): Dir => s.facing ?? game.explorer.startFacing;
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: MoveDir): ActionResult {
@@ -172,6 +195,14 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo
if (isWall(game.grid, next.x, next.y)) return fail('wall', "Solid stone. You can't go that way.");
if (gateBlocks(game.oneWay, s.x, s.y, dir)) return fail('gate', "The gate won't open from this side.");
const inventory = (s.inventory ??= []);
const door = doorAt(game, next.x, next.y);
if (door && !inventory.includes(door.needs)) {
// The Explorer only finds out by trying: they stay put and hear why.
s.message = door.lockedText;
return OK;
}
const flavour = game.explorerView.flavour;
const throughGate = game.oneWay.some((g) => g.x === s.x && g.y === s.y && g.dir === dir);
s.moves++;
@@ -180,6 +211,18 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo
s.facing = dir;
s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : '';
const opened = (s.opened ??= []);
if (door && !opened.includes(`${door.x},${door.y}`)) {
opened.push(`${door.x},${door.y}`);
s.message = door.openText;
}
// Items are picked up on entry, without telling the Navigator.
const item = game.items.find((it) => it.x === next.x && it.y === next.y);
if (item && !inventory.includes(item.id)) {
inventory.push(item.id);
s.message = item.explorerText;
}
const cell = cellAt(game.grid, next.x, next.y);
if (cell === 'T') {
const [start] = findCells(game.grid, 'S');
+2 -1
View File
@@ -176,7 +176,8 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
const total = game.rounds.length;
const ending =
keys === total
? `**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`
? (game.successEnding ??
`**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`)
: keys > 0
? `**${keys} out of ${total} keys.** In sync where it counts, with a few surprises left to discover. That's the fun part. 💞`
: `**No keys this time.** Which just means you have a lot of excellent conversations ahead of you. Rematch? 😏`;
+144
View File
@@ -0,0 +1,144 @@
import { otherRole, type Role } from '@two-keys/shared';
import type { TwinGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
type Scene = TwinGame['scenes'][number];
export interface TwinTap {
/** In A's coordinates (B's taps are un-mirrored first). */
x: number;
y: number;
at: number;
}
export interface TwinState {
scene: number;
status: 'ready' | 'playing' | 'won' | 'failed';
/** 0 on the first try, 1 on the retry. */
attempt: number;
startedAt: number | null;
/** Seconds taken off this scene's clock by wrong claims. */
scenePenalty: number;
/** Indexes into the scene's `changes`. */
found: number[];
taps: Partial<Record<Role, TwinTap>>;
}
const state = (ctx: Ctx) => ctx.room.engine as TwinState;
export function initTwin(): TwinState {
return { scene: 0, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} };
}
/** A row's cells. Emoji can be several code points (🐈‍⬛), so rows split on spaces, never by character. */
export const cellsOf = (row: string): string[] => row.split(' ');
export const gridA = (scene: Scene): string[][] => scene.grid.map(cellsOf);
/** B's room: A's with the changes applied. */
export function gridB(scene: Scene): string[][] {
const grid = gridA(scene);
for (const c of scene.changes) grid[c.y][c.x] = c.to;
return grid;
}
export const widthOf = (scene: Scene): number => cellsOf(scene.grid[0]).length;
/** Mirrored rooms flip x for B, both ways. */
export const mirrored = (game: TwinGame, role: Role) => role === 'B' && game.twin.mirrorB;
export const flipX = (game: TwinGame, scene: Scene, role: Role, x: number) =>
mirrored(game, role) ? widthOf(scene) - 1 - x : x;
export function sceneRemaining(game: TwinGame, s: TwinState, now: number): number {
const scene = game.scenes[s.scene];
if (s.startedAt === null) return scene.timeSeconds;
return scene.timeSeconds - (now - s.startedAt) / 1000 - s.scenePenalty;
}
/** A tap still counts while it's inside the claim window. */
export const liveTap = (game: TwinGame, tap: TwinTap | undefined, now: number): TwinTap | null =>
tap && now - tap.at <= game.twin.claimWindowSeconds * 1000 ? tap : null;
export const canRetry = (s: TwinState) => s.status === 'failed' && s.attempt === 0;
function startScene(s: TwinState, now: number) {
Object.assign(s, { status: 'playing', startedAt: now, scenePenalty: 0, found: [], taps: {} });
}
export function tapCell(game: TwinGame, ctx: Ctx, role: Role, x: number, y: number): ActionResult {
const s = state(ctx);
if (s.status !== 'playing') return fail('not_playing', 'The room is not open right now.');
if (tickScene(game, ctx)) return OK; // time ran out first
const scene = game.scenes[s.scene];
const width = widthOf(scene);
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= scene.grid.length) {
return fail('bad_cell', 'That square is off the grid.');
}
const ax = flipX(game, scene, role, x);
const change = scene.changes.findIndex((c) => c.x === ax && c.y === y);
if (change >= 0 && s.found.includes(change)) return fail('found', 'You already found that one.');
const partner = otherRole(role);
const theirs = liveTap(game, s.taps[partner], ctx.now);
if (theirs && theirs.x === ax && theirs.y === y) {
// Both tapped the same square in time: claim it.
s.taps = {};
if (change >= 0) {
s.found.push(change);
ctx.toast('all', `✅ Difference found! ${s.found.length} / ${scene.changes.length}`);
if (s.found.length === scene.changes.length) {
s.status = 'won';
ctx.addKey(scene.keyName);
}
} else {
const penalty = game.twin.wrongClaimPenaltySeconds;
s.scenePenalty += penalty;
ctx.addPenalty(penalty);
ctx.room.wrongGuesses++;
ctx.toast('all', `❌ No difference there. −${penalty}s`);
tickScene(game, ctx);
}
return OK;
}
// A different square (or none): the earlier tap expires, this one waits for the partner.
delete s.taps[partner];
s.taps[role] = { x: ax, y, at: ctx.now };
ctx.toast(partner, 'Your partner tapped a square.');
return OK;
}
/** Starts a scene, retries a failed one, or moves on. */
export function continueTwin(game: TwinGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
startScene(s, ctx.now);
return OK;
}
if (s.status === 'playing') return fail('busy', 'The clock is running!');
if (canRetry(s)) {
s.attempt = 1;
startScene(s, ctx.now);
return OK;
}
if (s.scene < game.scenes.length - 1) {
Object.assign(s, { scene: s.scene + 1, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} });
return OK;
}
const keys = ctx.room.keys.length;
const total = game.scenes.length;
ctx.finish(
keys === total,
keys === total ? game.successEnding : `**${keys} of ${total} rooms solved.** So close. The differences are still out there. Play again?`,
);
return OK;
}
/** Called on a timer and before each tap; returns true if the scene clock ran out. */
export function tickScene(game: TwinGame, ctx: Ctx): boolean {
const s = state(ctx);
if (!s || s.status !== 'playing' || sceneRemaining(game, s, ctx.now) > 0) return false;
s.status = 'failed';
s.taps = {};
ctx.toast('all', "⏰ Time's up for this room.");
return true;
}
+3 -4
View File
@@ -1,8 +1,7 @@
import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/shared';
import type { Game } from '../content/schemas';
import { engineView, gameMeta } from '../content/sanitize';
import { totalKeys } from '../engines/index';
import { timeLimitSeconds } from '../engines/story';
import { timeLimitLabel, timeLimitSeconds, totalKeys } from '../engines/index';
import type { PlayerRec, Room } from './store';
export function computeResult(room: Room, game: Game): GameResult | null {
@@ -75,8 +74,8 @@ export function buildState(
finishedAt: room.finishedAt,
penaltySeconds: room.penaltySeconds,
serverNow: now,
limitSeconds: game.engine === 'story' ? timeLimitSeconds(game) : null,
limitLabel: game.engine === 'story' ? (game.rules.timeLimitLabel ?? null) : null,
limitSeconds: timeLimitSeconds(game),
limitLabel: timeLimitLabel(game),
},
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
views,
+3 -1
View File
@@ -1,11 +1,13 @@
import { loadAllFiles } from '../content/loader';
import { describeGame } from '../content/validate';
const files = loadAllFiles();
let failed = 0;
for (const { file, game, errors } of files) {
if (errors.length === 0 && game) {
console.log(`✔ ${file} (${game.engine})`);
const head = `${game.difficulty}★ ${String(game.estimatedMinutes).padStart(2)}m ${game.engine.padEnd(9)}`;
console.log(`✔ ${head} ${file.padEnd(32)} ${describeGame(game)}`);
} else {
failed++;
console.error(`✖ ${file}`);
+63 -10
View File
@@ -12,8 +12,10 @@ import {
type Role,
} from '@two-keys/shared';
import type { Game } from '../content/schemas';
import { defuseAction, tickDefuse } from '../engines/defuse';
import { continueGame, initEngine } from '../engines/index';
import { paint, peek, submitCanvas } from '../engines/blueprint';
import { defuseAction, levelReady } from '../engines/defuse';
import { giveClue, lockGuess, moveNeedle } from '../engines/dial';
import { continueGame, hasClock, initEngine, tickGame } from '../engines/index';
import { moveExplorer } from '../engines/labyrinth';
import {
agreeAccusation,
@@ -21,10 +23,10 @@ import {
requestHint,
submitAccusation,
submitLock,
tickStory,
togglePin,
} from '../engines/story';
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
import { tapCell } from '../engines/twin';
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
import { cleanCode } from '../rooms/codes';
import { buildState } from '../rooms/state';
@@ -44,7 +46,8 @@ const schemas = {
pin: z.object({ clueId: z.string().max(100), ...asRole }),
propose: z.object({ answers: z.record(z.string()), ...asRole }),
defuse: z.object({
moduleId: z.enum(DEFUSE_MODULES),
// `wires-1`, `wires-2`... when a level has more than one of a module.
moduleId: z.string().regex(new RegExp(`^(${DEFUSE_MODULES.join('|')})(-\d)?$`)),
action: z.discriminatedUnion('type', [
z.object({ type: z.literal('cut'), index: z.number().int() }),
z.object({ type: z.literal('tap') }),
@@ -70,6 +73,16 @@ const schemas = {
.transform((d): MoveDir => (d.length > 1 ? (d[0] as RelDir) : (d as MoveDir))),
...asRole,
}),
dialClue: z.object({ text: z.string().max(200), ...asRole }),
dialValue: z.object({ value: z.number().finite(), ...asRole }),
twinTap: z.object({ x: z.number().int(), y: z.number().int(), ...asRole }),
paint: z.object({
cells: z
.array(z.object({ x: z.number().int(), y: z.number().int(), color: z.string().max(2).nullable() }))
.min(1)
.max(256),
...asRole,
}),
notepad: z.object({ text: z.string() }),
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
view: z.object({ label: z.string().max(80) }),
@@ -133,7 +146,8 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
schema: S,
phase: Phase | null,
handler: (args: ActionArgs<z.infer<S>>) => ActionResult | void,
opts: { echo?: boolean } = {},
/** `echo: false` skips the sender; `silent` tells nobody (state that no one else may see). */
opts: { echo?: boolean; silent?: boolean } = {},
) {
socket.on(event, (payload: unknown) => {
const parsed = schema.safeParse(payload ?? {});
@@ -152,7 +166,7 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
room.lastActivity = ctx.now;
flushToasts(room, ctx);
broadcast(room, opts.echo === false ? socket : undefined);
if (!opts.silent) broadcast(room, opts.echo === false ? socket : undefined);
});
}
@@ -270,6 +284,41 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
game.engine === 'labyrinth' ? moveExplorer(game, ctx, role, data.dir) : fail('wrong_engine', 'No maze here.'),
);
on('level:ready', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'defuse' ? levelReady(game, ctx, role) : fail('wrong_engine', 'Nothing to get ready for.'),
);
const noDial = fail('wrong_engine', 'No dial here.');
on('dial:clue', schemas.dialClue, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'dial' ? giveClue(game, ctx, role, data.text) : noDial,
);
// Throttled by the client, kept for a refresh, and never broadcast: the clue-giver mustn't see it move.
on(
'dial:needle',
schemas.dialValue,
'playing',
({ game, data, role, ctx }) => (game.engine === 'dial' ? moveNeedle(game, ctx, role, data.value) : noDial),
{ silent: true },
);
on('dial:lock', schemas.dialValue, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'dial' ? lockGuess(game, ctx, role, data.value) : noDial,
);
on('twin:tap', schemas.twinTap, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'twin' ? tapCell(game, ctx, role, data.x, data.y) : fail('wrong_engine', 'No rooms here.'),
);
const noCanvas = fail('wrong_engine', 'No canvas here.');
on('blueprint:paint', schemas.paint, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'blueprint' ? paint(game, ctx, role, data.cells) : noCanvas,
);
on('blueprint:peek', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'blueprint' ? peek(game, ctx, role) : noCanvas,
);
on('blueprint:submit', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'blueprint' ? submitCanvas(game, ctx, role) : noCanvas,
);
// ----- presence -----
on(
@@ -301,14 +350,18 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
});
});
// A defuse clock or a story time limit can run out with nobody touching anything.
// Clocks (a defuse level, a twin room, a blueprint picture, a game's time limit) can run out
// with nobody touching anything. A peek ending also needs a fresh state for the Architect.
const peekEnds = new Map<string, number>();
const ticker = setInterval(() => {
for (const room of store.rooms.values()) {
const game = games.get(room.gameId);
if (room.phase !== 'playing' || !game) continue;
if (game.engine !== 'defuse' && !(game.engine === 'story' && game.rules.timeLimitMinutes)) continue;
if (room.phase !== 'playing' || !game || !hasClock(game)) continue;
const ctx = createCtx(room);
if (game.engine === 'defuse' ? tickDefuse(ctx) : tickStory(game, ctx)) {
const peekUntil = game.engine === 'blueprint' ? (room.engine as { peekUntil: number | null }).peekUntil : null;
const peekOver = peekUntil !== null && ctx.now >= peekUntil && peekEnds.get(room.code) !== peekUntil;
if (peekOver) peekEnds.set(room.code, peekUntil);
if (tickGame(game, ctx) || peekOver) {
flushToasts(room, ctx);
broadcast(room);
}
+11 -7
View File
@@ -7,16 +7,20 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
import { games, getGame, labyrinth, sync } from './helpers';
describe('content', () => {
it('loads all 18 games with no validation errors', () => {
it('loads all 40 games with no validation errors', () => {
const files = loadAllFiles();
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
expect(files).toHaveLength(18);
expect(files).toHaveLength(40);
const engines = [...games.values()].map((g) => g.engine);
expect(Object.fromEntries(['story', 'defuse', 'sync', 'labyrinth'].map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
story: 11,
defuse: 2,
sync: 2,
labyrinth: 3,
const ids = ['story', 'defuse', 'sync', 'labyrinth', 'dial', 'twin', 'blueprint'];
expect(Object.fromEntries(ids.map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
story: 21,
defuse: 3,
sync: 3,
labyrinth: 4,
dial: 3,
twin: 3,
blueprint: 3,
});
});
+689
View File
@@ -0,0 +1,689 @@
// 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();
}
});
});