Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52745935ee | ||
|
|
4ff95e6018 |
@@ -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: <game-id>`** (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,9 +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
|
||||
- Sync: ≥15 questions per round type in use; every rank question has exactly 4 items
|
||||
- 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)
|
||||
@@ -269,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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Color+Emoji&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Two Keys · Co-op mysteries for couples</title>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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<GameSummary['engine'], string> = {
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
|
||||
<span className="chip text-accent">{chipLabel(game)}</span>
|
||||
<span className="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
|
||||
@@ -10,7 +10,8 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
|
||||
const used = lock.hints.length;
|
||||
const left = lock.hintsTotal - used;
|
||||
const nextIsLast = used === lock.hintsTotal - 1;
|
||||
const cost = `+${formatClock(lock.hintPenaltySeconds)}`;
|
||||
// A countdown loses time; a stopwatch gains it.
|
||||
const cost = `${lock.timeLimited ? '−' : '+'}${formatClock(lock.hintPenaltySeconds)}`;
|
||||
|
||||
const reveal = () => {
|
||||
setConfirming(false);
|
||||
@@ -30,8 +31,17 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
|
||||
|
||||
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
|
||||
<p className="mb-4 text-sm text-stone-400">
|
||||
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">{cost}</strong> to
|
||||
your time, and you both see it.
|
||||
Hints go from a gentle nudge to nearly the answer.{' '}
|
||||
{lock.timeLimited ? (
|
||||
<>
|
||||
Each one takes <strong className="text-stone-200">{formatClock(lock.hintPenaltySeconds)}</strong> off the time you
|
||||
have left, and you both see it.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Each one adds <strong className="text-stone-200">{cost}</strong> to your time, and you both see it.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<ol className="space-y-2">
|
||||
{lock.hints.map((hint, i) => (
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { BlueprintColor, BlueprintView, Role, RoomState } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { useNow } from '../lib/hooks';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: BlueprintView;
|
||||
state: RoomState;
|
||||
role: Role;
|
||||
receivedAt: number;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
const EMPTY = '.';
|
||||
const UNDO_LIMIT = 20;
|
||||
type Cell = { x: number; y: number; color: string | null };
|
||||
|
||||
const formatSeconds = (s: number) => `${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 (
|
||||
<div className="mx-auto w-full" style={{ maxWidth: `${size * 44 + 20}px` }}>
|
||||
<div
|
||||
className={`grid gap-px ${onPaint ? 'touch-none select-none' : ''}`}
|
||||
style={{ gridTemplateColumns: `16px repeat(${size}, minmax(0, 1fr))` }}
|
||||
role="grid"
|
||||
aria-label={label}
|
||||
onPointerUp={() => (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);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden />
|
||||
{Array.from({ length: size }, (_, c) => (
|
||||
<span key={c} className="text-center font-mono text-[10px] text-stone-500" aria-hidden>
|
||||
{c + 1}
|
||||
</span>
|
||||
))}
|
||||
{Array.from({ length: size }, (_, r) => (
|
||||
<div key={r} role="row" className="contents">
|
||||
<span className="flex items-center font-mono text-[10px] text-stone-500" aria-hidden>
|
||||
{r + 1}
|
||||
</span>
|
||||
{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 (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
data-c={c}
|
||||
data-r={r}
|
||||
tabIndex={onPaint ? 0 : -1}
|
||||
disabled={!onPaint}
|
||||
aria-label={`Row ${r + 1}, column ${c + 1}${fill ? '' : ', empty'}`}
|
||||
onPointerDown={(e) => {
|
||||
if (!onPaint) return;
|
||||
e.preventDefault();
|
||||
// A drag keeps painting: moves are hit-tested by position, wherever the pointer is captured.
|
||||
painting.current = true;
|
||||
onPaint(c, r, true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (onPaint && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onPaint(c, r, true);
|
||||
}
|
||||
}}
|
||||
className={`aspect-square min-h-[24px] ${gridlines ? 'border border-white/10' : ''} ${ring} disabled:cursor-default`}
|
||||
style={{ backgroundColor: fill ?? 'rgb(255 255 255 / 0.04)' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Palette({ palette, color, onPick }: { palette: BlueprintColor[]; color: string | null; onPick: (c: string | null) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-2" role="radiogroup" aria-label="Colour">
|
||||
{palette.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
role="radio"
|
||||
aria-checked={color === p.key}
|
||||
onClick={() => onPick(p.key)}
|
||||
className={`flex min-h-[44px] items-center gap-2 rounded-xl border px-3 py-1.5 text-sm font-semibold ${color === p.key ? 'border-accent bg-accent/20' : 'border-white/15 bg-white/5'}`}
|
||||
>
|
||||
<span className="h-5 w-5 rounded border border-white/30" style={{ backgroundColor: p.hex }} aria-hidden />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={color === null}
|
||||
onClick={() => onPick(null)}
|
||||
className={`flex min-h-[44px] items-center gap-2 rounded-xl border px-3 py-1.5 text-sm font-semibold ${color === null ? 'border-accent bg-accent/20' : 'border-white/15 bg-white/5'}`}
|
||||
>
|
||||
<span aria-hidden>🧽</span> Eraser
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string[]>(() => 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<string | null>(view.palette[0]?.key ?? null);
|
||||
const [undo, setUndo] = useState<string[][]>([]);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const queue = useRef(new Map<string, Cell>());
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="space-y-4">
|
||||
<Grid
|
||||
size={n}
|
||||
label={`Your canvas${view.rotateBuilder ? `, turned ${view.rotateBuilder}° clockwise` : ''}`}
|
||||
cellAt={(c, r) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<Palette palette={view.palette} color={color} onPick={setColor} />
|
||||
<div className="flex gap-2">
|
||||
<button className="btn flex-1" disabled={undo.length === 0} onClick={() => { restore(undo[undo.length - 1]); setUndo((u) => u.slice(0, -1)); }}>
|
||||
↶ Undo
|
||||
</button>
|
||||
<button
|
||||
className="btn flex-1"
|
||||
onClick={() => {
|
||||
pushUndo(latest.current);
|
||||
restore(Array.from({ length: n }, () => EMPTY.repeat(n)));
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="rounded-2xl border border-accent/50 bg-accent/10 p-4 text-center">
|
||||
<p className="font-semibold">
|
||||
Submit this canvas?{' '}
|
||||
{view.submitsLeft > 1 ? `You have ${view.submitsLeft} tries; the best counts.` : "It's your only try."}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-center gap-2">
|
||||
<button className="btn-primary" onClick={submit}>Yes, score it</button>
|
||||
<button className="btn" onClick={() => setConfirming(false)}>Keep painting</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn-primary w-full" onClick={() => setConfirming(true)} disabled={remaining <= 0}>
|
||||
Submit ({view.submitsLeft} {view.submitsLeft === 1 ? 'try' : 'tries'} left)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
Picture {view.pattern.index + 1} of {view.pattern.total}
|
||||
</div>
|
||||
<div className="font-display text-3xl font-bold uppercase tracking-wide">
|
||||
You're the <span className="text-accent">{seatName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view.status === 'ready') {
|
||||
return (
|
||||
<div className="space-y-5 text-center">
|
||||
{header}
|
||||
<div className="text-6xl" aria-hidden>{architect ? '📐' : '🖌️'}</div>
|
||||
<p className="text-stone-200">
|
||||
{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!` : ''}`}
|
||||
</p>
|
||||
<p className="text-sm text-stone-400">
|
||||
{n}×{n} grid, {formatSeconds(view.timeSeconds)}. Match {percent(view.threshold)} of the painted squares to earn the {view.pattern.keyName}.
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
Start the clock ⏱
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = (
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
Need <strong>{percent(view.threshold)}</strong>
|
||||
{view.bestScore !== null && <span className="text-stone-400"> · best {percent(view.bestScore)}</span>}
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-1 font-mono text-xl font-bold tabular-nums ${remaining <= 20 && view.status === 'building' ? 'border-red-400 text-red-300' : 'border-white/15'}`}
|
||||
role="timer"
|
||||
aria-label={`${formatSeconds(remaining)} left for this picture`}
|
||||
>
|
||||
{formatSeconds(remaining)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="space-y-4" aria-live="polite">
|
||||
{header}
|
||||
<div className={`rounded-2xl border-2 p-4 text-center ${view.passed ? 'border-emerald-500/70 bg-emerald-950/50' : 'border-red-500/70 bg-red-950/50'}`}>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-stone-300">It was…</div>
|
||||
<h3 className="font-display text-2xl font-bold">{view.title}</h3>
|
||||
<p className="mt-1 text-3xl font-bold">{percent(view.bestScore ?? 0)}</p>
|
||||
<p className="text-stone-300">{view.passed ? `🔑 ${view.pattern.keyName}!` : `Needed ${percent(view.threshold)}. No key this time.`}</p>
|
||||
</div>
|
||||
<Grid
|
||||
size={n}
|
||||
label="The picture over the canvas: green is right, red is wrong, dashed was missed"
|
||||
cellAt={(c, r) => {
|
||||
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 };
|
||||
}}
|
||||
/>
|
||||
<p className="text-center text-xs text-stone-400">Green: right. Red: wrong colour or shouldn't be painted. Dashed: missed.</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
{view.pattern.index === view.pattern.total - 1 ? 'See how we did →' : 'Next picture →'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{header}
|
||||
{status}
|
||||
{view.lastScore !== null && (
|
||||
<p className="text-center text-sm text-amber-200">Last try: {percent(view.lastScore)}. Keep going!</p>
|
||||
)}
|
||||
{architect && view.target ? (
|
||||
<>
|
||||
<Grid size={n} label="The picture to describe" cellAt={(c, r) => ({ fill: view.target![r][c] === EMPTY ? null : hex[view.target![r][c]] })} />
|
||||
<ul className="flex flex-wrap justify-center gap-2 text-sm">
|
||||
{view.palette.map((p) => (
|
||||
<li key={p.key} className="chip">
|
||||
<span className="mr-1 inline-block h-3 w-3 rounded-sm align-middle" style={{ backgroundColor: p.hex }} aria-hidden />
|
||||
{p.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{peekOn && view.canvas ? (
|
||||
<div className="rounded-2xl border border-accent/50 bg-accent/10 p-3">
|
||||
<p className="mb-2 text-center text-sm font-semibold text-accent">👀 Their canvas right now</p>
|
||||
<Grid size={n} label="The Builder's canvas" cellAt={(c, r) => ({ fill: view.canvas![r][c] === EMPTY ? null : hex[view.canvas![r][c]] })} />
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn w-full" disabled={view.peeksLeft === 0} onClick={() => send('blueprint:peek')}>
|
||||
👀 Peek at their canvas for 3s ({view.peeksLeft} left)
|
||||
</button>
|
||||
)}
|
||||
<p className="text-center text-sm text-stone-400">
|
||||
Describe it out loud: rows, columns, colours. {partnerName} submits when you're both happy.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Builder key={`${view.pattern.index}`} view={view} send={send} remaining={remaining} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ModuleFrame title="Wires" solved={module.solved}>
|
||||
<ModuleFrame title={module.title} solved={module.solved}>
|
||||
<ol className="space-y-2">
|
||||
{module.wires.map((color, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
@@ -359,7 +360,7 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) {
|
||||
{modules.map((m) => {
|
||||
switch (m.id) {
|
||||
case 'wires':
|
||||
return <Wires key={m.id} module={m} act={act(m.id)} />;
|
||||
return <Wires key={m.instance} module={m} act={act(m.instance)} />;
|
||||
case 'button':
|
||||
return <BigButton key={m.id} module={m} act={act(m.id)} />;
|
||||
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 = (
|
||||
<div className="card flex min-h-[60vh] flex-col items-center justify-center text-center" aria-live="polite">
|
||||
<div className="text-6xl" aria-hidden>{swapped ? '🔄' : technician ? '🧰' : '📖'}</div>
|
||||
<h2 className="mt-3 font-display text-3xl font-bold">
|
||||
{swapped ? 'Swap! ' : ''}You're {swapped ? 'now ' : ''}the <span className="text-accent">{seatName}</span>
|
||||
</h2>
|
||||
<p className="mt-2 text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-stone-400">
|
||||
{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.`}
|
||||
</p>
|
||||
<button
|
||||
className={`${view.swapReady.you ? 'btn' : 'btn-primary'} mt-5 w-full max-w-xs`}
|
||||
aria-pressed={view.swapReady.you}
|
||||
onClick={() => send('level:ready')}
|
||||
>
|
||||
{view.swapReady.you ? '✓ Ready (tap to undo)' : 'Ready'}
|
||||
</button>
|
||||
<p className="mt-2 text-sm text-stone-400">
|
||||
{view.swapReady.partner ? `${partner} is ready.` : `Waiting for ${partner}…`} The clock starts when you both are.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else if (view.status === 'ready') {
|
||||
banner = (
|
||||
<div className="card text-center">
|
||||
<div className="text-5xl" aria-hidden>💣</div>
|
||||
|
||||
@@ -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<SVGSVGElement>(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 (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<svg
|
||||
ref={svg}
|
||||
viewBox={`0 0 ${W} ${CY + 24}`}
|
||||
className={`w-full select-none ${interactive ? 'cursor-pointer touch-none' : ''}`}
|
||||
role={interactive ? 'slider' : 'img'}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-label={interactive ? 'Dial' : showTarget ? `Dial, target at ${view.target}` : 'Dial'}
|
||||
aria-valuemin={interactive ? lo : undefined}
|
||||
aria-valuemax={interactive ? hi : undefined}
|
||||
aria-valuenow={interactive && needle !== null ? needle : undefined}
|
||||
onKeyDown={interactive ? onKey : undefined}
|
||||
onPointerDown={(e) => {
|
||||
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)}
|
||||
>
|
||||
<path d={wedge(lo, hi)} fill="rgb(255 255 255 / 0.06)" stroke="rgb(255 255 255 / 0.2)" />
|
||||
{showTarget &&
|
||||
[...view.bands]
|
||||
.map((b, i) => ({ ...b, color: BAND_COLORS[i] ?? BAND_COLORS[BAND_COLORS.length - 1] }))
|
||||
.reverse()
|
||||
.map((b) => (
|
||||
<motion.path
|
||||
key={b.within}
|
||||
d={wedge(view.target! - b.within - 0.5, view.target! + b.within + 0.5)}
|
||||
fill={b.color}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
))}
|
||||
{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 (
|
||||
<text key={b.within} x={p.x} y={p.y} textAnchor="middle" dominantBaseline="middle" className="fill-stone-950 text-[9px] font-bold">
|
||||
{b.points}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{view.showNumbers &&
|
||||
ticks.map((t) => {
|
||||
const p = point(t, R + 11);
|
||||
return (
|
||||
<text key={t} x={p.x} y={Math.min(p.y, CY + 14)} textAnchor="middle" className="fill-stone-400 text-[10px]">
|
||||
{Math.round(t)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{tip && (
|
||||
<g>
|
||||
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y} stroke="#f5f5f4" strokeWidth={4} strokeLinecap="round" />
|
||||
<circle cx={CX} cy={CY} r={9} fill="#f5f5f4" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
<div className="mt-1 flex justify-between gap-4 text-sm font-semibold">
|
||||
<span className="max-w-[45%]">← {view.left ?? STATIC}</span>
|
||||
<span className="max-w-[45%] text-right">{view.right ?? STATIC} →</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyProgress({ view }: { view: DialView }) {
|
||||
const pct = (n: number) => `${Math.min(100, (n / view.maxTotal) * 100)}%`;
|
||||
return (
|
||||
<div aria-label={`${view.total} points. Keys at ${view.keys.map((k) => k.at).join(', ')}.`}>
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="font-bold">{view.total} points</span>
|
||||
<span className="text-stone-400">Round {view.round + 1} of {view.rounds}</span>
|
||||
</div>
|
||||
<div className="relative mt-1 h-2.5 rounded-full bg-white/10">
|
||||
<motion.div className="h-full rounded-full bg-accent" animate={{ width: pct(view.total) }} />
|
||||
{view.keys.map((k) => (
|
||||
<span
|
||||
key={k.at}
|
||||
className={`absolute -top-2 -translate-x-1/2 text-base ${k.earned ? '' : 'opacity-40 grayscale'}`}
|
||||
style={{ left: pct(k.at) }}
|
||||
title={`${k.name}: ${k.at} points`}
|
||||
aria-hidden
|
||||
>
|
||||
🔑
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={submit} className="space-y-2">
|
||||
<label htmlFor="dial-clue" className="block text-sm font-semibold">
|
||||
Your clue: something that sits right at the target
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="dial-clue"
|
||||
className="field text-lg"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
maxLength={maxChars}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={maxWords === 1 ? 'One word' : `Up to ${maxWords} words`}
|
||||
/>
|
||||
<button type="submit" className="btn-primary shrink-0" disabled={!text.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400">
|
||||
{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.'}
|
||||
</p>
|
||||
{noRepeatWords && view.usedWords.length > 0 && (
|
||||
<p className="text-xs text-stone-500">Used: {view.usedWords.join(', ').toLowerCase()}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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<number>(view.needle ?? Math.round((view.range[0] + view.range[1]) / 2));
|
||||
const lastSent = useRef(0);
|
||||
const pending = useRef<number | null>(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 (
|
||||
<div className="space-y-5">
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
{view.iAmGiver ? 'You give the clue' : `${giverName} gives the clue`}
|
||||
</div>
|
||||
</div>
|
||||
<KeyProgress view={view} />
|
||||
|
||||
{view.status === 'clue' && view.iAmGiver && (
|
||||
<>
|
||||
<Dial view={view} needle={null} interactive={false} />
|
||||
<p className="text-center text-sm text-stone-400">Only you can see the target. {partnerName} will turn the dial from your clue.</p>
|
||||
<ClueForm key={view.round} view={view} send={send} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.status === 'clue' && !view.iAmGiver && (
|
||||
<>
|
||||
<Dial view={view} needle={null} interactive={false} />
|
||||
<p className="card text-center text-stone-300" aria-live="polite">
|
||||
{giverName} is thinking of a clue… 🤔
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.status === 'guess' && (
|
||||
<div className="card text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-stone-400">The clue</div>
|
||||
<p className="mt-1 font-display text-3xl font-bold">“{view.clue}”</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.status === 'guess' && view.iAmGiver && (
|
||||
<>
|
||||
<Dial view={view} needle={null} interactive={false} />
|
||||
<p className="text-center text-sm text-stone-400" aria-live="polite">
|
||||
{partnerName} is turning the dial. 🤫 No hints, no faces!
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.status === 'guess' && !view.iAmGiver && (
|
||||
<>
|
||||
<p className="text-center text-sm font-semibold text-accent">🤫 Clue only! Drag the needle (or use the arrow keys).</p>
|
||||
<Dial view={view} needle={needle} onNeedle={moveNeedle} interactive />
|
||||
{view.showNumbers && <p className="text-center font-mono text-2xl font-bold tabular-nums">{needle}</p>}
|
||||
<button className="btn-primary w-full" onClick={() => send('dial:lock', { value: needle })}>
|
||||
Lock in 🔒
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.status === 'reveal' && (
|
||||
<div className="space-y-4" aria-live="polite">
|
||||
<Dial view={view} needle={view.guess} interactive={false} />
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<p className="font-display text-3xl font-bold">
|
||||
{view.points ? `+${view.points} point${view.points === 1 ? '' : 's'}` : 'No points'}
|
||||
</p>
|
||||
<p className="mt-1 text-stone-300">
|
||||
Target {view.target}, guess {view.guess}: {Math.abs((view.target ?? 0) - (view.guess ?? 0))} away.
|
||||
</p>
|
||||
</motion.div>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
{last ? 'See how we did →' : 'Next round →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.history.length > 0 && (
|
||||
<details className="card text-sm">
|
||||
<summary className="cursor-pointer font-semibold">Rounds so far</summary>
|
||||
<ol className="mt-2 space-y-1 text-stone-300">
|
||||
{view.history.map((h, i) => (
|
||||
<li key={i}>
|
||||
{i + 1}. {h.left} ↔ {h.right}: “{h.clue}” ({roles[h.giver].name}). Target {h.target}, guess {h.guess},{' '}
|
||||
<strong>{h.points} pts</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
@@ -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 && <span className="no-underline"> ✓</span>}
|
||||
</li>
|
||||
))}
|
||||
{view.items.map((it) => (
|
||||
<li key={it.name} className={`flex gap-2 ${it.collected ? 'text-stone-500 line-through' : ''}`}>
|
||||
<span className="w-6 text-center" aria-hidden>🗝</span>
|
||||
{it.name}
|
||||
{it.collected && <span className="no-underline"> ✓</span>}
|
||||
</li>
|
||||
))}
|
||||
{view.doors.map((d) => (
|
||||
<li key={d.name} className="flex gap-2">
|
||||
<span className="w-6 text-center" aria-hidden>🚪</span>
|
||||
{d.name}: shut until they carry its key
|
||||
</li>
|
||||
))}
|
||||
{view.oneWay.length > 0 && (
|
||||
<li className="flex gap-2 text-accent"><span className="w-6 text-center" aria-hidden>→</span>One-way gate. Only opens the way the arrow points.</li>
|
||||
)}
|
||||
<li className="flex gap-2 text-red-300"><span className="w-6 text-center" aria-hidden>☠</span>Trap. They can't see it!</li>
|
||||
<li className="flex gap-2"><span className="w-6 text-center" aria-hidden>🚪</span>Exit (needs all {view.totalKeys} lanterns)</li>
|
||||
<li className="flex gap-2"><span className="w-6 text-center" aria-hidden>{view.doors.length > 0 ? '🟩' : '🚪'}</span>Exit (needs all {view.totalKeys} lanterns)</li>
|
||||
<li className="flex gap-2 text-stone-400"><span className="w-6 text-center" aria-hidden>▪</span>Most floor is bare stone</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -163,6 +180,14 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
|
||||
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
|
||||
<div className="text-sm text-stone-300">{view.moves} moves</div>
|
||||
</div>
|
||||
{view.inventory.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm" aria-label="Inventory">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-stone-400">Carrying</span>
|
||||
{view.inventory.map((name) => (
|
||||
<span key={name} className="chip">🗝 {name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="rounded-2xl border border-accent/30 bg-[radial-gradient(circle_at_center,rgb(var(--accent)/0.22),transparent_70%)] p-6 text-center"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Role, RoomState, TwinView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { useNow } from '../lib/hooks';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: TwinView;
|
||||
state: RoomState;
|
||||
role: Role;
|
||||
receivedAt: number;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
// Symbols that render as plain text unless asked for their emoji form.
|
||||
const TEXT_DEFAULT = new Set(['🛏', '🕯', '🖼', '☂', '✉', '✏', '☘', '🛋', '🕰', '🗝', '⏱', '☕', '⏰', '🍽', '🪑', '🕶', '☀', '❄', '⚱', '🏺', '♠', '♥', '♦', '♣']);
|
||||
/** Ask for the emoji form, so both phones draw the same picture. */
|
||||
const asEmoji = (cell: string) => (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 = (
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
Room {view.scene.index + 1} of {view.scene.total}
|
||||
{view.isRetry && ' · Retry'}
|
||||
</div>
|
||||
<h2 className="font-display text-3xl font-bold">{view.scene.title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view.status === 'ready') {
|
||||
return (
|
||||
<div className="space-y-5 text-center">
|
||||
{header}
|
||||
<p className="text-lg text-stone-200">
|
||||
{view.scene.differences} differences, {formatSeconds(view.timeSeconds)} on the clock.
|
||||
</p>
|
||||
<p className="text-sm text-stone-400">
|
||||
Describe your room to {partnerName}, square by square. When you spot a difference, you <strong>both</strong> tap
|
||||
that square within {view.claimWindowSeconds} seconds. A wrong claim costs {view.wrongClaimPenaltySeconds}s.
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
Open the room 🚪
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{header}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-display text-xl font-bold" aria-label={`${view.found.length} of ${view.scene.differences} differences found`}>
|
||||
{view.found.length} / {view.scene.differences}
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-1 font-mono text-xl font-bold tabular-nums ${remaining <= 30 && playing ? 'border-red-400 text-red-300' : 'border-white/15'}`}
|
||||
role="timer"
|
||||
aria-label={`${formatSeconds(remaining)} left in this room`}
|
||||
>
|
||||
{formatSeconds(remaining)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrolls inside its card if it ever has to, never the page. */}
|
||||
<div className="overflow-x-auto rounded-xl border border-accent/30 bg-black/40 p-1.5">
|
||||
<div
|
||||
className="mx-auto grid w-full gap-0.5 font-emoji"
|
||||
// Cells shrink to 30px at the narrowest (10 columns fit a 375px phone) and stop growing at 48px.
|
||||
style={{
|
||||
gridTemplateColumns: `${view.showCoordinates ? 'auto ' : ''}repeat(${width}, minmax(30px, 1fr))`,
|
||||
maxWidth: `${width * 48 + (view.showCoordinates ? 20 : 0)}px`,
|
||||
}}
|
||||
role="grid"
|
||||
aria-label="Your room. Tap a square to claim a difference."
|
||||
>
|
||||
{view.showCoordinates && (
|
||||
<>
|
||||
<span aria-hidden />
|
||||
{Array.from({ length: width }, (_, x) => (
|
||||
<span key={x} className="text-center font-mono text-[11px] text-stone-400" aria-hidden>
|
||||
{x + 1}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{view.grid.map((row, y) => (
|
||||
<div key={y} role="row" className="contents">
|
||||
{view.showCoordinates && (
|
||||
<span className="flex items-center pr-1 font-mono text-[11px] text-stone-400" aria-hidden>
|
||||
{rows[y]}
|
||||
</span>
|
||||
)}
|
||||
{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 (
|
||||
<button
|
||||
key={x}
|
||||
role="gridcell"
|
||||
disabled={!playing || isFound}
|
||||
onClick={() => send('twin:tap', { x, y })}
|
||||
aria-label={`${name}: ${cell}${isFound ? ', found' : isMine ? ', tapped' : ''}`}
|
||||
className={`relative flex aspect-square items-center justify-center rounded-md text-[clamp(18px,6vw,28px)] leading-none transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
isFound ? 'bg-emerald-500/30 ring-2 ring-emerald-400' : 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{asEmoji(cell)}
|
||||
{isMine && <span className="absolute inset-0 rounded-md ring-4 ring-accent motion-safe:animate-pulse" aria-hidden />}
|
||||
{isFound && <span className="absolute -right-0.5 -top-1 text-xs" aria-hidden>✅</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-stone-400" aria-live="polite">
|
||||
{!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!'}
|
||||
</p>
|
||||
|
||||
{view.status === 'won' && (
|
||||
<div className="rounded-2xl border-2 border-emerald-500/70 bg-emerald-950/50 p-5 text-center" aria-live="polite">
|
||||
<div className="text-5xl" aria-hidden>🔑</div>
|
||||
<h3 className="mt-2 font-display text-2xl font-bold">Every difference found! {view.scene.keyName}</h3>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
{view.scene.index === view.scene.total - 1 ? 'See how we did →' : 'Next room →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{view.status === 'failed' && (
|
||||
<div className="rounded-2xl border-2 border-red-500/70 bg-red-950/50 p-5 text-center" aria-live="polite">
|
||||
<div className="text-5xl" aria-hidden>⏰</div>
|
||||
<h3 className="mt-2 font-display text-2xl font-bold">Out of time in this room</h3>
|
||||
<p className="mt-1 text-stone-300">
|
||||
{view.canRetry ? 'You get one retry, with the clock reset.' : 'No key for this room.'}
|
||||
</p>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
{view.canRetry ? 'Try this room again ↻' : view.scene.index === view.scene.total - 1 ? 'See how we did →' : 'Next room →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-1
@@ -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<Category, string> = {
|
||||
'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<LibraryFilter, string> = {
|
||||
...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<Role, string> = { A: 'text-roleA', B: 'text-roleB' };
|
||||
export const ROLE_BORDER: Record<Role, string> = { A: 'border-roleA/60', B: 'border-roleB/60' };
|
||||
|
||||
@@ -25,7 +25,8 @@ const STEPS = [
|
||||
];
|
||||
|
||||
const TIPS = [
|
||||
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes on your final time, and you both see it.'],
|
||||
['💡 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.'],
|
||||
|
||||
@@ -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<Category | 'all'>('all');
|
||||
const [filter, setFilter] = useState<LibraryFilter | 'all'>('all');
|
||||
const [level, setLevel] = useState<number | null>(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() {
|
||||
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
|
||||
{FILTERS.map((f) => (
|
||||
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
|
||||
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
|
||||
{f === 'all' ? 'All games' : FILTER_LABEL[f]}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-6">
|
||||
<header className="text-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-stone-300">
|
||||
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
|
||||
<span className="chip text-accent">{chipLabel(game)}</span>
|
||||
<span>⏱ {game.estimatedMinutes} min</span>
|
||||
<span aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}`}>{difficultyKeys(game.difficulty)}</span>
|
||||
{game.timeLimitMinutes !== null && <span className="text-red-300">⏳ {game.timeLimitMinutes} min limit</span>}
|
||||
|
||||
@@ -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<Role, string> = { A: '#f5b942', B: '#2dd4bf' };
|
||||
@@ -82,6 +85,11 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
|
||||
{view?.engine === 'defuse' && <DefuseGame view={view} state={state} receivedAt={receivedAt} send={send} />}
|
||||
{view?.engine === 'sync' && <SyncGame view={view} state={state} role={activeRole} send={send} />}
|
||||
{view?.engine === 'labyrinth' && <LabyrinthGame view={view} state={state} send={send} />}
|
||||
{view?.engine === 'dial' && <DialGame view={view} state={state} role={activeRole} send={send} />}
|
||||
{view?.engine === 'twin' && <TwinGame view={view} state={state} role={activeRole} receivedAt={receivedAt} send={send} />}
|
||||
{view?.engine === 'blueprint' && (
|
||||
<BlueprintGame view={view} state={state} role={activeRole} receivedAt={receivedAt} send={send} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!couch && <PingOverlay roles={game.roles} />}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -232,9 +232,9 @@ finale:
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: gunsolo, label: Shot with Vincent's .38 under the drum solo, gun hidden in the piano }
|
||||
- { id: gunsolo, label: "Shot with Vincent's .38 under the drum solo, gun hidden in the piano" }
|
||||
- { id: alley, label: Shot from the alley through the office window }
|
||||
- { id: poison, label: Poison in his whiskey, gunshot staged later }
|
||||
- { id: poison, label: "Poison in his whiskey, gunshot staged later" }
|
||||
answer: gunsolo
|
||||
- id: why
|
||||
label: Why?
|
||||
|
||||
@@ -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.** 🎨🔑
|
||||
@@ -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.** 🎨🔑
|
||||
@@ -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.** 🎨🔑
|
||||
@@ -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**.
|
||||
@@ -311,7 +311,7 @@ finale:
|
||||
label: How?
|
||||
options:
|
||||
- { id: tea, label: Peanut in the tea Zanele served him at 23:24 }
|
||||
- { id: honey, label: Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card }
|
||||
- { id: honey, label: "Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card" }
|
||||
- { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day }
|
||||
- { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers }
|
||||
answer: honey
|
||||
|
||||
@@ -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**.
|
||||
@@ -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**.
|
||||
@@ -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. 🏮🔑
|
||||
@@ -90,10 +90,10 @@ predict:
|
||||
- { id: d09, q: "{subject}'s happiest childhood memory involves...", options: [A holiday, A family tradition, A friend, A pet] }
|
||||
- { id: d10, q: "What would {subject} want written about them one day?", options: [They were generous, They were brave, They made people laugh, They loved well] }
|
||||
- { id: d11, q: "What drains {subject} the fastest?", options: [Conflict, Crowds, Boredom, Being rushed] }
|
||||
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, A full, noisy home, Building something together, Peace and quiet somewhere beautiful] }
|
||||
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, "A full, noisy home", Building something together, Peace and quiet somewhere beautiful] }
|
||||
- { id: d13, q: "What does {subject} think is our biggest strength as a couple?", options: [We laugh a lot, We fight fair, We have the same values, We're a good team] }
|
||||
- { id: d14, q: "When {subject} is upset with me, they usually...", options: [Tell me straight away, Go quiet first, Joke about it, Wait until later to bring it up] }
|
||||
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, A craft (woodwork, pottery, etc.)] }
|
||||
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, "A craft (woodwork, pottery, etc.)"] }
|
||||
- { id: d16, q: "What does {subject} spend the most time thinking about?", options: [The future, The past, Other people, Ideas and projects] }
|
||||
- { id: d17, q: "If {subject} wrote a book, it'd be...", options: [A thriller, A memoir, A how-to, A fantasy epic] }
|
||||
- { id: d18, q: "{subject}'s ideal way to celebrate a big win?", options: [Fancy dinner, Big party, Quiet night in, Buy something special] }
|
||||
|
||||
@@ -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.** 🚗🔑
|
||||
@@ -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?)
|
||||
@@ -384,15 +384,15 @@ finale:
|
||||
options:
|
||||
- { id: snake, label: An accidental puff adder bite }
|
||||
- { id: same, label: Drugged and killed by the same killer to silence him }
|
||||
- { id: other, label: Killed by a second, different killer }
|
||||
- { id: other, label: "Killed by a second, different killer" }
|
||||
- { id: illness, label: Heart failure from the heat }
|
||||
answer: same
|
||||
- id: how
|
||||
label: How did Marlowe die?
|
||||
options:
|
||||
- { id: fall, label: He fell into the pit in the dark }
|
||||
- { id: struck, label: Lured to the dig at 02:00, struck on the head, then put in the pit }
|
||||
- { id: poison, label: Poisoned at dinner, found dead at the pit }
|
||||
- { id: struck, label: "Lured to the dig at 02:00, struck on the head, then put in the pit" }
|
||||
- { id: poison, label: "Poisoned at dinner, found dead at the pit" }
|
||||
- { id: shot, label: Shot with the Captain's rifle }
|
||||
answer: struck
|
||||
- id: why
|
||||
|
||||
@@ -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!** 🎩🔑
|
||||
@@ -281,7 +281,7 @@ finale:
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: dagger, label: Stabbed with the armoury dagger, then buried it in a dry cactus pot }
|
||||
- { id: dagger, label: "Stabbed with the armoury dagger, then buried it in a dry cactus pot" }
|
||||
- { id: poison, label: Poison in his champagne }
|
||||
- { id: lake, label: Drowned in the conservatory pond }
|
||||
- { id: pistol, label: Shot with a silenced pistol }
|
||||
|
||||
@@ -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?)
|
||||
@@ -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**.
|
||||
@@ -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?)
|
||||
@@ -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!** 🌳🔑
|
||||
@@ -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. 💣🔑
|
||||
@@ -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.
|
||||
@@ -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.** 🪞🔑
|
||||
@@ -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.** 🪞🔑
|
||||
@@ -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.** 🪞🔑
|
||||
@@ -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. 🍷🔑
|
||||
@@ -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. 📡🔑
|
||||
@@ -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. 📻🔑
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "two-keys",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "two-keys",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.2.1",
|
||||
"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.1",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"express": "^4.21.0",
|
||||
@@ -7332,7 +7332,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "two-keys",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"description": "Co-op mystery games for couples",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -33,7 +33,9 @@ export function loadGameFile(file: string): LoadedFile {
|
||||
} catch (err) {
|
||||
return { file: name, game: null, errors: [`YAML parse error: ${(err as Error).message}`] };
|
||||
}
|
||||
const parsed = gameSchema.safeParse(raw);
|
||||
const borrowed = borrowManual(file, raw);
|
||||
if (borrowed.error) return { file: name, game: null, errors: [borrowed.error] };
|
||||
const parsed = gameSchema.safeParse(borrowed.raw);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
|
||||
return { file: name, game: null, errors };
|
||||
@@ -45,6 +47,28 @@ export function loadGameFile(file: string): LoadedFile {
|
||||
return { file: name, game, errors };
|
||||
}
|
||||
|
||||
/** What a defuse game with `manualFrom` takes from that game's file. */
|
||||
const MANUAL_FIELDS = ['ruleset', 'device', 'modules', 'manual', 'fixtures'] as const;
|
||||
|
||||
/** `manualFrom: <game-id>` reuses that game's device, modules, manual, ruleset and fixtures (and so its solver). */
|
||||
function borrowManual(file: string, raw: unknown): { raw: unknown; error?: string } {
|
||||
const own = raw as Record<string, unknown> | null;
|
||||
if (!own || typeof own !== 'object' || own.engine !== 'defuse' || typeof own.manualFrom !== 'string') return { raw };
|
||||
const source = path.join(path.dirname(file), `${own.manualFrom}.yaml`);
|
||||
let base: Record<string, unknown>;
|
||||
try {
|
||||
base = yaml.load(fs.readFileSync(source, 'utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { raw, error: `manualFrom: game "${own.manualFrom}" is missing or unreadable` };
|
||||
}
|
||||
if (base?.engine !== 'defuse' || base.manualFrom) {
|
||||
return { raw, error: `manualFrom: "${own.manualFrom}" must be a defuse game with its own manual` };
|
||||
}
|
||||
const merged = { ...own };
|
||||
for (const field of MANUAL_FIELDS) if (field in base) merged[field] = base[field];
|
||||
return { raw: merged };
|
||||
}
|
||||
|
||||
export function loadAllFiles(dir = gamesDir()): LoadedFile[] {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Builds everything that leaves the server. Views are assembled field by field (never by
|
||||
// copying a content object and deleting from it), so answers can't leak by accident.
|
||||
import type {
|
||||
BlueprintView,
|
||||
ChapterView,
|
||||
ClueView,
|
||||
DefuseModuleView,
|
||||
DefuseView,
|
||||
DialView,
|
||||
Dir,
|
||||
EngineView,
|
||||
GameMeta,
|
||||
@@ -15,10 +17,13 @@ import type {
|
||||
Role,
|
||||
StoryView,
|
||||
SyncView,
|
||||
TwinView,
|
||||
} from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { maxStrikes, remainingAt, toSignal, type DefuseState } from '../engines/defuse';
|
||||
import { totalKeys } from '../engines/index';
|
||||
import { architectOf, patternRemaining, peeking, type BlueprintState } from '../engines/blueprint';
|
||||
import { maxStrikes, remainingAt, technicianOf, toSignal, wireModules, type DefuseState } from '../engines/defuse';
|
||||
import { giverOf, maxTotal, spectrumOf, type DialState } from '../engines/dial';
|
||||
import { timeLimitSeconds, totalKeys } from '../engines/index';
|
||||
import {
|
||||
DELTAS,
|
||||
OPPOSITE,
|
||||
@@ -32,8 +37,29 @@ import {
|
||||
} from '../engines/labyrinth';
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +102,7 @@ function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView {
|
||||
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
|
||||
hintsTotal: lock.hints.length,
|
||||
hintPenaltySeconds: hintPenalty(game),
|
||||
timeLimited: timeLimitSeconds(game) !== null,
|
||||
wrongAttempts,
|
||||
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
|
||||
successText: solved ? (lock.successText ?? null) : null,
|
||||
@@ -146,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 },
|
||||
@@ -156,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',
|
||||
@@ -285,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,
|
||||
@@ -320,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,5 +474,11 @@ export function engineView(game: Game, room: Room, role: Role, now: number): Eng
|
||||
return syncView(game, room.engine as SyncState, role);
|
||||
case 'labyrinth':
|
||||
return labyrinthView(game, room.engine as LabyrinthState, role);
|
||||
case 'dial':
|
||||
return dialView(game, room.engine as DialState, role);
|
||||
case 'twin':
|
||||
return twinView(game, room.engine as TwinState, role, now);
|
||||
case 'blueprint':
|
||||
return blueprintView(game, room.engine as BlueprintState, role, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,14 @@ const clueSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) });
|
||||
/** 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();
|
||||
|
||||
const lockSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -92,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
|
||||
@@ -115,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,
|
||||
@@ -155,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({
|
||||
@@ -238,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 ----------
|
||||
@@ -255,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. */
|
||||
@@ -282,17 +312,118 @@ const labyrinthSchema = z.object({
|
||||
successEnding: z.string().min(1),
|
||||
});
|
||||
|
||||
// ---------- dial (Wavelength) ----------
|
||||
|
||||
const dialSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('dial'),
|
||||
rules: z.object(timeLimitRules).default({}),
|
||||
dial: z.object({
|
||||
rounds: z.number().int().positive(),
|
||||
range: z.tuple([z.number().int(), z.number().int()]),
|
||||
/** The hidden target is a random integer at least this far inside the range. */
|
||||
targetMargin: z.number().int().nonnegative(),
|
||||
/** Distance from the target → points; the first band that fits wins, outside all = 0. */
|
||||
scoring: z.array(z.object({ within: z.number().nonnegative(), points: z.number().int().positive() })).min(1),
|
||||
clue: z.object({
|
||||
maxWords: z.number().int().positive(),
|
||||
maxChars: z.number().int().positive(),
|
||||
banLabelWords: z.boolean(),
|
||||
noRepeatWords: z.boolean(),
|
||||
}),
|
||||
guesserSeesLabels: z.enum(['both', 'left', 'none']),
|
||||
guesserSeesNumbers: z.boolean(),
|
||||
/** Cumulative points thresholds. */
|
||||
keys: z.array(z.object({ at: z.number().int().positive(), name: z.string().min(1) })).min(1),
|
||||
}),
|
||||
spectra: z.array(z.object({ id: z.string().min(1), left: z.string().min(1), right: z.string().min(1) })).min(1),
|
||||
successEnding: z.string().min(1),
|
||||
});
|
||||
|
||||
// ---------- twin (Twin Rooms) ----------
|
||||
|
||||
const twinSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('twin'),
|
||||
rules: z.object(timeLimitRules).default({}),
|
||||
twin: z.object({
|
||||
claimWindowSeconds: z.number().positive(),
|
||||
wrongClaimPenaltySeconds: z.number().nonnegative(),
|
||||
/** B's room is shown flipped left to right. */
|
||||
mirrorB: z.boolean().default(false),
|
||||
showCoordinates: z.boolean().default(false),
|
||||
}),
|
||||
scenes: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
timeSeconds: z.number().int().positive(),
|
||||
keyName: z.string().min(1),
|
||||
differences: z.number().int().positive().optional(),
|
||||
/** A's room: one row per string, cells separated by single spaces (never split by character). */
|
||||
grid: z.array(z.string().min(1)).min(1),
|
||||
/** SERVER ONLY: B's room is the grid with these cells replaced. */
|
||||
changes: z.array(z.object({ x: z.number().int(), y: z.number().int(), to: z.string().min(1) })).min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
successEnding: z.string().min(1),
|
||||
});
|
||||
|
||||
// ---------- blueprint (Describe & Draw) ----------
|
||||
|
||||
const blueprintSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('blueprint'),
|
||||
rules: z.object(timeLimitRules).default({}),
|
||||
blueprint: z.object({
|
||||
scoring: z.literal('overlap'),
|
||||
/** The Builder's canvas is shown rotated clockwise; the server stores it in target orientation. */
|
||||
rotateBuilder: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).default(0),
|
||||
swapRolesEachPattern: z.boolean().default(false),
|
||||
submitsPerPattern: z.number().int().positive().default(1),
|
||||
}),
|
||||
patterns: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
/** SERVER ONLY for the Builder until the pattern is scored. */
|
||||
title: z.string().min(1),
|
||||
size: z.number().int().min(2).max(16),
|
||||
timeSeconds: z.number().int().positive(),
|
||||
threshold: z.number(),
|
||||
peeks: z.number().int().nonnegative(),
|
||||
keyName: z.string().min(1),
|
||||
palette: z.record(
|
||||
z.string().regex(/^[A-Z]$/, 'palette keys are single capital letters'),
|
||||
z.object({ name: z.string().min(1), hex }),
|
||||
),
|
||||
/** '.' = empty. SERVER ONLY for the Builder. */
|
||||
rows: z.array(z.string()).min(2),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
successEnding: z.string().min(1),
|
||||
});
|
||||
|
||||
export const gameSchema = z.discriminatedUnion('engine', [
|
||||
storySchema,
|
||||
defuseSchema,
|
||||
syncSchema,
|
||||
labyrinthSchema,
|
||||
dialSchema,
|
||||
twinSchema,
|
||||
blueprintSchema,
|
||||
]);
|
||||
|
||||
export type StoryGame = z.infer<typeof storySchema>;
|
||||
export type DefuseGame = z.infer<typeof defuseSchema>;
|
||||
export type SyncGame = z.infer<typeof syncSchema>;
|
||||
export type LabyrinthGame = z.infer<typeof labyrinthSchema>;
|
||||
export type DialGame = z.infer<typeof dialSchema>;
|
||||
export type TwinGame = z.infer<typeof twinSchema>;
|
||||
export type BlueprintGame = z.infer<typeof blueprintSchema>;
|
||||
export type Game = z.infer<typeof gameSchema>;
|
||||
export type Clue = z.infer<typeof clueSchema>;
|
||||
export type Lock = z.infer<typeof lockSchema>;
|
||||
|
||||
@@ -10,8 +10,21 @@ import {
|
||||
wireSolution,
|
||||
} from '../engines/defuse';
|
||||
import { DELTAS, analyseMaze, cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
|
||||
import { maxTotal } from '../engines/dial';
|
||||
import { groupOf, pointsForKey } from '../engines/sync';
|
||||
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
|
||||
import { gridA } from '../engines/twin';
|
||||
import type {
|
||||
BlueprintGame,
|
||||
Clue,
|
||||
DefuseGame,
|
||||
DialGame,
|
||||
Game,
|
||||
LabyrinthGame,
|
||||
Lock,
|
||||
StoryGame,
|
||||
SyncGame,
|
||||
TwinGame,
|
||||
} from './schemas';
|
||||
|
||||
export const MIN_SYNC_QUESTIONS = 15;
|
||||
|
||||
@@ -26,9 +39,99 @@ export function validateGame(game: Game): string[] {
|
||||
return validateSync(game);
|
||||
case 'labyrinth':
|
||||
return validateLabyrinth(game);
|
||||
case 'dial':
|
||||
return validateDial(game);
|
||||
case 'twin':
|
||||
return validateTwin(game);
|
||||
case 'blueprint':
|
||||
return validateBlueprint(game);
|
||||
}
|
||||
}
|
||||
|
||||
/** A one-line summary for the validate-content report. */
|
||||
export function describeGame(game: Game): string {
|
||||
switch (game.engine) {
|
||||
case 'story':
|
||||
return `${game.chapters.length} chapters + ${game.finale.type}`;
|
||||
case 'defuse':
|
||||
return `${game.levels.length} levels${game.manualFrom ? `, manual from ${game.manualFrom}` : ''}${game.swapRolesEachLevel ? ', roles swap' : ''}`;
|
||||
case 'sync':
|
||||
return `${game.rounds.length} rounds: ${game.rounds.map((r) => r.type).join(', ')}`;
|
||||
case 'labyrinth': {
|
||||
const traps = findCells(game.grid, 'T').length;
|
||||
return `${game.grid[0].length}×${game.grid.length}, ${traps} traps, ${game.lanterns.length} lanterns, ${game.oneWay.length} one-way, ${game.doors.length} doors`;
|
||||
}
|
||||
case 'dial':
|
||||
return `${game.dial.rounds} rounds, ${game.spectra.length} spectra, keys at ${game.dial.keys.map((k) => k.at).join('/')} of ${maxTotal(game)}`;
|
||||
case 'twin':
|
||||
return `${game.scenes.length} scenes, ${game.scenes.reduce((n, s) => n + s.changes.length, 0)} differences${game.twin.mirrorB ? ', B mirrored' : ''}`;
|
||||
case 'blueprint':
|
||||
return `${game.patterns.length} pictures, ${[...new Set(game.patterns.map((p) => p.size))].join('/')} grid${game.blueprint.rotateBuilder ? `, Builder rotated ${game.blueprint.rotateBuilder}°` : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const MIN_SPECTRA = 30;
|
||||
|
||||
function validateDial(game: DialGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const { dial, spectra } = game;
|
||||
// Enough for a session that never repeats, with room to spare.
|
||||
const needed = Math.max(MIN_SPECTRA, dial.rounds * 2);
|
||||
if (spectra.length < needed) errors.push(`spectra bank has ${spectra.length}, needs at least ${needed}`);
|
||||
if (new Set(spectra.map((x) => x.id)).size !== spectra.length) errors.push('duplicate spectrum id');
|
||||
const [lo, hi] = dial.range;
|
||||
if (hi - lo <= 2 * dial.targetMargin) errors.push('targetMargin leaves no room for a target');
|
||||
const bands = dial.scoring.map((b) => b.within);
|
||||
if (bands.some((w, i) => i > 0 && w <= bands[i - 1])) errors.push('scoring bands must widen');
|
||||
if (dial.scoring.some((b, i) => i > 0 && b.points >= dial.scoring[i - 1].points)) errors.push('wider scoring bands must score less');
|
||||
const max = maxTotal(game);
|
||||
for (const k of dial.keys) if (k.at > max) errors.push(`key "${k.name}" needs ${k.at} points, but ${max} is the most possible`);
|
||||
if (dial.keys.some((k, i) => i > 0 && k.at <= dial.keys[i - 1].at)) errors.push('key thresholds must go up');
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateTwin(game: TwinGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const scene of game.scenes) {
|
||||
const where = `scene ${scene.id}`;
|
||||
if (ids.has(scene.id)) errors.push(`duplicate scene id "${scene.id}"`);
|
||||
ids.add(scene.id);
|
||||
const grid = gridA(scene);
|
||||
const width = grid[0].length;
|
||||
if (grid.some((row) => row.length !== width)) errors.push(`${where}: rows don't all have ${width} cells`);
|
||||
if (grid.some((row) => row.some((cell) => cell === ''))) errors.push(`${where}: a row has a double space`);
|
||||
const seen = new Set<string>();
|
||||
for (const c of scene.changes) {
|
||||
if (c.x < 0 || c.y < 0 || c.y >= grid.length || c.x >= width) {
|
||||
errors.push(`${where}: change at (${c.x},${c.y}) is off the grid`);
|
||||
continue;
|
||||
}
|
||||
if (grid[c.y][c.x] === c.to) errors.push(`${where}: change at (${c.x},${c.y}) doesn't change anything`);
|
||||
if (seen.has(`${c.x},${c.y}`)) errors.push(`${where}: two changes at (${c.x},${c.y})`);
|
||||
seen.add(`${c.x},${c.y}`);
|
||||
}
|
||||
if ((scene.differences ?? scene.changes.length) !== scene.changes.length) {
|
||||
errors.push(`${where}: says ${scene.differences} differences but has ${scene.changes.length} changes`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateBlueprint(game: BlueprintGame): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const p of game.patterns) {
|
||||
const where = `pattern ${p.id}`;
|
||||
if (p.rows.length !== p.size || p.rows.some((r) => r.length !== p.size)) errors.push(`${where}: rows must be ${p.size}×${p.size}`);
|
||||
const used = new Set(p.rows.join('').replaceAll('.', ''));
|
||||
for (const c of used) if (!(c in p.palette)) errors.push(`${where}: colour "${c}" is not in the palette`);
|
||||
if (used.size === 0) errors.push(`${where}: the picture is empty`);
|
||||
if (!(p.threshold > 0 && p.threshold <= 1)) errors.push(`${where}: threshold must be above 0 and at most 1`);
|
||||
}
|
||||
if (new Set(game.patterns.map((p) => p.id)).size !== game.patterns.length) errors.push('duplicate pattern id');
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateStory(game: StoryGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
@@ -118,8 +221,15 @@ function validateDefuse(game: DefuseGame): string[] {
|
||||
});
|
||||
|
||||
for (const level of game.levels) {
|
||||
const from = game.manualFrom ? ` (in ${game.manualFrom})` : '';
|
||||
for (const id of level.modules) {
|
||||
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules`);
|
||||
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules${from}`);
|
||||
}
|
||||
// Only wires can appear twice on one device (as Wires A and Wires B).
|
||||
for (const id of new Set(level.modules)) {
|
||||
if (id !== 'wires' && level.modules.filter((m) => m === id).length > 1) {
|
||||
errors.push(`level ${level.id}: only wires can appear more than once, "${id}" does`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (modules.passwords) {
|
||||
@@ -180,6 +290,15 @@ function validateSync(game: SyncGame): string[] {
|
||||
for (const q of game.rank) {
|
||||
if (q.items.length !== 4) errors.push(`rank question ${q.id}: needs exactly 4 items, has ${q.items.length}`);
|
||||
}
|
||||
// An unquoted comma in a YAML [flow, list] splits one option into several, e.g. "A craft (woodwork" + "pottery".
|
||||
const lists = [...game.match, ...game.predict].map((q) => [q.id, q.options] as const);
|
||||
for (const [id, options] of [...lists, ...game.rank.map((q) => [q.id, q.items] as const)]) {
|
||||
for (const o of options) {
|
||||
if (o.split('(').length !== o.split(')').length || /^[a-z]/.test(o)) {
|
||||
errors.push(`question ${id}: option "${o}" looks split by an unquoted comma; quote it`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const r of game.rounds) {
|
||||
if (r.type === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`);
|
||||
if (r.type === 'rank' && r.subjectPattern && r.subjectPattern.length !== game.rules.questionsPerRound) {
|
||||
@@ -219,16 +338,39 @@ function validateLabyrinth(game: LabyrinthGame): string[] {
|
||||
if (ends.includes('T')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} sits on a trap`);
|
||||
}
|
||||
|
||||
// Items and doors live on plain floor; a door needs an item that exists.
|
||||
const itemIds = new Set(game.items.map((it) => it.id));
|
||||
if (itemIds.size !== game.items.length) errors.push('duplicate item id');
|
||||
for (const it of [...game.items, ...game.doors]) {
|
||||
const c = cellAt(grid, it.x, it.y);
|
||||
if (c !== '.') errors.push(`"${it.name}" at (${it.x},${it.y}) must be on plain floor (.), not "${c}"`);
|
||||
}
|
||||
for (const d of game.doors) {
|
||||
if (!itemIds.has(d.needs)) errors.push(`door "${d.name}" needs unknown item "${d.needs}"`);
|
||||
}
|
||||
const taken = new Set<string>();
|
||||
for (const p of [...game.items, ...game.doors]) {
|
||||
if (taken.has(`${p.x},${p.y}`)) errors.push(`two items or doors share (${p.x},${p.y})`);
|
||||
taken.add(`${p.x},${p.y}`);
|
||||
}
|
||||
|
||||
if (starts.length === 1) {
|
||||
const reach = safeReachable(grid, game.oneWay);
|
||||
// Doors block until their item is carried, so reachability plays the maze out with items.
|
||||
const reach =
|
||||
game.doors.length > 0
|
||||
? analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors).safeCells
|
||||
: safeReachable(grid, game.oneWay);
|
||||
for (const k of keys) {
|
||||
if (!isReachable(reach, k)) errors.push(`lantern at (${k.x},${k.y}) is not reachable from S without a trap`);
|
||||
}
|
||||
for (const e of exits) {
|
||||
if (!isReachable(reach, e)) errors.push(`exit at (${e.x},${e.y}) is not reachable from S without a trap`);
|
||||
}
|
||||
for (const it of game.items) {
|
||||
if (!isReachable(reach, it)) errors.push(`item "${it.name}" at (${it.x},${it.y}) is not reachable from S without a trap`);
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns);
|
||||
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors);
|
||||
if (!winnable) errors.push('no trap-free route collects every lantern and then reaches the exit');
|
||||
for (const p of stuck) errors.push(`the Explorer can get stuck for good at (${p.x},${p.y})`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { BlueprintGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
type Pattern = BlueprintGame['patterns'][number];
|
||||
|
||||
export const PEEK_MS = 3000;
|
||||
export const EMPTY = '.';
|
||||
|
||||
export interface BlueprintState {
|
||||
pattern: number;
|
||||
status: 'ready' | 'building' | 'scored';
|
||||
startedAt: number | null;
|
||||
/** The Builder's canvas, row by row, in target orientation ('.' = empty). */
|
||||
canvas: string[];
|
||||
submits: number;
|
||||
bestScore: number | null;
|
||||
lastScore: number | null;
|
||||
passed: boolean | null;
|
||||
peeksUsed: number;
|
||||
peekUntil: number | null;
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as BlueprintState;
|
||||
|
||||
const blank = (size: number) => Array.from({ length: size }, () => EMPTY.repeat(size));
|
||||
|
||||
export function initBlueprint(game: BlueprintGame): BlueprintState {
|
||||
return {
|
||||
pattern: 0,
|
||||
status: 'ready',
|
||||
startedAt: null,
|
||||
canvas: blank(game.patterns[0].size),
|
||||
submits: 0,
|
||||
bestScore: null,
|
||||
lastScore: null,
|
||||
passed: null,
|
||||
peeksUsed: 0,
|
||||
peekUntil: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pattern 1: A describes. With `swapRolesEachPattern`, the Architect alternates A, B, A... */
|
||||
export const architectOf = (game: BlueprintGame, pattern: number): Role =>
|
||||
game.blueprint.swapRolesEachPattern && pattern % 2 === 1 ? 'B' : 'A';
|
||||
|
||||
/**
|
||||
* Matched cells (both painted, same colour) over cells painted in either grid. Cells empty in
|
||||
* both don't count, so a blank canvas never scores.
|
||||
*/
|
||||
export function overlapScore(target: readonly string[], canvas: readonly string[]): number {
|
||||
let matched = 0;
|
||||
let union = 0;
|
||||
target.forEach((row, y) => {
|
||||
[...row].forEach((t, x) => {
|
||||
const p = canvas[y]?.[x] ?? EMPTY;
|
||||
if (t === EMPTY && p === EMPTY) return;
|
||||
union++;
|
||||
if (t === p) matched++;
|
||||
});
|
||||
});
|
||||
return union === 0 ? 0 : matched / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Builder's canvas shown rotated 90° clockwise: displayed (column c, row r) on an n×n grid is
|
||||
* stored cell (x = r, y = n − 1 − c). The other angles follow by repeating it.
|
||||
*/
|
||||
export function displayedToStored(n: number, rotate: number, c: number, r: number): { x: number; y: number } {
|
||||
let p = { x: c, y: r };
|
||||
for (let turn = 0; turn < ((rotate / 90) % 4); turn++) p = { x: p.y, y: n - 1 - p.x };
|
||||
return p;
|
||||
}
|
||||
|
||||
export function patternRemaining(game: BlueprintGame, s: BlueprintState, now: number): number {
|
||||
const p = game.patterns[s.pattern];
|
||||
return s.startedAt === null ? p.timeSeconds : p.timeSeconds - (now - s.startedAt) / 1000;
|
||||
}
|
||||
|
||||
export const peeking = (s: BlueprintState, now: number) => s.peekUntil !== null && now < s.peekUntil;
|
||||
|
||||
export function paint(
|
||||
game: BlueprintGame,
|
||||
ctx: Ctx,
|
||||
role: Role,
|
||||
cells: { x: number; y: number; color: string | null }[],
|
||||
): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'building') return fail('not_building', 'The canvas is closed.');
|
||||
if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder paints.');
|
||||
const p = game.patterns[s.pattern];
|
||||
const rows = s.canvas.map((row) => [...row]);
|
||||
for (const { x, y, color } of cells) {
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= p.size || y >= p.size) {
|
||||
return fail('bad_cell', 'That square is off the canvas.');
|
||||
}
|
||||
if (color !== null && !(color in p.palette)) return fail('bad_color', "That colour isn't on the palette.");
|
||||
rows[y][x] = color ?? EMPTY;
|
||||
}
|
||||
s.canvas = rows.map((row) => row.join(''));
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function peek(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'building') return fail('not_building', 'Nothing to peek at right now.');
|
||||
if (role !== architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Architect can peek.');
|
||||
if (peeking(s, ctx.now)) return fail('peeking', 'You are already peeking.');
|
||||
if (s.peeksUsed >= game.patterns[s.pattern].peeks) return fail('no_peeks', 'No peeks left for this picture.');
|
||||
s.peeksUsed++;
|
||||
s.peekUntil = ctx.now + PEEK_MS;
|
||||
return OK;
|
||||
}
|
||||
|
||||
function score(game: BlueprintGame, ctx: Ctx, s: BlueprintState, p: Pattern) {
|
||||
const value = overlapScore(p.rows, s.canvas);
|
||||
s.submits++;
|
||||
s.lastScore = value;
|
||||
s.bestScore = Math.max(s.bestScore ?? 0, value);
|
||||
const passed = s.bestScore >= p.threshold;
|
||||
if (passed || s.submits >= game.blueprint.submitsPerPattern) {
|
||||
s.status = 'scored';
|
||||
s.passed = passed;
|
||||
s.peekUntil = null;
|
||||
if (passed) ctx.addKey(p.keyName);
|
||||
} else {
|
||||
ctx.room.wrongGuesses++;
|
||||
ctx.toast('all', `${Math.round(value * 100)}%. You need ${Math.round(p.threshold * 100)}%. One more try!`);
|
||||
}
|
||||
}
|
||||
|
||||
export function submitCanvas(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'building') return fail('not_building', 'Nothing to submit right now.');
|
||||
if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder submits.');
|
||||
if (tickBlueprint(game, ctx)) return OK;
|
||||
score(game, ctx, s, game.patterns[s.pattern]);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Starts a picture, or moves on to the next one. */
|
||||
export function continueBlueprint(game: BlueprintGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status === 'ready') {
|
||||
s.status = 'building';
|
||||
s.startedAt = ctx.now;
|
||||
return OK;
|
||||
}
|
||||
if (s.status === 'building') return fail('busy', 'The clock is running!');
|
||||
if (s.pattern < game.patterns.length - 1) {
|
||||
const next = s.pattern + 1;
|
||||
Object.assign(s, initBlueprint(game), { pattern: next, canvas: blank(game.patterns[next].size) });
|
||||
return OK;
|
||||
}
|
||||
const keys = ctx.room.keys.length;
|
||||
const total = game.patterns.length;
|
||||
ctx.finish(
|
||||
keys === total,
|
||||
keys === total ? game.successEnding : `**${keys} of ${total} pictures.** A few brushstrokes off. Play again?`,
|
||||
);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Called on a timer; returns true if the room changed. When the clock runs out, the canvas is scored as it is. */
|
||||
export function tickBlueprint(game: BlueprintGame, ctx: Ctx): boolean {
|
||||
const s = state(ctx);
|
||||
if (!s || s.status !== 'building' || patternRemaining(game, s, ctx.now) > 0) return false;
|
||||
ctx.toast('all', "⏰ Time's up! Scoring the canvas as it is.");
|
||||
// Out of time ends the picture even with a submit left.
|
||||
s.submits = Math.max(s.submits, game.blueprint.submitsPerPattern - 1);
|
||||
score(game, ctx, s, game.patterns[s.pattern]);
|
||||
return true;
|
||||
}
|
||||
@@ -209,10 +209,17 @@ export function mulberry32(seed: number): () => number {
|
||||
};
|
||||
}
|
||||
|
||||
export interface WiresModule {
|
||||
colors: string[];
|
||||
solved: boolean;
|
||||
}
|
||||
|
||||
export interface DefuseDevice {
|
||||
serial: string;
|
||||
batteries: number;
|
||||
wires?: { colors: string[]; solved: boolean };
|
||||
wires?: WiresModule;
|
||||
/** A level that lists `wires` more than once gets one module per extra entry, generated and solved separately. */
|
||||
moreWires?: WiresModule[];
|
||||
button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean };
|
||||
keypad?: { symbols: string[]; pressed: string[]; solved: boolean };
|
||||
indicators?: { label: string; lit: boolean }[];
|
||||
@@ -266,7 +273,9 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
|
||||
for (const id of game.levels[levelIndex].modules) {
|
||||
if (id === 'wires') {
|
||||
const count = int(mods.wires.count.min, mods.wires.count.max);
|
||||
device.wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
|
||||
const wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
|
||||
if (device.wires) (device.moreWires ??= []).push(wires);
|
||||
else device.wires = wires;
|
||||
} else if (id === 'button') {
|
||||
device.button = {
|
||||
label: pick(mods.button.labels),
|
||||
@@ -307,12 +316,22 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wire module on the device with the id `defuse:action` uses for it: plain `wires` when
|
||||
* there is one, `wires-1`, `wires-2`... (shown as "Wires A", "Wires B"...) when there are several.
|
||||
*/
|
||||
export function wireModules(d: DefuseDevice): { instance: string; title: string; module: WiresModule }[] {
|
||||
const all = d.wires ? [d.wires, ...(d.moreWires ?? [])] : [];
|
||||
if (all.length === 1) return [{ instance: 'wires', title: 'Wires', module: all[0] }];
|
||||
return all.map((module, i) => ({ instance: `wires-${i + 1}`, title: `Wires ${String.fromCharCode(65 + i)}`, module }));
|
||||
}
|
||||
|
||||
/** A generated device must always be solvable by the manual. */
|
||||
export function isSolvable(game: DefuseGame, device: DefuseDevice): boolean {
|
||||
if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) return false;
|
||||
if (device.wires) {
|
||||
const { index } = wireSolution(game.ruleset, edgework(device), device.wires.colors);
|
||||
if (index < 1 || index > device.wires.colors.length) return false;
|
||||
for (const { module } of wireModules(device)) {
|
||||
const { index } = wireSolution(game.ruleset, edgework(device), module.colors);
|
||||
if (index < 1 || index > module.colors.length) return false;
|
||||
}
|
||||
if (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false;
|
||||
if (device.passwords && !solvePassword(device.passwords.columns, game.modules.passwords?.words ?? [])) return false;
|
||||
@@ -332,6 +351,8 @@ export interface DefuseState {
|
||||
strikes: number;
|
||||
/** `remaining` game-seconds as of `anchor`; ticks down at `speed` per real second. */
|
||||
clock: { remaining: number; anchor: number; speed: number } | null;
|
||||
/** Roles that tapped Ready on the swap card (games that swap roles every level). */
|
||||
swapReady?: Role[];
|
||||
}
|
||||
|
||||
const newSeed = () => Math.floor(Math.random() * 0xffffffff);
|
||||
@@ -347,7 +368,11 @@ export const maxStrikes = (game: DefuseGame, level: number): number =>
|
||||
game.levels[level]?.strikesOverride ?? game.strikes.max;
|
||||
|
||||
const deviceModules = (d: DefuseDevice) =>
|
||||
[d.wires, d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
|
||||
[d.wires, ...(d.moreWires ?? []), d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
|
||||
|
||||
/** Who holds the device on a level: A, unless roles swap every level (then B on levels 2, 4...). */
|
||||
export const technicianOf = (game: DefuseGame, level: number): Role =>
|
||||
game.swapRolesEachLevel && level % 2 === 1 ? 'B' : 'A';
|
||||
|
||||
export function remainingAt(s: DefuseState, now: number): number {
|
||||
if (!s.clock) return 0;
|
||||
@@ -391,6 +416,7 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
|
||||
if (s.level === game.levels.length - 1) {
|
||||
ctx.finish(
|
||||
true,
|
||||
game.successEnding ??
|
||||
'**Device disarmed.** The ticking stops. Somewhere inside the box, a tiny speaker plays a wedding march.\n\nOne of you had the hands, one of you had the manual. Neither of you could have done it alone. 💣🔑',
|
||||
);
|
||||
}
|
||||
@@ -400,16 +426,8 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
|
||||
export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status === 'ready') {
|
||||
s.device = generateDevice(game, s.level, s.seed);
|
||||
// Never arm a device the solver can't solve.
|
||||
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
|
||||
s.seed = newSeed();
|
||||
s.device = generateDevice(game, s.level, s.seed);
|
||||
}
|
||||
s.strikes = 0;
|
||||
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
|
||||
s.status = 'active';
|
||||
return OK;
|
||||
if (game.swapRolesEachLevel) return fail('swap_ready', 'You both need to tap Ready on the swap card.');
|
||||
return startLevel(game, ctx, s);
|
||||
}
|
||||
if (s.status === 'active') return fail('busy', 'The clock is running!');
|
||||
if (s.status === 'cleared') {
|
||||
@@ -417,7 +435,33 @@ export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
|
||||
s.level++;
|
||||
}
|
||||
// Retry (same level, no key lost) or next level: fresh seed, back to the briefing.
|
||||
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null });
|
||||
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null, swapReady: [] });
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** The swap card: once both have tapped Ready, the clock starts. Tapping again takes it back. */
|
||||
export function levelReady(game: DefuseGame, ctx: Ctx, role: Role): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (!game.swapRolesEachLevel) return fail('no_swap', 'Roles stay the same in this game.');
|
||||
if (s.status !== 'ready') return fail('not_ready', 'The level has already started.');
|
||||
const ready = new Set(s.swapReady ?? []);
|
||||
if (ready.has(role)) ready.delete(role);
|
||||
else ready.add(role);
|
||||
s.swapReady = [...ready];
|
||||
return ready.size === 2 ? startLevel(game, ctx, s) : OK;
|
||||
}
|
||||
|
||||
function startLevel(game: DefuseGame, ctx: Ctx, s: DefuseState): ActionResult {
|
||||
s.device = generateDevice(game, s.level, s.seed);
|
||||
// Never arm a device the solver can't solve.
|
||||
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
|
||||
s.seed = newSeed();
|
||||
s.device = generateDevice(game, s.level, s.seed);
|
||||
}
|
||||
s.strikes = 0;
|
||||
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
|
||||
s.status = 'active';
|
||||
s.swapReady = [];
|
||||
return OK;
|
||||
}
|
||||
|
||||
@@ -429,7 +473,7 @@ export function defuseAction(
|
||||
action: DefuseAction,
|
||||
): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (role !== 'A') return fail('wrong_role', 'Only the Technician can touch the device.');
|
||||
if (role !== technicianOf(game, s.level)) return fail('wrong_role', 'Only the Technician can touch the device.');
|
||||
if (s.status !== 'active' || !s.device) return fail('not_active', 'The device is not live.');
|
||||
if (remainingAt(s, ctx.now) <= 0) {
|
||||
explode(ctx, s, 'time');
|
||||
@@ -438,8 +482,9 @@ export function defuseAction(
|
||||
const d = s.device;
|
||||
const edge = edgework(d);
|
||||
|
||||
if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') {
|
||||
if (action.index === wireSolution(game.ruleset, edge, d.wires.colors).index) d.wires.solved = true;
|
||||
const wires = wireModules(d).find((w) => w.instance === moduleId)?.module;
|
||||
if (wires && !wires.solved && action.type === 'cut') {
|
||||
if (action.index === wireSolution(game.ruleset, edge, wires.colors).index) wires.solved = true;
|
||||
else strike(game, ctx, s);
|
||||
} else if (moduleId === 'button' && d.button && !d.button.solved) {
|
||||
const b = d.button;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { normalizeAnswer, otherRole, type DialRoundRecord, type Role } from '@two-keys/shared';
|
||||
import type { DialGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
export interface DialState {
|
||||
/** 0-based. */
|
||||
round: number;
|
||||
status: 'clue' | 'guess' | 'reveal';
|
||||
spectrumId: string;
|
||||
/** SERVER ONLY until the guesser locks in. */
|
||||
target: number;
|
||||
clue: string | null;
|
||||
/** The guesser's live needle. Never shown to the clue-giver. */
|
||||
needle: number | null;
|
||||
guess: number | null;
|
||||
points: number | null;
|
||||
total: number;
|
||||
/** Spectra already dealt this session. */
|
||||
used: string[];
|
||||
/** Normalized clue words used so far (for `noRepeatWords`). */
|
||||
usedWords: string[];
|
||||
history: DialRoundRecord[];
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as DialState;
|
||||
|
||||
/** A on odd rounds (1, 3, ...), B on even ones. */
|
||||
export const giverOf = (round: number): Role => (round % 2 === 0 ? 'A' : 'B');
|
||||
|
||||
/** Uppercase, accents and punctuation gone, one trailing S dropped. */
|
||||
export function normalizeClueWord(word: string): string {
|
||||
const w = normalizeAnswer(word);
|
||||
return w.length > 1 ? w.replace(/S$/, '') : w;
|
||||
}
|
||||
|
||||
/** The words of a label, so "Old-fashioned" bans OLD, FASHIONED and OLDFASHIONED. */
|
||||
export function labelWords(label: string): string[] {
|
||||
const parts = label.split(/[\s\-–—/]+/).map(normalizeClueWord);
|
||||
return [...new Set([...parts, normalizeClueWord(label.replace(/\s+/g, ''))])].filter(Boolean);
|
||||
}
|
||||
|
||||
export function pointsFor(game: DialGame, distance: number): number {
|
||||
return game.dial.scoring.find((b) => distance <= b.within)?.points ?? 0;
|
||||
}
|
||||
|
||||
export const maxTotal = (game: DialGame): number =>
|
||||
Math.max(...game.dial.scoring.map((b) => b.points)) * game.dial.rounds;
|
||||
|
||||
export const spectrumOf = (game: DialGame, s: DialState) => game.spectra.find((x) => x.id === s.spectrumId)!;
|
||||
|
||||
function deal(game: DialGame, s: DialState, random: () => number) {
|
||||
const fresh = game.spectra.filter((x) => !s.used.includes(x.id));
|
||||
const pool = fresh.length > 0 ? fresh : game.spectra;
|
||||
const spectrum = pool[Math.floor(random() * pool.length)];
|
||||
const [lo, hi] = game.dial.range;
|
||||
const margin = game.dial.targetMargin;
|
||||
s.spectrumId = spectrum.id;
|
||||
s.used.push(spectrum.id);
|
||||
s.target = lo + margin + Math.floor(random() * (hi - lo - 2 * margin + 1));
|
||||
s.status = 'clue';
|
||||
s.clue = null;
|
||||
s.needle = null;
|
||||
s.guess = null;
|
||||
s.points = null;
|
||||
}
|
||||
|
||||
export function initDial(game: DialGame, random: () => number = Math.random): DialState {
|
||||
const s: DialState = {
|
||||
round: 0,
|
||||
status: 'clue',
|
||||
spectrumId: '',
|
||||
target: 0,
|
||||
clue: null,
|
||||
needle: null,
|
||||
guess: null,
|
||||
points: null,
|
||||
total: 0,
|
||||
used: [],
|
||||
usedWords: [],
|
||||
history: [],
|
||||
};
|
||||
deal(game, s, random);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Why a clue isn't allowed, or null if it is. */
|
||||
export function clueProblem(game: DialGame, s: DialState, text: string): string | null {
|
||||
const rules = game.dial.clue;
|
||||
const clue = text.trim();
|
||||
const words = clue.split(/\s+/).filter((w) => normalizeClueWord(w) !== '');
|
||||
if (words.length === 0) return 'Type a clue first.';
|
||||
if (clue.length > rules.maxChars) return `Keep it to ${rules.maxChars} characters.`;
|
||||
if (words.length > rules.maxWords) {
|
||||
return rules.maxWords === 1 ? 'One word only!' : `Up to ${rules.maxWords} words.`;
|
||||
}
|
||||
const normalized = words.map(normalizeClueWord);
|
||||
if (rules.banLabelWords) {
|
||||
const { left, right } = spectrumOf(game, s);
|
||||
const banned = new Set([...labelWords(left), ...labelWords(right)]);
|
||||
const hit = words.find((_, i) => banned.has(normalized[i]));
|
||||
if (hit) return `"${hit}" is on the dial. Try another word.`;
|
||||
}
|
||||
if (rules.noRepeatWords) {
|
||||
const hit = words.find((_, i) => s.usedWords.includes(normalized[i]));
|
||||
if (hit) return `"${hit}" was already used this game.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function giveClue(game: DialGame, ctx: Ctx, role: Role, text: string): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'clue') return fail('not_open', 'The clue is already in.');
|
||||
if (role !== giverOf(s.round)) return fail('wrong_role', "It's your partner's turn to give the clue.");
|
||||
// The rejection reaches the clue-giver as a friendly toast.
|
||||
const problem = clueProblem(game, s, text);
|
||||
if (problem) return fail('bad_clue', problem);
|
||||
s.clue = text.trim().replace(/\s+/g, ' ');
|
||||
if (game.dial.clue.noRepeatWords) s.usedWords.push(...s.clue.split(' ').map(normalizeClueWord));
|
||||
s.status = 'guess';
|
||||
return OK;
|
||||
}
|
||||
|
||||
const clampDial = (game: DialGame, value: number) => {
|
||||
const [lo, hi] = game.dial.range;
|
||||
return Math.min(hi, Math.max(lo, Math.round(value)));
|
||||
};
|
||||
|
||||
/** The guesser's live needle: kept for a refresh, never broadcast. */
|
||||
export function moveNeedle(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'guess' || role === giverOf(s.round)) return fail('not_open', 'Not your dial right now.');
|
||||
s.needle = clampDial(game, value);
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function lockGuess(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'guess') return fail('not_open', 'Nothing to lock in yet.');
|
||||
if (role === giverOf(s.round)) return fail('wrong_role', 'Only the guesser turns the dial.');
|
||||
const guess = clampDial(game, value);
|
||||
const distance = Math.abs(guess - s.target);
|
||||
const points = pointsFor(game, distance);
|
||||
const before = s.total;
|
||||
s.guess = guess;
|
||||
s.needle = guess;
|
||||
s.points = points;
|
||||
s.total += points;
|
||||
s.status = 'reveal';
|
||||
// Keys land the moment the running total reaches each threshold.
|
||||
for (const key of game.dial.keys) if (before < key.at && s.total >= key.at) ctx.addKey(key.name);
|
||||
const { left, right } = spectrumOf(game, s);
|
||||
s.history.push({ left, right, clue: s.clue ?? '', giver: giverOf(s.round), target: s.target, guess, points });
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function continueDial(game: DialGame, ctx: Ctx, random: () => number = Math.random): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'reveal') return fail('not_done', 'Finish this round first.');
|
||||
if (s.round < game.dial.rounds - 1) {
|
||||
s.round++;
|
||||
deal(game, s, random);
|
||||
return OK;
|
||||
}
|
||||
const keys = ctx.room.keys.length;
|
||||
const total = game.dial.keys.length;
|
||||
ctx.finish(
|
||||
keys === total,
|
||||
keys === total
|
||||
? game.successEnding
|
||||
: `**${s.total} points, ${keys} of ${total} keys.** ${keys > 0 ? 'Nearly on the same wavelength.' : 'Static on the line this time.'} Play again?`,
|
||||
);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** The partner of the clue-giver this round. */
|
||||
export const guesserOf = (round: number): Role => otherRole(giverOf(round));
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Game } from '../content/schemas';
|
||||
import { continueDefuse, initDefuse } from './defuse';
|
||||
import { continueBlueprint, initBlueprint, tickBlueprint } from './blueprint';
|
||||
import { continueDefuse, initDefuse, tickDefuse } from './defuse';
|
||||
import { continueDial, initDial } from './dial';
|
||||
import { initLabyrinth } from './labyrinth';
|
||||
import { continueChapter, initStory } from './story';
|
||||
import { continueChapter, initStory, tickStory } from './story';
|
||||
import { continueSync, initSync } from './sync';
|
||||
import { continueTwin, initTwin, tickScene } from './twin';
|
||||
import type { ActionResult, Ctx } from './types';
|
||||
|
||||
export function initEngine(game: Game): unknown {
|
||||
@@ -15,6 +18,12 @@ export function initEngine(game: Game): unknown {
|
||||
return initSync(game);
|
||||
case 'labyrinth':
|
||||
return initLabyrinth(game);
|
||||
case 'dial':
|
||||
return initDial(game);
|
||||
case 'twin':
|
||||
return initTwin();
|
||||
case 'blueprint':
|
||||
return initBlueprint(game);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +37,12 @@ export function totalKeys(game: Game): number {
|
||||
return game.rounds.length;
|
||||
case 'labyrinth':
|
||||
return game.lanterns.length;
|
||||
case 'dial':
|
||||
return game.dial.keys.length;
|
||||
case 'twin':
|
||||
return game.scenes.length;
|
||||
case 'blueprint':
|
||||
return game.patterns.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,5 +57,44 @@ export function continueGame(game: Game, ctx: Ctx): ActionResult {
|
||||
return continueSync(game, ctx);
|
||||
case 'labyrinth':
|
||||
return { ok: false, code: 'no_stages', message: 'Nothing to continue.' };
|
||||
case 'dial':
|
||||
return continueDial(game, ctx);
|
||||
case 'twin':
|
||||
return continueTwin(game, ctx);
|
||||
case 'blueprint':
|
||||
return continueBlueprint(game, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/** The hard limit on elapsed + penalty time, for the games that have one. */
|
||||
export function timeLimitSeconds(game: Game): number | null {
|
||||
if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null;
|
||||
const minutes = game.rules.timeLimitMinutes;
|
||||
return minutes ? Math.round(minutes * 60) : null;
|
||||
}
|
||||
|
||||
export function timeLimitLabel(game: Game): string | null {
|
||||
if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null;
|
||||
return timeLimitSeconds(game) === null ? null : (game.rules.timeLimitLabel ?? null);
|
||||
}
|
||||
|
||||
/** Whether a room of this game has clocks that can run out with nobody touching anything. */
|
||||
export function hasClock(game: Game): boolean {
|
||||
return timeLimitSeconds(game) !== null || game.engine === 'defuse' || game.engine === 'twin' || game.engine === 'blueprint';
|
||||
}
|
||||
|
||||
/** Called on a timer; returns true if the room changed. */
|
||||
export function tickGame(game: Game, ctx: Ctx): boolean {
|
||||
const { room } = ctx;
|
||||
if (room.phase !== 'playing') return false;
|
||||
if (game.engine === 'story') return tickStory(game, ctx);
|
||||
const limit = timeLimitSeconds(game);
|
||||
if (limit !== null && room.startedAt !== null && (ctx.now - room.startedAt) / 1000 + room.penaltySeconds >= limit) {
|
||||
ctx.finish(false, "**Time's up.** The clock beat you this time. Run it back?", null, true);
|
||||
return true;
|
||||
}
|
||||
if (game.engine === 'defuse') return tickDefuse(ctx);
|
||||
if (game.engine === 'twin') return tickScene(game, ctx);
|
||||
if (game.engine === 'blueprint') return tickBlueprint(game, ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -75,15 +75,26 @@ export function analyseMaze(
|
||||
grid: string[],
|
||||
gates: readonly Gate[],
|
||||
lanterns: readonly Point[],
|
||||
): { winnable: boolean; stuck: Point[] } {
|
||||
items: readonly (Point & { id: string })[] = [],
|
||||
doors: readonly (Point & { needs: string })[] = [],
|
||||
): { winnable: boolean; stuck: Point[]; safeCells: Set<string> } {
|
||||
const [start] = findCells(grid, 'S');
|
||||
if (!start) return { winnable: false, stuck: [] };
|
||||
if (!start) return { winnable: false, stuck: [], safeCells: new Set() };
|
||||
// One bit per lantern, then one per item.
|
||||
const full = (1 << lanterns.length) - 1;
|
||||
const pickups: Point[] = [...lanterns, ...items];
|
||||
const itemBit = (itemId: string) => {
|
||||
const i = items.findIndex((it) => it.id === itemId);
|
||||
return i < 0 ? 0 : 1 << (lanterns.length + i);
|
||||
};
|
||||
const id = (p: Point, mask: number, safe: boolean) => `${p.x},${p.y},${mask},${safe ? 1 : 0}`;
|
||||
const pickUp = (p: Point, mask: number) => {
|
||||
const i = lanterns.findIndex((l) => l.x === p.x && l.y === p.y);
|
||||
const i = pickups.findIndex((l) => l.x === p.x && l.y === p.y);
|
||||
return i >= 0 ? mask | (1 << i) : mask;
|
||||
};
|
||||
/** A door stays shut until its item is carried (an unknown item never opens it). */
|
||||
const doorShut = (p: Point, mask: number) =>
|
||||
doors.some((d) => d.x === p.x && d.y === p.y && !(itemBit(d.needs) && mask & itemBit(d.needs)));
|
||||
|
||||
// `safe` tracks whether the run so far is trap-free.
|
||||
type Node = { p: Point; mask: number; safe: boolean };
|
||||
@@ -96,14 +107,14 @@ export function analyseMaze(
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
const from = id(node.p, node.mask, node.safe);
|
||||
if (cellAt(grid, node.p.x, node.p.y) === 'E' && node.mask === full) {
|
||||
if (cellAt(grid, node.p.x, node.p.y) === 'E' && (node.mask & full) === full) {
|
||||
goals.push(from);
|
||||
continue;
|
||||
}
|
||||
for (const dir of CLOCKWISE) {
|
||||
const step = { x: node.p.x + DELTAS[dir].x, y: node.p.y + DELTAS[dir].y };
|
||||
const c = cellAt(grid, step.x, step.y);
|
||||
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir)) continue;
|
||||
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir) || doorShut(step, node.mask)) continue;
|
||||
const next: Node =
|
||||
c === 'T'
|
||||
? { p: start, mask: node.mask, safe: false }
|
||||
@@ -129,8 +140,12 @@ export function analyseMaze(
|
||||
}
|
||||
|
||||
const stuck = new Map<string, Point>();
|
||||
for (const [nodeId, node] of seen) if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
|
||||
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()] };
|
||||
const safeCells = new Set<string>();
|
||||
for (const [nodeId, node] of seen) {
|
||||
if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
|
||||
if (node.safe) safeCells.add(key(node.p));
|
||||
}
|
||||
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()], safeCells };
|
||||
}
|
||||
|
||||
export interface LabyrinthState {
|
||||
@@ -142,6 +157,10 @@ export interface LabyrinthState {
|
||||
moves: number;
|
||||
falls: number;
|
||||
message: string;
|
||||
/** Item ids carried. Kept after traps. */
|
||||
inventory?: string[];
|
||||
/** Doors passed at least once, as "x,y" (their openText shows only the first time). */
|
||||
opened?: string[];
|
||||
}
|
||||
|
||||
export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
|
||||
@@ -153,9 +172,13 @@ export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
|
||||
moves: 0,
|
||||
falls: 0,
|
||||
message: '',
|
||||
inventory: [],
|
||||
opened: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const doorAt = (game: LabyrinthGame, x: number, y: number) => game.doors.find((d) => d.x === x && d.y === y);
|
||||
|
||||
export const facingOf = (game: LabyrinthGame, s: LabyrinthState): Dir => s.facing ?? game.explorer.startFacing;
|
||||
|
||||
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: MoveDir): ActionResult {
|
||||
@@ -172,6 +195,14 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo
|
||||
if (isWall(game.grid, next.x, next.y)) return fail('wall', "Solid stone. You can't go that way.");
|
||||
if (gateBlocks(game.oneWay, s.x, s.y, dir)) return fail('gate', "The gate won't open from this side.");
|
||||
|
||||
const inventory = (s.inventory ??= []);
|
||||
const door = doorAt(game, next.x, next.y);
|
||||
if (door && !inventory.includes(door.needs)) {
|
||||
// The Explorer only finds out by trying: they stay put and hear why.
|
||||
s.message = door.lockedText;
|
||||
return OK;
|
||||
}
|
||||
|
||||
const flavour = game.explorerView.flavour;
|
||||
const throughGate = game.oneWay.some((g) => g.x === s.x && g.y === s.y && g.dir === dir);
|
||||
s.moves++;
|
||||
@@ -180,6 +211,18 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo
|
||||
s.facing = dir;
|
||||
s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : '';
|
||||
|
||||
const opened = (s.opened ??= []);
|
||||
if (door && !opened.includes(`${door.x},${door.y}`)) {
|
||||
opened.push(`${door.x},${door.y}`);
|
||||
s.message = door.openText;
|
||||
}
|
||||
// Items are picked up on entry, without telling the Navigator.
|
||||
const item = game.items.find((it) => it.x === next.x && it.y === next.y);
|
||||
if (item && !inventory.includes(item.id)) {
|
||||
inventory.push(item.id);
|
||||
s.message = item.explorerText;
|
||||
}
|
||||
|
||||
const cell = cellAt(game.grid, next.x, next.y);
|
||||
if (cell === 'T') {
|
||||
const [start] = findCells(game.grid, 'S');
|
||||
|
||||
@@ -176,7 +176,8 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
|
||||
const total = game.rounds.length;
|
||||
const ending =
|
||||
keys === total
|
||||
? `**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`
|
||||
? (game.successEnding ??
|
||||
`**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`)
|
||||
: keys > 0
|
||||
? `**${keys} out of ${total} keys.** In sync where it counts, with a few surprises left to discover. That's the fun part. 💞`
|
||||
: `**No keys this time.** Which just means you have a lot of excellent conversations ahead of you. Rematch? 😏`;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { otherRole, type Role } from '@two-keys/shared';
|
||||
import type { TwinGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
type Scene = TwinGame['scenes'][number];
|
||||
|
||||
export interface TwinTap {
|
||||
/** In A's coordinates (B's taps are un-mirrored first). */
|
||||
x: number;
|
||||
y: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface TwinState {
|
||||
scene: number;
|
||||
status: 'ready' | 'playing' | 'won' | 'failed';
|
||||
/** 0 on the first try, 1 on the retry. */
|
||||
attempt: number;
|
||||
startedAt: number | null;
|
||||
/** Seconds taken off this scene's clock by wrong claims. */
|
||||
scenePenalty: number;
|
||||
/** Indexes into the scene's `changes`. */
|
||||
found: number[];
|
||||
taps: Partial<Record<Role, TwinTap>>;
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as TwinState;
|
||||
|
||||
export function initTwin(): TwinState {
|
||||
return { scene: 0, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} };
|
||||
}
|
||||
|
||||
/** A row's cells. Emoji can be several code points (🐈⬛), so rows split on spaces, never by character. */
|
||||
export const cellsOf = (row: string): string[] => row.split(' ');
|
||||
|
||||
export const gridA = (scene: Scene): string[][] => scene.grid.map(cellsOf);
|
||||
|
||||
/** B's room: A's with the changes applied. */
|
||||
export function gridB(scene: Scene): string[][] {
|
||||
const grid = gridA(scene);
|
||||
for (const c of scene.changes) grid[c.y][c.x] = c.to;
|
||||
return grid;
|
||||
}
|
||||
|
||||
export const widthOf = (scene: Scene): number => cellsOf(scene.grid[0]).length;
|
||||
|
||||
/** Mirrored rooms flip x for B, both ways. */
|
||||
export const mirrored = (game: TwinGame, role: Role) => role === 'B' && game.twin.mirrorB;
|
||||
export const flipX = (game: TwinGame, scene: Scene, role: Role, x: number) =>
|
||||
mirrored(game, role) ? widthOf(scene) - 1 - x : x;
|
||||
|
||||
export function sceneRemaining(game: TwinGame, s: TwinState, now: number): number {
|
||||
const scene = game.scenes[s.scene];
|
||||
if (s.startedAt === null) return scene.timeSeconds;
|
||||
return scene.timeSeconds - (now - s.startedAt) / 1000 - s.scenePenalty;
|
||||
}
|
||||
|
||||
/** A tap still counts while it's inside the claim window. */
|
||||
export const liveTap = (game: TwinGame, tap: TwinTap | undefined, now: number): TwinTap | null =>
|
||||
tap && now - tap.at <= game.twin.claimWindowSeconds * 1000 ? tap : null;
|
||||
|
||||
export const canRetry = (s: TwinState) => s.status === 'failed' && s.attempt === 0;
|
||||
|
||||
function startScene(s: TwinState, now: number) {
|
||||
Object.assign(s, { status: 'playing', startedAt: now, scenePenalty: 0, found: [], taps: {} });
|
||||
}
|
||||
|
||||
export function tapCell(game: TwinGame, ctx: Ctx, role: Role, x: number, y: number): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status !== 'playing') return fail('not_playing', 'The room is not open right now.');
|
||||
if (tickScene(game, ctx)) return OK; // time ran out first
|
||||
const scene = game.scenes[s.scene];
|
||||
const width = widthOf(scene);
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= scene.grid.length) {
|
||||
return fail('bad_cell', 'That square is off the grid.');
|
||||
}
|
||||
const ax = flipX(game, scene, role, x);
|
||||
const change = scene.changes.findIndex((c) => c.x === ax && c.y === y);
|
||||
if (change >= 0 && s.found.includes(change)) return fail('found', 'You already found that one.');
|
||||
|
||||
const partner = otherRole(role);
|
||||
const theirs = liveTap(game, s.taps[partner], ctx.now);
|
||||
if (theirs && theirs.x === ax && theirs.y === y) {
|
||||
// Both tapped the same square in time: claim it.
|
||||
s.taps = {};
|
||||
if (change >= 0) {
|
||||
s.found.push(change);
|
||||
ctx.toast('all', `✅ Difference found! ${s.found.length} / ${scene.changes.length}`);
|
||||
if (s.found.length === scene.changes.length) {
|
||||
s.status = 'won';
|
||||
ctx.addKey(scene.keyName);
|
||||
}
|
||||
} else {
|
||||
const penalty = game.twin.wrongClaimPenaltySeconds;
|
||||
s.scenePenalty += penalty;
|
||||
ctx.addPenalty(penalty);
|
||||
ctx.room.wrongGuesses++;
|
||||
ctx.toast('all', `❌ No difference there. −${penalty}s`);
|
||||
tickScene(game, ctx);
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
// A different square (or none): the earlier tap expires, this one waits for the partner.
|
||||
delete s.taps[partner];
|
||||
s.taps[role] = { x: ax, y, at: ctx.now };
|
||||
ctx.toast(partner, 'Your partner tapped a square.');
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Starts a scene, retries a failed one, or moves on. */
|
||||
export function continueTwin(game: TwinGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status === 'ready') {
|
||||
startScene(s, ctx.now);
|
||||
return OK;
|
||||
}
|
||||
if (s.status === 'playing') return fail('busy', 'The clock is running!');
|
||||
if (canRetry(s)) {
|
||||
s.attempt = 1;
|
||||
startScene(s, ctx.now);
|
||||
return OK;
|
||||
}
|
||||
if (s.scene < game.scenes.length - 1) {
|
||||
Object.assign(s, { scene: s.scene + 1, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} });
|
||||
return OK;
|
||||
}
|
||||
const keys = ctx.room.keys.length;
|
||||
const total = game.scenes.length;
|
||||
ctx.finish(
|
||||
keys === total,
|
||||
keys === total ? game.successEnding : `**${keys} of ${total} rooms solved.** So close. The differences are still out there. Play again?`,
|
||||
);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Called on a timer and before each tap; returns true if the scene clock ran out. */
|
||||
export function tickScene(game: TwinGame, ctx: Ctx): boolean {
|
||||
const s = state(ctx);
|
||||
if (!s || s.status !== 'playing' || sceneRemaining(game, s, ctx.now) > 0) return false;
|
||||
s.status = 'failed';
|
||||
s.taps = {};
|
||||
ctx.toast('all', "⏰ Time's up for this room.");
|
||||
return true;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/shared';
|
||||
import type { Game } from '../content/schemas';
|
||||
import { engineView, gameMeta } from '../content/sanitize';
|
||||
import { totalKeys } from '../engines/index';
|
||||
import { timeLimitSeconds } from '../engines/story';
|
||||
import { timeLimitLabel, timeLimitSeconds, totalKeys } from '../engines/index';
|
||||
import type { PlayerRec, Room } from './store';
|
||||
|
||||
export function computeResult(room: Room, game: Game): GameResult | null {
|
||||
@@ -75,8 +74,8 @@ export function buildState(
|
||||
finishedAt: room.finishedAt,
|
||||
penaltySeconds: room.penaltySeconds,
|
||||
serverNow: now,
|
||||
limitSeconds: game.engine === 'story' ? timeLimitSeconds(game) : null,
|
||||
limitLabel: game.engine === 'story' ? (game.rules.timeLimitLabel ?? null) : null,
|
||||
limitSeconds: timeLimitSeconds(game),
|
||||
limitLabel: timeLimitLabel(game),
|
||||
},
|
||||
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
|
||||
views,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { loadAllFiles } from '../content/loader';
|
||||
import { describeGame } from '../content/validate';
|
||||
|
||||
const files = loadAllFiles();
|
||||
let failed = 0;
|
||||
|
||||
for (const { file, game, errors } of files) {
|
||||
if (errors.length === 0 && game) {
|
||||
console.log(`✔ ${file} (${game.engine})`);
|
||||
const head = `${game.difficulty}★ ${String(game.estimatedMinutes).padStart(2)}m ${game.engine.padEnd(9)}`;
|
||||
console.log(`✔ ${head} ${file.padEnd(32)} ${describeGame(game)}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`✖ ${file}`);
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
type Role,
|
||||
} from '@two-keys/shared';
|
||||
import type { Game } from '../content/schemas';
|
||||
import { defuseAction, tickDefuse } from '../engines/defuse';
|
||||
import { continueGame, initEngine } from '../engines/index';
|
||||
import { paint, peek, submitCanvas } from '../engines/blueprint';
|
||||
import { defuseAction, levelReady } from '../engines/defuse';
|
||||
import { giveClue, lockGuess, moveNeedle } from '../engines/dial';
|
||||
import { continueGame, hasClock, initEngine, tickGame } from '../engines/index';
|
||||
import { moveExplorer } from '../engines/labyrinth';
|
||||
import {
|
||||
agreeAccusation,
|
||||
@@ -21,10 +23,10 @@ import {
|
||||
requestHint,
|
||||
submitAccusation,
|
||||
submitLock,
|
||||
tickStory,
|
||||
togglePin,
|
||||
} from '../engines/story';
|
||||
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
|
||||
import { tapCell } from '../engines/twin';
|
||||
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
|
||||
import { cleanCode } from '../rooms/codes';
|
||||
import { buildState } from '../rooms/state';
|
||||
@@ -44,7 +46,8 @@ const schemas = {
|
||||
pin: z.object({ clueId: z.string().max(100), ...asRole }),
|
||||
propose: z.object({ answers: z.record(z.string()), ...asRole }),
|
||||
defuse: z.object({
|
||||
moduleId: z.enum(DEFUSE_MODULES),
|
||||
// `wires-1`, `wires-2`... when a level has more than one of a module.
|
||||
moduleId: z.string().regex(new RegExp(`^(${DEFUSE_MODULES.join('|')})(-\d)?$`)),
|
||||
action: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cut'), index: z.number().int() }),
|
||||
z.object({ type: z.literal('tap') }),
|
||||
@@ -70,6 +73,16 @@ const schemas = {
|
||||
.transform((d): MoveDir => (d.length > 1 ? (d[0] as RelDir) : (d as MoveDir))),
|
||||
...asRole,
|
||||
}),
|
||||
dialClue: z.object({ text: z.string().max(200), ...asRole }),
|
||||
dialValue: z.object({ value: z.number().finite(), ...asRole }),
|
||||
twinTap: z.object({ x: z.number().int(), y: z.number().int(), ...asRole }),
|
||||
paint: z.object({
|
||||
cells: z
|
||||
.array(z.object({ x: z.number().int(), y: z.number().int(), color: z.string().max(2).nullable() }))
|
||||
.min(1)
|
||||
.max(256),
|
||||
...asRole,
|
||||
}),
|
||||
notepad: z.object({ text: z.string() }),
|
||||
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
|
||||
view: z.object({ label: z.string().max(80) }),
|
||||
@@ -133,7 +146,8 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
schema: S,
|
||||
phase: Phase | null,
|
||||
handler: (args: ActionArgs<z.infer<S>>) => ActionResult | void,
|
||||
opts: { echo?: boolean } = {},
|
||||
/** `echo: false` skips the sender; `silent` tells nobody (state that no one else may see). */
|
||||
opts: { echo?: boolean; silent?: boolean } = {},
|
||||
) {
|
||||
socket.on(event, (payload: unknown) => {
|
||||
const parsed = schema.safeParse(payload ?? {});
|
||||
@@ -152,7 +166,7 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
|
||||
room.lastActivity = ctx.now;
|
||||
flushToasts(room, ctx);
|
||||
broadcast(room, opts.echo === false ? socket : undefined);
|
||||
if (!opts.silent) broadcast(room, opts.echo === false ? socket : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,6 +284,41 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
game.engine === 'labyrinth' ? moveExplorer(game, ctx, role, data.dir) : fail('wrong_engine', 'No maze here.'),
|
||||
);
|
||||
|
||||
on('level:ready', empty, 'playing', ({ game, role, ctx }) =>
|
||||
game.engine === 'defuse' ? levelReady(game, ctx, role) : fail('wrong_engine', 'Nothing to get ready for.'),
|
||||
);
|
||||
|
||||
const noDial = fail('wrong_engine', 'No dial here.');
|
||||
on('dial:clue', schemas.dialClue, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'dial' ? giveClue(game, ctx, role, data.text) : noDial,
|
||||
);
|
||||
// Throttled by the client, kept for a refresh, and never broadcast: the clue-giver mustn't see it move.
|
||||
on(
|
||||
'dial:needle',
|
||||
schemas.dialValue,
|
||||
'playing',
|
||||
({ game, data, role, ctx }) => (game.engine === 'dial' ? moveNeedle(game, ctx, role, data.value) : noDial),
|
||||
{ silent: true },
|
||||
);
|
||||
on('dial:lock', schemas.dialValue, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'dial' ? lockGuess(game, ctx, role, data.value) : noDial,
|
||||
);
|
||||
|
||||
on('twin:tap', schemas.twinTap, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'twin' ? tapCell(game, ctx, role, data.x, data.y) : fail('wrong_engine', 'No rooms here.'),
|
||||
);
|
||||
|
||||
const noCanvas = fail('wrong_engine', 'No canvas here.');
|
||||
on('blueprint:paint', schemas.paint, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'blueprint' ? paint(game, ctx, role, data.cells) : noCanvas,
|
||||
);
|
||||
on('blueprint:peek', empty, 'playing', ({ game, role, ctx }) =>
|
||||
game.engine === 'blueprint' ? peek(game, ctx, role) : noCanvas,
|
||||
);
|
||||
on('blueprint:submit', empty, 'playing', ({ game, role, ctx }) =>
|
||||
game.engine === 'blueprint' ? submitCanvas(game, ctx, role) : noCanvas,
|
||||
);
|
||||
|
||||
// ----- presence -----
|
||||
|
||||
on(
|
||||
@@ -301,14 +350,18 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
});
|
||||
});
|
||||
|
||||
// A defuse clock or a story time limit can run out with nobody touching anything.
|
||||
// Clocks (a defuse level, a twin room, a blueprint picture, a game's time limit) can run out
|
||||
// with nobody touching anything. A peek ending also needs a fresh state for the Architect.
|
||||
const peekEnds = new Map<string, number>();
|
||||
const ticker = setInterval(() => {
|
||||
for (const room of store.rooms.values()) {
|
||||
const game = games.get(room.gameId);
|
||||
if (room.phase !== 'playing' || !game) continue;
|
||||
if (game.engine !== 'defuse' && !(game.engine === 'story' && game.rules.timeLimitMinutes)) continue;
|
||||
if (room.phase !== 'playing' || !game || !hasClock(game)) continue;
|
||||
const ctx = createCtx(room);
|
||||
if (game.engine === 'defuse' ? tickDefuse(ctx) : tickStory(game, ctx)) {
|
||||
const peekUntil = game.engine === 'blueprint' ? (room.engine as { peekUntil: number | null }).peekUntil : null;
|
||||
const peekOver = peekUntil !== null && ctx.now >= peekUntil && peekEnds.get(room.code) !== peekUntil;
|
||||
if (peekOver) peekEnds.set(room.code, peekUntil);
|
||||
if (tickGame(game, ctx) || peekOver) {
|
||||
flushToasts(room, ctx);
|
||||
broadcast(room);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,20 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
|
||||
import { games, getGame, labyrinth, sync } from './helpers';
|
||||
|
||||
describe('content', () => {
|
||||
it('loads all 18 games with no validation errors', () => {
|
||||
it('loads all 40 games with no validation errors', () => {
|
||||
const files = loadAllFiles();
|
||||
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
|
||||
expect(files).toHaveLength(18);
|
||||
expect(files).toHaveLength(40);
|
||||
const engines = [...games.values()].map((g) => g.engine);
|
||||
expect(Object.fromEntries(['story', 'defuse', 'sync', 'labyrinth'].map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
|
||||
story: 11,
|
||||
defuse: 2,
|
||||
sync: 2,
|
||||
labyrinth: 3,
|
||||
const ids = ['story', 'defuse', 'sync', 'labyrinth', 'dial', 'twin', 'blueprint'];
|
||||
expect(Object.fromEntries(ids.map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
|
||||
story: 21,
|
||||
defuse: 3,
|
||||
sync: 3,
|
||||
labyrinth: 4,
|
||||
dial: 3,
|
||||
twin: 3,
|
||||
blueprint: 3,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -191,6 +191,13 @@ describe('results', () => {
|
||||
expect(stateOf(room, 'A').result).toMatchObject({ success: false, outOfTime: true, rank: 'Out of time', nightmareSurvivors: false });
|
||||
});
|
||||
|
||||
it('tells the hint sheet when hints come off a countdown', () => {
|
||||
const timed = getGame('kestrel-heist', 'story');
|
||||
const untimed = getGame('alchemists-vault', 'story');
|
||||
expect(stateOf(playingRoom(timed), 'A').views.A).toMatchObject({ chapters: [{ lock: { timeLimited: true } }] });
|
||||
expect(stateOf(playingRoom(untimed), 'A').views.A).toMatchObject({ chapters: [{ lock: { timeLimited: false } }] });
|
||||
});
|
||||
|
||||
it('a difficulty-5 game with zero hints earns Nightmare Survivors', () => {
|
||||
const game = getGame('kestrel-heist', 'story');
|
||||
expect(game.difficulty).toBe(5);
|
||||
@@ -240,6 +247,23 @@ describe('content validation additions', () => {
|
||||
expect(validateGame(game).join('\n')).toMatch(/exactly 4 items/);
|
||||
});
|
||||
|
||||
it('keeps options that contain commas whole', () => {
|
||||
const deep = getGame('in-sync-deep-cuts', 'sync');
|
||||
expect(deep.predict.find((q) => q.id === 'd12')!.options).toContain('A full, noisy home');
|
||||
expect(deep.predict.find((q) => q.id === 'd15')!.options).toEqual(['An instrument', 'A language', 'A sport', 'A craft (woodwork, pottery, etc.)']);
|
||||
const masq = getGame('masquerade-montague', 'story');
|
||||
if (masq.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
expect(masq.finale.questions.flatMap((q) => q.options.map((o) => o.label))).toContain(
|
||||
'Stabbed with the armoury dagger, then buried it in a dry cactus pot',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a sync option split by an unquoted comma', () => {
|
||||
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
|
||||
game.predict[0].options = ['A craft (woodwork', 'pottery', 'etc.)', 'A sport'];
|
||||
expect(validateGame(game).join('\n')).toMatch(/looks split by an unquoted comma/);
|
||||
});
|
||||
|
||||
it("rejects Overtime tables that don't match the solver's", () => {
|
||||
const game = clone(getGame('tick-tock-overtime', 'defuse'));
|
||||
game.modules.passwords!.words[0] = 'ZEBRA';
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
// GAMES-PACK-3.md: the dial, twin and blueprint engines, labyrinth items and doors, defuse
|
||||
// `manualFrom` and role swaps, and sanitized payloads for all 22 new games.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { io as connect } from 'socket.io-client';
|
||||
import type {
|
||||
BlueprintView,
|
||||
DialView,
|
||||
JoinAck,
|
||||
NavigatorView,
|
||||
ExplorerView,
|
||||
Role,
|
||||
RoomState,
|
||||
TechnicianView,
|
||||
TwinView,
|
||||
} from '@two-keys/shared';
|
||||
import { createApp } from '../src/app';
|
||||
import { loadGameFile } from '../src/content/loader';
|
||||
import type { Game } from '../src/content/schemas';
|
||||
import { validateGame } from '../src/content/validate';
|
||||
import {
|
||||
architectOf,
|
||||
continueBlueprint,
|
||||
displayedToStored,
|
||||
overlapScore,
|
||||
paint,
|
||||
peek,
|
||||
submitCanvas,
|
||||
type BlueprintState,
|
||||
} from '../src/engines/blueprint';
|
||||
import {
|
||||
continueDefuse,
|
||||
defuseAction,
|
||||
levelReady,
|
||||
wireModules,
|
||||
wireSolution,
|
||||
edgework,
|
||||
type DefuseState,
|
||||
} from '../src/engines/defuse';
|
||||
import {
|
||||
clueProblem,
|
||||
continueDial,
|
||||
giveClue,
|
||||
giverOf,
|
||||
initDial,
|
||||
lockGuess,
|
||||
moveNeedle,
|
||||
pointsFor,
|
||||
type DialState,
|
||||
} from '../src/engines/dial';
|
||||
import { tickGame } from '../src/engines/index';
|
||||
import { moveExplorer, type LabyrinthState } from '../src/engines/labyrinth';
|
||||
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
|
||||
import { continueTwin, gridA, gridB, tapCell, widthOf, type TwinState } from '../src/engines/twin';
|
||||
import { buildState } from '../src/rooms/state';
|
||||
import type { Room } from '../src/rooms/store';
|
||||
import { clock, games, getGame, playingRoom } from './helpers';
|
||||
|
||||
const PACK_3 = [
|
||||
'the-treehouse',
|
||||
'twin-sunday-morning',
|
||||
'blueprint-first-sketch',
|
||||
'wavelength-tune-in',
|
||||
'death-at-the-braai',
|
||||
'in-sync-road-trip',
|
||||
'wavelength-date-night',
|
||||
'twin-cafes',
|
||||
'curtain-call',
|
||||
'magicians-dressing-room',
|
||||
'blueprint-stained-glass',
|
||||
'hedge-maze-midnight',
|
||||
'snowbound-edelweiss',
|
||||
'pharaohs-tomb',
|
||||
'twin-looking-glass',
|
||||
'tick-tock-switch',
|
||||
'hawthorn-park-cipher',
|
||||
'inventors-paradox',
|
||||
'blueprint-blind-mirror',
|
||||
'wavelength-static',
|
||||
'til-death-do-us-part',
|
||||
'the-lighthouse',
|
||||
];
|
||||
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'changes', 'expect', 'seed', 'rows', 'taps'];
|
||||
|
||||
const stateOf = (room: Room, role: Role, now = 2_000_000) => {
|
||||
const player = room.players.find((p) => p.seat === role)!;
|
||||
return buildState(room, player, games.get(room.gameId)!, () => true, now);
|
||||
};
|
||||
const viewOf = <V>(room: Room, role: Role, now?: number) => stateOf(room, role, now).views[role] as V;
|
||||
|
||||
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (!Array.isArray(value)) out.add(k);
|
||||
keysOf(v, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Moves a game a little way in, so there is real state to leak. */
|
||||
function started(game: Game) {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
if (game.engine === 'defuse') {
|
||||
levelReady(game, t.ctx(), 'A');
|
||||
levelReady(game, t.ctx(), 'B');
|
||||
continueDefuse(game, t.ctx());
|
||||
} else if (game.engine === 'sync') {
|
||||
continueSync(game, t.ctx());
|
||||
} else if (game.engine === 'twin') {
|
||||
continueTwin(game, t.ctx());
|
||||
tapCell(game, t.ctx(), 'A', 0, 0);
|
||||
} else if (game.engine === 'blueprint') {
|
||||
continueBlueprint(game, t.ctx());
|
||||
paint(game, t.ctx(), 'B', [{ x: 0, y: 0, color: Object.keys(game.patterns[0].palette)[0] }]);
|
||||
} else if (game.engine === 'dial') {
|
||||
giveClue(game, t.ctx(), 'A', 'zebra');
|
||||
moveNeedle(game, t.ctx(), 'B', 42);
|
||||
}
|
||||
return { room, t };
|
||||
}
|
||||
|
||||
describe('pack 3 content', () => {
|
||||
it('has all 22 games', () => {
|
||||
for (const id of PACK_3) expect(games.get(id), id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(PACK_3.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
|
||||
it('never carry answers, fixtures, changes, solver state or hidden pictures for either role', () => {
|
||||
const { room, t } = started(game);
|
||||
for (const role of ['A', 'B'] as const) {
|
||||
const keys = keysOf(stateOf(room, role, t.now));
|
||||
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- dial ----------
|
||||
|
||||
describe('dial: Wavelength', () => {
|
||||
const tuneIn = getGame('wavelength-tune-in', 'dial');
|
||||
const staticGame = getGame('wavelength-static', 'dial');
|
||||
|
||||
it('alternates the clue-giver: A on odd rounds, B on even', () => {
|
||||
expect([0, 1, 2, 3].map(giverOf)).toEqual(['A', 'B', 'A', 'B']);
|
||||
});
|
||||
|
||||
it('keeps the target from the guesser until they lock in, and the needle from the clue-giver', () => {
|
||||
const room = playingRoom(tuneIn);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DialState;
|
||||
expect(viewOf<DialView>(room, 'A').target).toBe(s.target);
|
||||
expect(viewOf<DialView>(room, 'B')).toMatchObject({ target: null, clue: null });
|
||||
|
||||
expect(giveClue(tuneIn, t.ctx(), 'B', 'tea').ok).toBe(false); // not B's turn
|
||||
expect(giveClue(tuneIn, t.ctx(), 'A', 'a warm bath').ok).toBe(true);
|
||||
expect(moveNeedle(tuneIn, t.ctx(), 'B', 61).ok).toBe(true);
|
||||
expect(viewOf<DialView>(room, 'B')).toMatchObject({ target: null, clue: 'a warm bath', needle: 61 });
|
||||
const giver = viewOf<DialView>(room, 'A');
|
||||
expect(giver.needle).toBeNull();
|
||||
expect(JSON.stringify(giver)).not.toContain('61');
|
||||
|
||||
expect(lockGuess(tuneIn, t.ctx(), 'A', 50).ok).toBe(false); // the giver can't guess
|
||||
expect(lockGuess(tuneIn, t.ctx(), 'B', s.target + 7).ok).toBe(true);
|
||||
expect(viewOf<DialView>(room, 'B')).toMatchObject({ status: 'reveal', target: s.target, guess: s.target + 7, points: 3 });
|
||||
});
|
||||
|
||||
it('scores by distance: the first band that fits, 0 outside all', () => {
|
||||
expect([0, 5, 6, 10, 15, 20, 21].map((d) => pointsFor(tuneIn, d))).toEqual([4, 4, 3, 3, 2, 1, 0]);
|
||||
expect([2, 3, 5, 8, 9].map((d) => pointsFor(staticGame, d))).toEqual([4, 3, 3, 2, 0]);
|
||||
});
|
||||
|
||||
it('checks clues: length, words, label words (with a trailing S) and repeats', () => {
|
||||
const s = initDial(staticGame, () => 0);
|
||||
const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!;
|
||||
const labelWord = spectrum.left.split(/\s+/)[0];
|
||||
expect(clueProblem(staticGame, s, '')).toMatch(/Type a clue/);
|
||||
expect(clueProblem(staticGame, s, 'two words')).toMatch(/One word only/);
|
||||
expect(clueProblem(staticGame, s, 'x'.repeat(31))).toMatch(/30 characters/);
|
||||
expect(clueProblem(staticGame, s, `${labelWord.toLowerCase()}s`)).toMatch(/is on the dial/);
|
||||
expect(clueProblem(staticGame, s, 'Zebra!')).toBeNull();
|
||||
s.usedWords.push('ZEBRA');
|
||||
expect(clueProblem(staticGame, s, 'zebras')).toMatch(/already used/);
|
||||
expect(clueProblem(tuneIn, initDial(tuneIn), 'a b c d e f')).toMatch(/Up to 5 words/);
|
||||
});
|
||||
|
||||
it("hides the right label (and the numbers) from Static's guesser", () => {
|
||||
const room = playingRoom(staticGame);
|
||||
const s = room.engine as DialState;
|
||||
const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!;
|
||||
const guesser = viewOf<DialView>(room, 'B');
|
||||
expect(guesser).toMatchObject({ left: spectrum.left, right: null, showNumbers: false });
|
||||
expect(JSON.stringify(guesser)).not.toContain(spectrum.right);
|
||||
expect(viewOf<DialView>(room, 'A')).toMatchObject({ right: spectrum.right, showNumbers: true });
|
||||
});
|
||||
|
||||
it('never repeats a spectrum, awards keys as the total crosses each threshold, and ends after the last round', () => {
|
||||
const room = playingRoom(tuneIn);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DialState;
|
||||
for (let round = 0; round < tuneIn.dial.rounds; round++) {
|
||||
const giver = giverOf(round);
|
||||
const guesser: Role = giver === 'A' ? 'B' : 'A';
|
||||
expect(giveClue(tuneIn, t.ctx(), giver, 'zebra').ok).toBe(true);
|
||||
expect(lockGuess(tuneIn, t.ctx(), guesser, s.target).ok).toBe(true); // bullseye, 4 points
|
||||
expect(room.keys.length).toBe(tuneIn.dial.keys.filter((k) => s.total >= k.at).length);
|
||||
expect(continueDial(tuneIn, t.ctx()).ok).toBe(true);
|
||||
}
|
||||
expect(new Set(s.used).size).toBe(tuneIn.dial.rounds);
|
||||
expect(s.total).toBe(24);
|
||||
expect(room.keys).toEqual(tuneIn.dial.keys.map((k) => k.name));
|
||||
expect(room.result).toMatchObject({ success: true, ending: tuneIn.successEnding });
|
||||
});
|
||||
|
||||
it('fails without every key', () => {
|
||||
const room = playingRoom(tuneIn);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DialState;
|
||||
for (let round = 0; round < tuneIn.dial.rounds; round++) {
|
||||
giveClue(tuneIn, t.ctx(), giverOf(round), 'zebra');
|
||||
lockGuess(tuneIn, t.ctx(), giverOf(round) === 'A' ? 'B' : 'A', s.target > 50 ? 0 : 100);
|
||||
continueDial(tuneIn, t.ctx());
|
||||
}
|
||||
expect(room.result?.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a too-small spectra bank and keys nobody can reach', () => {
|
||||
const game = structuredClone(tuneIn);
|
||||
game.spectra = game.spectra.slice(0, 10);
|
||||
game.dial.keys[1].at = 99;
|
||||
const errors = validateGame(game).join('\n');
|
||||
expect(errors).toMatch(/spectra bank has 10/);
|
||||
expect(errors).toMatch(/is the most possible/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- twin ----------
|
||||
|
||||
describe('twin: Twin Rooms', () => {
|
||||
const sunday = getGame('twin-sunday-morning', 'twin');
|
||||
const glass = getGame('twin-looking-glass', 'twin');
|
||||
|
||||
function playing(game: typeof sunday) {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
expect(continueTwin(game, t.ctx()).ok).toBe(true);
|
||||
return { room, t, s: room.engine as TwinState };
|
||||
}
|
||||
|
||||
it("gives each player only their own room, never the changes", () => {
|
||||
const { room } = playing(sunday);
|
||||
const scene = sunday.scenes[0];
|
||||
expect(viewOf<TwinView>(room, 'A').grid).toEqual(gridA(scene));
|
||||
expect(viewOf<TwinView>(room, 'B').grid).toEqual(gridB(scene));
|
||||
expect(gridA(scene)).not.toEqual(gridB(scene));
|
||||
});
|
||||
|
||||
it('splits rows on spaces, so multi-code-point emoji stay whole', () => {
|
||||
for (const game of [sunday, glass, getGame('twin-cafes', 'twin')]) {
|
||||
for (const scene of game.scenes) for (const row of gridA(scene)) expect(row).toHaveLength(widthOf(scene));
|
||||
}
|
||||
});
|
||||
|
||||
it('claims a difference when both tap the same square in time', () => {
|
||||
const { room, t, s } = playing(sunday);
|
||||
const [c] = sunday.scenes[0].changes;
|
||||
expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(true);
|
||||
// The partner hears that a square was tapped, never which.
|
||||
const b = viewOf<TwinView>(room, 'B', t.now);
|
||||
expect(b.partnerTap).not.toBeNull();
|
||||
expect(b.myTap).toBeNull();
|
||||
expect(JSON.stringify(b.partnerTap)).not.toMatch(/"x"|"y"/);
|
||||
tapCell(sunday, t.ctx(), 'B', c.x, c.y);
|
||||
expect(s.found).toEqual([0]);
|
||||
expect(viewOf<TwinView>(room, 'A', t.now).found).toEqual([{ x: c.x, y: c.y }]);
|
||||
expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(false); // already found
|
||||
});
|
||||
|
||||
it('a wrong claim costs time; different squares just replace the earlier tap; late taps expire', () => {
|
||||
const { room, t, s } = playing(sunday);
|
||||
const scene = sunday.scenes[0];
|
||||
const isChange = (x: number, y: number) => scene.changes.some((c) => c.x === x && c.y === y);
|
||||
const plain = gridA(scene).flatMap((row, y) => row.map((_, x) => ({ x, y }))).find((p) => !isChange(p.x, p.y))!;
|
||||
tapCell(sunday, t.ctx(), 'A', plain.x, plain.y);
|
||||
tapCell(sunday, t.ctx(), 'B', plain.x, plain.y);
|
||||
expect(room.penaltySeconds).toBe(sunday.twin.wrongClaimPenaltySeconds);
|
||||
expect(s.scenePenalty).toBe(sunday.twin.wrongClaimPenaltySeconds);
|
||||
expect(s.found).toEqual([]);
|
||||
|
||||
const [c] = scene.changes;
|
||||
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
|
||||
tapCell(sunday, t.ctx(), 'B', plain.x, plain.y); // a different square: A's tap expires
|
||||
expect(s.taps.A).toBeUndefined();
|
||||
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
|
||||
tapCell(sunday, t.ctx((sunday.twin.claimWindowSeconds + 1) * 1000), 'B', c.x, c.y); // too late
|
||||
expect(s.found).toEqual([]);
|
||||
});
|
||||
|
||||
it('mirror mode: tapping the mirrored square of a difference claims it', () => {
|
||||
const { room, t, s } = playing(glass);
|
||||
const scene = glass.scenes[0];
|
||||
const [c] = scene.changes;
|
||||
const mirroredX = widthOf(scene) - 1 - c.x;
|
||||
expect(viewOf<TwinView>(room, 'B').grid).toEqual(gridB(scene).map((row) => [...row].reverse()));
|
||||
tapCell(glass, t.ctx(), 'A', c.x, c.y);
|
||||
tapCell(glass, t.ctx(), 'B', mirroredX, c.y);
|
||||
expect(s.found).toEqual([0]);
|
||||
expect(viewOf<TwinView>(room, 'B', t.now).found).toEqual([{ x: mirroredX, y: c.y }]);
|
||||
});
|
||||
|
||||
it('earns the key when every difference is found; a timed-out room retries once, then moves on', () => {
|
||||
const { room, t, s } = playing(sunday);
|
||||
for (const c of sunday.scenes[0].changes) {
|
||||
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
|
||||
tapCell(sunday, t.ctx(), 'B', c.x, c.y);
|
||||
}
|
||||
expect(s.status).toBe('won');
|
||||
expect(room.keys).toEqual([sunday.scenes[0].keyName]);
|
||||
|
||||
continueTwin(sunday, t.ctx()); // next room
|
||||
continueTwin(sunday, t.ctx()); // open it
|
||||
expect(tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000))).toBe(true);
|
||||
expect(viewOf<TwinView>(room, 'A', t.now)).toMatchObject({ status: 'failed', canRetry: true });
|
||||
continueTwin(sunday, t.ctx());
|
||||
expect(s).toMatchObject({ status: 'playing', attempt: 1, found: [] });
|
||||
tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000));
|
||||
expect(viewOf<TwinView>(room, 'A', t.now).canRetry).toBe(false);
|
||||
continueTwin(sunday, t.ctx());
|
||||
expect(s).toMatchObject({ scene: 2, status: 'ready' });
|
||||
});
|
||||
|
||||
it("Looking Glass's 25-minute limit ends the game", () => {
|
||||
const { room, t } = playing(glass);
|
||||
expect(tickGame(glass, t.ctx(25 * 60_000))).toBe(true);
|
||||
expect(stateOf(room, 'A', t.now).result).toMatchObject({ outOfTime: true, rank: 'Out of time' });
|
||||
});
|
||||
|
||||
it('rejects a change that changes nothing, and a ragged row', () => {
|
||||
const game = structuredClone(sunday);
|
||||
const c = game.scenes[0].changes[0];
|
||||
c.to = gridA(game.scenes[0])[c.y][c.x];
|
||||
game.scenes[1].grid[0] += ' 🍄';
|
||||
const errors = validateGame(game).join('\n');
|
||||
expect(errors).toMatch(/doesn't change anything/);
|
||||
expect(errors).toMatch(/rows don't all have/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- blueprint ----------
|
||||
|
||||
describe('blueprint: Describe & Draw', () => {
|
||||
const sketch = getGame('blueprint-first-sketch', 'blueprint');
|
||||
const mirror = getGame('blueprint-blind-mirror', 'blueprint');
|
||||
|
||||
function building(game: typeof sketch) {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueBlueprint(game, t.ctx());
|
||||
return { room, t, s: room.engine as BlueprintState };
|
||||
}
|
||||
|
||||
/** Paints the whole picture as the Builder sees it: displayed cell (c, r) on a rotated canvas. */
|
||||
function paintAsBuilder(game: typeof sketch, room: Room, t: ReturnType<typeof clock>, role: Role, rows: string[]) {
|
||||
const n = rows.length;
|
||||
const cells = [];
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
const { x, y } = displayedToStored(n, game.blueprint.rotateBuilder, c, r);
|
||||
const v = rows[y][x];
|
||||
cells.push({ x, y, color: v === '.' ? null : v });
|
||||
}
|
||||
}
|
||||
expect(paint(game, t.ctx(), role, cells).ok).toBe(true);
|
||||
}
|
||||
|
||||
it('scores overlap: matched painted cells over cells painted in either grid', () => {
|
||||
expect(overlapScore(['RR', '..'], ['RR', '..'])).toBe(1);
|
||||
expect(overlapScore(['RR', '..'], ['..', '..'])).toBe(0); // a blank canvas never scores
|
||||
expect(overlapScore(['RR', '..'], ['RB', '..'])).toBe(0.5);
|
||||
expect(overlapScore(['RR', '..'], ['RR', 'R.'])).toBeCloseTo(2 / 3);
|
||||
expect(overlapScore(['..', '..'], ['..', '..'])).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the picture from the Builder, and the canvas from the Architect except in a peek", () => {
|
||||
const { room, t } = building(sketch);
|
||||
const builder = viewOf<BlueprintView>(room, 'B', t.now);
|
||||
expect(builder).toMatchObject({ seat: 'builder', target: null, title: null });
|
||||
expect(JSON.stringify(builder)).not.toContain(sketch.patterns[0].title);
|
||||
expect(viewOf<BlueprintView>(room, 'A', t.now)).toMatchObject({ seat: 'architect', canvas: null });
|
||||
|
||||
expect(peek(sketch, t.ctx(), 'B').ok).toBe(false); // only the Architect peeks
|
||||
expect(peek(sketch, t.ctx(), 'A').ok).toBe(true);
|
||||
expect(viewOf<BlueprintView>(room, 'A', t.now).canvas).not.toBeNull();
|
||||
expect(viewOf<BlueprintView>(room, 'A', t.now + 3500).canvas).toBeNull();
|
||||
peek(sketch, t.ctx(), 'A');
|
||||
expect(peek(sketch, t.ctx(), 'A').ok).toBe(false); // two peeks per picture
|
||||
});
|
||||
|
||||
it('rotation: a Builder who paints the rotated picture correctly scores 100%', () => {
|
||||
expect(mirror.blueprint.rotateBuilder).toBe(90);
|
||||
// Displayed (c, r) on an n×n grid is stored (x = r, y = n − 1 − c).
|
||||
expect(displayedToStored(10, 90, 0, 0)).toEqual({ x: 0, y: 9 });
|
||||
expect(displayedToStored(10, 90, 9, 0)).toEqual({ x: 0, y: 0 });
|
||||
const { room, t, s } = building(mirror);
|
||||
paintAsBuilder(mirror, room, t, 'B', mirror.patterns[0].rows);
|
||||
expect(submitCanvas(mirror, t.ctx(), 'B').ok).toBe(true);
|
||||
expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true });
|
||||
expect(room.keys).toEqual([mirror.patterns[0].keyName]);
|
||||
});
|
||||
|
||||
it('swaps the Architect every picture when the game says so', () => {
|
||||
expect([0, 1, 2, 3].map((i) => architectOf(mirror, i))).toEqual(['A', 'B', 'A', 'B']);
|
||||
expect([0, 1, 2].map((i) => architectOf(sketch, i))).toEqual(['A', 'A', 'A']);
|
||||
const { room, t } = building(mirror);
|
||||
submitCanvas(mirror, t.ctx(), 'B');
|
||||
continueBlueprint(mirror, t.ctx());
|
||||
continueBlueprint(mirror, t.ctx());
|
||||
expect(viewOf<BlueprintView>(room, 'A', t.now)).toMatchObject({ seat: 'builder', target: null });
|
||||
expect(viewOf<BlueprintView>(room, 'B', t.now).seat).toBe('architect');
|
||||
expect(paint(mirror, t.ctx(), 'B', [{ x: 0, y: 0, color: null }]).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('a second submit is allowed and the best score counts; the reveal shows the picture to the Builder', () => {
|
||||
const { room, t, s } = building(sketch);
|
||||
const rows = sketch.patterns[0].rows;
|
||||
const colour = Object.keys(sketch.patterns[0].palette)[0];
|
||||
paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: colour }]);
|
||||
submitCanvas(sketch, t.ctx(), 'B');
|
||||
expect(s).toMatchObject({ status: 'building', submits: 1 });
|
||||
expect(viewOf<BlueprintView>(room, 'B', t.now).target).toBeNull();
|
||||
paintAsBuilder(sketch, room, t, 'B', rows);
|
||||
submitCanvas(sketch, t.ctx(), 'B');
|
||||
expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true });
|
||||
expect(viewOf<BlueprintView>(room, 'B', t.now)).toMatchObject({ target: rows, title: sketch.patterns[0].title });
|
||||
});
|
||||
|
||||
it('when time runs out, the canvas is scored as it is', () => {
|
||||
const { t, s } = building(sketch);
|
||||
expect(tickGame(sketch, t.ctx(sketch.patterns[0].timeSeconds * 1000))).toBe(true);
|
||||
expect(s).toMatchObject({ status: 'scored', passed: false, bestScore: 0 });
|
||||
});
|
||||
|
||||
it('rejects colours that are not on the palette, and a picture of the wrong size', () => {
|
||||
const { t } = building(sketch);
|
||||
expect(paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: 'Q' }]).ok).toBe(false);
|
||||
expect(paint(sketch, t.ctx(), 'B', [{ x: 6, y: 0, color: null }]).ok).toBe(false);
|
||||
const game = structuredClone(sketch);
|
||||
game.patterns[0].rows[0] = 'RR';
|
||||
game.patterns[1].rows[1] = '.ZZZZ.';
|
||||
const errors = validateGame(game).join('\n');
|
||||
expect(errors).toMatch(/rows must be 6×6/);
|
||||
expect(errors).toMatch(/colour "Z" is not in the palette/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- labyrinth items and doors ----------
|
||||
|
||||
describe('labyrinth: items and doors (Hedge Maze)', () => {
|
||||
const hedge = getGame('hedge-maze-midnight', 'labyrinth');
|
||||
const [door] = hedge.doors;
|
||||
const [item] = hedge.items;
|
||||
|
||||
/** Walks the Explorer along a path of n/e/s/w steps. */
|
||||
const walk = (room: Room, t: ReturnType<typeof clock>, steps: string) => {
|
||||
for (const dir of steps) moveExplorer(hedge, t.ctx(), 'B', dir as 'n');
|
||||
};
|
||||
|
||||
it('a door stays shut until the Explorer carries its item, which survives a trap', () => {
|
||||
const room = playingRoom(hedge);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
// Start (1,1) → down to (1,7) → east to the door's neighbour (4,7).
|
||||
s.x = door.x - 1;
|
||||
s.y = door.y;
|
||||
moveExplorer(hedge, t.ctx(), 'B', 'e');
|
||||
expect({ x: s.x, y: s.y }).toEqual({ x: door.x - 1, y: door.y });
|
||||
expect(viewOf<ExplorerView>(room, 'B').message).toBe(door.lockedText);
|
||||
|
||||
// Pick the key up from the cell next to it.
|
||||
s.x = item.x - 1;
|
||||
s.y = item.y;
|
||||
moveExplorer(hedge, t.ctx(), 'B', 'e');
|
||||
expect(viewOf<ExplorerView>(room, 'B')).toMatchObject({ inventory: [item.name], message: item.explorerText });
|
||||
// The Navigator gets no toast about it.
|
||||
expect(t.ctx().toasts).toEqual([]);
|
||||
expect(viewOf<NavigatorView>(room, 'A').items).toEqual([{ x: item.x, y: item.y, name: item.name, collected: true }]);
|
||||
|
||||
// A trap sends them back with the key.
|
||||
const [trap] = hedge.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
|
||||
s.x = trap.x - 1;
|
||||
s.y = trap.y;
|
||||
if (hedge.grid[trap.y][trap.x - 1] === '#') s.x = trap.x + 1;
|
||||
moveExplorer(hedge, t.ctx(), 'B', s.x < trap.x ? 'e' : 'w');
|
||||
expect(s.falls).toBe(1);
|
||||
expect(s.inventory).toEqual([item.id]);
|
||||
|
||||
s.x = door.x - 1;
|
||||
s.y = door.y;
|
||||
moveExplorer(hedge, t.ctx(), 'B', 'e');
|
||||
expect({ x: s.x, y: s.y }).toEqual({ x: door.x, y: door.y });
|
||||
expect(s.message).toBe(door.openText);
|
||||
walk(room, t, 'we');
|
||||
expect(s.message).not.toBe(door.openText); // only the first time
|
||||
});
|
||||
|
||||
it("never sends the Explorer where items and doors are", () => {
|
||||
const room = playingRoom(hedge);
|
||||
const keys = keysOf(viewOf<ExplorerView>(room, 'B'));
|
||||
for (const key of ['items', 'doors', 'x', 'y', 'grid']) expect(keys).not.toContain(key);
|
||||
});
|
||||
|
||||
it('validation: doors need a known item, items and doors sit on plain floor, and the gate must be passable', () => {
|
||||
const game = structuredClone(hedge);
|
||||
game.doors[0].needs = 'nothing';
|
||||
game.items[0].x = 0; // a wall
|
||||
const errors = validateGame(game).join('\n');
|
||||
expect(errors).toMatch(/needs unknown item "nothing"/);
|
||||
expect(errors).toMatch(/must be on plain floor/);
|
||||
|
||||
// The key behind its own gate ((3,7) is only reachable through it): the gate's lantern is out of reach.
|
||||
const locked = structuredClone(hedge);
|
||||
locked.items[0] = { ...locked.items[0], x: 3, y: 7 };
|
||||
const lockedErrors = validateGame(locked).join('\n');
|
||||
expect(lockedErrors).toMatch(/item "Iron Gate Key" .* is not reachable/);
|
||||
expect(lockedErrors).toMatch(/lantern at \(\d+,\d+\) is not reachable/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- defuse: shared manual and role swaps ----------
|
||||
|
||||
describe('defuse: Tick Tock: Switch', () => {
|
||||
const sw = getGame('tick-tock-switch', 'defuse');
|
||||
const overtime = getGame('tick-tock-overtime', 'defuse');
|
||||
|
||||
it("uses Overtime's device, modules, manual and solver", () => {
|
||||
expect(sw).toMatchObject({ manualFrom: 'tick-tock-overtime', ruleset: 'overtime', manual: overtime.manual });
|
||||
expect(sw.modules).toEqual(overtime.modules);
|
||||
expect(sw.levels).toHaveLength(5);
|
||||
expect(sw.levels[4].strikesOverride).toBe(1);
|
||||
});
|
||||
|
||||
it('a missing manualFrom game fails to load', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'two-keys-'));
|
||||
const file = path.join(dir, 'switch-copy.yaml');
|
||||
const raw = fs.readFileSync(path.join(__dirname, '../../content/games/tick-tock-switch.yaml'), 'utf8');
|
||||
fs.writeFileSync(file, raw.replace('id: tick-tock-switch', 'id: switch-copy'));
|
||||
expect(loadGameFile(file).errors.join('\n')).toMatch(/manualFrom: game "tick-tock-overtime" is missing/);
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
it('swaps roles every level, behind a swap card both players tap Ready on', () => {
|
||||
const room = playingRoom(sw);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
expect(continueDefuse(sw, t.ctx()).ok).toBe(false); // the swap card first
|
||||
levelReady(sw, t.ctx(), 'A');
|
||||
expect(viewOf<TechnicianView>(room, 'B').swapReady).toEqual({ you: false, partner: true });
|
||||
levelReady(sw, t.ctx(), 'B');
|
||||
expect(s.status).toBe('active');
|
||||
expect(viewOf<TechnicianView>(room, 'A').seat).toBe('technician');
|
||||
expect(viewOf<TechnicianView>(room, 'B').seat).toBe('expert');
|
||||
|
||||
// Clear level 1 with the solver's answers... or just jump to level 2.
|
||||
s.status = 'cleared';
|
||||
continueDefuse(sw, t.ctx());
|
||||
expect(s).toMatchObject({ level: 1, status: 'ready', swapReady: [] });
|
||||
levelReady(sw, t.ctx(), 'A');
|
||||
levelReady(sw, t.ctx(), 'B');
|
||||
expect(viewOf<TechnicianView>(room, 'B')).toMatchObject({ seat: 'technician', technician: 'B' });
|
||||
const expert = stateOf(room, 'A').views.A!;
|
||||
expect(expert).toMatchObject({ seat: 'expert', manual: sw.manual });
|
||||
expect(keysOf(expert)).not.toContain('device');
|
||||
// Only this level's Technician may touch the device.
|
||||
expect(defuseAction(sw, t.ctx(), 'A', 'wires', { type: 'cut', index: 1 }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('two wire modules on one level are generated and solved separately, as Wires A and Wires B', () => {
|
||||
const room = playingRoom(sw);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 3;
|
||||
levelReady(sw, t.ctx(), 'A');
|
||||
levelReady(sw, t.ctx(), 'B');
|
||||
const wires = wireModules(s.device!);
|
||||
expect(wires.map((w) => [w.instance, w.title])).toEqual([
|
||||
['wires-1', 'Wires A'],
|
||||
['wires-2', 'Wires B'],
|
||||
]);
|
||||
const tech = technicianOf3(s);
|
||||
const view = viewOf<TechnicianView>(room, tech, t.now);
|
||||
expect(view.device!.modules.filter((m) => m.id === 'wires').map((m) => (m as { instance: string }).instance)).toEqual(['wires-1', 'wires-2']);
|
||||
|
||||
const cut = (instance: string, i: number) =>
|
||||
defuseAction(sw, t.ctx(), tech, instance, {
|
||||
type: 'cut',
|
||||
index: wireSolution(sw.ruleset, edgework(s.device!), wires[i].module.colors).index,
|
||||
});
|
||||
expect(cut('wires-2', 1).ok).toBe(true);
|
||||
expect(wires[1].module.solved).toBe(true);
|
||||
expect(wires[0].module.solved).toBe(false);
|
||||
expect(cut('wires-1', 0).ok).toBe(true);
|
||||
expect(wires[0].module.solved).toBe(true);
|
||||
expect(s.strikes).toBe(0);
|
||||
});
|
||||
|
||||
it('only wires may appear twice in a level', () => {
|
||||
const game = structuredClone(sw);
|
||||
game.levels[0].modules = ['keypad', 'keypad'];
|
||||
expect(validateGame(game).join('\n')).toMatch(/only wires can appear more than once/);
|
||||
});
|
||||
});
|
||||
|
||||
/** Level 4 (index 3) is an even level: B holds the device. */
|
||||
const technicianOf3 = (s: DefuseState): Role => (s.level % 2 === 1 ? 'B' : 'A');
|
||||
|
||||
// ---------- sync: Road Trip ----------
|
||||
|
||||
describe('sync: In Sync: Road Trip', () => {
|
||||
it('plays every round to the end', () => {
|
||||
const game = getGame('in-sync-road-trip', 'sync');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as SyncState;
|
||||
for (let r = 0; r < game.rounds.length; r++) {
|
||||
continueSync(game, t.ctx()); // intro → question
|
||||
for (let q = 0; q < s.questions.length; q++) {
|
||||
const question = s.questions[s.q];
|
||||
const answer = game.rounds[r].type === 'telepathy' ? 'Road' : 0;
|
||||
expect(answerSync(game, t.ctx(), 'A', question.id, answer).ok).toBe(true);
|
||||
expect(answerSync(game, t.ctx(), 'B', question.id, answer).ok).toBe(true);
|
||||
continueSync(game, t.ctx()); // reveal → next / summary
|
||||
}
|
||||
continueSync(game, t.ctx()); // summary → next round (or the end)
|
||||
}
|
||||
expect(room.keys).toEqual(game.rounds.map((r) => r.keyName));
|
||||
expect(room.result).toMatchObject({ success: true, ending: game.successEnding });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- over real sockets ----------
|
||||
|
||||
describe('dial over sockets', () => {
|
||||
const app = createApp({ dataFile: null });
|
||||
let url = '';
|
||||
beforeAll(async () => {
|
||||
url = `http://localhost:${await app.listen(0)}`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('never broadcasts the guesser\'s needle', async () => {
|
||||
const make = () => {
|
||||
const socket = connect(url, { transports: ['websocket'], forceNew: true });
|
||||
const states: RoomState[] = [];
|
||||
socket.on('room:state', (st: RoomState) => states.push(st));
|
||||
return { socket, states };
|
||||
};
|
||||
const a = make();
|
||||
const b = make();
|
||||
const ack = (s: typeof a, event: string, payload: object) =>
|
||||
new Promise<JoinAck>((resolve) => s.socket.emit(event, payload, resolve));
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
try {
|
||||
const created = await ack(a, 'room:create', { gameId: 'wavelength-tune-in' });
|
||||
if (!created.ok) throw new Error(created.message);
|
||||
await ack(b, 'room:join', { code: created.code });
|
||||
a.socket.emit('room:pickRole', { role: 'A' });
|
||||
await wait(100);
|
||||
a.socket.emit('room:ready');
|
||||
b.socket.emit('room:ready');
|
||||
await wait(150);
|
||||
a.socket.emit('dial:clue', { text: 'zebra' });
|
||||
await wait(150);
|
||||
const before = a.states.length;
|
||||
b.socket.emit('dial:needle', { value: 77 });
|
||||
await wait(200);
|
||||
expect(a.states.length).toBe(before);
|
||||
expect(a.states.every((st) => JSON.stringify(st.views).indexOf('"needle":77') === -1)).toBe(true);
|
||||
} finally {
|
||||
a.socket.close();
|
||||
b.socket.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
+127
-2
@@ -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 {
|
||||
@@ -87,6 +87,8 @@ export interface LockView {
|
||||
hintsTotal: number;
|
||||
/** What the next hint costs. */
|
||||
hintPenaltySeconds: number;
|
||||
/** The game has a countdown, so a hint takes time off what's left instead of adding to a stopwatch. */
|
||||
timeLimited: boolean;
|
||||
wrongAttempts: number;
|
||||
nudge: boolean;
|
||||
successText: string | null;
|
||||
@@ -154,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[];
|
||||
}
|
||||
@@ -215,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';
|
||||
@@ -299,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 {
|
||||
@@ -309,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;
|
||||
@@ -317,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 ----------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user