diff --git a/CLAUDE.md b/CLAUDE.md index 7d7ccf6..43123a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ A website where **two players (a couple)** play co-op mystery games together. Ea **Core rule of every game: Player A and Player B never see the same clue set.** Every lock needs info from both. -### Game library (8 games, 4 engines) +### Game library (18 games, 4 engines) | # | Game | Category | Engine | File | |---|------|----------|--------|------| @@ -21,6 +21,16 @@ A website where **two players (a couple)** play co-op mystery games together. Ea | 6 | Tick Tock Together | Co-op Defuse | `defuse` | `tick-tock-together.yaml` | | 7 | In Sync | Couples Connection | `sync` | `in-sync.yaml` | | 8 | Labyrinth of Lanterns | Co-op Navigation | `labyrinth` | `labyrinth-of-lanterns.yaml` | +| 9 | Dead Air | Murder Mystery | `story` | `dead-air.yaml` | +| 10 | The Masquerade at Montague House | Murder Mystery | `story` | `masquerade-montague.yaml` | +| 11 | The Kalahari Expedition | Murder Mystery | `story` | `kalahari-expedition.yaml` | +| 12 | The Alchemist's Vault | Escape Room | `story` | `alchemists-vault.yaml` | +| 13 | Submerged: Deep Lab Nereid | Escape Room | `story` | `deep-lab-nereid.yaml` | +| 14 | The Kestrel Bank Job | Escape Room | `story` | `kestrel-heist.yaml` | +| 15 | Tick Tock: Overtime | Co-op Defuse | `defuse` | `tick-tock-overtime.yaml` | +| 16 | In Sync: Deep Cuts | Couples Connection | `sync` | `in-sync-deep-cuts.yaml` | +| 17 | The Drowned Catacombs | Co-op Navigation | `labyrinth` | `drowned-catacombs.yaml` | +| 18 | The Mirror Maze | Co-op Navigation | `labyrinth` | `mirror-maze.yaml` | ## 2. Tech stack @@ -90,16 +100,20 @@ engine: story | defuse | sync | labyrinth category: murder-mystery | escape-room | co-op title, tagline: string estimatedMinutes: number -difficulty: 1-3 +difficulty: 1-5 players: { min: 2, max: 2 } theme: { primary, accent, background, headingFont } roles: { A: {name, blurb}, B: {name, blurb} } intro: markdown ``` -### `story` engine (games 1–5) +### `story` engine (games 1–5, 9–14) ```yaml +rules: # optional + hintPenaltySeconds: number # default 120 + timeLimitMinutes: number # hard limit on elapsed + penalties; running out ends the game + timeLimitLabel: string # what the countdown is called, e.g. "Water level" suspects: # optional (murder mysteries) - { id, name, role, description } chapters: @@ -129,6 +143,7 @@ finale: lock: Lock # same shape as a chapter lock (no keyName needed) # both: successEnding: markdown + failEnding: markdown # optional on escape: shown when the time limit runs out ``` Use a zod discriminated union on `finale.type`. @@ -163,7 +178,8 @@ See each YAML for its schema. Summaries in §6. - State: `currentChapter`, `keys[]`, `hintsUsed{lockId: tier}`, `wrongAttempts{}`, `startedAt`, `penaltySeconds`, `finaleAttempts`. - Chapter view: narrative on top → my clues → lock panel → key ring. - Correct answer → both players see the lock spring open (animation), `successText` reveals, the key flies onto the key ring, **Continue** button (either player can press) advances both. -- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty. Confirm dialog before revealing tier 3 ("This basically solves it. Sure?"). +- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty (or the game's `rules.hintPenaltySeconds`). Confirm dialog before revealing tier 3 ("This basically solves it. Sure?"). +- **Time limit:** games with `rules.timeLimitMinutes` show a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game with `failEnding` (plus `reveal` for an accusation). - **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`. - For `escape` finales, it's just the last lock; on success show `successEnding`. @@ -173,6 +189,8 @@ See each YAML for its schema. Summaries in §6. - Implement the fixtures in the YAML as unit tests. They must pass. - Strikes: 3 per level. Timer per level. Clock speeds up 25% after each strike. - Each level cleared = 1 key. Fail a level → retry with a new seed (no key lost). +- **Rulesets:** each manual's rules are code in `engines/defuse.ts`, picked by the YAML's `ruleset` (`classic` = Tick Tock Together, `overtime` = Tick Tock: Overtime). A new manual needs a new ruleset. +- **Overtime** adds indicator lights, a per-level `strikesOverride`, and three modules: `passwords` (cycle five letter columns, submit the one listed word they spell), `morse` (read a blinking light, tune 3.500–3.600 MHz, transmit) and `simon` (press the mapped colour for every flash; the mapping moves with the strike count). The Technician gets the Morse pattern only as a light, and only the flashes of the current stage. ### 6.3 Signal clues (game 5) `signal: "./-.-./..../---"`: `.` = 250ms on, `-` = 750ms on, 250ms gap between symbols, `/` = 1000ms gap between letters, 3000ms pause, then loop. Render as a glowing circle. Add a "Replay" button. Never render the dot/dash text to that player (they must read the light). @@ -182,7 +200,10 @@ See each YAML for its schema. Summaries in §6. - `match` rounds: both pick from the same options. Match = point. - `predict` rounds: the `subject` role answers about themselves, the other guesses. Match = point. - 3+ points in a round = key. Failing a round lets them replay it once with fresh questions. -- Draw questions randomly from the bank, never repeating within a session. +- Draw questions randomly from the bank, never repeating within a session. (When a `predict` bank is too small for that, a question may come back once, asked about the other partner.) +- `telepathy` rounds (Deep Cuts): no options, both type one word. Same word = point; case, spaces, punctuation and a trailing S are ignored. +- `rank` rounds (Deep Cuts): the subject (cycled through `subjectPattern`) orders four items, the partner predicts. Point only if **both** the top and the bottom match. +- A round can set its own `pointsForKey`. ### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml` - Player B (**Explorer**) sees only their current cell: four direction buttons (disabled where wall), glyph on the floor if any, key if present. @@ -190,6 +211,10 @@ See each YAML for its schema. Summaries in §6. - Traps are invisible to the Explorer. Stepping on one sends them back to Start (keys kept) and adds 1:00 penalty. - Each lantern key collected = key. Exit only opens with all 3. - Server validates every move. +- `lanternsRequired` sets how many lanterns the maze has (default 3). +- `oneWay` gates (Drowned Catacombs): passable from `{x, y}` in `dir` only. The Navigator sees an arrow; the Explorer sees the way back as a gate, not a wall. +- `explorer.compass: relative` (Mirror Maze): the Explorer moves forward/left/right/back from the way they face (their last step), and never sees N/E/S/W. +- `navigatorView.mapTransform: mirror-x` (Mirror Maze): the Navigator's map, and everything on it, is served flipped left to right. ## 7. Socket events @@ -197,8 +222,8 @@ Client → server: `room:create {gameId}`, `room:join {code, playerToken?}`, `room:pickRole {role}`, `room:ready`, `lock:submit {lockId, value}`, `hint:request {lockId}`, `chapter:continue`, `finale:propose {answers}`, `finale:agree`, `finale:submit`, -`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId}`, -`maze:move {dir}`, `notepad:update {text}`, `ping {emoji}` +`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId | text | order}`, +`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` with a relative compass), `notepad:update {text}`, `ping {emoji}` Server → client: `room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`, @@ -216,7 +241,7 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e - Vibe: **date-night cozy meets noir**. Dark mode default, warm candlelit accents, per-game theme from YAML `theme`. - Fonts: Playfair Display / Inter / Caveat (handwritten notes) / JetBrains Mono (logs, maps) from Google Fonts. -- Home page: hero "Two players. One mystery. Every key needs both of you." + game library grid (cards show category chip, time, difficulty 🔑🔑🔑). +- Home page: hero "Two players. One mystery. Every key needs both of you." + game library grid (cards show category chip, time, difficulty 🔑 out of 5). - **Mobile-first.** Most couples will play on two phones. Clue cards stack vertically; lock panel is sticky at the bottom; evidence locker is a bottom sheet. - Accessibility: all locks keyboard-operable, `prefers-reduced-motion` respected, colorblind-safe (defuse wires also show their color name as text label). - Role color-coding: A = warm (amber), B = cool (teal), shown as a badge on every clue card ("Only you can see this"). @@ -226,9 +251,9 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e `npm run validate-content` must check: - Every YAML parses and passes its engine's zod schema - Story: every chapter has ≥1 clue for A **and** ≥1 for B; every lock has ≥1 answer and exactly 3 hints; finale answers exist in options -- Defuse: all fixtures solve to their expected solution -- Labyrinth: grid rows equal length; exactly one S and E; exactly 3 K; every K and E reachable from S **without stepping on a trap** (BFS) -- Sync: ≥15 questions per round type +- Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined +- Labyrinth: grid rows equal length; exactly one S and E; exactly `lanternsRequired` K; every K and E reachable from S **without stepping on a trap** (BFS, respecting one-way gates); no cell where a gate leaves the Explorer stuck for good +- Sync: ≥15 questions per round type in use Run it in CI and as a pre-start step. ## 11. Build phases (do in order, commit after each) @@ -242,7 +267,7 @@ Run it in CI and as a pre-start step. ## 12. Acceptance criteria -- [ ] Two phones can play any of the 8 games start to finish +- [ ] Two phones can play any of the 18 games start to finish - [ ] Player A cannot find B's clues or any answer via DevTools / network tab - [ ] Refreshing mid-game rejoins the same role and progress - [ ] All answers listed in the YAML are accepted; normalizer handles spaces/case/punctuation diff --git a/GAMES.md b/GAMES.md index 90171d9..89876bd 100644 --- a/GAMES.md +++ b/GAMES.md @@ -96,3 +96,91 @@ Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3 #..T....#E# ########### ``` + +--- + +## 9. Dead Air (murder mystery · 30 min · 🔑🔑🔑) +Hint penalty +3:00. Two accusation attempts. + +| Ch | Lock | Answer | +|----|------|--------| +| 1 | Ghost Signal | `GHOST SIGNAL` | +| 2 | The Last Cup | `23:06` | +| 3 | The Studio Door | Kyle | +| 4 | The Midnight Announcement | `THE SALE IS FORGED` | + +**Accusation:** `marisa` · `honey` · `forged`. + +## 10. The Masquerade at Montague House (murder mystery · 40 min · 🔑🔑🔑🔑) +Hint penalty +3:00. **One** accusation attempt. + +| Ch | Lock | Answer | +|----|------|--------| +| 1 | Who Wore What | Odette | +| 2 | The Raven in the Conservatory | Ivy | +| 3 | The Missing Dagger | `11` | +| 4 | Julian's Confession | `MARSH WAS INNOCENT` | + +**Accusation:** `ivy` · `raven` · `dagger` · `father`. + +## 11. The Kalahari Expedition (murder mystery · 60 min · 🔑🔑🔑🔑🔑) +Hint penalty +4:00. **75-minute limit.** **One** accusation attempt. + +| Ch | Lock | Answer | +|----|------|--------| +| 1 | Star Trails | `02:00` | +| 2 | Footprints | `11` | +| 3 | The Watch Rota | Pienaar | +| 4 | Samuel's Note | `HE SMELLED OF PIPE SMOKE` | +| 5 | The Diamonds | `KIMBERLEY` (the Big Hole) | +| 6 | The Unsent Telegram | `SALTED` | + +**Accusation:** `dunmore` · `same` · `struck` · `fraud`. + +## 12. The Alchemist's Vault (escape room · 25 min · 🔑🔑🔑) +Hint penalty +3:00. + +| Lock | Answer | +|------|--------| +| The Five Flasks | `GENIUS` | +| The Philosopher's Stone | `25` | +| The Dotted Plaque | `80` | +| Door (finale) | `234` | + +## 13. Submerged: Deep Lab Nereid (escape room · 35 min · 🔑🔑🔑🔑) +Roles: **Pilot** (A) / **Diver** (B). Hint penalty +3:00. **40-minute limit**, shown as the water level. + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| Pressure valves | Warning lights: ON ON ON off ON | Which lights each valve flips | `145` | +| Breathing gas | 210 L at exactly 21% oxygen | Tank X 36%, Tank Y 15% | `60` | +| Find the pod | 300 m east, then 200 m south | Chart: lab at E5, 100 m squares | `H7` | +| Launch word (finale) | Keypresses 555·4·3·66·55·9·222 | The rewired keypad | `SURFACE` | + +## 14. The Kestrel Bank Job (escape room · 50 min · 🔑🔑🔑🔑🔑) +Roles: **Inside Man** (A) / **The Wheel** (B). Hint penalty +4:00. **55-minute limit.** + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| Staff door | Worn keys 0, 2, 6 (0 most) | Codes are family dates DDMM; Mia born 20 Jun | `2006` | +| Laser hall | Column bit values 16/8/4/2/1 | Row masks 21, 12, 25, 17, 8 | `4` | +| Guards | Guard 1 from 22:03 every 7, Guard 2 from 22:05 every 11 | Guard 3 from 22:02 every 6; need 4 clear minutes | `22:33` | +| Vault dial | Sons' ages in Roman numerals: VII, XIX, XII | Atbash note: "my sons' ages, eldest first" | `19-12-07` | +| Box number | SQL query + CLIENTS table | BOXES table | `317` | +| Getaway (finale) | `MUTLEETSTHODIREAEP`, "rails = floors" | 3 floors; how a rail fence works | `OLD PIER` | + +## 15. Tick Tock: Overtime (co-op defuse · 20 min · 🔑🔑🔑🔑) +The sequel: its own manual (`ruleset: overtime`), indicator lights, and three new modules (Passwords, Morse Light, Say It Back). +4 levels = 4 keys; the last level has all six modules and only 2 strikes. Fixtures in the YAML are the unit tests. + +## 16. In Sync: Deep Cuts (couples connection · 25 min · 🔑🔑🔑) +Round 1 telepathy (type the same word, 3/5). Rounds 2 and 3 predict about A, then B (4/5). +Round 4 ranking, subject alternating A/B (top **and** bottom must match, 3/5). One replay per round. + +## 17. The Drowned Catacombs (co-op navigation · 20 min · 🔑🔑🔑) +3 lanterns, 5 traps (1:00 each), and three one-way flood gates: +(5,9) east only · (5,11) north only · (7,8) north only. + +## 18. The Mirror Maze (co-op navigation · 30 min · 🔑🔑🔑🔑🔑) +4 lanterns, 7 traps (1:30 each). The Navigator's map is served **mirrored left to right**; the Explorer has no compass +(forward/left/right/back from the way they face, starting east). Two glyphs (crescent, star) appear twice. diff --git a/README.md b/README.md index 7c3a812..8118f72 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Co-op mystery games for couples. Two players, different clues, every lock needs |------|------------| | `CLAUDE.md` | Full build spec: stack, architecture, security, engines, socket events, design, phases | | `GAMES.md` | Spoiler guide with every answer (don't let your partner read this 👀) | -| `content/games/*.yaml` | All 8 games: 3 murder mysteries, 2 escape rooms, defuse, In Sync, labyrinth | +| `content/games/*.yaml` | All 18 games: 6 murder mysteries, 5 escape rooms, 2 defuse, 2 In Sync, 3 labyrinths | | `package.json` | Root workspace config | ## Adding a new game later diff --git a/client/package.json b/client/package.json index c58469e..7eb2aaa 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/client", - "version": "1.0.1", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/GameCard.tsx b/client/src/components/GameCard.tsx index 8ca513c..5e79d48 100644 --- a/client/src/components/GameCard.tsx +++ b/client/src/components/GameCard.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared'; import { getBest, saveSession } from '../lib/session'; import { request } from '../lib/socket'; -import { CATEGORY_LABEL, difficultyKeys, themeVars } from '../lib/theme'; +import { CATEGORY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme'; import { pushToast } from '../lib/toasts'; import { RoleBadge, Sheet } from './ui'; @@ -39,9 +39,9 @@ export function GameCard({ game, className = '' }: { game: GameSummary; classNam
{CATEGORY_LABEL[game.category]} ⏱ {game.estimatedMinutes} min - + {difficultyKeys(game.difficulty)} - {difficultyKeys(3 - game.difficulty)} + {difficultyKeys(MAX_DIFFICULTY - game.difficulty)}

{game.title}

