V1.3.0
Game pack 3: 22 new games (19-40), 40 in total. - New engines: dial (Wavelength / Mind Meld), twin (Twin Rooms / Spot the Difference, incl. mirror mode) and blueprint (Describe & Draw, incl. peeks, overlap scoring and a rotated Builder canvas) - Labyrinth: items and doors (Hedge Maze) - Defuse: manualFrom, roles swapping every level behind a Ready card, and duplicate wire modules (Tick Tock: Switch) - Generic time limits for the new engines; sync/defuse successEnding - Library: engine-label chips and filters; Noto Color Emoji font - Validation: Appendix A checks ported, with a per-game summary - Quote comma/colon-split labels in five new YAMLs (wording unchanged) - Tests for all new engines and sanitized payloads of all 22 games - Docs: CLAUDE.md, GAMES.md (pack 3 spoiler guide), How to Play Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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? 😏`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user