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
- 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.
- {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.
+ 🪞 {view.banner} +
+ )}Open passages: {open.join(', ') || 'none'}
+ {gated.length > 0 && ( ++ Gate (won't open from this side): {gated.join(', ')} +
+ )}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.'}
{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.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 && (
{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