diff --git a/client/src/components/HintButton.tsx b/client/src/components/HintButton.tsx index 828a2f0..4b2db0e 100644 --- a/client/src/components/HintButton.tsx +++ b/client/src/components/HintButton.tsx @@ -1,15 +1,16 @@ import { useState } from 'react'; -import type { LockView } from '@two-keys/shared'; +import { formatClock, type LockView } from '@two-keys/shared'; import type { Send } from '../lib/socket'; import { Sheet } from './ui'; -/** Hints unlock in order, cost +2:00 each, and both players see them. */ +/** Hints unlock in order, cost penalty time each, and both players see them. */ export function HintButton({ lock, send }: { lock: LockView; send: Send }) { const [open, setOpen] = useState(false); const [confirming, setConfirming] = useState(false); const used = lock.hints.length; const left = lock.hintsTotal - used; const nextIsLast = used === lock.hintsTotal - 1; + const cost = `+${formatClock(lock.hintPenaltySeconds)}`; const reveal = () => { setConfirming(false); @@ -29,7 +30,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) { { setOpen(false); setConfirming(false); }} title="Hints">

- Hints go from a gentle nudge to nearly the answer. Each one adds +2:00 to + Hints go from a gentle nudge to nearly the answer. Each one adds {cost} to your time, and you both see it.

    @@ -53,7 +54,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) { ) : ( )} diff --git a/client/src/components/Timer.tsx b/client/src/components/Timer.tsx index ff4e40a..ffcef91 100644 --- a/client/src/components/Timer.tsx +++ b/client/src/components/Timer.tsx @@ -1,12 +1,36 @@ import { formatClock, type RoomState } from '@two-keys/shared'; import { useNow } from '../lib/hooks'; -/** Elapsed time plus penalties. Runs locally between server updates. */ +/** Elapsed time plus penalties, or a countdown when the game has a time limit. Runs locally between server updates. */ export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) { const now = useNow(1000); if (timer.startedAt === null) return null; const serverNow = timer.serverNow + (now - receivedAt); const elapsed = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000; + + if (timer.limitSeconds !== null) { + const used = elapsed + timer.penaltySeconds; + const left = Math.max(0, timer.limitSeconds - used); + const label = timer.limitLabel ?? 'Time left'; + return ( +
    +
    + {label} + + {formatClock(left)} + +
    + {/* Fills up as the time (and every hint penalty) runs out. */} +
    +
    +
    +
    + ); + } + return (
    = { yellow: '#facc15', green: '#22c55e', white: '#f5f5f4', + black: '#0c0a09', }; const darkText = (color: string) => color === 'yellow' || color === 'white'; @@ -98,7 +104,7 @@ function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction {module.wires.map((color, i) => (
  1. {i + 1} - + {color} +
    + {letters[rows[i]]} +
    + + + {rows[i] + 1}/{letters.length} + +
  2. + ))} +
    + + + ); +} + +/** A blinking light and a dial. The pattern is only ever shown as light. */ +function Morse({ module, act }: { module: MorseModuleView; act: (a: DefuseAction) => void }) { + const [khz, setKhz] = useState(module.minKhz); + const tune = (by: number) => setKhz((f) => Math.min(module.maxKhz, Math.max(module.minKhz, f + by))); + const mhz = (khz / 1000).toFixed(3); + const steps: [string, number][] = [['−10', -10], ['−1', -1], ['+1', 1], ['+10', 10]]; + + return ( + + {!module.solved && } +
    + {mhz} MHz +
    + setKhz(Number(e.target.value))} + aria-label="Frequency dial" + aria-valuetext={`${mhz} megahertz`} + /> +
    + {steps.map(([label, by]) => ( + + ))} +
    + +
    + ); +} + +/** Pads that flash a sequence, then take the answer. Every pad carries its colour name. */ +function Simon({ module, act }: { module: SimonModuleView; act: (a: DefuseAction) => void }) { + const [lit, setLit] = useState(null); + const flashes = module.flashes.join(','); + + useEffect(() => { + if (module.solved) return setLit(null); + const sequence = flashes.split(','); + let timer: ReturnType; + let i = 0; + // Each flash 600ms on, 300ms off, then a long pause before the sequence repeats. + const show = () => { + setLit(sequence[i]); + timer = setTimeout(hide, 600); + }; + const hide = () => { + setLit(null); + i = (i + 1) % sequence.length; + timer = setTimeout(show, i === 0 ? 1800 : 300); + }; + timer = setTimeout(show, 800); + return () => clearTimeout(timer); + }, [flashes, module.solved]); + + return ( + +
    + Stage {module.stage} of {module.stages} + {module.entered} of {module.flashes.length} pressed +
    +
    + {module.colors.map((color) => ( + + ))} +
    +
    + ); +} + function Device({ view, send }: { view: TechnicianView; send: Send }) { if (!view.device) return null; const act = (moduleId: string) => (action: DefuseAction) => send('defuse:action', { moduleId, action }); - const { serial, batteries, modules } = view.device; + const { serial, batteries, indicators, modules } = view.device; return (
    @@ -204,16 +337,41 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) { {batteries} {'🔋'.repeat(batteries)}
    + {indicators.length > 0 && ( +
    +
    Indicators
    +
      + {indicators.map((i) => ( +
    • + + {i.label} {i.lit ? 'lit' : 'unlit'} +
    • + ))} +
    +
    + )} - {modules.map((m) => - m.id === 'wires' ? ( - - ) : m.id === 'button' ? ( - - ) : ( - - ), - )} + {modules.map((m) => { + switch (m.id) { + case 'wires': + return ; + case 'button': + return ; + case 'keypad': + return ; + case 'passwords': + return ; + case 'morse': + return ; + case 'simon': + return ; + } + })} ); } @@ -250,7 +408,7 @@ export function DefuseGame({ view, state, receivedAt, send }: Props) {
    💥

    Boom.

    - {view.failReason === 'time' ? 'The clock ran out.' : 'Three strikes.'} Good thing it was only confetti. + {view.failReason === 'time' ? 'The clock ran out.' : `${view.maxStrikes} strikes.`} Good thing it was only confetti.

    You keep your keys. A fresh device is waiting.

    - ))} -
    🧭
    + {controls.map((d) => { + const gate = view.gates.includes(d.dir); + const blocked = gate ? " (gate, won't open from this side)" : ' (wall)'; + return ( + + ); + })} +
    {relative ? '👣' : '🧭'}

    Tell {navigatorName} what's on the floor and which ways are open. There are traps you can't see. Wait for directions! + {relative && ' You have no compass: every step turns you to face the way you just went.'}

    ); diff --git a/client/src/engines/SyncGame.tsx b/client/src/engines/SyncGame.tsx index 9d62342..792aead 100644 --- a/client/src/engines/SyncGame.tsx +++ b/client/src/engines/SyncGame.tsx @@ -1,5 +1,6 @@ +import { useState, type FormEvent, type ReactNode } from 'react'; import { motion } from 'framer-motion'; -import type { Role, RoomState, SyncView } from '@two-keys/shared'; +import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared'; import { otherRole } from '@two-keys/shared'; import { RoleBadge } from '../components/ui'; import type { Send } from '../lib/socket'; @@ -34,7 +35,7 @@ function Marks({ view }: { view: SyncView }) { } /** One answer card that flips over when both answers are in. */ -function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: string; delay: number }) { +function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: ReactNode; delay: number }) { return (
    void }) { + const [text, setText] = useState(''); + const submit = (e: FormEvent) => { + e.preventDefault(); + if (text.trim()) onSubmit(text.trim()); + }; + return ( +
    + setText(e.target.value)} + maxLength={40} + autoComplete="off" + autoCapitalize="none" + spellCheck={false} + placeholder="One word" + aria-label="Your word" + /> + +
    + ); +} + +/** Ranking: tap the items in order, most to least. Tap a placed one to take it back out. */ +function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) { + const [order, setOrder] = useState([]); + const toggle = (i: number) => setOrder((o) => (o.includes(i) ? o.filter((x) => x !== i) : [...o, i])); + return ( +
    +
    + {items.map((item, i) => { + const place = order.indexOf(i); + return ( + + ); + })} +
    +
    + + +
    +
    + ); +} + export function SyncGame({ view, state, role, send }: Props) { const roles = state.game.roles; const partnerName = roles[otherRole(role)].name; @@ -108,8 +180,33 @@ export function SyncGame({ view, state, role, send }: Props) { const q = view.question; if (!q) return null; - const predict = view.round.type === 'predict'; + const { type } = view.round; const subjectName = view.round.subject ? roles[view.round.subject].name : ''; + const answered = view.myAnswer !== null; + + let instruction: string; + if (type === 'match') instruction = 'Pick what you think your partner will pick.'; + else if (type === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.'; + else if (type === 'rank') { + instruction = view.iAmSubject + ? "This one's about you. Tap them in order, most to least. Be honest!" + : `Tap them in the order ${subjectName} would, most to least. The top and the bottom have to match.`; + } else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`; + + /** An answer as the reveal cards show it. */ + const show = (answer: SyncAnswer): ReactNode => { + if (typeof answer === 'string') return answer; + if (typeof answer === 'number') return q.options[answer]; + return ( +
      + {answer.map((item, place) => ( +
    1. + {place + 1}. {q.options[item]} +
    2. + ))} +
    + ); + }; return (
    @@ -120,39 +217,39 @@ export function SyncGame({ view, state, role, send }: Props) { Question {view.questionNumber} of {view.questionsPerRound}

    {q.text}

    - {view.status === 'question' && ( -

    - {!predict - ? 'Pick what you think your partner will pick.' - : view.iAmSubject - ? "This one's about you. Answer honestly!" - : `Guess what ${subjectName} will say.`} -

    - )} + {view.status === 'question' &&

    {instruction}

    }
    {view.status === 'question' && ( <> -
    - {q.options.map((option, i) => ( - - ))} -
    + {answered && type !== 'match' && type !== 'predict' ? ( +
    {show(view.myAnswer!)}
    + ) : type === 'telepathy' ? ( + send('sync:answer', { questionId: q.id, text })} /> + ) : type === 'rank' ? ( + send('sync:answer', { questionId: q.id, order })} /> + ) : ( +
    + {q.options.map((option, i) => ( + + ))} +
    + )}

    - {view.myAnswer !== null + {answered ? couch ? `Locked in. Pass the device to ${partnerName}.` : `Locked in. Waiting for ${partnerName}…` @@ -166,8 +263,8 @@ export function SyncGame({ view, state, role, send }: Props) { {view.status === 'reveal' && view.reveal && (

    - - + +
    = { A: 'text-roleA', B: 'text-roleB export const ROLE_BORDER: Record = { A: 'border-roleA/60', B: 'border-roleB/60' }; export const ROLE_BG: Record = { A: 'bg-roleA/15', B: 'bg-roleB/15' }; -export const difficultyKeys = (n: number) => '🔑'.repeat(n); +export const MAX_DIFFICULTY = 5; +export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n)); diff --git a/client/src/pages/Lobby.tsx b/client/src/pages/Lobby.tsx index ed65432..e2f4379 100644 --- a/client/src/pages/Lobby.tsx +++ b/client/src/pages/Lobby.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import type { Role, RoomState } from '@two-keys/shared'; import { Markdown, RoleBadge } from '../components/ui'; import { socket } from '../lib/socket'; -import { CATEGORY_LABEL, ROLE_BORDER, difficultyKeys } from '../lib/theme'; +import { CATEGORY_LABEL, MAX_DIFFICULTY, ROLE_BORDER, difficultyKeys } from '../lib/theme'; function ShareBox({ code }: { code: string }) { const [copied, setCopied] = useState(false); @@ -51,7 +51,8 @@ export function Lobby({ state }: { state: RoomState }) {
    {CATEGORY_LABEL[game.category]} ⏱ {game.estimatedMinutes} min - {difficultyKeys(game.difficulty)} + {difficultyKeys(game.difficulty)} + {game.timeLimitMinutes !== null && ⏳ {game.timeLimitMinutes} min limit}

    {game.title}

    {game.tagline}

    diff --git a/content/games/alchemists-vault.yaml b/content/games/alchemists-vault.yaml new file mode 100644 index 0000000..642764b --- /dev/null +++ b/content/games/alchemists-vault.yaml @@ -0,0 +1,208 @@ +id: alchemists-vault +engine: story +category: escape-room +title: The Alchemist's Vault +tagline: Prague, 1602. The alchemist's cellar. The door only opens for those who understand the elements. +estimatedMinutes: 25 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#2d5a27", accent: "#d4af37", background: "#0b100a", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Apprentice + blurb: You're at the workbench. Flasks, scales, plaques and the vault door. + B: + name: The Scribe + blurb: You're in the library alcove. Tables, charts, and the master's books. +intro: | + You came to the old alchemist's house to deliver a package. The cellar door swung shut behind you + and a bar dropped across it. + + A brass speaking-tube carries your voices between the workbench and the library alcove. + Carved above the vault door: + + > *"Gold is not made. It is understood. — Master Kelley"* + +chapters: + - id: ch1 + title: The Five Flasks + narrative: | + Five sealed flasks stand on the workbench, each etched with a number and a colour of flame. + A wooden cabinet beside them has a six-letter word lock. + clues: + A: + - id: a1-flasks + title: The five flasks (left to right as they stand) + type: table + body: | + | Flask | Etched number | Flame colour | + |-------|---------------|--------------| + | 1 | 92 | Green | + | 2 | 7 | Orange | + | 3 | 16 | Violet | + | 4 | 32 | Red | + | 5 | 53 | Yellow | + B: + - id: b1-table + title: Master Kelley's table of elements (a page) + type: table + body: | + | No. | Symbol | No. | Symbol | No. | Symbol | + |-----|--------|-----|--------|-----|--------| + | 6 | C | 26 | Fe | 53 | I | + | 7 | N | 29 | Cu | 79 | Au | + | 8 | O | 30 | Zn | 80 | Hg | + | 16 | S | 32 | Ge | 82 | Pb | + | 19 | K | 47 | Ag | 92 | U | + - id: b1-recipe + title: Recipe card on the lectern + type: note + body: | + *Read the flasks by their fire, as the rainbow burns: + **red, orange, yellow, green, violet.** Their signs will name what I see in my pupil.* + lock: + id: lock-flasks + prompt: Enter the cabinet word. + inputType: text + format: 6 letters + answers: ["GENIUS"] + hints: + - Order the flasks by flame colour, using the rainbow order from the recipe card. + - "Red is flask 4 (32), orange is flask 2 (7), yellow is flask 5 (53)..." + - 32 Ge, 7 N, 53 I, 92 U, 16 S = GeNIUS. + successText: | + **GENIUS.** The cabinet opens: a set of brass weights and a lump of dark, heavy stone wrapped in cloth. + Painted on the inside of the door: *"Weigh the stone."* + keyName: Flame Key + + - id: ch2 + title: The Philosopher's Stone + narrative: | + Two balance scales stand on the workbench, already loaded, perfectly level. + A dial on the wall wants the weight of the stone in drams. + clues: + A: + - id: a2-scales + title: The two balance scales + type: diagram + body: | + ``` + SCALE ONE (balanced) + left pan: the STONE + 1 lead weight + right pan: 2 gold weights + 1 silver weight + + SCALE TWO (balanced) + left pan: 2 lead weights + right pan: 1 gold weight + 1 silver weight + ``` + The weights carry the symbols of their metals, but no numbers. + B: + - id: b2-ledger + title: Weights ledger, margin notes + type: note + body: | + *The silver weight is stamped **5 drams**. I lost the other tags. + I remember only that a gold weight is **three times** a silver one.* + lock: + id: lock-stone + prompt: How many drams does the stone weigh? + inputType: code + format: A number + answers: ["25"] + hints: + - Work out gold first, then use scale two to find lead. + - Gold = 15. Scale two says 2 lead = 15 + 5, so lead = 10. + - "Scale one: stone + 10 = 15 + 15 + 5 = 35, so the stone is 25." + successText: | + **25 drams.** The dial clicks, and a stone panel slides aside, revealing a brass plaque of raised dots. + keyName: Scale Key + + - id: ch3 + title: The Dotted Plaque + narrative: | + The plaque is covered in raised dots. Beneath it, a two-digit brass dial. + Master Kelley went blind in his last years. He wrote his secrets for his fingers. + clues: + A: + - id: a3-plaque + title: The plaque + type: diagram + body: | + ## ⠍⠑⠗⠉⠥⠗⠽ + + *Under it, engraved: "Turn the dial to my **number**."* + B: + - id: b3-braille + title: A card of raised-dot letters (from the master's desk) + type: table + body: | + | ⠁ A | ⠃ B | ⠉ C | ⠙ D | ⠑ E | ⠋ F | ⠛ G | ⠓ H | ⠊ I | + |-----|-----|-----|-----|-----|-----|-----|-----|-----| + | ⠚ J | ⠅ K | ⠇ L | ⠍ M | ⠝ N | ⠕ O | ⠏ P | ⠟ Q | ⠗ R | + | ⠎ S | ⠞ T | ⠥ U | ⠧ V | ⠺ W | ⠭ X | ⠽ Y | ⠵ Z | | + - id: b3-names + title: Old names for the metals (bookmark) + type: note + body: | + Quicksilver = **Mercury** = Hg. *The only metal that runs like water.* + lock: + id: lock-plaque + prompt: Set the two-digit dial. + inputType: code + format: 2 digits + answers: ["80"] + hints: + - Decode the plaque letter by letter using the dot card. + - The word is MERCURY. "My number" is its number in the table of elements. + - Mercury (Hg) is 80. + successText: | + **80.** Somewhere in the walls, chains rattle. The vault door's last lock lights up: + four alchemical symbols glowing in a row. + keyName: Quicksilver Key + +finale: + type: escape + prompt: The vault door has a three-digit wheel and four glowing symbols. + clues: + A: + - id: af-door + title: The vault door + type: diagram + body: | + ``` + ☉ ☽ ♂ ♄ + ``` + *Carved around the wheel: "Add up the hearts of my four metals."* + B: + - id: bf-symbols + title: Chart of the seven metals + type: table + body: | + | Symbol | Planet | Metal | + |--------|---------|---------| + | ☉ | Sun | Gold | + | ☽ | Moon | Silver | + | ☿ | Mercury | Mercury | + | ♀ | Venus | Copper | + | ♂ | Mars | Iron | + | ♃ | Jupiter | Tin | + | ♄ | Saturn | Lead | + *"A metal's heart is its number."* + lock: + id: lock-vault + prompt: Turn the wheel to the right number. + inputType: code + format: 3 digits + answers: ["234"] + hints: + - Name the four metals from the chart, then find each one's number in the table of elements from the first chapter. + - Gold 79, Silver 47, Iron 26, Lead 82. + - 79 + 47 + 26 + 82 = 234. + successEnding: | + **234.** The vault swings open. No gold inside. Just a mirror, and a note: + + > *"You found it together. That was always the gold."* + + The cellar door's bar lifts. **You escaped!** ⚗️🔑 diff --git a/content/games/dead-air.yaml b/content/games/dead-air.yaml new file mode 100644 index 0000000..77d0364 --- /dev/null +++ b/content/games/dead-air.yaml @@ -0,0 +1,340 @@ +id: dead-air +engine: story +category: murder-mystery +title: Dead Air +tagline: A legendary DJ goes silent live on air. Four minutes of nothing. One killer in the building. +estimatedMinutes: 30 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#d7263d", accent: "#f9c80e", background: "#0c0a12", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Detective + blurb: You have the police file. Lab results, statements, staff records. + B: + name: The Night Intern + blurb: You work the graveyard shift at Night Owl FM. Logs, run sheets, cameras, gossip. +intro: | + **Night Owl FM, Johannesburg. 23:31.** + + Rex Daniels has hosted the midnight show for 22 years. Tonight he promised listeners + "the biggest announcement in the history of this station, at midnight sharp." + + He never made it. A song ended, and nothing came after it. Just hiss. + The producer found him slumped over the desk, his tea still warm. + + Five people were in the building. **Every one of them had a reason.** + + ⚠️ *Harder case: the locks give you facts, not the answer. You'll have to connect them yourselves in the accusation.* + +suspects: + - id: zanele + name: Zanele Dube + role: Producer + description: Runs the desk, makes Rex's tea, knows his every habit. Has been passed over for promotion three times. + - id: kyle + name: Kyle Pretorius + role: Junior DJ + description: Ambitious, loud, and openly waiting for Rex's slot. + - id: marisa + name: Marisa Venter + role: Station manager + description: Co-owns the station with Rex. Was "in meetings" all night. + - id: tumi + name: Tumi Nkosi + role: Sound engineer + description: Keeps the old place running on duct tape and prayer. Rex once got him written up. + - id: owen + name: Owen Hart + role: Superfan + description: Calls in every night. Turned up at the gate tonight with flowers for Rex. + +chapters: + - id: ch1 + title: Ghost Signal + narrative: | + The automation computer kept playing what it was told. The dead-air alarm caught the silence. + First question: **which song was playing when Rex went quiet for good?** + clues: + A: + - id: a1-automation + title: Automation log, tonight's queue + type: log + body: | + | # | Track | Length | Status | + |---|-----------------------------|--------|----------| + | 1 | Neon Rain | 4:12 | PLAYED | + | 2 | Highveld Heart | 3:48 | PLAYED | + | 3 | Slow Dance in a Fast Car | 5:05 | PLAYED | + | 4 | Paper Moons | 3:55 | PLAYED | + | 5 | Ghost Signal | 6:20 | PLAYED | + | 6 | Last Train Home | 4:40 | NOT PLAYED (needs manual start) | + - id: a1-security + title: Gate security log + type: log + body: | + 22:30 · Owen Hart at gate with flowers. Not on guest list. Refused entry. + 22:30–23:50 · Owen sits in reception with guard Sipho. "Wouldn't stop talking about Rex." + B: + - id: b1-runsheet + title: Producer's run sheet + type: document + body: | + **Midnight Show, standard format** + - Show opens **23:00:00** sharp. Rex's intro link: **2:00**. + - Rex talks for **exactly 1:30** after every song. He's never late. Ever. + - Songs play straight from automation. Rex must start each song manually. + - id: b1-alarm + title: Dead-air alarm printout + type: log + body: | + ``` + ** DEAD AIR ALARM ** 23:31:35 + Trigger: silence > 15 seconds on main output + ``` + lock: + id: lock-song + prompt: Which song was the last thing listeners heard? + inputType: text + format: Song title + answers: ["GHOSTSIGNAL"] + hints: + - The alarm fires 15 seconds after silence starts. Work out when the silence started, then build the show's timeline. + - Silence started at 23:31:20. Add the intro, each song, and each 1:30 talk break, starting from 23:00:00. + - Ghost Signal ran from 23:25:00 to 23:31:20, and Rex never came back to talk after it. + successText: | + **Ghost Signal**, 23:25:00 to 23:31:20. Rex's last words on air ended at 23:25:00: + *"Longest song of the night, which means it's tea time. Back in six, Joburg."* + He never started Last Train Home. Whatever happened, it happened during Ghost Signal. + keyName: Needle Key + + - id: ch2 + title: The Last Cup + narrative: | + The paramedics say anaphylactic shock. Rex had a severe peanut allergy and everyone knew it. + His EpiPen was not in his bag. And he'd been drinking tea all night. + clues: + A: + - id: a2-lab + title: Lab report, rushed + type: report + body: | + **Cause of death:** anaphylaxis (peanut protein, Ara h 2). + | Item tested | Peanut protein | + |---------------------------------------|----------------| + | Mug "ON AIR" (in Rex's hand) | **POSITIVE** | + | Mug "WORLD'S OKAYEST DJ" (studio sink)| negative (honey residue present) | + | Jar labelled "REX ONLY" (honey) | **POSITIVE** | + | Tea bags, kettle water, milk | negative | + - id: a2-minutes + title: Boardroom meeting minutes + type: document + body: | + **Quick staff meeting, 23:08–23:20.** Topic: Rex's "midnight announcement". + Present: Zanele Dube, Tumi Nkosi, Kyle Pretorius, Marisa Venter (chair). + *23:11 · Marisa steps out "to take a call from the lawyers". Returns 23:17.* + Nobody else left the room. (Rex on air, Owen in reception.) + B: + - id: b2-camera + title: Kitchen camera log (motion triggered) + type: log + body: | + | Time | Who | What | + |-------|---------|------| + | 22:45 | Rex | Makes himself rooibos. **Black, no honey.** | + | 22:58 | Tumi | Fills water bottle | + | 23:05 | Kyle | Instant coffee | + | 23:06 | Zanele | Makes Rex's tea in the **WORLD'S OKAYEST DJ** mug, honey from **REX ONLY** jar. Takes it to studio. | + | 23:07 | (empty) | · | + | 23:09 | · | **CAMERA OFFLINE** | + | 23:21 | · | Camera back online. Kitchen empty. | + | 23:24 | Zanele | Makes Rex's tea in the **ON AIR** mug, honey from **REX ONLY** jar. Takes it to studio. | + - id: b2-maintenance + title: Tumi's maintenance whiteboard + type: note + body: | + *Generator test TONIGHT 23:09 (booked by M. Venter this afternoon, last minute??). + Cameras + kitchen plugs drop for about 12 min. Studio is on the UPS, stays live.* + lock: + id: lock-safe-honey + prompt: What's the last time the honey was definitely safe? + inputType: code + format: "HH:MM" + answers: ["2306", "1106", "1106PM"] + hints: + - The honey jar is positive now. Was it always? Look at the first mug with honey in it. + - The OKAYEST DJ mug had honey and tested negative. When was that cup made? + - Zanele made the OKAYEST DJ cup at 23:06, and Rex drank it and was fine. + successText: | + **23:06.** The honey was clean at 23:06 and poisoned by 23:24. + The camera was on from 23:06 to 23:09 and from 23:21 to 23:24, and nobody touched the jar then. + So the honey was poisoned **while the camera was off, 23:09 to 23:21.** + keyName: Honey Key + + - id: ch3 + title: The Studio Door + narrative: | + Rex's EpiPen always lived in the side pocket of his bag, which sits in the studio lounge. + The lounge has a card reader. Someone went in while Rex was live in the booth. + clues: + A: + - id: a3-register + title: Staff access card register + type: table + body: | + | Staff member | Card number | + |-------------------|-------------| + | Rex Daniels | 401 | + | Zanele Dube | 409 | + | Marisa Venter | 412 | + | Kyle Pretorius | 417 | + | Tumi Nkosi | 423 | + - id: a3-kyle + title: Kyle's statement + type: transcript + body: | + "I had a one-on-one with Marisa in the boardroom at half ten. Left my card on the table. + Went back for it at ten to eleven and it was gone. I reported it to security, check the book. + Then I was stuck in that meeting from eight past till twenty past. Ask anyone." + B: + - id: b3-doorlog + title: Studio lounge door reader + type: log + body: | + ``` + CARD READER v2.1 · IDs IN HEX (Tumi: "don't ask") + 22:45:02 ID 0x191 IN + 23:12:40 ID 0x1A1 IN + 23:13:55 ID 0x1A1 OUT + 23:24:31 ID 0x199 IN + 23:31:50 ID 0x199 IN (panic button pressed) + ``` + - id: b3-reception + title: Reception lost-property book + type: note + body: | + *22:52 · Kyle P. reports staff card missing. Says left in boardroom. Card NOT cancelled (system down for generator test prep).* + lock: + id: lock-card + prompt: Whose card opened the studio lounge at 23:12? + inputType: choice + options: + - { id: rex, label: Rex Daniels } + - { id: zanele, label: Zanele Dube } + - { id: marisa, label: Marisa Venter } + - { id: kyle, label: Kyle Pretorius } + - { id: tumi, label: Tumi Nkosi } + answers: ["kyle"] + hints: + - The reader shows hexadecimal. The register is in normal (decimal) numbers. + - "0x1A1 = 1×256 + 10×16 + 1." + - 0x1A1 = 417, which is Kyle's card. + successText: | + **Kyle's card.** 23:12 to 23:13, in and out in 75 seconds, just long enough to empty a bag pocket. + But Kyle reported that card stolen at 22:52... and the minutes put him in the boardroom at 23:12. + keyName: Access Key + + - id: ch4 + title: The Midnight Announcement + narrative: | + What was Rex going to say at midnight? His notes were in his jacket: a page of crossword-looking + letters, and a piece of card with little windows cut into it. + clues: + A: + - id: a4-grid + title: Page from Rex's notebook + type: diagram + body: | + ``` + G S C A L C + H C T E B I + D H W W S F + O U R B H B + T G E O E T + D U D T Y F + ``` + *Scrawled underneath: "Zan knows how to read these. Lay the card on top."* + B: + - id: b4-stencil + title: Cut card from Rex's jacket + type: diagram + body: | + ``` + ■ □ ■ □ □ ■ + ■ ■ ■ □ ■ □ + ■ ■ ■ ■ □ □ + □ ■ □ ■ ■ ■ + ■ □ □ ■ ■ ■ + ■ ■ □ ■ ■ ■ + ``` + ■ = solid card · □ = window cut out. Same size as a notebook page, 6 × 6 squares. + Zanele says: "His old trick. Read the windows left to right, top to bottom." + - id: b4-gossip + title: Overheard in the corridor (last week) + type: transcript + body: | + **Tumi:** "Marisa says some media group's buying us. Rex says he never signed anything." + **Zanele:** "He'd never sell. He'd sooner die on air." + lock: + id: lock-stencil + prompt: What did Rex's hidden message say? + inputType: text + format: The message (3 words) + answers: ["SALEISFORGED", "THESALEISFORGED"] + hints: + - The Detective reads out the letters that sit under each □ window. + - "Row 1 windows are columns 2, 4 and 5: S, A, L." + - S-A-L-E-I-S-F-O-R-G-E-D. + successText: | + **"SALE IS FORGED."** The station's sale contract carries Rex's signature. Rex never signed it. + At midnight, he was going to tell the whole city, live. + The only other owner who could sell the station: **Marisa Venter.** + keyName: Microphone Key + +finale: + type: accusation + prompt: Four keys. Put it together. Who killed Rex Daniels? + maxAttempts: 2 + questions: + - id: who + label: Who killed Rex? + options: + - { id: zanele, label: Zanele Dube } + - { id: kyle, label: Kyle Pretorius } + - { id: marisa, label: Marisa Venter } + - { id: tumi, label: Tumi Nkosi } + - { id: owen, label: Owen Hart } + answer: marisa + - id: how + label: How? + options: + - { id: tea, label: Peanut in the tea Zanele served him at 23:24 } + - { id: honey, label: Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card } + - { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day } + - { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers } + answer: honey + - id: why + label: Why? + options: + - { id: slot, label: To take over the midnight slot } + - { id: writeup, label: Revenge for a disciplinary write-up } + - { id: forged, label: Rex was about to reveal on air that she'd forged his signature to sell the station } + - { id: obsession, label: Rejected obsession } + answer: forged + successEnding: | + Marisa doesn't argue. She just looks at the "ON AIR" light, still glowing red. + + She booked the generator test so the kitchen camera would drop. She took Kyle's card off + the boardroom table at 22:30. At 23:11 she "stepped out for a call", used Kyle's card to get + into the lounge at 23:12, took the EpiPen from Rex's bag, then slipped peanut oil into his honey. + Back in the meeting by 23:17. Zanele carried the poison in herself, never knowing. + + **Case closed.** At midnight, Zanele reads Rex's announcement on air. 📻🔑 + failEnding: | + The wrong person is arrested. The sale goes through. Night Owl FM becomes a 24-hour playlist with no DJs. + reveal: | + **Marisa Venter** poisoned the honey with **peanut oil** during the generator test she booked, + and used **Kyle's stolen card** to take Rex's **EpiPen**, because Rex was going to reveal live + that she'd **forged his signature to sell the station**. diff --git a/content/games/deep-lab-nereid.yaml b/content/games/deep-lab-nereid.yaml new file mode 100644 index 0000000..03a6c1c --- /dev/null +++ b/content/games/deep-lab-nereid.yaml @@ -0,0 +1,211 @@ +id: deep-lab-nereid +engine: story +category: escape-room +title: "Submerged: Deep Lab Nereid" +tagline: 300 metres under the sea. The lab is flooding. The clock is literal. +estimatedMinutes: 35 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#0b4f6c", accent: "#20bf55", background: "#03111a", headingFont: "JetBrains Mono" } +rules: + hintPenaltySeconds: 180 + timeLimitMinutes: 40 + timeLimitLabel: "Water level" # show the countdown as a rising water gauge +roles: + A: + name: Pilot + blurb: You're in the control room. Panels, sonar, and the launch console. + B: + name: Diver + blurb: You're in the wet lab. Pipe diagrams, tank labels, the station map, and a lot of water. +intro: | + **Deep Lab Nereid**, 300 m below the Agulhas Current. Your shift was supposed to be quiet. + + At 03:12, a pressure seal fails. Alarms. Red lights. Water pouring through the wet lab. + The rest of the crew made it to the first escape pod. You two are on opposite sides of a sealed hatch. + + The second pod is your only way out. **The water gauge is your timer. When it hits the ceiling, it's over.** + +chapters: + - id: ch1 + title: Pressure Valves + narrative: | + The hatch between you won't open while any pressure warning light is on. + The Diver can reach five valves. Turning a valve flips some of the warning lights. + clues: + A: + - id: a1-lights + title: Pressure warning panel (control room) + type: diagram + body: | + ``` + LIGHT: L1 L2 L3 L4 L5 + STATE: ON ON ON off ON + ``` + HATCH RELEASE: requires ALL LIGHTS OFF. + Valve entry: type the numbers of the valves to turn, smallest first. Each valve once only. + B: + - id: b1-pipes + title: Valve wiring plate (wet lab wall) + type: diagram + body: | + ``` + VALVE 1 → flips L1, L2 + VALVE 2 → flips L2, L3, L5 + VALVE 3 → flips L1, L4 + VALVE 4 → flips L3, L4 + VALVE 5 → flips L4, L5 + ``` + "Flip" = ON becomes off, off becomes ON. Turning a valve twice undoes it. + lock: + id: lock-valves + prompt: Which valves do you turn? + inputType: code + format: Valve numbers, smallest first (e.g. 23) + answers: ["145"] + hints: + - Try it on paper. Each valve flips its lights, and order doesn't matter. + - L1 is ON. Only valves 1 and 3 touch L1, so you need exactly one of them. + - "Valves 1, 4 and 5. Check: L1 flips once, L2 once, L3 once, L4 twice (back to off), L5 once." + successText: | + **Valves 1, 4, 5.** Every light dies. The hatch hisses open, and you can finally see each other. + Ankle-deep water. Keep moving. + keyName: Valve Key + + - id: ch2 + title: Breathing Gas + narrative: | + The pod's air tanks are empty. The lab's mixing rig can fill them, but the mix has to be exactly right. + clues: + A: + - id: a2-spec + title: Pod life-support spec (launch console) + type: document + body: | + **Fill pod tank to 210 litres.** + Oxygen content must be exactly **21%**. + Mixer input: litres drawn from **Tank X**. The rest is topped up from Tank Y automatically. + B: + - id: b2-tanks + title: Tank labels (wet lab) + type: table + body: | + | Tank | Contents | Oxygen | + |------|-------------------|--------| + | X | Nitrox | 36% | + | Y | Low-oxygen mix | 15% | + | Z | Pure helium | 0% | + *Tank Z is not connected to the mixer.* + lock: + id: lock-mix + prompt: How many litres from Tank X? + inputType: code + format: Litres + answers: ["60"] + hints: + - You need 21% of 210 litres to be oxygen. That's 44.1 litres of oxygen. + - If x litres come from X, oxygen = 0.36x + 0.15(210 − x). + - 0.21x = 44.1 − 31.5 = 12.6, so x = 60. + successText: | + **60 litres.** The mixer hums. The pod's oxygen gauge settles at 21.0%. Water is at your knees. + keyName: Oxygen Key + + - id: ch3 + title: Find the Pod + narrative: | + The second pod broke loose from its dock in the quake and drifted. Its beacon is pinging. + You need to steer the tether crawler to the right square of the seabed. + clues: + A: + - id: a3-sonar + title: Sonar contact (control room) + type: report + body: | + ``` + CONTACT: ESCAPE POD 2 + Leg 1: 300 m, bearing 090 (due east) from the lab + Leg 2: 200 m, bearing 180 (due south) from the end of leg 1 + ``` + B: + - id: b3-chart + title: Seabed chart (wet lab) + type: map + body: | + ``` + A B C D E F G H I J + 1 . . . . . . . . . . + 2 . . . . . . . . . . + 3 . . . . . . . . . . + 4 . . . . . . . . . . + 5 . . . . [N] . . . . . + 6 . . . . . . . . . . + 7 . . . . . . . . . . + 8 . . . . . . . . . . + ``` + [N] = Lab Nereid. Each square = **100 m**. North is up. + lock: + id: lock-grid + prompt: Which chart square is the pod in? + inputType: text + format: e.g. B4 + answers: ["H7"] + hints: + - Start at the lab's square, E5. + - 300 m east is three squares east. 200 m south is two squares down. + - E → F → G → H, then 5 → 6 → 7. H7. + successText: | + **H7.** The crawler latches on and reels the pod in. It docks with a clunk you feel in your teeth. + Water is at your waist. + keyName: Sonar Key + +finale: + type: escape + prompt: The pod's launch console wants a code word, typed on the ancient intercom keypad. + clues: + A: + - id: af-log + title: Station commander's last log entry + type: log + body: | + ``` + LAUNCH WORD (typed on the intercom keypad, pauses between letters): + 555 · 4 · 3 · 66 · 55 · 9 · 222 + ``` + *The intercom is not a normal phone keypad. The engineers rewired it. Ask the wet lab.* + B: + - id: bf-keypad + title: Intercom keypad (wet lab wall) + type: diagram + body: | + ``` + ┌──────┬──────┬───────┐ + │ 1 │ 2 │ 3 │ + │ │ QWE │ RTY │ + ├──────┼──────┼───────┤ + │ 4 │ 5 │ 6 │ + │ UIO │ PAS │ DFG │ + ├──────┼──────┼───────┤ + │ 7 │ 8 │ 9 │ + │ HJK │ LZX │ CVBNM │ + └──────┴──────┴───────┘ + ``` + Press a key once for its first letter, twice for its second, and so on. + lock: + id: lock-launch + prompt: Enter the launch word. + inputType: text + format: One word + answers: ["SURFACE"] + hints: + - "555 means key 5 pressed three times." + - Key 5 is P, A, S. Three presses = S. + - S · U · R · F · A · C · E. + successEnding: | + **SURFACE.** The pod seals. A thump, a lurch, and you're rising, fast. + + Through the porthole, the dark turns blue, then green, then gold. You break the surface + into the sunrise, holding hands and laughing like idiots. + + **You escaped!** 🌊🔑 + failEnding: | + The water reaches the ceiling. The lights flicker out. (Don't worry, it's a game. Try again?) diff --git a/content/games/drowned-catacombs.yaml b/content/games/drowned-catacombs.yaml new file mode 100644 index 0000000..4c1c3a5 --- /dev/null +++ b/content/games/drowned-catacombs.yaml @@ -0,0 +1,90 @@ +id: drowned-catacombs +engine: labyrinth +category: co-op +title: The Drowned Catacombs +tagline: Ankle-deep water. Gates that only swing one way. Five places the floor gives out. +estimatedMinutes: 20 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#1e3d59", accent: "#7ec8e3", background: "#050b10", headingFont: "Playfair Display" } +roles: + A: + name: Navigator + blurb: You hold a water-stained survey map. Walls, carvings, traps, and the old one-way flood gates. + B: + name: Explorer + blurb: You're down in the dark with a torch. You see the floor under you and the ways you can go. That's it. +intro: | + Under the old harbour town, the catacombs flood with every spring tide. Three **tide lanterns** + were left down there a century ago. Light all three at the sea gate, and the water drains away. + + **The flood gates swing one way only.** Go through one and you can't come back that way. + And five stretches of the floor have rotted through. + + Navigator, you have the map. Explorer, you have the torch. Talk. + +# Grid legend: # wall · . floor · S start · E exit (needs all lanterns) · K lantern · T trap +grid: + - "#############" + - "#S..#.......#" + - "###.#.###.#.#" + - "#.T...#K#...#" + - "#.#.#.#.#.###" + - "#.#.T.#...TK#" + - "#.##.##.....#" + - "#.#.T...#.#.#" + - "#.#.###.#.#.#" + - "#.......#.#.#" + - "#.###.#.#.#.#" + - "#..K#.....TE#" + - "#############" + +lanternsRequired: 3 + +# One-way gates. {x, y, dir}: you may step FROM (x,y) in direction `dir`, but never back the other way. +# Navigator sees an arrow on the map. Explorer only sees "a heavy gate swings shut behind you" after passing, +# and the reverse direction button is disabled with the label "Gate (won't open from this side)". +oneWay: + - { x: 5, y: 9, dir: e } + - { x: 5, y: 11, dir: n } + - { x: 7, y: 8, dir: n } + +# Only some cells have carvings. Blank cells say nothing, so the Navigator must count steps. +glyphs: + - { x: 1, y: 1, glyph: arch, label: "⌒ A stone archway (the entrance)" } + - { x: 3, y: 1, glyph: urn, label: "⚱ A cracked urn" } + - { x: 9, y: 1, glyph: candle, label: "🕯 A melted candle stub" } + - { x: 5, y: 3, glyph: bones, label: "☠ Crossed bones carved in the floor" } + - { x: 10, y: 3, glyph: drip, label: "💧 Water dripping from the ceiling" } + - { x: 1, y: 5, glyph: shell, label: "🐚 A seashell set in the stone" } + - { x: 8, y: 5, glyph: eye, label: "👁 A carved eye" } + - { x: 7, y: 6, glyph: anchor, label: "⚓ An anchor carving" } + - { x: 3, y: 9, glyph: wave, label: "🌊 Three wavy lines" } + - { x: 9, y: 9, glyph: fish, label: "🐟 A fish" } + - { x: 11, y: 8, glyph: moon, label: "☾ A crescent moon" } + - { x: 6, y: 11, glyph: keyhole, label: "🗝 A rusted keyhole in the wall" } + +lanterns: + - { x: 3, y: 11, name: Lantern of the Low Tide } + - { x: 7, y: 3, name: Lantern of the Deep } + - { x: 11, y: 5, name: Lantern of the Turning Tide } + +explorerView: + flavour: + blank: "Cold water around your ankles. Nothing carved here." + trap: "The floor collapses into black water! You surface, gasping... back at the archway." + lantern: "You strike the tide lantern and it flares blue. ({name})" + exitLocked: "A rusted sea gate with three lantern brackets. Locked tight." + exitOpen: "You hang the lanterns. Somewhere, a huge drain groans open. The sea gate rises." + gate: "A heavy flood gate swings shut behind you. It won't open from this side." + +navigatorView: + showMoveCount: true + +scoring: + trapPenaltySeconds: 60 + parMoves: 80 + +successEnding: | + The water drains with a roar, and the Explorer climbs the sea-gate stairs into the harbour moonlight. + The Navigator's map is soaked, but nobody cares. 🏮🔑 diff --git a/content/games/in-sync-deep-cuts.yaml b/content/games/in-sync-deep-cuts.yaml new file mode 100644 index 0000000..51fe310 --- /dev/null +++ b/content/games/in-sync-deep-cuts.yaml @@ -0,0 +1,118 @@ +id: in-sync-deep-cuts +engine: sync +category: co-op +title: "In Sync: Deep Cuts" +tagline: You think you know each other? Prove it without the multiple choice. +estimatedMinutes: 25 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#6a0572", accent: "#ff6f91", background: "#120612", headingFont: "Playfair Display" } +roles: + A: + name: Partner A + blurb: In the spotlight for Round 2, and for questions 1, 3 and 5 of Round 4. + B: + name: Partner B + blurb: In the spotlight for Round 3, and for questions 2 and 4 of Round 4. +intro: | + The sequel to In Sync. Same idea, much harder bar. + + - **Round 1, Telepathy:** no options. You both **type** one word. Same word = point. + - **Rounds 2 & 3, Spotlight:** deeper questions about each of you. You need **4 out of 5**. + - **Round 4, Ranking:** put four things in order for your partner. Nail the **top AND bottom** to score. + + One replay per round. No talking during a question. (Yes, we mean it. Not even eyebrows.) + +rules: + questionsPerRound: 5 + replaysPerRound: 1 + +rounds: + - id: r1 + title: Telepathy + type: telepathy + pointsForKey: 3 + blurb: Read the prompt. Both type ONE word, privately. Normalized exact match (case, spaces, and a trailing S are ignored) = point. + keyName: Telepathy Key + - id: r2 + title: Spotlight on A + type: predict + subject: A + pointsForKey: 4 + blurb: Partner A answers honestly about themselves. Partner B guesses. 4 of 5 to earn the key. + keyName: Deep Key A + - id: r3 + title: Spotlight on B + type: predict + subject: B + pointsForKey: 4 + blurb: Partner B answers honestly about themselves. Partner A guesses. 4 of 5 to earn the key. + keyName: Deep Key B + - id: r4 + title: Ranking + type: rank + subjectPattern: [A, B, A, B, A] + pointsForKey: 3 + blurb: The subject ranks four things from most to least. The partner predicts the order. Point only if BOTH the #1 and the #4 match. + keyName: Ranking Key + +# TELEPATHY bank: free-text prompts. Both type one word. +telepathy: + - { id: t01, q: "A fruit." } + - { id: t02, q: "Something you find in a kitchen drawer." } + - { id: t03, q: "A colour that feels like us." } + - { id: t04, q: "An animal that would be a great pet for us." } + - { id: t05, q: "A city we should visit together." } + - { id: t06, q: "Something you'd take to a picnic." } + - { id: t07, q: "A word that describes our first date." } + - { id: t08, q: "A breakfast food." } + - { id: t09, q: "Something cold." } + - { id: t10, q: "A board or card game." } + - { id: t11, q: "Something you plug in." } + - { id: t12, q: "A pizza topping." } + - { id: t13, q: "Something with wheels." } + - { id: t14, q: "A word that rhymes with 'love'." } + - { id: t15, q: "Something you find at the beach." } + - { id: t16, q: "A dessert." } + - { id: t17, q: "A famous movie couple's first name (either one)." } + - { id: t18, q: "Something that makes noise at night." } + +# PREDICT bank (deeper). "{subject}" is replaced with the subject's role name. +predict: + - { id: d01, q: "What does {subject} wish people asked them about more?", options: [Their dreams, Their faith or beliefs, Their work, Their childhood] } + - { id: d02, q: "When {subject} feels most loved, it's usually because...", options: [Someone made time, Someone said it out loud, Someone did something for them, Someone remembered a small detail] } + - { id: d03, q: "{subject}'s biggest fear about the future?", options: [Money, Health, Not doing something that matters, Drifting apart from people] } + - { id: d04, q: "Which compliment means the most to {subject}?", options: ["You're so kind", "You're so smart", "You're so funny", "You're so strong"] } + - { id: d05, q: "If {subject} could redo one year of their life, which kind?", options: [A school year, Their first year working, A year with a hard loss, They wouldn't redo any] } + - { id: d06, q: "After a hard day, {subject} secretly wants...", options: [To vent without advice, Advice and a plan, Distraction, To be left alone for a bit] } + - { id: d07, q: "What would {subject} do with a free Saturday, nobody else's plans?", options: [Create something, Be outdoors, Sleep and snack, See people] } + - { id: d08, q: "Which does {subject} find harder?", options: [Saying sorry, Saying no, Asking for help, Accepting a compliment] } + - { id: d09, q: "{subject}'s happiest childhood memory involves...", options: [A holiday, A family tradition, A friend, A pet] } + - { id: d10, q: "What would {subject} want written about them one day?", options: [They were generous, They were brave, They made people laugh, They loved well] } + - { id: d11, q: "What drains {subject} the fastest?", options: [Conflict, Crowds, Boredom, Being rushed] } + - { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, A full, noisy home, Building something together, Peace and quiet somewhere beautiful] } + - { id: d13, q: "What does {subject} think is our biggest strength as a couple?", options: [We laugh a lot, We fight fair, We have the same values, We're a good team] } + - { id: d14, q: "When {subject} is upset with me, they usually...", options: [Tell me straight away, Go quiet first, Joke about it, Wait until later to bring it up] } + - { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, A craft (woodwork, pottery, etc.)] } + - { id: d16, q: "What does {subject} spend the most time thinking about?", options: [The future, The past, Other people, Ideas and projects] } + - { id: d17, q: "If {subject} wrote a book, it'd be...", options: [A thriller, A memoir, A how-to, A fantasy epic] } + - { id: d18, q: "{subject}'s ideal way to celebrate a big win?", options: [Fancy dinner, Big party, Quiet night in, Buy something special] } + +# RANK bank: subject orders the 4 items from MOST to LEAST. Partner predicts. +rank: + - { id: k01, q: "Rank these date nights for {subject}.", items: [Fancy dinner, Movie at home, Something active, A road trip] } + - { id: k02, q: "Rank these for {subject}, best to worst.", items: [Breakfast in bed, A surprise gift, A handwritten letter, A whole day planned for them] } + - { id: k03, q: "Rank these chores, {subject}'s favourite to most hated.", items: [Dishes, Laundry, Cooking, Grocery shopping] } + - { id: k04, q: "Rank these holidays for {subject}.", items: [Bush or safari, Beach, Snow, A big city] } + - { id: k05, q: "Rank these snacks for {subject}.", items: [Chips, Chocolate, Popcorn, Biltong] } + - { id: k06, q: "Rank these pet peeves, most annoying to least, for {subject}.", items: [Being late, Loud chewing, Phone at dinner, Leaving lights on] } + - { id: k07, q: "Rank these superpowers for {subject}.", items: [Flying, Invisibility, Teleporting, Reading minds] } + - { id: k08, q: "Rank these seasons for {subject}.", items: [Summer, Autumn, Winter, Spring] } + - { id: k09, q: "Rank these ways to spend R1000 for {subject}.", items: [Experience, Clothes, Food, Save it] } + - { id: k10, q: "Rank these film genres for {subject}.", items: [Comedy, Thriller, Romance, Action] } + - { id: k11, q: "Rank these for {subject}: what they'd miss most on a desert island.", items: [Phone, Coffee, Music, Their bed] } + - { id: k12, q: "Rank these love languages for {subject}.", items: [Words, Time, Touch, Acts of service] } + - { id: k13, q: "Rank these sounds for {subject}.", items: [Rain on a roof, Ocean waves, A crackling fire, Birds in the morning] } + - { id: k14, q: "Rank these for {subject}: dream house feature.", items: [Big kitchen, Pool, Library, Big garden] } + - { id: k15, q: "Rank these drinks for {subject}.", items: [Coffee, Tea, Hot chocolate, Something fizzy] } + - { id: k16, q: "Rank these for {subject}: best part of a holiday.", items: [Planning it, Getting there, Being there, The photos after] } diff --git a/content/games/kalahari-expedition.yaml b/content/games/kalahari-expedition.yaml new file mode 100644 index 0000000..3e62710 --- /dev/null +++ b/content/games/kalahari-expedition.yaml @@ -0,0 +1,422 @@ +id: kalahari-expedition +engine: story +category: murder-mystery +title: The Kalahari Expedition +tagline: 1931. A diamond find. Two bodies in the sand. Nobody leaves until the truck comes back. +estimatedMinutes: 60 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#b5651d", accent: "#f4d58d", background: "#140d06", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 240 + timeLimitMinutes: 75 +roles: + A: + name: The Magistrate + blurb: Sent from Upington with the post truck. You have the medical notes, the photographs, and the dead man's papers. + B: + name: The Camp Clerk + blurb: You keep the expedition's books. Rotas, inventories, maps, and every rumour around the fire. +intro: | + **The southern Kalahari, March 1931.** + + Professor Edwin Marlowe's expedition has been digging for six weeks. Two weeks ago, the sponsor, + Felix Dunmore, announced a find that made headlines in London: **diamonds**, lying in the sand. + + Three nights ago, Marlowe was found dead at the bottom of the excavation pit. + Last night, the camp cook, **Samuel Brink**, was found dead by the water barrels. "A snake," they say. + + The post truck won't be back for days. Someone in this camp has killed twice. + + ☠️ *Nightmare case. One accusation. No second chances. The clock is running.* + +suspects: + - id: helena + name: Dr Helena Marlowe + role: Botanist, the professor's wife + description: Calm, clinical, and inheriting everything. Acted as camp doctor. + - id: reyes + name: Captain Arthur Reyes + role: Guide and quartermaster + description: Ex-army. Ran the camp with a sergeant's eye. Smokes a pipe. + - id: dunmore + name: Mr Felix Dunmore + role: Sponsor + description: Financier. His Dunmore Kalahari Diamond Company shares have tripled since the find. + - id: bell + name: Miss Clara Bell + role: Photographer and journalist + description: Sent by a London paper. Up all night photographing the stars. + - id: kessler + name: Dr Otto Kessler + role: Geologist + description: Marlowe's old rival. Said loudly at dinner that "the find makes no geological sense." + - id: pienaar + name: Jan Pienaar + role: Driver and mechanic + description: Keeps the two trucks alive. Quiet. Noticed things. + +chapters: + - id: ch1 + title: Star Trails + narrative: | + Nobody saw Marlowe leave camp. But Clara Bell left her camera open to the sky all night, + and the dig site is in the corner of her photograph. + clues: + A: + - id: a1-photo + title: Clara's long-exposure photograph + type: photo + body: | + *The night sky drawn into curved white arcs around the south celestial pole. + In the bottom corner, the dig. A small orange streak shows a lantern there. + Measured against the star arcs, the lantern streak **begins 60°** into the arc and **ends 75°** in.* + - id: a1-medical + title: Dr Helena Marlowe's notes on her husband + type: report + body: | + Skull fracture, back of the head. Sand in the wound, but **no sand in the mouth or nose**. + He did not breathe after he fell. He was dead **before** he went into the pit. + B: + - id: b1-photolog + title: Clara's photo log + type: log + body: | + Plate 14 · Star trails, camera facing south. + Shutter **opened 22:00**. Closed 05:00. + *(Note to self: the sky turns **15° every hour**, so a 7-hour plate gives 105° arcs.)* + - id: b1-clara + title: Clara's statement (given privately to you) + type: transcript + body: | + "Arthur, Captain Reyes, sat with me by the camera from ten until four. We were... talking. + Please don't put that in the official report. My editor would have a fit." + lock: + id: lock-lantern + prompt: At what time did the lantern at the dig light up? + inputType: code + format: "HH:MM" + answers: ["0200", "200", "200AM"] + hints: + - The star arcs grow at a steady rate from the moment the shutter opened. + - 60° at 15° per hour is 4 hours. + - 22:00 plus 4 hours = 02:00. (It went out at 03:00.) + successText: | + **02:00.** Someone was at the dig with a lantern from 02:00 to 03:00. + Marlowe was killed then, before he was put in the pit. + keyName: Starlight Key + + - id: ch2 + title: Footprints + narrative: | + The wind dropped that night. Two sets of prints lead to the pit. One is Marlowe's (size 9, hobnail). + The other set leads there and back again. + clues: + A: + - id: a2-sketch + title: Police sketch of the second set of prints + type: diagram + body: | + ``` + Print length (heel to toe): 31.5 cm + Tread pattern: >>>> chevron + Stride: even, about 78 cm. No limp. + ``` + *Note: in loose sand, a print spreads about **1 cm longer** than the sole that made it.* + B: + - id: b2-boots + title: Quartermaster's boot inventory + type: table + body: | + | Person | UK size | Tread | + |-----------------|---------|-------| + | Prof. Marlowe | 9 | hobnail | + | Dr H. Marlowe | 5 | smooth | + | Capt. Reyes | 11 | chevron (army issue) | + | Mr Dunmore | 11 | chevron ("insisted on the same as the Captain's") | + | Miss Bell | 6 | smooth | + | Dr Kessler | 11 | hobnail | + | Mr Pienaar | 10 | chevron | + | Samuel Brink | 8 | smooth | + - id: b2-sizes + title: Bootmaker's size card (sole length) + type: table + body: | + | UK size | Sole length | + |---------|-------------| + | 8 | 27.5 cm | + | 9 | 28.5 cm | + | 10 | 29.5 cm | + | 11 | 30.5 cm | + | 12 | 31.5 cm | + lock: + id: lock-boot + prompt: What size boot made the second set of prints? + inputType: code + format: UK size + answers: ["11"] + hints: + - Don't read the print length straight off the size card. Sand changes it. + - The sole is about 1 cm shorter than the print, so 30.5 cm. + - 30.5 cm is a size 11. + successText: | + **Size 11, chevron tread, even stride.** Only two people in camp own that boot. + Check the inventory again. And remember who has an alibi from ten until four. + keyName: Boot Key + + - id: ch3 + title: The Watch Rota + narrative: | + The camp keeps a fire watch every night. Whoever was on watch at 02:00 might have seen the lantern leave. + clues: + A: + - id: a3-rule + title: Captain Reyes' standing orders + type: document + body: | + **Fire watch.** Two-hour shifts: 20:00, 22:00, 00:00, 02:00, 04:00. + Watch passes through the six watchkeepers in **alphabetical order of surname**, wrapping round. + Each night, the **first shift** starts with the **next person** after the one who started the night before. + - id: a3-samuel + title: Dr Helena Marlowe on Samuel's death + type: report + body: | + Two puncture marks on the ankle, **4 cm apart**, perfectly parallel. + A puff adder's fangs are under 1.5 cm apart. Whatever this was, it wasn't a snake. + Traces of a sedative in his coffee cup. + B: + - id: b3-roster + title: Watchkeepers and the first night + type: log + body: | + Watchkeepers: **Reyes, Bell, Dunmore, Kessler, H. Marlowe, Pienaar** + (the Professor and the cook were excused). + **Night 1:** first watch taken by Capt. Reyes (he drew the short straw). + Marlowe died on **Night 3**. + lock: + id: lock-watch + prompt: Who was on fire watch at 02:00 on the night Marlowe died? + inputType: choice + options: + - { id: helena, label: Dr Helena Marlowe } + - { id: reyes, label: Captain Arthur Reyes } + - { id: dunmore, label: Mr Felix Dunmore } + - { id: bell, label: Miss Clara Bell } + - { id: kessler, label: Dr Otto Kessler } + - { id: pienaar, label: Jan Pienaar } + answers: ["pienaar"] + hints: + - Put the six watchkeepers in alphabetical order by surname first. + - "Order: Bell, Dunmore, Kessler, Marlowe, Pienaar, Reyes. Night 1 starts with Reyes, Night 2 with Bell, Night 3 with Dunmore." + - "Night 3: Dunmore 20:00, Kessler 22:00, Marlowe 00:00, Pienaar 02:00." + successText: | + **Jan Pienaar.** He tells you: + *"Ten past two, someone walked out to the dig with a lantern. Tall, too far to see a face. + Came back around five past three and went into the **north row** of tents. Samuel was up too, + brought me coffee at half two. He said, 'I know who that is.' I told him to leave it."* + North row tents: **Dunmore, Kessler, Reyes.** + keyName: Firewatch Key + + - id: ch4 + title: Samuel's Note + narrative: | + In Samuel's recipe tin: a scrap of paper in his careful capitals. It's nonsense. On purpose. + clues: + A: + - id: a4-note + title: Note from Samuel's recipe tin + type: note + body: | + ## RS XRMPVSI TN TSDJ XUSUS + + *On the back: "Prof taught me the secret way. My word goes under the letters, + over and over. Count back by each letter of my word: A is 0, B is 1, C is 2..."* + - id: a4-alphabet + title: Alphabet strip + type: diagram + body: | + ``` + A B C D E F G H I J K L M N O P Q R S T U V W X Y Z + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 + ``` + B: + - id: b4-samuel + title: What everyone knew about Samuel + type: note + body: | + Samuel greeted every single person, every single morning, with the same word, and painted it + on his enamel mug: **KOFFIE**. "Everything good starts with it," he'd say. + - id: b4-smokers + title: Camp stores, tobacco issued + type: table + body: | + | Person | Tobacco | + |--------------|---------| + | Capt. Reyes | Pipe tobacco, 2 tins | + | Mr Dunmore | Pipe tobacco, 3 tins (Dutch cherry) | + | Dr Kessler | None ("gave up in '29, doctor's orders") | + | Miss Bell | Cigarettes | + | Mr Pienaar | Cigarettes | + lock: + id: lock-samuel + prompt: What did Samuel's note say? + inputType: text + format: The message (5 words) + answers: ["HESMELLEDOFPIPESMOKE"] + hints: + - The key word is Samuel's word, KOFFIE, repeated under the message and skipping spaces. + - "R − K(10) = H. S − O(14) = E. X − F(5) = S." + - "The message: HE SMELLED OF PIPE SMOKE." + successText: | + **"HE SMELLED OF PIPE SMOKE."** The figure at the dig, returning to the north row, smelled of a pipe. + Samuel knew who, and two nights later he was dead. + keyName: Coffee Tin Key + + - id: ch5 + title: The Diamonds + narrative: | + Dr Kessler said the find "made no geological sense." Marlowe sent three stones to a gemologist + in Cape Town. The reply arrived in the post bag the day after he died. Nobody had opened it. + clues: + A: + - id: a5-gem + title: Gemologist's report + type: report + body: | + The three stones are genuine diamonds. However: + - They are **sharp-edged octahedra**. Diamonds carried by rivers into the desert sand + are always **water-worn and rounded**. These have never been near water. + - Their trace-element profile matches **reference sample K-3** in our collection exactly. + + *Conclusion: these stones were not found where you say they were.* + B: + - id: b5-reference + title: Reference samples (Cape Town Gem Society) + type: table + body: | + | Sample | Source mine | + |--------|-------------| + | K-1 | Jagersfontein | + | K-2 | Koffiefontein | + | K-3 | **Kimberley**, the Big Hole | + | K-4 | Premier Mine | + - id: b5-prospectus + title: Dunmore Kalahari Diamond Co. prospectus + type: document + body: | + *"Alluvial diamonds, discovered personally by our chairman **Mr F. Dunmore** at Site 7. + Shares available now at a very favourable price."* + lock: + id: lock-mine + prompt: Where did the "Kalahari" diamonds really come from? + inputType: text + format: One word + answers: ["KIMBERLEY", "KIMBERLY", "BIGHOLE", "THEBIGHOLE", "KIMBERLEYBIGHOLE"] + hints: + - The report gives a sample code. The clerk has the codes. + - Sample K-3. + - Kimberley. + successText: | + **Kimberley.** Bought stones, scattered in the sand to look like a find. + The "discovery" was a fraud, and the man who found them personally was selling the shares. + keyName: Diamond Key + + - id: ch6 + title: The Unsent Telegram + narrative: | + Marlowe's last act was a telegram he never got to send. Like Samuel, he didn't trust plain words. + clues: + A: + - id: a6-telegram + title: Marlowe's draft telegram + type: letter + body: | + ``` + TO: SOUTH AFRICAN POLICE, UPINGTON + 1.4 3.3 3.9 5.4 7.1 6.2 5.2 4.5 6.7 + ``` + *Pencilled underneath: "Line, then word. The rules we all signed."* + B: + - id: b6-handbook + title: Expedition Handbook, Rules of Camp (page 1) + type: document + body: | + 1. Every member of the expedition shall carry water at all times. + 2. The camp fire is never to be left without a guard after dark. + 3. Report any find to the leader before it is moved or cleaned. + 4. No stone is to be called a diamond until it has been tested. + 5. Samples must be salted away in sealed tins, labelled and dated. + 6. The police at Upington must be informed of any death in camp. + 7. Stop all digging at once if the walls of a trench show cracks. + 8. A find that seems too good to be true usually is. + lock: + id: lock-telegram + prompt: What word in Marlowe's telegram explains everything? + inputType: text + format: One word + answers: ["SALTED"] + hints: + - "1.4 means line 1, word 4." + - "The telegram reads: THE FIND IS ___ STOP POLICE MUST BE INFORMED." + - Line 5, word 4 is "salted". A "salted" mine is one where someone has planted stones to fake a find. + successText: | + **"THE FIND IS SALTED. STOP. POLICE MUST BE INFORMED."** + Marlowe knew. He wrote this the night he died. Someone made sure it never left camp. + keyName: Telegraph Key + +finale: + type: accusation + prompt: The post truck's dust is on the horizon. One accusation. Get every part right. + maxAttempts: 1 + questions: + - id: who + label: Who killed Professor Marlowe? + options: + - { id: helena, label: Dr Helena Marlowe } + - { id: reyes, label: Captain Arthur Reyes } + - { id: dunmore, label: Mr Felix Dunmore } + - { id: bell, label: Miss Clara Bell } + - { id: kessler, label: Dr Otto Kessler } + - { id: pienaar, label: Jan Pienaar } + answer: dunmore + - id: samuel + label: What happened to Samuel? + options: + - { id: snake, label: An accidental puff adder bite } + - { id: same, label: Drugged and killed by the same killer to silence him } + - { id: other, label: Killed by a second, different killer } + - { id: illness, label: Heart failure from the heat } + answer: same + - id: how + label: How did Marlowe die? + options: + - { id: fall, label: He fell into the pit in the dark } + - { id: struck, label: Lured to the dig at 02:00, struck on the head, then put in the pit } + - { id: poison, label: Poisoned at dinner, found dead at the pit } + - { id: shot, label: Shot with the Captain's rifle } + answer: struck + - id: why + label: Why? + options: + - { id: inherit, label: To inherit his estate } + - { id: rival, label: Professional jealousy over the find } + - { id: fraud, label: Marlowe discovered the diamond find was salted and was going to the police } + - { id: affair, label: A love affair gone wrong } + answer: fraud + successEnding: | + Dunmore lights his pipe with steady hands. Dutch cherry. Samuel was right. + + "Do you know what those shares are worth? Do you know what I'd have lost?" + + He scattered Kimberley stones at Site 7 and "discovered" them himself. When Marlowe found out, + Dunmore asked him to the dig at two in the morning "to explain", struck him with a survey pole, + and rolled him into the pit. He wore the same boots as the Captain on purpose. + When Samuel let slip that he knew, Dunmore drugged his coffee and faked a snakebite. + + **Case closed.** The post truck takes one extra passenger back to Upington. 🏜️🔑 + failEnding: | + The truck leaves with the wrong person in handcuffs. Dunmore's shares hit a record high the next month. + reveal: | + **Felix Dunmore** lured Marlowe to the dig at **02:00**, **struck him and put him in the pit**, + then **drugged and killed Samuel** to silence him, all to hide that his diamond find was **salted** + with stones from Kimberley. diff --git a/content/games/kestrel-heist.yaml b/content/games/kestrel-heist.yaml new file mode 100644 index 0000000..091570f --- /dev/null +++ b/content/games/kestrel-heist.yaml @@ -0,0 +1,309 @@ +id: kestrel-heist +engine: story +category: escape-room +title: The Kestrel Bank Job +tagline: A crooked banker stole Gran's necklace. Tonight you're taking it back. In and out. No mistakes. +estimatedMinutes: 50 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#1b1b1e", accent: "#00e5a0", background: "#050506", headingFont: "JetBrains Mono" } +rules: + hintPenaltySeconds: 240 + timeLimitMinutes: 55 + timeLimitLabel: "Until the morning shift" +roles: + A: + name: The Inside Man + blurb: You're in the building. You see the doors, panels, lasers and guards. You don't have the plans. + B: + name: The Wheel + blurb: You're in the van outside with the laptop, the blueprints, and everything you've dug up on the banker. +intro: | + Six months ago, banker **Vincent Kestrel** "held" your gran's emerald necklace as security + on a loan, then forged the paperwork and kept it. The police shrugged. The lawyers shrugged. + + So tonight, you're getting it back. **Kestrel Private Bank, 21:58.** + One of you is inside in a cleaner's uniform with an earpiece. The other is in the van with a laptop. + + Get to his private box. Get the necklace. Get out before the morning shift. + + ☠️ *Nightmare tier. Longer hint penalty. A hard time limit. Trust each other.* + +chapters: + - id: ch1 + title: The Staff Door + narrative: | + The staff entrance has a four-digit keypad. Kestrel uses it himself every morning. + The Inside Man dusts the keypad with the cleaner's talc. + clues: + A: + - id: a1-keypad + title: Dusted keypad + type: diagram + body: | + ``` + [1] [2] [3] Worn, greasy keys: 0, 2, 6 + [4] [5] [6] The 0 is the MOST worn. + [7] [8] [9] Every other key is clean. + [0] + ``` + Code length: 4 digits. + B: + - id: b1-assistant + title: Message from Kestrel's ex-assistant + type: transcript + body: | + "Every code he has is a date, **day then month**, two digits each. + Always someone in his family. He's not as clever as he thinks." + - id: b1-social + title: Kestrel's social media, a few posts + type: document + body: | + - *14 Feb:* "Happy Valentine's to the wife. ❤️" *(deleted a week later)* + - *20 Jun:* "Happy birthday to my princess Mia! 🎂 Daddy loves you." + - *03 Sep:* "Proud dad. Liam made the first XV!" + - *11 Nov:* "Happy birthday Noah, my little man!" + lock: + id: lock-door + prompt: Enter the staff door code. + inputType: code + format: 4 digits (DDMM) + answers: ["2006"] + hints: + - The code uses only the digits 0, 2 and 6, with one of them used twice. + - Which family date uses exactly 0, 2 and 6, with 0 twice? + - Mia's birthday, 20 June = 2006. + successText: | + **2006.** Green light. You're in. Mop bucket rolling, cap pulled low. + Ahead: the long marble corridor to the vault, criss-crossed with red light. + keyName: Staff Door Key + + - id: ch2 + title: The Laser Hall + narrative: | + Five columns of floor tiles. Five rows of laser beams across them. + Exactly one column of tiles is never crossed by a beam. + clues: + A: + - id: a2-hall + title: What the Inside Man sees + type: diagram + body: | + ``` + col1 col2 col3 col4 col5 + row1 ▢ ▢ ▢ ▢ ▢ + row2 ▢ ▢ ▢ ▢ ▢ + row3 ▢ ▢ ▢ ▢ ▢ + row4 ▢ ▢ ▢ ▢ ▢ + row5 ▢ ▢ ▢ ▢ ▢ + ``` + The beams are too faint to see by eye in this light. + Sticker on the laser control box: **"MASK BITS: col1 = 16, col2 = 8, col3 = 4, col4 = 2, col5 = 1"** + B: + - id: b2-config + title: Hacked security config (laptop) + type: log + body: | + ``` + [LASER_HALL] + ROW_MASK_1 = 21 + ROW_MASK_2 = 12 + ROW_MASK_3 = 25 + ROW_MASK_4 = 17 + ROW_MASK_5 = 8 + ; a set bit means the beam crosses that tile + ``` + lock: + id: lock-lasers + prompt: Which column is safe to walk? + inputType: code + format: Column number + answers: ["4"] + hints: + - Each row mask is a sum of the column values its beam crosses. Break each number into 16, 8, 4, 2, 1. + - "21 = 16 + 4 + 1 → columns 1, 3, 5." + - "Rows cross: (1,3,5), (2,3), (1,2,5), (1,5), (2). Column 4 is never crossed." + successText: | + **Column 4.** Tile by tile, heel to toe. Not a single alarm. Halfway there. + keyName: Laser Key + + - id: ch3 + title: The Guards + narrative: | + Past the hall, the vault corridor. Three guards walk through it on fixed loops. + Crossing it to the vault door takes **4 full minutes** of picking the outer lock. + clues: + A: + - id: a3-watching + title: What the Inside Man has clocked from the vent + type: log + body: | + ``` + Guard 1 (big guy, whistles): passes at 22:03, then every 7 minutes + Guard 2 (torch, limps): passes at 22:05, then every 11 minutes + ``` + It's 22:00 now. + B: + - id: b3-roster + title: Security rota (hacked) + type: document + body: | + **Guard 3 (Dlamini):** corridor sweep at **22:02**, then **every 6 minutes**. + *A guard occupies the corridor for the whole minute they pass through it.* + You need **4 clear minutes in a row** with no guard passing. + lock: + id: lock-guards + prompt: At what time does the first safe 4-minute window start? + inputType: code + format: "HH:MM" + answers: ["2233", "1033", "1033PM"] + hints: + - "List the busy minutes: Guard 1 → :03, :10, :17, :24, :31, :38..." + - "Guard 2 → :05, :16, :27, :38. Guard 3 → :02, :08, :14, :20, :26, :32, :38." + - "Gaps before :33 are at most 3 minutes long. :33, :34, :35, :36 are all clear." + successText: | + **22:33.** You slip in behind Guard 3, pick the outer lock in three minutes fifty, and close the door + a breath before Guard 1 whistles past. You're in the vault anteroom. One huge dial. + keyName: Shadow Key + + - id: ch4 + title: The Vault Dial + narrative: | + Three-number combination dial. Kestrel left himself a reminder taped under the anteroom desk. + It's not in any language you know. + clues: + A: + - id: a4-photo + title: Photo on the anteroom desk + type: photo + body: | + *Kestrel with his three sons, a joint birthday party this year. Each boy holds a cake + with candles shaped like numerals: Noah's says **VII**, Liam's says **XIX**, Ben's says **XII**. + Frame engraved: "My boys."* + B: + - id: b4-note + title: Note taped under the desk (Inside Man reads it out letter by letter) + type: note + body: | + ## XLNYRMZGRLM: NB HLMH ZTVH, VOWVHG URIHG + + *Your laptop's notes on Kestrel: "Ancient-cipher nerd. Favourite: the mirror alphabet. A↔Z, B↔Y, C↔X..."* + lock: + id: lock-dial + prompt: Dial the combination. + inputType: code + format: "NN-NN-NN" + answers: ["191207", "19127"] + hints: + - "Mirror alphabet: A↔Z, B↔Y, C↔X. X → C, L → O, N → M..." + - "The note says: COMBINATION: MY SONS AGES, ELDEST FIRST." + - "Ages are XIX = 19, XII = 12, VII = 7. Eldest first: 19-12-07." + successText: | + **19-12-07.** Twelve tons of steel swing open on silent hinges. + A wall of safe deposit boxes. Hundreds of them. **Which one is his?** + keyName: Vault Key + + - id: ch5 + title: Box Number + narrative: | + Kestrel doesn't keep stolen goods under his own name. He uses aliases, all birds of prey. + On the anteroom terminal, a sticky note with a database query in his handwriting. + clues: + A: + - id: a5-query + title: Sticky note on the terminal + type: note + body: | + ```sql + SELECT b.box_no + FROM boxes b + JOIN clients c ON c.client_id = b.client_id + WHERE c.alias LIKE 'K%' + AND b.size = 'L' + AND b.last_opened >= '2026-06-01'; + ``` + - id: a5-clients + title: Terminal screen, CLIENTS table + type: table + body: | + | client_id | alias | + |-----------|------------| + | 101 | Kestrel | + | 102 | Heron | + | 103 | Kite | + | 104 | Kingfisher | + | 105 | Osprey | + B: + - id: b5-boxes + title: Downloaded BOXES table (laptop) + type: table + body: | + | box_no | client_id | size | last_opened | + |--------|-----------|------|-------------| + | 214 | 101 | M | 2026-07-14 | + | 233 | 103 | L | 2026-05-31 | + | 309 | 103 | L | 2026-03-02 | + | 317 | 104 | L | 2026-08-30 | + | 402 | 102 | L | 2026-09-01 | + | 411 | 101 | L | 2026-05-29 | + | 518 | 105 | L | 2026-09-10 | + | 520 | 104 | S | 2026-09-11 | + lock: + id: lock-box + prompt: Which box do you open? + inputType: code + format: Box number + answers: ["317"] + hints: + - "Aliases starting with K: Kestrel (101), Kite (103), Kingfisher (104)." + - "Large boxes for those clients: 233, 309, 317, 411." + - Only 317 was opened on or after 1 June 2026. + successText: | + **Box 317.** Inside, wrapped in a bank cloth: Gran's emerald necklace. And a stack of other + "held as security" jewellery with little tags. You photograph every tag for the police. + keyName: Emerald Key + +finale: + type: escape + prompt: Alarms trip as you leave the vault. The getaway meet point is in a coded text from your driver. + clues: + A: + - id: af-text + title: Text from the driver (on the Inside Man's burner) + type: transcript + body: | + ``` + MUTLEETSTHODIREAEP + ``` + *"Zig-zag it. Rails = floors in that building. You know the rest."* + B: + - id: bf-blueprint + title: Kestrel Private Bank blueprint, cover sheet + type: document + body: | + **Floors:** Ground (banking hall), First (offices), Second (vault and anteroom). + *Basement: none. The vault is upstairs, which is why the lift is reinforced.* + - id: bf-railfence + title: Your notes on zig-zag (rail fence) ciphers + type: note + body: | + Write the message in a zig-zag down and up across N rails, then read each rail left to right. + To decode: work out how many letters sit on each rail, split the text, then zig-zag back through. + lock: + id: lock-getaway + prompt: Where's the meet point? + inputType: text + format: The place (a few words) + answers: ["OLDPIER", "THEOLDPIER", "MEETUSATTHEOLDPIER"] + hints: + - The building has 3 floors, so 3 rails. + - "18 letters on 3 rails split 5 / 9 / 4: MUTLE · ETSTHODIR · EAEP." + - Zig-zag back through and it reads MEET US AT THE OLD PIER. + successEnding: | + **The old pier.** You sprint out the loading dock as sirens wake up three blocks away. The van's door + is already open. Gran's necklace is in your pocket, and Kestrel's box of stolen tags is on its way to a + journalist who's been waiting six months for exactly this. + + **You got out!** 💎🔑 + failEnding: | + The morning shift arrives. You're still inside, holding a mop. (It's a game. Run it back?) diff --git a/content/games/masquerade-montague.yaml b/content/games/masquerade-montague.yaml new file mode 100644 index 0000000..162180b --- /dev/null +++ b/content/games/masquerade-montague.yaml @@ -0,0 +1,313 @@ +id: masquerade-montague +engine: story +category: murder-mystery +title: The Masquerade at Montague House +tagline: 1894. Five masks. One swap. A lord who never unmasked. +estimatedMinutes: 40 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#4a1942", accent: "#e0c068", background: "#0f0a12", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Inspector + blurb: Scotland Yard, called in at midnight. You have the staff's statements and the gardener's notes. + B: + name: The Lady's Maid + blurb: You've served Montague House for years. You saw the guests arrive, and you hear everything. +intro: | + **Montague House, Surrey. New Year's Eve, 1894.** + + Lord Julian Montague's masquerade is the event of the season. At midnight, the guests + unmask for the toast. Lord Julian doesn't. He's found in the glass conservatory, + a jewelled dagger from his own armoury missing from its case and nowhere to be found. + + Five guests. Five masks. **And at least one of them is lying about which mask they wore.** + + ⚠️ *Expert case: the locks only give you facts. A wrong accusation costs you dearly.* + +suspects: + - id: agatha + name: Lady Agatha Stroud + role: Dowager + description: Julian's aunt. Controls the family purse strings and hates parties. + - id: rupert + name: Colonel Rupert Blackwood + role: Retired soldier + description: Owes Julian a fortune in card debts. Loud, red-faced, and very nervous tonight. + - id: ivy + name: Miss Ivy Marsh + role: Young guest + description: Nobody quite knows who invited her. Charming, quiet, watches Julian closely. + - id: silas + name: Mr Silas Penhaligon + role: Business partner + description: Julian's partner in a shipping company that is quietly going bankrupt. + - id: odette + name: Mrs Odette Vance + role: Widow + description: Julian courted her for a year, then dropped her for a duke's daughter. Everyone saw her scene at dinner. + +chapters: + - id: ch1 + title: Who Wore What + narrative: | + Every guest arrived masked and cloaked. The footman logged the masks: **Fox, Raven, Swan, Lion, Stag**. + The cloaks were **crimson, green, black, silver, gold**. Nobody wrote down who wore what. + You'll have to work it out together. + clues: + A: + - id: a1-butler + title: The butler's recollections + type: note + body: | + 1. The Colonel wore **green**. "Like a proper huntsman," he kept saying. + 2. The **Swan** wore **black**. Some joke about black swans. + 3. Mr Penhaligon did **not** wear a bird mask. + 4. Whoever wore **gold** had a **bird** mask. (Birds: Raven, Swan.) + B: + - id: b1-maid + title: What you noticed in the cloakroom + type: note + body: | + 5. The **Lion** wore **silver**. + 6. None of the three ladies wore the **Fox** or the **Lion**. + 7. Miss Marsh did **not** wear gold. + 8. Lady Agatha calls feathers "vulgar". She would **never** wear a bird mask. + shared: + - id: s1-guests + title: The guest list + type: document + body: | + **Ladies:** Lady Agatha Stroud, Miss Ivy Marsh, Mrs Odette Vance + **Gentlemen:** Colonel Rupert Blackwood, Mr Silas Penhaligon + Each guest wore exactly one mask and one cloak. No two guests shared either. + lock: + id: lock-raven + prompt: Who arrived wearing the Raven mask? + inputType: choice + options: + - { id: agatha, label: Lady Agatha Stroud } + - { id: rupert, label: Colonel Rupert Blackwood } + - { id: ivy, label: Miss Ivy Marsh } + - { id: silas, label: Mr Silas Penhaligon } + - { id: odette, label: Mrs Odette Vance } + answers: ["odette"] + hints: + - Start with the men. Clue 6 says the ladies never wore Fox or Lion, so the two men wore Fox and Lion. + - The Colonel wore green and the Lion wore silver, so the Colonel is the Fox and Penhaligon is the Lion (silver). Agatha can't wear a bird, so she's the Stag. + - Ivy and Odette share Raven and Swan. The Swan wears black; gold goes to a bird; Ivy isn't gold. So Ivy is the black Swan, and Odette is the gold Raven. + successText: | + **Mrs Odette Vance arrived as the Raven, in gold.** + The full picture: Agatha (Stag, crimson) · Rupert (Fox, green) · Ivy (Swan, black) · Silas (Lion, silver) · Odette (Raven, gold). + Write that down. You'll need it. + keyName: Feather Key + + - id: ch2 + title: The Raven in the Conservatory + narrative: | + One witness saw someone go into the conservatory just before midnight. + But a masquerade is a very good place to be someone else. + clues: + A: + - id: a2-footman + title: Footman's statement + type: transcript + body: | + "Ten to midnight, sir. Someone in the **Raven** mask went into the conservatory. Quick, like. + I'd swear to the mask. And the cloak was **black**. I remember thinking it looked like a funeral." + - id: a2-odette + title: Mrs Vance's statement + type: transcript + body: | + "Yes, I hated him. Half the room heard me say so at dinner. But I was in the ballroom + from eleven onward. Ask anyone who danced with the **Swan**." *(She doesn't seem to notice what she said.)* + B: + - id: b2-cloakroom + title: What you saw in the cloakroom at 23:00 + type: note + body: | + Two of the ladies came in giggling and **traded masks**, the Raven and the Swan. + They **kept their own cloaks**. One of them said, "The Colonel will never guess, it'll be such a lark." + - id: b2-colonel + title: The Colonel, overheard + type: transcript + body: | + "Julian gave me till Friday to pay. Friday! I'd sooner see him at the bottom of the lake." + *(He was in the card room from 23:30 until midnight with Mr Penhaligon. Both swear to it.)* + lock: + id: lock-seen + prompt: Who did the footman really see enter the conservatory? + inputType: choice + options: + - { id: agatha, label: Lady Agatha Stroud } + - { id: rupert, label: Colonel Rupert Blackwood } + - { id: ivy, label: Miss Ivy Marsh } + - { id: silas, label: Mr Silas Penhaligon } + - { id: odette, label: Mrs Odette Vance } + answers: ["ivy"] + hints: + - The swap was Raven ↔ Swan. Who had those masks at the start, and what colour were their cloaks? + - After 23:00 the Raven mask was worn with whichever cloak the Swan had arrived in. + - Ivy arrived as the black Swan. After the swap she wore the Raven with her own black cloak. + successText: | + **Miss Ivy Marsh.** After the swap, Ivy wore the Raven with her black cloak. Odette wore the Swan with gold. + Anyone looking for "the Raven" would blame Odette, the jilted widow who'd made a scene at dinner. + keyName: Mask Key + + - id: ch3 + title: The Missing Dagger + narrative: | + The jewelled dagger isn't on the body or in the house. The police think it's still in the conservatory, + buried in one of the sixteen numbered plant pots. They can't dig up every pot before dawn. + clues: + A: + - id: a3-gardener + title: The gardener's evening notes + type: note + body: | + *Watered every pot at 10 o'clock tonight, soaked through, **except the prime-numbered pots**. + Those are His Lordship's cacti. Water rots them.* + B: + - id: b3-police + title: Constable's conservatory notes + type: report + body: | + - Fresh disturbance in the soil of **pots 4, 9, 11 and 15**. + - A pair of **white evening gloves** found burning in the conservatory stove. Unburnt fingertips are **spotless, no mud at all**. + - Pots 4, 9 and 15 were re-potted by the under-gardener this afternoon (his note on the potting bench). + - id: b3-map + title: Sketch of the conservatory pots + type: map + body: | + ``` + [ 1] [ 2] [ 3] [ 4] + [ 5] [ 6] [ 7] [ 8] + [ 9] [10] [11] [12] + [13] [14] [15] [16] + ``` + lock: + id: lock-pot + prompt: Which pot is the dagger buried in? + inputType: code + format: Pot number + answers: ["11"] + hints: + - At midnight, which pots would be muddy? Which would be dry? + - Clean gloves means the killer dug in dry soil. Dry soil means a prime-numbered pot. + - Of the disturbed pots, only 11 is prime, and the others have an innocent explanation anyway. + successText: | + **Pot 11.** Under the cactus: the jewelled dagger. Wrapped around the hilt is a strip of black silk + torn from a cloak. + keyName: Dagger Key + + - id: ch4 + title: Julian's Confession + narrative: | + In Lord Julian's desk, a sealed envelope addressed *"To be opened after my death."* + Inside: rows of numbers, and nothing else. Julian was fond of the old soldiers' square cipher. + clues: + A: + - id: a4-numbers + title: The letter inside the envelope + type: letter + body: | + ``` + 44 23 13 25 31 + 52 23 25 + 42 21 21 12 35 22 21 14 + ``` + *Below, in Julian's hand: "God forgive me. I built my square on our motto, as Father taught me."* + - id: a4-howto + title: Police cipher handbook, page 12 + type: document + body: | + **The square cipher (Polybius).** Write a keyword into a 5×5 square, row by row, skipping + repeated letters, then fill the rest with the unused alphabet (I and J share a square). + Each letter is written as **row then column**, e.g. 23 = row 2, column 3. + B: + - id: b4-crest + title: The Montague crest above the fireplace + type: photo + body: | + *A carved stone shield with a lion and a ship. Beneath it, the family motto:* + **FORTUNE FAVOURS THE BOLD** + - id: b4-marsh + title: Servants' gossip about the Marsh family + type: note + body: | + Twenty years ago, Mr Edward Marsh owned this house. Lord Julian accused him of cheating at cards. + Marsh lost everything, the house went to Julian, and Marsh died in debtors' prison. + *He had a little daughter. Nobody knows what became of her.* + lock: + id: lock-confession + prompt: What did Julian confess? + inputType: text + format: Three words + answers: ["MARSHWASINNOCENT"] + hints: + - Build the square from FORTUNE FAVOURS THE BOLD, skipping repeats. + - "Row 1: F O R T U. Row 2: N E A V S. Row 3: H B L D C. Row 4: G I K M P. Row 5: Q W X Y Z." + - 44 = row 4, column 4 = M. The message starts MARSH. + successText: | + **"MARSH WAS INNOCENT."** Julian framed Edward Marsh and took his house. + And Edward Marsh had a daughter. **Ivy** Marsh. + keyName: Crest Key + +finale: + type: accusation + prompt: The clocks strike one. Make your accusation, all four parts. + maxAttempts: 1 + questions: + - id: who + label: Who killed Lord Julian? + options: + - { id: agatha, label: Lady Agatha Stroud } + - { id: rupert, label: Colonel Rupert Blackwood } + - { id: ivy, label: Miss Ivy Marsh } + - { id: silas, label: Mr Silas Penhaligon } + - { id: odette, label: Mrs Odette Vance } + answer: ivy + - id: mask + label: Which mask was the killer wearing in the conservatory? + options: + - { id: fox, label: Fox } + - { id: raven, label: Raven } + - { id: swan, label: Swan } + - { id: lion, label: Lion } + - { id: stag, label: Stag } + answer: raven + - id: how + label: How? + options: + - { id: dagger, label: Stabbed with the armoury dagger, then buried it in a dry cactus pot } + - { id: poison, label: Poison in his champagne } + - { id: lake, label: Drowned in the conservatory pond } + - { id: pistol, label: Shot with a silenced pistol } + answer: dagger + - id: why + label: Why? + options: + - { id: jilted, label: He jilted her for a duke's daughter } + - { id: debts, label: To escape gambling debts } + - { id: father, label: He framed her father and stole the family home } + - { id: business, label: To save a failing shipping company } + answer: father + successEnding: | + Ivy takes off the Raven mask and sets it on the table, very gently. + + "He took our house. He took my father. Twenty years I've practised being a stranger in it." + + She suggested the mask swap as "a lark" so that Odette, the famous jilted widow, would be seen + as the Raven. She followed Julian to the conservatory at ten to midnight, used the dagger + she'd lifted during the Grand March, and buried it in the one pot she knew would be dry. + She forgot one thing: she swapped masks, but not cloaks. + + **Case closed.** 🎭🔑 + failEnding: | + The Inspector makes an arrest. The real killer curtsies on her way out the door, and is never seen again. + reveal: | + **Miss Ivy Marsh**, wearing **Odette's Raven mask** after the swap, stabbed Julian with the + **armoury dagger** and buried it in **cactus pot 11**, because Julian had **framed her father + and stolen Montague House**. diff --git a/content/games/mirror-maze.yaml b/content/games/mirror-maze.yaml new file mode 100644 index 0000000..a690226 --- /dev/null +++ b/content/games/mirror-maze.yaml @@ -0,0 +1,94 @@ +id: mirror-maze +engine: labyrinth +category: co-op +title: The Mirror Maze +tagline: The map is backwards. Your partner can't tell north from left. Seven traps. Good luck. +estimatedMinutes: 30 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#5f0f40", accent: "#c0fdff", background: "#07040a", headingFont: "Playfair Display" } +roles: + A: + name: Navigator + blurb: You hold the only map of the maze. It was copied from a mirror, so it's flipped left to right. + B: + name: Explorer + blurb: You're inside a hall of mirrors. You have no compass, only forward, back, left and right. +intro: | + An abandoned carnival on the edge of town. Its famous **Mirror Maze** has four **glass lanterns** hidden inside. + Legend says couples who carry all four out together never lose each other. + + ☠️ **Nightmare rules:** + - **Explorer:** you have no compass. Your controls are **Forward / Left / Right / Back**, relative to the way you're facing. + Whenever you move, you turn to face that way. + - **Navigator:** the only surviving map was **traced from a mirror**. Everything on it is flipped **left to right**. + - Some carvings appear **twice** in the maze. Don't trust a single glyph. + - **Seven** hidden traps. Four lanterns needed. + +# Grid as it REALLY is (server truth). The Navigator is served the mirror image (see navigatorView). +grid: + - "###############" + - "#S..#.........#" + - "###.#.#.#####.#" + - "#K#.....#...#.#" + - "#.####.##.#.#.#" + - "#..T.....T....#" + - "#.###T#.###..T#" + - "#...#T#.#.....#" + - "#.#.#.#.#.###.#" + - "#..K#.#.....#K#" + - "#.#.#.#.###.#T#" + - "#.#.........#.#" + - "#.#.#.#.###.#.#" + - "#KT...#......E#" + - "###############" + +lanternsRequired: 4 + +explorer: + compass: relative # controls are forward/left/right/back instead of N/E/S/W + startFacing: e # the only open way out of S + # Server converts relative → absolute using the Explorer's current facing, then sets facing = move direction. + # Explorer's view lists open directions as relative ("Open: forward, right") and never shows N/E/S/W. + +glyphs: + - { x: 1, y: 1, glyph: ticket, label: "🎟 A torn carnival ticket (the entrance)" } + - { x: 3, y: 1, glyph: crescent, label: "☾ A crescent moon etched in the glass" } + - { x: 11, y: 11, glyph: crescent, label: "☾ A crescent moon etched in the glass" } + - { x: 9, y: 3, glyph: star, label: "✦ A four-pointed star" } + - { x: 7, y: 11, glyph: star, label: "✦ A four-pointed star" } + - { x: 13, y: 1, glyph: shard, label: "🪞 A shattered mirror panel" } + - { x: 5, y: 5, glyph: bell, label: "🔔 A tiny brass bell hanging from the ceiling" } + - { x: 11, y: 7, glyph: butterfly, label: "🦋 A painted butterfly" } + - { x: 1, y: 7, glyph: feather, label: "🪶 A feather on the floor" } + - { x: 9, y: 9, glyph: rose, label: "🌹 A paper rose" } + - { x: 5, y: 13, glyph: heart, label: "♥ Two hearts scratched into the glass" } + +lanterns: + - { x: 1, y: 3, name: Lantern of Reflection } + - { x: 1, y: 13, name: Lantern of Patience } + - { x: 3, y: 9, name: Lantern of Trust } + - { x: 13, y: 9, name: Lantern of Home } + +navigatorView: + mapTransform: mirror-x # serve each row reversed; glyph/lantern/trap x becomes (width - 1 - x) + showMoveCount: true + banner: "This map was traced from a mirror. Left is right. Right is left." + +explorerView: + flavour: + blank: "Your own reflection stares back from every side." + trap: "The mirror floor cracks and you drop through... and wake up at the entrance, facing the way in." + lantern: "A glass lantern glows to life in your hands. ({name})" + exitLocked: "A mirrored door with four lantern hooks. It won't budge." + exitOpen: "You hang four lanterns. Every mirror in the maze lights up at once, and the door opens onto the fairground." + +scoring: + trapPenaltySeconds: 90 + parMoves: 110 + +successEnding: | + The Explorer stumbles out into the empty fairground. The Navigator is waiting by the ferris wheel, + holding the backwards map upside down, grinning. + + **You never lost each other.** 🪞🔑 diff --git a/content/games/tick-tock-overtime.yaml b/content/games/tick-tock-overtime.yaml new file mode 100644 index 0000000..3efa10b --- /dev/null +++ b/content/games/tick-tock-overtime.yaml @@ -0,0 +1,303 @@ +id: tick-tock-overtime +engine: defuse +category: co-op +title: "Tick Tock: Overtime" +tagline: Six modules. Four levels. A manual that fights back. +estimatedMinutes: 20 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#ff3b30", accent: "#00d1ff", background: "#0a0a0a", headingFont: "JetBrains Mono" } +roles: + A: + name: Technician + blurb: You see the device. You can't see the manual. Describe everything, exactly. + B: + name: Expert + blurb: You have the manual. You can't see the device. Ask the right questions, in the right order. +intro: | + Remember the training device? This is the sequel. Someone upgraded it. + New modules, new rules, indicator lights, and a manual written by someone who clearly hates couples. + + **Technician:** start by reading out the serial number, battery count, and every indicator (lit and unlit). + **Expert:** you'll need all of it. + +# ---------- Device definition ---------- +ruleset: overtime # which manual the server's solver implements (server/src/engines/defuse.ts) +device: + serial: + length: 6 + alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # last char is ALWAYS a digit + batteries: { min: 0, max: 4 } + indicators: + labels: [LUV, HUG, BAE, XOX, DTE] + count: { min: 1, max: 3 } # each shown indicator is randomly lit or unlit + +levels: + - id: lvl1 + title: Warm-up (that isn't) + timeSeconds: 240 + modules: [wires, button, keypad] + keyName: Spark Key + - id: lvl2 + title: Words & Waves + timeSeconds: 270 + modules: [wires, passwords, morse] + keyName: Frequency Key + - id: lvl3 + title: Say It Back + timeSeconds: 300 + modules: [wires, button, passwords, simon] + keyName: Echo Key + - id: lvl4 + title: Overtime + timeSeconds: 360 + strikesOverride: 2 # only 2 strikes on this level + modules: [wires, button, keypad, passwords, morse, simon] + keyName: Overtime Key + +strikes: { max: 3, speedUpPerStrike: 0.25 } + +# ---------- Module generation ---------- +modules: + wires: + count: { min: 3, max: 6 } + colors: [red, blue, yellow, green, white, black] + # Technician: wires top to bottom, colour swatch + colour NAME label. Action { type: "cut", index } + button: + labels: [HOLD, PRESS, ABORT, DETONATE, LOVE] + colors: [red, blue, white, yellow] + stripColors: [blue, white, yellow, red] + # Strip only shown WHILE holding. Actions: tap | hold → release { timerText } + keypad: + symbols: + heart: "♥ Heart" + star: "★ Star" + moon: "☾ Moon" + sun: "☀ Sun" + club: "♣ Club" + flower: "✿ Flower" + key: "⚷ Key" + umbrella: "☂ Umbrella" + plane: "✈ Plane" + music: "♫ Music note" + anchor: "⚓ Anchor" + crown: "♛ Crown" + diamond: "♦ Diamond" + spade: "♠ Spade" + bolt: "⚡ Bolt" + cloud: "☁ Cloud" + columns: + - [heart, star, moon, sun, club, flower, key] + - [umbrella, heart, plane, music, star, anchor, crown] + - [diamond, sun, spade, umbrella, flower, music, bolt] + - [anchor, spade, club, plane, moon, diamond, key] + - [crown, bolt, heart, diamond, plane, sun, cloud] + - [cloud, music, key, spade, club, crown, star] + # Generator: pick a column, pick 4 of its symbols, shuffle. Re-roll if any OTHER column also + # contains all 4 (validated: no 4-subset of these columns is ambiguous). + passwords: + columnsCount: 5 + lettersPerColumn: 6 + words: [ADORE, ANGEL, BLISS, BLUSH, CANDY, CHARM, CRUSH, DANCE, DREAM, FLAME, FLIRT, HEART, + HONEY, LOVER, MATCH, PEACH, RINGS, ROSES, SMILE, SPARK, SUGAR, SWEET, TEDDY, TRUST, + TULIP, UNITY, WALTZ, BLOOM, CHOSE, PEARL, VOWED, QUEEN] + # Technician sees 5 columns with up/down arrows; each column cycles through 6 letters. + # Generator: pick a target word; fill each column with its letter + 5 random letters; re-roll until + # EXACTLY ONE word in the list can be formed. Action { type: "submit", word } + morse: + # Technician sees a blinking light (reuse the signal renderer, §6.3) and a frequency dial + # from 3.500 to 3.600 MHz in 0.001 steps + a TX button. Action { type: "transmit", frequency } + words: + HUG: "3.505" + KISS: "3.515" + LOVE: "3.522" + DATE: "3.532" + RING: "3.535" + ROSE: "3.542" + WINE: "3.545" + DUET: "3.552" + DANCE: "3.555" + HEART: "3.565" + SWEET: "3.572" + HONEY: "3.575" + CUDDLE: "3.582" + SPARK: "3.592" + VOWS: "3.595" + BLISS: "3.600" + simon: + colors: [red, blue, green, yellow] + stages: { min: 3, max: 5 } + # Device flashes a sequence; each stage adds one flash. Technician presses the MAPPED colours + # for the whole sequence so far. Mapping depends on (serial has a vowel) and CURRENT strike count. + # Action { type: "press", color } + +# ---------- The manual (shown ONLY to the Expert) ---------- +manual: | + # Defusal Manual: Overtime Edition + + **Before anything else, ask for:** serial number · battery count · every indicator and whether it's **lit**. + A "vowel" means A, E, I, O or U. + + --- + + ## Module: Wires (3 to 6 wires) + Wires are numbered **top to bottom** from 1. Cut exactly one. Find the section for the number of wires, + then use the **first** rule that applies. + + **3 wires** + 1. If all three wires are **different colours** → cut the **second** wire. + 2. If there are **two or more blue** → cut the **first blue** wire. + 3. If the serial contains a **vowel** → cut the **last** wire. + 4. Otherwise → cut the **first** wire. + + **4 wires** + 1. If there is **exactly one green** wire and **2 or more batteries** → cut the **green** wire. + 2. If the **first and last** wires are the **same colour** → cut the **third** wire. + 3. If there are **no white** wires → cut the **second** wire. + 4. Otherwise → cut the **last white** wire. + + **5 wires** + 1. If there are **more black than red** wires → cut the **first black** wire. + 2. If the serial's **last digit is odd** → cut the **fourth** wire. + 3. If there is **exactly one yellow** → cut the wire **directly after** the yellow (if yellow is last, cut the **first**). + 4. Otherwise → cut the **second** wire. + + **6 wires** + 1. If there's a **lit LUV** indicator → cut the **sixth** wire. + 2. If there are **exactly three red** wires → cut the **third red** wire. + 3. If there are **no yellow** wires → cut the **third** wire. + 4. Otherwise → cut the **fifth** wire. + + --- + + ## Module: The Button + First rule that applies: + 1. **Blue** button that says **ABORT** → **hold**. + 2. **More than 1 battery** and says **DETONATE** → **tap**. + 3. **White** button and a **lit BAE** → **hold**. + 4. **More than 2 batteries** and a **lit HUG** → **tap**. + 5. **Yellow** button → **hold**. + 6. **Red** button that says **HOLD** → **tap**. + 7. Otherwise → **hold**. + + **If holding**, a strip lights up. Release when the timer shows this digit **anywhere**: + Blue → **4** · White → **1** · Yellow → **5** · Red → **3** + + --- + + ## Module: Keypad + Exactly one column below contains all four symbols on the device. + Press them in the order they appear **top to bottom** in that column. + + | 1 | 2 | 3 | 4 | 5 | 6 | + |---|---|---|---|---|---| + | ♥ Heart | ☂ Umbrella | ♦ Diamond | ⚓ Anchor | ♛ Crown | ☁ Cloud | + | ★ Star | ♥ Heart | ☀ Sun | ♠ Spade | ⚡ Bolt | ♫ Music note | + | ☾ Moon | ✈ Plane | ♠ Spade | ♣ Club | ♥ Heart | ⚷ Key | + | ☀ Sun | ♫ Music note | ☂ Umbrella | ✈ Plane | ♦ Diamond | ♠ Spade | + | ♣ Club | ★ Star | ✿ Flower | ☾ Moon | ✈ Plane | ♣ Club | + | ✿ Flower | ⚓ Anchor | ♫ Music note | ♦ Diamond | ☀ Sun | ♛ Crown | + | ⚷ Key | ♛ Crown | ⚡ Bolt | ⚷ Key | ☁ Cloud | ★ Star | + + --- + + ## Module: Passwords + The Technician can cycle five letter columns. Exactly **one** word below can be spelled. + Find it and have them submit it. + + ADORE · ANGEL · BLISS · BLOOM · BLUSH · CANDY · CHARM · CHOSE · CRUSH · DANCE · DREAM + FLAME · FLIRT · HEART · HONEY · LOVER · MATCH · PEACH · PEARL · QUEEN · RINGS · ROSES + SMILE · SPARK · SUGAR · SWEET · TEDDY · TRUST · TULIP · UNITY · VOWED · WALTZ + + *Tip: ask for the letters in column 1 first, then 2. That usually narrows it down fast.* + + --- + + ## Module: Morse Light + The light blinks a word in Morse, over and over, with a long pause between repeats. + Short = dot · Long = dash. Decode the word, then have them tune and transmit: + + | Word | MHz | Word | MHz | + |------|-----|------|-----| + | HUG | 3.505 | DUET | 3.552 | + | KISS | 3.515 | DANCE | 3.555 | + | LOVE | 3.522 | HEART | 3.565 | + | DATE | 3.532 | SWEET | 3.572 | + | RING | 3.535 | HONEY | 3.575 | + | ROSE | 3.542 | CUDDLE | 3.582 | + | WINE | 3.545 | SPARK | 3.592 | + | BLISS | 3.600 | VOWS | 3.595 | + + `A .- B -... C -.-. D -.. E . F ..-. G --. H .... I .. J .--- K -.- L .-.. M --` + `N -. O --- P .--. Q --.- R .-. S ... T - U ..- V ...- W .-- X -..- Y -.-- Z --..` + + --- + + ## Module: Say It Back (Simon) + The device flashes colours. The Technician must press the **mapped** colour for every flash so far. + ⚠️ The mapping **changes with the strike count**. Re-check after every strike. + + **Serial HAS a vowel** + | Strikes | Red → | Blue → | Green → | Yellow → | + |---------|-------|--------|---------|----------| + | 0 | Blue | Red | Yellow | Green | + | 1 | Yellow | Green | Blue | Red | + | 2 | Green | Red | Yellow | Blue | + + **Serial has NO vowel** + | Strikes | Red → | Blue → | Green → | Yellow → | + |---------|-------|--------|---------|----------| + | 0 | Blue | Yellow | Green | Red | + | 1 | Red | Blue | Yellow | Green | + | 2 | Yellow | Green | Blue | Red | + +# ---------- Solver fixtures (MUST pass as unit tests) ---------- +# Generated from the reference solver in tools/reference/overtime_solver.py, which implements +# the manual above exactly. Port that solver to TypeScript in server/src/engines/defuse.ts. +fixtures: + wires: + - {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}} + - {"serial": "QX7M35", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "blue"], "expect": {"type": "cut", "index": 1}} + - {"serial": "ZP2R44", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "white"], "expect": {"type": "cut", "index": 1}} + - {"serial": "BC2D48", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "green"], "expect": {"type": "cut", "index": 1}} + - {"serial": "AB9C11", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["green", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}} + - {"serial": "LM3N06", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "yellow", "blue"], "expect": {"type": "cut", "index": 3}} + - {"serial": "RT5W22", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "yellow", "red", "black"], "expect": {"type": "cut", "index": 2}} + - {"serial": "HK8P71", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "white"], "expect": {"type": "cut", "index": 4}} + - {"serial": "XY3Z45", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["black", "black", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}} + - {"serial": "PQ7R18", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}} + - {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}} + - {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "white", "white", "yellow"], "expect": {"type": "cut", "index": 1}} + - {"serial": "ST4V20", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "green", "black"], "expect": {"type": "cut", "index": 2}} + - {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": ["LUV"], "unlit": []}, "wires": ["red", "red", "red", "blue", "white", "green"], "expect": {"type": "cut", "index": 6}} + - {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": ["LUV"]}, "wires": ["red", "blue", "red", "white", "red", "green"], "expect": {"type": "cut", "index": 5}} + - {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 3}} + - {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["yellow", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 5}} + button: + - {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "ABORT", "color": "blue", "strip": "yellow", "expect": {"type": "hold", "releaseDigit": 5}} + - {"batteries": 2, "indicators": {"lit": [], "unlit": []}, "label": "DETONATE", "color": "red", "strip": "blue", "expect": {"type": "tap"}} + - {"batteries": 1, "indicators": {"lit": ["BAE"], "unlit": []}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}} + - {"batteries": 3, "indicators": {"lit": ["HUG"], "unlit": []}, "label": "PRESS", "color": "red", "strip": "white", "expect": {"type": "tap"}} + - {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "PRESS", "color": "yellow", "strip": "blue", "expect": {"type": "hold", "releaseDigit": 4}} + - {"batteries": 1, "indicators": {"lit": [], "unlit": []}, "label": "HOLD", "color": "red", "strip": "white", "expect": {"type": "tap"}} + - {"batteries": 1, "indicators": {"lit": [], "unlit": ["HUG"]}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}} + keypad: + - {"symbols": ["star", "flower", "heart", "sun"], "expect": ["heart", "star", "sun", "flower"]} + - {"symbols": ["spade", "plane", "anchor", "moon"], "expect": ["anchor", "spade", "plane", "moon"]} + - {"symbols": ["music", "umbrella", "diamond", "sun"], "expect": ["diamond", "sun", "umbrella", "music"]} + - {"symbols": ["cloud", "crown", "bolt", "plane"], "expect": ["crown", "bolt", "plane", "cloud"]} + - {"symbols": ["star", "key", "club", "crown"], "expect": ["key", "club", "crown", "star"]} + passwords: + - {"columns": ["XAPUDI", "RVCSEX", "TGAHRQ", "RASIYZ", "CDGKYL"], "expect": "PEARL"} + - {"columns": ["BZRWXO", "GLTUAS", "YHCDLJ", "FVTGIL", "OFZIUM"], "expect": "WALTZ"} + - {"columns": ["KYBZTH", "UPOMRG", "SLXUMN", "SEFZUD", "ROTIAQ"], "expect": "TRUST"} + morse: + - {"signal": "..../..-/--.", "expect": {"word": "HUG", "frequency": "3.505"}} + - {"signal": "...././.-/.-./-", "expect": {"word": "HEART", "frequency": "3.565"}} + - {"signal": "-.../.-../../.../...", "expect": {"word": "BLISS", "frequency": "3.600"}} + - {"signal": "...-/---/.--/...", "expect": {"word": "VOWS", "frequency": "3.595"}} + simon: + - {"serial": "TK4E82", "strikes": 0, "flashes": ["red", "green", "yellow"], "expect": ["blue", "yellow", "green"]} + - {"serial": "TK4E82", "strikes": 1, "flashes": ["red", "green", "yellow"], "expect": ["yellow", "blue", "red"]} + - {"serial": "ZP2R44", "strikes": 0, "flashes": ["blue", "blue", "green", "yellow"], "expect": ["yellow", "yellow", "green", "red"]} + - {"serial": "ZP2R44", "strikes": 2, "flashes": ["yellow", "red", "green"], "expect": ["red", "yellow", "blue"]} diff --git a/package-lock.json b/package-lock.json index 2e045da..3a6d440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "two-keys", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "two-keys", - "version": "1.0.1", + "version": "1.1.0", "workspaces": [ "shared", "server", @@ -23,7 +23,7 @@ }, "client": { "name": "@two-keys/client", - "version": "1.0.1", + "version": "1.1.0", "dependencies": { "@two-keys/shared": "*", "framer-motion": "^11.11.0", @@ -6847,7 +6847,7 @@ }, "server": { "name": "@two-keys/server", - "version": "1.0.1", + "version": "1.1.0", "dependencies": { "@two-keys/shared": "*", "express": "^4.21.0", @@ -7332,7 +7332,7 @@ }, "shared": { "name": "@two-keys/shared", - "version": "1.0.1", + "version": "1.1.0", "devDependencies": { "vitest": "^2.1.0" } diff --git a/package.json b/package.json index 2f4ac3b..b367661 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "two-keys", "private": true, - "version": "1.0.1", + "version": "1.1.0", "description": "Co-op mystery games for couples", "workspaces": [ "shared", diff --git a/server/package.json b/server/package.json index 6d13bcb..707472b 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/server", - "version": "1.0.1", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/server/src/content/sanitize.ts b/server/src/content/sanitize.ts index afa1c38..d27e86f 100644 --- a/server/src/content/sanitize.ts +++ b/server/src/content/sanitize.ts @@ -11,16 +11,27 @@ import type { GameSummary, LabyrinthView, LockView, + MoveDir, Role, StoryView, SyncView, } from '@two-keys/shared'; import { otherRole } from '@two-keys/shared'; -import { remainingAt, type DefuseState } from '../engines/defuse'; +import { maxStrikes, remainingAt, toSignal, type DefuseState } from '../engines/defuse'; import { totalKeys } from '../engines/index'; -import { DELTAS, cellAt, isWall, type LabyrinthState } from '../engines/labyrinth'; -import { NUDGE_AFTER_WRONG, type StoryState } from '../engines/story'; -import { canReplay, type SyncState } from '../engines/sync'; +import { + DELTAS, + OPPOSITE, + REL_DIRS, + cellAt, + facingOf, + gateBlocks, + isWall, + toAbsolute, + type LabyrinthState, +} from '../engines/labyrinth'; +import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story'; +import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync'; import type { Room } from '../rooms/store'; import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas'; @@ -36,6 +47,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, }; } @@ -51,7 +63,7 @@ function clueView(clue: Clue, audience: ClueView['audience']): ClueView { return view; } -function lockView(lock: Lock, s: StoryState): LockView { +function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView { const solved = s.solved.includes(lock.id); const wrongAttempts = s.wrong[lock.id] ?? 0; return { @@ -63,6 +75,7 @@ function lockView(lock: Lock, s: StoryState): LockView { solved, hints: lock.hints.slice(0, s.hints[lock.id] ?? 0), hintsTotal: lock.hints.length, + hintPenaltySeconds: hintPenalty(game), wrongAttempts, nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG, successText: solved ? (lock.successText ?? null) : null, @@ -81,7 +94,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView ...ch.clues[role].map((c) => clueView(c, 'private')), ...ch.clues.shared.map((c) => clueView(c, 'shared')), ], - lock: lockView(ch.lock, s), + lock: lockView(game, ch.lock, s), })); let finale: StoryView['finale'] = null; @@ -108,7 +121,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView type: 'escape', prompt: f.prompt, clues: f.clues[role].map((c) => clueView(c, 'private')), - lock: lockView(f.lock, s), + lock: lockView(game, f.lock, s), }; } @@ -125,6 +138,9 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView // ---------- defuse ---------- +/** The Morse dial runs from 3.500 to 3.600 MHz. */ +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 common = { @@ -133,7 +149,7 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu status: s.status, failReason: s.failReason, strikes: s.strikes, - maxStrikes: game.strikes.max, + maxStrikes: maxStrikes(game, s.level), clock: s.clock ? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' } : null, @@ -170,14 +186,39 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu })), }); } - return { ...common, seat: 'technician', device: { serial: d.serial, batteries: d.batteries, modules } }; + if (d.passwords) modules.push({ id: 'passwords', solved: d.passwords.solved, columns: [...d.passwords.columns] }); + if (d.morse) { + // The light is the puzzle: the Technician gets the blink pattern, never the word. + modules.push({ id: 'morse', solved: d.morse.solved, signal: toSignal(d.morse.word), ...MORSE_BAND }); + } + if (d.simon) { + modules.push({ + id: 'simon', + solved: d.simon.solved, + colors: [...(game.modules.simon?.colors ?? [])], + flashes: d.simon.sequence.slice(0, d.simon.stage), + stage: d.simon.stage, + stages: d.simon.sequence.length, + entered: d.simon.progress, + }); + } + return { + ...common, + seat: 'technician', + device: { + serial: d.serial, + batteries: d.batteries, + indicators: (d.indicators ?? []).map((i) => ({ label: i.label, lit: i.lit })), + modules, + }, + }; } // ---------- sync ---------- export function syncView(game: SyncGame, s: SyncState, role: Role): SyncView { const round = game.rounds[s.round]; - const subject = round.type === 'predict' ? (round.subject ?? 'A') : null; + const subject = subjectOf(round, s.q); const q = s.status === 'question' || s.status === 'reveal' ? s.questions[s.q] : undefined; const answers = q ? (s.answers[q.id] ?? {}) : {}; const both = answers.A !== undefined && answers.B !== undefined; @@ -196,20 +237,20 @@ export function syncView(game: SyncGame, s: SyncState, role: Role): SyncView { isReplay: s.attempt > 0, questionNumber: s.q + 1, questionsPerRound: s.questions.length, - pointsForKey: game.rules.pointsForKey, + pointsForKey: pointsForKey(game, round), points: s.marks.filter(Boolean).length, question: q ? { id: q.id, text: q.q.replaceAll('{subject}', subject ? game.roles[subject].name : ''), - options: [...q.options], + options: [...(q.options ?? q.items ?? [])], } : null, iAmSubject: subject === role, myAnswer: answers[role] ?? null, // Whether the partner has answered, never what they answered, until both are in. partnerAnswered: answers[otherRole(role)] !== undefined, - reveal: both ? { A: answers.A!, B: answers.B!, match: answers.A === answers.B } : null, + reveal: both ? { A: answers.A!, B: answers.B!, match: isMatch(round.type, answers.A!, answers.B!) } : null, marks: [...s.marks], summary: s.status === 'summary' @@ -226,12 +267,22 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role // Navigator: the whole map, but never the Explorer's coordinates. if (role === 'A') { + // A mirrored map is flipped left to right: rows reversed, x counted from the other side. + const mirror = game.navigatorView.mapTransform === 'mirror-x'; + const width = game.grid[0].length; + const fx = (x: number) => (mirror ? width - 1 - x : x); return { engine: 'labyrinth', seat: 'navigator', - grid: [...game.grid], - glyphs: game.glyphs.map((g) => ({ x: g.x, y: g.y, label: g.label })), - lanterns: game.lanterns.map((l, i) => ({ x: l.x, y: l.y, name: l.name, collected: s.collected[i] })), + grid: game.grid.map((row) => (mirror ? [...row].reverse().join('') : row)), + glyphs: game.glyphs.map((g) => ({ x: fx(g.x), y: g.y, label: g.label })), + lanterns: game.lanterns.map((l, i) => ({ x: fx(l.x), y: l.y, name: l.name, collected: s.collected[i] })), + oneWay: game.oneWay.map((g) => ({ + x: fx(g.x), + y: g.y, + dir: mirror && (g.dir === 'e' || g.dir === 'w') ? OPPOSITE[g.dir] : g.dir, + })), + banner: game.navigatorView.banner ?? null, keysCarried, totalKeys: total, moves: game.navigatorView.showMoveCount ? s.moves : null, @@ -240,16 +291,26 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role } // Explorer: only the cell under their feet. No grid, no coordinates, no traps. - const open = {} as Record; - for (const dir of Object.keys(DELTAS) as Dir[]) { - open[dir] = !isWall(game.grid, s.x + DELTAS[dir].x, s.y + DELTAS[dir].y); + // With no compass, the ways out are forward/left/right/back and never N/E/S/W. + const relative = game.explorer.compass === 'relative'; + const facing = facingOf(game, s); + const open: Partial> = {}; + const gates: MoveDir[] = []; + for (const move of relative ? REL_DIRS : (Object.keys(DELTAS) as Dir[])) { + const dir = relative ? toAbsolute(facing, move as (typeof REL_DIRS)[number]) : (move as Dir); + const wall = isWall(game.grid, s.x + DELTAS[dir].x, s.y + DELTAS[dir].y); + const gate = !wall && gateBlocks(game.oneWay, s.x, s.y, dir); + open[move] = !wall && !gate; + if (gate) gates.push(move); } const glyph = game.glyphs.find((g) => g.x === s.x && g.y === s.y); return { engine: 'labyrinth', seat: 'explorer', glyphLabel: glyph?.label ?? null, + compass: game.explorer.compass, open, + gates, keysCarried, totalKeys: total, atExit: cellAt(game.grid, s.x, s.y) === 'E', diff --git a/server/src/content/schemas.ts b/server/src/content/schemas.ts index 2e2bb9c..ddb1080 100644 --- a/server/src/content/schemas.ts +++ b/server/src/content/schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { CLUE_TYPES } from '@two-keys/shared'; +import { CLUE_TYPES, DEFUSE_MODULES, SYNC_ROUND_TYPES } from '@two-keys/shared'; const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected a #rrggbb colour'); const roleInfo = z.object({ name: z.string().min(1), blurb: z.string() }); @@ -10,7 +10,7 @@ const header = { title: z.string().min(1), tagline: z.string().min(1), estimatedMinutes: z.number().positive(), - difficulty: z.number().int().min(1).max(3), + difficulty: z.number().int().min(1).max(5), players: z.object({ min: z.literal(2), max: z.literal(2) }), theme: z.object({ primary: hex, accent: hex, background: hex, headingFont: z.string().min(1) }), roles: z.object({ A: roleInfo, B: roleInfo }), @@ -81,12 +81,22 @@ const finaleSchema = z.discriminatedUnion('type', [ clues: z.object({ A: z.array(clueSchema).default([]), B: z.array(clueSchema).default([]) }), lock: lockSchema, successEnding: z.string().min(1), + /** Shown if a time limit runs out. */ + failEnding: z.string().min(1).optional(), }), ]); const storySchema = z.object({ ...header, engine: z.literal('story'), + 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(), + }) + .default({}), suspects: z .array(z.object({ id: z.string(), name: z.string(), role: z.string(), description: z.string() })) .optional(), @@ -97,14 +107,18 @@ const storySchema = z.object({ // ---------- defuse ---------- const range = z.object({ min: z.number().int().nonnegative(), max: z.number().int().nonnegative() }); -const moduleId = z.enum(['wires', 'button', 'keypad']); +const moduleId = z.enum(DEFUSE_MODULES); +const indicators = z.object({ lit: z.array(z.string()), unlit: z.array(z.string()) }).optional(); const defuseSchema = z.object({ ...header, 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'), device: z.object({ serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }), batteries: range, + indicators: z.object({ labels: z.array(z.string().min(1)).min(1), count: range }).optional(), }), levels: z .array( @@ -113,6 +127,7 @@ const defuseSchema = z.object({ title: z.string().min(1), timeSeconds: z.number().int().positive(), modules: z.array(moduleId).min(1), + strikesOverride: z.number().int().positive().optional(), keyName: z.string().min(1), }), ) @@ -129,6 +144,15 @@ const defuseSchema = z.object({ symbols: z.record(z.string()), columns: z.array(z.array(z.string()).min(4)).min(1), }), + passwords: z + .object({ + columnsCount: z.number().int().positive(), + lettersPerColumn: z.number().int().min(2).max(26), + words: z.array(z.string().regex(/^[A-Z]+$/)).min(2), + }) + .optional(), + morse: z.object({ words: z.record(z.string().regex(/^\d\.\d{3}$/)) }).optional(), + simon: z.object({ colors: z.array(z.string()).min(2), stages: range }).optional(), }), manual: z.string().min(1), fixtures: z.object({ @@ -136,6 +160,7 @@ const defuseSchema = z.object({ z.object({ serial: z.string(), batteries: z.number().int(), + indicators, wires: z.array(z.string()), expect: z.object({ type: z.literal('cut'), index: z.number().int() }), }), @@ -143,6 +168,7 @@ const defuseSchema = z.object({ button: z.array( z.object({ batteries: z.number().int(), + indicators, label: z.string(), color: z.string(), strip: z.string(), @@ -153,6 +179,20 @@ const defuseSchema = z.object({ }), ), keypad: z.array(z.object({ symbols: z.array(z.string()), expect: z.array(z.string()) })), + passwords: z.array(z.object({ columns: z.array(z.string()), expect: z.string() })).default([]), + morse: z + .array(z.object({ signal: z.string(), expect: z.object({ word: z.string(), frequency: z.string() }) })) + .default([]), + simon: z + .array( + z.object({ + serial: z.string(), + strikes: z.number().int(), + flashes: z.array(z.string()), + expect: z.array(z.string()), + }), + ) + .default([]), }), }); @@ -163,13 +203,20 @@ const questionSchema = z.object({ q: z.string().min(1), options: z.array(z.coerce.string()).min(2), }); +const telepathySchema = z.object({ id: z.string().min(1), q: z.string().min(1) }); +const rankSchema = z.object({ + id: z.string().min(1), + q: z.string().min(1), + items: z.array(z.coerce.string()).min(3), +}); const syncSchema = z.object({ ...header, engine: z.literal('sync'), rules: z.object({ questionsPerRound: z.number().int().positive(), - pointsForKey: z.number().int().positive(), + /** Default for rounds that don't set their own. */ + pointsForKey: z.number().int().positive().default(3), replaysPerRound: z.number().int().nonnegative(), }), rounds: z @@ -177,19 +224,26 @@ const syncSchema = z.object({ z.object({ id: z.string().min(1), title: z.string().min(1), - type: z.enum(['match', 'predict']), + type: z.enum(SYNC_ROUND_TYPES), subject: z.enum(['A', 'B']).optional(), + /** rank rounds: who does the ranking on each question, cycled. */ + subjectPattern: z.array(z.enum(['A', 'B'])).min(1).optional(), + pointsForKey: z.number().int().positive().optional(), blurb: z.string(), keyName: z.string().min(1), }), ) .min(1), - match: z.array(questionSchema), - predict: z.array(questionSchema), + match: z.array(questionSchema).default([]), + predict: z.array(questionSchema).default([]), + telepathy: z.array(telepathySchema).default([]), + rank: z.array(rankSchema).default([]), }); // ---------- labyrinth ---------- +const dir = z.enum(['n', 'e', 's', 'w']); + const labyrinthSchema = z.object({ ...header, engine: z.literal('labyrinth'), @@ -198,6 +252,16 @@ const labyrinthSchema = z.object({ z.object({ x: z.number().int(), y: z.number().int(), glyph: z.string(), label: z.string().min(1) }), ), lanterns: z.array(z.object({ x: z.number().int(), y: z.number().int(), name: z.string().min(1) })), + 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([]), + explorer: z + .object({ + /** `relative` swaps N/E/S/W for forward/left/right/back from the way the Explorer faces. */ + compass: z.enum(['absolute', 'relative']).default('absolute'), + startFacing: dir.default('n'), + }) + .default({}), explorerView: z.object({ flavour: z.object({ blank: z.string(), @@ -205,9 +269,15 @@ const labyrinthSchema = z.object({ lantern: z.string(), exitLocked: z.string(), exitOpen: z.string(), + gate: z.string().optional(), }), }), - navigatorView: z.object({ showMoveCount: z.boolean().default(true) }), + navigatorView: z.object({ + showMoveCount: z.boolean().default(true), + /** `mirror-x` serves the map flipped left to right. */ + mapTransform: z.enum(['none', 'mirror-x']).default('none'), + banner: z.string().optional(), + }), scoring: z.object({ trapPenaltySeconds: z.number().nonnegative(), parMoves: z.number().int().positive() }), successEnding: z.string().min(1), }); @@ -226,4 +296,4 @@ export type LabyrinthGame = z.infer; export type Game = z.infer; export type Clue = z.infer; export type Lock = z.infer; -export type SyncQuestion = z.infer; +export type SyncQuestion = { id: string; q: string; options?: string[]; items?: string[] }; diff --git a/server/src/content/validate.ts b/server/src/content/validate.ts index d398c9f..73d752d 100644 --- a/server/src/content/validate.ts +++ b/server/src/content/validate.ts @@ -1,6 +1,15 @@ -import { normalizeAnswer } from '@two-keys/shared'; -import { solveButton, solveKeypad, solveWires } from '../engines/defuse'; -import { cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth'; +import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared'; +import { + buttonSolution, + solveKeypad, + solveMorse, + solvePassword, + solveSimon, + toSignal, + wireSolution, +} from '../engines/defuse'; +import { DELTAS, analyseMaze, cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth'; +import { groupOf, pointsForKey } from '../engines/sync'; import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas'; export const MIN_SYNC_QUESTIONS = 15; @@ -75,14 +84,14 @@ function validateStory(game: StoryGame): string[] { function validateDefuse(game: DefuseGame): string[] { const errors: string[] = []; const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); - const { fixtures, modules } = game; + const { fixtures, modules, ruleset } = game; fixtures.wires.forEach((f, i) => { - const got = solveWires(f.serial, f.wires); + const got = wireSolution(ruleset, f, f.wires); if (got.index !== f.expect.index) errors.push(`wires fixture ${i + 1}: expected cut ${f.expect.index}, solver says ${got.index}`); }); fixtures.button.forEach((f, i) => { - const got = solveButton(f); + const got = buttonSolution(ruleset, { serial: '', ...f }, f); if (!same(got, f.expect)) errors.push(`button fixture ${i + 1}: expected ${JSON.stringify(f.expect)}, solver says ${JSON.stringify(got)}`); }); fixtures.keypad.forEach((f, i) => { @@ -90,6 +99,38 @@ function validateDefuse(game: DefuseGame): string[] { if (!same(got, f.expect)) errors.push(`keypad fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got?.join(',') ?? 'no unique column'}`); }); + fixtures.passwords.forEach((f, i) => { + const got = solvePassword(f.columns, modules.passwords?.words ?? []); + if (got !== f.expect) errors.push(`passwords fixture ${i + 1}: expected ${f.expect}, solver says ${got ?? 'no unique word'}`); + }); + fixtures.morse.forEach((f, i) => { + const got = solveMorse(f.signal, modules.morse?.words ?? {}); + if (!same(got, f.expect)) errors.push(`morse fixture ${i + 1}: expected ${JSON.stringify(f.expect)}, solver says ${JSON.stringify(got)}`); + }); + fixtures.simon.forEach((f, i) => { + const got = solveSimon(f.serial, f.strikes, f.flashes); + if (!same(got, f.expect)) errors.push(`simon fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got.join(',')}`); + }); + + for (const level of game.levels) { + for (const id of level.modules) { + if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules`); + } + } + if (modules.passwords) { + const { words, columnsCount } = modules.passwords; + for (const w of words) if (w.length !== columnsCount) errors.push(`password "${w}" is not ${columnsCount} letters`); + if (new Set(words).size !== words.length) errors.push('password list has duplicates'); + } + if (modules.morse) { + const entries = Object.entries(modules.morse.words); + for (const [word] of entries) { + if (!solveMorse(toSignal(word), modules.morse.words)) errors.push(`morse word "${word}" can't be sent in Morse`); + } + if (new Set(entries.map(([, f]) => f)).size !== entries.length) errors.push('two morse words share a frequency'); + } + if (modules.simon && modules.simon.stages.min < 1) errors.push('simon needs at least one stage'); + if (!/\d/.test(game.device.serial.alphabet)) errors.push('serial alphabet has no digits'); if (modules.wires.count.min < 1 || modules.wires.count.max < modules.wires.count.min) errors.push('bad wire count range'); const { columns, symbols } = modules.keypad; @@ -106,18 +147,22 @@ function validateDefuse(game: DefuseGame): string[] { function validateSync(game: SyncGame): string[] { const errors: string[] = []; const perRound = game.rules.questionsPerRound * (1 + game.rules.replaysPerRound); - for (const type of ['match', 'predict'] as const) { - const rounds = game.rounds.filter((r) => r.type === type).length; - if (rounds === 0) continue; + for (const type of SYNC_ROUND_TYPES) { + const rounds = game.rounds.filter((r) => r.type === type); + if (rounds.length === 0) continue; const bank = game[type]; if (bank.length < MIN_SYNC_QUESTIONS) errors.push(`${type} bank has ${bank.length} questions, needs at least ${MIN_SYNC_QUESTIONS}`); - if (bank.length < rounds * perRound) errors.push(`${type} bank has ${bank.length} questions, but ${rounds * perRound} are needed to never repeat`); + // A question never repeats, except that a spotlight question may be asked once about each partner. + const groups = new Map(); + for (const r of rounds) groups.set(groupOf(r), (groups.get(groupOf(r)) ?? 0) + 1); + const needed = Math.max(...groups.values()) * perRound; + if (bank.length < needed) errors.push(`${type} bank has ${bank.length} questions, but ${needed} are needed to never repeat`); if (new Set(bank.map((q) => q.id)).size !== bank.length) errors.push(`${type} bank has duplicate question ids`); } for (const r of game.rounds) { if (r.type === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`); + if (pointsForKey(game, r) > game.rules.questionsPerRound) errors.push(`round ${r.id}: pointsForKey is more than questionsPerRound`); } - if (game.rules.pointsForKey > game.rules.questionsPerRound) errors.push('pointsForKey is more than questionsPerRound'); return errors; } @@ -131,7 +176,8 @@ function validateLabyrinth(game: LabyrinthGame): string[] { const keys = findCells(grid, 'K'); if (starts.length !== 1) errors.push(`grid needs exactly one S, found ${starts.length}`); if (exits.length !== 1) errors.push(`grid needs exactly one E, found ${exits.length}`); - if (keys.length !== 3) errors.push(`grid needs exactly 3 K, found ${keys.length}`); + const required = game.lanternsRequired; + if (keys.length !== required) errors.push(`grid needs exactly ${required} K, found ${keys.length}`); if (game.lanterns.length !== keys.length) errors.push('lantern list does not match the K cells in the grid'); for (const l of game.lanterns) { @@ -141,14 +187,26 @@ function validateLabyrinth(game: LabyrinthGame): string[] { if (cellAt(grid, g.x, g.y) === '#') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is inside a wall`); } + for (const g of game.oneWay) { + const through = DELTAS[g.dir]; + if (cellAt(grid, g.x, g.y) === '#' || cellAt(grid, g.x + through.x, g.y + through.y) === '#') { + errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} opens into a wall`); + } + } + if (starts.length === 1) { - const reach = safeReachable(grid); + const reach = 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`); } + if (errors.length === 0) { + const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns); + 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})`); + } } return errors; } diff --git a/server/src/engines/defuse.ts b/server/src/engines/defuse.ts index fdd5174..a46704a 100644 --- a/server/src/engines/defuse.ts +++ b/server/src/engines/defuse.ts @@ -2,12 +2,26 @@ import type { DefuseAction, DefuseStatus, Role } from '@two-keys/shared'; import type { DefuseGame } from '../content/schemas'; import { OK, fail, type ActionResult, type Ctx } from './types'; -// ---------- Solver: implements the manual exactly ---------- +// ---------- Solver: implements each manual exactly ---------- +// `classic` is the Tick Tock Together manual, `overtime` the Tick Tock: Overtime one. export type WireSolution = { type: 'cut'; index: number }; export type ButtonSolution = { type: 'tap' } | { type: 'hold'; releaseDigit: number }; +export interface Indicators { + lit: string[]; + unlit: string[]; +} +/** What the Technician reads out before anything else. */ +export interface Edgework { + serial: string; + batteries: number; + indicators?: Indicators; +} -/** Returns the 1-based index of the wire to cut. */ +const hasVowel = (serial: string) => /[AEIOU]/.test(serial); +const isLit = (e: { indicators?: Indicators }, label: string) => e.indicators?.lit.includes(label) ?? false; + +/** Classic manual. Returns the 1-based index of the wire to cut. */ export function solveWires(serial: string, wires: string[]): WireSolution { const indexesOf = (color: string) => wires.flatMap((w, i) => (w === color ? [i + 1] : [])); const reds = indexesOf('red'); @@ -21,6 +35,43 @@ export function solveWires(serial: string, wires: string[]): WireSolution { return { type: 'cut', index: 1 }; } +/** Overtime manual: one section per wire count, first rule that applies. */ +export function solveWiresOvertime(e: Edgework, wires: string[]): WireSolution { + const indexesOf = (color: string) => wires.flatMap((w, i) => (w === color ? [i + 1] : [])); + const cut = (index: number): WireSolution => ({ type: 'cut', index }); + const n = wires.length; + + if (n === 3) { + if (new Set(wires).size === 3) return cut(2); + if (indexesOf('blue').length >= 2) return cut(indexesOf('blue')[0]); + if (hasVowel(e.serial)) return cut(3); + return cut(1); + } + if (n === 4) { + const greens = indexesOf('green'); + const whites = indexesOf('white'); + if (greens.length === 1 && e.batteries >= 2) return cut(greens[0]); + if (wires[0] === wires[3]) return cut(3); + if (whites.length === 0) return cut(2); + return cut(whites[whites.length - 1]); + } + if (n === 5) { + const blacks = indexesOf('black'); + const yellows = indexesOf('yellow'); + const lastDigit = Number(e.serial.match(/\d(?=\D*$)/)?.[0] ?? NaN); + if (blacks.length > indexesOf('red').length) return cut(blacks[0]); + if (lastDigit % 2 === 1) return cut(4); + if (yellows.length === 1) return cut(yellows[0] === 5 ? 1 : yellows[0] + 1); + return cut(2); + } + const reds = indexesOf('red'); + if (isLit(e, 'LUV')) return cut(6); + if (reds.length === 3) return cut(reds[2]); + if (!wires.includes('yellow')) return cut(3); + return cut(5); +} + +/** Classic manual. */ export function solveButton(b: { batteries: number; label: string; @@ -34,6 +85,85 @@ export function solveButton(b: { return { type: 'hold', releaseDigit }; } +export function solveButtonOvertime(b: { + batteries: number; + indicators?: Indicators; + label: string; + color: string; + strip: string; +}): ButtonSolution { + const releaseDigit = { blue: 4, white: 1, yellow: 5, red: 3 }[b.strip] ?? 1; + const hold: ButtonSolution = { type: 'hold', releaseDigit }; + if (b.color === 'blue' && b.label === 'ABORT') return hold; + if (b.batteries > 1 && b.label === 'DETONATE') return { type: 'tap' }; + if (b.color === 'white' && isLit(b, 'BAE')) return hold; + if (b.batteries > 2 && isLit(b, 'HUG')) return { type: 'tap' }; + if (b.color === 'yellow') return hold; + if (b.color === 'red' && b.label === 'HOLD') return { type: 'tap' }; + return hold; +} + +type Ruleset = DefuseGame['ruleset']; + +export function wireSolution(ruleset: Ruleset, e: Edgework, wires: string[]): WireSolution { + return ruleset === 'overtime' ? solveWiresOvertime(e, wires) : solveWires(e.serial, wires); +} + +export function buttonSolution( + ruleset: Ruleset, + e: Edgework, + button: { label: string; color: string; strip: string }, +): ButtonSolution { + const b = { ...button, batteries: e.batteries, indicators: e.indicators }; + return ruleset === 'overtime' ? solveButtonOvertime(b) : solveButton(b); +} + +/** The one listed word the letter columns can spell, or null unless there is exactly one. */ +export function solvePassword(columns: string[], words: string[]): string | null { + const matches = words.filter( + (word) => word.length === columns.length && [...word].every((letter, i) => columns[i].includes(letter)), + ); + return matches.length === 1 ? matches[0] : null; +} + +const MORSE: Record = { + A: '.-', B: '-...', C: '-.-.', D: '-..', E: '.', F: '..-.', G: '--.', H: '....', I: '..', J: '.---', + K: '-.-', L: '.-..', M: '--', N: '-.', O: '---', P: '.--.', Q: '--.-', R: '.-.', S: '...', T: '-', + U: '..-', V: '...-', W: '.--', X: '-..-', Y: '-.--', Z: '--..', +}; + +/** "HUG" → "..../..-/--." (the signal format of §6.3). */ +export const toSignal = (word: string): string => [...word].map((letter) => MORSE[letter] ?? '').join('/'); + +export function solveMorse( + signal: string, + words: Record, +): { word: string; frequency: string } | null { + const letters = signal.split('/').map((code) => Object.keys(MORSE).find((l) => MORSE[l] === code)); + const word = letters.join(''); + return letters.every(Boolean) && word in words ? { word, frequency: words[word] } : null; +} + +// Say It Back: flashed colour → colour to press, one row per strike count. +const SIMON_MAPS: Record<'vowel' | 'noVowel', Record[]> = { + vowel: [ + { red: 'blue', blue: 'red', green: 'yellow', yellow: 'green' }, + { red: 'yellow', blue: 'green', green: 'blue', yellow: 'red' }, + { red: 'green', blue: 'red', green: 'yellow', yellow: 'blue' }, + ], + noVowel: [ + { red: 'blue', blue: 'yellow', green: 'green', yellow: 'red' }, + { red: 'red', blue: 'blue', green: 'yellow', yellow: 'green' }, + { red: 'yellow', blue: 'green', green: 'blue', yellow: 'red' }, + ], +}; + +export function solveSimon(serial: string, strikes: number, flashes: string[]): string[] { + const rows = SIMON_MAPS[hasVowel(serial) ? 'vowel' : 'noVowel']; + const map = rows[Math.min(Math.max(strikes, 0), rows.length - 1)]; + return flashes.map((color) => map[color] ?? color); +} + /** The press order, or null unless exactly one column contains every symbol. */ export function solveKeypad(symbols: string[], columns: string[][]): string[] | null { const matches = columns.filter((col) => symbols.every((s) => col.includes(s))); @@ -60,6 +190,25 @@ export interface DefuseDevice { wires?: { colors: string[]; solved: boolean }; button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean }; keypad?: { symbols: string[]; pressed: string[]; solved: boolean }; + indicators?: { label: string; lit: boolean }[]; + passwords?: { columns: string[]; solved: boolean }; + morse?: { word: string; solved: boolean }; + /** `stage` flashes are showing; `progress` of them have been answered. */ + simon?: { sequence: string[]; stage: number; progress: number; solved: boolean }; +} + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +export function edgework(d: DefuseDevice): Edgework { + const shown = d.indicators ?? []; + return { + serial: d.serial, + batteries: d.batteries, + indicators: { + lit: shown.filter((i) => i.lit).map((i) => i.label), + unlit: shown.filter((i) => !i.lit).map((i) => i.label), + }, + }; } export function generateDevice(game: DefuseGame, levelIndex: number, seed: number): DefuseDevice { @@ -83,6 +232,11 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe serial += pick(digits); // last char is always a digit const device: DefuseDevice = { serial, batteries: int(batteries.min, batteries.max) }; + const ind = game.device.indicators; + if (ind) { + const shown = shuffle(ind.labels).slice(0, int(ind.count.min, ind.count.max)); + device.indicators = shown.map((label) => ({ label, lit: rng() < 0.5 })); + } const mods = game.modules; for (const id of game.levels[levelIndex].modules) { if (id === 'wires') { @@ -96,9 +250,28 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe holding: false, solved: false, }; - } else { + } else if (id === 'keypad') { const column = pick(mods.keypad.columns); device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false }; + } else if (id === 'passwords' && mods.passwords) { + const { words, lettersPerColumn } = mods.passwords; + // Re-roll until exactly one listed word can be spelled. + let columns: string[] = []; + for (let attempt = 0; attempt < 200; attempt++) { + const target = pick(words); + columns = [...target].map((letter) => { + const others = shuffle([...ALPHABET].filter((l) => l !== letter)).slice(0, lettersPerColumn - 1); + return shuffle([letter, ...others]).join(''); + }); + if (solvePassword(columns, words) === target) break; + } + device.passwords = { columns, solved: false }; + } else if (id === 'morse' && mods.morse) { + device.morse = { word: pick(Object.keys(mods.morse.words)), solved: false }; + } else if (id === 'simon' && mods.simon) { + const { colors, stages } = mods.simon; + const sequence = Array.from({ length: int(stages.min, stages.max) }, () => pick(colors)); + device.simon = { sequence, stage: 1, progress: 0, solved: false }; } } return device; @@ -106,11 +279,15 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe /** 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 } = solveWires(device.serial, device.wires.colors); + const { index } = wireSolution(game.ruleset, edgework(device), device.wires.colors); if (index < 1 || index > device.wires.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; + if (device.morse && !solveMorse(toSignal(device.morse.word), game.modules.morse?.words ?? {})) return false; + if (device.simon && device.simon.sequence.length < 1) return false; return true; } @@ -135,6 +312,13 @@ export function initDefuse(): DefuseState { const state = (ctx: Ctx) => ctx.room.engine as DefuseState; +/** Strikes allowed on a level (some levels are stricter than the game default). */ +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); + export function remainingAt(s: DefuseState, now: number): number { if (!s.clock) return 0; if (s.status !== 'active') return s.clock.remaining; @@ -162,14 +346,15 @@ function strike(game: DefuseGame, ctx: Ctx, s: DefuseState) { s.strikes++; ctx.room.wrongGuesses++; s.clock = { remaining, anchor: ctx.now, speed: 1 + game.strikes.speedUpPerStrike * s.strikes }; - if (s.strikes >= game.strikes.max) explode(ctx, s, 'strikes'); - else ctx.toast('all', `Strike ${s.strikes} of ${game.strikes.max}! The clock speeds up.`); + const max = maxStrikes(game, s.level); + // The Say It Back mapping moves with the strike count, so the stage starts over. + if (s.device?.simon) s.device.simon.progress = 0; + if (s.strikes >= max) explode(ctx, s, 'strikes'); + else ctx.toast('all', `Strike ${s.strikes} of ${max}! The clock speeds up.`); } function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) { - const d = s.device!; - const modules = [d.wires, d.button, d.keypad].filter((m) => m !== undefined); - if (!modules.every((m) => m.solved)) return; + if (!deviceModules(s.device!).every((m) => m.solved)) return; freezeClock(s, ctx.now); s.status = 'cleared'; ctx.addKey(game.levels[s.level].keyName); @@ -186,6 +371,11 @@ 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'; @@ -216,13 +406,14 @@ export function defuseAction( return OK; } const d = s.device; + const edge = edgework(d); if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') { - if (action.index === solveWires(d.serial, d.wires.colors).index) d.wires.solved = true; + if (action.index === wireSolution(game.ruleset, edge, d.wires.colors).index) d.wires.solved = true; else strike(game, ctx, s); } else if (moduleId === 'button' && d.button && !d.button.solved) { const b = d.button; - const solution = solveButton({ ...b, batteries: d.batteries }); + const solution = buttonSolution(game.ruleset, edge, b); if (action.type === 'tap' && !b.holding) { if (solution.type === 'tap') b.solved = true; else strike(game, ctx, s); @@ -242,16 +433,41 @@ export function defuseAction( } } else if (moduleId === 'keypad' && d.keypad && !d.keypad.solved && action.type === 'press') { const k = d.keypad; - if (!k.symbols.includes(action.symbol) || k.pressed.includes(action.symbol)) { + const symbol = action.symbol ?? ''; + if (!k.symbols.includes(symbol) || k.pressed.includes(symbol)) { return fail('bad_action', 'That key is not available.'); } const order = solveKeypad(k.symbols, game.modules.keypad.columns)!; - if (order[k.pressed.length] === action.symbol) { - k.pressed.push(action.symbol); + if (order[k.pressed.length] === symbol) { + k.pressed.push(symbol); if (k.pressed.length === order.length) k.solved = true; } else { strike(game, ctx, s); } + } else if (moduleId === 'passwords' && d.passwords && !d.passwords.solved && action.type === 'submit') { + const word = action.word.trim().toUpperCase(); + if (word === solvePassword(d.passwords.columns, game.modules.passwords?.words ?? [])) d.passwords.solved = true; + else strike(game, ctx, s); + } else if (moduleId === 'morse' && d.morse && !d.morse.solved && action.type === 'transmit') { + const khz = Math.round(Number(action.frequency) * 1000); + if (!Number.isFinite(khz)) return fail('bad_action', "That isn't a frequency."); + const target = Number(game.modules.morse?.words[d.morse.word]); + if (khz === Math.round(target * 1000)) d.morse.solved = true; + else strike(game, ctx, s); + } else if (moduleId === 'simon' && d.simon && !d.simon.solved && action.type === 'press') { + const sim = d.simon; + const color = action.color ?? ''; + if (!game.modules.simon?.colors.includes(color)) return fail('bad_action', 'That pad is not on the device.'); + const [expected] = solveSimon(d.serial, s.strikes, [sim.sequence[sim.progress]]); + if (color !== expected) { + strike(game, ctx, s); // also restarts the stage + } else if (++sim.progress >= sim.stage) { + if (sim.stage >= sim.sequence.length) sim.solved = true; + else { + sim.stage++; + sim.progress = 0; + } + } } else { return fail('bad_action', "That module isn't on this device."); } diff --git a/server/src/engines/labyrinth.ts b/server/src/engines/labyrinth.ts index 3451b7e..8d442fe 100644 --- a/server/src/engines/labyrinth.ts +++ b/server/src/engines/labyrinth.ts @@ -1,4 +1,4 @@ -import type { Dir, Role } from '@two-keys/shared'; +import type { Dir, MoveDir, RelDir, Role } from '@two-keys/shared'; import type { LabyrinthGame } from '../content/schemas'; import { OK, fail, type ActionResult, type Ctx } from './types'; @@ -7,16 +7,35 @@ export interface Point { y: number; } +/** You may step from (x, y) in `dir`, never back the other way. */ +export interface Gate extends Point { + dir: Dir; +} + export const DELTAS: Record = { n: { x: 0, y: -1 }, e: { x: 1, y: 0 }, s: { x: 0, y: 1 }, w: { x: -1, y: 0 }, }; +export const OPPOSITE: Record = { n: 's', e: 'w', s: 'n', w: 'e' }; +export const REL_DIRS: readonly RelDir[] = ['f', 'r', 'b', 'l']; +const CLOCKWISE: readonly Dir[] = ['n', 'e', 's', 'w']; + +/** Forward / right / back / left from the way you face → a compass direction. */ +export function toAbsolute(facing: Dir, rel: RelDir): Dir { + return CLOCKWISE[(CLOCKWISE.indexOf(facing) + REL_DIRS.indexOf(rel)) % 4]; +} export const cellAt = (grid: string[], x: number, y: number): string => grid[y]?.[x] ?? '#'; export const isWall = (grid: string[], x: number, y: number): boolean => cellAt(grid, x, y) === '#'; +/** True if a one-way gate stops a step from (x, y) in `dir`. */ +export function gateBlocks(gates: readonly Gate[], x: number, y: number, dir: Dir): boolean { + const d = DELTAS[dir]; + return gates.some((g) => g.x === x + d.x && g.y === y + d.y && g.dir === OPPOSITE[dir]); +} + export function findCells(grid: string[], char: string): Point[] { const found: Point[] = []; grid.forEach((row, y) => [...row].forEach((c, x) => c === char && found.push({ x, y }))); @@ -26,7 +45,7 @@ export function findCells(grid: string[], char: string): Point[] { const key = (p: Point) => `${p.x},${p.y}`; /** Every cell reachable from S without ever stepping on a trap. */ -export function safeReachable(grid: string[]): Set { +export function safeReachable(grid: string[], gates: readonly Gate[] = []): Set { const [start] = findCells(grid, 'S'); const seen = new Set(); if (!start) return seen; @@ -34,10 +53,10 @@ export function safeReachable(grid: string[]): Set { seen.add(key(start)); while (queue.length > 0) { const p = queue.shift()!; - for (const d of Object.values(DELTAS)) { - const next = { x: p.x + d.x, y: p.y + d.y }; + for (const dir of CLOCKWISE) { + const next = { x: p.x + DELTAS[dir].x, y: p.y + DELTAS[dir].y }; const c = cellAt(grid, next.x, next.y); - if (c === '#' || c === 'T' || seen.has(key(next))) continue; + if (c === '#' || c === 'T' || seen.has(key(next)) || gateBlocks(gates, p.x, p.y, dir)) continue; seen.add(key(next)); queue.push(next); } @@ -47,9 +66,78 @@ export function safeReachable(grid: string[]): Set { export const isReachable = (reach: Set, p: Point) => reach.has(key(p)); +/** + * Plays the maze out as a graph of (cell, lanterns held). Returns whether a trap-free run can + * finish, and any cell where the Explorer could end up with no way left to finish (one-way + * gates make that possible). Falling into a trap counts as a way out: it leads back to S. + */ +export function analyseMaze( + grid: string[], + gates: readonly Gate[], + lanterns: readonly Point[], +): { winnable: boolean; stuck: Point[] } { + const [start] = findCells(grid, 'S'); + if (!start) return { winnable: false, stuck: [] }; + const full = (1 << lanterns.length) - 1; + 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); + return i >= 0 ? mask | (1 << i) : mask; + }; + + // `safe` tracks whether the run so far is trap-free. + type Node = { p: Point; mask: number; safe: boolean }; + const first: Node = { p: start, mask: 0, safe: true }; + const seen = new Map([[id(start, 0, true), first]]); + const reverse = new Map(); + const queue: Node[] = [first]; + const goals: string[] = []; + + 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) { + 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; + const next: Node = + c === 'T' + ? { p: start, mask: node.mask, safe: false } + : { p: step, mask: pickUp(step, node.mask), safe: node.safe }; + const to = id(next.p, next.mask, next.safe); + reverse.set(to, [...(reverse.get(to) ?? []), from]); + if (!seen.has(to)) { + seen.set(to, next); + queue.push(next); + } + } + } + + const canFinish = new Set(goals); + const back = [...goals]; + while (back.length > 0) { + for (const from of reverse.get(back.pop()!) ?? []) { + if (!canFinish.has(from)) { + canFinish.add(from); + back.push(from); + } + } + } + + const stuck = new Map(); + 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()] }; +} + export interface LabyrinthState { x: number; y: number; + /** The way the Explorer faces: the direction of their last step. */ + facing?: Dir; collected: boolean[]; moves: number; falls: number; @@ -58,27 +146,46 @@ export interface LabyrinthState { export function initLabyrinth(game: LabyrinthGame): LabyrinthState { const [start] = findCells(game.grid, 'S'); - return { ...start, collected: game.lanterns.map(() => false), moves: 0, falls: 0, message: '' }; + return { + ...start, + facing: game.explorer.startFacing, + collected: game.lanterns.map(() => false), + moves: 0, + falls: 0, + message: '', + }; } -export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, dir: Dir): ActionResult { +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 { const s = ctx.room.engine as LabyrinthState; if (role !== 'B') return fail('wrong_role', 'Only the Explorer can move.'); + + const relative = game.explorer.compass === 'relative'; + const isRel = (REL_DIRS as readonly string[]).includes(move); + if (relative !== isRel) return fail('bad_dir', relative ? 'You have no compass in here.' : "You can't move like that here."); + const dir = isRel ? toAbsolute(facingOf(game, s), move as RelDir) : (move as Dir); + const d = DELTAS[dir]; const next = { x: s.x + d.x, y: s.y + d.y }; 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 flavour = game.explorerView.flavour; + const throughGate = game.oneWay.some((g) => g.x === s.x && g.y === s.y && g.dir === dir); s.moves++; s.x = next.x; s.y = next.y; - s.message = ''; + s.facing = dir; + s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : ''; const cell = cellAt(game.grid, next.x, next.y); if (cell === 'T') { const [start] = findCells(game.grid, 'S'); s.x = start.x; s.y = start.y; + s.facing = game.explorer.startFacing; s.falls++; s.message = flavour.trap; ctx.addPenalty(game.scoring.trapPenaltySeconds); diff --git a/server/src/engines/story.ts b/server/src/engines/story.ts index 8e22dcc..95c3f64 100644 --- a/server/src/engines/story.ts +++ b/server/src/engines/story.ts @@ -1,4 +1,4 @@ -import { matchesAnswer, type Role } from '@two-keys/shared'; +import { formatClock, matchesAnswer, type Role } from '@two-keys/shared'; import type { Lock, StoryGame } from '../content/schemas'; import { OK, fail, type ActionResult, type Ctx } from './types'; @@ -34,6 +34,28 @@ export function initStory(): StoryState { } const state = (ctx: Ctx) => ctx.room.engine as StoryState; + +/** What one hint costs in this game. */ +export const hintPenalty = (game: StoryGame): number => game.rules.hintPenaltySeconds ?? HINT_PENALTY_SECONDS; + +/** The hard limit on elapsed + penalty time, for the games that have one. */ +export const timeLimitSeconds = (game: StoryGame): number | null => + game.rules.timeLimitMinutes ? Math.round(game.rules.timeLimitMinutes * 60) : null; + +/** Called on a timer; returns true if the room changed (the time limit ran out). */ +export function tickStory(game: StoryGame, ctx: Ctx): boolean { + const limit = timeLimitSeconds(game); + const { room } = ctx; + if (limit === null || room.phase !== 'playing' || room.startedAt === null) return false; + if ((ctx.now - room.startedAt) / 1000 + room.penaltySeconds < limit) return false; + const { finale } = game; + ctx.finish( + false, + finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?", + finale.type === 'accusation' ? finale.reveal : null, + ); + return true; +} const atFinale = (game: StoryGame, s: StoryState) => s.chapter >= game.chapters.length; /** The one lock that can currently be worked on, if any. */ @@ -82,8 +104,8 @@ export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionRe if (tier >= open.lock.hints.length) return fail('no_more_hints', "That's every hint for this lock."); s.hints[lockId] = tier + 1; ctx.room.hintsUsed++; - ctx.addPenalty(HINT_PENALTY_SECONDS); - ctx.toast('all', `Hint ${tier + 1} revealed (+2:00)`); + ctx.addPenalty(hintPenalty(game)); + ctx.toast('all', `Hint ${tier + 1} revealed (+${formatClock(hintPenalty(game))})`); return OK; } diff --git a/server/src/engines/sync.ts b/server/src/engines/sync.ts index e545a87..bbd807d 100644 --- a/server/src/engines/sync.ts +++ b/server/src/engines/sync.ts @@ -1,7 +1,11 @@ -import type { Role } from '@two-keys/shared'; +import { normalizeAnswer, type Role, type SyncAnswer } from '@two-keys/shared'; import type { SyncGame, SyncQuestion } from '../content/schemas'; import { OK, fail, type ActionResult, type Ctx } from './types'; +export const TELEPATHY_MAX_LENGTH = 40; + +type Round = SyncGame['rounds'][number]; + export interface SyncState { round: number; /** 0 on the first try, 1+ on a replay. */ @@ -9,10 +13,10 @@ export interface SyncState { status: 'intro' | 'question' | 'reveal' | 'summary'; questions: SyncQuestion[]; q: number; - answers: Record>>; + answers: Record>>; marks: boolean[]; earned: boolean; - /** Question ids already drawn this session, so nothing repeats. */ + /** Questions already drawn this session, as `group|id`, so nothing repeats. */ used: string[]; } @@ -34,14 +38,38 @@ export function initSync(game: SyncGame): SyncState { const state = (ctx: Ctx) => ctx.room.engine as SyncState; +export const pointsForKey = (game: SyncGame, round: Round): number => round.pointsForKey ?? game.rules.pointsForKey; + +/** Who question number `q` (0-based) of a round is about, if anyone. */ +export function subjectOf(round: Round, q: number): Role | null { + if (round.type === 'predict') return round.subject ?? 'A'; + if (round.type === 'rank') { + const pattern = round.subjectPattern ?? ['A', 'B']; + return pattern[q % pattern.length]; + } + return null; +} + +/** Rounds that ask the same question in the same way share a group. */ +export const groupOf = (round: Round): string => `${round.type}:${round.type === 'predict' ? (round.subject ?? 'A') : ''}`; + /** Draws a fresh hand of questions for the current round. */ function deal(game: SyncGame, s: SyncState, random: () => number = Math.random) { - const bank = game[game.rounds[s.round].type].filter((q) => !s.used.includes(q.id)); + const round = game.rounds[s.round]; + const group = groupOf(round); + const all: SyncQuestion[] = game[round.type]; + const usedIds = new Set(s.used.map((u) => u.slice(u.lastIndexOf('|') + 1))); + // Never-seen questions first. A spotlight question may come back only to be asked about the other partner. + const fresh = all.filter((q) => !usedIds.has(q.id)); + const recycled = all.filter((q) => usedIds.has(q.id) && !s.used.includes(`${group}|${q.id}`)); + const hand: SyncQuestion[] = []; - while (hand.length < game.rules.questionsPerRound && bank.length > 0) { - hand.push(...bank.splice(Math.floor(random() * bank.length), 1)); + for (const bank of [fresh, recycled]) { + while (hand.length < game.rules.questionsPerRound && bank.length > 0) { + hand.push(...bank.splice(Math.floor(random() * bank.length), 1)); + } } - s.used.push(...hand.map((q) => q.id)); + s.used.push(...hand.map((q) => `${group}|${q.id}`)); s.questions = hand; s.q = 0; s.answers = {}; @@ -53,27 +81,65 @@ export function canReplay(game: SyncGame, s: SyncState): boolean { return !s.earned && s.attempt < game.rules.replaysPerRound; } +/** Telepathy: case, spaces, punctuation and a trailing S are ignored. */ +export function normalizeWord(input: string): string { + const word = normalizeAnswer(input); + return word.length > 1 ? word.replace(/S$/, '') : word; +} + +/** Checks an answer against the question's shape and returns it in stored form, or null if it doesn't fit. */ +function cleanAnswer(type: Round['type'], question: SyncQuestion, answer: unknown): SyncAnswer | null { + if (type === 'telepathy') { + if (typeof answer !== 'string') return null; + const text = answer.trim().slice(0, TELEPATHY_MAX_LENGTH); + return normalizeAnswer(text) === '' ? null : text; + } + if (type === 'rank') { + const count = question.items?.length ?? 0; + if (!Array.isArray(answer) || answer.length !== count) return null; + const valid = answer.every((i) => Number.isInteger(i) && i >= 0 && i < count); + return valid && new Set(answer).size === count ? [...answer] : null; + } + const count = question.options?.length ?? 0; + return typeof answer === 'number' && Number.isInteger(answer) && answer >= 0 && answer < count ? answer : null; +} + +export function isMatch(type: Round['type'], a: SyncAnswer, b: SyncAnswer): boolean { + if (type === 'telepathy') return normalizeWord(String(a)) === normalizeWord(String(b)); + if (type === 'rank') { + // Only the top and the bottom of the ranking have to agree. + const [x, y] = [a as number[], b as number[]]; + return x[0] === y[0] && x[x.length - 1] === y[y.length - 1]; + } + return a === b; +} + export function answerSync( game: SyncGame, ctx: Ctx, role: Role, questionId: string, - option: number, + answer: SyncAnswer | undefined, ): ActionResult { const s = state(ctx); const question = s.questions[s.q]; if (s.status !== 'question' || !question || question.id !== questionId) { return fail('not_open', "That question isn't open."); } - if (!Number.isInteger(option) || option < 0 || option >= question.options.length) { - return fail('bad_option', 'Pick one of the options.'); + const { type } = game.rounds[s.round]; + const clean = cleanAnswer(type, question, answer); + if (clean === null) { + return fail( + 'bad_option', + type === 'telepathy' ? 'Type one word.' : type === 'rank' ? 'Put every item in order.' : 'Pick one of the options.', + ); } const answers = (s.answers[questionId] ??= {}); if (answers[role] !== undefined) return fail('answered', "You've already locked in an answer."); - answers[role] = option; + answers[role] = clean; if (answers.A !== undefined && answers.B !== undefined) { - s.marks.push(answers.A === answers.B); + s.marks.push(isMatch(type, answers.A, answers.B)); s.status = 'reveal'; } return OK; @@ -92,7 +158,7 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult { s.q++; s.status = 'question'; } else { - s.earned = s.marks.filter(Boolean).length >= game.rules.pointsForKey; + s.earned = s.marks.filter(Boolean).length >= pointsForKey(game, round); if (s.earned) ctx.addKey(round.keyName); s.status = 'summary'; } diff --git a/server/src/rooms/state.ts b/server/src/rooms/state.ts index b5fe0ed..b065505 100644 --- a/server/src/rooms/state.ts +++ b/server/src/rooms/state.ts @@ -2,6 +2,7 @@ import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/s import type { Game } from '../content/schemas'; import { engineView, gameMeta } from '../content/sanitize'; import { totalKeys } from '../engines/index'; +import { timeLimitSeconds } from '../engines/story'; import type { PlayerRec, Room } from './store'; export function computeResult(room: Room, game: Game): GameResult | null { @@ -69,6 +70,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, }, stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses }, views, diff --git a/server/src/socket/handlers.ts b/server/src/socket/handlers.ts index 8fe5c92..a709913 100644 --- a/server/src/socket/handlers.ts +++ b/server/src/socket/handlers.ts @@ -1,13 +1,20 @@ import { randomUUID } from 'node:crypto'; import type { Server, Socket } from 'socket.io'; import { z } from 'zod'; -import { PING_EMOJIS, otherRole, type JoinAck, type Phase, type Role } from '@two-keys/shared'; +import { DEFUSE_MODULES, PING_EMOJIS, otherRole, type JoinAck, type Phase, 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 { moveExplorer } from '../engines/labyrinth'; -import { agreeAccusation, proposeAccusation, requestHint, submitAccusation, submitLock } from '../engines/story'; -import { answerSync } from '../engines/sync'; +import { + agreeAccusation, + proposeAccusation, + requestHint, + submitAccusation, + submitLock, + tickStory, +} from '../engines/story'; +import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync'; import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types'; import { cleanCode } from '../rooms/codes'; import { buildState } from '../rooms/state'; @@ -26,18 +33,27 @@ const schemas = { hint: z.object({ lockId: z.string(), ...asRole }), propose: z.object({ answers: z.record(z.string()), ...asRole }), defuse: z.object({ - moduleId: z.enum(['wires', 'button', 'keypad']), + moduleId: z.enum(DEFUSE_MODULES), action: z.discriminatedUnion('type', [ z.object({ type: z.literal('cut'), index: z.number().int() }), z.object({ type: z.literal('tap') }), z.object({ type: z.literal('hold') }), z.object({ type: z.literal('release') }), - z.object({ type: z.literal('press'), symbol: z.string() }), + z.object({ type: z.literal('press'), symbol: z.string().optional(), color: z.string().optional() }), + z.object({ type: z.literal('submit'), word: z.string().max(20) }), + z.object({ type: z.literal('transmit'), frequency: z.string().max(10) }), ]), ...asRole, }), - sync: z.object({ questionId: z.string(), optionId: z.number().int(), ...asRole }), - move: z.object({ dir: z.enum(['n', 'e', 's', 'w']), ...asRole }), + // One of: an option index, a typed word (telepathy), or item indexes in order (rank). + sync: z.object({ + questionId: z.string(), + optionId: z.number().int().optional(), + text: z.string().max(TELEPATHY_MAX_LENGTH * 2).optional(), + order: z.array(z.number().int()).max(12).optional(), + ...asRole, + }), + move: z.object({ dir: z.enum(['n', 'e', 's', 'w', 'f', 'l', 'r', 'b']), ...asRole }), notepad: z.object({ text: z.string() }), ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }), view: z.object({ label: z.string().max(80) }), @@ -226,7 +242,7 @@ export function registerHandlers(io: Server, games: Map, store: Ro on('sync:answer', schemas.sync, 'playing', ({ game, data, role, ctx }) => game.engine === 'sync' - ? answerSync(game, ctx, role, data.questionId, data.optionId) + ? answerSync(game, ctx, role, data.questionId, data.order ?? data.text ?? data.optionId) : fail('wrong_engine', 'No questions here.'), ); @@ -265,13 +281,14 @@ export function registerHandlers(io: Server, games: Map, store: Ro }); }); - // The defuse clock can run out with nobody touching anything. + // A defuse clock or a story time limit can run out with nobody touching anything. const ticker = setInterval(() => { for (const room of store.rooms.values()) { const game = games.get(room.gameId); - if (room.phase !== 'playing' || game?.engine !== 'defuse') continue; + if (room.phase !== 'playing' || !game) continue; + if (game.engine !== 'defuse' && !(game.engine === 'story' && game.rules.timeLimitMinutes)) continue; const ctx = createCtx(room); - if (tickDefuse(ctx)) { + if (game.engine === 'defuse' ? tickDefuse(ctx) : tickStory(game, ctx)) { flushToasts(room, ctx); broadcast(room); } diff --git a/server/test/content.test.ts b/server/test/content.test.ts index ad7783c..92407cf 100644 --- a/server/test/content.test.ts +++ b/server/test/content.test.ts @@ -7,13 +7,17 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store'; import { games, getGame, labyrinth, sync } from './helpers'; describe('content', () => { - it('loads all 8 games with no validation errors', () => { + it('loads all 18 games with no validation errors', () => { const files = loadAllFiles(); expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]); - expect(files).toHaveLength(8); - expect([...games.values()].map((g) => g.engine).sort()).toEqual( - ['defuse', 'labyrinth', 'story', 'story', 'story', 'story', 'story', 'sync'], - ); + expect(files).toHaveLength(18); + 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, + }); }); it('rejects a story chapter with no clue for one player', () => { diff --git a/server/test/expansion.test.ts b/server/test/expansion.test.ts new file mode 100644 index 0000000..aa8311f --- /dev/null +++ b/server/test/expansion.test.ts @@ -0,0 +1,471 @@ +// The second wave of games: Overtime (defuse), Deep Cuts (sync), the Drowned Catacombs and +// the Mirror Maze (labyrinth), and the story games with their own penalties and time limits. +import { describe, expect, it } from 'vitest'; +import type { Dir, MoveDir, Role } from '@two-keys/shared'; +import { + buttonSolution, + continueDefuse, + defuseAction, + edgework, + generateDevice, + isSolvable, + remainingAt, + solveKeypad, + solveMorse, + solvePassword, + solveSimon, + timerText, + toSignal, + wireSolution, + type DefuseState, +} from '../src/engines/defuse'; +import { validateGame } from '../src/content/validate'; +import { DELTAS, cellAt, findCells, gateBlocks, moveExplorer, toAbsolute, type LabyrinthState } from '../src/engines/labyrinth'; +import { continueChapter, requestHint, submitLock, tickStory, type StoryState } from '../src/engines/story'; +import { answerSync, continueSync, normalizeWord, type SyncState } from '../src/engines/sync'; +import { buildState } from '../src/rooms/state'; +import type { Room } from '../src/rooms/store'; +import { clock, games, getGame, playingRoom } from './helpers'; + +const overtime = () => getGame('tick-tock-overtime', 'defuse'); +const deepCuts = () => getGame('in-sync-deep-cuts', 'sync'); +const catacombs = () => getGame('drowned-catacombs', 'labyrinth'); +const mirrorMaze = () => getGame('mirror-maze', 'labyrinth'); + +const viewOf = (room: Room, role: Role, now = 2_000_000) => { + const player = room.players.find((p) => p.seat === role)!; + return JSON.parse(JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now))); +}; + +describe('defuse: Overtime manual', () => { + const game = overtime(); + const { fixtures, modules, ruleset } = game; + + it.each(fixtures.wires)('wires fixture %#', (f) => { + expect(wireSolution(ruleset, f, f.wires)).toEqual(f.expect); + }); + it.each(fixtures.button)('button fixture %#', (f) => { + expect(buttonSolution(ruleset, { serial: '', ...f }, f)).toEqual(f.expect); + }); + it.each(fixtures.keypad)('keypad fixture %#', (f) => { + expect(solveKeypad(f.symbols, modules.keypad.columns)).toEqual(f.expect); + }); + it.each(fixtures.passwords)('passwords fixture %#', (f) => { + expect(solvePassword(f.columns, modules.passwords!.words)).toBe(f.expect); + }); + it.each(fixtures.morse)('morse fixture %#', (f) => { + expect(solveMorse(f.signal, modules.morse!.words)).toEqual(f.expect); + }); + it.each(fixtures.simon)('simon fixture %#', (f) => { + expect(solveSimon(f.serial, f.strikes, f.flashes)).toEqual(f.expect); + }); + + it('never generates a device the solver cannot solve, on any level', () => { + for (let level = 0; level < game.levels.length; level++) { + for (let seed = 1; seed <= 400; seed++) { + const device = generateDevice(game, level, seed); + expect(isSolvable(game, device), `level ${level} seed ${seed}`).toBe(true); + expect(device.indicators!.length).toBeGreaterThanOrEqual(1); + expect(device.indicators!.length).toBeLessThanOrEqual(3); + if (device.passwords) { + expect(device.passwords.columns).toHaveLength(5); + for (const column of device.passwords.columns) expect(new Set(column).size).toBe(6); + } + if (device.simon) { + expect(device.simon.sequence.length).toBeGreaterThanOrEqual(3); + expect(device.simon.sequence.length).toBeLessThanOrEqual(5); + } + } + } + }); + + /** Solves whatever is on the device, using only the solver. */ + function solveAll(room: Room, t: ReturnType) { + const s = room.engine as DefuseState; + const d = s.device!; + const act = (id: string, action: Parameters[4], ms = 100) => + expect(defuseAction(game, t.ctx(ms), 'A', id, action).ok, id).toBe(true); + + if (d.wires) act('wires', wireSolution(ruleset, edgework(d), d.wires.colors)); + if (d.keypad) for (const symbol of solveKeypad(d.keypad.symbols, modules.keypad.columns)!) act('keypad', { type: 'press', symbol }); + if (d.passwords) act('passwords', { type: 'submit', word: solvePassword(d.passwords.columns, modules.passwords!.words)! }); + if (d.morse) act('morse', { type: 'transmit', frequency: solveMorse(toSignal(d.morse.word), modules.morse!.words)!.frequency }); + if (d.simon) { + for (let stage = 1; stage <= d.simon.sequence.length; stage++) { + for (const color of solveSimon(d.serial, s.strikes, d.simon.sequence.slice(0, stage))) act('simon', { type: 'press', color }); + } + } + if (d.button) { + const solution = buttonSolution(ruleset, edgework(d), d.button); + if (solution.type === 'tap') act('button', { type: 'tap' }); + else { + act('button', { type: 'hold' }); + for (let i = 0; i < 400; i++) { + if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break; + t.ctx(500); + } + act('button', { type: 'release' }, 0); + } + } + } + + it('clears all four levels for a key each and finishes the game', () => { + const room = playingRoom(game); + const t = clock(room); + for (let level = 0; level < game.levels.length; level++) { + expect(continueDefuse(game, t.ctx()).ok).toBe(true); + solveAll(room, t); + const s = room.engine as DefuseState; + expect(s.strikes, `level ${level}`).toBe(0); + expect(s.status, `level ${level}`).toBe('cleared'); + if (level < game.levels.length - 1) continueDefuse(game, t.ctx()); + } + expect(room.keys).toEqual(game.levels.map((l) => l.keyName)); + expect(room.result?.success).toBe(true); + }); + + it('Say It Back: a wrong pad strikes, restarts the stage, and moves the mapping', () => { + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as DefuseState; + s.level = 2; + continueDefuse(game, t.ctx()); + const sim = s.device!.simon!; + const expected = (strikes: number) => solveSimon(s.device!.serial, strikes, [sim.sequence[0]])[0]; + + defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) }); + expect(sim).toMatchObject({ stage: 2, progress: 0 }); + defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) }); + expect(sim.progress).toBe(1); + + const wrong = modules.simon!.colors.find((c) => c !== solveSimon(s.device!.serial, 0, [sim.sequence[1]])[0])!; + defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: wrong }); + expect(s.strikes).toBe(1); + expect(sim).toMatchObject({ stage: 2, progress: 0 }); + + // One strike in, the first flash now maps to a different pad. + expect(expected(1)).not.toBe(expected(0)); + defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(1) }); + expect(s.strikes).toBe(1); + expect(sim.progress).toBe(1); + }); + + it('a wrong password or frequency is a strike, and the last level only allows two', () => { + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as DefuseState; + s.level = 3; + continueDefuse(game, t.ctx()); + expect(viewOf(room, 'A', t.now).views.A.maxStrikes).toBe(2); + + defuseAction(game, t.ctx(), 'A', 'passwords', { type: 'submit', word: 'ZZZZZ' }); + expect(s).toMatchObject({ strikes: 1, status: 'active' }); + expect(defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: 'loud' }).ok).toBe(false); + expect(s.strikes).toBe(1); + defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: '3.501' }); + expect(s).toMatchObject({ strikes: 2, status: 'exploded', failReason: 'strikes' }); + }); + + it('the Technician gets the blinking pattern and the letters, never the word or the manual', () => { + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as DefuseState; + s.level = 3; + continueDefuse(game, t.ctx()); + const d = s.device!; + + const tech = viewOf(room, 'A', t.now).views.A; + const json = JSON.stringify(tech); + expect(json).not.toContain('Defusal Manual'); + expect(tech.device.indicators).toEqual(d.indicators); + const byId = Object.fromEntries(tech.device.modules.map((m: { id: string }) => [m.id, m])); + expect(Object.keys(byId).sort()).toEqual([...game.levels[3].modules].sort()); + expect(byId.morse.signal).toBe(toSignal(d.morse!.word)); + expect(byId.morse).not.toHaveProperty('word'); + expect(byId.passwords.columns).toEqual(d.passwords!.columns); + // Only the flashes of the current stage, not the whole sequence. + expect(byId.simon.flashes).toEqual(d.simon!.sequence.slice(0, 1)); + expect(json).not.toContain('"sequence"'); + + const expert = JSON.stringify(viewOf(room, 'B', t.now).views.B); + expect(expert).toContain('Overtime Edition'); + expect(expert).not.toContain(d.serial); + for (const key of ['device', 'indicators', 'signal', 'columns', 'flashes']) expect(expert).not.toContain(`"${key}"`); + }); +}); + +describe('sync: Deep Cuts', () => { + const game = deepCuts(); + + it('telepathy ignores case, spacing, punctuation and a trailing S', () => { + expect(normalizeWord(' Ice-Cream ')).toBe(normalizeWord('ice cream')); + expect(normalizeWord('Apples')).toBe(normalizeWord('apple')); + expect(normalizeWord('kiss')).toBe(normalizeWord('KISS')); + expect(normalizeWord('pear')).not.toBe(normalizeWord('peach')); + }); + + /** Plays the open round; `matches` of the questions are answered in sync. */ + function playRound(room: Room, t: ReturnType, matches: number, seen: string[]) { + const s = room.engine as SyncState; + const { type } = game.rounds[s.round]; + expect(s.questions, `round ${s.round} attempt ${s.attempt}`).toHaveLength(game.rules.questionsPerRound); + for (let i = 0; i < game.rules.questionsPerRound; i++) { + const q = s.questions[s.q]; + seen.push(`${s.round}:${q.id}`); + const hit = i < matches; + if (type === 'telepathy') { + expect(answerSync(game, t.ctx(), 'A', q.id, 2).ok).toBe(false); // no options to pick from + expect(answerSync(game, t.ctx(), 'A', q.id, ' ').ok).toBe(false); + answerSync(game, t.ctx(), 'A', q.id, 'Mangos'); + answerSync(game, t.ctx(), 'B', q.id, hit ? ' mango' : 'pear'); + } else if (type === 'rank') { + expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2]).ok).toBe(false); // every item, once + expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 1, 3]).ok).toBe(false); + answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2, 3]); + // Top and bottom agree (the middle doesn't have to), or only the top does. + answerSync(game, t.ctx(), 'B', q.id, hit ? [0, 2, 1, 3] : [0, 1, 3, 2]); + } else { + answerSync(game, t.ctx(), 'A', q.id, 0); + answerSync(game, t.ctx(), 'B', q.id, hit ? 0 : 1); + } + expect(s.status).toBe('reveal'); + expect(s.marks[i]).toBe(hit); + continueSync(game, t.ctx()); + } + expect(s.status).toBe('summary'); + } + + it('scores each round type, with its own bar for the key', () => { + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as SyncState; + const seen: string[] = []; + + continueSync(game, t.ctx()); + playRound(room, t, 3, seen); // telepathy needs 3 + expect(room.keys).toEqual(['Telepathy Key']); + + continueSync(game, t.ctx()); + continueSync(game, t.ctx()); + expect(viewOf(room, 'A').views.A).toMatchObject({ pointsForKey: 4, round: { type: 'predict', subject: 'A' }, iAmSubject: true }); + playRound(room, t, 3, seen); // spotlight needs 4: not enough + expect(s.earned).toBe(false); + continueSync(game, t.ctx()); // replay + playRound(room, t, 4, seen); + expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A']); + + continueSync(game, t.ctx()); + continueSync(game, t.ctx()); + playRound(room, t, 0, seen); + continueSync(game, t.ctx()); + playRound(room, t, 0, seen); // both tries missed + + continueSync(game, t.ctx()); + expect(s.round).toBe(3); + continueSync(game, t.ctx()); + // The spotlight alternates A, B, A, B, A through the ranking round. + expect(viewOf(room, 'B').views.B).toMatchObject({ round: { type: 'rank', subject: 'A' }, iAmSubject: false }); + expect(viewOf(room, 'B').views.B.question.options).toHaveLength(4); + playRound(room, t, 3, seen); + expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A', 'Ranking Key']); + continueSync(game, t.ctx()); + expect(room.phase).toBe('finished'); + + // Nothing was asked twice in the same way (a spotlight question may return for the other partner). + const groups = seen.map((x) => x.replace(/^[12]:/, (m) => (m === '1:' ? 'A:' : 'B:'))); + expect(new Set(groups).size).toBe(groups.length); + }); + + it("keeps a typed word private until both are in", () => { + const room = playingRoom(game); + const t = clock(room); + continueSync(game, t.ctx()); + const q = (room.engine as SyncState).questions[0]; + answerSync(game, t.ctx(), 'A', q.id, 'Banana'); + expect(JSON.stringify(viewOf(room, 'B').views.B)).not.toContain('Banana'); + expect(viewOf(room, 'B').views.B).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null }); + answerSync(game, t.ctx(), 'B', q.id, 'bananas'); + expect(viewOf(room, 'B').views.B.reveal).toEqual({ A: 'Banana', B: 'bananas', match: true }); + }); +}); + +describe('labyrinth: one-way gates (Drowned Catacombs)', () => { + const game = catacombs(); + + it('lets the Explorer through a gate one way only', () => { + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as LabyrinthState; + const [gate] = game.oneWay; // (5,9) → east + Object.assign(s, { x: gate.x, y: gate.y }); + + expect(viewOf(room, 'B').views.B).toMatchObject({ compass: 'absolute', gates: [] }); + expect(moveExplorer(game, t.ctx(), 'B', gate.dir).ok).toBe(true); + expect(s.message).toBe(game.explorerView.flavour.gate); + + const explorer = viewOf(room, 'B').views.B; + expect(explorer.open.w).toBe(false); + expect(explorer.gates).toEqual(['w']); + expect(moveExplorer(game, t.ctx(), 'B', 'w')).toMatchObject({ ok: false, code: 'gate' }); + expect(s).toMatchObject({ x: gate.x + 1, y: gate.y }); + expect(viewOf(room, 'A').views.A.oneWay).toEqual(game.oneWay); + }); + + it('rejects a maze where a gate can trap the Explorer for good', () => { + const sealed = structuredClone(game); + // A dead-end pocket behind a gate, with no trap to fall out through. + sealed.grid = ['#####', '#S.E#', '#.###', '#.KK#', '#.#K#', '#####']; + sealed.lanterns = [ + { x: 2, y: 3, name: 'a' }, + { x: 3, y: 3, name: 'b' }, + { x: 3, y: 4, name: 'c' }, + ]; + sealed.glyphs = []; + sealed.oneWay = [{ x: 1, y: 3, dir: 'e' }]; + expect(validateGame(sealed).join('\n')).toMatch(/stuck for good/); + sealed.oneWay = []; + expect(validateGame(sealed)).toEqual([]); + }); +}); + +describe('labyrinth: no compass, mirrored map (Mirror Maze)', () => { + const game = mirrorMaze(); + + it('turns forward/left/right/back into steps from the way the Explorer faces', () => { + expect((['f', 'r', 'b', 'l'] as const).map((rel) => toAbsolute('e', rel))).toEqual(['e', 's', 'w', 'n']); + + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as LabyrinthState; + expect(s).toMatchObject({ x: 1, y: 1, facing: 'e' }); + expect(moveExplorer(game, t.ctx(), 'B', 'e')).toMatchObject({ ok: false, code: 'bad_dir' }); // no compass + expect(moveExplorer(game, t.ctx(), 'B', 'l')).toMatchObject({ ok: false, code: 'wall' }); + + moveExplorer(game, t.ctx(), 'B', 'f'); + moveExplorer(game, t.ctx(), 'B', 'f'); // (3,1), still facing east + expect(s).toMatchObject({ x: 3, y: 1, facing: 'e' }); + moveExplorer(game, t.ctx(), 'B', 'r'); // right of east is south + expect(s).toMatchObject({ x: 3, y: 2, facing: 's' }); + + const explorer = viewOf(room, 'B').views.B; + expect(explorer.compass).toBe('relative'); + expect(explorer.open).toEqual({ f: true, r: false, b: true, l: false }); + expect(JSON.stringify(explorer)).not.toMatch(/"(facing|n|e|s|w|x|y|grid)"/); + }); + + it('serves the Navigator the map flipped left to right', () => { + const room = playingRoom(game); + const navigator = viewOf(room, 'A').views.A; + const width = game.grid[0].length; + expect(navigator.banner).toMatch(/traced from a mirror/); + expect(navigator.grid).toEqual(game.grid.map((row) => [...row].reverse().join(''))); + expect(navigator.grid).not.toEqual(game.grid); + game.lanterns.forEach((l, i) => { + expect(navigator.lanterns[i]).toMatchObject({ x: width - 1 - l.x, y: l.y }); + expect(navigator.grid[l.y][navigator.lanterns[i].x]).toBe('K'); + }); + game.glyphs.forEach((g, i) => expect(navigator.glyphs[i].x).toBe(width - 1 - g.x)); + }); +}); + +describe.each([catacombs(), mirrorMaze()].map((g) => [g.id, g] as const))('labyrinth: %s', (_id, game) => { + it('can be completed without a single trap', () => { + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as LabyrinthState; + const state = (p: { x: number; y: number }, held: boolean[]) => `${p.x},${p.y},${held.map(Number).join('')}`; + + // Breadth-first over (cell, lanterns held): gates make the order matter. + type Node = { x: number; y: number; held: boolean[]; path: Dir[] }; + const queue: Node[] = [{ x: s.x, y: s.y, held: game.lanterns.map(() => false), path: [] }]; + const seen = new Set([state(queue[0], queue[0].held)]); + let route: Dir[] | null = null; + while (queue.length && !route) { + const node = queue.shift()!; + for (const dir of Object.keys(DELTAS) as Dir[]) { + const next = { x: node.x + DELTAS[dir].x, y: node.y + DELTAS[dir].y }; + const c = cellAt(game.grid, next.x, next.y); + if (c === '#' || c === 'T' || gateBlocks(game.oneWay, node.x, node.y, dir)) continue; + const held = node.held.map((h, i) => h || (game.lanterns[i].x === next.x && game.lanterns[i].y === next.y)); + if (seen.has(state(next, held))) continue; + seen.add(state(next, held)); + const path = [...node.path, dir]; + if (c === 'E' && held.every(Boolean)) route = path; + queue.push({ ...next, held, path }); + } + } + expect(route).not.toBeNull(); + + const relative = game.explorer.compass === 'relative'; + for (const dir of route!) { + const move: MoveDir = relative ? (['f', 'r', 'b', 'l'] as const).find((rel) => toAbsolute(s.facing!, rel) === dir)! : dir; + expect(moveExplorer(game, t.ctx(), 'B', move).ok).toBe(true); + } + expect(room.phase).toBe('finished'); + expect(s.falls).toBe(0); + expect(room.keys).toHaveLength(game.lanternsRequired); + expect(findCells(game.grid, 'K')).toHaveLength(game.lanternsRequired); + }); +}); + +describe('story: per-game hint penalties and time limits', () => { + it('charges the hint penalty the game sets', () => { + const game = getGame('kestrel-heist', 'story'); + const room = playingRoom(game); + const t = clock(room); + const ctx = t.ctx(); + requestHint(game, ctx, game.chapters[0].lock.id); + expect(room.penaltySeconds).toBe(240); + expect(ctx.toasts[0].message).toBe('Hint 1 revealed (+4:00)'); + expect(viewOf(room, 'A').views.A.chapters[0].lock.hintPenaltySeconds).toBe(240); + + const classic = getGame('vineyard-hollow', 'story'); + const other = playingRoom(classic); + requestHint(classic, clock(other).ctx(), classic.chapters[0].lock.id); + expect(other.penaltySeconds).toBe(120); + }); + + it('ends the game when the clock (elapsed + penalties) passes the limit', () => { + const game = getGame('deep-lab-nereid', 'story'); + const limit = game.rules.timeLimitMinutes! * 60; + const room = playingRoom(game); + const t = clock(room); + expect(viewOf(room, 'A').timer).toMatchObject({ limitSeconds: limit, limitLabel: 'Water level' }); + + expect(tickStory(game, t.ctx((limit - 100) * 1000))).toBe(false); + requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over + expect(tickStory(game, t.ctx())).toBe(true); + expect(room.phase).toBe('finished'); + expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null }); + expect(tickStory(game, t.ctx())).toBe(false); + }); + + it('shows the reveal when an accusation game times out, and leaves untimed games alone', () => { + const game = getGame('kalahari-expedition', 'story'); + if (game.finale.type !== 'accusation') throw new Error('expected accusation'); + const room = playingRoom(game); + const t = clock(room); + expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true); + expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal }); + + const untimed = getGame('vineyard-hollow', 'story'); + const calm = playingRoom(untimed); + expect(tickStory(untimed, clock(calm).ctx(24 * 3600_000))).toBe(false); + expect(viewOf(calm, 'A').timer).toMatchObject({ limitSeconds: null, limitLabel: null }); + }); + + it('plays a timed escape room through to the end inside the limit', () => { + const game = getGame('deep-lab-nereid', 'story'); + if (game.finale.type !== 'escape') throw new Error('expected escape'); + const room = playingRoom(game); + const t = clock(room); + for (const ch of game.chapters) { + expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toMatchObject({ correct: true }); + continueChapter(game, t.ctx()); + } + expect((room.engine as StoryState).chapter).toBe(game.chapters.length); + expect(tickStory(game, t.ctx())).toBe(false); + submitLock(game, t.ctx(), game.finale.lock.id, 'surface'); + expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null }); + }); +}); diff --git a/shared/package.json b/shared/package.json index 2aea310..9c23dd2 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/shared", - "version": "1.0.1", + "version": "1.1.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/shared/src/types.ts b/shared/src/types.ts index 1efb784..c7d032b 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -35,6 +35,8 @@ export interface GameSummary { theme: Theme; roles: Record; totalKeys: number; + /** Hard time limit, for the games that have one. */ + timeLimitMinutes: number | null; } export interface GameMeta extends GameSummary { @@ -83,6 +85,8 @@ export interface LockView { /** Revealed hint tiers only. */ hints: string[]; hintsTotal: number; + /** What the next hint costs. */ + hintPenaltySeconds: number; wrongAttempts: number; nudge: boolean; successText: string | null; @@ -162,7 +166,42 @@ export interface KeypadModuleView { solved: boolean; symbols: { id: string; label: string; pressed: boolean }[]; } -export type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView; +export interface PasswordsModuleView { + id: 'passwords'; + solved: boolean; + /** The letters each column cycles through, top to bottom. */ + columns: string[]; +} +export interface MorseModuleView { + id: 'morse'; + solved: boolean; + /** Same format as a signal clue. Never print it as text. */ + signal: string; + /** Dial range in kHz (3500 = 3.500 MHz). */ + minKhz: number; + maxKhz: number; +} +export interface SimonModuleView { + id: 'simon'; + solved: boolean; + colors: string[]; + /** The flashes of the current stage. */ + flashes: string[]; + stage: number; + stages: number; + /** Presses accepted so far in this stage. */ + entered: number; +} +export type DefuseModuleView = + | WiresModuleView + | ButtonModuleView + | KeypadModuleView + | PasswordsModuleView + | MorseModuleView + | SimonModuleView; + +export const DEFUSE_MODULES = ['wires', 'button', 'keypad', 'passwords', 'morse', 'simon'] as const; +export type DefuseModuleId = (typeof DEFUSE_MODULES)[number]; interface DefuseCommon { engine: 'defuse'; @@ -175,7 +214,12 @@ interface DefuseCommon { } export interface TechnicianView extends DefuseCommon { seat: 'technician'; - device: { serial: string; batteries: number; modules: DefuseModuleView[] } | null; + device: { + serial: string; + batteries: number; + indicators: { label: string; lit: boolean }[]; + modules: DefuseModuleView[]; + } | null; } export interface ExpertView extends DefuseCommon { seat: 'expert'; @@ -188,10 +232,18 @@ export type DefuseAction = | { type: 'tap' } | { type: 'hold' } | { type: 'release' } - | { type: 'press'; symbol: string }; + | { type: 'press'; symbol?: string; color?: string } + | { type: 'submit'; word: string } + | { type: 'transmit'; frequency: string }; // ---------- Sync engine ---------- +export const SYNC_ROUND_TYPES = ['match', 'predict', 'telepathy', 'rank'] as const; +export type SyncRoundType = (typeof SYNC_ROUND_TYPES)[number]; + +/** An option index (match/predict), a typed word (telepathy), or item indexes, best first (rank). */ +export type SyncAnswer = number | string | number[]; + export interface SyncView { engine: 'sync'; status: 'intro' | 'question' | 'reveal' | 'summary'; @@ -199,8 +251,9 @@ export interface SyncView { index: number; total: number; title: string; - type: 'match' | 'predict'; + type: SyncRoundType; blurb: string; + /** Who the current question is about (predict and rank rounds). */ subject: Role | null; }; isReplay: boolean; @@ -208,12 +261,13 @@ export interface SyncView { questionsPerRound: number; pointsForKey: number; points: number; + /** `options` holds the items to order in a rank round, and is empty for telepathy. */ question: { id: string; text: string; options: string[] } | null; iAmSubject: boolean; - myAnswer: number | null; + myAnswer: SyncAnswer | null; partnerAnswered: boolean; /** Both answers, only once both are in. */ - reveal: { A: number; B: number; match: boolean } | null; + reveal: { A: SyncAnswer; B: SyncAnswer; match: boolean } | null; /** Match / miss for each question revealed so far this round. */ marks: boolean[]; summary: { earned: boolean; canReplay: boolean; keyName: string } | null; @@ -222,12 +276,19 @@ export interface SyncView { // ---------- Labyrinth engine ---------- export type Dir = 'n' | 'e' | 's' | 'w'; +/** Forward / left / right / back, for mazes where the Explorer has no compass. */ +export type RelDir = 'f' | 'l' | 'r' | 'b'; +export type MoveDir = Dir | RelDir; export interface ExplorerView { engine: 'labyrinth'; seat: 'explorer'; glyphLabel: string | null; - open: Record; + compass: 'absolute' | 'relative'; + /** Keyed by n/e/s/w, or by f/l/r/b when the compass is relative. */ + open: Partial>; + /** Directions blocked by a one-way gate rather than a wall. */ + gates: MoveDir[]; keysCarried: number; totalKeys: number; atExit: boolean; @@ -242,6 +303,9 @@ export interface NavigatorView { grid: string[]; glyphs: { x: number; y: number; label: string }[]; lanterns: { x: number; y: number; name: string; collected: boolean }[]; + /** One-way gates: passable from (x, y) in `dir` only. */ + oneWay: { x: number; y: number; dir: Dir }[]; + banner: string | null; keysCarried: number; totalKeys: number; moves: number | null; @@ -281,6 +345,9 @@ export interface RoomState { finishedAt: number | null; penaltySeconds: number; serverNow: number; + /** Hard limit on elapsed + penalty time, if the game has one. */ + limitSeconds: number | null; + limitLabel: string | null; }; stats: { hintsUsed: number; wrongGuesses: number }; /** One view per role this socket may see: own role, or both in couch mode. */