diff --git a/CLAUDE.md b/CLAUDE.md index ff4e4e0..02e458c 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 (18 games, 4 engines) +### Game library (40 games, 7 engines) | # | Game | Category | Engine | File | |---|------|----------|--------|------| @@ -31,6 +31,28 @@ A website where **two players (a couple)** play co-op mystery games together. Ea | 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` | +| 19 | The Treehouse | Escape Room | `story` | `the-treehouse.yaml` | +| 20 | Twin Rooms: Sunday Morning | Spot the Difference | `twin` | `twin-sunday-morning.yaml` | +| 21 | Blueprint: First Sketch | Describe & Draw | `blueprint` | `blueprint-first-sketch.yaml` | +| 22 | Wavelength: Tune In | Mind Meld | `dial` | `wavelength-tune-in.yaml` | +| 23 | Death at the Braai | Murder Mystery | `story` | `death-at-the-braai.yaml` | +| 24 | In Sync: Road Trip | Couples Connection | `sync` | `in-sync-road-trip.yaml` | +| 25 | Wavelength: Date Night | Mind Meld | `dial` | `wavelength-date-night.yaml` | +| 26 | Twin Rooms: The Twin Cafés | Spot the Difference | `twin` | `twin-cafes.yaml` | +| 27 | Curtain Call | Murder Mystery | `story` | `curtain-call.yaml` | +| 28 | The Magician's Dressing Room | Escape Room | `story` | `magicians-dressing-room.yaml` | +| 29 | Blueprint: Stained Glass | Describe & Draw | `blueprint` | `blueprint-stained-glass.yaml` | +| 30 | The Hedge Maze at Midnight | Co-op Navigation | `labyrinth` | `hedge-maze-midnight.yaml` | +| 31 | Snowbound at the Edelweiss | Murder Mystery | `story` | `snowbound-edelweiss.yaml` | +| 32 | The Pharaoh's Tomb | Escape Room | `story` | `pharaohs-tomb.yaml` | +| 33 | Twin Rooms: Through the Looking Glass | Spot the Difference | `twin` | `twin-looking-glass.yaml` | +| 34 | Tick Tock: Switch | Co-op Defuse | `defuse` | `tick-tock-switch.yaml` | +| 35 | The Hawthorn Park Cipher | Murder Mystery | `story` | `hawthorn-park-cipher.yaml` | +| 36 | The Inventor's Paradox | Escape Room | `story` | `inventors-paradox.yaml` | +| 37 | Blueprint: Blind Mirror | Describe & Draw | `blueprint` | `blueprint-blind-mirror.yaml` | +| 38 | Wavelength: Static | Mind Meld | `dial` | `wavelength-static.yaml` | +| 39 | 'Til Death Do Us Part | Murder Mystery | `story` | `til-death-do-us-part.yaml` | +| 40 | The Last Lighthouse | Escape Room | `story` | `the-lighthouse.yaml` | ## 2. Tech stack @@ -63,11 +85,12 @@ two-keys/ engines/defuse.ts # generator + solver engines/sync.ts engines/labyrinth.ts + engines/dial.ts twin.ts blueprint.ts socket/handlers.ts scripts/validate-content.ts client/src/ pages/Home.tsx Library.tsx Lobby.tsx Play.tsx Results.tsx HowToPlay.tsx - engines/StoryGame/ DefuseGame/ SyncGame/ LabyrinthGame/ + engines/StoryGame/ DefuseGame/ SyncGame/ LabyrinthGame/ DialGame/ TwinGame/ BlueprintGame/ components/ClueCard.tsx LockPanel.tsx KeyRing.tsx HintButton.tsx Notepad.tsx PartnerStatus.tsx Timer.tsx PassDeviceScreen.tsx lib/socket.ts session.ts theme.ts @@ -96,7 +119,7 @@ All game files share a header: ```yaml id: string # matches filename -engine: story | defuse | sync | labyrinth +engine: story | defuse | sync | labyrinth | dial | twin | blueprint category: murder-mystery | escape-room | co-op title, tagline: string estimatedMinutes: number @@ -107,7 +130,7 @@ roles: { A: {name, blurb}, B: {name, blurb} } intro: markdown ``` -### `story` engine (games 1–5, 9–14) +### `story` engine (games 1–5, 9–14, 19, 23, 27, 28, 31, 32, 35, 36, 39, 40) ```yaml rules: # optional @@ -167,7 +190,7 @@ See each YAML for its schema. Summaries in §6. - The server loads YAML and keeps the full version in memory. - `sanitize.ts` builds the **per-role payload**: removes every `answers`, `answer`, `solution`, finale `answer` field, and removes the *other* role's clues. Write a unit test asserting no sanitized payload contains any answer string. -- Also strip per engine: **defuse** → Technician never gets `manual`, Expert never gets the device state; nobody gets `fixtures` or computed solutions. **labyrinth** → Explorer never gets `grid`, `glyphs`, `lanterns` positions; Navigator never gets the Explorer's coordinates. **sync** → never send the partner's answer until both have answered. **signal clues** → send the `signal` pattern only for clues of type `signal`, and the client must never print it as text. +- Also strip per engine: **defuse** → Technician never gets `manual`, Expert never gets the device state; nobody gets `fixtures` or computed solutions. **labyrinth** → Explorer never gets `grid`, `glyphs`, `lanterns` positions; Navigator never gets the Explorer's coordinates. **sync** → never send the partner's answer until both have answered. **dial** → the guesser never gets the target until they lock in; the clue-giver never gets the guesser's live needle. **twin** → `changes` never leave the server; each player gets only their own room, and the partner only learns *that* a square was tapped, never which. **blueprint** → the Builder never gets `rows` or `title` until the picture is scored; the Architect never gets the canvas except during a peek or after scoring. **labyrinth items/doors** → positions never go to the Explorer. **signal clues** → send the `signal` pattern only for clues of type `signal`, and the client must never print it as text. - Hints are only sent once revealed (never ship unrevealed hint tiers to the client). - **Only the server checks answers.** Client sends `lock:submit {lockId, value}`. - **Normalizer** (`shared/normalize.ts`): uppercase → strip accents → remove everything except `A-Z0-9`. So `"20:40"`, `"20 40"` → `2040`; `"Fire Swart, Friday!"` → `FIRESWARTFRIDAY`. Compare normalized input to each normalized entry in `answers`. @@ -191,6 +214,9 @@ See each YAML for its schema. Summaries in §6. - 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. +- **`manualFrom: `** (Tick Tock: Switch): the loader takes `ruleset`, `device`, `modules`, `manual` and `fixtures` from that game's file, so it uses that game's solver. The file itself only has `levels`, `strikes` and the options below. Loading fails if the source game is missing. +- **`swapRolesEachLevel`**: A is the Technician on levels 1, 3, 5, B on 2 and 4. Before each level both see a "🔄 Swap! You're now the Technician" card and tap Ready (`level:ready`); the clock starts when both have. +- A level may list `wires` more than once: each becomes its own module (`wires-1`, `wires-2`, shown as "Wires A", "Wires B"), generated and solved separately. No other module may repeat. `strikesOverride: 1` is valid. - **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) @@ -216,6 +242,27 @@ See each YAML for its schema. Summaries in §6. - `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. +- `items` and `doors` (Hedge Maze): items (on plain `.` floor) are picked up on entry and kept after traps; a door cell is shut until the Explorer carries the item it `needs`. Bumping into it shows `lockedText` (no move), the first pass shows `openText`. The Explorer has an inventory strip of item names; the Navigator sees 🚪 and 🗝 on the map, items dimmed once collected, and gets no toast about pickups. + +### 6.6 Dial engine (Wavelength · "Mind Meld", games 22, 25, 38) +- Each round: an unused spectrum (two opposite labels) and a random integer target at least `targetMargin` inside `range`. The clue-giver alternates: **A on odd rounds, B on even**. +- The clue-giver sees the target as wedges (the scoring bands) and sends `dial:clue`. The server rejects, with a toast: empty clues, more than `maxWords` words or `maxChars` characters, any word from either label (`banLabelWords`), and any word used earlier in the game (`noRepeatWords`). Words are compared uppercase, without accents or punctuation, minus one trailing S. +- The guesser sees the clue ("🤫 Clue only!") and drags the needle (arrow keys ±1, shift ±5). `dial:needle` is throttled, kept for a refresh and never broadcast; `dial:lock` scores it: the first band whose `within` covers the distance, else 0. +- `guesserSeesLabels: left` replaces the right label with "▒▒▒ static ▒▒▒" (`none` hides both); `guesserSeesNumbers: false` hides the tick numbers. Both show again at the reveal. +- `keys` are cumulative point thresholds, awarded the moment the total reaches them. After `rounds` the game ends: success only with every key. + +### 6.7 Twin engine (Twin Rooms · "Spot the Difference", games 20, 26, 33) +- Each scene's `grid` is A's room; rows split on single spaces (emoji can be ZWJ sequences, never split by character). B's room is the grid with `changes` applied, and with `mirrorB` it's served flipped left to right (B's taps are un-mirrored with `x → width − 1 − x`). +- Cells render in **Noto Color Emoji**, with U+FE0F added to text-default symbols (🛏 🕯 🖼 ☂ ✉ ✏ ☘…) so both phones match. +- A tap (`twin:tap`) marks the square on your own screen and waits `claimWindowSeconds` for the partner, who's told "Your partner tapped a square". Same square in time: a difference is found (✅ on both, counter "3 / 6"), anything else costs `wrongClaimPenaltySeconds` off the scene clock (and onto the final time). A different square replaces the earlier tap. +- Every difference found = the scene's key. A scene that times out can be retried once with the clock reset; after that it moves on with no key. `rules.timeLimitMinutes` (Looking Glass) still applies on top. Success only with every key. + +### 6.8 Blueprint engine (Describe & Draw, games 21, 29, 37) +- The **Architect** (A, or alternating A, B, A… with `swapRolesEachPattern`) sees the pixel picture; the **Builder** has a blank `size`×`size` canvas, the palette (swatch plus name) and an eraser. Tap or drag to paint, Undo (20 steps), Clear, Submit (with a confirm). Paints go to the server in debounced batches (`blueprint:paint`), so a refresh keeps the canvas. +- **Peek** (`blueprint:peek`, `peeks` per picture) shows the Architect the canvas for 3 seconds. +- **Scoring (`overlap`)**: cells painted on both with the same colour ÷ cells painted on either. A blank canvas scores 0. Pass at `threshold`; with `submitsPerPattern: 2` the best of two counts. Running out of time scores the canvas as it is. The reveal overlays the picture on the canvas (green right, red wrong, dashed missed). +- **`rotateBuilder: 90`**: the canvas is stored and scored in target orientation and shown to the Builder turned 90° clockwise: displayed (column c, row r) on an n×n grid is stored (x = r, y = n − 1 − c). +- Each passed picture = its key. Success only with every key. ## 7. Socket events @@ -224,7 +271,9 @@ Client → server: `lock:submit {lockId, value}`, `hint:request {lockId}`, `clue:pin {clueId}`, `chapter:continue`, `finale:propose {answers}`, `finale:agree`, `finale:submit`, `defuse:action {moduleId, action}`, `sync:answer {questionId, optionId | text | order}`, -`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` / `forward/left/right/back` with a relative compass), `notepad:update {text}`, `ping {emoji}` +`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` / `forward/left/right/back` with a relative compass), `level:ready` (defuse swap card), +`dial:clue {text}`, `dial:needle {value}` (guesser only, never broadcast), `dial:lock {value}`, `twin:tap {x, y}`, +`blueprint:paint {cells: [{x, y, color | null}]}`, `blueprint:peek`, `blueprint:submit`, `notepad:update {text}`, `ping {emoji}` Server → client: `room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`, @@ -242,8 +291,8 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e ## 9. Design direction - 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 🔑 out of 5; difficulty 5 adds a **☠️ Nightmare** chip). Difficulty: 1 Easy · 2 Medium · 3 Hard · 4 Expert · 5 Nightmare. The library sorts easiest first and filters by category and difficulty. +- Fonts: Playfair Display / Inter / Caveat (handwritten notes) / JetBrains Mono (logs, maps) / Noto Color Emoji (Twin Rooms) 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 🔑 out of 5; difficulty 5 adds a **☠️ Nightmare** chip). Difficulty: 1 Easy · 2 Medium · 3 Hard · 4 Expert · 5 Nightmare. The library sorts easiest first and filters by category and difficulty. The dial, twin and blueprint games (`category: co-op`) show their engine label as the chip ("Mind Meld", "Spot the Difference", "Describe & Draw") and each has its own filter. - **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"). @@ -253,10 +302,14 @@ 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; clue ids are unique; every signal clue has a `signal`; escape finales have A and B clues -- Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined; for `overtime`, the keypad columns, password words and Morse frequencies match the solver's tables (`OVERTIME_TABLES`) and every password fixture column has exactly `lettersPerColumn` letters -- 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; glyphs and gates never on walls or traps; every `lanterns` entry sits on a K +- Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined (in the `manualFrom` game, if set, which must exist); only `wires` may repeat in a level; for `overtime`, the keypad columns, password words and Morse frequencies match the solver's tables (`OVERTIME_TABLES`) and every password fixture column has exactly `lettersPerColumn` letters +- 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; glyphs and gates never on walls or traps; every `lanterns` entry sits on a K; items and doors on plain floor, every door needs a known item, and with doors the reachability and stuck checks play the maze out carrying items - Sync: ≥15 questions per round type in use; every rank question has exactly 4 items; no option that looks split by an unquoted comma (quote any option or label containing a comma) - Choice options (`{ id, label }`) are strict, so a stray key from an unquoted comma fails validation +- Dial: spectra ≥ max(30, rounds × 2) with unique ids; bands widen and score less as they do; key thresholds go up and are reachable +- Twin: rows have equal cell counts; every change is on the grid, actually changes the cell, and has its own cell; `differences` matches the change count +- Blueprint: `rows` are `size`×`size`; every colour is on the palette; the picture isn't empty; `0 < threshold ≤ 1` +The report prints a one-line summary per game (the TypeScript port of GAMES-PACK-3.md Appendix A). Run it in CI and as a pre-start step. ## 11. Build phases (do in order, commit after each) @@ -270,7 +323,7 @@ Run it in CI and as a pre-start step. ## 12. Acceptance criteria -- [ ] Two phones can play any of the 18 games start to finish +- [ ] Two phones can play any of the 40 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 03ab456..4b97373 100644 --- a/GAMES.md +++ b/GAMES.md @@ -232,3 +232,200 @@ Telepathy (type the same word, 3/5) · Spotlight A (4/5) · Spotlight B (4/5) · #KT...#......E# ############### ``` + +--- + +# Pack 3: games 19–40 + +Every lock below needs info from **both** players. "A has / B has" shows the split. + +## 19. The Treehouse (escape room · 15 min · 🔑) +Roles: **Up the Tree** (A) / **Down Below** (B). + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| Lockbox | Comics #4 red, #9 blue, #2 green | "Rainbow order" | `429` | +| Password | Charter order: Sam, Ava, Jo, Ben | Pets: Peanut, Ace, Luna, Socks | `PALS` | +| Treasure | Garden grid (rock at B1) | Map: 3 squares east of the rock | `BIRDBATH` | +| Trapdoor (finale) | "Year we built it, +1 every digit" | Trunk: BUILT 2009 | `3110` | + +--- + +## 23. Death at the Braai (murder mystery · 20 min · 🔑🔑) +Roles: **Detective** (A) / **Neighbour** (B). + +| Ch | Lock | A has | B has | Answer | +|----|------|-------|-------|--------| +| 1 | Collapse time | Meat on when coals ready, 20 min | Fire lit 17:10, coals 45 min | `18:15` | +| 2 | Who turned the meat | Someone in the Bulls apron at ~18:00 | Thandi borrowed the Bulls apron at 17:50 | Thandi | +| 3 | Skewer plant | Lab: oleandrin, from the skewer stick | Oleander hedge, branches cut | `OLEANDER` | +| 4 | Gerrit's note | A=1 number note | "A=1, B=2" + Thandi does the books | `AUDIT MONDAY THE TILL IS SHORT` | + +**Killer:** Thandi Mokoena · sosatie on an oleander stick swapped onto Gerrit's side · to stop the audit exposing her theft from the butchery. + +--- + +## 27. Curtain Call (murder mystery · 30 min · 🔑🔑🔑) +Roles: **Critic** (A) / **Stage Manager** (B). Hint penalty 3:00. + +| Ch | Lock | A has | B has | Answer | +|----|------|-------|-------|--------| +| 1 | Weapon source | Forensic: 21 cm steel, crown-over-B, dusty | Inventory | `FOYER` (1952 display dagger) | +| 2 | Who handled it | Witness: grey gown on audience's RIGHT | Prompt book: Margot (Vivienne) DSL, Ros (Mia) DSR | Vivienne | +| 3 | Speech card | `DJR OD PIY GTOFSU` | Typed one key too far right + keyboard | `SHE IS OUT FRIDAY` | +| 4 | Key sign-out | Room 1 took the display key 19:40–19:55 | Room list; Owen says hairpin opens it | Owen (red herring; Margot's hairpin missing) | + +**Killer:** Vivienne Shaw · hairpin opened the foyer case, swapped the 1952 dagger for the prop in the banquet scene · Desmond was firing her at the curtain call. + +--- + +## 28. The Magician's Dressing Room (escape room · 25 min · 🔑🔑🔑) +Roles: **Assistant** (A) / **Stagehand** (B). + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| Drawer | Magic square with gaps | "Every line adds to the same number" | `7913` (square 276 / 951 / 438) | +| Hat | 5♥ A♠ 2♠ 2♠ 9♠ 7♥ | Card cipher (♠ A–M, ♥ N–Z) | `RABBIT` | +| Picture | Row clues | Column clues | `HEART` (5×5 nonogram, unique) | +| Stage door (finale) | ♠ ♥ ♦ ♣ dial order | Card table | `3521` | + +--- + +## 31. Snowbound at the Edelweiss (murder mystery · 40 min · 🔑🔑🔑🔑) +Roles: **Inspector** (A) / **Concierge** (B). Hint penalty 3:00. **1 attempt, 4 questions.** + +| Ch | Lock | A has | B has | Answer | +|----|------|-------|-------|--------| +| 1 | Ski track time | 9 cm in Bruno's prints, 7 cm in ski tracks | Snowfall rates | `00:20` (Bruno walked out 23:40) | +| 2 | Wax colour | Wax rated −10 to −18 °C | Wax chart + wax log | `GREEN` (Hans, Sophie) | +| 3 | Pole signals | Porter's sketch (porter's left/right) | Chart from the signaller's view | `BINDINGS` (swap left/right) | +| 4 | No alibi | Rooms; Sophie poured the pill away | Room 4 (Hans) on the phone 00:05–00:40; others at cards | Sophie | +| 5 | Paper strip | `THCHWCEAICSDRNEAONSAT` | 3 letters per wrap; clipping: S. Weiss prepared Hans's skis | `THE CRASH WAS NO ACCIDENT` | + +**Killer:** Sophie Weiss · skied out at 00:20 on green-waxed skis and struck Bruno with a pole · he was about to reveal she loosened Hans's binding. Bruno was signalling to Hans. + +--- + +## 32. The Pharaoh's Tomb (escape room · 35 min · 🔑🔑🔑🔑) +Roles: **Archaeologist** (A) / **Translator** (B). **40-minute "Torch" limit.** + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| God's name | 🔒 👄 🔒 | Consonant chart + gods list | `OSIRIS` (S-R-S) | +| Two kings | Khaf-Ra numerals (2347) | Number signs + Djed-Ka-Ra (1921) | `426` | +| Floor | Tile grid with symbols + letters | △ = life | `PHARAOH` (only △ path) | +| Sarcophagus (finale) | 🌊 🐍 👄 🍞 👄 | Royals list | `NEFERTARI` (not Nefertiti) | + +Floor (verified unique △ path, bottom-left up): +``` +○K □D △H ◇M △T +◇E ○S △O ○L □N +□P △R △A ◇F ○B +△H △A ○I □C △X +△P ◇W □V ○G ◇Y +``` + +--- + +## 35. The Hawthorn Park Cipher (murder mystery · 60 min · ☠️) +Roles: **Security Officer** (A) / **Hut Supervisor** (B). Hint penalty 4:00. **75-minute limit. 1 attempt, 4 questions.** + +| Ch | Lock | A has | B has | Answer | +|----|------|-------|-------|--------| +| 1 | Codename | Intercept + rotor method | Key for 14 JAN = R | `NIGHTINGALE` ("NIGHTINGALE REPORTS FROM HUT SEVEN") | +| 2 | Circled letters | Clues; CO poisoning | Answer slots + circles | `FLUE` (OFFICER, COLD, UBOAT, ENIGMA) | +| 3 | Typewriter | Note's three faults | Machine inventory | `4` (Teleprinter Room: Kemp, Greaves until midnight) | +| 4 | Silent teleprinter | Kemp: "never left"; Greaves alibi | Send log; canteen book (Harrow on phone) | `45` (01:20–02:05) | +| 5 | Eleanor's tape | ●○ rows | 5-bit binary, A=1 | `SHE SINGS` | +| 6 | D. K. | Concert programme: Nightingale solo by D. K. | Choir roster | Dorothy Kemp | + +**Killer:** Dorothy "Dot" Kemp (Nightingale) · blocked the stove flue with a rag during the teleprinter silence · forged the note on machine 4 to frame Nowak · Eleanor had uncovered the spy. + +--- + +## 36. The Inventor's Paradox (escape room · 50 min · ☠️) +Roles: **Inside the Lab** (A) / **Archivist** (B). Hint penalty 4:00. **55-minute limit.** + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| Switchboard | Circuit in private symbols | Symbol legend (⊗ AND, ⊘ OR, ⊕ XOR, ¬ NOT) | `1001` (unique) | +| Chalk grid | Half the 4×4 givens | Other half | `1441` (unique only combined) | +| Return date | "First Friday 13th after her 60th birthday" | Born 14 Apr 1871 + 1931 calendar | `13111931` | +| Fuel mix | Vial colours | Prime vials, average, round down | `#667744` | +| Brass clock | Reflection reads ~4:35 | "Seen in brass", 1↔11 | `07:25` | +| Year dial (finale) | Built 1912 | Age riddle (3× then 2× after 18 years) | `1858` | + +--- + +## 30. The Hedge Maze at Midnight (co-op navigation · 15 min · 🔑🔑🔑) +15×11, 4 traps, 3 lanterns, **1 iron gate + key** (new items/doors feature). + +``` +############### S (1,1) E (13,9) +#S....#K.T.T..# K (1,8) (5,8) (7,1) +#.#.#.#.#####.# T (3,5) (9,1) (9,3) (11,1) +#...#.#..T#...# D = iron gate (5,7), needs gate-key +#.#.#.##.##.#.# G = gate-key item (5,9) +#.#T..#.....#.# +#.#####.#####.# Verified by state BFS: all reachable +#.#..D..#...#.# without traps, gate only after key, +#K###K###.#.#.# no softlocks. (D and G are '.' in the +#....G#......E# YAML grid; they're defined in lists.) +############### +``` + +## 24. In Sync: Road Trip (couples connection · 15 min · 🔑🔑) +Match · Telepathy · Spotlight Driver · Spotlight Navigator. 3/5 per key. 4 keys. + +## 34. Tick Tock: Switch (co-op defuse · 20 min · 🔑🔑🔑🔑) +Overtime's manual and solver (`manualFrom`). 5 levels, **roles swap every level**, 2 strikes (level 5: 1 strike). Level 4 has two separate wire modules. + +## 22 / 25 / 38. Wavelength (Mind Meld) +| Game | Diff | Rounds | Clue | Labels for guesser | Bands (pts 4/3/2/1) | Keys at | +|------|------|--------|------|--------------------|---------------------|---------| +| Tune In | 1 | 6 | ≤5 words | both, numbers | ±5 / 10 / 15 / 20 | 8, 14 | +| Date Night | 2 | 8 | ≤3 words | both, numbers | ±4 / 9 / 14 | 8, 14, 20 | +| Static | 5 | 10 | 1 word, no repeats | left only, no numbers | ±2 / 5 / 8 | 12, 20, 26 | + +## 20 / 26 / 33. Twin Rooms (Spot the Difference) +| Game | Diff | Grid | Diffs per room | Coordinates | Mirror | Wrong claim | +|------|------|------|----------------|-------------|--------|-------------| +| Sunday Morning | 1 | 6×5 | 4 | yes | no | −15 s | +| The Twin Cafés | 2 | 8×6 | 6 | no | no | −20 s | +| Through the Looking Glass | 4 | 10×8 | 8 (very subtle: 🍎/🍏, 🌑/🌚, 🕐/🕜) | no | **B mirrored** | −30 s, 25-min limit | + +Exact differences are in each YAML's `changes` list (x, y from the top-left of A's grid). + +## 21 / 29 / 37. Blueprint (Describe & Draw) +| Game | Diff | Grid | Pictures | Colours | Threshold | Peeks | Submits | Twist | +|------|------|------|----------|---------|-----------|-------|---------|-------| +| First Sketch | 1 | 6×6 | heart, house, smiley | 1 each | 80% | 2 | 2 | none | +| Stained Glass | 3 | 8×8 | rose, sailboat, key, mug | 1–3 | 90% | 1 | 1 | roles swap | +| Blind Mirror | 5 | 10×10 | lighthouse, black cat, apple tree, ring | 3–6 | 95% | 0 | 1 | **Builder canvas rotated 90°**, roles swap, 30-min limit | + +--- + +## 39. 'Til Death Do Us Part (murder mystery · 30 min · 🔑🔑🔑) +Roles: **Detective** (A) / **Photographer's Assistant** (B). Hint penalty 3:00. + +| Ch | Lock | A has | B has | Answer | +|----|------|-------|-------|--------| +| 1 | Empty seat 3 | Seating rules 1–4 | Rules 5–8 + video: seats 3 and 5 empty | Sipho (seating verified unique: Anita, Johan, Sipho, Kayla, Jason). Red herring: on the phone 21:30–21:47 | +| 2 | Real photo time | Camera times; IMG_2219 = Jason + woman in navy at 23:45 | Camera is 2 h ahead + 7 min fast; navy = planner team | `21:38` | +| 3 | Toast card | Bold/normal letters | Bacon cipher table (26-letter) | `WIDOW` | +| 4 | The link | Celeste: swallow tattoo, left wrist | Uncle Leon's 2019 wedding photo, same tattoo | `SWALLOW` | + +**Killer:** Celeste Moreau (the wedding planner) · pushed Jason down the garden stairs at 21:38 during the first dance · he'd recognised her as the "widow" who married and robbed his uncle. + +--- + +## 40. The Last Lighthouse (escape room · 20 min · 🔑🔑) +Roles: **Lookout** (A) / **Keeper's Helper** (B). **25-minute limit.** + +| Lock | A has | B has | Answer | +|------|-------|-------|--------| +| Flags | Four flag descriptions | Signal flag book | `HOPE` (P vs S is the trap) | +| Lens | 4 bullseye panels | One turn per 40 s | `10` | +| Light across the bay | Red light flashing in groups of 3 (signal clue) | Notice to Mariners list | `BIRD ROCK` | +| Storm door (finale) | "Steps from the 2nd floor up" | Tower cross-section | `60` (24 + 20 + 16) | + diff --git a/client/index.html b/client/index.html index a1279a7..b1447a5 100644 --- a/client/index.html +++ b/client/index.html @@ -9,7 +9,7 @@ Two Keys · Co-op mysteries for couples diff --git a/client/package.json b/client/package.json index debda79..e1417c2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/client", - "version": "1.2.2", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/GameCard.tsx b/client/src/components/GameCard.tsx index e8f59bd..c365d78 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, DIFFICULTY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme'; +import { DIFFICULTY_LABEL, MAX_DIFFICULTY, chipLabel, difficultyKeys, themeVars } from '../lib/theme'; import { pushToast } from '../lib/toasts'; import { RoleBadge, Sheet } from './ui'; @@ -12,6 +12,9 @@ const ENGINE_BLURB: Record = { defuse: 'One sees it, one has the manual', sync: 'How well do you know each other?', labyrinth: 'One map, one explorer', + dial: 'One clue, one dial', + twin: 'Same room... almost', + blueprint: 'One describes, one draws', }; /** A library card, themed with its game's own colours. Opens the "how do you want to play" sheet. */ @@ -37,7 +40,7 @@ export function GameCard({ game, className = '' }: { game: GameSummary; classNam className="group flex h-full w-full flex-col rounded-2xl border border-white/10 bg-bg bg-gradient-to-br from-primary/60 via-primary/15 to-transparent p-5 text-left shadow-lg shadow-black/30 transition hover:-translate-y-0.5 hover:border-accent/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" >
- {CATEGORY_LABEL[game.category]} + {chipLabel(game)} ⏱ {game.estimatedMinutes} min `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; +const percent = (score: number) => `${Math.round(score * 100)}%`; + +/** + * The Builder's canvas is shown rotated clockwise. Displayed (column c, row r) on an n×n grid is + * stored cell (x = r, y = n − 1 − c) for each 90°. The server stores and scores in target orientation. + */ +function toStored(n: number, rotate: number, c: number, r: number) { + let p = { x: c, y: r }; + for (let turn = 0; turn < (rotate / 90) % 4; turn++) p = { x: p.y, y: n - 1 - p.x }; + return p; +} + +function useRemaining(view: BlueprintView, receivedAt: number) { + const now = useNow(250); + return view.status === 'building' ? Math.max(0, view.remaining - (now - receivedAt) / 1000) : view.remaining; +} + +/** One square grid. `cellAt` gets displayed coordinates; numbers run along the edges. */ +function Grid({ + size, + label, + cellAt, + onPaint, + gridlines = true, +}: { + size: number; + label: string; + cellAt: (c: number, r: number) => { fill: string | null; mark?: 'right' | 'wrong' | 'missed' }; + onPaint?: (c: number, r: number, start: boolean) => void; + gridlines?: boolean; +}) { + const painting = useRef(false); + const cellFromPoint = (x: number, y: number) => { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + const c = Number(el?.dataset.c); + const r = Number(el?.dataset.r); + return Number.isInteger(c) && Number.isInteger(r) ? { c, r } : null; + }; + return ( +
+
(painting.current = false)} + onPointerLeave={() => (painting.current = false)} + onPointerCancel={() => (painting.current = false)} + onPointerMove={(e) => { + if (!painting.current || !onPaint) return; + const hit = cellFromPoint(e.clientX, e.clientY); + if (hit) onPaint(hit.c, hit.r, false); + }} + > + + {Array.from({ length: size }, (_, c) => ( + + {c + 1} + + ))} + {Array.from({ length: size }, (_, r) => ( +
+ + {r + 1} + + {Array.from({ length: size }, (_, c) => { + const { fill, mark } = cellAt(c, r); + const ring = + mark === 'right' ? 'ring-2 ring-inset ring-emerald-400' : mark === 'wrong' ? 'ring-2 ring-inset ring-red-500' : mark === 'missed' ? 'outline outline-2 -outline-offset-2 outline-dashed outline-amber-300' : ''; + return ( +
+ ))} +
+
+ ); +} + +function Palette({ palette, color, onPick }: { palette: BlueprintColor[]; color: string | null; onPick: (c: string | null) => void }) { + return ( +
+ {palette.map((p) => ( + + ))} + +
+ ); +} + +/** The Builder's side: the canvas lives here and goes to the server in small debounced batches. */ +function Builder({ view, send, remaining }: { view: BlueprintView; send: Send; remaining: number }) { + const n = view.pattern.size; + const [canvas, setCanvasState] = useState(() => view.canvas ?? Array.from({ length: n }, () => EMPTY.repeat(n))); + // Pointer events can arrive faster than renders, so painting reads the latest canvas from a ref. + const latest = useRef(canvas); + const setCanvas = (next: string[]) => { + latest.current = next; + setCanvasState(next); + }; + const [color, setColor] = useState(view.palette[0]?.key ?? null); + const [undo, setUndo] = useState([]); + const [confirming, setConfirming] = useState(false); + const queue = useRef(new Map()); + const timer = useRef | null>(null); + /** Whether this stroke has already saved an undo step. */ + const stroke = useRef(false); + const hex = Object.fromEntries(view.palette.map((p) => [p.key, p.hex])); + const pushUndo = (snapshot: string[]) => setUndo((u) => [...u.slice(-(UNDO_LIMIT - 1)), snapshot]); + + const flush = () => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + if (queue.current.size === 0) return; + send('blueprint:paint', { cells: [...queue.current.values()] }); + queue.current.clear(); + }; + useEffect(() => () => flush(), []); // eslint-disable-line react-hooks/exhaustive-deps + + const apply = (cells: Cell[], next: string[]) => { + for (const cell of cells) queue.current.set(`${cell.x},${cell.y}`, cell); + setCanvas(next); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(flush, 250); + }; + + const paintAt = (c: number, r: number) => { + const { x, y } = toStored(n, view.rotateBuilder, c, r); + const value = color ?? EMPTY; + const current = latest.current; + if (current[y][x] === value) return; + // One undo step per stroke. + if (!stroke.current) pushUndo(current); + stroke.current = true; + const next = current.map((row, ry) => (ry === y ? row.slice(0, x) + value + row.slice(x + 1) : row)); + apply([{ x, y, color }], next); + }; + + const restore = (target: string[]) => { + const current = latest.current; + const cells: Cell[] = []; + target.forEach((row, y) => [...row].forEach((v, x) => v !== current[y][x] && cells.push({ x, y, color: v === EMPTY ? null : v }))); + if (cells.length > 0) apply(cells, target); + }; + + const submit = () => { + flush(); + setConfirming(false); + send('blueprint:submit'); + }; + + return ( +
+ { + const { x, y } = toStored(n, view.rotateBuilder, c, r); + const v = canvas[y][x]; + return { fill: v === EMPTY ? null : hex[v] }; + }} + onPaint={(c, r, start) => { + if (start) stroke.current = false; + paintAt(c, r); + }} + /> + +
+ + +
+ {confirming ? ( +
+

+ Submit this canvas?{' '} + {view.submitsLeft > 1 ? `You have ${view.submitsLeft} tries; the best counts.` : "It's your only try."} +

+
+ + +
+
+ ) : ( + + )} +
+ ); +} + +export function BlueprintGame({ view, state, role, receivedAt, send }: Props) { + const roles = state.game.roles; + const partnerName = roles[otherRole(role)].name; + const architect = view.seat === 'architect'; + const remaining = useRemaining(view, receivedAt); + const now = useNow(200); + const n = view.pattern.size; + const hex = Object.fromEntries(view.palette.map((p) => [p.key, p.hex])); + // A peek lasts until the server's peekUntil, measured on this device's clock. + const peekOn = view.peekUntil !== null && view.canvas !== null && now - receivedAt < view.peekUntil - state.timer.serverNow; + const seatName = architect ? 'Architect' : 'Builder'; + + const header = ( +
+
+ Picture {view.pattern.index + 1} of {view.pattern.total} +
+
+ You're the {seatName} +
+
+ ); + + if (view.status === 'ready') { + return ( +
+ {header} +
{architect ? '📐' : '🖌️'}
+

+ {architect + ? `You see the picture. Describe it to ${partnerName}, square by square. You can't see their canvas${view.peeksLeft > 0 ? `, except with a peek (${view.peeksLeft} for this picture)` : ''}.` + : `${partnerName} sees a picture you can't. Paint it from their description alone.${view.rotateBuilder ? ` Your canvas is turned ${view.rotateBuilder}° clockwise!` : ''}`} +

+

+ {n}×{n} grid, {formatSeconds(view.timeSeconds)}. Match {percent(view.threshold)} of the painted squares to earn the {view.pattern.keyName}. +

+ +
+ ); + } + + const status = ( +
+
+ Need {percent(view.threshold)} + {view.bestScore !== null && · best {percent(view.bestScore)}} +
+
+ {formatSeconds(remaining)} +
+
+ ); + + if (view.status === 'scored' && view.target && view.canvas) { + const target = view.target; + const canvas = view.canvas; + // On the Builder's screen the reveal keeps the Builder's orientation. + const rotate = architect ? 0 : view.rotateBuilder; + return ( +
+ {header} +
+
It was…
+

{view.title}

+

{percent(view.bestScore ?? 0)}

+

{view.passed ? `🔑 ${view.pattern.keyName}!` : `Needed ${percent(view.threshold)}. No key this time.`}

+
+ { + const { x, y } = toStored(n, rotate, c, r); + const t = target[y][x]; + const p = canvas[y][x]; + if (p !== EMPTY) return { fill: hex[p], mark: p === t ? 'right' : 'wrong' }; + return { fill: null, mark: t !== EMPTY ? 'missed' : undefined }; + }} + /> +

Green: right. Red: wrong colour or shouldn't be painted. Dashed: missed.

+ +
+ ); + } + + return ( +
+ {header} + {status} + {view.lastScore !== null && ( +

Last try: {percent(view.lastScore)}. Keep going!

+ )} + {architect && view.target ? ( + <> + ({ fill: view.target![r][c] === EMPTY ? null : hex[view.target![r][c]] })} /> +
    + {view.palette.map((p) => ( +
  • + + {p.name} +
  • + ))} +
+ {peekOn && view.canvas ? ( +
+

👀 Their canvas right now

+ ({ fill: view.canvas![r][c] === EMPTY ? null : hex[view.canvas![r][c]] })} /> +
+ ) : ( + + )} +

+ Describe it out loud: rows, columns, colours. {partnerName} submits when you're both happy. +

+ + ) : ( + + )} +
+ ); +} diff --git a/client/src/engines/DefuseGame.tsx b/client/src/engines/DefuseGame.tsx index fe28e5b..3a56fb3 100644 --- a/client/src/engines/DefuseGame.tsx +++ b/client/src/engines/DefuseGame.tsx @@ -11,6 +11,7 @@ import type { TechnicianView, WiresModuleView, } from '@two-keys/shared'; +import { otherRole } from '@two-keys/shared'; import { SignalLamp } from '../components/ClueCard'; import { Markdown } from '../components/ui'; import { useNow } from '../lib/hooks'; @@ -99,7 +100,7 @@ function ModuleFrame({ title, solved, children }: { title: string; solved: boole function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction) => void }) { return ( - +
    {module.wires.map((color, i) => (
  1. @@ -359,7 +360,7 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) { {modules.map((m) => { switch (m.id) { case 'wires': - return ; + return ; case 'button': return ; case 'keypad': @@ -379,10 +380,41 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) { export function DefuseGame({ view, state, receivedAt, send }: Props) { const remaining = useRemaining(view, receivedAt); const technician = view.seat === 'technician'; - const partner = state.game.roles[technician ? 'B' : 'A'].name; + // Roles can swap every level, so the partner is whoever doesn't hold the seat you hold now. + const partner = state.game.roles[technician ? otherRole(view.technician) : view.technician].name; + const seatName = technician ? 'Technician' : 'Expert'; let banner = null; - if (view.status === 'ready') { + if (view.status === 'ready' && view.swapRoles) { + const swapped = view.level.index > 0; + banner = ( +
    +
    {swapped ? '🔄' : technician ? '🧰' : '📖'}
    +

    + {swapped ? 'Swap! ' : ''}You're {swapped ? 'now ' : ''}the {seatName} +

    +

    + Level {view.level.index + 1} of {view.level.total}: {view.level.title}. {timerText(view.level.timeSeconds)} on the clock,{' '} + {view.maxStrikes} {view.maxStrikes === 1 ? 'strike' : 'strikes'} and it's over. +

    +

    + {technician + ? `You'll see the device. Describe everything to the ${partner}.` + : `You have the manual. Ask the ${partner} for the serial number and battery count first.`} +

    + +

    + {view.swapReady.partner ? `${partner} is ready.` : `Waiting for ${partner}…`} The clock starts when you both are. +

    +
    + ); + } else if (view.status === 'ready') { banner = (
    💣
    diff --git a/client/src/engines/DialGame.tsx b/client/src/engines/DialGame.tsx new file mode 100644 index 0000000..d486cf1 --- /dev/null +++ b/client/src/engines/DialGame.tsx @@ -0,0 +1,342 @@ +import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent, type PointerEvent } from 'react'; +import { motion } from 'framer-motion'; +import type { DialView, Role, RoomState } from '@two-keys/shared'; +import { otherRole } from '@two-keys/shared'; +import type { Send } from '../lib/socket'; + +interface Props { + view: DialView; + state: RoomState; + role: Role; + send: Send; +} + +// The dial is a half circle: the range's low end on the left, high end on the right. +const W = 300; +const CX = 150; +const CY = 150; +const R = 130; +const STATIC = '▒▒▒ static ▒▒▒'; +// Narrowest band first: 4 points, 3, 2, 1. +const BAND_COLORS = ['rgb(var(--accent))', 'rgb(var(--accent) / 0.6)', 'rgb(var(--accent) / 0.35)', 'rgb(var(--accent) / 0.18)']; + +function useDial(range: [number, number]) { + const [lo, hi] = range; + const angle = (v: number) => Math.PI * (1 - (v - lo) / (hi - lo)); + const point = (v: number, r = R) => ({ x: CX + r * Math.cos(angle(v)), y: CY - r * Math.sin(angle(v)) }); + /** A pie slice from value a to value b. */ + const wedge = (a: number, b: number) => { + const [from, to] = [Math.max(lo, a), Math.min(hi, b)]; + const p1 = point(from); + const p2 = point(to); + return `M ${CX} ${CY} L ${p1.x} ${p1.y} A ${R} ${R} 0 0 1 ${p2.x} ${p2.y} Z`; + }; + const clamp = (v: number) => Math.min(hi, Math.max(lo, Math.round(v))); + return { lo, hi, point, wedge, clamp }; +} + +function Dial({ + view, + needle, + onNeedle, + interactive, +}: { + view: DialView; + needle: number | null; + onNeedle?: (value: number) => void; + interactive: boolean; +}) { + const { lo, hi, point, wedge, clamp } = useDial(view.range); + const svg = useRef(null); + const [dragging, setDragging] = useState(false); + const showTarget = view.target !== null; + + const fromPointer = (e: PointerEvent) => { + const box = svg.current?.getBoundingClientRect(); + if (!box || !onNeedle) return; + const x = ((e.clientX - box.left) / box.width) * W - CX; + const y = CY - ((e.clientY - box.top) / box.width) * W; + const a = Math.atan2(Math.max(y, 0), x); // 0 (right) .. π (left) + onNeedle(clamp(lo + (1 - a / Math.PI) * (hi - lo))); + }; + + const onKey = (e: KeyboardEvent) => { + if (!onNeedle || needle === null) return; + const step = e.shiftKey ? 5 : 1; + const next = { ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step, Home: lo - needle, End: hi - needle }[e.key]; + if (next === undefined) return; + e.preventDefault(); + onNeedle(clamp(needle + next)); + }; + + const tip = needle !== null ? point(needle, R - 6) : null; + const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * (hi - lo)); + + return ( +
    + { + if (!interactive) return; + (e.currentTarget as Element).setPointerCapture(e.pointerId); + setDragging(true); + fromPointer(e); + }} + onPointerMove={(e) => dragging && fromPointer(e)} + onPointerUp={() => setDragging(false)} + onPointerCancel={() => setDragging(false)} + > + + {showTarget && + [...view.bands] + .map((b, i) => ({ ...b, color: BAND_COLORS[i] ?? BAND_COLORS[BAND_COLORS.length - 1] })) + .reverse() + .map((b) => ( + + ))} + {showTarget && + view.bands.map((b, i) => { + const prev = i === 0 ? 0 : view.bands[i - 1].within; + const mid = view.target! + (prev + b.within) / 2 + 0.5; + if (mid > hi) return null; + const p = point(mid, R * 0.82); + return ( + + {b.points} + + ); + })} + {view.showNumbers && + ticks.map((t) => { + const p = point(t, R + 11); + return ( + + {Math.round(t)} + + ); + })} + {tip && ( + + + + + )} + +
    + ← {view.left ?? STATIC} + {view.right ?? STATIC} → +
    +
    + ); +} + +function KeyProgress({ view }: { view: DialView }) { + const pct = (n: number) => `${Math.min(100, (n / view.maxTotal) * 100)}%`; + return ( +
    k.at).join(', ')}.`}> +
    + {view.total} points + Round {view.round + 1} of {view.rounds} +
    +
    + + {view.keys.map((k) => ( + + 🔑 + + ))} +
    +
    + ); +} + +function ClueForm({ view, send }: { view: DialView; send: Send }) { + const [text, setText] = useState(''); + const submit = (e: FormEvent) => { + e.preventDefault(); + if (text.trim()) send('dial:clue', { text: text.trim() }); + }; + const { maxWords, maxChars, banLabelWords, noRepeatWords } = view.rules; + return ( +
    + +
    + setText(e.target.value)} + maxLength={maxChars} + autoComplete="off" + spellCheck={false} + placeholder={maxWords === 1 ? 'One word' : `Up to ${maxWords} words`} + /> + +
    +

    + {maxWords === 1 ? 'Exactly one word' : `Up to ${maxWords} words`}, {maxChars} characters. + {banLabelWords && ' No words from the dial labels.'} + {noRepeatWords && ' No word used earlier in the game.'} +

    + {noRepeatWords && view.usedWords.length > 0 && ( +

    Used: {view.usedWords.join(', ').toLowerCase()}

    + )} +
    + ); +} + +export function DialGame({ view, state, role, send }: Props) { + const roles = state.game.roles; + const partnerName = roles[otherRole(role)].name; + const giverName = roles[view.giver].name; + const [needle, setNeedle] = useState(view.needle ?? Math.round((view.range[0] + view.range[1]) / 2)); + const lastSent = useRef(0); + const pending = useRef(null); + + // A new round starts the needle in the middle again (or where the server says it was). + useEffect(() => { + setNeedle(view.needle ?? Math.round((view.range[0] + view.range[1]) / 2)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view.round]); + + // Throttled: the server keeps the needle for a refresh, and never shows it to the clue-giver. + const moveNeedle = (value: number) => { + setNeedle(value); + pending.current = value; + const now = Date.now(); + if (now - lastSent.current > 400) { + lastSent.current = now; + send('dial:needle', { value }); + pending.current = null; + } + }; + useEffect(() => { + const t = setInterval(() => { + if (pending.current !== null) { + send('dial:needle', { value: pending.current }); + lastSent.current = Date.now(); + pending.current = null; + } + }, 600); + return () => clearInterval(t); + }, [send]); + + const last = view.round === view.rounds - 1; + + return ( +
    +
    +
    + {view.iAmGiver ? 'You give the clue' : `${giverName} gives the clue`} +
    +
    + + + {view.status === 'clue' && view.iAmGiver && ( + <> + +

    Only you can see the target. {partnerName} will turn the dial from your clue.

    + + + )} + + {view.status === 'clue' && !view.iAmGiver && ( + <> + +

    + {giverName} is thinking of a clue… 🤔 +

    + + )} + + {view.status === 'guess' && ( +
    +
    The clue
    +

    “{view.clue}”

    +
    + )} + + {view.status === 'guess' && view.iAmGiver && ( + <> + +

    + {partnerName} is turning the dial. 🤫 No hints, no faces! +

    + + )} + + {view.status === 'guess' && !view.iAmGiver && ( + <> +

    🤫 Clue only! Drag the needle (or use the arrow keys).

    + + {view.showNumbers &&

    {needle}

    } + + + )} + + {view.status === 'reveal' && ( +
    + + +

    + {view.points ? `+${view.points} point${view.points === 1 ? '' : 's'}` : 'No points'} +

    +

    + Target {view.target}, guess {view.guess}: {Math.abs((view.target ?? 0) - (view.guess ?? 0))} away. +

    +
    + +
    + )} + + {view.history.length > 0 && ( +
    + Rounds so far +
      + {view.history.map((h, i) => ( +
    1. + {i + 1}. {h.left} ↔ {h.right}: “{h.clue}” ({roles[h.giver].name}). Target {h.target}, guess {h.guess},{' '} + {h.points} pts +
    2. + ))} +
    +
    + )} +
    + ); +} diff --git a/client/src/engines/LabyrinthGame.tsx b/client/src/engines/LabyrinthGame.tsx index 942067b..9342a9b 100644 --- a/client/src/engines/LabyrinthGame.tsx +++ b/client/src/engines/LabyrinthGame.tsx @@ -35,6 +35,8 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam const gateAt = new Map(view.oneWay.map((g) => [`${g.x},${g.y}`, g])); const glyphAt = new Map(view.glyphs.map((g) => [`${g.x},${g.y}`, g])); const lanternAt = new Map(view.lanterns.map((l) => [`${l.x},${l.y}`, l])); + const itemAt = new Map(view.items.map((it) => [`${it.x},${it.y}`, it])); + const doorAt = new Map(view.doors.map((d) => [`${d.x},${d.y}`, d])); return (
    @@ -66,8 +68,10 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam let tone = 'bg-stone-800/40'; if (cell === '#') tone = 'bg-stone-700'; else if (cell === 'T') { content = '☠'; tone = 'bg-red-950 text-red-400'; } - else if (cell === 'E') { content = '🚪'; tone = 'bg-emerald-950'; } + else if (cell === 'E') { content = view.doors.length > 0 ? '🟩' : '🚪'; tone = 'bg-emerald-950'; } else if (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; } + else if (doorAt.has(key)) { content = '🚪'; tone = 'bg-amber-900/60'; } + else if (itemAt.has(key)) { content = '🗝'; tone = itemAt.get(key)!.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-yellow-900/60'; } else if (glyph) content = splitLabel(glyph.label)[0]; const gate = gateAt.get(key); if (gate && cell !== '#') { @@ -106,11 +110,24 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam {l.collected && ✓}
  2. ))} + {view.items.map((it) => ( +
  3. + 🗝 + {it.name} + {it.collected && ✓} +
  4. + ))} + {view.doors.map((d) => ( +
  5. + 🚪 + {d.name}: shut until they carry its key +
  6. + ))} {view.oneWay.length > 0 && (
  7. →One-way gate. Only opens the way the arrow points.
  8. )}
  9. ☠Trap. They can't see it!
  10. -
  11. 🚪Exit (needs all {view.totalKeys} lanterns)
  12. +
  13. {view.doors.length > 0 ? '🟩' : '🚪'}Exit (needs all {view.totalKeys} lanterns)
  14. ▪Most floor is bare stone
@@ -163,6 +180,14 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
{view.moves} moves
+ {view.inventory.length > 0 && ( +
+ Carrying + {view.inventory.map((name) => ( + 🗝 {name} + ))} +
+ )}
(TEXT_DEFAULT.has(cell) ? `${cell}️` : cell); + +const formatSeconds = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; + +function useCountdown(seconds: number, receivedAt: number, running: boolean): number { + const now = useNow(250); + return running ? Math.max(0, seconds - (now - receivedAt) / 1000) : seconds; +} + +export function TwinGame({ view, state, role, receivedAt, send }: Props) { + const roles = state.game.roles; + const partnerName = roles[otherRole(role)].name; + const playing = view.status === 'playing'; + const remaining = useCountdown(view.remaining, receivedAt, playing); + // Taps expire on their own, so the rings fade out here without waiting for the server. + const myTapLeft = useCountdown(view.myTap?.secondsLeft ?? 0, receivedAt, playing); + const partnerTapLeft = useCountdown(view.partnerTap?.secondsLeft ?? 0, receivedAt, playing); + const myTap = view.myTap && myTapLeft > 0 ? view.myTap : null; + const found = new Set(view.found.map((f) => `${f.x},${f.y}`)); + const width = view.grid[0]?.length ?? 0; + const rows = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + const header = ( +
+
+ Room {view.scene.index + 1} of {view.scene.total} + {view.isRetry && ' · Retry'} +
+

{view.scene.title}

+
+ ); + + if (view.status === 'ready') { + return ( +
+ {header} +

+ {view.scene.differences} differences, {formatSeconds(view.timeSeconds)} on the clock. +

+

+ Describe your room to {partnerName}, square by square. When you spot a difference, you both tap + that square within {view.claimWindowSeconds} seconds. A wrong claim costs {view.wrongClaimPenaltySeconds}s. +

+ +
+ ); + } + + return ( +
+ {header} +
+
+ {view.found.length} / {view.scene.differences} +
+
+ {formatSeconds(remaining)} +
+
+ + {/* Scrolls inside its card if it ever has to, never the page. */} +
+
+ {view.showCoordinates && ( + <> + + {Array.from({ length: width }, (_, x) => ( + + {x + 1} + + ))} + + )} + {view.grid.map((row, y) => ( +
+ {view.showCoordinates && ( + + {rows[y]} + + )} + {row.map((cell, x) => { + const isFound = found.has(`${x},${y}`); + const isMine = myTap?.x === x && myTap?.y === y; + const name = view.showCoordinates ? `${rows[y]}${x + 1}` : `row ${y + 1}, column ${x + 1}`; + return ( + + ); + })} +
+ ))} +
+
+ +

+ {!playing + ? '' + : myTap + ? `You tapped a square. ${partnerName} has ${Math.ceil(myTapLeft)}s to tap the same one.` + : view.partnerTap && partnerTapLeft > 0 + ? `${partnerName} tapped a square! Which one? Ask, then tap it within ${Math.ceil(partnerTapLeft)}s.` + : 'Both tap the same square to claim a difference. No screen-peeking!'} +

+ + {view.status === 'won' && ( +
+
🔑
+

Every difference found! {view.scene.keyName}

+ +
+ )} + {view.status === 'failed' && ( +
+
⏰
+

Out of time in this room

+

+ {view.canRetry ? 'You get one retry, with the clock reset.' : 'No key for this room.'} +

+ +
+ )} +
+ ); +} diff --git a/client/src/lib/theme.ts b/client/src/lib/theme.ts index 43ad378..1d73a51 100644 --- a/client/src/lib/theme.ts +++ b/client/src/lib/theme.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import type { CSSProperties } from 'react'; -import type { Category, Role, Theme } from '@two-keys/shared'; +import type { Category, EngineId, Role, Theme } from '@two-keys/shared'; /** "#7b1e3a" → "123 30 58", the form Tailwind's rgb(var(--x) / alpha) wants. */ export function hexToRgb(hex: string): string { @@ -37,6 +37,25 @@ export const CATEGORY_LABEL: Record = { 'co-op': 'Co-op', }; +/** The newer game types show their own label instead of plain "Co-op", and get their own filter. */ +const LABELLED_ENGINES = ['dial', 'twin', 'blueprint'] as const; + +/** A library filter: a category, or one of the engines that has its own label. */ +export type LibraryFilter = Category | (typeof LABELLED_ENGINES)[number]; + +export const filterOf = (game: { category: Category; engine: EngineId }): LibraryFilter => + (LABELLED_ENGINES as readonly string[]).includes(game.engine) ? (game.engine as LibraryFilter) : game.category; + +export const FILTER_LABEL: Record = { + ...CATEGORY_LABEL, + dial: 'Mind Meld', + twin: 'Spot the Difference', + blueprint: 'Describe & Draw', +}; + +/** What a game's chip says. */ +export const chipLabel = (game: { category: Category; engine: EngineId }) => FILTER_LABEL[filterOf(game)]; + /** A is warm (amber), B is cool (teal). */ export const ROLE_TEXT: Record = { A: 'text-roleA', B: 'text-roleB' }; export const ROLE_BORDER: Record = { A: 'border-roleA/60', B: 'border-roleB/60' }; diff --git a/client/src/pages/HowToPlay.tsx b/client/src/pages/HowToPlay.tsx index 924f73f..dcf24e9 100644 --- a/client/src/pages/HowToPlay.tsx +++ b/client/src/pages/HowToPlay.tsx @@ -26,6 +26,7 @@ const STEPS = [ const TIPS = [ ['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes: added to your final time, or taken off the countdown in timed games. You both see it.'], + ['🎯 Mind Meld, Spot the Difference, Describe & Draw', 'No locks here: one of you gives a clue for a hidden spot on a dial, you both hunt for the differences between two rooms, or one describes a picture the other paints.'], ['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'], ['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."], ['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'], diff --git a/client/src/pages/Library.tsx b/client/src/pages/Library.tsx index 2b1aac8..195afee 100644 --- a/client/src/pages/Library.tsx +++ b/client/src/pages/Library.tsx @@ -1,11 +1,10 @@ import { useState, type ReactNode } from 'react'; -import type { Category } from '@two-keys/shared'; import { GameCard } from '../components/GameCard'; import { SiteShell } from '../components/SiteShell'; import { useGames } from '../lib/hooks'; -import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY } from '../lib/theme'; +import { DIFFICULTY_LABEL, FILTER_LABEL, MAX_DIFFICULTY, filterOf, type LibraryFilter } from '../lib/theme'; -const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op']; +const FILTERS: (LibraryFilter | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op', 'dial', 'twin', 'blueprint']; const LEVELS = Array.from({ length: MAX_DIFFICULTY }, (_, i) => i + 1); function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { @@ -24,11 +23,11 @@ function Chip({ active, onClick, children }: { active: boolean; onClick: () => v export function Library() { const games = useGames(); - const [filter, setFilter] = useState('all'); + const [filter, setFilter] = useState('all'); const [level, setLevel] = useState(null); // Easiest first; ties keep the library's own order. const shown = (games ?? []) - .filter((g) => (filter === 'all' || g.category === filter) && (level === null || g.difficulty === level)) + .filter((g) => (filter === 'all' || filterOf(g) === filter) && (level === null || g.difficulty === level)) .map((g, i) => ({ g, i })) .sort((a, b) => a.g.difficulty - b.g.difficulty || a.i - b.i) .map(({ g }) => g); @@ -43,7 +42,7 @@ export function Library() {
{FILTERS.map((f) => ( setFilter(f)}> - {f === 'all' ? 'All games' : CATEGORY_LABEL[f]} + {f === 'all' ? 'All games' : FILTER_LABEL[f]} ))}
diff --git a/client/src/pages/Lobby.tsx b/client/src/pages/Lobby.tsx index e2f4379..167970b 100644 --- a/client/src/pages/Lobby.tsx +++ b/client/src/pages/Lobby.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import type { Role, RoomState } from '@two-keys/shared'; import { Markdown, RoleBadge } from '../components/ui'; import { socket } from '../lib/socket'; -import { CATEGORY_LABEL, MAX_DIFFICULTY, ROLE_BORDER, difficultyKeys } from '../lib/theme'; +import { MAX_DIFFICULTY, chipLabel, ROLE_BORDER, difficultyKeys } from '../lib/theme'; function ShareBox({ code }: { code: string }) { const [copied, setCopied] = useState(false); @@ -49,7 +49,7 @@ export function Lobby({ state }: { state: RoomState }) {
- {CATEGORY_LABEL[game.category]} + {chipLabel(game)} ⏱ {game.estimatedMinutes} min {difficultyKeys(game.difficulty)} {game.timeLimitMinutes !== null && ⏳ {game.timeLimitMinutes} min limit} diff --git a/client/src/pages/Play.tsx b/client/src/pages/Play.tsx index 4d4c1ed..95e6a0c 100644 --- a/client/src/pages/Play.tsx +++ b/client/src/pages/Play.tsx @@ -7,10 +7,13 @@ import { PartnerStatus, PingOverlay } from '../components/PartnerStatus'; import { PassDeviceScreen } from '../components/PassDeviceScreen'; import { Timer, WaterGauge, isWaterLevel } from '../components/Timer'; import { RoleBadge } from '../components/ui'; +import { BlueprintGame } from '../engines/BlueprintGame'; import { DefuseGame } from '../engines/DefuseGame'; +import { DialGame } from '../engines/DialGame'; import { LabyrinthGame } from '../engines/LabyrinthGame'; import { StoryGame } from '../engines/StoryGame'; import { SyncGame } from '../engines/SyncGame'; +import { TwinGame } from '../engines/TwinGame'; import { socket, type Send } from '../lib/socket'; const ROLE_COLOR: Record = { A: '#f5b942', B: '#2dd4bf' }; @@ -82,6 +85,11 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb {view?.engine === 'defuse' && } {view?.engine === 'sync' && } {view?.engine === 'labyrinth' && } + {view?.engine === 'dial' && } + {view?.engine === 'twin' && } + {view?.engine === 'blueprint' && ( + + )} {!couch && } diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 363b2e7..5c2f32a 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -19,6 +19,8 @@ export default { sans: ['Inter', 'system-ui', 'Noto Sans Symbols 2', 'sans-serif'], hand: ['Caveat', 'Noto Sans Symbols 2', 'cursive'], mono: ['JetBrains Mono', 'ui-monospace', 'Noto Sans Symbols 2', 'monospace'], + // Twin Rooms: the same emoji glyphs on both phones. + emoji: ['Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'sans-serif'], }, }, }, diff --git a/content/games/blueprint-blind-mirror.yaml b/content/games/blueprint-blind-mirror.yaml new file mode 100644 index 0000000..c9e30d4 --- /dev/null +++ b/content/games/blueprint-blind-mirror.yaml @@ -0,0 +1,128 @@ +id: blueprint-blind-mirror +engine: blueprint +category: co-op +title: "Blueprint: Blind Mirror" +tagline: 10×10. Six colours. No peeks. And the Builder's canvas is turned on its side. +estimatedMinutes: 30 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#212529", accent: "#ff006e", background: "#060607", headingFont: "Playfair Display" } +rules: + timeLimitMinutes: 30 +roles: + A: + name: Partner A + blurb: Architect for pictures 1 and 3, Builder for 2 and 4. + B: + name: Partner B + blurb: Builder for pictures 1 and 3, Architect for 2 and 4. +intro: | + ☠️ **Nightmare rules.** + - **The Builder's canvas is rotated 90° clockwise.** The Architect's "top row" is the Builder's right-hand column. + You'll have to agree how to translate, fast. + - 10×10 grids, up to six colours. + - **No peeks.** One submit. **95%** to earn each key. + - Roles swap every picture. 30 minutes for all four. +blueprint: + scoring: overlap + rotateBuilder: 90 # Builder's canvas is shown rotated 90° clockwise; server un-rotates before scoring + swapRolesEachPattern: true + submitsPerPattern: 1 + +patterns: + - id: lighthouse10 + title: "A Lighthouse" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 10 + timeSeconds: 420 + threshold: 0.95 + peeks: 0 + keyName: Lighthouse Key + palette: + B: { name: Blue, hex: "#3a86ff" } + G: { name: Green, hex: "#2a9d8f" } + R: { name: Red, hex: "#e63946" } + U: { name: Navy, hex: "#1d3557" } + W: { name: White, hex: "#f1f1f1" } + Y: { name: Yellow, hex: "#ffd60a" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "....YY...." + - "...BBBB..." + - "....RR...." + - "....WW...." + - "....RR...." + - "...WWWW..." + - "...RRRR..." + - "..WWWWWW.." + - "GGGGGGGGGG" + - "UUUUUUUUUU" + - id: cat10 + title: "A Black Cat" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 10 + timeSeconds: 420 + threshold: 0.95 + peeks: 0 + keyName: Cat Key + palette: + G: { name: Green, hex: "#2a9d8f" } + K: { name: Black, hex: "#2b2b2b" } + P: { name: Pink, hex: "#ff70a6" } + rows: # '.' = empty. Architect sees this; Builder never does. + - ".K......K." + - ".KK....KK." + - ".KKKKKKKK." + - "KKGKKKKGKK" + - "KKKKKKKKKK" + - "KKKKPPKKKK" + - ".KKKKKKKK." + - "..KKKKKK.." + - "...K..K..." + - "..KK..KK.." + - id: tree10 + title: "An Apple Tree" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 10 + timeSeconds: 420 + threshold: 0.95 + peeks: 0 + keyName: Orchard Key + palette: + G: { name: Green, hex: "#2a9d8f" } + N: { name: Brown, hex: "#7f5539" } + R: { name: Red, hex: "#e63946" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "....GG...." + - "...GGGG..." + - "..GGRGGG.." + - ".GGGGGGRG." + - "GGRGGGGGGG" + - "..GGGGGG.." + - "....NN...." + - "....NN...." + - "....NN...." + - "..NNNNNN.." + - id: ring10 + title: "A Ring" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 10 + timeSeconds: 420 + threshold: 0.95 + peeks: 0 + keyName: Ring Key + palette: + B: { name: Blue, hex: "#3a86ff" } + W: { name: White, hex: "#f1f1f1" } + Y: { name: Yellow, hex: "#ffd60a" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "....WW...." + - "...WBBW..." + - "....WW...." + - "...YYYY..." + - "..YY..YY.." + - ".YY....YY." + - ".Y......Y." + - ".YY....YY." + - "..YY..YY.." + - "...YYYY..." + +successEnding: | + Every picture finished. Turns out you speak the same visual language. + + **Masterpiece.** 🎨🔑 diff --git a/content/games/blueprint-first-sketch.yaml b/content/games/blueprint-first-sketch.yaml new file mode 100644 index 0000000..b1e6abf --- /dev/null +++ b/content/games/blueprint-first-sketch.yaml @@ -0,0 +1,82 @@ +id: blueprint-first-sketch +engine: blueprint +category: co-op +title: "Blueprint: First Sketch" +tagline: One of you sees the picture. The other has the paintbrush. Describe it, square by square. +estimatedMinutes: 10 +difficulty: 1 +players: { min: 2, max: 2 } +theme: { primary: "#118ab2", accent: "#ffd166", background: "#071219", headingFont: "Playfair Display" } +roles: + A: + name: Architect + blurb: You see the target picture. You can't see what your partner paints. + B: + name: Builder + blurb: You have a blank grid and a palette. You can't see the picture. Listen carefully. +intro: | + **Architect:** describe the picture. Rows, columns, shapes, colours, whatever works. + **Builder:** paint what you hear, then hit **Submit**. + + Match at least **80%** to earn the key. The Architect gets **2 peeks** per picture: a 3-second look at the Builder's canvas. + Small grids, one colour each. A gentle start. +blueprint: + scoring: overlap # see requirements: matched painted cells ÷ cells painted in either grid + rotateBuilder: 0 + swapRolesEachPattern: false + submitsPerPattern: 2 # a second try is allowed; best score counts + +patterns: + - id: heart6 + title: "A Heart" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 6 + timeSeconds: 180 + threshold: 0.8 + peeks: 2 + keyName: Heart Key + palette: + R: { name: Red, hex: "#e63946" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "......" + - ".R..R." + - "RRRRRR" + - "RRRRRR" + - ".RRRR." + - "..RR.." + - id: house6 + title: "A House" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 6 + timeSeconds: 180 + threshold: 0.8 + peeks: 2 + keyName: House Key + palette: + B: { name: Blue, hex: "#3a86ff" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "..BB.." + - ".BBBB." + - "BBBBBB" + - ".B..B." + - ".B..B." + - ".BBBB." + - id: smile6 + title: "A Smiley" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 6 + timeSeconds: 180 + threshold: 0.8 + peeks: 2 + keyName: Smile Key + palette: + Y: { name: Yellow, hex: "#ffd60a" } + rows: # '.' = empty. Architect sees this; Builder never does. + - ".YYYY." + - "YY.Y.Y" + - "YYYYYY" + - "Y.YY.Y" + - "YY..YY" + - ".YYYY." + +successEnding: | + Every picture finished. Turns out you speak the same visual language. + + **Masterpiece.** 🎨🔑 diff --git a/content/games/blueprint-stained-glass.yaml b/content/games/blueprint-stained-glass.yaml new file mode 100644 index 0000000..3b60017 --- /dev/null +++ b/content/games/blueprint-stained-glass.yaml @@ -0,0 +1,111 @@ +id: blueprint-stained-glass +engine: blueprint +category: co-op +title: "Blueprint: Stained Glass" +tagline: Restore four stained-glass windows from description alone. Swap roles every window. +estimatedMinutes: 25 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#9b2226", accent: "#94d2bd", background: "#0d0a0a", headingFont: "Playfair Display" } +roles: + A: + name: Partner A + blurb: Architect for windows 1 and 3, Builder for windows 2 and 4. + B: + name: Partner B + blurb: Builder for windows 1 and 3, Architect for windows 2 and 4. +intro: | + An old chapel's stained-glass windows were smashed in a storm. Only the sketches survive, and only one of + you can see each sketch. + + **Roles swap after every window.** 8×8 grids, up to three colours, **90%** to earn each key, and the Architect + gets **1 peek** per window. You get one submit, so check before you hit it. +blueprint: + scoring: overlap + rotateBuilder: 0 + swapRolesEachPattern: true + submitsPerPattern: 1 + +patterns: + - id: rose8 + title: "A Rose" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 8 + timeSeconds: 240 + threshold: 0.9 + peeks: 1 + keyName: Rose Key + palette: + G: { name: Green, hex: "#2a9d8f" } + P: { name: Pink, hex: "#ff70a6" } + R: { name: Red, hex: "#e63946" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "..RRRR.." + - ".RRPPRR." + - ".RPRRPR." + - "..RRRR.." + - "...GG..." + - ".GGG...." + - "...G.GG." + - "...G...." + - id: boat8 + title: "A Sailboat" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 8 + timeSeconds: 240 + threshold: 0.9 + peeks: 1 + keyName: Sail Key + palette: + B: { name: Blue, hex: "#3a86ff" } + U: { name: Navy, hex: "#1d3557" } + W: { name: White, hex: "#f1f1f1" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "...W...." + - "...WW..." + - "...WWW.." + - "...WWWW." + - "...W...." + - "BBBBBBBB" + - ".BBBBBB." + - "..UUUU.." + - id: key8 + title: "A Key" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 8 + timeSeconds: 240 + threshold: 0.9 + peeks: 1 + keyName: Skeleton Key + palette: + Y: { name: Yellow, hex: "#ffd60a" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "........" + - ".YYY...." + - "Y...Y..." + - "Y...YYYY" + - "Y...Y.Y." + - ".YYY..Y." + - "........" + - "........" + - id: mug8 + title: "A Mug of Cocoa" # SERVER ONLY until the pattern is finished (the Builder never sees the title) + size: 8 + timeSeconds: 240 + threshold: 0.9 + peeks: 1 + keyName: Cocoa Key + palette: + R: { name: Red, hex: "#e63946" } + W: { name: White, hex: "#f1f1f1" } + rows: # '.' = empty. Architect sees this; Builder never does. + - "..W.W..." + - "...W.W.." + - "RRRRRR.." + - "RWWWWRRR" + - "RWWWWR.R" + - "RWWWWRRR" + - "RRRRRR.." + - ".RRRR..." + +successEnding: | + Every picture finished. Turns out you speak the same visual language. + + **Masterpiece.** 🎨🔑 diff --git a/content/games/curtain-call.yaml b/content/games/curtain-call.yaml new file mode 100644 index 0000000..8385a0b --- /dev/null +++ b/content/games/curtain-call.yaml @@ -0,0 +1,283 @@ +id: curtain-call +engine: story +category: murder-mystery +title: Curtain Call +tagline: The stage dagger was supposed to fold. Opening night. Act Three. It didn't. +estimatedMinutes: 30 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#8b0000", accent: "#ffd166", background: "#0d0606", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Critic + blurb: You watched from row C with a notebook. You have the police file and the audience's view. + B: + name: The Stage Manager + blurb: You ran the show from the wings. Prompt book, props, keys, and backstage gossip. +intro: | + **The Alhambra Theatre, Cape Town. Opening night of "The Raven King".** + + In Act Three, the usurper stabs the old king with a folding stage dagger. Every night, the king + falls, the audience gasps, and the actor gets up for his bow. + + Tonight, **Desmond Ashcroft** didn't get up. The dagger didn't fold. It was real. + + The understudy who held it is in shock. But he didn't bring a real dagger on stage. **Someone switched it.** + + ⚠️ *Hard case: the locks give you facts. You'll connect them in the accusation.* + +suspects: + - id: felix + name: Felix Moore + role: The usurper (Desmond's understudy) + description: Held the dagger. Gets Desmond's role if anything happens to him. + - id: vivienne + name: Vivienne Shaw + role: Leading lady, plays Queen Margot + description: Desmond's co-star of fifteen years. Their dressing-room arguments are legendary. + - id: mia + name: Mia Daniels + role: Rising star, plays Lady Ros + description: Twenty-two, brilliant, and rumoured to be Desmond's next big discovery. + - id: owen + name: Owen Price + role: Director + description: Desmond embarrassed him in front of the cast at every rehearsal. + - id: greta + name: Greta Lindqvist + role: Props master + description: Sets every prop herself. Swears her table was perfect at curtain up. + +chapters: + - id: ch1 + title: Two Daggers + narrative: | + The theatre owns more than one dagger. Which one killed Desmond, and where did it come from? + clues: + A: + - id: a1-forensic + title: Forensic note on the weapon + type: report + body: | + Blade **21 cm, double-edged, steel**. Hallmark on the hilt: **a crown over the letter B**. + Light dust in the crossguard grooves. This blade had **not been handled in a long time**. + B: + - id: b1-inventory + title: Props and display inventory + type: table + body: | + | Item | Where it lives | Blade | Notes | + |------|----------------|-------|-------| + | Stage dagger (folding) | Props table, stage left wings | 21 cm aluminium, retracts | Used nightly | + | Spare stage dagger | Props cupboard (locked) | 21 cm aluminium, retracts | Still in cupboard | + | 1952 "Raven King" dagger | **Foyer display case** | 21 cm double-edged steel, crown-over-B hallmark | Display only. Case "sticky", opens with a hairpin | + | Director's letter opener | Owen's office | 15 cm brass | On his desk | + - id: b1-greta + title: Greta's props note + type: note + body: | + *Checked my table at 19:30, all present and correct. Stage dagger folds. Set it on the BANQUET TABLE prop at the start of Act 3 myself.* + lock: + id: lock-dagger + prompt: Where did the murder weapon come from? + inputType: text + format: The location + answers: ["FOYER", "THEFOYER", "FOYERDISPLAYCASE", "DISPLAYCASE", "FOYERCASE", "THEDISPLAYCASE"] + hints: + - Match the blade description to the inventory. + - Steel, double-edged, crown over B, and dusty. + - The 1952 display dagger in the foyer case. + successText: | + **The foyer display case.** The 1952 dagger was taken from the case (sticky lock, opens with a hairpin) + and swapped for the folding prop. The stage dagger lay on the banquet table during Act 3, + within reach of the actors in that scene. + keyName: Foyer Key + + - id: ch2 + title: Stage Left + narrative: | + During the banquet scene, a woman in grey lifted the dagger from the banquet table and put it back. + Two actresses wear grey gowns in that scene. + clues: + A: + - id: a2-witness + title: Your notebook, row C, centre + type: note + body: | + *Banquet scene. The lady in grey on the **RIGHT-hand side of the stage** (as I look at it) picks up + the dagger, turns her back to us, puts it down again. Odd. Not in last night's preview.* + B: + - id: b2-promptbook + title: Prompt book, Act 3 banquet scene blocking + type: document + body: | + *All directions are from the ACTOR's point of view, facing the audience.* + - **Queen Margot** (Vivienne, grey gown): **DSL**, downstage left, at the end of the banquet table. + - **Lady Ros** (Mia, grey gown): **DSR**, downstage right, by the candelabra. + - The King (Desmond): centre, seated. + - The Usurper (Felix): enters **UC**, upstage centre, after the toast. + lock: + id: lock-stageleft + prompt: Which actress handled the dagger? + inputType: choice + options: + - { id: vivienne, label: Vivienne Shaw } + - { id: mia, label: Mia Daniels } + - { id: greta, label: Greta Lindqvist } + answers: ["vivienne"] + hints: + - Stage directions are from the actor's view. The critic was looking from the audience. + - The audience's right is the actor's left. + - The audience's right is stage left, and that's Queen Margot. That's Vivienne. + successText: | + **Vivienne Shaw.** The audience's right is stage left, and Queen Margot stands at stage left, + right at the end of the banquet table where the dagger lay. + keyName: Spotlight Key + + - id: ch3 + title: The Typewriter Card + narrative: | + In Desmond's dressing room, his curtain-call speech card. He typed it himself on his ancient typewriter. + It looks like nonsense. + clues: + A: + - id: a3-card + title: Desmond's speech card + type: note + body: | + *"Thank you all. And tonight, a little announcement:"* + ## DJR OD PIY GTOFSU + *"...and please welcome my new leading lady!"* + B: + - id: b3-typist + title: Backstage gossip about Desmond's typewriter + type: note + body: | + "He typed everything without looking, and **his hands always started one key too far to the RIGHT.** + Every letter comes out as the key to the right of the one he meant. He never noticed." + - id: b3-keyboard + title: Keyboard layout (stage-door office typewriter) + type: diagram + body: | + ``` + Q W E R T Y U I O P + A S D F G H J K L + Z X C V B N M + ``` + lock: + id: lock-card + prompt: What did Desmond mean to type? + inputType: text + format: The message (4 words) + answers: ["SHEISOUTFRIDAY"] + hints: + - Each typed letter is one key to the right of the intended one. Move every letter one key LEFT. + - D → S, J → H, R → E. + - SHE IS OUT FRIDAY. + successText: | + **"SHE IS OUT FRIDAY."** At the curtain call, Desmond was going to announce that his leading lady + of fifteen years was finished, and introduce **Mia** as his new lead. + keyName: Typewriter Key + + - id: ch4 + title: The Display Key + narrative: | + The foyer case has a proper key too. Someone signed it out tonight. + clues: + A: + - id: a4-signout + title: Key cabinet sign-out sheet + type: log + body: | + | Key | Out | In | Dressing room | + |-----|-----|----|---------------| + | Foyer display case | 19:40 | 19:55 | **Room 1** | + | Props cupboard | 18:30 | 18:45 | Room 6 | + B: + - id: b4-rooms + title: Dressing room list (by billing) + type: table + body: | + | Room | Who | + |------|-----| + | 1 | Director's office (Owen Price) | + | 2 | Desmond Ashcroft | + | 3 | Vivienne Shaw | + | 4 | Felix Moore | + | 5 | Mia Daniels | + | 6 | Props (Greta Lindqvist) | + - id: b4-owen + title: Owen, cornered in the bar + type: transcript + body: | + "Yes, I took the key! I showed a reviewer the old dagger before curtain up. It was in the case + when I locked it at five to eight. Ask her. Anyway, that lock's so sticky you could open it with a hairpin." + lock: + id: lock-key + prompt: Who signed out the display-case key? + inputType: choice + options: + - { id: felix, label: Felix Moore } + - { id: vivienne, label: Vivienne Shaw } + - { id: mia, label: Mia Daniels } + - { id: owen, label: Owen Price } + - { id: greta, label: Greta Lindqvist } + answers: ["owen"] + hints: + - The sign-out sheet uses dressing room numbers. + - Room 1 belongs to the director. + - Owen Price. + successText: | + **Owen**, from 19:40 to 19:55. The reviewer confirms the dagger was in the case at 19:55. + So the dagger left the case **after** 19:55, and the key wasn't needed: a hairpin would do. + Greta notes that one of **Queen Margot's** jewelled hairpins went missing during the first interval. + keyName: Cabinet Key + +finale: + type: accusation + prompt: The house lights are up. Name the killer. + maxAttempts: 2 + questions: + - id: who + label: Who killed Desmond Ashcroft? + options: + - { id: felix, label: Felix Moore } + - { id: vivienne, label: Vivienne Shaw } + - { id: mia, label: Mia Daniels } + - { id: owen, label: Owen Price } + - { id: greta, label: Greta Lindqvist } + answer: vivienne + - id: how + label: How? + options: + - { id: swap, label: Took the 1952 dagger from the foyer case with a hairpin and swapped it for the prop on the banquet table } + - { id: felix, label: Felix brought a real dagger on stage on purpose } + - { id: props, label: Greta set the wrong dagger on the props table before the show } + - { id: key, label: Used the signed-out key to take the dagger before curtain up } + answer: swap + - id: why + label: Why? + options: + - { id: role, label: To take over his role } + - { id: fired, label: He was about to fire her at the curtain call and announce a new leading lady } + - { id: humiliation, label: Revenge for years of humiliation in rehearsals } + - { id: money, label: An unpaid debt } + answer: fired + successEnding: | + Vivienne is still in Queen Margot's grey gown. She takes a jewelled hairpin out of her sleeve + and lays it on the table. + + "Fifteen years I carried him. And he was going to replace me in front of a full house." + + In the interval she slipped into the foyer, opened the sticky case with her hairpin, and hid the 1952 dagger + in her gown. In the banquet scene, at stage left, back to the audience, she swapped it for the prop. + Felix never knew what he was holding. + + **Case closed.** The understudy will go on tomorrow. 🎭🔑 + failEnding: | + The wrong person is arrested. The play closes after one night, and Queen Margot takes the lead in the next one. + reveal: | + **Vivienne Shaw** used a hairpin to take the **1952 dagger from the foyer case** and **swapped it for the prop + on the banquet table**, because Desmond was about to **fire her and announce a new leading lady**. diff --git a/content/games/death-at-the-braai.yaml b/content/games/death-at-the-braai.yaml new file mode 100644 index 0000000..f4e2380 --- /dev/null +++ b/content/games/death-at-the-braai.yaml @@ -0,0 +1,243 @@ +id: death-at-the-braai +engine: story +category: murder-mystery +title: Death at the Braai +tagline: A Saturday braai in Pretoria. Boerewors, brandy, and one guest who never got dessert. +estimatedMinutes: 20 +difficulty: 2 +players: { min: 2, max: 2 } +theme: { primary: "#a23b1d", accent: "#f2a541", background: "#160b07", headingFont: "Playfair Display" } +roles: + A: + name: The Detective + blurb: Off duty, but you were invited. You have the police notes and everyone's statements. + B: + name: The Neighbour + blurb: You live next door and see everything over the wall. Photos, gossip, and the garden. +intro: | + **Waterkloof, Pretoria. Saturday, 18:20.** + + Gerrit Botha throws the best braai in the street. Tonight: boerewors, lamb sosaties, and his famous + "never-fail" fire. Then, halfway through dishing up, Gerrit grabs his chest and goes down by the braai stand. + + The paramedics can't save him. And the doctor says it wasn't his heart. Not on its own, anyway. + +suspects: + - id: marlene + name: Marlene Botha + role: Gerrit's wife + description: Hostess. Running between the kitchen and the salads all evening. + - id: dawie + name: Dawie van Wyk + role: Neighbour on the other side + description: Three-year feud with Gerrit over a boundary wall and a jacaranda. + - id: thandi + name: Thandi Mokoena + role: Business partner + description: Co-owns Botha & Mokoena Butchery with Gerrit. Does the books. + - id: riaan + name: Riaan Botha + role: Nephew + description: Twenty-four, broke, and suspiciously keen to help with the cooking. + - id: annelie + name: Annelie Smit + role: Marlene's sister + description: Never liked Gerrit. Says so often. + +chapters: + - id: ch1 + title: The Fire + narrative: | + Gerrit collapsed the moment he took the meat off the grid. When exactly was that? + clues: + A: + - id: a1-marlene + title: Marlene's statement + type: transcript + body: | + "Gerrit has rules. The meat goes on the **second the coals are ready**, not a minute later. + Sosaties and wors together, **exactly 20 minutes**. He took them off and then he just... fell." + B: + - id: b1-wall + title: What you saw over the wall + type: note + body: | + - Gerrit lit the fire at **17:10**. You checked your watch because the smoke came straight into your lounge, as usual. + - Gerrit always says his coals take **45 minutes** to be ready. He's never wrong. It's annoying. + lock: + id: lock-time + prompt: What time did Gerrit collapse? + inputType: code + format: "HH:MM" + answers: ["1815", "615", "615PM"] + hints: + - Work out when the coals were ready first. + - 17:10 plus 45 minutes is 17:55. Then the meat cooked for 20 minutes. + - 17:55 + 20 = 18:15. + successText: | + **18:15.** The meat was on the fire from 17:55 to 18:15. Whatever killed Gerrit + was eaten or drunk right at the end of that. + keyName: Tongs Key + + - id: ch2 + title: Who Turned the Meat? + narrative: | + Gerrit never leaves his fire. Except, it turns out, once tonight. + clues: + A: + - id: a2-riaan + title: Riaan's statement + type: transcript + body: | + "Oom Gerrit ran inside for ice at about six. Someone in the **Bulls apron** took over the tongs for him. + I didn't see who, I was carrying chairs. They swapped the sosaties around a bit, that's all I saw." + - id: a2-annelie + title: Annelie's statement + type: transcript + body: | + "I was in the kitchen with Marlene making the potato salad from half five until he fell. Ask her." + B: + - id: b2-photos + title: Your phone photos (timestamped) + type: photo + body: | + - *17:48:* Gerrit in his **Springbok** shirt, poking the coals. + - *17:50:* Thandi borrowing the **Bulls apron** from the hook "so I don't ruin my white dress". + - *17:58:* Dawie glaring at the jacaranda in his **Stormers** jersey, beer in hand. + - *18:01:* Someone at the braai in the Bulls apron, back to the camera. + lock: + id: lock-apron + prompt: Who was at the braai while Gerrit fetched ice? + inputType: choice + options: + - { id: marlene, label: Marlene Botha } + - { id: dawie, label: Dawie van Wyk } + - { id: thandi, label: Thandi Mokoena } + - { id: riaan, label: Riaan Botha } + - { id: annelie, label: Annelie Smit } + answers: ["thandi"] + hints: + - Riaan saw the apron, not the face. Who was wearing that apron? + - Check who borrowed the Bulls apron at 17:50. + - Thandi. + successText: | + **Thandi** was minding the fire at 18:00, in the Bulls apron, swapping sosaties around. + keyName: Apron Key + + - id: ch3 + title: The Sosatie Sticks + narrative: | + The paramedic found green leaves stuck to the bottom of Gerrit's plate. The lab rushed his sosatie. + clues: + A: + - id: a3-lab + title: Lab report + type: report + body: | + Poison: **oleandrin**, a heart toxin. Found **in the meat**, soaked in from the **skewer stick**, + not in any sauce or drink. Gerrit's skewer was a fresh green stick, not bamboo. + *Only the plant itself contains oleandrin.* + B: + - id: b3-garden + title: Your garden notes + type: note + body: | + - Gerrit's hedge by the gate is **oleander**: pink flowers, long thin leaves. Everyone in Pretoria + knows you never use oleander sticks for skewers. It's an old warning. + - This afternoon, a few **oleander branches** were freshly cut, stripped of leaves. + - All the other sosaties were on **bamboo** skewers from the shop. + lock: + id: lock-plant + prompt: What were the deadly skewers cut from? + inputType: text + format: One word + answers: ["OLEANDER", "OLEANDERS", "NERIUM", "NERIUMOLEANDER"] + hints: + - The lab names the poison. The neighbour knows the garden. + - Oleandrin comes from one plant. + - Oleander, from the hedge by the gate. + successText: | + **Oleander.** Someone made one special sosatie on an oleander stick and swapped it onto + Gerrit's side of the grid while he was getting ice. + keyName: Skewer Key + + - id: ch4 + title: Gerrit's Note + narrative: | + On Gerrit's phone, a note to himself from this morning. Just numbers. Marlene says he did that + "so nosy people can't read my business." + clues: + A: + - id: a4-phone + title: Gerrit's phone note + type: note + body: | + ## 1·21·4·9·20 · 13·15·14·4·1·25 + ## 20·8·5 · 20·9·12·12 · 9·19 · 19·8·15·18·20 + B: + - id: b4-gossip + title: What you know about the butchery + type: note + body: | + - Gerrit told you last week: *"A=1, B=2... my old army trick. Still works."* + - Thandi does all the books for Botha & Mokoena Butchery. Gerrit "can't read a spreadsheet to save his life." + - Riaan owes Gerrit R20,000. Dawie wants the jacaranda gone. Annelie just doesn't like him. + lock: + id: lock-note + prompt: What did Gerrit's note say? + inputType: text + format: The message + answers: ["AUDITMONDAYTHETILLISSHORT"] + hints: + - Each number is a letter. A=1, B=2, and so on. + - "1-21-4-9-20 = AUDIT." + - AUDIT MONDAY. THE TILL IS SHORT. + successText: | + **"AUDIT MONDAY. THE TILL IS SHORT."** Gerrit had found money missing from the butchery, + and he'd booked an audit. Only one person kept those books. + keyName: Phone Key + +finale: + type: accusation + prompt: Make your accusation before the pudding goes cold. + maxAttempts: 2 + questions: + - id: who + label: Who killed Gerrit? + options: + - { id: marlene, label: Marlene Botha } + - { id: dawie, label: Dawie van Wyk } + - { id: thandi, label: Thandi Mokoena } + - { id: riaan, label: Riaan Botha } + - { id: annelie, label: Annelie Smit } + answer: thandi + - id: how + label: How? + options: + - { id: brandy, label: Poison in his brandy and Coke } + - { id: skewer, label: "A sosatie on an oleander stick, swapped onto his side while he fetched ice" } + - { id: salad, label: Poison in the potato salad } + - { id: gas, label: Fumes from a tampered gas bottle } + answer: skewer + - id: why + label: Why? + options: + - { id: wall, label: The boundary wall feud } + - { id: debt, label: "To get out of a R20,000 debt" } + - { id: till, label: To stop the audit that would expose her stealing from the butchery } + - { id: sister, label: Family resentment } + answer: till + successEnding: | + Thandi takes off the Bulls apron and folds it, very neatly. + + "It was going to be paid back. All of it. He didn't have to call in auditors like I was a criminal." + + She cut the oleander that afternoon, threaded one sosatie on it at home, and slipped it onto + Gerrit's side of the grid while he fetched ice. + + **Case closed.** Somebody else will have to braai next Saturday. 🔥🔑 + failEnding: | + The wrong person is arrested. Monday's audit never happens, and the butchery's money keeps disappearing. + reveal: | + **Thandi Mokoena** swapped a **sosatie on an oleander stick** onto Gerrit's side while he fetched ice, + to stop the **audit that would expose her stealing from the butchery**. diff --git a/content/games/hawthorn-park-cipher.yaml b/content/games/hawthorn-park-cipher.yaml new file mode 100644 index 0000000..1227658 --- /dev/null +++ b/content/games/hawthorn-park-cipher.yaml @@ -0,0 +1,408 @@ +id: hawthorn-park-cipher +engine: story +category: murder-mystery +title: The Hawthorn Park Cipher +tagline: 1943. A secret codebreaking station. A codebreaker is dead, and there's a spy in the huts. +estimatedMinutes: 60 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#3a5a40", accent: "#dad7cd", background: "#0b0f0b", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 240 + timeLimitMinutes: 75 + timeLimitLabel: "Until MI5 arrives" +roles: + A: + name: The Security Officer + blurb: Sent from London overnight. You have the intercepts, the statements, and the dead woman's desk. + B: + name: The Hut Supervisor + blurb: You run Hut 7. Key sheets, rotas, inventories, logs, and who sings in the Thursday choir. +intro: | + **Hawthorn Park, Buckinghamshire. 14 January 1943.** + + Behind the fences, hundreds of people break enemy codes in freezing wooden huts. Nobody outside knows. + + At 03:00, **Eleanor Finch**, the best codebreaker in Hut 7, is found dead at her desk. + The stove is out. The air smells wrong. On her desk, a typed note accuses a colleague of being a spy. + + Eleanor was working on something alone. Someone made sure she never finished. + + ☠️ *Nightmare case. One accusation. A hard time limit. The locks give you pieces, not the answer.* + +suspects: + - id: penrose + name: Prof. Arthur Penrose + role: Head of Hut 7 + description: Brilliant, distracted, protective of "his" codebreakers. Argued with Eleanor last week. + - id: kemp + name: Dorothy "Dot" Kemp + role: Wren, teleprinter operator + description: Cheerful, kind, sings in the station choir. Sends Hut 7's decrypts to London. + - id: harrow + name: Lt. Miles Harrow + role: Army liaison officer + description: Handsome, secretive, and seen leaving Eleanor's billet twice this month. + - id: nowak + name: Stanisław Nowak + role: Polish mathematician + description: Escaped Warsaw in 1939. Some of the staff whisper about him because he's foreign. Unfairly. + - id: greaves + name: Rupert Greaves + role: Chess champion, new recruit + description: Recruited through a newspaper crossword. Arrogant, brilliant, always in debt. + - id: ashworth + name: Beatrice Ashworth + role: Canteen manager + description: Knows who eats what and when. Sees everyone come and go. + +chapters: + - id: ch1 + title: The Intercept + narrative: | + Locked in Eleanor's drawer: an enemy intercept she'd been working on alone. Hut 7 uses a simplified + "rotor" method for training, and Eleanor had pencilled the method in the margin. + clues: + A: + - id: a1-intercept + title: Intercept, dated 14 JAN 43 + type: document + body: | + ``` + EAZBOEKEZLF THTTXAA OBZY UII IVNXH + ``` + *Eleanor's margin note:* "Rotor steps one place per letter. Spaces don't count. + Undo it: shift letter 1 back by the day's key, letter 2 back by key + 1, letter 3 by key + 2..." + - id: a1-alphabet + title: Alphabet strip (A = 0) + 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: b1-keysheet + title: Training key sheet, January 1943 + type: table + body: | + | Date | Key letter | + |------|------------| + | 12 JAN | M | + | 13 JAN | C | + | **14 JAN** | **R** | + | 15 JAN | W | + *Key letter → number using A = 0.* + - id: b1-nowak + title: Found in Stanisław Nowak's coat (by a "concerned colleague") + type: note + body: | + *A scrap of German newspaper.* Nowak: "I read every newspaper I can find. I am a mathematician, not a spy. + Someone put that there." + lock: + id: lock-intercept + prompt: What is the spy's codename? + inputType: text + format: One word + answers: ["NIGHTINGALE"] + hints: + - The key for 14 JAN is R = 17. Letter 1 shifts back 17, letter 2 back 18, and so on. + - "E − 17 = N. A − 18 = I. Z − 19 = G." + - "The message reads NIGHTINGALE REPORTS FROM HUT SEVEN." + successText: | + **"NIGHTINGALE REPORTS FROM HUT SEVEN."** An enemy agent, codename **Nightingale**, is inside Hut 7. + Eleanor had worked it out. She just hadn't worked out who. + keyName: Rotor Key + + - id: ch2 + title: The Crossword + narrative: | + Eleanor kept a half-finished crossword-style puzzle she'd been setting for the hut. Circled squares. + She'd scribbled: *"If anything happens, the answer's in the circles."* + clues: + A: + - id: a2-clues + title: Eleanor's clues + type: document + body: | + 1. A lieutenant or a captain, for instance + 2. How Hut 7 feels at three in the morning in January + 3. The submarine the Navy fears most (hyphen ignored) + 4. The machine every hut is fighting + - id: a2-coroner + title: Medical officer's first note + type: report + body: | + Cherry-pink colour to the skin. Headache tablets on the desk. **Carbon monoxide poisoning**. + The hut stove was alight until about 02:00, then went out. Something stopped it drawing air. + B: + - id: b2-grid + title: Eleanor's answer slots + type: diagram + body: | + ``` + 1. _ (_) _ _ _ _ _ 7 letters, circle on letter 2 + 2. _ _ (_) _ 4 letters, circle on letter 3 + 3. (_) _ _ _ _ 5 letters, circle on letter 1 + 4. (_) _ _ _ _ _ 6 letters, circle on letter 1 + ``` + lock: + id: lock-crossword + prompt: What do the circled letters spell? + inputType: text + format: 4 letters + answers: ["FLUE"] + hints: + - The answers are OFFICER, COLD, UBOAT and ENIGMA. + - Take letter 2 of OFFICER, letter 3 of COLD, letter 1 of UBOAT, letter 1 of ENIGMA. + - F-L-U-E. + successText: | + **FLUE.** Eleanor suspected the stove. The police climb onto Hut 7's roof: **a rag stuffed into the stove flue**. + Smoke and carbon monoxide poured back into the hut. A ladder was leant against the back wall. + This wasn't an accident. + keyName: Crossword Key + + - id: ch3 + title: The Typed Note + narrative: | + The note on Eleanor's desk reads: *"Nowak is the spy. I have proof. — E.F."* It's typed. + Every typewriter at Hawthorn Park has its own flaws. + clues: + A: + - id: a3-note + title: The note, under a magnifying glass + type: report + body: | + - Every lower-case **e** sits slightly **above** the line. + - The **t** is missing the left half of its crossbar. + - Capital letters are **smudged**, as if the type is clogged. + - The "E.F." is typed, not signed. Eleanor signed everything by hand. + B: + - id: b3-machines + title: Typewriter inventory and known faults + type: table + body: | + | Machine | Location | e above line | broken t | smudged capitals | + |---------|----------|--------------|----------|------------------| + | 1 | Hut 7, Eleanor's desk | yes | no | no | + | 2 | Hut 7, Penrose's office | yes | yes | no | + | 3 | Hut 3 | no | yes | yes | + | 4 | **Teleprinter Room** | yes | yes | yes | + | 5 | Canteen office | no | no | yes | + | 6 | Liaison office (Harrow) | yes | no | yes | + lock: + id: lock-typewriter + prompt: Which machine typed the note? + inputType: code + format: Machine number + answers: ["4"] + hints: + - The note has three faults. Find the machine with all three. + - Machines 2, 3 and 6 each have two of the three faults. + - Machine 4, in the Teleprinter Room. + successText: | + **Machine 4, the Teleprinter Room.** Not Eleanor's typewriter. Someone framed Nowak with a forged note. + The Teleprinter Room is staffed overnight by two people: **Dot Kemp** and, until midnight, **Rupert Greaves** (doing a filing shift for extra pay). + keyName: Typewriter Key + + - id: ch4 + title: The Silent Teleprinter + narrative: | + To stuff a rag in Hut 7's flue, someone had to leave their post, fetch a ladder, climb the roof, and come back. + The flue was blocked around 01:30, give or take. + clues: + A: + - id: a4-kemp + title: Dot Kemp's statement + type: transcript + body: | + "I was on the teleprinter all night, sending Hut 7's decrypts to London. I never left my machine once. + Ask anyone. It clatters like a train, you'd have heard it." + - id: a4-greaves + title: Greaves's statement + type: transcript + body: | + "I left the Teleprinter Room at midnight and went to the canteen. Mrs Ashworth gave me cocoa. I lost at + chess to the night guard until four. Badly." + B: + - id: b4-tape + title: Teleprinter outgoing log, night of 13–14 Jan + type: log + body: | + | Time sent | Message | + |-----------|---------| + | 00:35 | HUT7/0412 | + | 00:50 | HUT7/0413 | + | 01:05 | HUT7/0414 | + | **01:20** | HUT7/0415 | + | **02:05** | HUT7/0416 | + | 02:20 | HUT7/0417 | + | 02:35 | HUT7/0418 | + *Normal rhythm: one message every 15 minutes while there's traffic. There was traffic all night.* + - id: b4-canteen + title: Mrs Ashworth's canteen book + type: log + body: | + 00:05 Greaves, cocoa. Still here at 04:00 playing chess with Sgt Bell. + 01:15 Lt Harrow, tea, left 01:25 for the liaison office (phone logs show him calling London 01:30–02:10). + lock: + id: lock-silent + prompt: For how many minutes was the teleprinter silent? + inputType: code + format: Minutes + answers: ["45"] + hints: + - Look for the gap that breaks the 15-minute rhythm. + - Nothing was sent between 01:20 and 02:05. + - 45 minutes. + successText: | + **45 minutes**, 01:20 to 02:05, right across the time the flue was blocked. The machine that + "clatters like a train" went quiet. Dot Kemp said she never left it. + keyName: Teleprinter Key + + - id: ch5 + title: Eleanor's Tape + narrative: | + Under Eleanor's blotter: a short strip of punched paper tape, the kind the teleprinters use. + Eleanor had punched it by hand. Five holes per row. + clues: + A: + - id: a5-tape + title: The punched tape (● hole, ○ no hole) + type: diagram + body: | + ``` + ● ○ ○ ● ● + ○ ● ○ ○ ○ + ○ ○ ● ○ ● + ● ○ ○ ● ● + ○ ● ○ ○ ● + ○ ● ● ● ○ + ○ ○ ● ● ● + ● ○ ○ ● ● + ``` + B: + - id: b5-code + title: Hut 7 training code card + type: document + body: | + Each row of a training tape is one letter, written in **binary**: ● = 1, ○ = 0, + read **left to right**, biggest place first (16, 8, 4, 2, 1). + The number is the letter's place in the alphabet: 1 = A, 2 = B... 26 = Z. + lock: + id: lock-tape + prompt: What did Eleanor punch? + inputType: text + format: The message (2 words) + answers: ["SHESINGS"] + hints: + - "Row 1: ●○○●● = 16 + 2 + 1 = 19." + - 19 is S. Row 2 is 8 = H. + - "19, 8, 5, 19, 9, 14, 7, 19 = SHE SINGS." + successText: | + **"SHE SINGS."** A nightingale sings. Eleanor knew the spy was a woman, and a singer. + keyName: Tape Key + + - id: ch6 + title: The Thursday Choir + narrative: | + Hawthorn Park's choir rehearses every Thursday in the chapel. Several women sing in it. + clues: + A: + - id: a6-programme + title: Christmas concert programme, December 1942 + type: document + body: | + 1. Carol: "In the Bleak Midwinter" · Full choir + 2. Song: **"A Nightingale Sang in Berkeley Square"** · *Soloist: D. K.* + 3. Carol: "Silent Night" · Sopranos + 4. Readings · Prof. Penrose + B: + - id: b6-roster + title: Choir roster + type: table + body: | + | Name | Voice | + |------|-------| + | Beatrice Ashworth | Alto | + | **Dorothy Kemp** | **Soprano** (soloist) | + | Rupert Greaves | Tenor | + | Arthur Penrose | Bass | + | Eleanor Finch | Alto | + *Nicknames are common: the choir calls Dorothy "our little nightingale".* + lock: + id: lock-choir + prompt: Who is D. K.? + inputType: choice + options: + - { id: penrose, label: Prof. Arthur Penrose } + - { id: kemp, label: Dorothy "Dot" Kemp } + - { id: harrow, label: Lt. Miles Harrow } + - { id: nowak, label: Stanisław Nowak } + - { id: greaves, label: Rupert Greaves } + - { id: ashworth, label: Beatrice Ashworth } + answers: ["kemp"] + hints: + - Match the initials to the roster. + - Only one choir member has the initials D. K. + - Dorothy Kemp. + successText: | + **Dorothy Kemp.** The choir's "little nightingale" sang the Nightingale solo at Christmas. + keyName: Choir Key + +finale: + type: accusation + prompt: MI5's car is at the gate. One accusation. Every part must be right. + maxAttempts: 1 + questions: + - id: who + label: Who killed Eleanor Finch? + options: + - { id: penrose, label: Prof. Arthur Penrose } + - { id: kemp, label: Dorothy "Dot" Kemp } + - { id: harrow, label: Lt. Miles Harrow } + - { id: nowak, label: Stanisław Nowak } + - { id: greaves, label: Rupert Greaves } + - { id: ashworth, label: Beatrice Ashworth } + answer: kemp + - id: how + label: How? + options: + - { id: flue, label: Blocked the Hut 7 stove flue with a rag from the roof during the teleprinter's silence } + - { id: poison, label: Poisoned her tea } + - { id: gas, label: Turned on a gas tap in the hut } + - { id: tablets, label: Swapped her headache tablets } + answer: flue + - id: note + label: What about the note on Eleanor's desk? + options: + - { id: real, label: Eleanor wrote it; Nowak really is the spy } + - { id: forged, label: Forged on Teleprinter Room machine 4 to frame Nowak } + - { id: harrow, label: Typed by Harrow in the liaison office } + - { id: penrose, label: Typed by Penrose to protect the hut } + answer: forged + - id: why + label: Why? + options: + - { id: affair, label: Jealousy over Lt. Harrow } + - { id: spy, label: Eleanor had uncovered that she was the enemy agent Nightingale } + - { id: promotion, label: To get Eleanor's job } + - { id: debt, label: Eleanor knew about her gambling debts } + answer: spy + successEnding: | + Dot Kemp stops smiling for the first time anyone can remember. + + "She was so close. Another day and she'd have had me." + + At 01:20 Dot left her teleprinter, took the gardener's ladder, climbed onto Hut 7's roof and stuffed a rag + into the stove flue. Back at her machine by 02:05. Before dawn she typed a note on machine 4 to frame + Stanisław Nowak, the foreigner everyone already whispered about, and slipped it onto Eleanor's desk + when the hut was cleared. + + **Nightingale is caught.** Nowak gets an apology from the Prime Minister's office. Quietly. 🕵️🔑 + failEnding: | + MI5 takes away the wrong person. Nightingale keeps singing, and the decrypts keep leaking. + reveal: | + **Dorothy "Dot" Kemp**, codename **Nightingale**, blocked the **Hut 7 stove flue** with a rag during the + 45-minute silence of her teleprinter, then **forged a note on machine 4 to frame Nowak**, because + Eleanor had **uncovered that she was the spy**. diff --git a/content/games/hedge-maze-midnight.yaml b/content/games/hedge-maze-midnight.yaml new file mode 100644 index 0000000..124b022 --- /dev/null +++ b/content/games/hedge-maze-midnight.yaml @@ -0,0 +1,82 @@ +id: hedge-maze-midnight +engine: labyrinth +category: co-op +title: The Hedge Maze at Midnight +tagline: An old estate maze, a locked iron gate, and somewhere in the hedges, the key. +estimatedMinutes: 15 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#2d6a4f", accent: "#ffd166", background: "#06110b", headingFont: "Playfair Display" } +roles: + A: + name: Navigator + blurb: You have the gardener's plan of the maze from the balcony. You can't see your partner in the dark. + B: + name: Explorer + blurb: You're in the hedges with a candle. You see the ground beneath you and nothing else. +intro: | + Midsummer at Ravenscourt House. Tradition says couples who light all three **garden lanterns** in the + hedge maze before the clock strikes one will be lucky for a year. + + The heart of the maze is behind an **iron gate**, and the gate is locked. Its key was dropped somewhere + in the hedges a century ago. **Four** stretches of the path hide old gardener's traps. + + New: **items**. The Explorer picks up an item by stepping on it. The gate only opens for the Explorer carrying its key. + +# Grid legend: # hedge · . path · S start · E exit (needs all lanterns) · K lantern · T trap +grid: + - "###############" + - "#S....#K.T.T..#" + - "#.#.#.#.#####.#" + - "#...#.#..T#...#" + - "#.#.#.##.##.#.#" + - "#.#T..#.....#.#" + - "#.#####.#####.#" + - "#.#.....#...#.#" + - "#K###K###.#.#.#" + - "#.....#......E#" + - "###############" + +lanternsRequired: 3 + +# NEW: items and doors. Items are picked up automatically on entry (kept after traps). +# A door cell is impassable until the Explorer carries the item named in `needs`. +items: + - { x: 5, y: 9, id: gate-key, name: "Iron Gate Key", explorerText: "Something glints in the grass: a heavy iron key!" } +doors: + - { x: 5, y: 7, needs: gate-key, name: "Iron Gate", lockedText: "A locked iron gate. You need its key.", openText: "The key turns. The gate creaks open." } + +glyphs: + - { x: 1, y: 1, glyph: fountain, label: "⛲ A stone fountain (the entrance)" } + - { x: 3, y: 3, glyph: rabbit, label: "🐇 A stone rabbit" } + - { x: 5, y: 4, glyph: sundial, label: "🕰 A mossy sundial" } + - { x: 1, y: 6, glyph: bench, label: "🪑 A wooden bench" } + - { x: 3, y: 9, glyph: owl, label: "🦉 An owl watching from the hedge" } + - { x: 7, y: 5, glyph: urn, label: "🏺 A cracked urn" } + - { x: 10, y: 7, glyph: rose, label: "🌹 A climbing rose" } + - { x: 13, y: 4, glyph: statue, label: "🗿 A statue with no face" } + - { x: 9, y: 9, glyph: frog, label: "🐸 A frog on a stepping stone" } + - { x: 12, y: 3, glyph: bell, label: "🔔 A tiny bell on a ribbon" } + +lanterns: + - { x: 1, y: 8, name: Lantern of the Bench } + - { x: 5, y: 8, name: Lantern of the Gate } + - { x: 7, y: 1, name: Lantern of the High Hedge } + +explorerView: + flavour: + blank: "Clipped grass and the smell of roses." + trap: "The ground drops into a ha-ha ditch! You climb out, muddy, back at the fountain." + lantern: "You light a paper lantern. It glows gold. ({name})" + exitLocked: "The maze's heart: a pavilion with three empty lantern hooks." + exitOpen: "Three lanterns glow. Somewhere, a clock strikes one, and fireworks go up over the house." + +navigatorView: + showMoveCount: true + +scoring: + trapPenaltySeconds: 60 + parMoves: 70 + +successEnding: | + Three lanterns, one gate, zero arguments (mostly). A year of luck, guaranteed. 🏮🔑 diff --git a/content/games/in-sync-road-trip.yaml b/content/games/in-sync-road-trip.yaml new file mode 100644 index 0000000..4969372 --- /dev/null +++ b/content/games/in-sync-road-trip.yaml @@ -0,0 +1,110 @@ +id: in-sync-road-trip +engine: sync +category: co-op +title: "In Sync: Road Trip" +tagline: Ten hours in a car together. Do you agree on snacks, music, and who's driving? +estimatedMinutes: 15 +difficulty: 2 +players: { min: 2, max: 2 } +theme: { primary: "#f77f00", accent: "#fcbf49", background: "#140b02", headingFont: "Playfair Display" } +roles: + A: + name: Driver + blurb: In the spotlight for Round 3. + B: + name: Navigator + blurb: In the spotlight for Round 4. +intro: | + You're planning the ultimate road trip. First, let's see if you'd survive it. + + - **Round 1, Same Page:** both pick from the same options. Match = point. + - **Round 2, Telepathy:** both type one word. Match = point. + - **Rounds 3 & 4, Spotlight:** one answers about themselves, the other guesses. + + 3 out of 5 earns each key. One replay per round. + +rules: + questionsPerRound: 5 + pointsForKey: 3 + replaysPerRound: 1 + +rounds: + - id: r1 + title: Same Page + type: match + blurb: Both pick the answer you think your partner will pick. + keyName: Fuel Key + - id: r2 + title: Telepathy + type: telepathy + blurb: Both type ONE word. Same word = point. + keyName: Radio Key + - id: r3 + title: Spotlight on the Driver + type: predict + subject: A + blurb: The Driver answers about themselves. The Navigator guesses. + keyName: Ignition Key + - id: r4 + title: Spotlight on the Navigator + type: predict + subject: B + blurb: The Navigator answers about themselves. The Driver guesses. + keyName: Map Key + +match: + - { id: m01, q: "Our road trip destination?", options: [Garden Route, Drakensberg, Kruger, Cape Winelands] } + - { id: m02, q: "First snack out of the bag?", options: [Biltong, Chips, Sweets, Fruit (we're lying)] } + - { id: m03, q: "Car music situation?", options: [One shared playlist, Take turns, Podcasts, Silence and vibes] } + - { id: m04, q: "Who drives most of the way?", options: [Me, My partner, 50/50, Whoever loses rock-paper-scissors] } + - { id: m05, q: "Best road trip stop?", options: [Farm stall, Petrol station pie, Scenic lookout, A random small town] } + - { id: m06, q: "Where do we sleep?", options: [Fancy hotel, Cosy B&B, Camping, In the car (budget)] } + - { id: m07, q: "How do we handle getting lost?", options: [Laugh about it, Blame the GPS, Blame each other, Ask a stranger] } + - { id: m08, q: "Departure time?", options: [Before sunrise, Civilised morning, After lunch, Whenever we're packed] } + - { id: m09, q: "Car game of choice?", options: [I spy, 20 questions, Spot the number plate, Karaoke] } + - { id: m10, q: "Packing style?", options: [One small bag, Everything just in case, Last-minute chaos, Spreadsheet] } + - { id: m11, q: "The first argument will be about...", options: [Directions, Music, Aircon temperature, Bathroom stops] } + - { id: m12, q: "Perfect road trip length?", options: [A weekend, A week, Two weeks, Until the money runs out] } + - { id: m13, q: "Souvenir we'd bring home?", options: [Fridge magnet, Local food, Photos only, Something weird] } + - { id: m14, q: "Road trip breakfast?", options: [Wimpy, Coffee and rusks, Big hotel breakfast, Skip it and drive] } + - { id: m15, q: "Best time of day on the road?", options: [Sunrise, Midday, Golden hour, Night drive] } + - { id: m16, q: "Who falls asleep in the passenger seat first?", options: [Me, My partner, Neither, Both (uh oh)] } + +telepathy: + - { id: t01, q: "A road trip snack." } + - { id: t02, q: "Something you always forget to pack." } + - { id: t03, q: "A South African town." } + - { id: t04, q: "A car colour." } + - { id: t05, q: "Something you see out the window on a long drive." } + - { id: t06, q: "A song you'd both sing along to (one word from the title)." } + - { id: t07, q: "An animal you'd stop the car for." } + - { id: t08, q: "A drink for the road." } + - { id: t09, q: "Something in the cubbyhole." } + - { id: t10, q: "A word for how you feel after 8 hours in the car." } + - { id: t11, q: "A fast-food place." } + - { id: t12, q: "A thing that makes a road trip perfect." } + - { id: t13, q: "Something you'd find at a farm stall." } + - { id: t14, q: "A car brand." } + - { id: t15, q: "A word you'd yell at a pothole." } + - { id: t16, q: "Somewhere you'd stop to stretch your legs." } + +predict: + - { id: p01, q: "{subject}'s road trip superpower?", options: [Navigating, Snacks, Music, Keeping the peace] } + - { id: p02, q: "{subject}'s biggest road trip annoyance?", options: [Slow drivers, Bathroom stops, Bad music, Being hungry] } + - { id: p03, q: "If {subject} picked the music, it'd be...", options: [Throwbacks, Worship, Hip-hop, Whatever's trending] } + - { id: p04, q: "{subject} in the passenger seat is...", options: [The DJ, The backseat driver, Asleep, The snack dispenser] } + - { id: p05, q: "{subject}'s ideal pit stop meal?", options: [Pie, Burger, Toasted sarmie, A proper sit-down meal] } + - { id: p06, q: "Where would {subject} drive if they had a week and no plans?", options: [The coast, The mountains, The bush, Another country] } + - { id: p07, q: "{subject}'s packing mistake every time?", options: [Too many clothes, No charger, No jacket, Forgets toiletries] } + - { id: p08, q: "How does {subject} handle a flat tyre?", options: [Fixes it themselves, Calls for help, Panics a little, Makes me do it] } + - { id: p09, q: "{subject}'s favourite road trip memory type?", options: [A view, A meal, A song moment, A disaster we laugh about now] } + - { id: p10, q: "Would {subject} rather...", options: [Drive 10 hours straight, Stop every hour] } + - { id: p11, q: "{subject}'s petrol station buy?", options: [Coffee, Energy drink, Chocolate, "Nothing, just fuel"] } + - { id: p12, q: "When {subject} is tired on the road, they...", options: [Get quiet, Get grumpy, Get silly, Pull over and nap] } + - { id: p13, q: "{subject}'s road trip outfit?", options: [Comfy tracksuit, Shorts and slops, Actually stylish, "Pyjamas, honestly"] } + - { id: p14, q: "What would {subject} photograph most?", options: [Views, Food, Us, Random signs] } + - { id: p15, q: "{subject}'s dream car for the trip?", options: [Old-school Kombi, Big 4x4, Convertible, Whatever's paid off] } + - { id: p16, q: "{subject}'s first words at the destination?", options: ["Finally!", "Wow.", "Where's the bathroom?", "Let's eat."] } + +successEnding: | + Snacks agreed. Playlist approved. Nobody got left at a petrol station. **Road trip ready.** 🚗🔑 diff --git a/content/games/inventors-paradox.yaml b/content/games/inventors-paradox.yaml new file mode 100644 index 0000000..36eaa6e --- /dev/null +++ b/content/games/inventors-paradox.yaml @@ -0,0 +1,300 @@ +id: inventors-paradox +engine: story +category: escape-room +title: The Inventor's Paradox +tagline: A mad inventor's lab, sealed since 1930. His time machine still hums. So do his traps. +estimatedMinutes: 50 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#7f5539", accent: "#80ffdb", background: "#0a0806", headingFont: "JetBrains Mono" } +rules: + hintPenaltySeconds: 240 + timeLimitMinutes: 55 + timeLimitLabel: "Reactor stability" +roles: + A: + name: Inside the Lab + blurb: You're in the workshop with the machines. Panels, dials, clocks and vials. + B: + name: The Archivist + blurb: You're in the locked study next door with the inventor's notebooks. You can read his mind, nearly. +intro: | + **The Halloway Laboratory, sealed since 1930.** + + Professor Edmund Halloway claimed he'd built a working time machine, then vanished. His lab was bricked up. + Tonight you two are the first people inside in almost a hundred years. The moment you cross the threshold, + the doors seal and a reactor in the corner starts to hum, louder and louder. + + A gramophone crackles into life with his voice: + + > *"If you can hear this, you've triggered my safeguards. Only a mind that thinks in two places at once + > can shut them down. Good luck. You'll need each other."* + + ☠️ *Nightmare tier. Long hint penalty. A hard time limit. Some of it is maths. Breathe.* + +chapters: + - id: ch1 + title: The Switchboard + narrative: | + A brass panel with four switches (S1–S4) and one lamp. The lamp must light to open the inner door. + Halloway drew the circuit in his own private symbols. + clues: + A: + - id: a1-circuit + title: Circuit plate above the switches + type: diagram + body: | + ``` + LAMP = ( S1 ⊗ ¬S2 ) + ⊗ ( S3 ⊕ S4 ) + ⊗ ( S2 ⊘ S4 ) + ⊗ ¬( S1 ⊗ S3 ) + ``` + Lock entry: the four switch positions as 1 (on) / 0 (off), S1 first. Only one setting lights the lamp. + B: + - id: b1-legend + title: Notebook, "my symbols" + type: table + body: | + | Symbol | Meaning | + |--------|---------| + | ⊗ | AND: true only if both sides are true | + | ⊘ | OR: true if either side is true | + | ⊕ | XOR: true if exactly one side is true | + | ¬ | NOT: flips true and false | + lock: + id: lock-switches + prompt: Set the switches. + inputType: code + format: 4 digits of 1/0 (S1 S2 S3 S4) + answers: ["1001"] + hints: + - Every bracket is joined by AND, so every bracket must be true. + - "(S1 AND NOT S2) means S1 = 1 and S2 = 0. Then (S2 OR S4) forces S4 = 1." + - "S3 XOR S4 with S4 = 1 means S3 = 0. Check NOT(S1 AND S3): true. Answer 1001." + successText: | + **1001.** The lamp blazes. The inner door unlocks onto the workshop proper: a chalkboard grid, a wall of clocks, and a rack of glowing vials. + keyName: Circuit Key + + - id: ch2 + title: The Chalkboard Grid + narrative: | + A 4×4 grid is chalked on the board, half rubbed out. A lock below wants four digits. + clues: + A: + - id: a2-board + title: The chalkboard + type: diagram + body: | + ``` + ┌───┬───┬───┬───┐ + │ 1 │ │ │ │ + ├───┼───┼───┼───┤ + │ │ │ │ 2 │ + ├───┼───┼───┼───┤ + │ │ 1 │ │ │ + ├───┼───┼───┼───┤ + │ │ │ 2 │ │ + └───┴───┴───┴───┘ + ``` + *Fill with 1–4. Every row, column and 2×2 box has each digit once.* + *The lock wants the diagonal, top-left to bottom-right.* + B: + - id: b2-copy + title: Halloway's copy of the same grid (different squares survived) + type: diagram + body: | + ``` + ┌───┬───┬───┬───┐ + │ │ │ 3 │ │ + ├───┼───┼───┼───┤ + │ │ 4 │ │ │ + ├───┼───┼───┼───┤ + │ │ │ │ 3 │ + ├───┼───┼───┼───┤ + │ 4 │ │ │ │ + └───┴───┴───┴───┘ + ``` + lock: + id: lock-grid + prompt: Enter the diagonal. + inputType: code + format: 4 digits + answers: ["1441"] + hints: + - Combine both grids into one first. Neither has enough on its own. + - "Row 1 is 1, ?, 3, ?. Its top-left box already has 1 and 4 (from row 2), so row 1, column 2 is 2." + - "Solution rows: 1234 / 3412 / 2143 / 4321. Diagonal 1, 4, 4, 1." + successText: | + **1441.** A hatch pops open with a letter in it, from the Professor to his wife. + keyName: Chalk Key + + - id: ch3 + title: The Return Date + narrative: | + The time machine's destination panel wants a date: day, month, year. + clues: + A: + - id: a3-letter + title: Letter in the hatch + type: letter + body: | + *My dearest Margaret,* + + *I go forward tonight. I will return on **the first Friday the 13th after your 60th birthday**, + so that we may be unlucky together, as we always were.* + + *Your E.* + *(On the panel: enter DDMMYYYY.)* + B: + - id: b3-records + title: Family records, and a 1931 desk calendar + type: table + body: | + **Margaret Halloway**, born **14 April 1871**. + + | 1931 month | 1st of the month falls on a | + |------------|-----------------------------| + | Jan | Thursday | + | Feb | Sunday | + | Mar | Sunday | + | Apr | Wednesday | + | May | Friday | + | Jun | Monday | + | Jul | Wednesday | + | Aug | Saturday | + | Sep | Tuesday | + | Oct | Thursday | + | Nov | Sunday | + | Dec | Tuesday | + lock: + id: lock-date + prompt: Set the return date. + inputType: code + format: DDMMYYYY + answers: ["13111931"] + hints: + - Her 60th birthday is 14 April 1931. Look for 13ths after that. + - The 13th is a Friday when the 1st of the month is a Sunday. + - "After April, the first month starting on a Sunday is November. 13 November 1931." + successText: | + **13 11 1931.** The machine shudders, and a drawer marked "FUEL" slides open: empty, with a chemistry note pinned inside. + keyName: Calendar Key + + - id: ch4 + title: The Fuel Mix + narrative: | + The machine needs fuel mixed from the vials, and a colour meter that reads in hexadecimal. + clues: + A: + - id: a4-vials + title: The vial rack + type: table + body: | + | Vial | Glow colour | + |------|-------------| + | 1 | #FFFFFF | + | 2 | #FF3300 | + | 3 | #0066CC | + | 4 | #00FF00 | + | 5 | #33CC00 | + | 6 | #9900FF | + *Meter: "Enter the colour of the mix as a hex code."* + B: + - id: b4-recipe + title: Notebook, fuel recipe + type: note + body: | + *Equal parts of every vial whose number is **prime**. Nothing else. + The mix glows the **average** of their colours, **each channel separately** (red, green, blue), + **rounded down**. Hex: two digits per channel, #RRGGBB.* + lock: + id: lock-fuel + prompt: Enter the mix colour. + inputType: text + format: "#RRGGBB" + answers: ["667744", "#667744"] + hints: + - Prime vials are 2, 3 and 5. + - "Red: FF + 00 + 33 = 255 + 0 + 51 = 306. ÷ 3 = 102 = 0x66." + - "Green: (51 + 102 + 204) ÷ 3 = 119 = 0x77. Blue: (0 + 204 + 0) ÷ 3 = 68 = 0x44. #667744." + successText: | + **#667744.** The fuel glows a murky olive green. The Professor's notes call it "perfect". The reactor hum steadies, a little. + keyName: Vial Key + + - id: ch5 + title: The Brass Clock + narrative: | + The last lock wants the true time of the Professor's "departure". There's a clock on the wall behind you, + but you can only see it reflected in a big brass plate. + clues: + A: + - id: a5-clock + title: What you see in the brass plate + type: photo + body: | + *The clock face in the plate: the hour hand sits **between the 4 and the 5**, the minute hand points at the **7**. + It looks like about 4:35.* + *Lock: enter the time as HH:MM.* + B: + - id: b5-note + title: Notebook, last page + type: note + body: | + *Everything in my workshop is seen in brass. Trust no clock you see in a reflection. + In a mirror, the 1 sits where the 11 should be, the 2 where the 10 should be...* + lock: + id: lock-clock + prompt: What's the true time? + inputType: code + format: "HH:MM" + answers: ["0725", "725", "1925"] + hints: + - In a mirror, each hand points to the reflected position. 1 ↔ 11, 2 ↔ 10, 3 ↔ 9, 4 ↔ 8, 5 ↔ 7. + - The minute hand at the 7 is really at the 5 (25 minutes). The hour hand between 4 and 5 is really between 8 and 7. + - "7:25. (Or: 11:60 − 4:35 = 7:25.)" + successText: | + **7:25.** The time machine's door hisses open. Inside, one dial left, and a riddle in the Professor's handwriting. + keyName: Brass Key + +finale: + type: escape + prompt: The time machine's year dial. Set it to the year of the Professor's birth and the safeguards shut down. + clues: + A: + - id: af-plate + title: Maker's plate on the machine + type: photo + body: | + *A riveted brass plate:* **"HALLOWAY CHRONO-CARRIAGE · BUILT 1912"** + *Year dial: 4 digits.* + B: + - id: bf-riddle + title: The Professor's riddle (inside the notebook cover) + type: note + body: | + *The year I built my machine, I was **three times** as old as my daughter Ada. + **Eighteen years later**, I was only **twice** as old as she was. + Set my dial to the year I was born.* + lock: + id: lock-year + prompt: Set the year dial. + inputType: code + format: 4 digits + answers: ["1858"] + hints: + - "Let Ada's age in the build year be y. The Professor was 3y. Eighteen years later: 3y + 18 = 2(y + 18)." + - y = 18, so the Professor was 54 when he built it. + - The plate says 1912. 1912 − 54 = 1858. + successEnding: | + **1858.** The reactor sighs into silence. Every door in the lab unlocks at once. + + As you leave, the gramophone crackles one last time: + > *"Two minds, one problem. That was always the real machine. — E.H."* + + On the doorstep is a letter postmarked **13 November 1931**, addressed to Margaret. It's never been opened. + + **You escaped!** ⚙️🔑 + failEnding: | + The reactor hum reaches a screech. White light. You wake up outside the bricked-up door, with no memory of + the last hour. (It's a game. Try again?) diff --git a/content/games/magicians-dressing-room.yaml b/content/games/magicians-dressing-room.yaml new file mode 100644 index 0000000..276ea20 --- /dev/null +++ b/content/games/magicians-dressing-room.yaml @@ -0,0 +1,197 @@ +id: magicians-dressing-room +engine: story +category: escape-room +title: The Magician's Dressing Room +tagline: The Great Marvello vanished mid-show. Now you're locked in his dressing room. Coincidence? Never. +estimatedMinutes: 25 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#5b2a86", accent: "#f9c74f", background: "#0e0716", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Assistant + blurb: You're in the dressing room itself. Mirrors, cabinets, cards, and the stage door. + B: + name: The Stagehand + blurb: You're behind the trick wall in the prop store. Scripts, charts, and the magician's secrets. +intro: | + Front-row seats at **The Great Marvello**'s farewell show. Mid-act, he steps into his Vanishing Cabinet... + and genuinely vanishes. The theatre panics. An usher asks you two to "check his dressing room". + + The door clicks shut behind you. A playing card slides under it: + + > *"Every great trick needs two. Find your way out, and you'll find me. — M."* + + One of you is in the dressing room. The other fell through a trick wall into the prop store behind it. + You can hear each other through the mirror. + +chapters: + - id: ch1 + title: The Magic Square + narrative: | + The makeup table drawer has a 4-digit lock. Its lid is painted with a 3×3 grid of numbers, some rubbed away. + clues: + A: + - id: a1-grid + title: The painted grid on the drawer + type: diagram + body: | + ``` + ┌───┬───┬───┐ + │ 2 │ ? │ 6 │ + ├───┼───┼───┤ + │ ? │ 5 │ ? │ + ├───┼───┼───┤ + │ 4 │ ? │ 8 │ + └───┴───┴───┘ + ``` + *Tiny print under it: "Read the missing ones top to bottom, left to right."* + B: + - id: b1-notebook + title: Marvello's notebook, page 1 + type: note + body: | + *My drawer square is MAGIC: every row, every column and both diagonals add to the **same number**. + Use each number 1 to 9 exactly once.* + lock: + id: lock-square + prompt: Open the drawer. + inputType: code + format: 4 digits + answers: ["7913"] + hints: + - Find the magic total first. The diagonal 2, 5, 8 is complete. + - Every line adds to 15. The top row needs 15 − 2 − 6. + - "Missing: top 7, middle-left 9, middle-right 1, bottom 3. Reading order: 7, 9, 1, 3." + successText: | + **7913.** The drawer opens: a top hat, and a deck of cards with six cards turned face up. + keyName: Top Hat Key + + - id: ch2 + title: What's in the Hat? + narrative: | + A brass plate on the hat stand reads **"WHAT'S IN THE HAT?"** with a six-letter tile lock. + Six cards are laid face up on the table in a row. + clues: + A: + - id: a2-cards + title: The six face-up cards (left to right) + type: diagram + body: | + ``` + 5♥ A♠ 2♠ 2♠ 9♠ 7♥ + ``` + B: + - id: b2-cipher + title: Marvello's card cipher (prop store wall) + type: table + body: | + | Suit | Ace | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | J | Q | K | + |------|-----|---|---|---|---|---|---|---|---|----|---|---|---| + | ♠ Spades | A | B | C | D | E | F | G | H | I | J | K | L | M | + | ♥ Hearts | N | O | P | Q | R | S | T | U | V | W | X | Y | Z | + *Clubs and diamonds mean nothing. Decoys.* + lock: + id: lock-hat + prompt: What's in the hat? + inputType: text + format: 6 letters + answers: ["RABBIT"] + hints: + - Spades are A to M, hearts are N to Z, by card value. + - "5♥ = R. A♠ = A." + - R-A-B-B-I-T. + successText: | + **RABBIT.** The hat's false bottom drops open. Out falls a velvet card with a grid of little squares, + and a note: *"Picture my favourite thing."* + keyName: Rabbit Key + + - id: ch3 + title: The Picture Puzzle + narrative: | + A wall panel has a 5×5 grid of light switches. Turn on the right ones and the panel shows a picture. + Then say what it is. + clues: + A: + - id: a3-rows + title: Velvet card, side 1 (rows, top to bottom) + type: table + body: | + | Row | Clue | + |-----|------| + | 1 | 1 1 | + | 2 | 5 | + | 3 | 5 | + | 4 | 3 | + | 5 | 1 | + *Each number is a run of switches ON in a row, in order, with at least one OFF between runs.* + B: + - id: b3-cols + title: Velvet card, side 2 (columns, left to right) + type: table + body: | + | Column | Clue | + |--------|------| + | 1 | 2 | + | 2 | 4 | + | 3 | 4 | + | 4 | 4 | + | 5 | 2 | + *Same rules, but reading each column top to bottom.* + lock: + id: lock-picture + prompt: What does the picture show? + inputType: text + format: One word + answers: ["HEART", "AHEART", "LOVE", "LOVEHEART"] + hints: + - Rows 2 and 3 are fully ON. Start there. + - Column 1 has a run of 2 and rows 2–3 already give it that, so the rest of column 1 is OFF. Same for column 5. + - "Row 1: switches 2 and 4. Row 4: switches 2–4. Row 5: switch 3. It's a heart." + successText: | + **A heart.** The panel glows red. Behind it, a card table with a spread of cards and a stage door with four suit symbols. + keyName: Heart Key + +finale: + type: escape + prompt: The stage door has four dials, one under each suit. + clues: + A: + - id: af-door + title: The stage door + type: diagram + body: | + ``` + ♠ ♥ ♦ ♣ + [ ] [ ] [ ] [ ] + ``` + *Engraved: "Count my table. Every card counts."* + B: + - id: bf-table + title: The card table in the prop store + type: table + body: | + | Card | Card | Card | Card | + |------|------|------|------| + | K♥ | 3♠ | 7♥ | Q♦ | + | 2♥ | J♠ | 9♦ | A♥ | + | 5♣ | 8♠ | 4♥ | | + lock: + id: lock-stage + prompt: Set the four dials. + inputType: code + format: 4 digits (♠ ♥ ♦ ♣) + answers: ["3521"] + hints: + - Count how many cards of each suit are on the table. + - "♠: 3♠, J♠, 8♠." + - "♠ 3, ♥ 5, ♦ 2, ♣ 1 = 3521." + successEnding: | + **3521.** The stage door swings open onto the empty stage, under a single spotlight. + + The Vanishing Cabinet's door creaks. The Great Marvello steps out, bows deeply, and hands you two + a pair of tickets: *"Front row, for life. You make a marvellous double act."* + + **You escaped!** 🎩🔑 diff --git a/content/games/pharaohs-tomb.yaml b/content/games/pharaohs-tomb.yaml new file mode 100644 index 0000000..e2aaa16 --- /dev/null +++ b/content/games/pharaohs-tomb.yaml @@ -0,0 +1,205 @@ +id: pharaohs-tomb +engine: story +category: escape-room +title: The Pharaoh's Tomb +tagline: Valley of the Kings, 1922. The torch is dying, the door has sealed, and the walls don't write vowels. +estimatedMinutes: 35 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#b08d57", accent: "#40c9a2", background: "#120d06", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 + timeLimitMinutes: 40 + timeLimitLabel: "Torch" +roles: + A: + name: The Archaeologist + blurb: You're inside the tomb with a failing torch. You see the glyphs, the floor, the doors. + B: + name: The Translator + blurb: You're at the camp tent with the books and the radio. You can read what they can't. +intro: | + **The Valley of the Kings, November 1922.** + + The expedition finally breaks into an unknown tomb. You go in first, alone, while your partner + works the radio from the camp tent. Ten steps inside, a stone slab grinds down behind you. + + Your torch has about forty minutes of oil. The walls are covered in glyphs. + Your partner has the dictionaries. **Nothing in here opens without both of you.** + + ⚠️ *Ancient Egyptian writing usually leaves out the vowels. You'll have to fill them in.* + +chapters: + - id: ch1 + title: The God of the Door + narrative: | + The first inner door has a carved panel: *"Speak the name of the one who guards the dead."* + Below it, a row of glyphs, and a slot of letter tiles. + clues: + A: + - id: a1-door + title: Glyphs on the door + type: diagram + body: | + ## 🔒 👄 🔒 + *Each glyph is one sound. There are tiles for vowels too. The glyphs don't show them.* + B: + - id: b1-chart + title: Uniliteral sign chart (one glyph = one consonant) + type: table + body: | + | Glyph | Sound | Glyph | Sound | + |-------|-------|-------|-------| + | 🦉 | M | 🍞 | T | + | 🌊 | N | 🐍 | F | + | 👄 | R | 🦶 | B | + | 🔒 | S | 🌀 | H | + | 🧺 | K | 🪑 | P | + | 🖐 | D | | | + *Vowels are not written. Readers fill them in from context.* + - id: b1-gods + title: The gods (dictionary appendix) + type: document + body: | + Amun · Anubis · Horus · Isis · **Osiris** (lord of the dead) · Ptah · Seth + lock: + id: lock-god + prompt: Speak the name. + inputType: text + format: The god's name + answers: ["OSIRIS"] + hints: + - The glyphs give three consonants. Which god's name has exactly those consonants, in that order? + - 🔒 👄 🔒 is S-R-S. + - O-S-I-R-I-S. (Isis is S-S, Horus is H-R-S.) + successText: | + **OSIRIS.** The door rumbles aside. The next chamber has two painted cartouches and a number dial. + keyName: Ankh Key + + - id: ch2 + title: Two Kings + narrative: | + The dial in the second chamber is carved: *"How many years stand between the two kings?"* + clues: + A: + - id: a2-cartouche + title: The cartouche in this chamber + type: diagram + body: | + **King Khaf-Ra, crowned in year:** + ## 🪷🪷 🪢🪢🪢 ∩∩∩∩ ||||||| + B: + - id: b2-numerals + title: Egyptian number signs + type: table + body: | + | Sign | Value | + |------|-------| + | 🪷 lotus | 1000 | + | 🪢 coil of rope | 100 | + | ∩ heel bone | 10 | + | \| stroke | 1 | + *Add up every sign.* + - id: b2-notebook + title: The expedition notebook + type: note + body: | + *Copied from the entrance corridor yesterday:* + **King Djed-Ka-Ra, crowned in year:** 🪷 🪢🪢🪢🪢🪢🪢🪢🪢🪢 ∩∩ | + lock: + id: lock-kings + prompt: How many years between the two kings? + inputType: code + format: A number + answers: ["426"] + hints: + - Count each sign and multiply by its value. + - Khaf-Ra = 2000 + 300 + 40 + 7 = 2347. Djed-Ka-Ra = 1000 + 900 + 20 + 1 = 1921. + - 2347 − 1921 = 426. + successText: | + **426.** A passage opens onto a long chamber with a floor of carved tiles. Several of the tiles look... loose. + keyName: Cartouche Key + + - id: ch3 + title: The Floor of Judgement + narrative: | + To cross, you must step tile to tile (forwards, backwards, or sideways, never diagonally) from the + entrance row to the far row. A wrong tile means a long drop. + Each tile has a symbol, and a letter cut into its edge. + clues: + A: + - id: a3-floor + title: The floor (far wall at the top, you're at the bottom) + type: diagram + body: | + ``` + far row → ○K □D △H ◇M △T + ◇E ○S △O ○L □N + □P △R △A ◇F ○B + △H △A ○I □C △X + entrance → △P ◇W □V ○G ◇Y + ``` + *A plate at the entrance: "Walk only where the living walk. Speak the letters of your path."* + B: + - id: b3-book + title: Book of the Dead, translator's notes + type: table + body: | + | Symbol | Meaning | + |--------|---------| + | △ | **Life** | + | ○ | Death | + | □ | Water | + | ◇ | Sky | + lock: + id: lock-floor + prompt: Speak the letters of the safe path. + inputType: text + format: One word + answers: ["PHARAOH"] + hints: + - Only the △ (life) tiles are safe. Find a connected path of △ from the entrance row to the far row. + - Start at the bottom-left △P. Go up to △H, right to △A, up to △R... + - P-H-A-R-A-O-H. + successText: | + **PHARAOH.** You cross. The far wall slides open onto the burial chamber: a golden sarcophagus + with a sealed lid and a name lock. + keyName: Scarab Key + +finale: + type: escape + prompt: The sarcophagus lid will lift only for the name of the one who sleeps inside. + clues: + A: + - id: af-cartouche + title: The royal cartouche on the lid + type: diagram + body: | + ## 🌊 🐍 👄 🍞 👄 + *Above it, a symbol for "queen".* + B: + - id: bf-royals + title: Royal names (dictionary appendix) + type: document + body: | + Hatshepsut · Khufu · Nefertari · Nefertiti · Ramesses · Tutankhamun + *Use the uniliteral chart from the first door.* + lock: + id: lock-sarcophagus + prompt: Speak her name. + inputType: text + format: The queen's name + answers: ["NEFERTARI"] + hints: + - Read the consonants with the chart. + - "N-F-R-T-R. Careful: Nefertiti would be N-F-R-T-T." + - Nefertari. + successEnding: | + **NEFERTARI.** The lid lifts with a sigh of 3,000-year-old air. Inside, no mummy, but a hidden stair + spiralling up into daylight, and two gold rings, one for each of you. + + Your partner is waiting at the top of the stairs with the radio and a flask of tea. + + **You escaped!** 🏺🔑 + failEnding: | + The torch gutters out. Darkness. (It's a game. Light another torch?) diff --git a/content/games/snowbound-edelweiss.yaml b/content/games/snowbound-edelweiss.yaml new file mode 100644 index 0000000..719ea8c --- /dev/null +++ b/content/games/snowbound-edelweiss.yaml @@ -0,0 +1,362 @@ +id: snowbound-edelweiss +engine: story +category: murder-mystery +title: Snowbound at the Edelweiss +tagline: Switzerland, 1965. A blizzard, a ski champion, and tracks the snow is slowly erasing. +estimatedMinutes: 40 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#274c77", accent: "#e7ecef", background: "#070c12", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Inspector + blurb: You came up on the last train before the pass closed. Statements, the doctor's notes, the evidence bag. + B: + name: The Concierge + blurb: You run the Edelweiss. Logs, charts, the switchboard, and every guest's habits. +intro: | + **Hotel Edelweiss, above Zermatt. January 1965.** + + The blizzard closed the pass at nine. By dawn, a metre of snow, and **Bruno Keller**, Olympic champion and + owner of the Edelweiss, lies dead in the snow beside the old ski hut, 200 metres from the hotel. + + Two sets of tracks lead to the hut: Bruno's boot prints going out, and a pair of ski tracks that go out + **and come back**. The snow has been filling them in all night. + + ⚠️ *Expert case: locks give you facts, not the killer. One accusation, four parts. Make it count.* + +suspects: + - id: lotte + name: Lotte Keller + role: Bruno's wife + description: Ran the hotel while Bruno chased medals. Inherits everything. + - id: hans + name: Hans Brunner + role: Former rival + description: Bruno's great rival until a crash at last year's championship ended his career. + - id: ingrid + name: Dr Ingrid Falk + role: Hotel physician + description: Hands out sleeping pills like sweets. Knows everyone's secrets. + - id: pierre + name: Pierre Moreau + role: Investor + description: Wants to buy the Edelweiss and turn it into a casino. Bruno said no. Loudly. + - id: sophie + name: Sophie Weiss + role: Ski technician and instructor + description: Bruno's protégée. Prepares the racers' skis. Adored him. + +chapters: + - id: ch1 + title: Snow in the Tracks + narrative: | + The ski tracks are partly filled with fresh snow. Measure how much, and you know when they were made. + clues: + A: + - id: a1-measure + title: Police measurements at 06:00 + type: report + body: | + | Tracks | Fresh snow lying inside them | + |--------|------------------------------| + | Bruno's boot prints (going out) | **9 cm** | + | The ski tracks (out and back) | **7 cm** | + - id: a1-body + title: Dr Falk's examination + type: report + body: | + One heavy blow to the back of the head, something long and hard. A ski pole basket was + found pressed into the snow beside him. + B: + - id: b1-barometer + title: Hotel weather log + type: log + body: | + | Time | Snowfall rate | + |------|---------------| + | 20:00 – 22:00 | 1 cm per hour | + | 22:00 – 02:00 | **3 cm per hour** (blizzard peak) | + | 02:00 – 06:00 | 0.5 cm per hour | + *Snow settles evenly in tracks and on open ground.* + lock: + id: lock-snow + prompt: At what time were the ski tracks made? + inputType: code + format: "HH:MM" + answers: ["0020", "020", "1220", "1220AM"] + hints: + - Work backwards from 06:00. How much snow fell between 02:00 and 06:00? + - 2 cm fell from 02:00 to 06:00. The ski tracks need 5 cm more, at 3 cm per hour. + - 5 cm at 3 cm/hour is 1 hour 40 minutes before 02:00, so 00:20. (Bruno walked out at 23:40.) + successText: | + **00:20.** Bruno walked out to the hut at 23:40. Someone followed him **on skis at 00:20**, + and skied back to the hotel. + keyName: Snowflake Key + + - id: ch2 + title: The Wax + narrative: | + Racing skis are waxed for the temperature. The lab scraped wax from the ski tracks. + clues: + A: + - id: a2-lab + title: Lab note on the wax residue + type: report + body: | + Hard racing wax. Manufacturer's rating: **−10 °C to −18 °C**. + Not a rental-ski wax. Applied fresh, within the last day. + B: + - id: b2-chart + title: Ski room wax chart + type: table + body: | + | Wax colour | Temperature range | + |------------|-------------------| + | Yellow | 0 to −2 °C | + | Red | −2 to −6 °C | + | Violet | −4 to −10 °C | + | Blue | −8 to −14 °C | + | **Green** | **−10 to −18 °C** | + | Polar | −16 to −30 °C | + - id: b2-log + title: Ski room wax log, yesterday + type: log + body: | + | Skis | Waxed by | Wax | + |------|----------|-----| + | Lotte Keller (touring) | Sophie | Blue | + | Pierre Moreau (rental) | Sophie | Red | + | Dr Falk (touring) | herself | Violet | + | Hans Brunner (racing) | Sophie | Green | + | Sophie Weiss (racing) | herself | Green | + | Bruno Keller (racing) | Sophie | Green, *but Bruno walked, not skied* | + lock: + id: lock-wax + prompt: What colour wax was in the tracks? + inputType: text + format: One colour + answers: ["GREEN"] + hints: + - Match the lab's temperature rating to the chart exactly. + - −10 °C to −18 °C. + - Green. + successText: | + **Green.** Only two pairs of skis that could have made those tracks were waxed green: **Hans's** and **Sophie's**. + keyName: Wax Key + + - id: ch3 + title: Signals from the Hut + narrative: | + The night porter saw a lantern at the ski hut around 00:10, and someone signalling with ski poles. + He drew what he saw. + clues: + A: + - id: a3-porter + title: The night porter's sketch, from the lobby window + type: diagram + body: | + ``` + Signal Pole on MY left Pole on MY right + 1 Low Down + 2 High Low + 3 High Out + 4 High Down + 5 High Low + 6 High Out + 7 Low Low + 8 High High + ``` + *"He was facing the hotel, waving them about like a madman. Then the lantern went out."* + B: + - id: b3-chart + title: Edelweiss Ski School pole signal chart + type: table + body: | + **Written from the SIGNALLER'S point of view** (their own left and right pole). + + | Left ↓ \ Right → | Down | Low | Out | High | Up | + |------------------|------|-----|-----|------|----| + | **Down** | A | B | C | D | E | + | **Low** | F | G | H | I | J | + | **Out** | K | L | M | N | O | + | **High** | P | Q | R | S | T | + | **Up** | U | V | W | X | Y | + - id: b3-hans + title: Hans's window + type: note + body: | + Room 4, Hans Brunner's, is the only guest room that faces the ski hut. + Bruno asked you yesterday to "make sure Hans looks out of his window at midnight". + lock: + id: lock-signal + prompt: What word did Bruno signal? + inputType: text + format: One word + answers: ["BINDINGS"] + hints: + - The porter was facing Bruno. His left is Bruno's right. + - Swap each pair. Signal 1 becomes Bruno's left = Down, right = Low. + - B-I-N-D-I-N-G-S. + successText: | + **"BINDINGS."** Bruno was signalling to Hans's window. He wanted to tell Hans something + about the **bindings** that tore loose in last year's championship crash. + keyName: Pole Key + + - id: ch4 + title: Who Was Where + narrative: | + Two people had green-waxed skis. Where were they at 00:20? + clues: + A: + - id: a4-rooms + title: Guest register + type: table + body: | + | Room | Guest | + |------|-------| + | 1 | Bruno & Lotte Keller | + | 2 | Pierre Moreau | + | 3 | Dr Ingrid Falk | + | 4 | Hans Brunner | + | 5 | Sophie Weiss | + - id: a4-sophie + title: Sophie's statement + type: transcript + body: | + "Dr Falk gave me a sleeping pill at eleven. I took it and slept like a stone until the screaming at dawn." + - id: a4-falk + title: Dr Falk's private note + type: note + body: | + *Gave Sophie a pill at 23:00. She thanked me... then tipped it into the rubber plant when she thought I wasn't looking. Didn't say anything. Not my business.* + B: + - id: b4-switchboard + title: Switchboard log + type: log + body: | + | Time | Room | Call | + |------|------|------| + | 23:15 | 2 | Outside line, Paris. 6 min. | + | 00:05 – 00:40 | **4** | Outside line, Zürich. "A manager. Long call, lots of shouting." | + | 00:50 | 1 | Room service: hot chocolate, two cups | + - id: b4-bar + title: Barman's note + type: note + body: | + Card game in the lounge until 01:00: Madame Keller, Monsieur Moreau, Dr Falk. None of them left the table. + lock: + id: lock-where + prompt: Which green-wax skier has no alibi at 00:20? + inputType: choice + options: + - { id: hans, label: Hans Brunner } + - { id: sophie, label: Sophie Weiss } + answers: ["sophie"] + hints: + - Match the switchboard's room number to the guest register. + - Room 4 was on the phone from 00:05 to 00:40. That's Hans. + - Sophie says she was asleep, but she never took the pill. + successText: | + **Sophie.** Hans was shouting down the phone to Zürich the whole time (and missed Bruno's signal). + Sophie claims she slept, but she poured her sleeping pill into a plant. + keyName: Switchboard Key + + - id: ch5 + title: The Paper Strip + narrative: | + In Bruno's glove: a long, narrow strip of paper covered in letters. Bruno used to say that a message + wrapped around a ski pole is "the oldest trick in the Alps." + clues: + A: + - id: a5-strip + title: The unwound strip + type: note + body: | + ## THCHWCEAICSDRNEAONSAT + *Pencil marks show it was wound tightly around something round.* + B: + - id: b5-pole + title: Bruno's ski pole grip + type: photo + body: | + *A leather grip, thin and worn. A strip of paper wound round it shows **exactly 3 letters per turn**, one under the other.* + *(Wind the letters down in columns of 3, then read across.)* + - id: b5-clipping + title: Newspaper clipping, one year ago + type: document + body: | + **BRUNNER CRASHES OUT. CAREER OVER.** + *"...his binding tore loose at 90 km/h. The skis had been prepared that morning by + his young technician, **Mlle S. Weiss**, who was inconsolable."* + lock: + id: lock-strip + prompt: What does the strip say? + inputType: text + format: The message + answers: ["THECRASHWASNOACCIDENT"] + hints: + - Write the letters into columns, 3 letters per column, top to bottom. + - Column 1 is T-H-C, column 2 is H-W-C, column 3 is E-A-I... + - Read the top row, then the middle, then the bottom. THE CRASH WAS NO ACCIDENT. + successText: | + **"THE CRASH WAS NO ACCIDENT."** Bruno knew the binding had been loosened deliberately. + The person who prepared Hans's skis that morning was Sophie Weiss. + keyName: Scytale Key + +finale: + type: accusation + prompt: The snowplough is coming up the pass. One accusation, four parts. + maxAttempts: 1 + questions: + - id: who + label: Who killed Bruno Keller? + options: + - { id: lotte, label: Lotte Keller } + - { id: hans, label: Hans Brunner } + - { id: ingrid, label: Dr Ingrid Falk } + - { id: pierre, label: Pierre Moreau } + - { id: sophie, label: Sophie Weiss } + answer: sophie + - id: how + label: How? + options: + - { id: skis, label: Followed him to the hut on green-waxed racing skis at 00:20 and struck him with a ski pole } + - { id: walked, label: Walked out with him at 23:40 and pushed him } + - { id: pills, label: Drugged his drink so he collapsed in the snow } + - { id: window, label: Threw something from Room 4's window } + answer: skis + - id: why + label: Why? + options: + - { id: inherit, label: To inherit the hotel } + - { id: casino, label: He refused to sell the hotel } + - { id: crash, label: He was about to reveal she had loosened Hans's binding before last year's crash } + - { id: rival, label: Revenge for ending a career } + answer: crash + - id: signal + label: Who was Bruno signalling to? + options: + - { id: hans, label: "Hans, to tell him the truth about the crash" } + - { id: lotte, label: "Lotte, to call for help" } + - { id: porter, label: "The porter, to send a doctor" } + - { id: sophie, label: "Sophie, to warn her" } + answer: hans + successEnding: | + Sophie doesn't cry. She just looks out at the hut, half buried now. + + "I loosened one screw. One. I wanted Hans to lose, not to... and Bruno covered for me. For a year. + Then he said he couldn't live with it. He was going to tell Hans at midnight, and the federation in the morning." + + She saw Bruno walk out at 23:40, waxed her racing skis green for the cold, and followed him at 00:20. + He was still waving his poles at Hans's dark window when she reached him. + + **Case closed.** 🏔️🔑 + failEnding: | + The plough arrives, the pass opens, and the wrong person goes down the mountain in handcuffs. + reveal: | + **Sophie Weiss** skied out on **green-waxed racing skis at 00:20** and struck Bruno with a ski pole, because he + was about to reveal she had **loosened Hans's binding** before last year's crash. Bruno was signalling + **"BINDINGS"** to **Hans's window**. diff --git a/content/games/the-lighthouse.yaml b/content/games/the-lighthouse.yaml new file mode 100644 index 0000000..9cae316 --- /dev/null +++ b/content/games/the-lighthouse.yaml @@ -0,0 +1,201 @@ +id: the-lighthouse +engine: story +category: escape-room +title: The Last Lighthouse +tagline: A storm, a dead lamp, and a ship heading for the rocks. Relight it before it's too late. +estimatedMinutes: 20 +difficulty: 2 +players: { min: 2, max: 2 } +theme: { primary: "#d62828", accent: "#fcbf49", background: "#05090f", headingFont: "Playfair Display" } +rules: + timeLimitMinutes: 25 + timeLimitLabel: "Ship to the rocks" +roles: + A: + name: The Lookout + blurb: You're on the gallery outside the lamp room with a telescope, a radio and the keeper's logbook. + B: + name: The Keeper's Helper + blurb: You're down in the keeper's cottage with the signal books, charts and the lighthouse plans. +intro: | + **Cape Storm Lighthouse, 1950s. A winter gale.** + + You took shelter in the old lighthouse when the storm hit. Then the lamp went out. + Through the rain, a cargo ship's lights are heading straight for the reef. + + One of you climbed up to the gallery. The other is in the cottage with the keeper's books. + The radio between you crackles. **Relight the lamp. Save the ship.** + +chapters: + - id: ch1 + title: Flags in the Rain + narrative: | + Through the telescope, the ship is flying a string of signal flags, top to bottom. + The lamp room's emergency box has a four-letter word lock. + clues: + A: + - id: a1-flags + title: What you see through the telescope (top to bottom) + type: photo + body: | + 1. *Split down the middle: **white** on the side nearest the mast, **red** on the outer side.* + 2. *Split **diagonally** into a **red** triangle and a **yellow** triangle.* + 3. *A **blue** flag with a **white square** in the middle.* + 4. *Split across the middle: **blue** on top, **red** below.* + B: + - id: b1-book + title: International signal flag book (a few pages) + type: table + body: | + | Flag | Looks like | + |------|------------| + | A | White and blue, with a swallowtail notch | + | B | All red, with a swallowtail notch | + | C | Horizontal stripes: blue, white, red, white, blue | + | D | Horizontal stripes: yellow, blue, yellow | + | **E** | **Blue top half, red bottom half** | + | **H** | **White half (by the mast) and red half (outside)** | + | J | Horizontal stripes: blue, white, blue | + | N | Blue and white chequerboard | + | **O** | **Diagonal halves, red and yellow** | + | **P** | **Blue with a white square in the centre** | + | S | White with a blue square in the centre | + | U | Red and white quarters | + lock: + id: lock-flags + prompt: Enter the word the ship is flying. + inputType: text + format: 4 letters + answers: ["HOPE"] + hints: + - Match each flag description to the book. + - Careful with flag 3. P is blue with a white square; S is the other way round. + - H-O-P-E. + successText: | + **HOPE.** The emergency box opens: matches, a spare mantle for the lamp, and the clockwork crank. + keyName: Flag Key + + - id: ch2 + title: The Lens + narrative: | + The lamp is lit, but the lens has to turn at the right speed or the ship won't recognise the light. + The clockwork wants you to set the flash interval in seconds. + clues: + A: + - id: a2-lens + title: The great glass lens + type: photo + body: | + *A huge glass lens around the lamp. Around it are **4 bright "bullseye" panels**, evenly spaced. + Each time a bullseye sweeps past the ship, the ship sees one flash.* + B: + - id: b2-plans + title: Lighthouse plans, clockwork page + type: document + body: | + *Weighted clockwork. When properly wound, the lens makes **one full turn every 40 seconds**.* + *The Notice to Mariners lists Cape Storm as: "one white flash every ___ seconds."* + lock: + id: lock-lens + prompt: Set the flash interval (seconds). + inputType: code + format: Seconds + answers: ["10"] + hints: + - Each full turn of the lens sends out one flash per bullseye panel. + - 4 flashes per 40-second turn. + - 40 ÷ 4 = 10 seconds. + successText: | + **10 seconds.** The beam sweeps out across the water. The ship's horn blares, but it's still heading for the reef. + They don't know where they are. + keyName: Lens Key + + - id: ch3 + title: Which Light Is That? + narrative: | + Across the bay, another lighthouse is blinking. If you can name it, you can radio the ship its exact position. + clues: + A: + - id: a3-light + title: The light across the bay + type: signal + signal: "..." + body: | + A distant **red** light, flashing in a group, then dark, then the group again. + Count the flashes in each group and tell the cottage. + B: + - id: b3-list + title: Notice to Mariners, local lights + type: table + body: | + | Light | Pattern | Colour | + |-------|---------|--------| + | Seal Point | Groups of 2 flashes | White | + | Gull Island | Groups of 2 flashes | Red | + | Bird Rock | Groups of 3 flashes | Red | + | Whale Head | Groups of 3 flashes | White | + | Kelp Bay | Groups of 4 flashes | Red | + lock: + id: lock-light + prompt: Which light is across the bay? + inputType: text + format: The light's name + answers: ["BIRDROCK", "BIRDROCKLIGHT", "BIRDROCKLIGHTHOUSE"] + hints: + - "You need two things: how many flashes per group, and the colour." + - Groups of 3, red. + - Bird Rock. + successText: | + **Bird Rock.** You radio the ship: *"You're between Cape Storm and Bird Rock. Turn to port, now!"* + A long pause. Then the ship's lights swing slowly away from the reef. + One last job: lock the lamp room so the storm can't blow the lamp out again. + keyName: Radio Key + +finale: + type: escape + prompt: The lamp room storm door has a two-digit lock. + clues: + A: + - id: af-log + title: The keeper's logbook, inside cover + type: note + body: | + *Lamp room door code = the number of **steps from where you're standing now** up to the lamp.* + *(You're on the **2nd floor** landing, just below the gallery stairs.)* + B: + - id: bf-section + title: Cross-section of the tower + type: diagram + body: | + ``` + LAMP ROOM + ▲ 16 steps + 4th floor + ▲ 20 steps + 3rd floor + ▲ 24 steps + 2nd floor + ▲ 28 steps + 1st floor + ▲ 32 steps + GROUND (door) + ``` + lock: + id: lock-door + prompt: Lock the lamp room. + inputType: code + format: 2 digits + answers: ["60"] + hints: + - Only count the stairs above the 2nd floor. + - 24 + 20 + 16. + - "60." + successEnding: | + **60.** The storm door locks with a clang. The lamp burns steady, sweeping the sea every ten seconds. + + At dawn, a battered cargo ship limps into harbour. The captain sends a crate of oranges up to the lighthouse + with a note: *"To whoever was on watch last night. Thank you."* + + **You saved the ship!** 🌊🔑 + failEnding: | + A horrible grinding sound from the reef. (It's a game. Everyone made it to the lifeboats. Try again?) diff --git a/content/games/the-treehouse.yaml b/content/games/the-treehouse.yaml new file mode 100644 index 0000000..2689761 --- /dev/null +++ b/content/games/the-treehouse.yaml @@ -0,0 +1,175 @@ +id: the-treehouse +engine: story +category: escape-room +title: The Treehouse +tagline: Your old childhood treehouse. The trapdoor just locked. The secret club still has rules. +estimatedMinutes: 15 +difficulty: 1 +players: { min: 2, max: 2 } +theme: { primary: "#4f772d", accent: "#f6bd60", background: "#0f140b", headingFont: "Playfair Display" } +roles: + A: + name: Up the Tree + blurb: You climbed in. Comics, club signs, the trapdoor and a great view of the garden. + B: + name: Down Below + blurb: You stayed on the grass. The shed, the tree trunk, and the old club's photo album. +intro: | + You're visiting the house you grew up in, and you finally show your partner **the treehouse**. + One of you climbs up. The trapdoor slams shut and an old padlock clicks. + + Painted on the inside of the door, in very wobbly letters: + > **SECRET CLUB. NO GROWN-UPS. Only REAL members can get out.** + + Time to prove you're a real member. + +chapters: + - id: ch1 + title: The Comic Shelf + narrative: | + A tin lockbox on the shelf has a 3-digit lock. Next to it, a row of old comics. + clues: + A: + - id: a1-comics + title: The comic shelf + type: table + body: | + | Comic issue | Spine colour | + |-------------|--------------| + | #4 | Red | + | #9 | Blue | + | #2 | Green | + B: + - id: b1-chalk + title: Chalk on the shed wall + type: note + body: | + *LOCKBOX = comic numbers in RAINBOW order!* + *(Red, orange, yellow, green, blue, purple. We only have some of them.)* + lock: + id: lock-comics + prompt: Open the lockbox. + inputType: code + format: 3 digits + answers: ["429"] + hints: + - Line the comics up by spine colour, using the rainbow order on the shed wall. + - Red comes first, then green, then blue. + - "Red #4, green #2, blue #9: 429." + successText: | + **429.** The lockbox pops open: a club badge, a magnifying glass, and a card that says *"Password required."* + keyName: Badge Key + + - id: ch2 + title: The Club Password + narrative: | + A wooden sign hangs over the trapdoor: **"SAY THE PASSWORD."** Below it, four letter tiles. + clues: + A: + - id: a2-charter + title: The club charter (pinned to the wall) + type: document + body: | + **THE SECRET CLUB CHARTER** + Rule 1: The password is the first letters of our pets' names, **in the order we signed this charter.** + + *Signed:* Sam · Ava · Jo · Ben + B: + - id: b2-album + title: The club photo album + type: photo + body: | + *Four kids, four pets, captions in felt-tip:* + - "Ben + Socks the cat" + - "Jo + Luna the dog" + - "Sam + Peanut the hamster" + - "Ava + Ace the parrot" + lock: + id: lock-password + prompt: Say the password. + inputType: text + format: 4 letters + answers: ["PALS"] + hints: + - The Up-the-Tree player has the order. The Down-Below player has the pets. + - Sam, Ava, Jo, Ben → Peanut, Ace, Luna, Socks. + - P-A-L-S. + successText: | + **PALS.** A tiny drawer slides out of the sign. Inside: a treasure map of the garden, torn in half. + keyName: Password Key + + - id: ch3 + title: The Treasure Map + narrative: | + Half the map is in the treehouse. The other half was always kept in the shed. + clues: + A: + - id: a3-view + title: View of the garden from the window + type: map + body: | + ``` + 1 2 3 4 + A [shed] [roses] [swing] [veg patch] + B [rock] [bench] [pond] [birdbath] + C [compost] [lemon tree][tap] [gnome] + ``` + North is at the top (row A). East is to the right. + B: + - id: b3-map + title: Treasure map (shed half) + type: map + body: | + ``` + START at the BIG ROCK. + Walk 3 squares EAST. + Dig for the KEY! ✗ + ``` + lock: + id: lock-map + prompt: Where is the treasure buried? + inputType: text + format: The garden spot + answers: ["BIRDBATH", "THEBIRDBATH", "B4"] + hints: + - Find the big rock on the garden view first. + - The rock is at B1. Three squares east of B1. + - B1 → B2 → B3 → B4, the birdbath. + successText: | + **The birdbath!** Under it, in a jam jar: the trapdoor key's little brass tag... and a note: + *"The key is on the trunk. Ask the tree."* + keyName: Map Key + +finale: + type: escape + prompt: The trapdoor padlock needs a 4-digit code. + clues: + A: + - id: af-lock + title: Scratched next to the padlock + type: note + body: | + *Code = the year we built this, but add 1 to every digit. (So grown-ups can't guess it.)* + B: + - id: bf-trunk + title: Carved into the tree trunk + type: photo + body: | + *A heart with initials, a smiley face, and in big chunky letters: **BUILT 2009**.* + lock: + id: lock-trapdoor + prompt: Open the trapdoor. + inputType: code + format: 4 digits + answers: ["3110"] + hints: + - The year is carved on the trunk. + - Add 1 to each digit of 2009. Be careful with the 9. + - 2→3, 0→1, 0→1, 9→10... a 9 plus 1 wraps round to 0 on a lock dial. 3110. + successEnding: | + **3110.** The trapdoor swings open. You climb down, and on the way you notice something carved + under the window, small, in fresh-looking letters: your two initials, inside a heart. + + *(Your partner may have been here earlier than they said.)* + + **You escaped!** 🌳🔑 diff --git a/content/games/tick-tock-switch.yaml b/content/games/tick-tock-switch.yaml new file mode 100644 index 0000000..e81e7db --- /dev/null +++ b/content/games/tick-tock-switch.yaml @@ -0,0 +1,60 @@ +id: tick-tock-switch +engine: defuse +category: co-op +title: "Tick Tock: Switch" +tagline: Same bomb manual as Overtime. Except every level, you swap seats. +estimatedMinutes: 20 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#f72585", accent: "#4cc9f0", background: "#09030a", headingFont: "JetBrains Mono" } +roles: + A: + name: Partner A + blurb: Technician on levels 1, 3 and 5. Expert on levels 2 and 4. + B: + name: Partner B + blurb: Expert on levels 1, 3 and 5. Technician on levels 2 and 4. +intro: | + You've beaten Overtime. Now find out if you can do each other's job. + + **Every level, the roles swap.** Whoever just read the manual now has to describe the device, + and whoever just described it now has to find the rules, fast. + + Five levels, shorter timers, and only **2 strikes** per level. Same Overtime manual, same modules. + +# NEW: reuse another game's device, modules, manual and solver instead of duplicating them. +manualFrom: tick-tock-overtime # take `device`, `modules` and `manual` from that game; its fixtures already cover the rules +swapRolesEachLevel: true # level 1: A = Technician; level 2: B = Technician; and so on + +strikes: { max: 2, speedUpPerStrike: 0.25 } + +levels: + - id: lvl1 + title: "Switch: Warm-up" + timeSeconds: 180 + modules: [wires, button, keypad] + keyName: First Switch Key + - id: lvl2 + title: "Switch: Swapped" + timeSeconds: 210 + modules: [wires, passwords, morse] + keyName: Swap Key + - id: lvl3 + title: "Switch: Echoes" + timeSeconds: 240 + modules: [button, keypad, simon] + keyName: Echo Switch Key + - id: lvl4 + title: "Switch: Crossed Wires" + timeSeconds: 270 + modules: [wires, wires, passwords, morse] # two separate wire modules on one device + keyName: Crossed Key + - id: lvl5 + title: "Switch: Full Circle" + timeSeconds: 330 + strikesOverride: 1 + modules: [wires, button, keypad, passwords, morse, simon] + keyName: Full Circle Key + +successEnding: | + You can do each other's job. Under pressure. With a ticking bomb. That's basically marriage. 💣🔑 diff --git a/content/games/til-death-do-us-part.yaml b/content/games/til-death-do-us-part.yaml new file mode 100644 index 0000000..30bcc5c --- /dev/null +++ b/content/games/til-death-do-us-part.yaml @@ -0,0 +1,279 @@ +id: til-death-do-us-part +engine: story +category: murder-mystery +title: "'Til Death Do Us Part" +tagline: A Stellenbosch wedding. A perfect first dance. A best man at the bottom of the garden stairs. +estimatedMinutes: 30 +difficulty: 3 +players: { min: 2, max: 2 } +theme: { primary: "#b5838d", accent: "#ffcdb2", background: "#140c0e", headingFont: "Playfair Display" } +rules: + hintPenaltySeconds: 180 +roles: + A: + name: The Detective + blurb: You're a wedding guest who happens to be a detective. Statements, the seating rules, the police notes. + B: + name: The Photographer's Assistant + blurb: You worked the wedding. Camera files, the planner's folders, and the best man's phone. +intro: | + **A wine estate outside Stellenbosch. Saturday, 21:30.** + + Thabo and Lize are married. The speeches went well, the lights dim, and the couple step out for their first dance. + + At 21:50, a waiter finds **Jason Pike**, the best man, at the bottom of the stone garden stairs. Everyone assumes + champagne and a missed step. The doctor at table 6 doesn't. + + Don't let a murder ruin a perfectly good wedding. **Solve it before the cake is cut.** + + ⚠️ *Hard case: the locks give you facts. You'll connect them in the accusation.* + +suspects: + - id: kayla + name: Kayla Daniels + role: Maid of honour + description: Jason's ex. They broke up badly six months ago and had to share a head table tonight. + - id: sipho + name: Sipho Ndlovu + role: Groom's brother + description: Owes Jason money. Was seen arguing with him by the bar. + - id: johan + name: Johan Marais + role: Father of the bride + description: Paid for everything. Jason's speech joked about his "dodgy property deals". He didn't laugh. + - id: celeste + name: Celeste Moreau + role: Wedding planner + description: Elegant, efficient, everywhere at once. Wears the planner team's navy dress. + - id: marco + name: Marco Russo + role: The bride's ex + description: Not invited. Turned up anyway, "just to say congratulations". Security walked him out at 21:00. + +chapters: + - id: ch1 + title: The Empty Chair + narrative: | + The videographer filmed the first dance, 21:32 to 21:42, with the head table behind the couple. + Two chairs at the head table are empty in every frame. One is Jason's. Whose is the other? + clues: + A: + - id: a1-rules + title: The planner's seating notes (police copy) + type: note + body: | + Head table, seats **1–5 from the guests' left** (the couple's own seats in the middle don't count). + 1. Johan sits directly next to his wife, Anita. + 2. The maid of honour is not on either end. + 3. The best man sits at the groom's end: **seat 5**. + 4. Johan is **not** in the middle seat (3). "He hates being in the middle of photos." + B: + - id: b1-planner + title: More seating notes (from the planner's folder) + type: note + body: | + 5. The groom's brother sits directly next to the maid of honour. + 6. Anita sits somewhere to Johan's **left** (guests' view). + 7. Jason and Johan must **not** sit next to each other. Long story. + 8. The groom's brother sits to the maid of honour's **left**. + - id: b1-video + title: Videographer's note + type: log + body: | + First dance 21:32–21:42. Head table visible throughout. + **Seats 3 and 5 empty the entire time.** Seats 1, 2 and 4 occupied. + lock: + id: lock-chair + prompt: Who should have been in seat 3? + inputType: choice + options: + - { id: anita, label: Anita Marais (mother of the bride) } + - { id: johan, label: Johan Marais } + - { id: kayla, label: Kayla Daniels } + - { id: sipho, label: Sipho Ndlovu } + answers: ["sipho"] + hints: + - Jason is in seat 5. Johan can't be next to him, so Johan isn't in seat 4. + - Johan isn't in 3 or 4, and Anita is to his left and next to him. So Anita 1, Johan 2. + - "Seats 3 and 4 are Sipho and Kayla, with Sipho on Kayla's left: Sipho is seat 3." + successText: | + **Sipho**, the groom's brother. He wasn't at the table during the first dance. + (Sipho, cornered: *"I was in the car park on the phone to my bank. Ask them."* His call log + shows a call from 21:30 to 21:47. Inconvenient for a killer.) + keyName: Place Card Key + + - id: ch2 + title: The Last Photo + narrative: | + The photographer took hundreds of shots. One shows Jason at the top of the garden stairs with someone. + But the camera's clock was never set properly. + clues: + A: + - id: a2-photos + title: Photo files near the garden stairs (camera timestamps) + type: log + body: | + | File | Camera time | What it shows | + |------|-------------|---------------| + | IMG_2207 | 23:41 | Jason laughing on the terrace, champagne in hand | + | IMG_2219 | **23:45** | Jason at the **top of the garden stairs**, talking to a woman in a **navy dress**, her back to the camera | + | IMG_2231 | 23:47 | The first dance, wide shot | + | IMG_2246 | 23:52 | Garden stairs, empty | + B: + - id: b2-camera + title: Photographer's voice note + type: transcript + body: | + "Ugh, I never changed the camera clock after the Dubai job. It's **two hours ahead**. + And it was already running **seven minutes fast** before that. Sorry!" + - id: b2-dresscode + title: Planner's colour plan + type: table + body: | + | Group | Colour | + |-------|--------| + | Bridesmaids (incl. maid of honour) | Sage green | + | Mothers of the couple | Blush pink | + | **Planner team** | **Navy** | + | Guests | "Anything but white, navy or sage" | + lock: + id: lock-photo + prompt: What was the real time of IMG_2219? + inputType: code + format: "HH:MM" + answers: ["2138", "938", "938PM"] + hints: + - The camera is ahead by 2 hours plus 7 minutes. + - Take 2:07 off the camera time. + - 23:45 − 2:07 = 21:38. + successText: | + **21:38**, during the first dance. Jason was alive at the top of the garden stairs, talking to a woman in **navy**. + By 21:45 the stairs were empty. Navy is the **planner team's** colour, and only one planner was working tonight. + keyName: Camera Key + + - id: ch3 + title: The Toast Card + narrative: | + In Jason's jacket: his speech card. One line on the back looks oddly printed, with some letters bold and some not. + Jason's friends say he loved "old spy stuff". + clues: + A: + - id: a3-card + title: Back of Jason's speech card + type: note + body: | + **H**e**re**'s t**o** the bri**de** a**nd** **g**r**o**o**m**, **m**ay every day feel like this one + *(Pencilled: "Final toast. 5 by 5. Thin = a, fat = b.")* + B: + - id: b3-bacon + title: Jason's phone, a saved note "Bacon's cipher" + type: table + body: | + Read letters in groups of five: thin letter = **a**, bold letter = **b**. Ignore spaces and punctuation. + | Group | Letter | Group | Letter | Group | Letter | + |-------|--------|-------|--------|-------|--------| + | aaaaa | A | ababa | K | baaab | R | + | aaaab | B | ababb | L | baaba | S | + | aaaba | C | abbaa | M | baabb | T | + | aaabb | D | abbab | N | babaa | U | + | aabaa | E | abbba | O | babab | V | + | aabab | F | abbbb | P | babba | W | + | aabba | G | baaaa | Q | babbb | X | + | aabbb | H | | | bbaaa | Y | + | abaaa | I | | | bbaab | Z | + | abaab | J | | | | | + *This version has 26 separate letters (I and J, U and V are different).* + lock: + id: lock-bacon + prompt: What did Jason hide on his toast card? + inputType: text + format: One word + answers: ["WIDOW", "BLACKWIDOW"] + hints: + - "Write each letter as a (normal) or b (bold), then split into groups of five: H-e-r-e-s = b a b b a." + - babba = W. The next group, t-o-t-h-e, is a b a a a. + - W-I-D-O-W. + successText: | + **"WIDOW."** Jason's final toast was going to name someone as a **widow**. And not a sad one. + keyName: Toast Key + + - id: ch4 + title: Uncle Leon's Wedding + narrative: | + Jason's phone has an album called "UNCLE LEON". His uncle married in 2019, died eight months later, + and his new wife vanished with his savings. + clues: + A: + - id: a4-police + title: Police description of Celeste Moreau (taken tonight) + type: report + body: | + Female, about 40. Dark hair in a chignon. Navy planner's dress. + Small tattoo on the **inside of her left wrist: a swallow**. Says she's "been planning weddings for six years". + B: + - id: b4-album + title: Jason's album "UNCLE LEON", wedding photo, 2019 + type: photo + body: | + *Leon Pike, beaming, beside his bride "Claire". She's raising a glass with her left hand. + On the inside of her wrist: a small **swallow** tattoo.* + *Jason's caption, written last week: "Found her. She's planning Thabo's wedding."* + lock: + id: lock-tattoo + prompt: What links the planner to Uncle Leon's bride? + inputType: text + format: One word + answers: ["SWALLOW", "TATTOO", "SWALLOWTATTOO", "ASWALLOW", "THESWALLOW"] + hints: + - Compare the police description with the old wedding photo. + - Look at the left wrist. + - The swallow tattoo. + successText: | + **The swallow tattoo.** "Claire Pike" and Celeste Moreau are the same woman: she married Jason's uncle, + and when he died she disappeared with everything. Jason recognised her and was going to say so in his final toast. + keyName: Swallow Key + +finale: + type: accusation + prompt: The cake is coming out. Make your accusation before anyone cuts it. + maxAttempts: 2 + questions: + - id: who + label: Who killed Jason Pike? + options: + - { id: kayla, label: Kayla Daniels } + - { id: sipho, label: Sipho Ndlovu } + - { id: johan, label: Johan Marais } + - { id: celeste, label: Celeste Moreau } + - { id: marco, label: Marco Russo } + answer: celeste + - id: how + label: How? + options: + - { id: fell, label: "He fell, drunk, nobody did anything" } + - { id: pushed, label: Lured to the top of the garden stairs during the first dance at 21:38 and pushed } + - { id: poison, label: Poison in his champagne } + - { id: carpark, label: Attacked in the car park } + answer: pushed + - id: why + label: Why? + options: + - { id: ex, label: A bitter break-up } + - { id: money, label: An unpaid debt } + - { id: speech, label: Humiliation in the speeches } + - { id: widow, label: "He'd recognised her as the woman who married and robbed his uncle, and was about to expose her" } + answer: widow + successEnding: | + Celeste straightens a centrepiece that doesn't need straightening. + + "Six weddings a month. Nobody looks at the planner. And then the best man looks at my wrist and goes pale." + + At 21:38, while everyone watched the first dance, she asked Jason to "help with the sparklers" at the top + of the garden stairs. He told her he knew. She pushed. + + **Case closed.** The cake gets cut. Lize says it's the most memorable wedding in family history. 💍🔑 + failEnding: | + The wrong person is led out past the dessert table. "Claire" collects her fee and moves on to the next wedding. + reveal: | + **Celeste Moreau** pushed Jason down the garden stairs at **21:38 during the first dance**, because he'd recognised her + (the **swallow tattoo**) as the **"widow" who married and robbed his uncle** and was about to expose her in his toast. diff --git a/content/games/twin-cafes.yaml b/content/games/twin-cafes.yaml new file mode 100644 index 0000000..18dbca8 --- /dev/null +++ b/content/games/twin-cafes.yaml @@ -0,0 +1,91 @@ +id: twin-cafes +engine: twin +category: co-op +title: "Twin Rooms: The Twin Cafés" +tagline: Two cafés on opposite corners, run by identical twins who copy everything. Almost everything. +estimatedMinutes: 15 +difficulty: 2 +players: { min: 2, max: 2 } +theme: { primary: "#6f4e37", accent: "#e9c46a", background: "#120c08", headingFont: "Playfair Display" } +roles: + A: + name: Partner A + blurb: You're in Café Uno. + B: + name: Partner B + blurb: You're in Café Due, across the street. +intro: | + The Bianchi twins opened identical cafés on opposite corners of the same square. They copy each other's + menus, chairs, even the cat. But each twin secretly changed a few things to see if anyone would notice. + + You'll notice. Describe your café square by square, then **both tap the same square** within 15 seconds to claim a difference. + Wrong claims cost 20 seconds. No coordinates on the grid this time; you'll have to describe positions yourselves. +twin: + claimWindowSeconds: 15 + wrongClaimPenaltySeconds: 20 + mirrorB: false + showCoordinates: false + +scenes: + - id: counter + title: "The Counter" + timeSeconds: 240 + keyName: Espresso Key + differences: 6 + grid: # shown to A (and to B with changes applied) + - "🫖 🍰 🫖 🍪 🍩 🍰 🫖 🥪" + - "🍩 🫖 🧇 🥯 🥪 🍪 🍪 🥖" + - "🍰 🍫 🍰 ☕ ☕ 🥐 🍩 🥤" + - "🥯 🥪 🫖 🥯 🧁 🥖 🍰 🧇" + - "🥯 🥯 🫖 🥪 🍫 🥯 🥪 🍪" + - "🫖 🍩 ☕ 🍫 🍰 🧁 🥯 🥪" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 6, y: 0, to: "🍶" } + - { x: 0, y: 2, to: "🧁" } + - { x: 4, y: 3, to: "🍰" } + - { x: 0, y: 4, to: "🍩" } + - { x: 4, y: 5, to: "🧁" } + - { x: 6, y: 5, to: "🍩" } + - id: terrace + title: "The Terrace" + timeSeconds: 240 + keyName: Terrace Key + differences: 6 + grid: # shown to A (and to B with changes applied) + - "☂ 🌞 🌼 🎨 🛵 🪑 🧑‍🎨 🚲" + - "🛵 🌸 🪑 🕊 🎨 🌼 🌞 ☂" + - "🛵 🌿 🪑 🌿 🛵 🌳 🐈 🌼" + - "🪑 🌳 📰 🕊 🕊 🪑 🌳 🌿" + - "📰 🐈 🚲 🧑‍🎨 📰 🧑‍🎨 🌸 ☂" + - "🌿 🌸 🧑‍🎨 🌳 🌳 📰 🌼 🛵" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 0, y: 1, to: "🏍" } + - { x: 6, y: 1, to: "🌝" } + - { x: 6, y: 3, to: "🌲" } + - { x: 2, y: 4, to: "🛴" } + - { x: 4, y: 5, to: "🌲" } + - { x: 6, y: 5, to: "🌻" } + - id: window + title: "The Window Seat" + timeSeconds: 240 + keyName: Window Key + differences: 6 + grid: # shown to A (and to B with changes applied) + - "💌 🕯 💌 🍵 🧸 🧣 📚 📖" + - "🖋 🕯 🕯 🌙 🌙 💌 🧣 🧸" + - "📖 📖 🌙 🕯 🧸 🌙 🌧 📖" + - "🪟 📚 📖 📚 🪟 ⭐ 🎧 🖋" + - "🎧 🌙 🪟 🪟 🧸 🍂 🎧 🖋" + - "🧣 🍂 🎧 📚 🍂 🍂 💌 💌" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 1, y: 1, to: "🪔" } + - { x: 2, y: 1, to: "🪔" } + - { x: 5, y: 1, to: "✉" } + - { x: 3, y: 3, to: "🗂" } + - { x: 1, y: 4, to: "🌛" } + - { x: 5, y: 4, to: "🍁" } + +successEnding: | + Every difference found. You blink, and you're both back on the same side of the screen, looking at each other. + + **Same wavelength. Same room.** 🪞🔑 diff --git a/content/games/twin-looking-glass.yaml b/content/games/twin-looking-glass.yaml new file mode 100644 index 0000000..7f70ffa --- /dev/null +++ b/content/games/twin-looking-glass.yaml @@ -0,0 +1,108 @@ +id: twin-looking-glass +engine: twin +category: co-op +title: "Twin Rooms: Through the Looking Glass" +tagline: One of you is on the other side of the mirror. Everything is flipped. And the differences are tiny. +estimatedMinutes: 25 +difficulty: 4 +players: { min: 2, max: 2 } +theme: { primary: "#3c096c", accent: "#c77dff", background: "#0a0412", headingFont: "Playfair Display" } +rules: + timeLimitMinutes: 25 + timeLimitLabel: "Before the mirror clouds over" +roles: + A: + name: This Side + blurb: You see the real room. + B: + name: Through the Glass + blurb: You see the room in the mirror. Left and right are swapped. And some things have changed. +intro: | + An antique mirror in a junk shop. One of you leans in too close... and is suddenly standing inside it. + + **Through the Glass sees every room flipped left to right.** The differences are subtle: a green apple + for a red one, a new moon for a smiling one, a clock that's half an hour off. + + Describe, find, and **both tap the same square** (in your own view) within 15 seconds to claim it. + Wrong claims cost 30 seconds. No coordinates. Eight differences per room. Good luck. +twin: + claimWindowSeconds: 15 + wrongClaimPenaltySeconds: 30 + mirrorB: true # B's grid is shown left-right mirrored; server unmirrors B's taps (x → width − 1 − x) + showCoordinates: false + +scenes: + - id: orchard + title: "The Mirror Orchard" + timeSeconds: 300 + keyName: Apple Key + differences: 8 + grid: # shown to A (and to B with changes applied) + - "🐛 🌸 🪵 🐛 🍐 🌿 🪺 🍏 🍂 🐝" + - "🍎 🍐 🌿 🌳 🍎 🌿 🍂 🍃 🍎 🪵" + - "🍃 🐦 🌿 🍐 🪵 🪺 🌿 🍎 🌳 🌿" + - "🍂 🍐 🐦 🍂 🌳 🌳 🌿 🍃 🌳 🐝" + - "🍒 🐝 🍂 🐝 🍃 🍏 🐝 🍒 🌳 🌸" + - "🌳 🍒 🪵 🪵 🐦 🍃 🐝 🍒 🪵 🌳" + - "🌸 🍂 🪵 🪵 🍏 🍂 🐦 🍂 🐦 🍒" + - "🍃 🍏 🍃 🍐 🍐 🍎 🍏 🪵 🌿 🌸" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 3, y: 0, to: "🐌" } + - { x: 0, y: 2, to: "🌿" } + - { x: 3, y: 2, to: "🍋" } + - { x: 5, y: 4, to: "🍎" } + - { x: 2, y: 5, to: "🪨" } + - { x: 9, y: 6, to: "🍓" } + - { x: 1, y: 7, to: "🍎" } + - { x: 5, y: 7, to: "🍏" } + - id: night + title: "Midnight Garden" + timeSeconds: 300 + keyName: Moon Key + differences: 8 + grid: # shown to A (and to B with changes applied) + - "🌹 🐈 🔮 🐈‍⬛ 🌟 🌚 🐈 🥀 🦉 🐈‍⬛" + - "🔮 🐈 🥀 🌟 🐈 🦉 🐈 🦉 🌹 🐈‍⬛" + - "⭐ 🌚 🔮 🦉 🌚 🕯 ⭐ 🌚 🌹 🌑" + - "🔮 🦇 🥀 🔮 ⭐ 🌹 🌹 🌚 🐈‍⬛ 🦇" + - "🦉 🐈 🗝 🌚 🦉 🦇 ⭐ 🦉 🦉 🌚" + - "🌑 🕯 🗝 🔮 🦉 🕯 🌑 🌑 🌚 🌟" + - "🌑 🦉 🌚 🌚 🌹 🐈‍⬛ 🐈 🗝 🦇 🦇" + - "🔮 🌙 🐈 🌹 🌹 🥀 🐈 🌑 🕯 🦇" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 5, y: 1, to: "🦇" } + - { x: 7, y: 2, to: "🌑" } + - { x: 7, y: 4, to: "🦇" } + - { x: 4, y: 5, to: "🦇" } + - { x: 9, y: 5, to: "⭐" } + - { x: 4, y: 6, to: "🥀" } + - { x: 6, y: 7, to: "🐈‍⬛" } + - { x: 7, y: 7, to: "🌚" } + - id: clocks + title: "The Hall of Clocks" + timeSeconds: 300 + keyName: Clock Key + differences: 8 + grid: # shown to A (and to B with changes applied) + - "🕚 🕔 🕖 🕔 ⌛ 🕕 🕐 🕛 🕛 🕖" + - "🕐 🕔 🕛 🕕 🕔 🕛 ⏳ ⏳ 🕑 🕔" + - "⏳ 🕖 🕕 🕙 🕐 🕘 🕕 🕓 🕘 🕐" + - "🕛 🕑 🕒 🕙 🕑 🕑 🕐 🕒 🕘 🕕" + - "🕙 🕚 🕚 🕔 🕐 🕖 🕙 🕒 🕕 🕖" + - "🕖 🕖 🕓 ⏳ 🕖 🕚 🕓 ⏳ 🕒 🕒" + - "🕘 🕐 🕚 🕛 🕘 🕚 🕘 🕖 🕔 ⏳" + - "🕒 🕓 🕐 🕐 🕔 🕘 🕗 🕖 🕔 🕕" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 4, y: 0, to: "⏳" } + - { x: 1, y: 1, to: "🕠" } + - { x: 7, y: 1, to: "⌛" } + - { x: 2, y: 2, to: "🕡" } + - { x: 3, y: 3, to: "🕥" } + - { x: 5, y: 6, to: "🕦" } + - { x: 0, y: 7, to: "🕞" } + - { x: 5, y: 7, to: "🕤" } + +successEnding: | + Every difference found. You blink, and you're both back on the same side of the screen, looking at each other. + + **Same wavelength. Same room.** 🪞🔑 diff --git a/content/games/twin-sunday-morning.yaml b/content/games/twin-sunday-morning.yaml new file mode 100644 index 0000000..f2b5e7f --- /dev/null +++ b/content/games/twin-sunday-morning.yaml @@ -0,0 +1,84 @@ +id: twin-sunday-morning +engine: twin +category: co-op +title: "Twin Rooms: Sunday Morning" +tagline: Two cosy rooms. Almost identical. Find what's different without peeking at each other's screen. +estimatedMinutes: 10 +difficulty: 1 +players: { min: 2, max: 2 } +theme: { primary: "#e76f51", accent: "#f4a261", background: "#14100d", headingFont: "Playfair Display" } +roles: + A: + name: Partner A + blurb: You see one version of each room. + B: + name: Partner B + blurb: You see the other version. Same room... mostly. +intro: | + You each wake up in the same house, on the same lazy Sunday. Except it isn't quite the same. + + **Describe what you see, square by square.** When you think you've found a difference, + you **both tap that same square** within 15 seconds to claim it. + Find every difference in a room to earn its key. + + Tapping the same square when it **isn't** a difference costs you 15 seconds. No screen-peeking! +twin: + claimWindowSeconds: 15 + wrongClaimPenaltySeconds: 15 + mirrorB: false + showCoordinates: true # row letters + column numbers around the grid, to help you describe squares + +scenes: + - id: kitchen + title: "Breakfast Kitchen" + timeSeconds: 180 + keyName: Pancake Key + differences: 4 + grid: # shown to A (and to B with changes applied) + - "☕ 🍞 🍊 🍊 🍌 ☕" + - "🍓 🥄 🥄 🍊 🧈 🥄" + - "🍊 🍽 🍯 🥄 🍯 🍓" + - "🍳 🥄 ☕ ☕ 🥛 ☕" + - "🍯 🍳 🍌 🍯 🍌 🍞" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 0, y: 1, to: "🍒" } + - { x: 2, y: 1, to: "🍴" } + - { x: 5, y: 1, to: "🍴" } + - { x: 0, y: 2, to: "🍋" } + - id: garden + title: "The Back Garden" + timeSeconds: 180 + keyName: Garden Key + differences: 4 + grid: # shown to A (and to B with changes applied) + - "🪴 🐌 🐞 🌼 🪨 🌳" + - "🐞 🪴 🌳 🌼 🌼 🌷" + - "🌷 🍄 🐝 🪨 🐞 🐞" + - "🐦 🌷 🌻 🐞 🪴 🪴" + - "🍄 🐌 🌳 🐌 🐦 🌼" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 3, y: 0, to: "🌸" } + - { x: 1, y: 2, to: "🌰" } + - { x: 3, y: 2, to: "🧱" } + - { x: 3, y: 4, to: "🐢" } + - id: bedroom + title: "Lazy Bedroom" + timeSeconds: 180 + keyName: Pillow Key + differences: 4 + grid: # shown to A (and to B with changes applied) + - "🧦 🖼 📱 🎧 🧦 🪟" + - "🎧 🖼 📱 🧦 🧦 🛏" + - "🖼 🕯 📚 🛏 ⏰ 🧦" + - "🪴 🧸 ⏰ 🖼 🎧 📱" + - "📚 📱 🕯 🧸 🪴 ⏰" + changes: # SERVER ONLY: B's version differs at these cells + - { x: 2, y: 2, to: "📖" } + - { x: 3, y: 2, to: "🛋" } + - { x: 3, y: 3, to: "🪞" } + - { x: 4, y: 3, to: "📻" } + +successEnding: | + Every difference found. You blink, and you're both back on the same side of the screen, looking at each other. + + **Same wavelength. Same room.** 🪞🔑 diff --git a/content/games/wavelength-date-night.yaml b/content/games/wavelength-date-night.yaml new file mode 100644 index 0000000..e4ce810 --- /dev/null +++ b/content/games/wavelength-date-night.yaml @@ -0,0 +1,74 @@ +id: wavelength-date-night +engine: dial +category: co-op +title: "Wavelength: Date Night" +tagline: Every spectrum is about you two. Turns out you might not agree on what counts as romantic. +estimatedMinutes: 15 +difficulty: 2 +players: { min: 2, max: 2 } +theme: { primary: "#ef476f", accent: "#ffd6e0", background: "#14060a", headingFont: "Playfair Display" } +roles: + A: + name: Partner A + blurb: You give the clue in odd rounds and guess in even rounds. + B: + name: Partner B + blurb: You guess in odd rounds and give the clue in even rounds. +intro: | + Same rules as Tune In, but every dial is about relationships, dates, and the two of you. + **Terrible first date ↔ Perfect first date.** **Ick ↔ Green flag.** You'll learn things. + + Clues are up to **3 words**. Normal scoring bands. 8 rounds, three keys. Expect debate after every reveal. +dial: + rounds: 8 + range: [0, 100] + targetMargin: 5 # hidden target is a random integer from 5 to 95 + scoring: # distance from target → points (first band that fits) + - { within: 4, points: 4 } + - { within: 9, points: 3 } + - { within: 14, points: 2 } + clue: + maxWords: 3 + maxChars: 30 + banLabelWords: true # clue can't contain any word from either label (normalized, case-insensitive) + noRepeatWords: false # if true, no clue word may be reused within the game + guesserSeesLabels: both # both | left | none + guesserSeesNumbers: true # whether the dial shows 0–100 tick numbers to the guesser + keys: + - { at: 8, name: Spark Key } + - { at: 14, name: Flame Key } + - { at: 20, name: Forever Key } +spectra: + - { id: d01, left: "Terrible first date", right: "Perfect first date" } + - { id: d02, left: "Unromantic", right: "Very romantic" } + - { id: d03, left: "Cheap date", right: "Fancy date" } + - { id: d04, left: "Bad gift", right: "Amazing gift" } + - { id: d05, left: "Ick", right: "Green flag" } + - { id: d06, left: "Boring couple activity", right: "Thrilling couple activity" } + - { id: d07, left: "Awkward thing to say on a date", right: "Smooth thing to say on a date" } + - { id: d08, left: "Lazy Sunday", right: "Busy Sunday" } + - { id: d09, left: "Chore I'd do", right: "Chore I'd never do" } + - { id: d10, left: "Snack to share", right: "Snack to keep to myself" } + - { id: d11, left: "Normal pet name", right: "Cringe pet name" } + - { id: d12, left: "Bad movie for date night", right: "Perfect movie for date night" } + - { id: d13, left: "Easy to forgive", right: "Hard to forgive" } + - { id: d14, left: "Friends-zone energy", right: "Soulmate energy" } + - { id: d15, left: "Low-effort surprise", right: "High-effort surprise" } + - { id: d16, left: "Cosy", right: "Adventurous" } + - { id: d17, left: "Something I'd never do for you", right: "Something I'd always do for you" } + - { id: d18, left: "Our quiet night", right: "Our wild night" } + - { id: d19, left: "Bad holiday destination", right: "Dream holiday destination" } + - { id: d20, left: "Small argument", right: "Big argument" } + - { id: d21, left: "Polite", right: "Brutally honest" } + - { id: d22, left: "Morning person thing", right: "Night owl thing" } + - { id: d23, left: "Stay in", right: "Go out" } + - { id: d24, left: "Unimportant anniversary", right: "Huge anniversary" } + - { id: d25, left: "Normal habit", right: "Weird habit" } + - { id: d26, left: "Hated chore", right: "Loved chore" } + - { id: d27, left: "Bad wedding song", right: "Perfect wedding song" } + - { id: d28, left: "Cheap way to say I love you", right: "Expensive way to say I love you" } + - { id: d29, left: "Overshare", right: "Keep it private" } + - { id: d30, left: "Calm", right: "Chaotic" } + +successEnding: | + Date night: certified. 🍷🔑 diff --git a/content/games/wavelength-static.yaml b/content/games/wavelength-static.yaml new file mode 100644 index 0000000..9a38b7f --- /dev/null +++ b/content/games/wavelength-static.yaml @@ -0,0 +1,77 @@ +id: wavelength-static +engine: dial +category: co-op +title: "Wavelength: Static" +tagline: One-word clues. Narrow targets. And the guesser can only see half the dial. +estimatedMinutes: 20 +difficulty: 5 +players: { min: 2, max: 2 } +theme: { primary: "#8338ec", accent: "#3a86ff", background: "#06040d", headingFont: "JetBrains Mono" } +roles: + A: + name: Partner A + blurb: You give the clue in odd rounds and guess in even rounds. + B: + name: Partner B + blurb: You guess in odd rounds and give the clue in even rounds. +intro: | + ☠️ **Nightmare rules.** + - Clues are **exactly one word**, and you can **never reuse a word** used earlier in the game. + - The guesser only sees the **left** label. The right label is lost in static. Work it out from the clue... and from each other. + - The guesser's dial has **no numbers**. + - Scoring bands are razor thin. 10 rounds. Three keys. +dial: + rounds: 10 + range: [0, 100] + targetMargin: 5 # hidden target is a random integer from 5 to 95 + scoring: # distance from target → points (first band that fits) + - { within: 2, points: 4 } + - { within: 5, points: 3 } + - { within: 8, points: 2 } + clue: + maxWords: 1 + maxChars: 30 + banLabelWords: true # clue can't contain any word from either label (normalized, case-insensitive) + noRepeatWords: true # if true, no clue word may be reused within the game + guesserSeesLabels: left # both | left | none + guesserSeesNumbers: false # whether the dial shows 0–100 tick numbers to the guesser + keys: + - { at: 12, name: Static Key } + - { at: 20, name: Signal Key } + - { at: 26, name: Clear Channel Key } +spectra: + - { id: s01, left: "Cold", right: "Hot" } + - { id: s02, left: "Ordinary", right: "Magical" } + - { id: s03, left: "Past", right: "Future" } + - { id: s04, left: "Bitter", right: "Sweet" } + - { id: s05, left: "Simple", right: "Complicated" } + - { id: s06, left: "Silent", right: "Deafening" } + - { id: s07, left: "Fake", right: "Genuine" } + - { id: s08, left: "Temporary", right: "Permanent" } + - { id: s09, left: "Safe", right: "Dangerous" } + - { id: s10, left: "Nobody knows it", right: "Everybody knows it" } + - { id: s11, left: "Square", right: "Round" } + - { id: s12, left: "Fiction", right: "Fact" } + - { id: s13, left: "Smooth", right: "Rough" } + - { id: s14, left: "Light", right: "Heavy" } + - { id: s15, left: "Weird", right: "Normal" } + - { id: s16, left: "Sacred", right: "Silly" } + - { id: s17, left: "Harmless", right: "Deadly" } + - { id: s18, left: "Bad advice", right: "Good advice" } + - { id: s19, left: "Introvert", right: "Extrovert" } + - { id: s20, left: "Ancient", right: "Brand new" } + - { id: s21, left: "Dull", right: "Shiny" } + - { id: s22, left: "Soft skill", right: "Hard skill" } + - { id: s23, left: "Mainstream", right: "Niche" } + - { id: s24, left: "Peaceful", right: "Violent" } + - { id: s25, left: "Tasteless", right: "Delicious" } + - { id: s26, left: "Low quality", right: "High quality" } + - { id: s27, left: "Calm", right: "Panicked" } + - { id: s28, left: "Childish", right: "Mature" } + - { id: s29, left: "Guilty pleasure", right: "Proud pleasure" } + - { id: s30, left: "Overpriced", right: "Bargain" } + - { id: s31, left: "Messy", right: "Tidy" } + - { id: s32, left: "Rural", right: "Urban" } + +successEnding: | + Through all that static, you still found each other. 📡🔑 diff --git a/content/games/wavelength-tune-in.yaml b/content/games/wavelength-tune-in.yaml new file mode 100644 index 0000000..00256ee --- /dev/null +++ b/content/games/wavelength-tune-in.yaml @@ -0,0 +1,79 @@ +id: wavelength-tune-in +engine: dial +category: co-op +title: "Wavelength: Tune In" +tagline: One of you knows where the needle should land. One clue. Can you read each other's minds? +estimatedMinutes: 10 +difficulty: 1 +players: { min: 2, max: 2 } +theme: { primary: "#06d6a0", accent: "#ffd166", background: "#04120d", headingFont: "Playfair Display" } +roles: + A: + name: Partner A + blurb: You give the clue in odd rounds and guess in even rounds. + B: + name: Partner B + blurb: You guess in odd rounds and give the clue in even rounds. +intro: | + Each round, a dial appears with two opposites, like **Cold ↔ Hot**. The **clue-giver** secretly sees where + the target sits on the dial and types a short clue (up to 5 words), something that sits at that spot. + The **guesser** turns the dial to where they think the target is, then locks in. + + Example: target near "Hot" → clue "a fresh cup of coffee". Target in the middle → "a bath you forgot about". + + Wide scoring bands. 6 rounds. Two keys to win. No talking during a round, just the clue! +dial: + rounds: 6 + range: [0, 100] + targetMargin: 5 # hidden target is a random integer from 5 to 95 + scoring: # distance from target → points (first band that fits) + - { within: 5, points: 4 } + - { within: 10, points: 3 } + - { within: 15, points: 2 } + - { within: 20, points: 1 } + clue: + maxWords: 5 + maxChars: 30 + banLabelWords: true # clue can't contain any word from either label (normalized, case-insensitive) + noRepeatWords: false # if true, no clue word may be reused within the game + guesserSeesLabels: both # both | left | none + guesserSeesNumbers: true # whether the dial shows 0–100 tick numbers to the guesser + keys: + - { at: 8, name: Tuning Key } + - { at: 14, name: Frequency Key } +spectra: + - { id: g01, left: "Cold", right: "Hot" } + - { id: g02, left: "Cheap", right: "Expensive" } + - { id: g03, left: "Useless", right: "Useful" } + - { id: g04, left: "Boring", right: "Exciting" } + - { id: g05, left: "Quiet", right: "Loud" } + - { id: g06, left: "Tiny", right: "Huge" } + - { id: g07, left: "Healthy snack", right: "Unhealthy snack" } + - { id: g08, left: "Easy to spell", right: "Hard to spell" } + - { id: g09, left: "Scary", right: "Cute" } + - { id: g10, left: "Old-fashioned", right: "Modern" } + - { id: g11, left: "Overrated", right: "Underrated" } + - { id: g12, left: "Smells bad", right: "Smells good" } + - { id: g13, left: "Rare", right: "Common" } + - { id: g14, left: "Bad movie", right: "Great movie" } + - { id: g15, left: "Soft", right: "Hard" } + - { id: g16, left: "Normal pet", right: "Weird pet" } + - { id: g17, left: "Sad song", right: "Happy song" } + - { id: g18, left: "Easy job", right: "Hard job" } + - { id: g19, left: "Fragile", right: "Unbreakable" } + - { id: g20, left: "Breakfast food", right: "Dinner food" } + - { id: g21, left: "Forgettable", right: "Unforgettable" } + - { id: g22, left: "Slow", right: "Fast" } + - { id: g23, left: "Ugly", right: "Beautiful" } + - { id: g24, left: "Villain", right: "Hero" } + - { id: g25, left: "Dry", right: "Wet" } + - { id: g26, left: "Mild", right: "Spicy" } + - { id: g27, left: "Lazy", right: "Hardworking" } + - { id: g28, left: "Indoor activity", right: "Outdoor activity" } + - { id: g29, left: "Kid's toy", right: "Adult's toy" } + - { id: g30, left: "Waste of money", right: "Worth every cent" } + - { id: g31, left: "Weak superpower", right: "Strong superpower" } + - { id: g32, left: "Short-lived", right: "Lasts forever" } + +successEnding: | + You two are on the same frequency. 📻🔑 diff --git a/package-lock.json b/package-lock.json index b4b02dc..84fdd82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "two-keys", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "two-keys", - "version": "1.2.2", + "version": "1.3.0", "workspaces": [ "shared", "server", @@ -23,7 +23,7 @@ }, "client": { "name": "@two-keys/client", - "version": "1.2.2", + "version": "1.3.0", "dependencies": { "@two-keys/shared": "*", "framer-motion": "^11.11.0", @@ -6847,7 +6847,7 @@ }, "server": { "name": "@two-keys/server", - "version": "1.2.2", + "version": "1.3.0", "dependencies": { "@two-keys/shared": "*", "express": "^4.21.0", @@ -7332,7 +7332,7 @@ }, "shared": { "name": "@two-keys/shared", - "version": "1.2.2", + "version": "1.3.0", "devDependencies": { "vitest": "^2.1.0" } diff --git a/package.json b/package.json index 49c0efb..da0a13b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "two-keys", "private": true, - "version": "1.2.2", + "version": "1.3.0", "description": "Co-op mystery games for couples", "workspaces": [ "shared", diff --git a/server/package.json b/server/package.json index f841233..1a10afa 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/server", - "version": "1.2.2", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/server/src/content/loader.ts b/server/src/content/loader.ts index 552a264..dd6f793 100644 --- a/server/src/content/loader.ts +++ b/server/src/content/loader.ts @@ -33,7 +33,9 @@ export function loadGameFile(file: string): LoadedFile { } catch (err) { return { file: name, game: null, errors: [`YAML parse error: ${(err as Error).message}`] }; } - const parsed = gameSchema.safeParse(raw); + const borrowed = borrowManual(file, raw); + if (borrowed.error) return { file: name, game: null, errors: [borrowed.error] }; + const parsed = gameSchema.safeParse(borrowed.raw); if (!parsed.success) { const errors = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`); return { file: name, game: null, errors }; @@ -45,6 +47,28 @@ export function loadGameFile(file: string): LoadedFile { return { file: name, game, errors }; } +/** What a defuse game with `manualFrom` takes from that game's file. */ +const MANUAL_FIELDS = ['ruleset', 'device', 'modules', 'manual', 'fixtures'] as const; + +/** `manualFrom: ` reuses that game's device, modules, manual, ruleset and fixtures (and so its solver). */ +function borrowManual(file: string, raw: unknown): { raw: unknown; error?: string } { + const own = raw as Record | null; + if (!own || typeof own !== 'object' || own.engine !== 'defuse' || typeof own.manualFrom !== 'string') return { raw }; + const source = path.join(path.dirname(file), `${own.manualFrom}.yaml`); + let base: Record; + try { + base = yaml.load(fs.readFileSync(source, 'utf8')) as Record; + } catch { + return { raw, error: `manualFrom: game "${own.manualFrom}" is missing or unreadable` }; + } + if (base?.engine !== 'defuse' || base.manualFrom) { + return { raw, error: `manualFrom: "${own.manualFrom}" must be a defuse game with its own manual` }; + } + const merged = { ...own }; + for (const field of MANUAL_FIELDS) if (field in base) merged[field] = base[field]; + return { raw: merged }; +} + export function loadAllFiles(dir = gamesDir()): LoadedFile[] { return fs .readdirSync(dir) diff --git a/server/src/content/sanitize.ts b/server/src/content/sanitize.ts index f99a9c1..d8b3714 100644 --- a/server/src/content/sanitize.ts +++ b/server/src/content/sanitize.ts @@ -1,10 +1,12 @@ // Builds everything that leaves the server. Views are assembled field by field (never by // copying a content object and deleting from it), so answers can't leak by accident. import type { + BlueprintView, ChapterView, ClueView, DefuseModuleView, DefuseView, + DialView, Dir, EngineView, GameMeta, @@ -15,10 +17,13 @@ import type { Role, StoryView, SyncView, + TwinView, } from '@two-keys/shared'; import { otherRole } from '@two-keys/shared'; -import { maxStrikes, remainingAt, toSignal, type DefuseState } from '../engines/defuse'; -import { totalKeys } from '../engines/index'; +import { architectOf, patternRemaining, peeking, type BlueprintState } from '../engines/blueprint'; +import { maxStrikes, remainingAt, technicianOf, toSignal, wireModules, type DefuseState } from '../engines/defuse'; +import { giverOf, maxTotal, spectrumOf, type DialState } from '../engines/dial'; +import { timeLimitSeconds, totalKeys } from '../engines/index'; import { DELTAS, OPPOSITE, @@ -30,10 +35,31 @@ import { toAbsolute, type LabyrinthState, } from '../engines/labyrinth'; -import { NUDGE_AFTER_WRONG, hintPenalty, timeLimitSeconds, type StoryState } from '../engines/story'; +import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story'; import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync'; +import { + canRetry, + flipX, + gridA, + gridB, + liveTap, + mirrored, + sceneRemaining, + type TwinState, +} from '../engines/twin'; import type { Room } from '../rooms/store'; -import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas'; +import type { + BlueprintGame, + Clue, + DefuseGame, + DialGame, + Game, + LabyrinthGame, + Lock, + StoryGame, + SyncGame, + TwinGame, +} from './schemas'; export function gameSummary(game: Game): GameSummary { return { @@ -47,7 +73,7 @@ export function gameSummary(game: Game): GameSummary { theme: { ...game.theme }, roles: { A: { ...game.roles.A }, B: { ...game.roles.B } }, totalKeys: totalKeys(game), - timeLimitMinutes: game.engine === 'story' ? (game.rules.timeLimitMinutes ?? null) : null, + timeLimitMinutes: timeLimitSeconds(game) === null ? null : timeLimitSeconds(game)! / 60, }; } @@ -147,6 +173,8 @@ const MORSE_BAND = { minKhz: 3500, maxKhz: 3600 }; export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: number): DefuseView { const level = game.levels[s.level]; + const technician = technicianOf(game, s.level); + const ready = s.swapReady ?? []; const common = { engine: 'defuse' as const, level: { index: s.level, total: game.levels.length, title: level.title, timeSeconds: level.timeSeconds }, @@ -157,16 +185,21 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu clock: s.clock ? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' } : null, + technician, + swapRoles: game.swapRolesEachLevel, + swapReady: { you: ready.includes(role), partner: ready.includes(otherRole(role)) }, }; // The Expert gets the manual and nothing about the device. - if (role === 'B') return { ...common, seat: 'expert', manual: game.manual }; + if (role !== technician) return { ...common, seat: 'expert', manual: game.manual }; // The Technician gets the device and never the manual. const d = s.device; if (!d) return { ...common, seat: 'technician', device: null }; const modules: DefuseModuleView[] = []; - if (d.wires) modules.push({ id: 'wires', solved: d.wires.solved, wires: [...d.wires.colors] }); + for (const { instance, title, module } of wireModules(d)) { + modules.push({ id: 'wires', instance, title, solved: module.solved, wires: [...module.colors] }); + } if (d.button) { modules.push({ id: 'button', @@ -286,6 +319,9 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role y: g.y, dir: mirror && (g.dir === 'e' || g.dir === 'w') ? OPPOSITE[g.dir] : g.dir, })), + // Dimmed once picked up; the Navigator still only hears about it from the Explorer. + items: game.items.map((it) => ({ x: fx(it.x), y: it.y, name: it.name, collected: (s.inventory ?? []).includes(it.id) })), + doors: game.doors.map((d) => ({ x: fx(d.x), y: d.y, name: d.name })), banner: game.navigatorView.banner ?? null, keysCarried, totalKeys: total, @@ -321,6 +357,110 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role message: s.message || (glyph ? '' : game.explorerView.flavour.blank), moves: s.moves, falls: s.falls, + // Names only: where items and doors are stays on the Navigator's map. + inventory: (s.inventory ?? []).map((id) => game.items.find((it) => it.id === id)?.name ?? id), + }; +} + +// ---------- dial ---------- + +export function dialView(game: DialGame, s: DialState, role: Role): DialView { + const giver = giverOf(s.round); + const iAmGiver = role === giver; + const revealed = s.status === 'reveal'; + const { left, right } = spectrumOf(game, s); + // The guesser may only see some labels, and never the target before locking in. + const sees = iAmGiver || revealed ? 'both' : game.dial.guesserSeesLabels; + return { + engine: 'dial', + status: s.status, + round: s.round, + rounds: game.dial.rounds, + giver, + iAmGiver, + left: sees === 'none' ? null : left, + right: sees === 'both' ? right : null, + showNumbers: iAmGiver || revealed || game.dial.guesserSeesNumbers, + range: [...game.dial.range], + bands: game.dial.scoring.map((b) => ({ within: b.within, points: b.points })), + rules: { ...game.dial.clue }, + usedWords: [...s.usedWords], + clue: iAmGiver || s.status !== 'clue' ? s.clue : null, + target: iAmGiver || revealed ? s.target : null, + // The live needle stays with the guesser: no "warmer/colder" for the clue-giver. + needle: iAmGiver ? null : s.needle, + guess: revealed ? s.guess : null, + points: revealed ? s.points : null, + total: s.total, + maxTotal: maxTotal(game), + keys: game.dial.keys.map((k) => ({ at: k.at, name: k.name, earned: s.total >= k.at })), + history: s.history.map((h) => ({ ...h })), + }; +} + +// ---------- twin ---------- + +export function twinView(game: TwinGame, s: TwinState, role: Role, now: number): TwinView { + const scene = game.scenes[s.scene]; + // Each player gets only their own room; `changes` never leave the server. + const own = role === 'A' ? gridA(scene) : gridB(scene); + const grid = mirrored(game, role) ? own.map((row) => [...row].reverse()) : own; + const tap = s.status === 'playing' ? liveTap(game, s.taps[role], now) : null; + const theirs = s.status === 'playing' ? liveTap(game, s.taps[otherRole(role)], now) : null; + const left = (at: number) => Math.max(0, game.twin.claimWindowSeconds - (now - at) / 1000); + return { + engine: 'twin', + status: s.status, + scene: { + index: s.scene, + total: game.scenes.length, + title: scene.title, + keyName: scene.keyName, + differences: scene.changes.length, + }, + grid, + showCoordinates: game.twin.showCoordinates, + found: s.found.map((i) => ({ x: flipX(game, scene, role, scene.changes[i].x), y: scene.changes[i].y })), + myTap: tap ? { x: flipX(game, scene, role, tap.x), y: tap.y, secondsLeft: left(tap.at) } : null, + partnerTap: theirs ? { secondsLeft: left(theirs.at) } : null, + remaining: Math.max(0, sceneRemaining(game, s, now)), + timeSeconds: scene.timeSeconds, + claimWindowSeconds: game.twin.claimWindowSeconds, + wrongClaimPenaltySeconds: game.twin.wrongClaimPenaltySeconds, + canRetry: canRetry(s), + isRetry: s.attempt > 0, + }; +} + +// ---------- blueprint ---------- + +export function blueprintView(game: BlueprintGame, s: BlueprintState, role: Role, now: number): BlueprintView { + const p = game.patterns[s.pattern]; + const architect = architectOf(game, s.pattern); + const isArchitect = role === architect; + const scored = s.status === 'scored'; + return { + engine: 'blueprint', + status: s.status, + seat: isArchitect ? 'architect' : 'builder', + architect, + pattern: { index: s.pattern, total: game.patterns.length, size: p.size, keyName: p.keyName }, + palette: Object.entries(p.palette).map(([key, c]) => ({ key, name: c.name, hex: c.hex })), + // The Builder never gets the picture or its name until it's scored. + target: isArchitect || scored ? [...p.rows] : null, + title: isArchitect || scored ? p.title : null, + // The Architect only sees the canvas during a peek, or once it's scored. + canvas: !isArchitect || scored || peeking(s, now) ? [...s.canvas] : null, + rotateBuilder: game.blueprint.rotateBuilder, + peeksLeft: Math.max(0, p.peeks - s.peeksUsed), + peekUntil: isArchitect && peeking(s, now) ? s.peekUntil : null, + submitsLeft: Math.max(0, game.blueprint.submitsPerPattern - s.submits), + threshold: p.threshold, + bestScore: s.bestScore, + lastScore: s.lastScore, + passed: s.passed, + remaining: Math.max(0, patternRemaining(game, s, now)), + timeSeconds: p.timeSeconds, }; } @@ -334,5 +474,11 @@ export function engineView(game: Game, room: Room, role: Role, now: number): Eng return syncView(game, room.engine as SyncState, role); case 'labyrinth': return labyrinthView(game, room.engine as LabyrinthState, role); + case 'dial': + return dialView(game, room.engine as DialState, role); + case 'twin': + return twinView(game, room.engine as TwinState, role, now); + case 'blueprint': + return blueprintView(game, room.engine as BlueprintState, role, now); } } diff --git a/server/src/content/schemas.ts b/server/src/content/schemas.ts index d5cdca3..c37938a 100644 --- a/server/src/content/schemas.ts +++ b/server/src/content/schemas.ts @@ -30,6 +30,12 @@ const clueSchema = z.object({ .optional(), }); +/** A hard limit on the whole game's clock (elapsed + penalties). Running out ends the game. */ +const timeLimitRules = { + timeLimitMinutes: z.number().positive().optional(), + timeLimitLabel: z.string().min(1).optional(), +}; + // Strict: an unquoted comma in `{ id, label: a, b }` adds a stray `b` key instead of failing. const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) }).strict(); @@ -93,9 +99,7 @@ const storySchema = z.object({ rules: z .object({ hintPenaltySeconds: z.number().int().nonnegative().optional(), - /** Hard limit on the clock (elapsed + penalties). Running out ends the game. */ - timeLimitMinutes: z.number().positive().optional(), - timeLimitLabel: z.string().min(1).optional(), + ...timeLimitRules, }) .default({}), suspects: z @@ -116,6 +120,10 @@ const defuseSchema = z.object({ engine: z.literal('defuse'), /** Which manual the solver implements. Each manual's rules live in engines/defuse.ts. */ ruleset: z.enum(['classic', 'overtime']).default('classic'), + /** The game whose device, modules, manual, ruleset and fixtures this one reuses (filled in by the loader). */ + manualFrom: z.string().optional(), + /** Level 1: A is the Technician; level 2: B; and so on. */ + swapRolesEachLevel: z.boolean().default(false), device: z.object({ serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }), batteries: range, @@ -156,6 +164,8 @@ const defuseSchema = z.object({ simon: z.object({ colors: z.array(z.string()).min(2), stages: range }).optional(), }), manual: z.string().min(1), + /** Shown when the last level is cleared. */ + successEnding: z.string().min(1).optional(), fixtures: z.object({ wires: z.array( z.object({ @@ -239,6 +249,8 @@ const syncSchema = z.object({ predict: z.array(questionSchema).default([]), telepathy: z.array(telepathySchema).default([]), rank: z.array(rankSchema).default([]), + /** Shown when every key is earned. */ + successEnding: z.string().min(1).optional(), }); // ---------- labyrinth ---------- @@ -256,6 +268,23 @@ const labyrinthSchema = z.object({ lanternsRequired: z.number().int().positive().default(3), /** One-way gates: you may step from (x, y) in `dir`, never back the other way. */ oneWay: z.array(z.object({ x: z.number().int(), y: z.number().int(), dir })).default([]), + /** Picked up on entry and kept after traps. */ + items: z + .array(z.object({ x: z.number().int(), y: z.number().int(), id: z.string().min(1), name: z.string().min(1), explorerText: z.string().min(1) })) + .default([]), + /** Impassable until the Explorer carries the item `needs`. */ + doors: z + .array( + z.object({ + x: z.number().int(), + y: z.number().int(), + needs: z.string().min(1), + name: z.string().min(1), + lockedText: z.string().min(1), + openText: z.string().min(1), + }), + ) + .default([]), explorer: z .object({ /** `relative` swaps N/E/S/W for forward/left/right/back from the way the Explorer faces. */ @@ -283,17 +312,118 @@ const labyrinthSchema = z.object({ successEnding: z.string().min(1), }); +// ---------- dial (Wavelength) ---------- + +const dialSchema = z.object({ + ...header, + engine: z.literal('dial'), + rules: z.object(timeLimitRules).default({}), + dial: z.object({ + rounds: z.number().int().positive(), + range: z.tuple([z.number().int(), z.number().int()]), + /** The hidden target is a random integer at least this far inside the range. */ + targetMargin: z.number().int().nonnegative(), + /** Distance from the target → points; the first band that fits wins, outside all = 0. */ + scoring: z.array(z.object({ within: z.number().nonnegative(), points: z.number().int().positive() })).min(1), + clue: z.object({ + maxWords: z.number().int().positive(), + maxChars: z.number().int().positive(), + banLabelWords: z.boolean(), + noRepeatWords: z.boolean(), + }), + guesserSeesLabels: z.enum(['both', 'left', 'none']), + guesserSeesNumbers: z.boolean(), + /** Cumulative points thresholds. */ + keys: z.array(z.object({ at: z.number().int().positive(), name: z.string().min(1) })).min(1), + }), + spectra: z.array(z.object({ id: z.string().min(1), left: z.string().min(1), right: z.string().min(1) })).min(1), + successEnding: z.string().min(1), +}); + +// ---------- twin (Twin Rooms) ---------- + +const twinSchema = z.object({ + ...header, + engine: z.literal('twin'), + rules: z.object(timeLimitRules).default({}), + twin: z.object({ + claimWindowSeconds: z.number().positive(), + wrongClaimPenaltySeconds: z.number().nonnegative(), + /** B's room is shown flipped left to right. */ + mirrorB: z.boolean().default(false), + showCoordinates: z.boolean().default(false), + }), + scenes: z + .array( + z.object({ + id: z.string().min(1), + title: z.string().min(1), + timeSeconds: z.number().int().positive(), + keyName: z.string().min(1), + differences: z.number().int().positive().optional(), + /** A's room: one row per string, cells separated by single spaces (never split by character). */ + grid: z.array(z.string().min(1)).min(1), + /** SERVER ONLY: B's room is the grid with these cells replaced. */ + changes: z.array(z.object({ x: z.number().int(), y: z.number().int(), to: z.string().min(1) })).min(1), + }), + ) + .min(1), + successEnding: z.string().min(1), +}); + +// ---------- blueprint (Describe & Draw) ---------- + +const blueprintSchema = z.object({ + ...header, + engine: z.literal('blueprint'), + rules: z.object(timeLimitRules).default({}), + blueprint: z.object({ + scoring: z.literal('overlap'), + /** The Builder's canvas is shown rotated clockwise; the server stores it in target orientation. */ + rotateBuilder: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).default(0), + swapRolesEachPattern: z.boolean().default(false), + submitsPerPattern: z.number().int().positive().default(1), + }), + patterns: z + .array( + z.object({ + id: z.string().min(1), + /** SERVER ONLY for the Builder until the pattern is scored. */ + title: z.string().min(1), + size: z.number().int().min(2).max(16), + timeSeconds: z.number().int().positive(), + threshold: z.number(), + peeks: z.number().int().nonnegative(), + keyName: z.string().min(1), + palette: z.record( + z.string().regex(/^[A-Z]$/, 'palette keys are single capital letters'), + z.object({ name: z.string().min(1), hex }), + ), + /** '.' = empty. SERVER ONLY for the Builder. */ + rows: z.array(z.string()).min(2), + }), + ) + .min(1), + successEnding: z.string().min(1), +}); + export const gameSchema = z.discriminatedUnion('engine', [ storySchema, defuseSchema, syncSchema, labyrinthSchema, + dialSchema, + twinSchema, + blueprintSchema, ]); export type StoryGame = z.infer; export type DefuseGame = z.infer; export type SyncGame = z.infer; export type LabyrinthGame = z.infer; +export type DialGame = z.infer; +export type TwinGame = z.infer; +export type BlueprintGame = z.infer; export type Game = z.infer; export type Clue = z.infer; export type Lock = z.infer; diff --git a/server/src/content/validate.ts b/server/src/content/validate.ts index 9c0cd41..de29e3b 100644 --- a/server/src/content/validate.ts +++ b/server/src/content/validate.ts @@ -10,8 +10,21 @@ import { wireSolution, } from '../engines/defuse'; import { DELTAS, analyseMaze, cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth'; +import { maxTotal } from '../engines/dial'; import { groupOf, pointsForKey } from '../engines/sync'; -import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas'; +import { gridA } from '../engines/twin'; +import type { + BlueprintGame, + Clue, + DefuseGame, + DialGame, + Game, + LabyrinthGame, + Lock, + StoryGame, + SyncGame, + TwinGame, +} from './schemas'; export const MIN_SYNC_QUESTIONS = 15; @@ -26,9 +39,99 @@ export function validateGame(game: Game): string[] { return validateSync(game); case 'labyrinth': return validateLabyrinth(game); + case 'dial': + return validateDial(game); + case 'twin': + return validateTwin(game); + case 'blueprint': + return validateBlueprint(game); } } +/** A one-line summary for the validate-content report. */ +export function describeGame(game: Game): string { + switch (game.engine) { + case 'story': + return `${game.chapters.length} chapters + ${game.finale.type}`; + case 'defuse': + return `${game.levels.length} levels${game.manualFrom ? `, manual from ${game.manualFrom}` : ''}${game.swapRolesEachLevel ? ', roles swap' : ''}`; + case 'sync': + return `${game.rounds.length} rounds: ${game.rounds.map((r) => r.type).join(', ')}`; + case 'labyrinth': { + const traps = findCells(game.grid, 'T').length; + return `${game.grid[0].length}×${game.grid.length}, ${traps} traps, ${game.lanterns.length} lanterns, ${game.oneWay.length} one-way, ${game.doors.length} doors`; + } + case 'dial': + return `${game.dial.rounds} rounds, ${game.spectra.length} spectra, keys at ${game.dial.keys.map((k) => k.at).join('/')} of ${maxTotal(game)}`; + case 'twin': + return `${game.scenes.length} scenes, ${game.scenes.reduce((n, s) => n + s.changes.length, 0)} differences${game.twin.mirrorB ? ', B mirrored' : ''}`; + case 'blueprint': + return `${game.patterns.length} pictures, ${[...new Set(game.patterns.map((p) => p.size))].join('/')} grid${game.blueprint.rotateBuilder ? `, Builder rotated ${game.blueprint.rotateBuilder}°` : ''}`; + } +} + +export const MIN_SPECTRA = 30; + +function validateDial(game: DialGame): string[] { + const errors: string[] = []; + const { dial, spectra } = game; + // Enough for a session that never repeats, with room to spare. + const needed = Math.max(MIN_SPECTRA, dial.rounds * 2); + if (spectra.length < needed) errors.push(`spectra bank has ${spectra.length}, needs at least ${needed}`); + if (new Set(spectra.map((x) => x.id)).size !== spectra.length) errors.push('duplicate spectrum id'); + const [lo, hi] = dial.range; + if (hi - lo <= 2 * dial.targetMargin) errors.push('targetMargin leaves no room for a target'); + const bands = dial.scoring.map((b) => b.within); + if (bands.some((w, i) => i > 0 && w <= bands[i - 1])) errors.push('scoring bands must widen'); + if (dial.scoring.some((b, i) => i > 0 && b.points >= dial.scoring[i - 1].points)) errors.push('wider scoring bands must score less'); + const max = maxTotal(game); + for (const k of dial.keys) if (k.at > max) errors.push(`key "${k.name}" needs ${k.at} points, but ${max} is the most possible`); + if (dial.keys.some((k, i) => i > 0 && k.at <= dial.keys[i - 1].at)) errors.push('key thresholds must go up'); + return errors; +} + +function validateTwin(game: TwinGame): string[] { + const errors: string[] = []; + const ids = new Set(); + for (const scene of game.scenes) { + const where = `scene ${scene.id}`; + if (ids.has(scene.id)) errors.push(`duplicate scene id "${scene.id}"`); + ids.add(scene.id); + const grid = gridA(scene); + const width = grid[0].length; + if (grid.some((row) => row.length !== width)) errors.push(`${where}: rows don't all have ${width} cells`); + if (grid.some((row) => row.some((cell) => cell === ''))) errors.push(`${where}: a row has a double space`); + const seen = new Set(); + for (const c of scene.changes) { + if (c.x < 0 || c.y < 0 || c.y >= grid.length || c.x >= width) { + errors.push(`${where}: change at (${c.x},${c.y}) is off the grid`); + continue; + } + if (grid[c.y][c.x] === c.to) errors.push(`${where}: change at (${c.x},${c.y}) doesn't change anything`); + if (seen.has(`${c.x},${c.y}`)) errors.push(`${where}: two changes at (${c.x},${c.y})`); + seen.add(`${c.x},${c.y}`); + } + if ((scene.differences ?? scene.changes.length) !== scene.changes.length) { + errors.push(`${where}: says ${scene.differences} differences but has ${scene.changes.length} changes`); + } + } + return errors; +} + +function validateBlueprint(game: BlueprintGame): string[] { + const errors: string[] = []; + for (const p of game.patterns) { + const where = `pattern ${p.id}`; + if (p.rows.length !== p.size || p.rows.some((r) => r.length !== p.size)) errors.push(`${where}: rows must be ${p.size}×${p.size}`); + const used = new Set(p.rows.join('').replaceAll('.', '')); + for (const c of used) if (!(c in p.palette)) errors.push(`${where}: colour "${c}" is not in the palette`); + if (used.size === 0) errors.push(`${where}: the picture is empty`); + if (!(p.threshold > 0 && p.threshold <= 1)) errors.push(`${where}: threshold must be above 0 and at most 1`); + } + if (new Set(game.patterns.map((p) => p.id)).size !== game.patterns.length) errors.push('duplicate pattern id'); + return errors; +} + function validateStory(game: StoryGame): string[] { const errors: string[] = []; const ids = new Set(); @@ -118,8 +221,15 @@ function validateDefuse(game: DefuseGame): string[] { }); for (const level of game.levels) { + const from = game.manualFrom ? ` (in ${game.manualFrom})` : ''; for (const id of level.modules) { - if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules`); + if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules${from}`); + } + // Only wires can appear twice on one device (as Wires A and Wires B). + for (const id of new Set(level.modules)) { + if (id !== 'wires' && level.modules.filter((m) => m === id).length > 1) { + errors.push(`level ${level.id}: only wires can appear more than once, "${id}" does`); + } } } if (modules.passwords) { @@ -228,16 +338,39 @@ function validateLabyrinth(game: LabyrinthGame): string[] { if (ends.includes('T')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} sits on a trap`); } + // Items and doors live on plain floor; a door needs an item that exists. + const itemIds = new Set(game.items.map((it) => it.id)); + if (itemIds.size !== game.items.length) errors.push('duplicate item id'); + for (const it of [...game.items, ...game.doors]) { + const c = cellAt(grid, it.x, it.y); + if (c !== '.') errors.push(`"${it.name}" at (${it.x},${it.y}) must be on plain floor (.), not "${c}"`); + } + for (const d of game.doors) { + if (!itemIds.has(d.needs)) errors.push(`door "${d.name}" needs unknown item "${d.needs}"`); + } + const taken = new Set(); + for (const p of [...game.items, ...game.doors]) { + if (taken.has(`${p.x},${p.y}`)) errors.push(`two items or doors share (${p.x},${p.y})`); + taken.add(`${p.x},${p.y}`); + } + if (starts.length === 1) { - const reach = safeReachable(grid, game.oneWay); + // Doors block until their item is carried, so reachability plays the maze out with items. + const reach = + game.doors.length > 0 + ? analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors).safeCells + : safeReachable(grid, game.oneWay); for (const k of keys) { if (!isReachable(reach, k)) errors.push(`lantern at (${k.x},${k.y}) is not reachable from S without a trap`); } for (const e of exits) { if (!isReachable(reach, e)) errors.push(`exit at (${e.x},${e.y}) is not reachable from S without a trap`); } + for (const it of game.items) { + if (!isReachable(reach, it)) errors.push(`item "${it.name}" at (${it.x},${it.y}) is not reachable from S without a trap`); + } if (errors.length === 0) { - const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns); + const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors); if (!winnable) errors.push('no trap-free route collects every lantern and then reaches the exit'); for (const p of stuck) errors.push(`the Explorer can get stuck for good at (${p.x},${p.y})`); } diff --git a/server/src/engines/blueprint.ts b/server/src/engines/blueprint.ts new file mode 100644 index 0000000..724b6f3 --- /dev/null +++ b/server/src/engines/blueprint.ts @@ -0,0 +1,173 @@ +import type { Role } from '@two-keys/shared'; +import type { BlueprintGame } from '../content/schemas'; +import { OK, fail, type ActionResult, type Ctx } from './types'; + +type Pattern = BlueprintGame['patterns'][number]; + +export const PEEK_MS = 3000; +export const EMPTY = '.'; + +export interface BlueprintState { + pattern: number; + status: 'ready' | 'building' | 'scored'; + startedAt: number | null; + /** The Builder's canvas, row by row, in target orientation ('.' = empty). */ + canvas: string[]; + submits: number; + bestScore: number | null; + lastScore: number | null; + passed: boolean | null; + peeksUsed: number; + peekUntil: number | null; +} + +const state = (ctx: Ctx) => ctx.room.engine as BlueprintState; + +const blank = (size: number) => Array.from({ length: size }, () => EMPTY.repeat(size)); + +export function initBlueprint(game: BlueprintGame): BlueprintState { + return { + pattern: 0, + status: 'ready', + startedAt: null, + canvas: blank(game.patterns[0].size), + submits: 0, + bestScore: null, + lastScore: null, + passed: null, + peeksUsed: 0, + peekUntil: null, + }; +} + +/** Pattern 1: A describes. With `swapRolesEachPattern`, the Architect alternates A, B, A... */ +export const architectOf = (game: BlueprintGame, pattern: number): Role => + game.blueprint.swapRolesEachPattern && pattern % 2 === 1 ? 'B' : 'A'; + +/** + * Matched cells (both painted, same colour) over cells painted in either grid. Cells empty in + * both don't count, so a blank canvas never scores. + */ +export function overlapScore(target: readonly string[], canvas: readonly string[]): number { + let matched = 0; + let union = 0; + target.forEach((row, y) => { + [...row].forEach((t, x) => { + const p = canvas[y]?.[x] ?? EMPTY; + if (t === EMPTY && p === EMPTY) return; + union++; + if (t === p) matched++; + }); + }); + return union === 0 ? 0 : matched / union; +} + +/** + * The Builder's canvas shown rotated 90° clockwise: displayed (column c, row r) on an n×n grid is + * stored cell (x = r, y = n − 1 − c). The other angles follow by repeating it. + */ +export function displayedToStored(n: number, rotate: number, c: number, r: number): { x: number; y: number } { + let p = { x: c, y: r }; + for (let turn = 0; turn < ((rotate / 90) % 4); turn++) p = { x: p.y, y: n - 1 - p.x }; + return p; +} + +export function patternRemaining(game: BlueprintGame, s: BlueprintState, now: number): number { + const p = game.patterns[s.pattern]; + return s.startedAt === null ? p.timeSeconds : p.timeSeconds - (now - s.startedAt) / 1000; +} + +export const peeking = (s: BlueprintState, now: number) => s.peekUntil !== null && now < s.peekUntil; + +export function paint( + game: BlueprintGame, + ctx: Ctx, + role: Role, + cells: { x: number; y: number; color: string | null }[], +): ActionResult { + const s = state(ctx); + if (s.status !== 'building') return fail('not_building', 'The canvas is closed.'); + if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder paints.'); + const p = game.patterns[s.pattern]; + const rows = s.canvas.map((row) => [...row]); + for (const { x, y, color } of cells) { + if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= p.size || y >= p.size) { + return fail('bad_cell', 'That square is off the canvas.'); + } + if (color !== null && !(color in p.palette)) return fail('bad_color', "That colour isn't on the palette."); + rows[y][x] = color ?? EMPTY; + } + s.canvas = rows.map((row) => row.join('')); + return OK; +} + +export function peek(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult { + const s = state(ctx); + if (s.status !== 'building') return fail('not_building', 'Nothing to peek at right now.'); + if (role !== architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Architect can peek.'); + if (peeking(s, ctx.now)) return fail('peeking', 'You are already peeking.'); + if (s.peeksUsed >= game.patterns[s.pattern].peeks) return fail('no_peeks', 'No peeks left for this picture.'); + s.peeksUsed++; + s.peekUntil = ctx.now + PEEK_MS; + return OK; +} + +function score(game: BlueprintGame, ctx: Ctx, s: BlueprintState, p: Pattern) { + const value = overlapScore(p.rows, s.canvas); + s.submits++; + s.lastScore = value; + s.bestScore = Math.max(s.bestScore ?? 0, value); + const passed = s.bestScore >= p.threshold; + if (passed || s.submits >= game.blueprint.submitsPerPattern) { + s.status = 'scored'; + s.passed = passed; + s.peekUntil = null; + if (passed) ctx.addKey(p.keyName); + } else { + ctx.room.wrongGuesses++; + ctx.toast('all', `${Math.round(value * 100)}%. You need ${Math.round(p.threshold * 100)}%. One more try!`); + } +} + +export function submitCanvas(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult { + const s = state(ctx); + if (s.status !== 'building') return fail('not_building', 'Nothing to submit right now.'); + if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder submits.'); + if (tickBlueprint(game, ctx)) return OK; + score(game, ctx, s, game.patterns[s.pattern]); + return OK; +} + +/** Starts a picture, or moves on to the next one. */ +export function continueBlueprint(game: BlueprintGame, ctx: Ctx): ActionResult { + const s = state(ctx); + if (s.status === 'ready') { + s.status = 'building'; + s.startedAt = ctx.now; + return OK; + } + if (s.status === 'building') return fail('busy', 'The clock is running!'); + if (s.pattern < game.patterns.length - 1) { + const next = s.pattern + 1; + Object.assign(s, initBlueprint(game), { pattern: next, canvas: blank(game.patterns[next].size) }); + return OK; + } + const keys = ctx.room.keys.length; + const total = game.patterns.length; + ctx.finish( + keys === total, + keys === total ? game.successEnding : `**${keys} of ${total} pictures.** A few brushstrokes off. Play again?`, + ); + return OK; +} + +/** Called on a timer; returns true if the room changed. When the clock runs out, the canvas is scored as it is. */ +export function tickBlueprint(game: BlueprintGame, ctx: Ctx): boolean { + const s = state(ctx); + if (!s || s.status !== 'building' || patternRemaining(game, s, ctx.now) > 0) return false; + ctx.toast('all', "⏰ Time's up! Scoring the canvas as it is."); + // Out of time ends the picture even with a submit left. + s.submits = Math.max(s.submits, game.blueprint.submitsPerPattern - 1); + score(game, ctx, s, game.patterns[s.pattern]); + return true; +} diff --git a/server/src/engines/defuse.ts b/server/src/engines/defuse.ts index 65627cc..f3ac75b 100644 --- a/server/src/engines/defuse.ts +++ b/server/src/engines/defuse.ts @@ -209,10 +209,17 @@ export function mulberry32(seed: number): () => number { }; } +export interface WiresModule { + colors: string[]; + solved: boolean; +} + export interface DefuseDevice { serial: string; batteries: number; - wires?: { colors: string[]; solved: boolean }; + wires?: WiresModule; + /** A level that lists `wires` more than once gets one module per extra entry, generated and solved separately. */ + moreWires?: WiresModule[]; button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean }; keypad?: { symbols: string[]; pressed: string[]; solved: boolean }; indicators?: { label: string; lit: boolean }[]; @@ -266,7 +273,9 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe for (const id of game.levels[levelIndex].modules) { if (id === 'wires') { const count = int(mods.wires.count.min, mods.wires.count.max); - device.wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false }; + const wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false }; + if (device.wires) (device.moreWires ??= []).push(wires); + else device.wires = wires; } else if (id === 'button') { device.button = { label: pick(mods.button.labels), @@ -307,12 +316,22 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe return device; } +/** + * Every wire module on the device with the id `defuse:action` uses for it: plain `wires` when + * there is one, `wires-1`, `wires-2`... (shown as "Wires A", "Wires B"...) when there are several. + */ +export function wireModules(d: DefuseDevice): { instance: string; title: string; module: WiresModule }[] { + const all = d.wires ? [d.wires, ...(d.moreWires ?? [])] : []; + if (all.length === 1) return [{ instance: 'wires', title: 'Wires', module: all[0] }]; + return all.map((module, i) => ({ instance: `wires-${i + 1}`, title: `Wires ${String.fromCharCode(65 + i)}`, module })); +} + /** A generated device must always be solvable by the manual. */ export function isSolvable(game: DefuseGame, device: DefuseDevice): boolean { if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) return false; - if (device.wires) { - const { index } = wireSolution(game.ruleset, edgework(device), device.wires.colors); - if (index < 1 || index > device.wires.colors.length) return false; + for (const { module } of wireModules(device)) { + const { index } = wireSolution(game.ruleset, edgework(device), module.colors); + if (index < 1 || index > module.colors.length) return false; } if (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false; if (device.passwords && !solvePassword(device.passwords.columns, game.modules.passwords?.words ?? [])) return false; @@ -332,6 +351,8 @@ export interface DefuseState { strikes: number; /** `remaining` game-seconds as of `anchor`; ticks down at `speed` per real second. */ clock: { remaining: number; anchor: number; speed: number } | null; + /** Roles that tapped Ready on the swap card (games that swap roles every level). */ + swapReady?: Role[]; } const newSeed = () => Math.floor(Math.random() * 0xffffffff); @@ -347,7 +368,11 @@ export const maxStrikes = (game: DefuseGame, level: number): number => game.levels[level]?.strikesOverride ?? game.strikes.max; const deviceModules = (d: DefuseDevice) => - [d.wires, d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined); + [d.wires, ...(d.moreWires ?? []), d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined); + +/** Who holds the device on a level: A, unless roles swap every level (then B on levels 2, 4...). */ +export const technicianOf = (game: DefuseGame, level: number): Role => + game.swapRolesEachLevel && level % 2 === 1 ? 'B' : 'A'; export function remainingAt(s: DefuseState, now: number): number { if (!s.clock) return 0; @@ -391,6 +416,7 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) { if (s.level === game.levels.length - 1) { ctx.finish( true, + game.successEnding ?? '**Device disarmed.** The ticking stops. Somewhere inside the box, a tiny speaker plays a wedding march.\n\nOne of you had the hands, one of you had the manual. Neither of you could have done it alone. 💣🔑', ); } @@ -400,16 +426,8 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) { export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult { const s = state(ctx); if (s.status === 'ready') { - s.device = generateDevice(game, s.level, s.seed); - // Never arm a device the solver can't solve. - for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) { - s.seed = newSeed(); - s.device = generateDevice(game, s.level, s.seed); - } - s.strikes = 0; - s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 }; - s.status = 'active'; - return OK; + if (game.swapRolesEachLevel) return fail('swap_ready', 'You both need to tap Ready on the swap card.'); + return startLevel(game, ctx, s); } if (s.status === 'active') return fail('busy', 'The clock is running!'); if (s.status === 'cleared') { @@ -417,7 +435,33 @@ export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult { s.level++; } // Retry (same level, no key lost) or next level: fresh seed, back to the briefing. - Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null }); + Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null, swapReady: [] }); + return OK; +} + +/** The swap card: once both have tapped Ready, the clock starts. Tapping again takes it back. */ +export function levelReady(game: DefuseGame, ctx: Ctx, role: Role): ActionResult { + const s = state(ctx); + if (!game.swapRolesEachLevel) return fail('no_swap', 'Roles stay the same in this game.'); + if (s.status !== 'ready') return fail('not_ready', 'The level has already started.'); + const ready = new Set(s.swapReady ?? []); + if (ready.has(role)) ready.delete(role); + else ready.add(role); + s.swapReady = [...ready]; + return ready.size === 2 ? startLevel(game, ctx, s) : OK; +} + +function startLevel(game: DefuseGame, ctx: Ctx, s: DefuseState): ActionResult { + s.device = generateDevice(game, s.level, s.seed); + // Never arm a device the solver can't solve. + for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) { + s.seed = newSeed(); + s.device = generateDevice(game, s.level, s.seed); + } + s.strikes = 0; + s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 }; + s.status = 'active'; + s.swapReady = []; return OK; } @@ -429,7 +473,7 @@ export function defuseAction( action: DefuseAction, ): ActionResult { const s = state(ctx); - if (role !== 'A') return fail('wrong_role', 'Only the Technician can touch the device.'); + if (role !== technicianOf(game, s.level)) return fail('wrong_role', 'Only the Technician can touch the device.'); if (s.status !== 'active' || !s.device) return fail('not_active', 'The device is not live.'); if (remainingAt(s, ctx.now) <= 0) { explode(ctx, s, 'time'); @@ -438,8 +482,9 @@ export function defuseAction( const d = s.device; const edge = edgework(d); - if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') { - if (action.index === wireSolution(game.ruleset, edge, d.wires.colors).index) d.wires.solved = true; + const wires = wireModules(d).find((w) => w.instance === moduleId)?.module; + if (wires && !wires.solved && action.type === 'cut') { + if (action.index === wireSolution(game.ruleset, edge, wires.colors).index) wires.solved = true; else strike(game, ctx, s); } else if (moduleId === 'button' && d.button && !d.button.solved) { const b = d.button; diff --git a/server/src/engines/dial.ts b/server/src/engines/dial.ts new file mode 100644 index 0000000..487e350 --- /dev/null +++ b/server/src/engines/dial.ts @@ -0,0 +1,176 @@ +import { normalizeAnswer, otherRole, type DialRoundRecord, type Role } from '@two-keys/shared'; +import type { DialGame } from '../content/schemas'; +import { OK, fail, type ActionResult, type Ctx } from './types'; + +export interface DialState { + /** 0-based. */ + round: number; + status: 'clue' | 'guess' | 'reveal'; + spectrumId: string; + /** SERVER ONLY until the guesser locks in. */ + target: number; + clue: string | null; + /** The guesser's live needle. Never shown to the clue-giver. */ + needle: number | null; + guess: number | null; + points: number | null; + total: number; + /** Spectra already dealt this session. */ + used: string[]; + /** Normalized clue words used so far (for `noRepeatWords`). */ + usedWords: string[]; + history: DialRoundRecord[]; +} + +const state = (ctx: Ctx) => ctx.room.engine as DialState; + +/** A on odd rounds (1, 3, ...), B on even ones. */ +export const giverOf = (round: number): Role => (round % 2 === 0 ? 'A' : 'B'); + +/** Uppercase, accents and punctuation gone, one trailing S dropped. */ +export function normalizeClueWord(word: string): string { + const w = normalizeAnswer(word); + return w.length > 1 ? w.replace(/S$/, '') : w; +} + +/** The words of a label, so "Old-fashioned" bans OLD, FASHIONED and OLDFASHIONED. */ +export function labelWords(label: string): string[] { + const parts = label.split(/[\s\-–—/]+/).map(normalizeClueWord); + return [...new Set([...parts, normalizeClueWord(label.replace(/\s+/g, ''))])].filter(Boolean); +} + +export function pointsFor(game: DialGame, distance: number): number { + return game.dial.scoring.find((b) => distance <= b.within)?.points ?? 0; +} + +export const maxTotal = (game: DialGame): number => + Math.max(...game.dial.scoring.map((b) => b.points)) * game.dial.rounds; + +export const spectrumOf = (game: DialGame, s: DialState) => game.spectra.find((x) => x.id === s.spectrumId)!; + +function deal(game: DialGame, s: DialState, random: () => number) { + const fresh = game.spectra.filter((x) => !s.used.includes(x.id)); + const pool = fresh.length > 0 ? fresh : game.spectra; + const spectrum = pool[Math.floor(random() * pool.length)]; + const [lo, hi] = game.dial.range; + const margin = game.dial.targetMargin; + s.spectrumId = spectrum.id; + s.used.push(spectrum.id); + s.target = lo + margin + Math.floor(random() * (hi - lo - 2 * margin + 1)); + s.status = 'clue'; + s.clue = null; + s.needle = null; + s.guess = null; + s.points = null; +} + +export function initDial(game: DialGame, random: () => number = Math.random): DialState { + const s: DialState = { + round: 0, + status: 'clue', + spectrumId: '', + target: 0, + clue: null, + needle: null, + guess: null, + points: null, + total: 0, + used: [], + usedWords: [], + history: [], + }; + deal(game, s, random); + return s; +} + +/** Why a clue isn't allowed, or null if it is. */ +export function clueProblem(game: DialGame, s: DialState, text: string): string | null { + const rules = game.dial.clue; + const clue = text.trim(); + const words = clue.split(/\s+/).filter((w) => normalizeClueWord(w) !== ''); + if (words.length === 0) return 'Type a clue first.'; + if (clue.length > rules.maxChars) return `Keep it to ${rules.maxChars} characters.`; + if (words.length > rules.maxWords) { + return rules.maxWords === 1 ? 'One word only!' : `Up to ${rules.maxWords} words.`; + } + const normalized = words.map(normalizeClueWord); + if (rules.banLabelWords) { + const { left, right } = spectrumOf(game, s); + const banned = new Set([...labelWords(left), ...labelWords(right)]); + const hit = words.find((_, i) => banned.has(normalized[i])); + if (hit) return `"${hit}" is on the dial. Try another word.`; + } + if (rules.noRepeatWords) { + const hit = words.find((_, i) => s.usedWords.includes(normalized[i])); + if (hit) return `"${hit}" was already used this game.`; + } + return null; +} + +export function giveClue(game: DialGame, ctx: Ctx, role: Role, text: string): ActionResult { + const s = state(ctx); + if (s.status !== 'clue') return fail('not_open', 'The clue is already in.'); + if (role !== giverOf(s.round)) return fail('wrong_role', "It's your partner's turn to give the clue."); + // The rejection reaches the clue-giver as a friendly toast. + const problem = clueProblem(game, s, text); + if (problem) return fail('bad_clue', problem); + s.clue = text.trim().replace(/\s+/g, ' '); + if (game.dial.clue.noRepeatWords) s.usedWords.push(...s.clue.split(' ').map(normalizeClueWord)); + s.status = 'guess'; + return OK; +} + +const clampDial = (game: DialGame, value: number) => { + const [lo, hi] = game.dial.range; + return Math.min(hi, Math.max(lo, Math.round(value))); +}; + +/** The guesser's live needle: kept for a refresh, never broadcast. */ +export function moveNeedle(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult { + const s = state(ctx); + if (s.status !== 'guess' || role === giverOf(s.round)) return fail('not_open', 'Not your dial right now.'); + s.needle = clampDial(game, value); + return OK; +} + +export function lockGuess(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult { + const s = state(ctx); + if (s.status !== 'guess') return fail('not_open', 'Nothing to lock in yet.'); + if (role === giverOf(s.round)) return fail('wrong_role', 'Only the guesser turns the dial.'); + const guess = clampDial(game, value); + const distance = Math.abs(guess - s.target); + const points = pointsFor(game, distance); + const before = s.total; + s.guess = guess; + s.needle = guess; + s.points = points; + s.total += points; + s.status = 'reveal'; + // Keys land the moment the running total reaches each threshold. + for (const key of game.dial.keys) if (before < key.at && s.total >= key.at) ctx.addKey(key.name); + const { left, right } = spectrumOf(game, s); + s.history.push({ left, right, clue: s.clue ?? '', giver: giverOf(s.round), target: s.target, guess, points }); + return OK; +} + +export function continueDial(game: DialGame, ctx: Ctx, random: () => number = Math.random): ActionResult { + const s = state(ctx); + if (s.status !== 'reveal') return fail('not_done', 'Finish this round first.'); + if (s.round < game.dial.rounds - 1) { + s.round++; + deal(game, s, random); + return OK; + } + const keys = ctx.room.keys.length; + const total = game.dial.keys.length; + ctx.finish( + keys === total, + keys === total + ? game.successEnding + : `**${s.total} points, ${keys} of ${total} keys.** ${keys > 0 ? 'Nearly on the same wavelength.' : 'Static on the line this time.'} Play again?`, + ); + return OK; +} + +/** The partner of the clue-giver this round. */ +export const guesserOf = (round: number): Role => otherRole(giverOf(round)); diff --git a/server/src/engines/index.ts b/server/src/engines/index.ts index deeeb82..3c694a4 100644 --- a/server/src/engines/index.ts +++ b/server/src/engines/index.ts @@ -1,8 +1,11 @@ import type { Game } from '../content/schemas'; -import { continueDefuse, initDefuse } from './defuse'; +import { continueBlueprint, initBlueprint, tickBlueprint } from './blueprint'; +import { continueDefuse, initDefuse, tickDefuse } from './defuse'; +import { continueDial, initDial } from './dial'; import { initLabyrinth } from './labyrinth'; -import { continueChapter, initStory } from './story'; +import { continueChapter, initStory, tickStory } from './story'; import { continueSync, initSync } from './sync'; +import { continueTwin, initTwin, tickScene } from './twin'; import type { ActionResult, Ctx } from './types'; export function initEngine(game: Game): unknown { @@ -15,6 +18,12 @@ export function initEngine(game: Game): unknown { return initSync(game); case 'labyrinth': return initLabyrinth(game); + case 'dial': + return initDial(game); + case 'twin': + return initTwin(); + case 'blueprint': + return initBlueprint(game); } } @@ -28,6 +37,12 @@ export function totalKeys(game: Game): number { return game.rounds.length; case 'labyrinth': return game.lanterns.length; + case 'dial': + return game.dial.keys.length; + case 'twin': + return game.scenes.length; + case 'blueprint': + return game.patterns.length; } } @@ -42,5 +57,44 @@ export function continueGame(game: Game, ctx: Ctx): ActionResult { return continueSync(game, ctx); case 'labyrinth': return { ok: false, code: 'no_stages', message: 'Nothing to continue.' }; + case 'dial': + return continueDial(game, ctx); + case 'twin': + return continueTwin(game, ctx); + case 'blueprint': + return continueBlueprint(game, ctx); } } + +/** The hard limit on elapsed + penalty time, for the games that have one. */ +export function timeLimitSeconds(game: Game): number | null { + if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null; + const minutes = game.rules.timeLimitMinutes; + return minutes ? Math.round(minutes * 60) : null; +} + +export function timeLimitLabel(game: Game): string | null { + if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null; + return timeLimitSeconds(game) === null ? null : (game.rules.timeLimitLabel ?? null); +} + +/** Whether a room of this game has clocks that can run out with nobody touching anything. */ +export function hasClock(game: Game): boolean { + return timeLimitSeconds(game) !== null || game.engine === 'defuse' || game.engine === 'twin' || game.engine === 'blueprint'; +} + +/** Called on a timer; returns true if the room changed. */ +export function tickGame(game: Game, ctx: Ctx): boolean { + const { room } = ctx; + if (room.phase !== 'playing') return false; + if (game.engine === 'story') return tickStory(game, ctx); + const limit = timeLimitSeconds(game); + if (limit !== null && room.startedAt !== null && (ctx.now - room.startedAt) / 1000 + room.penaltySeconds >= limit) { + ctx.finish(false, "**Time's up.** The clock beat you this time. Run it back?", null, true); + return true; + } + if (game.engine === 'defuse') return tickDefuse(ctx); + if (game.engine === 'twin') return tickScene(game, ctx); + if (game.engine === 'blueprint') return tickBlueprint(game, ctx); + return false; +} diff --git a/server/src/engines/labyrinth.ts b/server/src/engines/labyrinth.ts index 8d442fe..7205037 100644 --- a/server/src/engines/labyrinth.ts +++ b/server/src/engines/labyrinth.ts @@ -75,15 +75,26 @@ export function analyseMaze( grid: string[], gates: readonly Gate[], lanterns: readonly Point[], -): { winnable: boolean; stuck: Point[] } { + items: readonly (Point & { id: string })[] = [], + doors: readonly (Point & { needs: string })[] = [], +): { winnable: boolean; stuck: Point[]; safeCells: Set } { const [start] = findCells(grid, 'S'); - if (!start) return { winnable: false, stuck: [] }; + if (!start) return { winnable: false, stuck: [], safeCells: new Set() }; + // One bit per lantern, then one per item. const full = (1 << lanterns.length) - 1; + const pickups: Point[] = [...lanterns, ...items]; + const itemBit = (itemId: string) => { + const i = items.findIndex((it) => it.id === itemId); + return i < 0 ? 0 : 1 << (lanterns.length + i); + }; const id = (p: Point, mask: number, safe: boolean) => `${p.x},${p.y},${mask},${safe ? 1 : 0}`; const pickUp = (p: Point, mask: number) => { - const i = lanterns.findIndex((l) => l.x === p.x && l.y === p.y); + const i = pickups.findIndex((l) => l.x === p.x && l.y === p.y); return i >= 0 ? mask | (1 << i) : mask; }; + /** A door stays shut until its item is carried (an unknown item never opens it). */ + const doorShut = (p: Point, mask: number) => + doors.some((d) => d.x === p.x && d.y === p.y && !(itemBit(d.needs) && mask & itemBit(d.needs))); // `safe` tracks whether the run so far is trap-free. type Node = { p: Point; mask: number; safe: boolean }; @@ -96,14 +107,14 @@ export function analyseMaze( while (queue.length > 0) { const node = queue.shift()!; const from = id(node.p, node.mask, node.safe); - if (cellAt(grid, node.p.x, node.p.y) === 'E' && node.mask === full) { + if (cellAt(grid, node.p.x, node.p.y) === 'E' && (node.mask & full) === full) { goals.push(from); continue; } for (const dir of CLOCKWISE) { const step = { x: node.p.x + DELTAS[dir].x, y: node.p.y + DELTAS[dir].y }; const c = cellAt(grid, step.x, step.y); - if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir)) continue; + if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir) || doorShut(step, node.mask)) continue; const next: Node = c === 'T' ? { p: start, mask: node.mask, safe: false } @@ -129,8 +140,12 @@ export function analyseMaze( } const stuck = new Map(); - for (const [nodeId, node] of seen) if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p); - return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()] }; + const safeCells = new Set(); + for (const [nodeId, node] of seen) { + if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p); + if (node.safe) safeCells.add(key(node.p)); + } + return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()], safeCells }; } export interface LabyrinthState { @@ -142,6 +157,10 @@ export interface LabyrinthState { moves: number; falls: number; message: string; + /** Item ids carried. Kept after traps. */ + inventory?: string[]; + /** Doors passed at least once, as "x,y" (their openText shows only the first time). */ + opened?: string[]; } export function initLabyrinth(game: LabyrinthGame): LabyrinthState { @@ -153,9 +172,13 @@ export function initLabyrinth(game: LabyrinthGame): LabyrinthState { moves: 0, falls: 0, message: '', + inventory: [], + opened: [], }; } +export const doorAt = (game: LabyrinthGame, x: number, y: number) => game.doors.find((d) => d.x === x && d.y === y); + export const facingOf = (game: LabyrinthGame, s: LabyrinthState): Dir => s.facing ?? game.explorer.startFacing; export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: MoveDir): ActionResult { @@ -172,6 +195,14 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo if (isWall(game.grid, next.x, next.y)) return fail('wall', "Solid stone. You can't go that way."); if (gateBlocks(game.oneWay, s.x, s.y, dir)) return fail('gate', "The gate won't open from this side."); + const inventory = (s.inventory ??= []); + const door = doorAt(game, next.x, next.y); + if (door && !inventory.includes(door.needs)) { + // The Explorer only finds out by trying: they stay put and hear why. + s.message = door.lockedText; + return OK; + } + const flavour = game.explorerView.flavour; const throughGate = game.oneWay.some((g) => g.x === s.x && g.y === s.y && g.dir === dir); s.moves++; @@ -180,6 +211,18 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo s.facing = dir; s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : ''; + const opened = (s.opened ??= []); + if (door && !opened.includes(`${door.x},${door.y}`)) { + opened.push(`${door.x},${door.y}`); + s.message = door.openText; + } + // Items are picked up on entry, without telling the Navigator. + const item = game.items.find((it) => it.x === next.x && it.y === next.y); + if (item && !inventory.includes(item.id)) { + inventory.push(item.id); + s.message = item.explorerText; + } + const cell = cellAt(game.grid, next.x, next.y); if (cell === 'T') { const [start] = findCells(game.grid, 'S'); diff --git a/server/src/engines/sync.ts b/server/src/engines/sync.ts index 6b87a4f..b2a08cf 100644 --- a/server/src/engines/sync.ts +++ b/server/src/engines/sync.ts @@ -176,7 +176,8 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult { const total = game.rounds.length; const ending = keys === total - ? `**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑` + ? (game.successEnding ?? + `**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`) : keys > 0 ? `**${keys} out of ${total} keys.** In sync where it counts, with a few surprises left to discover. That's the fun part. 💞` : `**No keys this time.** Which just means you have a lot of excellent conversations ahead of you. Rematch? 😏`; diff --git a/server/src/engines/twin.ts b/server/src/engines/twin.ts new file mode 100644 index 0000000..bfa0d41 --- /dev/null +++ b/server/src/engines/twin.ts @@ -0,0 +1,144 @@ +import { otherRole, type Role } from '@two-keys/shared'; +import type { TwinGame } from '../content/schemas'; +import { OK, fail, type ActionResult, type Ctx } from './types'; + +type Scene = TwinGame['scenes'][number]; + +export interface TwinTap { + /** In A's coordinates (B's taps are un-mirrored first). */ + x: number; + y: number; + at: number; +} + +export interface TwinState { + scene: number; + status: 'ready' | 'playing' | 'won' | 'failed'; + /** 0 on the first try, 1 on the retry. */ + attempt: number; + startedAt: number | null; + /** Seconds taken off this scene's clock by wrong claims. */ + scenePenalty: number; + /** Indexes into the scene's `changes`. */ + found: number[]; + taps: Partial>; +} + +const state = (ctx: Ctx) => ctx.room.engine as TwinState; + +export function initTwin(): TwinState { + return { scene: 0, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} }; +} + +/** A row's cells. Emoji can be several code points (🐈‍⬛), so rows split on spaces, never by character. */ +export const cellsOf = (row: string): string[] => row.split(' '); + +export const gridA = (scene: Scene): string[][] => scene.grid.map(cellsOf); + +/** B's room: A's with the changes applied. */ +export function gridB(scene: Scene): string[][] { + const grid = gridA(scene); + for (const c of scene.changes) grid[c.y][c.x] = c.to; + return grid; +} + +export const widthOf = (scene: Scene): number => cellsOf(scene.grid[0]).length; + +/** Mirrored rooms flip x for B, both ways. */ +export const mirrored = (game: TwinGame, role: Role) => role === 'B' && game.twin.mirrorB; +export const flipX = (game: TwinGame, scene: Scene, role: Role, x: number) => + mirrored(game, role) ? widthOf(scene) - 1 - x : x; + +export function sceneRemaining(game: TwinGame, s: TwinState, now: number): number { + const scene = game.scenes[s.scene]; + if (s.startedAt === null) return scene.timeSeconds; + return scene.timeSeconds - (now - s.startedAt) / 1000 - s.scenePenalty; +} + +/** A tap still counts while it's inside the claim window. */ +export const liveTap = (game: TwinGame, tap: TwinTap | undefined, now: number): TwinTap | null => + tap && now - tap.at <= game.twin.claimWindowSeconds * 1000 ? tap : null; + +export const canRetry = (s: TwinState) => s.status === 'failed' && s.attempt === 0; + +function startScene(s: TwinState, now: number) { + Object.assign(s, { status: 'playing', startedAt: now, scenePenalty: 0, found: [], taps: {} }); +} + +export function tapCell(game: TwinGame, ctx: Ctx, role: Role, x: number, y: number): ActionResult { + const s = state(ctx); + if (s.status !== 'playing') return fail('not_playing', 'The room is not open right now.'); + if (tickScene(game, ctx)) return OK; // time ran out first + const scene = game.scenes[s.scene]; + const width = widthOf(scene); + if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= scene.grid.length) { + return fail('bad_cell', 'That square is off the grid.'); + } + const ax = flipX(game, scene, role, x); + const change = scene.changes.findIndex((c) => c.x === ax && c.y === y); + if (change >= 0 && s.found.includes(change)) return fail('found', 'You already found that one.'); + + const partner = otherRole(role); + const theirs = liveTap(game, s.taps[partner], ctx.now); + if (theirs && theirs.x === ax && theirs.y === y) { + // Both tapped the same square in time: claim it. + s.taps = {}; + if (change >= 0) { + s.found.push(change); + ctx.toast('all', `✅ Difference found! ${s.found.length} / ${scene.changes.length}`); + if (s.found.length === scene.changes.length) { + s.status = 'won'; + ctx.addKey(scene.keyName); + } + } else { + const penalty = game.twin.wrongClaimPenaltySeconds; + s.scenePenalty += penalty; + ctx.addPenalty(penalty); + ctx.room.wrongGuesses++; + ctx.toast('all', `❌ No difference there. −${penalty}s`); + tickScene(game, ctx); + } + return OK; + } + // A different square (or none): the earlier tap expires, this one waits for the partner. + delete s.taps[partner]; + s.taps[role] = { x: ax, y, at: ctx.now }; + ctx.toast(partner, 'Your partner tapped a square.'); + return OK; +} + +/** Starts a scene, retries a failed one, or moves on. */ +export function continueTwin(game: TwinGame, ctx: Ctx): ActionResult { + const s = state(ctx); + if (s.status === 'ready') { + startScene(s, ctx.now); + return OK; + } + if (s.status === 'playing') return fail('busy', 'The clock is running!'); + if (canRetry(s)) { + s.attempt = 1; + startScene(s, ctx.now); + return OK; + } + if (s.scene < game.scenes.length - 1) { + Object.assign(s, { scene: s.scene + 1, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} }); + return OK; + } + const keys = ctx.room.keys.length; + const total = game.scenes.length; + ctx.finish( + keys === total, + keys === total ? game.successEnding : `**${keys} of ${total} rooms solved.** So close. The differences are still out there. Play again?`, + ); + return OK; +} + +/** Called on a timer and before each tap; returns true if the scene clock ran out. */ +export function tickScene(game: TwinGame, ctx: Ctx): boolean { + const s = state(ctx); + if (!s || s.status !== 'playing' || sceneRemaining(game, s, ctx.now) > 0) return false; + s.status = 'failed'; + s.taps = {}; + ctx.toast('all', "⏰ Time's up for this room."); + return true; +} diff --git a/server/src/rooms/state.ts b/server/src/rooms/state.ts index d4780df..3d5555a 100644 --- a/server/src/rooms/state.ts +++ b/server/src/rooms/state.ts @@ -1,8 +1,7 @@ import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/shared'; import type { Game } from '../content/schemas'; import { engineView, gameMeta } from '../content/sanitize'; -import { totalKeys } from '../engines/index'; -import { timeLimitSeconds } from '../engines/story'; +import { timeLimitLabel, timeLimitSeconds, totalKeys } from '../engines/index'; import type { PlayerRec, Room } from './store'; export function computeResult(room: Room, game: Game): GameResult | null { @@ -75,8 +74,8 @@ export function buildState( finishedAt: room.finishedAt, penaltySeconds: room.penaltySeconds, serverNow: now, - limitSeconds: game.engine === 'story' ? timeLimitSeconds(game) : null, - limitLabel: game.engine === 'story' ? (game.rules.timeLimitLabel ?? null) : null, + limitSeconds: timeLimitSeconds(game), + limitLabel: timeLimitLabel(game), }, stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses }, views, diff --git a/server/src/scripts/validate-content.ts b/server/src/scripts/validate-content.ts index 40b6764..a8289db 100644 --- a/server/src/scripts/validate-content.ts +++ b/server/src/scripts/validate-content.ts @@ -1,11 +1,13 @@ import { loadAllFiles } from '../content/loader'; +import { describeGame } from '../content/validate'; const files = loadAllFiles(); let failed = 0; for (const { file, game, errors } of files) { if (errors.length === 0 && game) { - console.log(`✔ ${file} (${game.engine})`); + const head = `${game.difficulty}★ ${String(game.estimatedMinutes).padStart(2)}m ${game.engine.padEnd(9)}`; + console.log(`✔ ${head} ${file.padEnd(32)} ${describeGame(game)}`); } else { failed++; console.error(`✖ ${file}`); diff --git a/server/src/socket/handlers.ts b/server/src/socket/handlers.ts index 339a2b7..86c2148 100644 --- a/server/src/socket/handlers.ts +++ b/server/src/socket/handlers.ts @@ -12,8 +12,10 @@ import { type Role, } from '@two-keys/shared'; import type { Game } from '../content/schemas'; -import { defuseAction, tickDefuse } from '../engines/defuse'; -import { continueGame, initEngine } from '../engines/index'; +import { paint, peek, submitCanvas } from '../engines/blueprint'; +import { defuseAction, levelReady } from '../engines/defuse'; +import { giveClue, lockGuess, moveNeedle } from '../engines/dial'; +import { continueGame, hasClock, initEngine, tickGame } from '../engines/index'; import { moveExplorer } from '../engines/labyrinth'; import { agreeAccusation, @@ -21,10 +23,10 @@ import { requestHint, submitAccusation, submitLock, - tickStory, togglePin, } from '../engines/story'; import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync'; +import { tapCell } from '../engines/twin'; import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types'; import { cleanCode } from '../rooms/codes'; import { buildState } from '../rooms/state'; @@ -44,7 +46,8 @@ const schemas = { pin: z.object({ clueId: z.string().max(100), ...asRole }), propose: z.object({ answers: z.record(z.string()), ...asRole }), defuse: z.object({ - moduleId: z.enum(DEFUSE_MODULES), + // `wires-1`, `wires-2`... when a level has more than one of a module. + moduleId: z.string().regex(new RegExp(`^(${DEFUSE_MODULES.join('|')})(-\d)?$`)), action: z.discriminatedUnion('type', [ z.object({ type: z.literal('cut'), index: z.number().int() }), z.object({ type: z.literal('tap') }), @@ -70,6 +73,16 @@ const schemas = { .transform((d): MoveDir => (d.length > 1 ? (d[0] as RelDir) : (d as MoveDir))), ...asRole, }), + dialClue: z.object({ text: z.string().max(200), ...asRole }), + dialValue: z.object({ value: z.number().finite(), ...asRole }), + twinTap: z.object({ x: z.number().int(), y: z.number().int(), ...asRole }), + paint: z.object({ + cells: z + .array(z.object({ x: z.number().int(), y: z.number().int(), color: z.string().max(2).nullable() })) + .min(1) + .max(256), + ...asRole, + }), notepad: z.object({ text: z.string() }), ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }), view: z.object({ label: z.string().max(80) }), @@ -133,7 +146,8 @@ export function registerHandlers(io: Server, games: Map, store: Ro schema: S, phase: Phase | null, handler: (args: ActionArgs>) => ActionResult | void, - opts: { echo?: boolean } = {}, + /** `echo: false` skips the sender; `silent` tells nobody (state that no one else may see). */ + opts: { echo?: boolean; silent?: boolean } = {}, ) { socket.on(event, (payload: unknown) => { const parsed = schema.safeParse(payload ?? {}); @@ -152,7 +166,7 @@ export function registerHandlers(io: Server, games: Map, store: Ro room.lastActivity = ctx.now; flushToasts(room, ctx); - broadcast(room, opts.echo === false ? socket : undefined); + if (!opts.silent) broadcast(room, opts.echo === false ? socket : undefined); }); } @@ -270,6 +284,41 @@ export function registerHandlers(io: Server, games: Map, store: Ro game.engine === 'labyrinth' ? moveExplorer(game, ctx, role, data.dir) : fail('wrong_engine', 'No maze here.'), ); + on('level:ready', empty, 'playing', ({ game, role, ctx }) => + game.engine === 'defuse' ? levelReady(game, ctx, role) : fail('wrong_engine', 'Nothing to get ready for.'), + ); + + const noDial = fail('wrong_engine', 'No dial here.'); + on('dial:clue', schemas.dialClue, 'playing', ({ game, data, role, ctx }) => + game.engine === 'dial' ? giveClue(game, ctx, role, data.text) : noDial, + ); + // Throttled by the client, kept for a refresh, and never broadcast: the clue-giver mustn't see it move. + on( + 'dial:needle', + schemas.dialValue, + 'playing', + ({ game, data, role, ctx }) => (game.engine === 'dial' ? moveNeedle(game, ctx, role, data.value) : noDial), + { silent: true }, + ); + on('dial:lock', schemas.dialValue, 'playing', ({ game, data, role, ctx }) => + game.engine === 'dial' ? lockGuess(game, ctx, role, data.value) : noDial, + ); + + on('twin:tap', schemas.twinTap, 'playing', ({ game, data, role, ctx }) => + game.engine === 'twin' ? tapCell(game, ctx, role, data.x, data.y) : fail('wrong_engine', 'No rooms here.'), + ); + + const noCanvas = fail('wrong_engine', 'No canvas here.'); + on('blueprint:paint', schemas.paint, 'playing', ({ game, data, role, ctx }) => + game.engine === 'blueprint' ? paint(game, ctx, role, data.cells) : noCanvas, + ); + on('blueprint:peek', empty, 'playing', ({ game, role, ctx }) => + game.engine === 'blueprint' ? peek(game, ctx, role) : noCanvas, + ); + on('blueprint:submit', empty, 'playing', ({ game, role, ctx }) => + game.engine === 'blueprint' ? submitCanvas(game, ctx, role) : noCanvas, + ); + // ----- presence ----- on( @@ -301,14 +350,18 @@ export function registerHandlers(io: Server, games: Map, store: Ro }); }); - // A defuse clock or a story time limit can run out with nobody touching anything. + // Clocks (a defuse level, a twin room, a blueprint picture, a game's time limit) can run out + // with nobody touching anything. A peek ending also needs a fresh state for the Architect. + const peekEnds = new Map(); const ticker = setInterval(() => { for (const room of store.rooms.values()) { const game = games.get(room.gameId); - if (room.phase !== 'playing' || !game) continue; - if (game.engine !== 'defuse' && !(game.engine === 'story' && game.rules.timeLimitMinutes)) continue; + if (room.phase !== 'playing' || !game || !hasClock(game)) continue; const ctx = createCtx(room); - if (game.engine === 'defuse' ? tickDefuse(ctx) : tickStory(game, ctx)) { + const peekUntil = game.engine === 'blueprint' ? (room.engine as { peekUntil: number | null }).peekUntil : null; + const peekOver = peekUntil !== null && ctx.now >= peekUntil && peekEnds.get(room.code) !== peekUntil; + if (peekOver) peekEnds.set(room.code, peekUntil); + if (tickGame(game, ctx) || peekOver) { flushToasts(room, ctx); broadcast(room); } diff --git a/server/test/content.test.ts b/server/test/content.test.ts index 92407cf..a0c2796 100644 --- a/server/test/content.test.ts +++ b/server/test/content.test.ts @@ -7,16 +7,20 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store'; import { games, getGame, labyrinth, sync } from './helpers'; describe('content', () => { - it('loads all 18 games with no validation errors', () => { + it('loads all 40 games with no validation errors', () => { const files = loadAllFiles(); expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]); - expect(files).toHaveLength(18); + expect(files).toHaveLength(40); const engines = [...games.values()].map((g) => g.engine); - expect(Object.fromEntries(['story', 'defuse', 'sync', 'labyrinth'].map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({ - story: 11, - defuse: 2, - sync: 2, - labyrinth: 3, + const ids = ['story', 'defuse', 'sync', 'labyrinth', 'dial', 'twin', 'blueprint']; + expect(Object.fromEntries(ids.map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({ + story: 21, + defuse: 3, + sync: 3, + labyrinth: 4, + dial: 3, + twin: 3, + blueprint: 3, }); }); diff --git a/server/test/pack3.test.ts b/server/test/pack3.test.ts new file mode 100644 index 0000000..0aa2cde --- /dev/null +++ b/server/test/pack3.test.ts @@ -0,0 +1,689 @@ +// GAMES-PACK-3.md: the dial, twin and blueprint engines, labyrinth items and doors, defuse +// `manualFrom` and role swaps, and sanitized payloads for all 22 new games. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { io as connect } from 'socket.io-client'; +import type { + BlueprintView, + DialView, + JoinAck, + NavigatorView, + ExplorerView, + Role, + RoomState, + TechnicianView, + TwinView, +} from '@two-keys/shared'; +import { createApp } from '../src/app'; +import { loadGameFile } from '../src/content/loader'; +import type { Game } from '../src/content/schemas'; +import { validateGame } from '../src/content/validate'; +import { + architectOf, + continueBlueprint, + displayedToStored, + overlapScore, + paint, + peek, + submitCanvas, + type BlueprintState, +} from '../src/engines/blueprint'; +import { + continueDefuse, + defuseAction, + levelReady, + wireModules, + wireSolution, + edgework, + type DefuseState, +} from '../src/engines/defuse'; +import { + clueProblem, + continueDial, + giveClue, + giverOf, + initDial, + lockGuess, + moveNeedle, + pointsFor, + type DialState, +} from '../src/engines/dial'; +import { tickGame } from '../src/engines/index'; +import { moveExplorer, type LabyrinthState } from '../src/engines/labyrinth'; +import { answerSync, continueSync, type SyncState } from '../src/engines/sync'; +import { continueTwin, gridA, gridB, tapCell, widthOf, type TwinState } from '../src/engines/twin'; +import { buildState } from '../src/rooms/state'; +import type { Room } from '../src/rooms/store'; +import { clock, games, getGame, playingRoom } from './helpers'; + +const PACK_3 = [ + 'the-treehouse', + 'twin-sunday-morning', + 'blueprint-first-sketch', + 'wavelength-tune-in', + 'death-at-the-braai', + 'in-sync-road-trip', + 'wavelength-date-night', + 'twin-cafes', + 'curtain-call', + 'magicians-dressing-room', + 'blueprint-stained-glass', + 'hedge-maze-midnight', + 'snowbound-edelweiss', + 'pharaohs-tomb', + 'twin-looking-glass', + 'tick-tock-switch', + 'hawthorn-park-cipher', + 'inventors-paradox', + 'blueprint-blind-mirror', + 'wavelength-static', + 'til-death-do-us-part', + 'the-lighthouse', +]; +const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'changes', 'expect', 'seed', 'rows', 'taps']; + +const stateOf = (room: Room, role: Role, now = 2_000_000) => { + const player = room.players.find((p) => p.seat === role)!; + return buildState(room, player, games.get(room.gameId)!, () => true, now); +}; +const viewOf = (room: Room, role: Role, now?: number) => stateOf(room, role, now).views[role] as V; + +function keysOf(value: unknown, out = new Set()): Set { + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + if (!Array.isArray(value)) out.add(k); + keysOf(v, out); + } + } + return out; +} + +/** Moves a game a little way in, so there is real state to leak. */ +function started(game: Game) { + const room = playingRoom(game); + const t = clock(room); + if (game.engine === 'defuse') { + levelReady(game, t.ctx(), 'A'); + levelReady(game, t.ctx(), 'B'); + continueDefuse(game, t.ctx()); + } else if (game.engine === 'sync') { + continueSync(game, t.ctx()); + } else if (game.engine === 'twin') { + continueTwin(game, t.ctx()); + tapCell(game, t.ctx(), 'A', 0, 0); + } else if (game.engine === 'blueprint') { + continueBlueprint(game, t.ctx()); + paint(game, t.ctx(), 'B', [{ x: 0, y: 0, color: Object.keys(game.patterns[0].palette)[0] }]); + } else if (game.engine === 'dial') { + giveClue(game, t.ctx(), 'A', 'zebra'); + moveNeedle(game, t.ctx(), 'B', 42); + } + return { room, t }; +} + +describe('pack 3 content', () => { + it('has all 22 games', () => { + for (const id of PACK_3) expect(games.get(id), id).toBeDefined(); + }); +}); + +describe.each(PACK_3.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => { + it('never carry answers, fixtures, changes, solver state or hidden pictures for either role', () => { + const { room, t } = started(game); + for (const role of ['A', 'B'] as const) { + const keys = keysOf(stateOf(room, role, t.now)); + for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key); + } + }); +}); + +// ---------- dial ---------- + +describe('dial: Wavelength', () => { + const tuneIn = getGame('wavelength-tune-in', 'dial'); + const staticGame = getGame('wavelength-static', 'dial'); + + it('alternates the clue-giver: A on odd rounds, B on even', () => { + expect([0, 1, 2, 3].map(giverOf)).toEqual(['A', 'B', 'A', 'B']); + }); + + it('keeps the target from the guesser until they lock in, and the needle from the clue-giver', () => { + const room = playingRoom(tuneIn); + const t = clock(room); + const s = room.engine as DialState; + expect(viewOf(room, 'A').target).toBe(s.target); + expect(viewOf(room, 'B')).toMatchObject({ target: null, clue: null }); + + expect(giveClue(tuneIn, t.ctx(), 'B', 'tea').ok).toBe(false); // not B's turn + expect(giveClue(tuneIn, t.ctx(), 'A', 'a warm bath').ok).toBe(true); + expect(moveNeedle(tuneIn, t.ctx(), 'B', 61).ok).toBe(true); + expect(viewOf(room, 'B')).toMatchObject({ target: null, clue: 'a warm bath', needle: 61 }); + const giver = viewOf(room, 'A'); + expect(giver.needle).toBeNull(); + expect(JSON.stringify(giver)).not.toContain('61'); + + expect(lockGuess(tuneIn, t.ctx(), 'A', 50).ok).toBe(false); // the giver can't guess + expect(lockGuess(tuneIn, t.ctx(), 'B', s.target + 7).ok).toBe(true); + expect(viewOf(room, 'B')).toMatchObject({ status: 'reveal', target: s.target, guess: s.target + 7, points: 3 }); + }); + + it('scores by distance: the first band that fits, 0 outside all', () => { + expect([0, 5, 6, 10, 15, 20, 21].map((d) => pointsFor(tuneIn, d))).toEqual([4, 4, 3, 3, 2, 1, 0]); + expect([2, 3, 5, 8, 9].map((d) => pointsFor(staticGame, d))).toEqual([4, 3, 3, 2, 0]); + }); + + it('checks clues: length, words, label words (with a trailing S) and repeats', () => { + const s = initDial(staticGame, () => 0); + const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!; + const labelWord = spectrum.left.split(/\s+/)[0]; + expect(clueProblem(staticGame, s, '')).toMatch(/Type a clue/); + expect(clueProblem(staticGame, s, 'two words')).toMatch(/One word only/); + expect(clueProblem(staticGame, s, 'x'.repeat(31))).toMatch(/30 characters/); + expect(clueProblem(staticGame, s, `${labelWord.toLowerCase()}s`)).toMatch(/is on the dial/); + expect(clueProblem(staticGame, s, 'Zebra!')).toBeNull(); + s.usedWords.push('ZEBRA'); + expect(clueProblem(staticGame, s, 'zebras')).toMatch(/already used/); + expect(clueProblem(tuneIn, initDial(tuneIn), 'a b c d e f')).toMatch(/Up to 5 words/); + }); + + it("hides the right label (and the numbers) from Static's guesser", () => { + const room = playingRoom(staticGame); + const s = room.engine as DialState; + const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!; + const guesser = viewOf(room, 'B'); + expect(guesser).toMatchObject({ left: spectrum.left, right: null, showNumbers: false }); + expect(JSON.stringify(guesser)).not.toContain(spectrum.right); + expect(viewOf(room, 'A')).toMatchObject({ right: spectrum.right, showNumbers: true }); + }); + + it('never repeats a spectrum, awards keys as the total crosses each threshold, and ends after the last round', () => { + const room = playingRoom(tuneIn); + const t = clock(room); + const s = room.engine as DialState; + for (let round = 0; round < tuneIn.dial.rounds; round++) { + const giver = giverOf(round); + const guesser: Role = giver === 'A' ? 'B' : 'A'; + expect(giveClue(tuneIn, t.ctx(), giver, 'zebra').ok).toBe(true); + expect(lockGuess(tuneIn, t.ctx(), guesser, s.target).ok).toBe(true); // bullseye, 4 points + expect(room.keys.length).toBe(tuneIn.dial.keys.filter((k) => s.total >= k.at).length); + expect(continueDial(tuneIn, t.ctx()).ok).toBe(true); + } + expect(new Set(s.used).size).toBe(tuneIn.dial.rounds); + expect(s.total).toBe(24); + expect(room.keys).toEqual(tuneIn.dial.keys.map((k) => k.name)); + expect(room.result).toMatchObject({ success: true, ending: tuneIn.successEnding }); + }); + + it('fails without every key', () => { + const room = playingRoom(tuneIn); + const t = clock(room); + const s = room.engine as DialState; + for (let round = 0; round < tuneIn.dial.rounds; round++) { + giveClue(tuneIn, t.ctx(), giverOf(round), 'zebra'); + lockGuess(tuneIn, t.ctx(), giverOf(round) === 'A' ? 'B' : 'A', s.target > 50 ? 0 : 100); + continueDial(tuneIn, t.ctx()); + } + expect(room.result?.success).toBe(false); + }); + + it('rejects a too-small spectra bank and keys nobody can reach', () => { + const game = structuredClone(tuneIn); + game.spectra = game.spectra.slice(0, 10); + game.dial.keys[1].at = 99; + const errors = validateGame(game).join('\n'); + expect(errors).toMatch(/spectra bank has 10/); + expect(errors).toMatch(/is the most possible/); + }); +}); + +// ---------- twin ---------- + +describe('twin: Twin Rooms', () => { + const sunday = getGame('twin-sunday-morning', 'twin'); + const glass = getGame('twin-looking-glass', 'twin'); + + function playing(game: typeof sunday) { + const room = playingRoom(game); + const t = clock(room); + expect(continueTwin(game, t.ctx()).ok).toBe(true); + return { room, t, s: room.engine as TwinState }; + } + + it("gives each player only their own room, never the changes", () => { + const { room } = playing(sunday); + const scene = sunday.scenes[0]; + expect(viewOf(room, 'A').grid).toEqual(gridA(scene)); + expect(viewOf(room, 'B').grid).toEqual(gridB(scene)); + expect(gridA(scene)).not.toEqual(gridB(scene)); + }); + + it('splits rows on spaces, so multi-code-point emoji stay whole', () => { + for (const game of [sunday, glass, getGame('twin-cafes', 'twin')]) { + for (const scene of game.scenes) for (const row of gridA(scene)) expect(row).toHaveLength(widthOf(scene)); + } + }); + + it('claims a difference when both tap the same square in time', () => { + const { room, t, s } = playing(sunday); + const [c] = sunday.scenes[0].changes; + expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(true); + // The partner hears that a square was tapped, never which. + const b = viewOf(room, 'B', t.now); + expect(b.partnerTap).not.toBeNull(); + expect(b.myTap).toBeNull(); + expect(JSON.stringify(b.partnerTap)).not.toMatch(/"x"|"y"/); + tapCell(sunday, t.ctx(), 'B', c.x, c.y); + expect(s.found).toEqual([0]); + expect(viewOf(room, 'A', t.now).found).toEqual([{ x: c.x, y: c.y }]); + expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(false); // already found + }); + + it('a wrong claim costs time; different squares just replace the earlier tap; late taps expire', () => { + const { room, t, s } = playing(sunday); + const scene = sunday.scenes[0]; + const isChange = (x: number, y: number) => scene.changes.some((c) => c.x === x && c.y === y); + const plain = gridA(scene).flatMap((row, y) => row.map((_, x) => ({ x, y }))).find((p) => !isChange(p.x, p.y))!; + tapCell(sunday, t.ctx(), 'A', plain.x, plain.y); + tapCell(sunday, t.ctx(), 'B', plain.x, plain.y); + expect(room.penaltySeconds).toBe(sunday.twin.wrongClaimPenaltySeconds); + expect(s.scenePenalty).toBe(sunday.twin.wrongClaimPenaltySeconds); + expect(s.found).toEqual([]); + + const [c] = scene.changes; + tapCell(sunday, t.ctx(), 'A', c.x, c.y); + tapCell(sunday, t.ctx(), 'B', plain.x, plain.y); // a different square: A's tap expires + expect(s.taps.A).toBeUndefined(); + tapCell(sunday, t.ctx(), 'A', c.x, c.y); + tapCell(sunday, t.ctx((sunday.twin.claimWindowSeconds + 1) * 1000), 'B', c.x, c.y); // too late + expect(s.found).toEqual([]); + }); + + it('mirror mode: tapping the mirrored square of a difference claims it', () => { + const { room, t, s } = playing(glass); + const scene = glass.scenes[0]; + const [c] = scene.changes; + const mirroredX = widthOf(scene) - 1 - c.x; + expect(viewOf(room, 'B').grid).toEqual(gridB(scene).map((row) => [...row].reverse())); + tapCell(glass, t.ctx(), 'A', c.x, c.y); + tapCell(glass, t.ctx(), 'B', mirroredX, c.y); + expect(s.found).toEqual([0]); + expect(viewOf(room, 'B', t.now).found).toEqual([{ x: mirroredX, y: c.y }]); + }); + + it('earns the key when every difference is found; a timed-out room retries once, then moves on', () => { + const { room, t, s } = playing(sunday); + for (const c of sunday.scenes[0].changes) { + tapCell(sunday, t.ctx(), 'A', c.x, c.y); + tapCell(sunday, t.ctx(), 'B', c.x, c.y); + } + expect(s.status).toBe('won'); + expect(room.keys).toEqual([sunday.scenes[0].keyName]); + + continueTwin(sunday, t.ctx()); // next room + continueTwin(sunday, t.ctx()); // open it + expect(tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000))).toBe(true); + expect(viewOf(room, 'A', t.now)).toMatchObject({ status: 'failed', canRetry: true }); + continueTwin(sunday, t.ctx()); + expect(s).toMatchObject({ status: 'playing', attempt: 1, found: [] }); + tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000)); + expect(viewOf(room, 'A', t.now).canRetry).toBe(false); + continueTwin(sunday, t.ctx()); + expect(s).toMatchObject({ scene: 2, status: 'ready' }); + }); + + it("Looking Glass's 25-minute limit ends the game", () => { + const { room, t } = playing(glass); + expect(tickGame(glass, t.ctx(25 * 60_000))).toBe(true); + expect(stateOf(room, 'A', t.now).result).toMatchObject({ outOfTime: true, rank: 'Out of time' }); + }); + + it('rejects a change that changes nothing, and a ragged row', () => { + const game = structuredClone(sunday); + const c = game.scenes[0].changes[0]; + c.to = gridA(game.scenes[0])[c.y][c.x]; + game.scenes[1].grid[0] += ' 🍄'; + const errors = validateGame(game).join('\n'); + expect(errors).toMatch(/doesn't change anything/); + expect(errors).toMatch(/rows don't all have/); + }); +}); + +// ---------- blueprint ---------- + +describe('blueprint: Describe & Draw', () => { + const sketch = getGame('blueprint-first-sketch', 'blueprint'); + const mirror = getGame('blueprint-blind-mirror', 'blueprint'); + + function building(game: typeof sketch) { + const room = playingRoom(game); + const t = clock(room); + continueBlueprint(game, t.ctx()); + return { room, t, s: room.engine as BlueprintState }; + } + + /** Paints the whole picture as the Builder sees it: displayed cell (c, r) on a rotated canvas. */ + function paintAsBuilder(game: typeof sketch, room: Room, t: ReturnType, role: Role, rows: string[]) { + const n = rows.length; + const cells = []; + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) { + const { x, y } = displayedToStored(n, game.blueprint.rotateBuilder, c, r); + const v = rows[y][x]; + cells.push({ x, y, color: v === '.' ? null : v }); + } + } + expect(paint(game, t.ctx(), role, cells).ok).toBe(true); + } + + it('scores overlap: matched painted cells over cells painted in either grid', () => { + expect(overlapScore(['RR', '..'], ['RR', '..'])).toBe(1); + expect(overlapScore(['RR', '..'], ['..', '..'])).toBe(0); // a blank canvas never scores + expect(overlapScore(['RR', '..'], ['RB', '..'])).toBe(0.5); + expect(overlapScore(['RR', '..'], ['RR', 'R.'])).toBeCloseTo(2 / 3); + expect(overlapScore(['..', '..'], ['..', '..'])).toBe(0); + }); + + it("keeps the picture from the Builder, and the canvas from the Architect except in a peek", () => { + const { room, t } = building(sketch); + const builder = viewOf(room, 'B', t.now); + expect(builder).toMatchObject({ seat: 'builder', target: null, title: null }); + expect(JSON.stringify(builder)).not.toContain(sketch.patterns[0].title); + expect(viewOf(room, 'A', t.now)).toMatchObject({ seat: 'architect', canvas: null }); + + expect(peek(sketch, t.ctx(), 'B').ok).toBe(false); // only the Architect peeks + expect(peek(sketch, t.ctx(), 'A').ok).toBe(true); + expect(viewOf(room, 'A', t.now).canvas).not.toBeNull(); + expect(viewOf(room, 'A', t.now + 3500).canvas).toBeNull(); + peek(sketch, t.ctx(), 'A'); + expect(peek(sketch, t.ctx(), 'A').ok).toBe(false); // two peeks per picture + }); + + it('rotation: a Builder who paints the rotated picture correctly scores 100%', () => { + expect(mirror.blueprint.rotateBuilder).toBe(90); + // Displayed (c, r) on an n×n grid is stored (x = r, y = n − 1 − c). + expect(displayedToStored(10, 90, 0, 0)).toEqual({ x: 0, y: 9 }); + expect(displayedToStored(10, 90, 9, 0)).toEqual({ x: 0, y: 0 }); + const { room, t, s } = building(mirror); + paintAsBuilder(mirror, room, t, 'B', mirror.patterns[0].rows); + expect(submitCanvas(mirror, t.ctx(), 'B').ok).toBe(true); + expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true }); + expect(room.keys).toEqual([mirror.patterns[0].keyName]); + }); + + it('swaps the Architect every picture when the game says so', () => { + expect([0, 1, 2, 3].map((i) => architectOf(mirror, i))).toEqual(['A', 'B', 'A', 'B']); + expect([0, 1, 2].map((i) => architectOf(sketch, i))).toEqual(['A', 'A', 'A']); + const { room, t } = building(mirror); + submitCanvas(mirror, t.ctx(), 'B'); + continueBlueprint(mirror, t.ctx()); + continueBlueprint(mirror, t.ctx()); + expect(viewOf(room, 'A', t.now)).toMatchObject({ seat: 'builder', target: null }); + expect(viewOf(room, 'B', t.now).seat).toBe('architect'); + expect(paint(mirror, t.ctx(), 'B', [{ x: 0, y: 0, color: null }]).ok).toBe(false); + }); + + it('a second submit is allowed and the best score counts; the reveal shows the picture to the Builder', () => { + const { room, t, s } = building(sketch); + const rows = sketch.patterns[0].rows; + const colour = Object.keys(sketch.patterns[0].palette)[0]; + paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: colour }]); + submitCanvas(sketch, t.ctx(), 'B'); + expect(s).toMatchObject({ status: 'building', submits: 1 }); + expect(viewOf(room, 'B', t.now).target).toBeNull(); + paintAsBuilder(sketch, room, t, 'B', rows); + submitCanvas(sketch, t.ctx(), 'B'); + expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true }); + expect(viewOf(room, 'B', t.now)).toMatchObject({ target: rows, title: sketch.patterns[0].title }); + }); + + it('when time runs out, the canvas is scored as it is', () => { + const { t, s } = building(sketch); + expect(tickGame(sketch, t.ctx(sketch.patterns[0].timeSeconds * 1000))).toBe(true); + expect(s).toMatchObject({ status: 'scored', passed: false, bestScore: 0 }); + }); + + it('rejects colours that are not on the palette, and a picture of the wrong size', () => { + const { t } = building(sketch); + expect(paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: 'Q' }]).ok).toBe(false); + expect(paint(sketch, t.ctx(), 'B', [{ x: 6, y: 0, color: null }]).ok).toBe(false); + const game = structuredClone(sketch); + game.patterns[0].rows[0] = 'RR'; + game.patterns[1].rows[1] = '.ZZZZ.'; + const errors = validateGame(game).join('\n'); + expect(errors).toMatch(/rows must be 6×6/); + expect(errors).toMatch(/colour "Z" is not in the palette/); + }); +}); + +// ---------- labyrinth items and doors ---------- + +describe('labyrinth: items and doors (Hedge Maze)', () => { + const hedge = getGame('hedge-maze-midnight', 'labyrinth'); + const [door] = hedge.doors; + const [item] = hedge.items; + + /** Walks the Explorer along a path of n/e/s/w steps. */ + const walk = (room: Room, t: ReturnType, steps: string) => { + for (const dir of steps) moveExplorer(hedge, t.ctx(), 'B', dir as 'n'); + }; + + it('a door stays shut until the Explorer carries its item, which survives a trap', () => { + const room = playingRoom(hedge); + const t = clock(room); + const s = room.engine as LabyrinthState; + // Start (1,1) → down to (1,7) → east to the door's neighbour (4,7). + s.x = door.x - 1; + s.y = door.y; + moveExplorer(hedge, t.ctx(), 'B', 'e'); + expect({ x: s.x, y: s.y }).toEqual({ x: door.x - 1, y: door.y }); + expect(viewOf(room, 'B').message).toBe(door.lockedText); + + // Pick the key up from the cell next to it. + s.x = item.x - 1; + s.y = item.y; + moveExplorer(hedge, t.ctx(), 'B', 'e'); + expect(viewOf(room, 'B')).toMatchObject({ inventory: [item.name], message: item.explorerText }); + // The Navigator gets no toast about it. + expect(t.ctx().toasts).toEqual([]); + expect(viewOf(room, 'A').items).toEqual([{ x: item.x, y: item.y, name: item.name, collected: true }]); + + // A trap sends them back with the key. + const [trap] = hedge.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : []))); + s.x = trap.x - 1; + s.y = trap.y; + if (hedge.grid[trap.y][trap.x - 1] === '#') s.x = trap.x + 1; + moveExplorer(hedge, t.ctx(), 'B', s.x < trap.x ? 'e' : 'w'); + expect(s.falls).toBe(1); + expect(s.inventory).toEqual([item.id]); + + s.x = door.x - 1; + s.y = door.y; + moveExplorer(hedge, t.ctx(), 'B', 'e'); + expect({ x: s.x, y: s.y }).toEqual({ x: door.x, y: door.y }); + expect(s.message).toBe(door.openText); + walk(room, t, 'we'); + expect(s.message).not.toBe(door.openText); // only the first time + }); + + it("never sends the Explorer where items and doors are", () => { + const room = playingRoom(hedge); + const keys = keysOf(viewOf(room, 'B')); + for (const key of ['items', 'doors', 'x', 'y', 'grid']) expect(keys).not.toContain(key); + }); + + it('validation: doors need a known item, items and doors sit on plain floor, and the gate must be passable', () => { + const game = structuredClone(hedge); + game.doors[0].needs = 'nothing'; + game.items[0].x = 0; // a wall + const errors = validateGame(game).join('\n'); + expect(errors).toMatch(/needs unknown item "nothing"/); + expect(errors).toMatch(/must be on plain floor/); + + // The key behind its own gate ((3,7) is only reachable through it): the gate's lantern is out of reach. + const locked = structuredClone(hedge); + locked.items[0] = { ...locked.items[0], x: 3, y: 7 }; + const lockedErrors = validateGame(locked).join('\n'); + expect(lockedErrors).toMatch(/item "Iron Gate Key" .* is not reachable/); + expect(lockedErrors).toMatch(/lantern at \(\d+,\d+\) is not reachable/); + }); +}); + +// ---------- defuse: shared manual and role swaps ---------- + +describe('defuse: Tick Tock: Switch', () => { + const sw = getGame('tick-tock-switch', 'defuse'); + const overtime = getGame('tick-tock-overtime', 'defuse'); + + it("uses Overtime's device, modules, manual and solver", () => { + expect(sw).toMatchObject({ manualFrom: 'tick-tock-overtime', ruleset: 'overtime', manual: overtime.manual }); + expect(sw.modules).toEqual(overtime.modules); + expect(sw.levels).toHaveLength(5); + expect(sw.levels[4].strikesOverride).toBe(1); + }); + + it('a missing manualFrom game fails to load', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'two-keys-')); + const file = path.join(dir, 'switch-copy.yaml'); + const raw = fs.readFileSync(path.join(__dirname, '../../content/games/tick-tock-switch.yaml'), 'utf8'); + fs.writeFileSync(file, raw.replace('id: tick-tock-switch', 'id: switch-copy')); + expect(loadGameFile(file).errors.join('\n')).toMatch(/manualFrom: game "tick-tock-overtime" is missing/); + fs.rmSync(dir, { recursive: true }); + }); + + it('swaps roles every level, behind a swap card both players tap Ready on', () => { + const room = playingRoom(sw); + const t = clock(room); + const s = room.engine as DefuseState; + expect(continueDefuse(sw, t.ctx()).ok).toBe(false); // the swap card first + levelReady(sw, t.ctx(), 'A'); + expect(viewOf(room, 'B').swapReady).toEqual({ you: false, partner: true }); + levelReady(sw, t.ctx(), 'B'); + expect(s.status).toBe('active'); + expect(viewOf(room, 'A').seat).toBe('technician'); + expect(viewOf(room, 'B').seat).toBe('expert'); + + // Clear level 1 with the solver's answers... or just jump to level 2. + s.status = 'cleared'; + continueDefuse(sw, t.ctx()); + expect(s).toMatchObject({ level: 1, status: 'ready', swapReady: [] }); + levelReady(sw, t.ctx(), 'A'); + levelReady(sw, t.ctx(), 'B'); + expect(viewOf(room, 'B')).toMatchObject({ seat: 'technician', technician: 'B' }); + const expert = stateOf(room, 'A').views.A!; + expect(expert).toMatchObject({ seat: 'expert', manual: sw.manual }); + expect(keysOf(expert)).not.toContain('device'); + // Only this level's Technician may touch the device. + expect(defuseAction(sw, t.ctx(), 'A', 'wires', { type: 'cut', index: 1 }).ok).toBe(false); + }); + + it('two wire modules on one level are generated and solved separately, as Wires A and Wires B', () => { + const room = playingRoom(sw); + const t = clock(room); + const s = room.engine as DefuseState; + s.level = 3; + levelReady(sw, t.ctx(), 'A'); + levelReady(sw, t.ctx(), 'B'); + const wires = wireModules(s.device!); + expect(wires.map((w) => [w.instance, w.title])).toEqual([ + ['wires-1', 'Wires A'], + ['wires-2', 'Wires B'], + ]); + const tech = technicianOf3(s); + const view = viewOf(room, tech, t.now); + expect(view.device!.modules.filter((m) => m.id === 'wires').map((m) => (m as { instance: string }).instance)).toEqual(['wires-1', 'wires-2']); + + const cut = (instance: string, i: number) => + defuseAction(sw, t.ctx(), tech, instance, { + type: 'cut', + index: wireSolution(sw.ruleset, edgework(s.device!), wires[i].module.colors).index, + }); + expect(cut('wires-2', 1).ok).toBe(true); + expect(wires[1].module.solved).toBe(true); + expect(wires[0].module.solved).toBe(false); + expect(cut('wires-1', 0).ok).toBe(true); + expect(wires[0].module.solved).toBe(true); + expect(s.strikes).toBe(0); + }); + + it('only wires may appear twice in a level', () => { + const game = structuredClone(sw); + game.levels[0].modules = ['keypad', 'keypad']; + expect(validateGame(game).join('\n')).toMatch(/only wires can appear more than once/); + }); +}); + +/** Level 4 (index 3) is an even level: B holds the device. */ +const technicianOf3 = (s: DefuseState): Role => (s.level % 2 === 1 ? 'B' : 'A'); + +// ---------- sync: Road Trip ---------- + +describe('sync: In Sync: Road Trip', () => { + it('plays every round to the end', () => { + const game = getGame('in-sync-road-trip', 'sync'); + const room = playingRoom(game); + const t = clock(room); + const s = room.engine as SyncState; + for (let r = 0; r < game.rounds.length; r++) { + continueSync(game, t.ctx()); // intro → question + for (let q = 0; q < s.questions.length; q++) { + const question = s.questions[s.q]; + const answer = game.rounds[r].type === 'telepathy' ? 'Road' : 0; + expect(answerSync(game, t.ctx(), 'A', question.id, answer).ok).toBe(true); + expect(answerSync(game, t.ctx(), 'B', question.id, answer).ok).toBe(true); + continueSync(game, t.ctx()); // reveal → next / summary + } + continueSync(game, t.ctx()); // summary → next round (or the end) + } + expect(room.keys).toEqual(game.rounds.map((r) => r.keyName)); + expect(room.result).toMatchObject({ success: true, ending: game.successEnding }); + }); +}); + +// ---------- over real sockets ---------- + +describe('dial over sockets', () => { + const app = createApp({ dataFile: null }); + let url = ''; + beforeAll(async () => { + url = `http://localhost:${await app.listen(0)}`; + }); + afterAll(async () => { + await app.close(); + }); + + it('never broadcasts the guesser\'s needle', async () => { + const make = () => { + const socket = connect(url, { transports: ['websocket'], forceNew: true }); + const states: RoomState[] = []; + socket.on('room:state', (st: RoomState) => states.push(st)); + return { socket, states }; + }; + const a = make(); + const b = make(); + const ack = (s: typeof a, event: string, payload: object) => + new Promise((resolve) => s.socket.emit(event, payload, resolve)); + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + try { + const created = await ack(a, 'room:create', { gameId: 'wavelength-tune-in' }); + if (!created.ok) throw new Error(created.message); + await ack(b, 'room:join', { code: created.code }); + a.socket.emit('room:pickRole', { role: 'A' }); + await wait(100); + a.socket.emit('room:ready'); + b.socket.emit('room:ready'); + await wait(150); + a.socket.emit('dial:clue', { text: 'zebra' }); + await wait(150); + const before = a.states.length; + b.socket.emit('dial:needle', { value: 77 }); + await wait(200); + expect(a.states.length).toBe(before); + expect(a.states.every((st) => JSON.stringify(st.views).indexOf('"needle":77') === -1)).toBe(true); + } finally { + a.socket.close(); + b.socket.close(); + } + }); +}); diff --git a/shared/package.json b/shared/package.json index 19faafa..384f651 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/shared", - "version": "1.2.2", + "version": "1.3.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/shared/src/types.ts b/shared/src/types.ts index 66de0e3..46c117f 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -9,7 +9,7 @@ export const otherRole = (role: Role): Role => (role === 'A' ? 'B' : 'A'); export type Seat = Role | 'both'; export type PlayMode = 'duo' | 'couch'; export type Phase = 'lobby' | 'playing' | 'finished'; -export type EngineId = 'story' | 'defuse' | 'sync' | 'labyrinth'; +export type EngineId = 'story' | 'defuse' | 'sync' | 'labyrinth' | 'dial' | 'twin' | 'blueprint'; export type Category = 'murder-mystery' | 'escape-room' | 'co-op'; export interface Theme { @@ -156,6 +156,10 @@ export interface DefuseClock { export interface WiresModuleView { id: 'wires'; + /** What `defuse:action` names this module: `wires`, or `wires-1` / `wires-2` when a level has two. */ + instance: string; + /** "Wires", or "Wires A" / "Wires B". */ + title: string; solved: boolean; wires: string[]; } @@ -217,6 +221,12 @@ interface DefuseCommon { strikes: number; maxStrikes: number; clock: DefuseClock | null; + /** Who is the Technician this level (it changes every level when roles swap). */ + technician: Role; + /** Roles swap every level: both tap Ready on the swap card, and the clock starts. */ + swapRoles: boolean; + /** Ready on the swap card: you, and your partner. */ + swapReady: { you: boolean; partner: boolean }; } export interface TechnicianView extends DefuseCommon { seat: 'technician'; @@ -301,6 +311,8 @@ export interface ExplorerView { message: string; moves: number; falls: number; + /** Items carried (picked up on entry, kept after traps). */ + inventory: string[]; } export interface NavigatorView { @@ -311,6 +323,8 @@ export interface NavigatorView { lanterns: { x: number; y: number; name: string; collected: boolean }[]; /** One-way gates: passable from (x, y) in `dir` only. */ oneWay: { x: number; y: number; dir: Dir }[]; + items: { x: number; y: number; name: string; collected: boolean }[]; + doors: { x: number; y: number; name: string }[]; banner: string | null; keysCarried: number; totalKeys: number; @@ -319,7 +333,116 @@ export interface NavigatorView { } export type LabyrinthView = ExplorerView | NavigatorView; -export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView; +// ---------- Dial engine (Wavelength) ---------- + +export interface DialBand { + within: number; + points: number; +} + +export interface DialRoundRecord { + left: string; + right: string; + clue: string; + giver: Role; + target: number; + guess: number; + points: number; +} + +export interface DialView { + engine: 'dial'; + status: 'clue' | 'guess' | 'reveal'; + round: number; + rounds: number; + /** Who gives the clue this round. */ + giver: Role; + iAmGiver: boolean; + /** A label the guesser may not see is null. */ + left: string | null; + right: string | null; + showNumbers: boolean; + range: [number, number]; + bands: DialBand[]; + rules: { maxWords: number; maxChars: number; banLabelWords: boolean; noRepeatWords: boolean }; + /** Clue words used so far (only kept when words may not repeat). */ + usedWords: string[]; + clue: string | null; + /** The clue-giver always; the guesser only once they've locked in. */ + target: number | null; + /** The guesser's own needle, so a refresh keeps it. Never sent to the clue-giver. */ + needle: number | null; + /** Once locked in. */ + guess: number | null; + points: number | null; + total: number; + maxTotal: number; + keys: { at: number; name: string; earned: boolean }[]; + history: DialRoundRecord[]; +} + +// ---------- Twin engine (Twin Rooms) ---------- + +export interface TwinView { + engine: 'twin'; + status: 'ready' | 'playing' | 'won' | 'failed'; + scene: { index: number; total: number; title: string; keyName: string; differences: number }; + /** Your own room, row by row, one emoji per cell. */ + grid: string[][]; + showCoordinates: boolean; + /** Found differences, in your own view's coordinates. */ + found: { x: number; y: number }[]; + /** Your pending tap, in your own coordinates, and how long it waits for your partner. */ + myTap: { x: number; y: number; secondsLeft: number } | null; + /** Your partner has a pending tap (never where). */ + partnerTap: { secondsLeft: number } | null; + /** Seconds left on the scene clock as the state was built. */ + remaining: number; + timeSeconds: number; + claimWindowSeconds: number; + wrongClaimPenaltySeconds: number; + /** A failed scene can be retried once. */ + canRetry: boolean; + isRetry: boolean; +} + +// ---------- Blueprint engine (Describe & Draw) ---------- + +export interface BlueprintColor { + key: string; + name: string; + hex: string; +} + +export interface BlueprintView { + engine: 'blueprint'; + status: 'ready' | 'building' | 'scored'; + seat: 'architect' | 'builder'; + architect: Role; + pattern: { index: number; total: number; size: number; keyName: string }; + palette: BlueprintColor[]; + /** The picture, row by row ('.' = empty). The Architect always; the Builder only once it's scored. */ + target: string[] | null; + /** Ditto. */ + title: string | null; + /** The Builder's canvas in target orientation. The Builder always; the Architect only in a peek or once scored. */ + canvas: string[] | null; + /** The Builder's canvas is shown rotated this many degrees clockwise. */ + rotateBuilder: number; + peeksLeft: number; + /** Server time a running peek ends. */ + peekUntil: number | null; + submitsLeft: number; + threshold: number; + /** The best score so far this pattern (0–1), once something was submitted. */ + bestScore: number | null; + lastScore: number | null; + passed: boolean | null; + remaining: number; + timeSeconds: number; +} + +export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView | DialView | TwinView | BlueprintView; // ---------- Room ----------