Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52745935ee | ||
|
|
4ff95e6018 | ||
|
|
db2653c1ab | ||
|
|
6669be11e5 | ||
|
|
bbe44e84c4 | ||
|
|
62245ca67a |
@@ -9,7 +9,7 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
|
||||
|
||||
**Core rule of every game: Player A and Player B never see the same clue set.** Every lock needs info from both.
|
||||
|
||||
### Game library (8 games, 4 engines)
|
||||
### Game library (40 games, 7 engines)
|
||||
|
||||
| # | Game | Category | Engine | File |
|
||||
|---|------|----------|--------|------|
|
||||
@@ -21,6 +21,38 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
|
||||
| 6 | Tick Tock Together | Co-op Defuse | `defuse` | `tick-tock-together.yaml` |
|
||||
| 7 | In Sync | Couples Connection | `sync` | `in-sync.yaml` |
|
||||
| 8 | Labyrinth of Lanterns | Co-op Navigation | `labyrinth` | `labyrinth-of-lanterns.yaml` |
|
||||
| 9 | Dead Air | Murder Mystery | `story` | `dead-air.yaml` |
|
||||
| 10 | The Masquerade at Montague House | Murder Mystery | `story` | `masquerade-montague.yaml` |
|
||||
| 11 | The Kalahari Expedition | Murder Mystery | `story` | `kalahari-expedition.yaml` |
|
||||
| 12 | The Alchemist's Vault | Escape Room | `story` | `alchemists-vault.yaml` |
|
||||
| 13 | Submerged: Deep Lab Nereid | Escape Room | `story` | `deep-lab-nereid.yaml` |
|
||||
| 14 | The Kestrel Bank Job | Escape Room | `story` | `kestrel-heist.yaml` |
|
||||
| 15 | Tick Tock: Overtime | Co-op Defuse | `defuse` | `tick-tock-overtime.yaml` |
|
||||
| 16 | In Sync: Deep Cuts | Couples Connection | `sync` | `in-sync-deep-cuts.yaml` |
|
||||
| 17 | The Drowned Catacombs | Co-op Navigation | `labyrinth` | `drowned-catacombs.yaml` |
|
||||
| 18 | The Mirror Maze | Co-op Navigation | `labyrinth` | `mirror-maze.yaml` |
|
||||
| 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
|
||||
|
||||
@@ -53,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
|
||||
@@ -86,20 +119,24 @@ 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
|
||||
difficulty: 1-3
|
||||
difficulty: 1-5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary, accent, background, headingFont }
|
||||
roles: { A: {name, blurb}, B: {name, blurb} }
|
||||
intro: markdown
|
||||
```
|
||||
|
||||
### `story` engine (games 1–5)
|
||||
### `story` engine (games 1–5, 9–14, 19, 23, 27, 28, 31, 32, 35, 36, 39, 40)
|
||||
|
||||
```yaml
|
||||
rules: # optional
|
||||
hintPenaltySeconds: number # default 120
|
||||
timeLimitMinutes: number # hard limit on elapsed + penalties; running out ends the game
|
||||
timeLimitLabel: string # what the countdown is called, e.g. "Water level"
|
||||
suspects: # optional (murder mysteries)
|
||||
- { id, name, role, description }
|
||||
chapters:
|
||||
@@ -129,6 +166,7 @@ finale:
|
||||
lock: Lock # same shape as a chapter lock (no keyName needed)
|
||||
# both:
|
||||
successEnding: markdown
|
||||
failEnding: markdown # optional on escape: shown when the time limit runs out
|
||||
```
|
||||
|
||||
Use a zod discriminated union on `finale.type`.
|
||||
@@ -142,7 +180,8 @@ Use a zod discriminated union on `finale.type`.
|
||||
- `photo`: polaroid frame; body text is the photo description in italics (no image assets needed)
|
||||
- `map` / `diagram`: monospace block, preserve whitespace, grid tables
|
||||
- `signal`: animated blinking light driven by `signal` field (see §6.3)
|
||||
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer.
|
||||
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer. The locker searches clue titles and bodies (client-side, own clues only), filters by chapter, and lets each player star clues (`clue:pin`). The partner only ever gets the **count** of stars, never which clues.
|
||||
- Clue text uses `Noto Sans Symbols 2` as a fallback (Braille, alchemy symbols). Code fences (stencil grids, box-drawing keypads, SQL) are monospace at `line-height: 1.15`; wide tables and blocks scroll inside their card, never the page.
|
||||
|
||||
### Other engines
|
||||
See each YAML for its schema. Summaries in §6.
|
||||
@@ -151,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`.
|
||||
@@ -163,8 +202,9 @@ See each YAML for its schema. Summaries in §6.
|
||||
- State: `currentChapter`, `keys[]`, `hintsUsed{lockId: tier}`, `wrongAttempts{}`, `startedAt`, `penaltySeconds`, `finaleAttempts`.
|
||||
- Chapter view: narrative on top → my clues → lock panel → key ring.
|
||||
- Correct answer → both players see the lock spring open (animation), `successText` reveals, the key flies onto the key ring, **Continue** button (either player can press) advances both.
|
||||
- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty. Confirm dialog before revealing tier 3 ("This basically solves it. Sure?").
|
||||
- **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`.
|
||||
- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty (or the game's `rules.hintPenaltySeconds`). Confirm dialog before revealing tier 3 ("This basically solves it. Sure?").
|
||||
- **Time limit:** games with `rules.timeLimitMinutes` show a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game with `failEnding` (plus `reveal` for an accusation) and the results page shows **Out of time** instead of a rank. A `timeLimitLabel` containing "water" (Deep Lab Nereid) also draws the countdown as water rising behind the header (no wave with `prefers-reduced-motion`).
|
||||
- **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`. Any number of questions works. On the last attempt (always, with `maxAttempts: 1`) submitting asks "One shot. Are you both sure?" first.
|
||||
- For `escape` finales, it's just the last lock; on success show `successEnding`.
|
||||
|
||||
### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml`
|
||||
@@ -173,6 +213,11 @@ See each YAML for its schema. Summaries in §6.
|
||||
- Implement the fixtures in the YAML as unit tests. They must pass.
|
||||
- Strikes: 3 per level. Timer per level. Clock speeds up 25% after each strike.
|
||||
- Each level cleared = 1 key. Fail a level → retry with a new seed (no key lost).
|
||||
- **Rulesets:** each manual's rules are code in `engines/defuse.ts`, picked by the YAML's `ruleset` (`classic` = Tick Tock Together, `overtime` = Tick Tock: Overtime). A new manual needs a new ruleset.
|
||||
- **`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)
|
||||
`signal: "./-.-./..../---"`: `.` = 250ms on, `-` = 750ms on, 250ms gap between symbols, `/` = 1000ms gap between letters, 3000ms pause, then loop. Render as a glowing circle. Add a "Replay" button. Never render the dot/dash text to that player (they must read the light).
|
||||
@@ -182,7 +227,10 @@ See each YAML for its schema. Summaries in §6.
|
||||
- `match` rounds: both pick from the same options. Match = point.
|
||||
- `predict` rounds: the `subject` role answers about themselves, the other guesses. Match = point.
|
||||
- 3+ points in a round = key. Failing a round lets them replay it once with fresh questions.
|
||||
- Draw questions randomly from the bank, never repeating within a session.
|
||||
- Draw questions randomly from the bank, never repeating within a session. (When a `predict` bank is too small for that, a question may come back once, asked about the other partner.)
|
||||
- `telepathy` rounds (Deep Cuts): no options, both type one word (max 30 characters). Same word = point; case, spaces, punctuation and a trailing S are ignored.
|
||||
- `rank` rounds (Deep Cuts): the subject (cycled through `subjectPattern`) orders four items (drag, or up/down buttons), the partner predicts. Point only if **both** the top and the bottom match; the reveal highlights both.
|
||||
- A round can set its own `pointsForKey`.
|
||||
|
||||
### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml`
|
||||
- Player B (**Explorer**) sees only their current cell: four direction buttons (disabled where wall), glyph on the floor if any, key if present.
|
||||
@@ -190,15 +238,42 @@ See each YAML for its schema. Summaries in §6.
|
||||
- Traps are invisible to the Explorer. Stepping on one sends them back to Start (keys kept) and adds 1:00 penalty.
|
||||
- Each lantern key collected = key. Exit only opens with all 3.
|
||||
- Server validates every move.
|
||||
- `lanternsRequired` sets how many lanterns the maze has (default 3).
|
||||
- `oneWay` gates (Drowned Catacombs): passable from `{x, y}` in `dir` only. The Navigator sees an arrow; the Explorer sees the way back as a gate, not a wall.
|
||||
- `explorer.compass: relative` (Mirror Maze): the Explorer moves forward/left/right/back from the way they face (their last step), and never sees N/E/S/W.
|
||||
- `navigatorView.mapTransform: mirror-x` (Mirror Maze): the Navigator's map, and everything on it, is served flipped left to right.
|
||||
- `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
|
||||
|
||||
Client → server:
|
||||
`room:create {gameId}`, `room:join {code, playerToken?}`, `room:pickRole {role}`, `room:ready`,
|
||||
`lock:submit {lockId, value}`, `hint:request {lockId}`, `chapter:continue`,
|
||||
`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}`,
|
||||
`maze:move {dir}`, `notepad:update {text}`, `ping {emoji}`
|
||||
`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId | text | order}`,
|
||||
`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` / `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}`,
|
||||
@@ -209,14 +284,15 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
|
||||
## 8. Scoring & results
|
||||
|
||||
- `finalTime = elapsed + penaltySeconds`
|
||||
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅
|
||||
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅 / **Out of time** (a time limit ran out)
|
||||
- **Nightmare Survivors ☠️**: a difficulty-5 game finished with zero hints. Shown on the results page and, once earned, on that game's library card.
|
||||
- Results page: time, hints used, keys earned, wrong guesses, plus a "Play another" carousel. Store best results per game in localStorage.
|
||||
|
||||
## 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 🔑🔑🔑).
|
||||
- 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").
|
||||
@@ -225,10 +301,15 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
|
||||
|
||||
`npm run validate-content` must check:
|
||||
- Every YAML parses and passes its engine's zod schema
|
||||
- Story: every chapter has ≥1 clue for A **and** ≥1 for B; every lock has ≥1 answer and exactly 3 hints; finale answers exist in options
|
||||
- Defuse: all fixtures solve to their expected solution
|
||||
- Labyrinth: grid rows equal length; exactly one S and E; exactly 3 K; every K and E reachable from S **without stepping on a trap** (BFS)
|
||||
- Sync: ≥15 questions per round type
|
||||
- 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 (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)
|
||||
@@ -242,7 +323,7 @@ Run it in CI and as a pre-start step.
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
- [ ] Two phones can play any of the 8 games start to finish
|
||||
- [ ] Two phones can play any of the 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
|
||||
|
||||
@@ -4,7 +4,7 @@ Every lock below needs info from **both** players. "A has / B has" shows the spl
|
||||
|
||||
---
|
||||
|
||||
## 1. Death at Vineyard Hollow (murder mystery · 60 min · 🔑🔑)
|
||||
## 1. Death at Vineyard Hollow (murder mystery · 20 min · 🔑🔑)
|
||||
Roles: **Inspector** (A) / **Estate Insider** (B)
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
@@ -20,7 +20,7 @@ Roles: **Inspector** (A) / **Estate Insider** (B)
|
||||
|
||||
---
|
||||
|
||||
## 2. The Last Set at the Blue Lantern (murder mystery · 50 min · 🔑🔑)
|
||||
## 2. The Last Set at the Blue Lantern (murder mystery · 10 min · 🔑)
|
||||
Roles: **Detective** (A) / **Regular** (B)
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
@@ -34,7 +34,7 @@ Roles: **Detective** (A) / **Regular** (B)
|
||||
|
||||
---
|
||||
|
||||
## 3. Murder on the Southern Star (murder mystery · 70 min · 🔑🔑🔑)
|
||||
## 3. Murder on the Southern Star (murder mystery · 25 min · 🔑🔑)
|
||||
Roles: **Passenger Detective** (A) / **Railway Inspector** (B)
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
@@ -49,7 +49,7 @@ Roles: **Passenger Detective** (A) / **Railway Inspector** (B)
|
||||
|
||||
---
|
||||
|
||||
## 4. The Clockmaker's Attic (escape room · 40 min · 🔑) (best "first game")
|
||||
## 4. The Clockmaker's Attic (escape room · 15 min · 🔑) (best "first game")
|
||||
Roles: **Tinkerer** (A) / **Collector** (B)
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
@@ -61,7 +61,7 @@ Roles: **Tinkerer** (A) / **Collector** (B)
|
||||
|
||||
---
|
||||
|
||||
## 5. Signal Lost: Station Echo (escape room · 45 min · 🔑🔑)
|
||||
## 5. Signal Lost: Station Echo (escape room · 15 min · 🔑🔑)
|
||||
Roles: **Comms Officer** (A) / **Engineer** (B)
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
@@ -73,14 +73,14 @@ Roles: **Comms Officer** (A) / **Engineer** (B)
|
||||
|
||||
---
|
||||
|
||||
## 6. Tick Tock Together (co-op defuse · 25 min · 🔑🔑)
|
||||
## 6. Tick Tock Together (co-op defuse · 10 min · 🔑)
|
||||
Technician (A) sees the device, Expert (B) reads the manual. 3 levels = 3 keys.
|
||||
Device is generated server-side and solved by a solver that implements the manual exactly. Fixtures in the YAML are the unit tests.
|
||||
|
||||
## 7. In Sync (couples connection · 20 min · 🔑)
|
||||
## 7. In Sync (couples connection · 15 min · 🔑)
|
||||
Round 1 match, Round 2 predict about A, Round 3 predict about B. 3/5 matches = key. One replay per round.
|
||||
|
||||
## 8. Labyrinth of Lanterns (co-op navigation · 25 min · 🔑🔑)
|
||||
## 8. Labyrinth of Lanterns (co-op navigation · 10 min · 🔑)
|
||||
Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3 hidden traps.
|
||||
|
||||
```
|
||||
@@ -96,3 +96,336 @@ Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3
|
||||
#..T....#E#
|
||||
###########
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Difficulty: 1 Easy · 2 Medium · 3 Hard · 4 Expert · 5 Nightmare ☠️
|
||||
In the harder murder mysteries, **locks only give facts**. The killer is never handed to you; the accusation needs facts from several chapters.
|
||||
|
||||
## 9. Dead Air (murder mystery · 30 min · 🔑🔑🔑)
|
||||
Roles: **Detective** (A) / **Night Intern** (B). Hint penalty 3:00.
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
|----|------|-------|-------|--------|
|
||||
| 1 | Last song | Track lengths | Run sheet (23:00 start, 2:00 intro, 1:30 talk) + alarm 23:31:35 (15 s rule) | `GHOST SIGNAL` (23:25:00–23:31:20) |
|
||||
| 2 | Honey last safe | Lab: OKAYEST DJ mug negative, ON AIR mug + honey positive; meeting minutes | Kitchen camera log (offline 23:09–23:21); generator test booked by M. Venter | `23:06` |
|
||||
| 3 | Studio door card | Card register (decimal); Kyle lost card at 22:30 meeting with Marisa | Door log in hex (0x1A1 at 23:12) | Kyle's card (0x1A1 = 417) |
|
||||
| 4 | Stencil message | 6×6 letter grid | Card with cut-out windows | `SALE IS FORGED` |
|
||||
|
||||
**Killer:** Marisa Venter. Booked the generator test to kill the camera, left the meeting 23:11–23:17, used Kyle's stolen card at 23:12 to take the EpiPen, put peanut oil in the honey. Motive: Rex would reveal on air she forged his signature to sell the station.
|
||||
**Red herrings:** Zanele (served the fatal tea), Kyle (his card), Tumi (engineer), Owen (superfan, stuck in reception).
|
||||
|
||||
---
|
||||
|
||||
## 10. The Masquerade at Montague House (murder mystery · 40 min · 🔑🔑🔑🔑)
|
||||
Roles: **Inspector** (A) / **Lady's Maid** (B). Hint penalty 3:00. **1 accusation attempt, 4 questions.**
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
|----|------|-------|-------|--------|
|
||||
| 1 | Who arrived as Raven | Logic clues 1–4 | Logic clues 5–8 | Odette (verified unique: Agatha Stag/crimson, Rupert Fox/green, Ivy Swan/black, Silas Lion/silver, Odette Raven/gold) |
|
||||
| 2 | Who the footman saw | Footman: Raven + black cloak | Cloakroom: Raven ↔ Swan swapped at 23:00, cloaks kept | Ivy |
|
||||
| 3 | Dagger pot | Gardener: primes not watered | Disturbed pots 4, 9, 11, 15; spotless gloves | `11` |
|
||||
| 4 | Julian's confession | Polybius numbers + method | Motto FORTUNE FAVOURS THE BOLD; Marsh family gossip | `MARSH WAS INNOCENT` |
|
||||
|
||||
**Killer:** Ivy Marsh · wearing Odette's **Raven** mask · armoury dagger, buried in cactus pot 11 · Julian framed her father and took Montague House.
|
||||
|
||||
---
|
||||
|
||||
## 11. The Kalahari Expedition (murder mystery · 60 min · ☠️ Nightmare)
|
||||
Roles: **Magistrate** (A) / **Camp Clerk** (B). Hint penalty 4:00. **75-minute time limit. 1 attempt, 4 questions.**
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
|----|------|-------|-------|--------|
|
||||
| 1 | Lantern time | Star-trail photo: lantern at 60°–75° of arc | Shutter opened 22:00, sky turns 15°/hr; Clara alibis Reyes 22:00–04:00 | `02:00` |
|
||||
| 2 | Boot size | Print 31.5 cm, chevron, sand adds ~1 cm | Size card + boot inventory | `11` (Reyes & Dunmore have size 11 chevron) |
|
||||
| 3 | Watchman at 02:00 | Rota rules; Samuel's fake snakebite (4 cm marks, sedative) | Watchkeepers + Night 1 = Reyes; murder on Night 3 | Pienaar → figure went to north row (Dunmore, Kessler, Reyes) |
|
||||
| 4 | Samuel's note | Vigenère ciphertext + method | Key word KOFFIE; tobacco list (pipe: Reyes, Dunmore) | `HE SMELLED OF PIPE SMOKE` |
|
||||
| 5 | Diamond source | Gem report: sample K-3, never water-worn | Reference samples; prospectus "found by F. Dunmore" | `KIMBERLEY` |
|
||||
| 6 | Telegram | Book-cipher numbers | Expedition handbook | `SALTED` |
|
||||
|
||||
**Elimination:** size 11 chevron → Reyes, Dunmore · Reyes alibied by Clara · pipe smoke → Dunmore.
|
||||
**Killer:** Felix Dunmore · struck Marlowe at the dig at 02:00 and put him in the pit · drugged Samuel and faked a snakebite · the diamond find was salted with Kimberley stones.
|
||||
|
||||
---
|
||||
|
||||
## 12. The Alchemist's Vault (escape room · 25 min · 🔑🔑🔑)
|
||||
Roles: **Apprentice** (A) / **Scribe** (B).
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
|------|-------|-------|--------|
|
||||
| Cabinet | Flasks: numbers + flame colours | Element table + rainbow order | `GENIUS` (Ge N I U S) |
|
||||
| Stone dial | Two balanced scales | Silver = 5, gold = 3 × silver | `25` |
|
||||
| Dotted plaque | Braille ⠍⠑⠗⠉⠥⠗⠽ | Braille card; Mercury = Hg | `80` |
|
||||
| Vault (finale) | ☉ ☽ ♂ ♄ | Symbol → metal chart (+ element table from ch1) | `234` (79 + 47 + 26 + 82) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Submerged: Deep Lab Nereid (escape room · 35 min · 🔑🔑🔑🔑)
|
||||
Roles: **Pilot** (A) / **Diver** (B). **40-minute time limit shown as a rising water gauge.**
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
|------|-------|-------|--------|
|
||||
| Hatch valves | Lights L1 L2 L3 L5 on | Valve → light wiring | `145` (unique solution) |
|
||||
| Breathing gas | 210 L at 21% | Tank X 36%, Y 15% | `60` |
|
||||
| Pod location | Sonar legs 300 m E, 200 m S | Chart, lab at E5, 100 m squares | `H7` |
|
||||
| Launch (finale) | Multi-tap code 555·4·3·66·55·9·222 | Rewired intercom keypad | `SURFACE` |
|
||||
|
||||
---
|
||||
|
||||
## 14. The Kestrel Bank Job (escape room · 50 min · ☠️ Nightmare)
|
||||
Roles: **Inside Man** (A) / **The Wheel** (B). Hint penalty 4:00. **55-minute limit.**
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
|------|-------|-------|--------|
|
||||
| Staff door | Worn keys 0, 2, 6 (0 most worn) | Codes are DDMM family dates; social posts | `2006` (Mia, 20 June) |
|
||||
| Laser hall | Mask bit values per column | Row masks 21, 12, 25, 17, 8 | Column `4` |
|
||||
| Guards | Guards 1 & 2 timings | Guard 3 timing; need 4 clear minutes | `22:33` |
|
||||
| Vault dial | Sons' ages in Roman numerals | Atbash note "MY SONS AGES, ELDEST FIRST" | `19-12-07` |
|
||||
| Box | SQL query + CLIENTS | BOXES table | `317` |
|
||||
| Getaway (finale) | Rail-fence text | Building has 3 floors (= 3 rails) | `OLD PIER` |
|
||||
|
||||
---
|
||||
|
||||
## 15. Tick Tock: Overtime (co-op defuse · 20 min · 🔑🔑🔑🔑)
|
||||
4 levels = 4 keys. New: 3–6 wires, indicators (LUV, HUG, BAE, XOX, DTE), passwords, morse, simon. Level 4 runs all six modules with only 2 strikes.
|
||||
Rules are the GAMES-NEW.md Appendix A solver, ported to `server/src/engines/defuse.ts` (`ruleset: overtime`); all 40 fixtures in the YAML pass as unit tests.
|
||||
|
||||
## 16. In Sync: Deep Cuts (couples connection · 25 min · 🔑🔑🔑)
|
||||
Telepathy (type the same word, 3/5) · Spotlight A (4/5) · Spotlight B (4/5) · Ranking (top AND bottom must match, 3/5). 4 keys.
|
||||
|
||||
## 17. The Drowned Catacombs (co-op navigation · 20 min · 🔑🔑🔑)
|
||||
13×13, 5 traps, 3 lanterns, **3 one-way flood gates**, sparse carvings.
|
||||
|
||||
```
|
||||
############# S (1,1) E (11,11)
|
||||
#S..#.......# K (3,11) (7,3) (11,5)
|
||||
###.#.###.#.# T (2,3) (4,5) (4,7) (10,5) (10,11)
|
||||
#.T...#K#...# One-way: (5,9)→E (5,11)→N (7,8)→N
|
||||
#.#.#.#.#.###
|
||||
#.#.T.#...TK# Verified: all lanterns + exit reachable without traps,
|
||||
#.##.##.....# respecting gates, and no softlocks.
|
||||
#.#.T...#.#.#
|
||||
#.#.###.#.#.#
|
||||
#.......#.#.#
|
||||
#.###.#.#.#.#
|
||||
#..K#.....TE#
|
||||
#############
|
||||
```
|
||||
|
||||
## 18. The Mirror Maze (co-op navigation · 30 min · ☠️ Nightmare)
|
||||
15×15, 7 traps, **4 lanterns**, Explorer has **relative controls only**, Navigator's map is **mirrored left-right**, two glyphs appear twice (☾ and ✦).
|
||||
|
||||
```
|
||||
############### True layout (Navigator sees each row reversed)
|
||||
#S..#.........# S (1,1) facing east E (13,13)
|
||||
###.#.#.#####.# K (1,3) (1,13) (3,9) (13,9)
|
||||
#K#.....#...#.# T (2,13) (3,5) (5,6) (5,7) (9,5) (13,6) (13,10)
|
||||
#.####.##.#.#.#
|
||||
#..T.....T....#
|
||||
#.###T#.###..T#
|
||||
#...#T#.#.....#
|
||||
#.#.#.#.#.###.#
|
||||
#..K#.#.....#K#
|
||||
#.#.#.#.###.#T#
|
||||
#.#.........#.#
|
||||
#.#.#.#.###.#.#
|
||||
#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) |
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Co-op mystery games for couples. Two players, different clues, every lock needs
|
||||
|------|------------|
|
||||
| `CLAUDE.md` | Full build spec: stack, architecture, security, engines, socket events, design, phases |
|
||||
| `GAMES.md` | Spoiler guide with every answer (don't let your partner read this 👀) |
|
||||
| `content/games/*.yaml` | All 8 games: 3 murder mysteries, 2 escape rooms, defuse, In Sync, labyrinth |
|
||||
| `content/games/*.yaml` | All 18 games: 6 murder mysteries, 5 escape rooms, 2 defuse, 2 In Sync, 3 labyrinths |
|
||||
| `package.json` | Root workspace config |
|
||||
|
||||
## Adding a new game later
|
||||
|
||||
+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=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.0.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -36,7 +36,13 @@ function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
|
||||
/** Starring a clue: yours to see; your partner only learns how many you starred. */
|
||||
export interface PinProps {
|
||||
pinned?: boolean;
|
||||
onPin?: () => void;
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono, pinned, onPin }: { clue: ClueView; light: boolean; mono?: boolean } & PinProps) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0">
|
||||
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:
|
||||
</div>
|
||||
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
|
||||
</div>
|
||||
<Audience clue={clue} light={light} />
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Audience clue={clue} light={light} />
|
||||
{onPin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? `Unstar ${clue.title}` : `Star ${clue.title}`}
|
||||
className={`-m-1 flex h-9 w-9 items-center justify-center rounded-full font-sans text-lg not-italic transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
pinned ? 'text-amber-400' : light ? 'text-stone-500 hover:text-stone-800' : 'text-stone-500 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
{pinned ? '★' : '☆'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,19 +119,19 @@ export function SignalLamp({ pattern }: { pattern: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
export function ClueCard({ clue, ...pin }: { clue: ClueView } & PinProps) {
|
||||
switch (clue.type) {
|
||||
case 'note':
|
||||
return (
|
||||
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'letter':
|
||||
return (
|
||||
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
@@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Header clue={clue} light mono />
|
||||
<Header {...pin} clue={clue} light mono />
|
||||
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
case 'diagram':
|
||||
return (
|
||||
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'transcript':
|
||||
return (
|
||||
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
|
||||
<Header clue={clue} light={false} />
|
||||
<Header {...pin} clue={clue} light={false} />
|
||||
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'signal':
|
||||
return (
|
||||
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
{clue.signal && <SignalLamp pattern={clue.signal} />}
|
||||
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
||||
</article>
|
||||
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
case 'table':
|
||||
return (
|
||||
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
|
||||
import { getBest, saveSession } from '../lib/session';
|
||||
import { request } from '../lib/socket';
|
||||
import { CATEGORY_LABEL, difficultyKeys, themeVars } from '../lib/theme';
|
||||
import { 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,15 +40,27 @@ 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" aria-label={`Difficulty ${game.difficulty} of 3`}>
|
||||
<span
|
||||
className="text-xs"
|
||||
aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}: ${DIFFICULTY_LABEL[game.difficulty]}`}
|
||||
title={DIFFICULTY_LABEL[game.difficulty]}
|
||||
>
|
||||
{difficultyKeys(game.difficulty)}
|
||||
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
|
||||
<span className="opacity-25 grayscale">{difficultyKeys(MAX_DIFFICULTY - game.difficulty)}</span>
|
||||
</span>
|
||||
{game.difficulty >= MAX_DIFFICULTY && (
|
||||
<span className="chip border-red-400/40 text-red-200">☠️ Nightmare</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.tagline}</p>
|
||||
{best?.nightmareSurvivors && (
|
||||
<div className="mt-3 self-start rounded-full bg-red-500/15 px-2.5 py-1 text-xs font-semibold text-red-200">
|
||||
☠️ Nightmare Survivors
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between gap-2 text-xs text-stone-400">
|
||||
<span>{best ? `🏆 Best ${formatClock(best.finalSeconds)} · ${best.rank}` : ENGINE_BLURB[game.engine]}</span>
|
||||
<span className="font-semibold text-accent transition group-hover:translate-x-0.5">Play →</span>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import type { LockView } from '@two-keys/shared';
|
||||
import { formatClock, type LockView } from '@two-keys/shared';
|
||||
import type { Send } from '../lib/socket';
|
||||
import { Sheet } from './ui';
|
||||
|
||||
/** Hints unlock in order, cost +2:00 each, and both players see them. */
|
||||
/** Hints unlock in order, cost penalty time each, and both players see them. */
|
||||
export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const used = lock.hints.length;
|
||||
const left = lock.hintsTotal - used;
|
||||
const nextIsLast = used === lock.hintsTotal - 1;
|
||||
// A countdown loses time; a stopwatch gains it.
|
||||
const cost = `${lock.timeLimited ? '−' : '+'}${formatClock(lock.hintPenaltySeconds)}`;
|
||||
|
||||
const reveal = () => {
|
||||
setConfirming(false);
|
||||
@@ -29,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">+2:00</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) => (
|
||||
@@ -53,7 +64,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
|
||||
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
|
||||
Reveal hint {used + 1} of {lock.hintsTotal} ({cost})
|
||||
</button>
|
||||
)}
|
||||
</Sheet>
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
import { formatClock, type RoomState } from '@two-keys/shared';
|
||||
import { useNow } from '../lib/hooks';
|
||||
|
||||
/** Elapsed time plus penalties. Runs locally between server updates. */
|
||||
/** Elapsed time plus penalties, or a countdown when the game has a time limit. Runs locally between server updates. */
|
||||
export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
|
||||
const now = useNow(1000);
|
||||
if (timer.startedAt === null) return null;
|
||||
const serverNow = timer.serverNow + (now - receivedAt);
|
||||
const elapsed = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000;
|
||||
|
||||
if (timer.limitSeconds !== null) {
|
||||
const used = elapsed + timer.penaltySeconds;
|
||||
const left = Math.max(0, timer.limitSeconds - used);
|
||||
const label = timer.limitLabel ?? 'Time left';
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-end" role="timer" aria-label={`${label}: ${formatClock(left)} left`}>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="max-w-[7.5rem] truncate text-[10px] font-semibold uppercase tracking-wider text-stone-400">{label}</span>
|
||||
<span className={`font-mono text-base font-bold tabular-nums ${left <= 300 ? 'text-red-300' : ''}`}>
|
||||
{formatClock(left)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Fills up as the time (and every hint penalty) runs out. */}
|
||||
<div className="mt-0.5 h-1 w-28 overflow-hidden rounded-full bg-white/10" aria-hidden>
|
||||
<div
|
||||
className={`h-full ${left <= 300 ? 'bg-red-400' : 'bg-accent'}`}
|
||||
style={{ width: `${Math.min(100, (used / timer.limitSeconds) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-baseline gap-1.5 font-mono tabular-nums"
|
||||
@@ -22,3 +46,24 @@ export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receiv
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A countdown called a water level is drawn as water rising behind the header, 0% → 100% as time runs out. */
|
||||
export const isWaterLevel = (timer: RoomState['timer']) => timer.limitSeconds !== null && /water/i.test(timer.limitLabel ?? '');
|
||||
|
||||
export function WaterGauge({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
|
||||
const now = useNow(1000);
|
||||
if (timer.startedAt === null || timer.limitSeconds === null) return null;
|
||||
const serverNow = timer.serverNow + (now - receivedAt);
|
||||
const used = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000 + timer.penaltySeconds;
|
||||
const fill = Math.min(100, Math.max(0, (used / timer.limitSeconds) * 100));
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden" aria-hidden>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-sky-700/70 to-sky-500/40 transition-[height] duration-1000"
|
||||
style={{ height: `${fill}%` }}
|
||||
>
|
||||
{fill > 0 && fill < 100 && <div className="water-wave absolute inset-x-0 -top-1.5 h-3 motion-reduce:hidden" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type {
|
||||
ButtonModuleView,
|
||||
DefuseAction,
|
||||
DefuseView,
|
||||
KeypadModuleView,
|
||||
MorseModuleView,
|
||||
PasswordsModuleView,
|
||||
RoomState,
|
||||
SimonModuleView,
|
||||
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';
|
||||
import type { Send } from '../lib/socket';
|
||||
@@ -25,6 +31,7 @@ const SWATCH: Record<string, string> = {
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
white: '#f5f5f4',
|
||||
black: '#0c0a09',
|
||||
};
|
||||
const darkText = (color: string) => color === 'yellow' || color === 'white';
|
||||
|
||||
@@ -93,12 +100,12 @@ 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">
|
||||
<span className="w-5 font-mono text-sm text-stone-400">{i + 1}</span>
|
||||
<span className="h-3 flex-1 rounded-full shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
|
||||
<span className="h-3 flex-1 rounded-full border border-white/25 shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
|
||||
<span className="w-16 font-mono text-sm font-bold uppercase">{color}</span>
|
||||
<button
|
||||
className="btn !min-h-[40px] px-3 text-sm"
|
||||
@@ -187,10 +194,137 @@ function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseActi
|
||||
);
|
||||
}
|
||||
|
||||
/** Five letter columns to cycle. The word list is in the manual, not here. */
|
||||
function Passwords({ module, act }: { module: PasswordsModuleView; act: (a: DefuseAction) => void }) {
|
||||
const [rows, setRows] = useState<number[]>(() => module.columns.map(() => 0));
|
||||
const word = module.columns.map((letters, i) => letters[rows[i]]).join('');
|
||||
const turn = (column: number, by: number) =>
|
||||
setRows((r) => r.map((row, i) => (i === column ? (row + by + module.columns[i].length) % module.columns[i].length : row)));
|
||||
|
||||
return (
|
||||
<ModuleFrame title="Passwords" solved={module.solved}>
|
||||
<div className="flex justify-center gap-1.5">
|
||||
{module.columns.map((letters, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-1">
|
||||
<button className="btn !min-h-[40px] w-11 px-0" disabled={module.solved} onClick={() => turn(i, -1)} aria-label={`Column ${i + 1}, previous letter`}>
|
||||
▲
|
||||
</button>
|
||||
<div
|
||||
className="flex h-12 w-11 items-center justify-center rounded-lg border-2 border-stone-500 bg-black font-mono text-2xl font-bold"
|
||||
aria-label={`Column ${i + 1}: ${letters[rows[i]]}, letter ${rows[i] + 1} of ${letters.length}`}
|
||||
>
|
||||
{letters[rows[i]]}
|
||||
</div>
|
||||
<button className="btn !min-h-[40px] w-11 px-0" disabled={module.solved} onClick={() => turn(i, 1)} aria-label={`Column ${i + 1}, next letter`}>
|
||||
▼
|
||||
</button>
|
||||
<span className="font-mono text-[10px] text-stone-500" aria-hidden>
|
||||
{rows[i] + 1}/{letters.length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-primary mt-3 w-full font-mono" disabled={module.solved} onClick={() => act({ type: 'submit', word })}>
|
||||
Submit {word}
|
||||
</button>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** A blinking light and a dial. The pattern is only ever shown as light. */
|
||||
function Morse({ module, act }: { module: MorseModuleView; act: (a: DefuseAction) => void }) {
|
||||
const [khz, setKhz] = useState(module.minKhz);
|
||||
const tune = (by: number) => setKhz((f) => Math.min(module.maxKhz, Math.max(module.minKhz, f + by)));
|
||||
const mhz = (khz / 1000).toFixed(3);
|
||||
const steps: [string, number][] = [['−10', -10], ['−1', -1], ['+1', 1], ['+10', 10]];
|
||||
|
||||
return (
|
||||
<ModuleFrame title="Morse Light" solved={module.solved}>
|
||||
{!module.solved && <SignalLamp pattern={module.signal} />}
|
||||
<div className="rounded-lg border-2 border-stone-600 bg-black px-3 py-2 text-center font-mono text-2xl font-bold tabular-nums" aria-live="polite">
|
||||
{mhz} <span className="text-sm text-stone-400">MHz</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="mt-3 w-full accent-[rgb(var(--accent))]"
|
||||
min={module.minKhz}
|
||||
max={module.maxKhz}
|
||||
step={1}
|
||||
value={khz}
|
||||
disabled={module.solved}
|
||||
onChange={(e) => setKhz(Number(e.target.value))}
|
||||
aria-label="Frequency dial"
|
||||
aria-valuetext={`${mhz} megahertz`}
|
||||
/>
|
||||
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||
{steps.map(([label, by]) => (
|
||||
<button key={label} className="btn !min-h-[40px] px-0 font-mono text-sm" disabled={module.solved} onClick={() => tune(by)} aria-label={`${by > 0 ? 'Up' : 'Down'} ${Math.abs(by)} kilohertz`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-primary mt-3 w-full font-mono" disabled={module.solved} onClick={() => act({ type: 'transmit', frequency: mhz })}>
|
||||
TX on {mhz}
|
||||
</button>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pads that flash a sequence, then take the answer. Every pad carries its colour name. */
|
||||
function Simon({ module, act }: { module: SimonModuleView; act: (a: DefuseAction) => void }) {
|
||||
const [lit, setLit] = useState<string | null>(null);
|
||||
const flashes = module.flashes.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (module.solved) return setLit(null);
|
||||
const sequence = flashes.split(',');
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let i = 0;
|
||||
// Each flash 600ms on, 300ms off, then a long pause before the sequence repeats.
|
||||
const show = () => {
|
||||
setLit(sequence[i]);
|
||||
timer = setTimeout(hide, 600);
|
||||
};
|
||||
const hide = () => {
|
||||
setLit(null);
|
||||
i = (i + 1) % sequence.length;
|
||||
timer = setTimeout(show, i === 0 ? 1800 : 300);
|
||||
};
|
||||
timer = setTimeout(show, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [flashes, module.solved]);
|
||||
|
||||
return (
|
||||
<ModuleFrame title="Say It Back" solved={module.solved}>
|
||||
<div className="mb-3 flex items-center justify-between font-mono text-xs text-stone-400" aria-live="polite">
|
||||
<span>Stage {module.stage} of {module.stages}</span>
|
||||
<span>{module.entered} of {module.flashes.length} pressed</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{module.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`flex h-20 flex-col items-center justify-center rounded-xl border-4 font-mono text-sm font-bold uppercase transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
lit === color ? 'scale-105 border-white opacity-100 shadow-[0_0_24px_4px_rgba(255,255,255,0.45)]' : 'border-black/50 opacity-45'
|
||||
} ${darkText(color) ? 'text-stone-900' : 'text-white'}`}
|
||||
style={{ backgroundColor: SWATCH[color] ?? color }}
|
||||
disabled={module.solved}
|
||||
onClick={() => act({ type: 'press', color })}
|
||||
aria-label={`Press ${color}${lit === color ? ' (flashing now)' : ''}`}
|
||||
>
|
||||
{color}
|
||||
<span className="text-[10px]" aria-hidden>{lit === color ? '● FLASH' : '\u00a0'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Device({ view, send }: { view: TechnicianView; send: Send }) {
|
||||
if (!view.device) return null;
|
||||
const act = (moduleId: string) => (action: DefuseAction) => send('defuse:action', { moduleId, action });
|
||||
const { serial, batteries, modules } = view.device;
|
||||
const { serial, batteries, indicators, modules } = view.device;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-2xl border-2 border-stone-600 bg-stone-900/70 p-4 font-mono">
|
||||
@@ -204,16 +338,41 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) {
|
||||
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{indicators.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Indicators</div>
|
||||
<ul className="mt-1 flex flex-wrap gap-2">
|
||||
{indicators.map((i) => (
|
||||
<li
|
||||
key={i.label}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm font-bold ${
|
||||
i.lit ? 'border-amber-300 bg-amber-300/15 text-amber-200' : 'border-stone-600 text-stone-400'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${i.lit ? 'bg-amber-300 shadow-[0_0_8px_2px_rgba(252,211,77,0.7)]' : 'bg-stone-700'}`} aria-hidden />
|
||||
{i.label} <span className="text-[10px] font-normal uppercase">{i.lit ? 'lit' : 'unlit'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{modules.map((m) =>
|
||||
m.id === 'wires' ? (
|
||||
<Wires key={m.id} module={m} act={act(m.id)} />
|
||||
) : m.id === 'button' ? (
|
||||
<BigButton key={m.id} module={m} act={act(m.id)} />
|
||||
) : (
|
||||
<Keypad key={m.id} module={m} act={act(m.id)} />
|
||||
),
|
||||
)}
|
||||
{modules.map((m) => {
|
||||
switch (m.id) {
|
||||
case 'wires':
|
||||
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':
|
||||
return <Keypad key={m.id} module={m} act={act(m.id)} />;
|
||||
case 'passwords':
|
||||
return <Passwords key={m.columns.join('')} module={m} act={act(m.id)} />;
|
||||
case 'morse':
|
||||
return <Morse key={m.signal} module={m} act={act(m.id)} />;
|
||||
case 'simon':
|
||||
return <Simon key={m.id} module={m} act={act(m.id)} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,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>
|
||||
@@ -250,7 +440,7 @@ export function DefuseGame({ view, state, receivedAt, send }: Props) {
|
||||
<div className="text-5xl" aria-hidden>💥</div>
|
||||
<h2 className="mt-2 font-display text-2xl font-bold">Boom.</h2>
|
||||
<p className="mt-1 text-stone-200">
|
||||
{view.failReason === 'time' ? 'The clock ran out.' : 'Three strikes.'} Good thing it was only confetti.
|
||||
{view.failReason === 'time' ? 'The clock ran out.' : `${view.maxStrikes} strikes.`} Good thing it was only confetti.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-400">You keep your keys. A fresh device is waiting.</p>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Dir, ExplorerView, LabyrinthView, NavigatorView, RoomState } from '@two-keys/shared';
|
||||
import type { Dir, ExplorerView, LabyrinthView, MoveDir, NavigatorView, RoomState } from '@two-keys/shared';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
@@ -28,13 +28,23 @@ function Lanterns({ carried, total }: { carried: number; total: number }) {
|
||||
|
||||
// ---------- Navigator: the whole map, but not where the Explorer is ----------
|
||||
|
||||
const GATE_ARROW: Record<Dir, string> = { n: '↑', e: '→', s: '↓', w: '←' };
|
||||
|
||||
function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) {
|
||||
const width = view.grid[0].length;
|
||||
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">
|
||||
{view.banner && (
|
||||
<p className="rounded-xl border border-accent/50 bg-accent/10 px-4 py-3 text-center text-sm font-semibold text-accent">
|
||||
🪞 {view.banner}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
|
||||
<div className="text-right text-sm text-stone-300">
|
||||
@@ -58,9 +68,16 @@ 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 !== '#') {
|
||||
content += GATE_ARROW[gate.dir];
|
||||
if (tone === 'bg-stone-800/40') tone = 'bg-sky-950 font-bold text-accent';
|
||||
}
|
||||
return (
|
||||
<div key={key} className={`flex aspect-square items-center justify-center text-[clamp(11px,4vw,20px)] leading-none ${tone}`}>
|
||||
{content}
|
||||
@@ -93,8 +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>
|
||||
@@ -104,30 +137,42 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
|
||||
|
||||
// ---------- Explorer: one cell, four directions ----------
|
||||
|
||||
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
|
||||
type Control = { dir: MoveDir; label: string; arrow: string; area: string; keys: string[] };
|
||||
const DIRS: Control[] = [
|
||||
{ dir: 'n', label: 'North', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] },
|
||||
{ dir: 'w', label: 'West', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] },
|
||||
{ dir: 'e', label: 'East', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] },
|
||||
{ dir: 's', label: 'South', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] },
|
||||
];
|
||||
// No compass: the same pad, but relative to the way the Explorer is facing.
|
||||
const RELATIVE_DIRS: Control[] = [
|
||||
{ dir: 'f', label: 'Forward', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] },
|
||||
{ dir: 'l', label: 'Left', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] },
|
||||
{ dir: 'r', label: 'Right', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] },
|
||||
{ dir: 'b', label: 'Back', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] },
|
||||
];
|
||||
|
||||
function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) {
|
||||
const relative = view.compass === 'relative';
|
||||
const controls = relative ? RELATIVE_DIRS : DIRS;
|
||||
|
||||
// Arrow keys / WASD move too (ignored while typing in the notepad).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return;
|
||||
const match = DIRS.find((d) => d.keys.includes(e.key));
|
||||
const match = controls.find((d) => d.keys.includes(e.key));
|
||||
if (!match || !view.open[match.dir]) return;
|
||||
e.preventDefault();
|
||||
send('maze:move', { dir: match.dir });
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [view.open, send]);
|
||||
}, [view.open, controls, send]);
|
||||
|
||||
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
|
||||
const open = DIRS.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
|
||||
const open = controls.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
|
||||
const gated = controls.filter((d) => view.gates.includes(d.dir)).map((d) => d.label.toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -135,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"
|
||||
@@ -151,27 +204,43 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
|
||||
)}
|
||||
{view.message && <p className="mt-3 text-lg italic text-stone-200">{view.message}</p>}
|
||||
<p className="mt-3 text-sm text-stone-400">
|
||||
Open passages: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
|
||||
Open: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
|
||||
</p>
|
||||
{gated.length > 0 && (
|
||||
<p className="mt-1 text-sm text-stone-400">
|
||||
Gate (won't open from this side): <strong className="text-stone-200">{gated.join(', ')}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid w-56 grid-cols-3 grid-rows-3 gap-2" role="group" aria-label="Move">
|
||||
{DIRS.map((d) => (
|
||||
<button
|
||||
key={d.dir}
|
||||
className={`btn aspect-square text-2xl ${d.area}`}
|
||||
disabled={!view.open[d.dir]}
|
||||
onClick={() => send('maze:move', { dir: d.dir })}
|
||||
aria-label={`Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : ' (wall)'}`}
|
||||
>
|
||||
{d.arrow}
|
||||
</button>
|
||||
))}
|
||||
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>🧭</div>
|
||||
{controls.map((d) => {
|
||||
const gate = view.gates.includes(d.dir);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={d.dir}
|
||||
className={`btn aspect-square flex-col gap-0 text-2xl ${d.area}`}
|
||||
disabled={!view.open[d.dir]}
|
||||
onClick={() => send('maze:move', { dir: d.dir })}
|
||||
aria-label={gate ? `${d.label}: Gate (won't open from this side)` : `Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : ' (wall)'}`}
|
||||
title={gate ? "Gate (won't open from this side)" : undefined}
|
||||
>
|
||||
{gate ? '⛩' : d.arrow}
|
||||
{gate ? (
|
||||
<span className="text-[10px] font-semibold uppercase leading-tight tracking-wide">Gate</span>
|
||||
) : (
|
||||
relative && <span className="text-[10px] font-semibold uppercase tracking-wide">{d.label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>{relative ? '👣' : '🧭'}</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-stone-400">
|
||||
Tell {navigatorName} what's on the floor and which ways are open. There are traps you can't see. Wait for directions!
|
||||
{relative && ' You have no compass: every step turns you to face the way you just went.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
@@ -14,11 +14,16 @@ interface Props {
|
||||
send: Send;
|
||||
}
|
||||
|
||||
function Clues({ clues }: { clues: ClueView[] }) {
|
||||
interface Pins {
|
||||
pins: string[];
|
||||
onPin: (clueId: string) => void;
|
||||
}
|
||||
|
||||
function Clues({ clues, pins, onPin }: { clues: ClueView[] } & Pins) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{clues.map((clue) => (
|
||||
<ClueCard key={clue.id} clue={clue} />
|
||||
<ClueCard key={clue.id} clue={clue} pinned={pins.includes(clue.id)} onPin={() => onPin(clue.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -71,11 +76,28 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
|
||||
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
|
||||
const partnerName = state.game.roles[otherRole(role)].name;
|
||||
const mine = finale.proposal?.by === role;
|
||||
const lastShot = finale.attemptsLeft === 1;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
useEffect(() => setConfirming(false), [proposalKey, finale.agreed]);
|
||||
|
||||
let action;
|
||||
if (matchesProposal && finale.agreed) {
|
||||
if (matchesProposal && finale.agreed && confirming) {
|
||||
action = (
|
||||
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
|
||||
<div className="rounded-2xl border border-red-400/50 bg-red-500/10 p-3 text-center" role="alertdialog" aria-label="Confirm accusation">
|
||||
<p className="font-semibold">One shot. Are you both sure?</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="btn-primary flex-1" onClick={() => send('finale:submit')}>
|
||||
Yes, accuse
|
||||
</button>
|
||||
<button className="btn flex-1" onClick={() => setConfirming(false)}>
|
||||
Not yet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (matchesProposal && finale.agreed) {
|
||||
action = (
|
||||
<button className="btn-primary w-full" onClick={() => (lastShot ? setConfirming(true) : send('finale:submit'))}>
|
||||
You both agree. Make the accusation ⚖️
|
||||
</button>
|
||||
);
|
||||
@@ -136,7 +158,9 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
|
||||
<p className="mb-2 text-center text-xs text-stone-400" aria-live="polite">
|
||||
{finale.attemptsLeft} of {finale.maxAttempts} attempts left
|
||||
{finale.maxAttempts === 1
|
||||
? 'One attempt only. A wrong accusation ends the game.'
|
||||
: `${finale.attemptsLeft} of ${finale.maxAttempts} attempts left`}
|
||||
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
|
||||
</p>
|
||||
{action}
|
||||
@@ -145,9 +169,70 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
|
||||
const textOf = (clue: ClueView) => `${clue.title}\n${clue.body}`.toLowerCase();
|
||||
|
||||
/** Search, filter and star your own clues. Everything here is already on this device. */
|
||||
function EvidenceLocker({
|
||||
view,
|
||||
chapters,
|
||||
finaleClues,
|
||||
currentChapter,
|
||||
pins,
|
||||
onPin,
|
||||
}: { view: StoryView; chapters: ChapterView[]; finaleClues: ClueView[]; currentChapter: number } & Pins) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [only, setOnly] = useState<number | 'all' | 'starred'>('all');
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtering = q !== '' || only !== 'all';
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const all = [
|
||||
...chapters.map((ch, i) => ({ key: ch.id, index: i, title: `Chapter ${i + 1}: ${ch.title}`, chapter: ch, clues: ch.clues })),
|
||||
...(finaleClues.length > 0 ? [{ key: 'finale', index: chapters.length, title: 'Finale', chapter: null, clues: finaleClues }] : []),
|
||||
];
|
||||
return all
|
||||
.filter((s) => typeof only !== 'number' || s.index === only)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
clues: s.clues.filter((c) => (only !== 'starred' || pins.includes(c.id)) && (q === '' || textOf(c).includes(q))),
|
||||
}));
|
||||
}, [chapters, finaleClues, only, q, pins]);
|
||||
const found = sections.reduce((n, s) => n + s.clues.length, 0);
|
||||
|
||||
const chip = (value: typeof only, label: string) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
aria-pressed={only === value}
|
||||
onClick={() => setOnly(only === value ? 'all' : value)}
|
||||
className={`min-h-[36px] shrink-0 rounded-full border px-3 py-1 text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
only === value ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="search"
|
||||
className="field"
|
||||
placeholder="Search your clues…"
|
||||
aria-label="Search your clues"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1" role="group" aria-label="Show">
|
||||
{chip('all', 'All')}
|
||||
{chip('starred', `★ Starred (${pins.length})`)}
|
||||
{chapters.map((_, i) => chip(i, `Ch ${i + 1}`))}
|
||||
</div>
|
||||
<p className="text-xs text-stone-400" aria-live="polite">
|
||||
{filtering ? `${found} clue${found === 1 ? '' : 's'} found · ` : ''}
|
||||
Your partner has starred {view.partnerPins} of their clue{view.partnerPins === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
{view.suspects.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
|
||||
@@ -163,22 +248,25 @@ function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: Chapter
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{chapters.length === 0 && view.suspects.length === 0 && (
|
||||
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
|
||||
)}
|
||||
{chapters.map((ch, i) => (
|
||||
<details key={ch.id} className="group" open={i === chapters.length - 1}>
|
||||
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
||||
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
||||
Chapter {i + 1}: {ch.title}
|
||||
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 px-1">
|
||||
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
|
||||
<Clues clues={ch.clues} />
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{filtering && found === 0 && <p className="text-stone-400">Nothing matches.</p>}
|
||||
{sections
|
||||
.filter((s) => !filtering || s.clues.length > 0)
|
||||
.map((s) => (
|
||||
// Filtering opens every match; otherwise the latest solved chapter starts open.
|
||||
<details key={`${s.key}:${filtering}`} className="group" open={filtering || s.index === currentChapter - 1}>
|
||||
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
||||
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
||||
{s.title}
|
||||
{s.chapter?.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {s.chapter.lock.keyName}</span>}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 px-1">
|
||||
{!filtering && s.chapter?.lock.successText && (
|
||||
<Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{s.chapter.lock.successText}</Markdown>
|
||||
)}
|
||||
<Clues clues={s.clues} pins={pins} onPin={onPin} />
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -188,8 +276,10 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
const atFinale = view.finale !== null;
|
||||
const chapter = atFinale ? null : view.chapters[view.currentChapter];
|
||||
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
|
||||
// Everything already solved: all chapters once the finale opens, earlier ones before that.
|
||||
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
|
||||
const pins = view.pins ?? [];
|
||||
const onPin = (clueId: string) => send('clue:pin', { clueId });
|
||||
const finaleClues = view.finale?.type === 'escape' ? view.finale.clues : [];
|
||||
const clueCount = view.chapters.reduce((n, c) => n + c.clues.length, 0) + finaleClues.length;
|
||||
|
||||
// Tell the partner what we're looking at.
|
||||
useEffect(() => {
|
||||
@@ -208,7 +298,7 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
|
||||
</div>
|
||||
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
|
||||
🗄️ Evidence{past.length > 0 ? ` (${past.reduce((n, c) => n + c.clues.length, 0)})` : ''}
|
||||
🗄️ Evidence ({clueCount}){pins.length > 0 ? ` · ★${pins.length}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
@@ -226,7 +316,7 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
{chapter && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
|
||||
<Clues clues={chapter.clues} />
|
||||
<Clues clues={chapter.clues} pins={pins} onPin={onPin} />
|
||||
{chapter.lock.solved && view.awaitingContinue ? (
|
||||
<Unlocked
|
||||
lock={chapter.lock}
|
||||
@@ -249,13 +339,20 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
{view.finale?.type === 'escape' && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
||||
<Clues clues={view.finale.clues} />
|
||||
<Clues clues={view.finale.clues} pins={pins} onPin={onPin} />
|
||||
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
|
||||
<EvidenceLocker view={view} chapters={past} />
|
||||
<EvidenceLocker
|
||||
view={view}
|
||||
chapters={view.chapters}
|
||||
finaleClues={finaleClues}
|
||||
currentChapter={view.currentChapter}
|
||||
pins={pins}
|
||||
onPin={onPin}
|
||||
/>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
|
||||
+192
-33
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState, type FormEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Role, RoomState, SyncView } from '@two-keys/shared';
|
||||
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { RoleBadge } from '../components/ui';
|
||||
import type { Send } from '../lib/socket';
|
||||
@@ -34,7 +35,7 @@ function Marks({ view }: { view: SyncView }) {
|
||||
}
|
||||
|
||||
/** One answer card that flips over when both answers are in. */
|
||||
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: string; delay: number }) {
|
||||
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: ReactNode; delay: number }) {
|
||||
return (
|
||||
<div className="flex-1 [perspective:800px]">
|
||||
<motion.div
|
||||
@@ -50,6 +51,132 @@ function FlipCard({ role, name, answer, delay }: { role: Role; name: string; ans
|
||||
);
|
||||
}
|
||||
|
||||
/** Telepathy: one typed word, no options to lean on. */
|
||||
function WordInput({ onSubmit }: { onSubmit: (text: string) => void }) {
|
||||
const [text, setText] = useState('');
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) onSubmit(text.trim());
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} className="flex gap-2">
|
||||
<input
|
||||
className="field text-lg"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
maxLength={30}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="One word"
|
||||
aria-label="Your word"
|
||||
/>
|
||||
<button type="submit" className="btn-primary shrink-0" disabled={!text.trim()}>
|
||||
Lock in
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranking: put the items in order, most at the top. Drag the grip (mouse or touch), or use the
|
||||
* up/down buttons (keyboard and screen readers).
|
||||
*/
|
||||
function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) {
|
||||
const [order, setOrder] = useState<number[]>(() => items.map((_, i) => i));
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const rows = useRef<(HTMLLIElement | null)[]>([]);
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= order.length || from === to) return;
|
||||
setOrder((o) => {
|
||||
const next = [...o];
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
});
|
||||
setAnnounce(`${items[order[from]]} moved to number ${to + 1}.`);
|
||||
};
|
||||
|
||||
// Pointer dragging: whichever row the pointer is over becomes the dragged item's new place.
|
||||
const onPointerMove = (e: ReactPointerEvent) => {
|
||||
if (dragging === null) return;
|
||||
const from = order.indexOf(dragging);
|
||||
const to = rows.current.findIndex((row) => {
|
||||
const r = row?.getBoundingClientRect();
|
||||
return r !== undefined && e.clientY >= r.top && e.clientY <= r.bottom;
|
||||
});
|
||||
if (to >= 0 && to !== from) move(from, to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-xs text-stone-400">Most at the top, least at the bottom.</p>
|
||||
<ol className="grid gap-2 touch-none select-none" onPointerMove={onPointerMove} onPointerUp={() => setDragging(null)} onPointerCancel={() => setDragging(null)}>
|
||||
{order.map((item, place) => (
|
||||
<li
|
||||
key={item}
|
||||
ref={(el) => (rows.current[place] = el)}
|
||||
className={`flex min-h-[56px] items-center gap-2 rounded-xl border px-2 py-2 text-lg font-medium transition-colors ${
|
||||
dragging === item ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded-lg text-stone-400 active:cursor-grabbing"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest('ol') as HTMLElement | null)?.setPointerCapture(e.pointerId);
|
||||
setDragging(item);
|
||||
}}
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
|
||||
place === 0 || place === order.length - 1 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-400'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{place + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">{items[item]}</span>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn !min-h-[40px] !px-2.5 !py-1"
|
||||
disabled={place === 0}
|
||||
onClick={() => move(place, place - 1)}
|
||||
aria-label={`Move ${items[item]} up, now number ${place + 1}`}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn !min-h-[40px] !px-2.5 !py-1"
|
||||
disabled={place === order.length - 1}
|
||||
onClick={() => move(place, place + 1)}
|
||||
aria-label={`Move ${items[item]} down, now number ${place + 1}`}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => onSubmit(order)}>
|
||||
Lock in this order
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncGame({ view, state, role, send }: Props) {
|
||||
const roles = state.game.roles;
|
||||
const partnerName = roles[otherRole(role)].name;
|
||||
@@ -108,8 +235,40 @@ export function SyncGame({ view, state, role, send }: Props) {
|
||||
|
||||
const q = view.question;
|
||||
if (!q) return null;
|
||||
const predict = view.round.type === 'predict';
|
||||
const { type } = view.round;
|
||||
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
|
||||
const answered = view.myAnswer !== null;
|
||||
|
||||
let instruction: string;
|
||||
if (type === 'match') instruction = 'Pick what you think your partner will pick.';
|
||||
else if (type === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.';
|
||||
else if (type === 'rank') {
|
||||
instruction = view.iAmSubject
|
||||
? "This one's about you. Put them in order, most to least. Be honest!"
|
||||
: `Put them in the order ${subjectName} would, most to least. The top and the bottom have to match.`;
|
||||
} else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`;
|
||||
|
||||
/** An answer as the reveal cards show it. */
|
||||
const show = (answer: SyncAnswer): ReactNode => {
|
||||
if (typeof answer === 'string') return answer;
|
||||
if (typeof answer === 'number') return q.options[answer];
|
||||
return (
|
||||
<ol className="w-full space-y-1 text-left text-sm sm:text-base">
|
||||
{answer.map((item, place) => {
|
||||
// #1 and the last place are the ones that score.
|
||||
const scores = place === 0 || place === answer.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={item}
|
||||
className={scores ? 'rounded-md bg-accent/20 px-1.5 py-0.5 text-accent' : 'px-1.5 font-normal text-stone-400'}
|
||||
>
|
||||
{place + 1}. {q.options[item]}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -120,39 +279,39 @@ export function SyncGame({ view, state, role, send }: Props) {
|
||||
Question {view.questionNumber} of {view.questionsPerRound}
|
||||
</div>
|
||||
<p className="mt-2 font-display text-2xl font-bold leading-snug">{q.text}</p>
|
||||
{view.status === 'question' && (
|
||||
<p className="mt-2 text-sm text-accent">
|
||||
{!predict
|
||||
? 'Pick what you think your partner will pick.'
|
||||
: view.iAmSubject
|
||||
? "This one's about you. Answer honestly!"
|
||||
: `Guess what ${subjectName} will say.`}
|
||||
</p>
|
||||
)}
|
||||
{view.status === 'question' && <p className="mt-2 text-sm text-accent">{instruction}</p>}
|
||||
</div>
|
||||
|
||||
{view.status === 'question' && (
|
||||
<>
|
||||
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
|
||||
{q.options.map((option, i) => (
|
||||
<button
|
||||
key={i}
|
||||
role="radio"
|
||||
aria-checked={view.myAnswer === i}
|
||||
disabled={view.myAnswer !== null}
|
||||
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
|
||||
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
view.myAnswer === i
|
||||
? 'border-accent bg-accent/25'
|
||||
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{answered && type !== 'match' && type !== 'predict' ? (
|
||||
<div className="card text-center font-display text-lg font-bold">{show(view.myAnswer!)}</div>
|
||||
) : type === 'telepathy' ? (
|
||||
<WordInput key={q.id} onSubmit={(text) => send('sync:answer', { questionId: q.id, text })} />
|
||||
) : type === 'rank' ? (
|
||||
<RankPicker key={q.id} items={q.options} onSubmit={(order) => send('sync:answer', { questionId: q.id, order })} />
|
||||
) : (
|
||||
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
|
||||
{q.options.map((option, i) => (
|
||||
<button
|
||||
key={i}
|
||||
role="radio"
|
||||
aria-checked={view.myAnswer === i}
|
||||
disabled={answered}
|
||||
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
|
||||
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
view.myAnswer === i
|
||||
? 'border-accent bg-accent/25'
|
||||
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-sm text-stone-400" aria-live="polite">
|
||||
{view.myAnswer !== null
|
||||
{answered
|
||||
? couch
|
||||
? `Locked in. Pass the device to ${partnerName}.`
|
||||
: `Locked in. Waiting for ${partnerName}…`
|
||||
@@ -166,8 +325,8 @@ export function SyncGame({ view, state, role, send }: Props) {
|
||||
{view.status === 'reveal' && view.reveal && (
|
||||
<div className="space-y-4" aria-live="polite">
|
||||
<div className="flex gap-3">
|
||||
<FlipCard role="A" name={roles.A.name} answer={q.options[view.reveal.A]} delay={0} />
|
||||
<FlipCard role="B" name={roles.B.name} answer={q.options[view.reveal.B]} delay={0.15} />
|
||||
<FlipCard role="A" name={roles.A.name} answer={show(view.reveal.A)} delay={0} />
|
||||
<FlipCard role="B" name={roles.B.name} answer={show(view.reveal.B)} delay={0.15} />
|
||||
</div>
|
||||
<motion.p
|
||||
className="text-center font-display text-2xl font-bold"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+21
-3
@@ -101,18 +101,24 @@ body {
|
||||
.md th:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Stencil grids and box-drawing keypads need tight lines so the boxes join up. */
|
||||
.md pre {
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
max-width: 100%;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
line-height: 1.15;
|
||||
white-space: pre;
|
||||
}
|
||||
.md pre code.language-sql {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md pre code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.md code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.md .scroll-x {
|
||||
@@ -131,6 +137,18 @@ body {
|
||||
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
|
||||
}
|
||||
|
||||
/* ---------- Water gauge (Deep Lab Nereid) ---------- */
|
||||
.water-wave {
|
||||
background: radial-gradient(ellipse 12px 6px at 12px 6px, rgb(56 189 248 / 0.55) 98%, transparent) repeat-x;
|
||||
background-size: 24px 12px;
|
||||
animation: wave 2.4s linear infinite;
|
||||
}
|
||||
@keyframes wave {
|
||||
to {
|
||||
background-position-x: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
20%,
|
||||
60% {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface BestResult {
|
||||
rank: string;
|
||||
hintsUsed: number;
|
||||
keysEarned: number;
|
||||
/** Ever finished this (difficulty 5) game with zero hints. Kept once earned. */
|
||||
nightmareSurvivors?: boolean;
|
||||
}
|
||||
|
||||
export function getBest(gameId: string): BestResult | null {
|
||||
@@ -53,6 +55,10 @@ export function saveBest(gameId: string, result: BestResult): boolean {
|
||||
!best ||
|
||||
result.keysEarned > best.keysEarned ||
|
||||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
|
||||
const nightmareSurvivors = Boolean(result.nightmareSurvivors || best?.nightmareSurvivors);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify({ ...result, nightmareSurvivors }));
|
||||
else if (nightmareSurvivors && !best?.nightmareSurvivors) {
|
||||
write(`twokeys:best:${gameId}`, JSON.stringify({ ...best, nightmareSurvivors }));
|
||||
}
|
||||
return better;
|
||||
}
|
||||
|
||||
+23
-2
@@ -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,9 +37,30 @@ 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' };
|
||||
export const ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
|
||||
|
||||
export const difficultyKeys = (n: number) => '🔑'.repeat(n);
|
||||
export const MAX_DIFFICULTY = 5;
|
||||
export const DIFFICULTY_LABEL: Record<number, string> = { 1: 'Easy', 2: 'Medium', 3: 'Hard', 4: 'Expert', 5: 'Nightmare' };
|
||||
export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n));
|
||||
|
||||
@@ -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,16 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import type { Category } from '@two-keys/shared';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { GameCard } from '../components/GameCard';
|
||||
import { SiteShell } from '../components/SiteShell';
|
||||
import { useGames } from '../lib/hooks';
|
||||
import { CATEGORY_LABEL } 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 }) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
active ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Library() {
|
||||
const games = useGames();
|
||||
const [filter, setFilter] = useState<Category | 'all'>('all');
|
||||
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
|
||||
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' || 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);
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
@@ -21,16 +41,20 @@ export function Library() {
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
aria-pressed={filter === f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
|
||||
</button>
|
||||
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
|
||||
{f === 'all' ? 'All games' : FILTER_LABEL[f]}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2" role="group" aria-label="Filter by difficulty">
|
||||
<Chip active={level === null} onClick={() => setLevel(null)}>
|
||||
Any difficulty
|
||||
</Chip>
|
||||
{LEVELS.map((d) => (
|
||||
<Chip key={d} active={level === d} onClick={() => setLevel(level === d ? null : d)}>
|
||||
{DIFFICULTY_LABEL[d]}
|
||||
{d === MAX_DIFFICULTY ? ' ☠️' : ''}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -43,6 +67,7 @@ export function Library() {
|
||||
{shown.map((game) => (
|
||||
<GameCard key={game.id} game={game} />
|
||||
))}
|
||||
{shown.length === 0 && <p className="text-stone-400">No games match those filters.</p>}
|
||||
</div>
|
||||
)}
|
||||
</SiteShell>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import type { Role, RoomState } from '@two-keys/shared';
|
||||
import { Markdown, RoleBadge } from '../components/ui';
|
||||
import { socket } from '../lib/socket';
|
||||
import { CATEGORY_LABEL, ROLE_BORDER, difficultyKeys } from '../lib/theme';
|
||||
import { MAX_DIFFICULTY, chipLabel, ROLE_BORDER, difficultyKeys } from '../lib/theme';
|
||||
|
||||
function ShareBox({ code }: { code: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -49,9 +49,10 @@ 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 3`}>{difficultyKeys(game.difficulty)}</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>}
|
||||
</div>
|
||||
<h1 className="mt-3 font-display text-4xl font-bold leading-tight">{game.title}</h1>
|
||||
<p className="mt-2 text-stone-300">{game.tagline}</p>
|
||||
|
||||
@@ -5,12 +5,15 @@ import { KeyRing } from '../components/KeyRing';
|
||||
import { Notepad } from '../components/Notepad';
|
||||
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
|
||||
import { PassDeviceScreen } from '../components/PassDeviceScreen';
|
||||
import { Timer } from '../components/Timer';
|
||||
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' };
|
||||
@@ -45,8 +48,9 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
|
||||
<header
|
||||
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
|
||||
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} isolate z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
|
||||
>
|
||||
{isWaterLevel(state.timer) && <WaterGauge timer={state.timer} receivedAt={receivedAt} />}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
|
||||
🔑🔑
|
||||
@@ -81,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} />}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
||||
rank: result.rank,
|
||||
hintsUsed: result.hintsUsed,
|
||||
keysEarned: result.keysEarned,
|
||||
nightmareSurvivors: result.nightmareSurvivors,
|
||||
}),
|
||||
);
|
||||
}, [game.id, result]);
|
||||
@@ -55,9 +56,15 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
||||
>
|
||||
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
{result.outOfTime ? '⌛' : result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
</motion.div>
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
|
||||
{/* Out of time gets no rank badge. */}
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.outOfTime ? 'Out of time' : result.rank}</h1>
|
||||
{result.nightmareSurvivors && (
|
||||
<p className="mx-auto mt-3 w-fit rounded-full bg-red-500/15 px-4 py-1.5 font-semibold text-red-200">
|
||||
☠️ Nightmare Survivors: a difficulty-5 game with zero hints
|
||||
</p>
|
||||
)}
|
||||
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@ export default {
|
||||
roleA: '#f5b942',
|
||||
roleB: '#2dd4bf',
|
||||
},
|
||||
// Noto Sans Symbols 2 covers the Braille and alchemy symbols (⠍ ☉ ♄ ☿) the clues use.
|
||||
fontFamily: {
|
||||
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
|
||||
serif: ['Playfair Display', 'Georgia', 'serif'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
hand: ['Caveat', 'cursive'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
|
||||
serif: ['Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
id: alchemists-vault
|
||||
engine: story
|
||||
category: escape-room
|
||||
title: The Alchemist's Vault
|
||||
tagline: Prague, 1602. The alchemist's cellar. The door only opens for those who understand the elements.
|
||||
estimatedMinutes: 25
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2d5a27", accent: "#d4af37", background: "#0b100a", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
roles:
|
||||
A:
|
||||
name: The Apprentice
|
||||
blurb: You're at the workbench. Flasks, scales, plaques and the vault door.
|
||||
B:
|
||||
name: The Scribe
|
||||
blurb: You're in the library alcove. Tables, charts, and the master's books.
|
||||
intro: |
|
||||
You came to the old alchemist's house to deliver a package. The cellar door swung shut behind you
|
||||
and a bar dropped across it.
|
||||
|
||||
A brass speaking-tube carries your voices between the workbench and the library alcove.
|
||||
Carved above the vault door:
|
||||
|
||||
> *"Gold is not made. It is understood. — Master Kelley"*
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Five Flasks
|
||||
narrative: |
|
||||
Five sealed flasks stand on the workbench, each etched with a number and a colour of flame.
|
||||
A wooden cabinet beside them has a six-letter word lock.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-flasks
|
||||
title: The five flasks (left to right as they stand)
|
||||
type: table
|
||||
body: |
|
||||
| Flask | Etched number | Flame colour |
|
||||
|-------|---------------|--------------|
|
||||
| 1 | 92 | Green |
|
||||
| 2 | 7 | Orange |
|
||||
| 3 | 16 | Violet |
|
||||
| 4 | 32 | Red |
|
||||
| 5 | 53 | Yellow |
|
||||
B:
|
||||
- id: b1-table
|
||||
title: Master Kelley's table of elements (a page)
|
||||
type: table
|
||||
body: |
|
||||
| No. | Symbol | No. | Symbol | No. | Symbol |
|
||||
|-----|--------|-----|--------|-----|--------|
|
||||
| 6 | C | 26 | Fe | 53 | I |
|
||||
| 7 | N | 29 | Cu | 79 | Au |
|
||||
| 8 | O | 30 | Zn | 80 | Hg |
|
||||
| 16 | S | 32 | Ge | 82 | Pb |
|
||||
| 19 | K | 47 | Ag | 92 | U |
|
||||
- id: b1-recipe
|
||||
title: Recipe card on the lectern
|
||||
type: note
|
||||
body: |
|
||||
*Read the flasks by their fire, as the rainbow burns:
|
||||
**red, orange, yellow, green, violet.** Their signs will name what I see in my pupil.*
|
||||
lock:
|
||||
id: lock-flasks
|
||||
prompt: Enter the cabinet word.
|
||||
inputType: text
|
||||
format: 6 letters
|
||||
answers: ["GENIUS"]
|
||||
hints:
|
||||
- Order the flasks by flame colour, using the rainbow order from the recipe card.
|
||||
- "Red is flask 4 (32), orange is flask 2 (7), yellow is flask 5 (53)..."
|
||||
- 32 Ge, 7 N, 53 I, 92 U, 16 S = GeNIUS.
|
||||
successText: |
|
||||
**GENIUS.** The cabinet opens: a set of brass weights and a lump of dark, heavy stone wrapped in cloth.
|
||||
Painted on the inside of the door: *"Weigh the stone."*
|
||||
keyName: Flame Key
|
||||
|
||||
- id: ch2
|
||||
title: The Philosopher's Stone
|
||||
narrative: |
|
||||
Two balance scales stand on the workbench, already loaded, perfectly level.
|
||||
A dial on the wall wants the weight of the stone in drams.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-scales
|
||||
title: The two balance scales
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
SCALE ONE (balanced)
|
||||
left pan: the STONE + 1 lead weight
|
||||
right pan: 2 gold weights + 1 silver weight
|
||||
|
||||
SCALE TWO (balanced)
|
||||
left pan: 2 lead weights
|
||||
right pan: 1 gold weight + 1 silver weight
|
||||
```
|
||||
The weights carry the symbols of their metals, but no numbers.
|
||||
B:
|
||||
- id: b2-ledger
|
||||
title: Weights ledger, margin notes
|
||||
type: note
|
||||
body: |
|
||||
*The silver weight is stamped **5 drams**. I lost the other tags.
|
||||
I remember only that a gold weight is **three times** a silver one.*
|
||||
lock:
|
||||
id: lock-stone
|
||||
prompt: How many drams does the stone weigh?
|
||||
inputType: code
|
||||
format: A number
|
||||
answers: ["25"]
|
||||
hints:
|
||||
- Work out gold first, then use scale two to find lead.
|
||||
- Gold = 15. Scale two says 2 lead = 15 + 5, so lead = 10.
|
||||
- "Scale one: stone + 10 = 15 + 15 + 5 = 35, so the stone is 25."
|
||||
successText: |
|
||||
**25 drams.** The dial clicks, and a stone panel slides aside, revealing a brass plaque of raised dots.
|
||||
keyName: Scale Key
|
||||
|
||||
- id: ch3
|
||||
title: The Dotted Plaque
|
||||
narrative: |
|
||||
The plaque is covered in raised dots. Beneath it, a two-digit brass dial.
|
||||
Master Kelley went blind in his last years. He wrote his secrets for his fingers.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-plaque
|
||||
title: The plaque
|
||||
type: diagram
|
||||
body: |
|
||||
## ⠍⠑⠗⠉⠥⠗⠽
|
||||
|
||||
*Under it, engraved: "Turn the dial to my **number**."*
|
||||
B:
|
||||
- id: b3-braille
|
||||
title: A card of raised-dot letters (from the master's desk)
|
||||
type: table
|
||||
body: |
|
||||
| ⠁ A | ⠃ B | ⠉ C | ⠙ D | ⠑ E | ⠋ F | ⠛ G | ⠓ H | ⠊ I |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|
|
||||
| ⠚ J | ⠅ K | ⠇ L | ⠍ M | ⠝ N | ⠕ O | ⠏ P | ⠟ Q | ⠗ R |
|
||||
| ⠎ S | ⠞ T | ⠥ U | ⠧ V | ⠺ W | ⠭ X | ⠽ Y | ⠵ Z | |
|
||||
- id: b3-names
|
||||
title: Old names for the metals (bookmark)
|
||||
type: note
|
||||
body: |
|
||||
Quicksilver = **Mercury** = Hg. *The only metal that runs like water.*
|
||||
lock:
|
||||
id: lock-plaque
|
||||
prompt: Set the two-digit dial.
|
||||
inputType: code
|
||||
format: 2 digits
|
||||
answers: ["80"]
|
||||
hints:
|
||||
- Decode the plaque letter by letter using the dot card.
|
||||
- The word is MERCURY. "My number" is its number in the table of elements.
|
||||
- Mercury (Hg) is 80.
|
||||
successText: |
|
||||
**80.** Somewhere in the walls, chains rattle. The vault door's last lock lights up:
|
||||
four alchemical symbols glowing in a row.
|
||||
keyName: Quicksilver Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: The vault door has a three-digit wheel and four glowing symbols.
|
||||
clues:
|
||||
A:
|
||||
- id: af-door
|
||||
title: The vault door
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
☉ ☽ ♂ ♄
|
||||
```
|
||||
*Carved around the wheel: "Add up the hearts of my four metals."*
|
||||
B:
|
||||
- id: bf-symbols
|
||||
title: Chart of the seven metals
|
||||
type: table
|
||||
body: |
|
||||
| Symbol | Planet | Metal |
|
||||
|--------|---------|---------|
|
||||
| ☉ | Sun | Gold |
|
||||
| ☽ | Moon | Silver |
|
||||
| ☿ | Mercury | Mercury |
|
||||
| ♀ | Venus | Copper |
|
||||
| ♂ | Mars | Iron |
|
||||
| ♃ | Jupiter | Tin |
|
||||
| ♄ | Saturn | Lead |
|
||||
*"A metal's heart is its number."*
|
||||
lock:
|
||||
id: lock-vault
|
||||
prompt: Turn the wheel to the right number.
|
||||
inputType: code
|
||||
format: 3 digits
|
||||
answers: ["234"]
|
||||
hints:
|
||||
- Name the four metals from the chart, then find each one's number in the table of elements from the first chapter.
|
||||
- Gold 79, Silver 47, Iron 26, Lead 82.
|
||||
- 79 + 47 + 26 + 82 = 234.
|
||||
successEnding: |
|
||||
**234.** The vault swings open. No gold inside. Just a mirror, and a note:
|
||||
|
||||
> *"You found it together. That was always the gold."*
|
||||
|
||||
The cellar door's bar lifts. **You escaped!** ⚗️🔑
|
||||
@@ -3,8 +3,8 @@ engine: story
|
||||
category: murder-mystery
|
||||
title: The Last Set at the Blue Lantern
|
||||
tagline: Cape Town, 1958. The band played on. The boss didn't.
|
||||
estimatedMinutes: 50
|
||||
difficulty: 2
|
||||
estimatedMinutes: 10
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1f3a68", accent: "#e8b04a", background: "#0b0f1c", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
@@ -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.** 🎨🔑
|
||||
@@ -3,7 +3,7 @@ engine: story
|
||||
category: escape-room
|
||||
title: The Clockmaker's Attic
|
||||
tagline: Locked in an old clockmaker's attic. Every clock is ticking. So are you.
|
||||
estimatedMinutes: 40
|
||||
estimatedMinutes: 15
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#8a5a2b", accent: "#f2c66d", background: "#17110b", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -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**.
|
||||
@@ -0,0 +1,340 @@
|
||||
id: dead-air
|
||||
engine: story
|
||||
category: murder-mystery
|
||||
title: Dead Air
|
||||
tagline: A legendary DJ goes silent live on air. Four minutes of nothing. One killer in the building.
|
||||
estimatedMinutes: 30
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#d7263d", accent: "#f9c80e", background: "#0c0a12", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
roles:
|
||||
A:
|
||||
name: The Detective
|
||||
blurb: You have the police file. Lab results, statements, staff records.
|
||||
B:
|
||||
name: The Night Intern
|
||||
blurb: You work the graveyard shift at Night Owl FM. Logs, run sheets, cameras, gossip.
|
||||
intro: |
|
||||
**Night Owl FM, Johannesburg. 23:31.**
|
||||
|
||||
Rex Daniels has hosted the midnight show for 22 years. Tonight he promised listeners
|
||||
"the biggest announcement in the history of this station, at midnight sharp."
|
||||
|
||||
He never made it. A song ended, and nothing came after it. Just hiss.
|
||||
The producer found him slumped over the desk, his tea still warm.
|
||||
|
||||
Five people were in the building. **Every one of them had a reason.**
|
||||
|
||||
⚠️ *Harder case: the locks give you facts, not the answer. You'll have to connect them yourselves in the accusation.*
|
||||
|
||||
suspects:
|
||||
- id: zanele
|
||||
name: Zanele Dube
|
||||
role: Producer
|
||||
description: Runs the desk, makes Rex's tea, knows his every habit. Has been passed over for promotion three times.
|
||||
- id: kyle
|
||||
name: Kyle Pretorius
|
||||
role: Junior DJ
|
||||
description: Ambitious, loud, and openly waiting for Rex's slot.
|
||||
- id: marisa
|
||||
name: Marisa Venter
|
||||
role: Station manager
|
||||
description: Co-owns the station with Rex. Was "in meetings" all night.
|
||||
- id: tumi
|
||||
name: Tumi Nkosi
|
||||
role: Sound engineer
|
||||
description: Keeps the old place running on duct tape and prayer. Rex once got him written up.
|
||||
- id: owen
|
||||
name: Owen Hart
|
||||
role: Superfan
|
||||
description: Calls in every night. Turned up at the gate tonight with flowers for Rex.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Ghost Signal
|
||||
narrative: |
|
||||
The automation computer kept playing what it was told. The dead-air alarm caught the silence.
|
||||
First question: **which song was playing when Rex went quiet for good?**
|
||||
clues:
|
||||
A:
|
||||
- id: a1-automation
|
||||
title: Automation log, tonight's queue
|
||||
type: log
|
||||
body: |
|
||||
| # | Track | Length | Status |
|
||||
|---|-----------------------------|--------|----------|
|
||||
| 1 | Neon Rain | 4:12 | PLAYED |
|
||||
| 2 | Highveld Heart | 3:48 | PLAYED |
|
||||
| 3 | Slow Dance in a Fast Car | 5:05 | PLAYED |
|
||||
| 4 | Paper Moons | 3:55 | PLAYED |
|
||||
| 5 | Ghost Signal | 6:20 | PLAYED |
|
||||
| 6 | Last Train Home | 4:40 | NOT PLAYED (needs manual start) |
|
||||
- id: a1-security
|
||||
title: Gate security log
|
||||
type: log
|
||||
body: |
|
||||
22:30 · Owen Hart at gate with flowers. Not on guest list. Refused entry.
|
||||
22:30–23:50 · Owen sits in reception with guard Sipho. "Wouldn't stop talking about Rex."
|
||||
B:
|
||||
- id: b1-runsheet
|
||||
title: Producer's run sheet
|
||||
type: document
|
||||
body: |
|
||||
**Midnight Show, standard format**
|
||||
- Show opens **23:00:00** sharp. Rex's intro link: **2:00**.
|
||||
- Rex talks for **exactly 1:30** after every song. He's never late. Ever.
|
||||
- Songs play straight from automation. Rex must start each song manually.
|
||||
- id: b1-alarm
|
||||
title: Dead-air alarm printout
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
** DEAD AIR ALARM ** 23:31:35
|
||||
Trigger: silence > 15 seconds on main output
|
||||
```
|
||||
lock:
|
||||
id: lock-song
|
||||
prompt: Which song was the last thing listeners heard?
|
||||
inputType: text
|
||||
format: Song title
|
||||
answers: ["GHOSTSIGNAL"]
|
||||
hints:
|
||||
- The alarm fires 15 seconds after silence starts. Work out when the silence started, then build the show's timeline.
|
||||
- Silence started at 23:31:20. Add the intro, each song, and each 1:30 talk break, starting from 23:00:00.
|
||||
- Ghost Signal ran from 23:25:00 to 23:31:20, and Rex never came back to talk after it.
|
||||
successText: |
|
||||
**Ghost Signal**, 23:25:00 to 23:31:20. Rex's last words on air ended at 23:25:00:
|
||||
*"Longest song of the night, which means it's tea time. Back in six, Joburg."*
|
||||
He never started Last Train Home. Whatever happened, it happened during Ghost Signal.
|
||||
keyName: Needle Key
|
||||
|
||||
- id: ch2
|
||||
title: The Last Cup
|
||||
narrative: |
|
||||
The paramedics say anaphylactic shock. Rex had a severe peanut allergy and everyone knew it.
|
||||
His EpiPen was not in his bag. And he'd been drinking tea all night.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-lab
|
||||
title: Lab report, rushed
|
||||
type: report
|
||||
body: |
|
||||
**Cause of death:** anaphylaxis (peanut protein, Ara h 2).
|
||||
| Item tested | Peanut protein |
|
||||
|---------------------------------------|----------------|
|
||||
| Mug "ON AIR" (in Rex's hand) | **POSITIVE** |
|
||||
| Mug "WORLD'S OKAYEST DJ" (studio sink)| negative (honey residue present) |
|
||||
| Jar labelled "REX ONLY" (honey) | **POSITIVE** |
|
||||
| Tea bags, kettle water, milk | negative |
|
||||
- id: a2-minutes
|
||||
title: Boardroom meeting minutes
|
||||
type: document
|
||||
body: |
|
||||
**Quick staff meeting, 23:08–23:20.** Topic: Rex's "midnight announcement".
|
||||
Present: Zanele Dube, Tumi Nkosi, Kyle Pretorius, Marisa Venter (chair).
|
||||
*23:11 · Marisa steps out "to take a call from the lawyers". Returns 23:17.*
|
||||
Nobody else left the room. (Rex on air, Owen in reception.)
|
||||
B:
|
||||
- id: b2-camera
|
||||
title: Kitchen camera log (motion triggered)
|
||||
type: log
|
||||
body: |
|
||||
| Time | Who | What |
|
||||
|-------|---------|------|
|
||||
| 22:45 | Rex | Makes himself rooibos. **Black, no honey.** |
|
||||
| 22:58 | Tumi | Fills water bottle |
|
||||
| 23:05 | Kyle | Instant coffee |
|
||||
| 23:06 | Zanele | Makes Rex's tea in the **WORLD'S OKAYEST DJ** mug, honey from **REX ONLY** jar. Takes it to studio. |
|
||||
| 23:07 | (empty) | · |
|
||||
| 23:09 | · | **CAMERA OFFLINE** |
|
||||
| 23:21 | · | Camera back online. Kitchen empty. |
|
||||
| 23:24 | Zanele | Makes Rex's tea in the **ON AIR** mug, honey from **REX ONLY** jar. Takes it to studio. |
|
||||
- id: b2-maintenance
|
||||
title: Tumi's maintenance whiteboard
|
||||
type: note
|
||||
body: |
|
||||
*Generator test TONIGHT 23:09 (booked by M. Venter this afternoon, last minute??).
|
||||
Cameras + kitchen plugs drop for about 12 min. Studio is on the UPS, stays live.*
|
||||
lock:
|
||||
id: lock-safe-honey
|
||||
prompt: What's the last time the honey was definitely safe?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["2306", "1106", "1106PM"]
|
||||
hints:
|
||||
- The honey jar is positive now. Was it always? Look at the first mug with honey in it.
|
||||
- The OKAYEST DJ mug had honey and tested negative. When was that cup made?
|
||||
- Zanele made the OKAYEST DJ cup at 23:06, and Rex drank it and was fine.
|
||||
successText: |
|
||||
**23:06.** The honey was clean at 23:06 and poisoned by 23:24.
|
||||
The camera was on from 23:06 to 23:09 and from 23:21 to 23:24, and nobody touched the jar then.
|
||||
So the honey was poisoned **while the camera was off, 23:09 to 23:21.**
|
||||
keyName: Honey Key
|
||||
|
||||
- id: ch3
|
||||
title: The Studio Door
|
||||
narrative: |
|
||||
Rex's EpiPen always lived in the side pocket of his bag, which sits in the studio lounge.
|
||||
The lounge has a card reader. Someone went in while Rex was live in the booth.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-register
|
||||
title: Staff access card register
|
||||
type: table
|
||||
body: |
|
||||
| Staff member | Card number |
|
||||
|-------------------|-------------|
|
||||
| Rex Daniels | 401 |
|
||||
| Zanele Dube | 409 |
|
||||
| Marisa Venter | 412 |
|
||||
| Kyle Pretorius | 417 |
|
||||
| Tumi Nkosi | 423 |
|
||||
- id: a3-kyle
|
||||
title: Kyle's statement
|
||||
type: transcript
|
||||
body: |
|
||||
"I had a one-on-one with Marisa in the boardroom at half ten. Left my card on the table.
|
||||
Went back for it at ten to eleven and it was gone. I reported it to security, check the book.
|
||||
Then I was stuck in that meeting from eight past till twenty past. Ask anyone."
|
||||
B:
|
||||
- id: b3-doorlog
|
||||
title: Studio lounge door reader
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
CARD READER v2.1 · IDs IN HEX (Tumi: "don't ask")
|
||||
22:45:02 ID 0x191 IN
|
||||
23:12:40 ID 0x1A1 IN
|
||||
23:13:55 ID 0x1A1 OUT
|
||||
23:24:31 ID 0x199 IN
|
||||
23:31:50 ID 0x199 IN (panic button pressed)
|
||||
```
|
||||
- id: b3-reception
|
||||
title: Reception lost-property book
|
||||
type: note
|
||||
body: |
|
||||
*22:52 · Kyle P. reports staff card missing. Says left in boardroom. Card NOT cancelled (system down for generator test prep).*
|
||||
lock:
|
||||
id: lock-card
|
||||
prompt: Whose card opened the studio lounge at 23:12?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: rex, label: Rex Daniels }
|
||||
- { id: zanele, label: Zanele Dube }
|
||||
- { id: marisa, label: Marisa Venter }
|
||||
- { id: kyle, label: Kyle Pretorius }
|
||||
- { id: tumi, label: Tumi Nkosi }
|
||||
answers: ["kyle"]
|
||||
hints:
|
||||
- The reader shows hexadecimal. The register is in normal (decimal) numbers.
|
||||
- "0x1A1 = 1×256 + 10×16 + 1."
|
||||
- 0x1A1 = 417, which is Kyle's card.
|
||||
successText: |
|
||||
**Kyle's card.** 23:12 to 23:13, in and out in 75 seconds, just long enough to empty a bag pocket.
|
||||
But Kyle reported that card stolen at 22:52... and the minutes put him in the boardroom at 23:12.
|
||||
keyName: Access Key
|
||||
|
||||
- id: ch4
|
||||
title: The Midnight Announcement
|
||||
narrative: |
|
||||
What was Rex going to say at midnight? His notes were in his jacket: a page of crossword-looking
|
||||
letters, and a piece of card with little windows cut into it.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-grid
|
||||
title: Page from Rex's notebook
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
G S C A L C
|
||||
H C T E B I
|
||||
D H W W S F
|
||||
O U R B H B
|
||||
T G E O E T
|
||||
D U D T Y F
|
||||
```
|
||||
*Scrawled underneath: "Zan knows how to read these. Lay the card on top."*
|
||||
B:
|
||||
- id: b4-stencil
|
||||
title: Cut card from Rex's jacket
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
■ □ ■ □ □ ■
|
||||
■ ■ ■ □ ■ □
|
||||
■ ■ ■ ■ □ □
|
||||
□ ■ □ ■ ■ ■
|
||||
■ □ □ ■ ■ ■
|
||||
■ ■ □ ■ ■ ■
|
||||
```
|
||||
■ = solid card · □ = window cut out. Same size as a notebook page, 6 × 6 squares.
|
||||
Zanele says: "His old trick. Read the windows left to right, top to bottom."
|
||||
- id: b4-gossip
|
||||
title: Overheard in the corridor (last week)
|
||||
type: transcript
|
||||
body: |
|
||||
**Tumi:** "Marisa says some media group's buying us. Rex says he never signed anything."
|
||||
**Zanele:** "He'd never sell. He'd sooner die on air."
|
||||
lock:
|
||||
id: lock-stencil
|
||||
prompt: What did Rex's hidden message say?
|
||||
inputType: text
|
||||
format: The message (3 words)
|
||||
answers: ["SALEISFORGED", "THESALEISFORGED"]
|
||||
hints:
|
||||
- The Detective reads out the letters that sit under each □ window.
|
||||
- "Row 1 windows are columns 2, 4 and 5: S, A, L."
|
||||
- S-A-L-E-I-S-F-O-R-G-E-D.
|
||||
successText: |
|
||||
**"SALE IS FORGED."** The station's sale contract carries Rex's signature. Rex never signed it.
|
||||
At midnight, he was going to tell the whole city, live.
|
||||
The only other owner who could sell the station: **Marisa Venter.**
|
||||
keyName: Microphone Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: Four keys. Put it together. Who killed Rex Daniels?
|
||||
maxAttempts: 2
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Rex?
|
||||
options:
|
||||
- { id: zanele, label: Zanele Dube }
|
||||
- { id: kyle, label: Kyle Pretorius }
|
||||
- { id: marisa, label: Marisa Venter }
|
||||
- { id: tumi, label: Tumi Nkosi }
|
||||
- { id: owen, label: Owen Hart }
|
||||
answer: marisa
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: tea, label: Peanut in the tea Zanele served him at 23:24 }
|
||||
- { id: honey, label: "Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card" }
|
||||
- { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day }
|
||||
- { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers }
|
||||
answer: honey
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: slot, label: To take over the midnight slot }
|
||||
- { id: writeup, label: Revenge for a disciplinary write-up }
|
||||
- { id: forged, label: Rex was about to reveal on air that she'd forged his signature to sell the station }
|
||||
- { id: obsession, label: Rejected obsession }
|
||||
answer: forged
|
||||
successEnding: |
|
||||
Marisa doesn't argue. She just looks at the "ON AIR" light, still glowing red.
|
||||
|
||||
She booked the generator test so the kitchen camera would drop. She took Kyle's card off
|
||||
the boardroom table at 22:30. At 23:11 she "stepped out for a call", used Kyle's card to get
|
||||
into the lounge at 23:12, took the EpiPen from Rex's bag, then slipped peanut oil into his honey.
|
||||
Back in the meeting by 23:17. Zanele carried the poison in herself, never knowing.
|
||||
|
||||
**Case closed.** At midnight, Zanele reads Rex's announcement on air. 📻🔑
|
||||
failEnding: |
|
||||
The wrong person is arrested. The sale goes through. Night Owl FM becomes a 24-hour playlist with no DJs.
|
||||
reveal: |
|
||||
**Marisa Venter** poisoned the honey with **peanut oil** during the generator test she booked,
|
||||
and used **Kyle's stolen card** to take Rex's **EpiPen**, because Rex was going to reveal live
|
||||
that she'd **forged his signature to sell the station**.
|
||||
@@ -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,211 @@
|
||||
id: deep-lab-nereid
|
||||
engine: story
|
||||
category: escape-room
|
||||
title: "Submerged: Deep Lab Nereid"
|
||||
tagline: 300 metres under the sea. The lab is flooding. The clock is literal.
|
||||
estimatedMinutes: 35
|
||||
difficulty: 4
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#0b4f6c", accent: "#20bf55", background: "#03111a", headingFont: "JetBrains Mono" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
timeLimitMinutes: 40
|
||||
timeLimitLabel: "Water level" # show the countdown as a rising water gauge
|
||||
roles:
|
||||
A:
|
||||
name: Pilot
|
||||
blurb: You're in the control room. Panels, sonar, and the launch console.
|
||||
B:
|
||||
name: Diver
|
||||
blurb: You're in the wet lab. Pipe diagrams, tank labels, the station map, and a lot of water.
|
||||
intro: |
|
||||
**Deep Lab Nereid**, 300 m below the Agulhas Current. Your shift was supposed to be quiet.
|
||||
|
||||
At 03:12, a pressure seal fails. Alarms. Red lights. Water pouring through the wet lab.
|
||||
The rest of the crew made it to the first escape pod. You two are on opposite sides of a sealed hatch.
|
||||
|
||||
The second pod is your only way out. **The water gauge is your timer. When it hits the ceiling, it's over.**
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Pressure Valves
|
||||
narrative: |
|
||||
The hatch between you won't open while any pressure warning light is on.
|
||||
The Diver can reach five valves. Turning a valve flips some of the warning lights.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-lights
|
||||
title: Pressure warning panel (control room)
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
LIGHT: L1 L2 L3 L4 L5
|
||||
STATE: ON ON ON off ON
|
||||
```
|
||||
HATCH RELEASE: requires ALL LIGHTS OFF.
|
||||
Valve entry: type the numbers of the valves to turn, smallest first. Each valve once only.
|
||||
B:
|
||||
- id: b1-pipes
|
||||
title: Valve wiring plate (wet lab wall)
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
VALVE 1 → flips L1, L2
|
||||
VALVE 2 → flips L2, L3, L5
|
||||
VALVE 3 → flips L1, L4
|
||||
VALVE 4 → flips L3, L4
|
||||
VALVE 5 → flips L4, L5
|
||||
```
|
||||
"Flip" = ON becomes off, off becomes ON. Turning a valve twice undoes it.
|
||||
lock:
|
||||
id: lock-valves
|
||||
prompt: Which valves do you turn?
|
||||
inputType: code
|
||||
format: Valve numbers, smallest first (e.g. 23)
|
||||
answers: ["145"]
|
||||
hints:
|
||||
- Try it on paper. Each valve flips its lights, and order doesn't matter.
|
||||
- L1 is ON. Only valves 1 and 3 touch L1, so you need exactly one of them.
|
||||
- "Valves 1, 4 and 5. Check: L1 flips once, L2 once, L3 once, L4 twice (back to off), L5 once."
|
||||
successText: |
|
||||
**Valves 1, 4, 5.** Every light dies. The hatch hisses open, and you can finally see each other.
|
||||
Ankle-deep water. Keep moving.
|
||||
keyName: Valve Key
|
||||
|
||||
- id: ch2
|
||||
title: Breathing Gas
|
||||
narrative: |
|
||||
The pod's air tanks are empty. The lab's mixing rig can fill them, but the mix has to be exactly right.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-spec
|
||||
title: Pod life-support spec (launch console)
|
||||
type: document
|
||||
body: |
|
||||
**Fill pod tank to 210 litres.**
|
||||
Oxygen content must be exactly **21%**.
|
||||
Mixer input: litres drawn from **Tank X**. The rest is topped up from Tank Y automatically.
|
||||
B:
|
||||
- id: b2-tanks
|
||||
title: Tank labels (wet lab)
|
||||
type: table
|
||||
body: |
|
||||
| Tank | Contents | Oxygen |
|
||||
|------|-------------------|--------|
|
||||
| X | Nitrox | 36% |
|
||||
| Y | Low-oxygen mix | 15% |
|
||||
| Z | Pure helium | 0% |
|
||||
*Tank Z is not connected to the mixer.*
|
||||
lock:
|
||||
id: lock-mix
|
||||
prompt: How many litres from Tank X?
|
||||
inputType: code
|
||||
format: Litres
|
||||
answers: ["60"]
|
||||
hints:
|
||||
- You need 21% of 210 litres to be oxygen. That's 44.1 litres of oxygen.
|
||||
- If x litres come from X, oxygen = 0.36x + 0.15(210 − x).
|
||||
- 0.21x = 44.1 − 31.5 = 12.6, so x = 60.
|
||||
successText: |
|
||||
**60 litres.** The mixer hums. The pod's oxygen gauge settles at 21.0%. Water is at your knees.
|
||||
keyName: Oxygen Key
|
||||
|
||||
- id: ch3
|
||||
title: Find the Pod
|
||||
narrative: |
|
||||
The second pod broke loose from its dock in the quake and drifted. Its beacon is pinging.
|
||||
You need to steer the tether crawler to the right square of the seabed.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-sonar
|
||||
title: Sonar contact (control room)
|
||||
type: report
|
||||
body: |
|
||||
```
|
||||
CONTACT: ESCAPE POD 2
|
||||
Leg 1: 300 m, bearing 090 (due east) from the lab
|
||||
Leg 2: 200 m, bearing 180 (due south) from the end of leg 1
|
||||
```
|
||||
B:
|
||||
- id: b3-chart
|
||||
title: Seabed chart (wet lab)
|
||||
type: map
|
||||
body: |
|
||||
```
|
||||
A B C D E F G H I J
|
||||
1 . . . . . . . . . .
|
||||
2 . . . . . . . . . .
|
||||
3 . . . . . . . . . .
|
||||
4 . . . . . . . . . .
|
||||
5 . . . . [N] . . . . .
|
||||
6 . . . . . . . . . .
|
||||
7 . . . . . . . . . .
|
||||
8 . . . . . . . . . .
|
||||
```
|
||||
[N] = Lab Nereid. Each square = **100 m**. North is up.
|
||||
lock:
|
||||
id: lock-grid
|
||||
prompt: Which chart square is the pod in?
|
||||
inputType: text
|
||||
format: e.g. B4
|
||||
answers: ["H7"]
|
||||
hints:
|
||||
- Start at the lab's square, E5.
|
||||
- 300 m east is three squares east. 200 m south is two squares down.
|
||||
- E → F → G → H, then 5 → 6 → 7. H7.
|
||||
successText: |
|
||||
**H7.** The crawler latches on and reels the pod in. It docks with a clunk you feel in your teeth.
|
||||
Water is at your waist.
|
||||
keyName: Sonar Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: The pod's launch console wants a code word, typed on the ancient intercom keypad.
|
||||
clues:
|
||||
A:
|
||||
- id: af-log
|
||||
title: Station commander's last log entry
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
LAUNCH WORD (typed on the intercom keypad, pauses between letters):
|
||||
555 · 4 · 3 · 66 · 55 · 9 · 222
|
||||
```
|
||||
*The intercom is not a normal phone keypad. The engineers rewired it. Ask the wet lab.*
|
||||
B:
|
||||
- id: bf-keypad
|
||||
title: Intercom keypad (wet lab wall)
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
┌──────┬──────┬───────┐
|
||||
│ 1 │ 2 │ 3 │
|
||||
│ │ QWE │ RTY │
|
||||
├──────┼──────┼───────┤
|
||||
│ 4 │ 5 │ 6 │
|
||||
│ UIO │ PAS │ DFG │
|
||||
├──────┼──────┼───────┤
|
||||
│ 7 │ 8 │ 9 │
|
||||
│ HJK │ LZX │ CVBNM │
|
||||
└──────┴──────┴───────┘
|
||||
```
|
||||
Press a key once for its first letter, twice for its second, and so on.
|
||||
lock:
|
||||
id: lock-launch
|
||||
prompt: Enter the launch word.
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["SURFACE"]
|
||||
hints:
|
||||
- "555 means key 5 pressed three times."
|
||||
- Key 5 is P, A, S. Three presses = S.
|
||||
- S · U · R · F · A · C · E.
|
||||
successEnding: |
|
||||
**SURFACE.** The pod seals. A thump, a lurch, and you're rising, fast.
|
||||
|
||||
Through the porthole, the dark turns blue, then green, then gold. You break the surface
|
||||
into the sunrise, holding hands and laughing like idiots.
|
||||
|
||||
**You escaped!** 🌊🔑
|
||||
failEnding: |
|
||||
The water reaches the ceiling. The lights flicker out. (Don't worry, it's a game. Try again?)
|
||||
@@ -0,0 +1,90 @@
|
||||
id: drowned-catacombs
|
||||
engine: labyrinth
|
||||
category: co-op
|
||||
title: The Drowned Catacombs
|
||||
tagline: Ankle-deep water. Gates that only swing one way. Five places the floor gives out.
|
||||
estimatedMinutes: 20
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1e3d59", accent: "#7ec8e3", background: "#050b10", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Navigator
|
||||
blurb: You hold a water-stained survey map. Walls, carvings, traps, and the old one-way flood gates.
|
||||
B:
|
||||
name: Explorer
|
||||
blurb: You're down in the dark with a torch. You see the floor under you and the ways you can go. That's it.
|
||||
intro: |
|
||||
Under the old harbour town, the catacombs flood with every spring tide. Three **tide lanterns**
|
||||
were left down there a century ago. Light all three at the sea gate, and the water drains away.
|
||||
|
||||
**The flood gates swing one way only.** Go through one and you can't come back that way.
|
||||
And five stretches of the floor have rotted through.
|
||||
|
||||
Navigator, you have the map. Explorer, you have the torch. Talk.
|
||||
|
||||
# Grid legend: # wall · . floor · S start · E exit (needs all lanterns) · K lantern · T trap
|
||||
grid:
|
||||
- "#############"
|
||||
- "#S..#.......#"
|
||||
- "###.#.###.#.#"
|
||||
- "#.T...#K#...#"
|
||||
- "#.#.#.#.#.###"
|
||||
- "#.#.T.#...TK#"
|
||||
- "#.##.##.....#"
|
||||
- "#.#.T...#.#.#"
|
||||
- "#.#.###.#.#.#"
|
||||
- "#.......#.#.#"
|
||||
- "#.###.#.#.#.#"
|
||||
- "#..K#.....TE#"
|
||||
- "#############"
|
||||
|
||||
lanternsRequired: 3
|
||||
|
||||
# One-way gates. {x, y, dir}: you may step FROM (x,y) in direction `dir`, but never back the other way.
|
||||
# Navigator sees an arrow on the map. Explorer only sees "a heavy gate swings shut behind you" after passing,
|
||||
# and the reverse direction button is disabled with the label "Gate (won't open from this side)".
|
||||
oneWay:
|
||||
- { x: 5, y: 9, dir: e }
|
||||
- { x: 5, y: 11, dir: n }
|
||||
- { x: 7, y: 8, dir: n }
|
||||
|
||||
# Only some cells have carvings. Blank cells say nothing, so the Navigator must count steps.
|
||||
glyphs:
|
||||
- { x: 1, y: 1, glyph: arch, label: "⌒ A stone archway (the entrance)" }
|
||||
- { x: 3, y: 1, glyph: urn, label: "⚱ A cracked urn" }
|
||||
- { x: 9, y: 1, glyph: candle, label: "🕯 A melted candle stub" }
|
||||
- { x: 5, y: 3, glyph: bones, label: "☠ Crossed bones carved in the floor" }
|
||||
- { x: 10, y: 3, glyph: drip, label: "💧 Water dripping from the ceiling" }
|
||||
- { x: 1, y: 5, glyph: shell, label: "🐚 A seashell set in the stone" }
|
||||
- { x: 8, y: 5, glyph: eye, label: "👁 A carved eye" }
|
||||
- { x: 7, y: 6, glyph: anchor, label: "⚓ An anchor carving" }
|
||||
- { x: 3, y: 9, glyph: wave, label: "🌊 Three wavy lines" }
|
||||
- { x: 9, y: 9, glyph: fish, label: "🐟 A fish" }
|
||||
- { x: 11, y: 8, glyph: moon, label: "☾ A crescent moon" }
|
||||
- { x: 6, y: 11, glyph: keyhole, label: "🗝 A rusted keyhole in the wall" }
|
||||
|
||||
lanterns:
|
||||
- { x: 3, y: 11, name: Lantern of the Low Tide }
|
||||
- { x: 7, y: 3, name: Lantern of the Deep }
|
||||
- { x: 11, y: 5, name: Lantern of the Turning Tide }
|
||||
|
||||
explorerView:
|
||||
flavour:
|
||||
blank: "Cold water around your ankles. Nothing carved here."
|
||||
trap: "The floor collapses into black water! You surface, gasping... back at the archway."
|
||||
lantern: "You strike the tide lantern and it flares blue. ({name})"
|
||||
exitLocked: "A rusted sea gate with three lantern brackets. Locked tight."
|
||||
exitOpen: "You hang the lanterns. Somewhere, a huge drain groans open. The sea gate rises."
|
||||
gate: "A heavy flood gate swings shut behind you. It won't open from this side."
|
||||
|
||||
navigatorView:
|
||||
showMoveCount: true
|
||||
|
||||
scoring:
|
||||
trapPenaltySeconds: 60
|
||||
parMoves: 80
|
||||
|
||||
successEnding: |
|
||||
The water drains with a roar, and the Explorer climbs the sea-gate stairs into the harbour moonlight.
|
||||
The Navigator's map is soaked, but nobody cares. 🏮🔑
|
||||
@@ -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. 🏮🔑
|
||||
@@ -0,0 +1,118 @@
|
||||
id: in-sync-deep-cuts
|
||||
engine: sync
|
||||
category: co-op
|
||||
title: "In Sync: Deep Cuts"
|
||||
tagline: You think you know each other? Prove it without the multiple choice.
|
||||
estimatedMinutes: 25
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#6a0572", accent: "#ff6f91", background: "#120612", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Partner A
|
||||
blurb: In the spotlight for Round 2, and for questions 1, 3 and 5 of Round 4.
|
||||
B:
|
||||
name: Partner B
|
||||
blurb: In the spotlight for Round 3, and for questions 2 and 4 of Round 4.
|
||||
intro: |
|
||||
The sequel to In Sync. Same idea, much harder bar.
|
||||
|
||||
- **Round 1, Telepathy:** no options. You both **type** one word. Same word = point.
|
||||
- **Rounds 2 & 3, Spotlight:** deeper questions about each of you. You need **4 out of 5**.
|
||||
- **Round 4, Ranking:** put four things in order for your partner. Nail the **top AND bottom** to score.
|
||||
|
||||
One replay per round. No talking during a question. (Yes, we mean it. Not even eyebrows.)
|
||||
|
||||
rules:
|
||||
questionsPerRound: 5
|
||||
replaysPerRound: 1
|
||||
|
||||
rounds:
|
||||
- id: r1
|
||||
title: Telepathy
|
||||
type: telepathy
|
||||
pointsForKey: 3
|
||||
blurb: Read the prompt. Both type ONE word, privately. Normalized exact match (case, spaces, and a trailing S are ignored) = point.
|
||||
keyName: Telepathy Key
|
||||
- id: r2
|
||||
title: Spotlight on A
|
||||
type: predict
|
||||
subject: A
|
||||
pointsForKey: 4
|
||||
blurb: Partner A answers honestly about themselves. Partner B guesses. 4 of 5 to earn the key.
|
||||
keyName: Deep Key A
|
||||
- id: r3
|
||||
title: Spotlight on B
|
||||
type: predict
|
||||
subject: B
|
||||
pointsForKey: 4
|
||||
blurb: Partner B answers honestly about themselves. Partner A guesses. 4 of 5 to earn the key.
|
||||
keyName: Deep Key B
|
||||
- id: r4
|
||||
title: Ranking
|
||||
type: rank
|
||||
subjectPattern: [A, B, A, B, A]
|
||||
pointsForKey: 3
|
||||
blurb: The subject ranks four things from most to least. The partner predicts the order. Point only if BOTH the #1 and the #4 match.
|
||||
keyName: Ranking Key
|
||||
|
||||
# TELEPATHY bank: free-text prompts. Both type one word.
|
||||
telepathy:
|
||||
- { id: t01, q: "A fruit." }
|
||||
- { id: t02, q: "Something you find in a kitchen drawer." }
|
||||
- { id: t03, q: "A colour that feels like us." }
|
||||
- { id: t04, q: "An animal that would be a great pet for us." }
|
||||
- { id: t05, q: "A city we should visit together." }
|
||||
- { id: t06, q: "Something you'd take to a picnic." }
|
||||
- { id: t07, q: "A word that describes our first date." }
|
||||
- { id: t08, q: "A breakfast food." }
|
||||
- { id: t09, q: "Something cold." }
|
||||
- { id: t10, q: "A board or card game." }
|
||||
- { id: t11, q: "Something you plug in." }
|
||||
- { id: t12, q: "A pizza topping." }
|
||||
- { id: t13, q: "Something with wheels." }
|
||||
- { id: t14, q: "A word that rhymes with 'love'." }
|
||||
- { id: t15, q: "Something you find at the beach." }
|
||||
- { id: t16, q: "A dessert." }
|
||||
- { id: t17, q: "A famous movie couple's first name (either one)." }
|
||||
- { id: t18, q: "Something that makes noise at night." }
|
||||
|
||||
# PREDICT bank (deeper). "{subject}" is replaced with the subject's role name.
|
||||
predict:
|
||||
- { id: d01, q: "What does {subject} wish people asked them about more?", options: [Their dreams, Their faith or beliefs, Their work, Their childhood] }
|
||||
- { id: d02, q: "When {subject} feels most loved, it's usually because...", options: [Someone made time, Someone said it out loud, Someone did something for them, Someone remembered a small detail] }
|
||||
- { id: d03, q: "{subject}'s biggest fear about the future?", options: [Money, Health, Not doing something that matters, Drifting apart from people] }
|
||||
- { id: d04, q: "Which compliment means the most to {subject}?", options: ["You're so kind", "You're so smart", "You're so funny", "You're so strong"] }
|
||||
- { id: d05, q: "If {subject} could redo one year of their life, which kind?", options: [A school year, Their first year working, A year with a hard loss, They wouldn't redo any] }
|
||||
- { id: d06, q: "After a hard day, {subject} secretly wants...", options: [To vent without advice, Advice and a plan, Distraction, To be left alone for a bit] }
|
||||
- { id: d07, q: "What would {subject} do with a free Saturday, nobody else's plans?", options: [Create something, Be outdoors, Sleep and snack, See people] }
|
||||
- { id: d08, q: "Which does {subject} find harder?", options: [Saying sorry, Saying no, Asking for help, Accepting a compliment] }
|
||||
- { id: d09, q: "{subject}'s happiest childhood memory involves...", options: [A holiday, A family tradition, A friend, A pet] }
|
||||
- { id: d10, q: "What would {subject} want written about them one day?", options: [They were generous, They were brave, They made people laugh, They loved well] }
|
||||
- { id: d11, q: "What drains {subject} the fastest?", options: [Conflict, Crowds, Boredom, Being rushed] }
|
||||
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, "A full, noisy home", Building something together, Peace and quiet somewhere beautiful] }
|
||||
- { id: d13, q: "What does {subject} think is our biggest strength as a couple?", options: [We laugh a lot, We fight fair, We have the same values, We're a good team] }
|
||||
- { id: d14, q: "When {subject} is upset with me, they usually...", options: [Tell me straight away, Go quiet first, Joke about it, Wait until later to bring it up] }
|
||||
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, "A craft (woodwork, pottery, etc.)"] }
|
||||
- { id: d16, q: "What does {subject} spend the most time thinking about?", options: [The future, The past, Other people, Ideas and projects] }
|
||||
- { id: d17, q: "If {subject} wrote a book, it'd be...", options: [A thriller, A memoir, A how-to, A fantasy epic] }
|
||||
- { id: d18, q: "{subject}'s ideal way to celebrate a big win?", options: [Fancy dinner, Big party, Quiet night in, Buy something special] }
|
||||
|
||||
# RANK bank: subject orders the 4 items from MOST to LEAST. Partner predicts.
|
||||
rank:
|
||||
- { id: k01, q: "Rank these date nights for {subject}.", items: [Fancy dinner, Movie at home, Something active, A road trip] }
|
||||
- { id: k02, q: "Rank these for {subject}, best to worst.", items: [Breakfast in bed, A surprise gift, A handwritten letter, A whole day planned for them] }
|
||||
- { id: k03, q: "Rank these chores, {subject}'s favourite to most hated.", items: [Dishes, Laundry, Cooking, Grocery shopping] }
|
||||
- { id: k04, q: "Rank these holidays for {subject}.", items: [Bush or safari, Beach, Snow, A big city] }
|
||||
- { id: k05, q: "Rank these snacks for {subject}.", items: [Chips, Chocolate, Popcorn, Biltong] }
|
||||
- { id: k06, q: "Rank these pet peeves, most annoying to least, for {subject}.", items: [Being late, Loud chewing, Phone at dinner, Leaving lights on] }
|
||||
- { id: k07, q: "Rank these superpowers for {subject}.", items: [Flying, Invisibility, Teleporting, Reading minds] }
|
||||
- { id: k08, q: "Rank these seasons for {subject}.", items: [Summer, Autumn, Winter, Spring] }
|
||||
- { id: k09, q: "Rank these ways to spend R1000 for {subject}.", items: [Experience, Clothes, Food, Save it] }
|
||||
- { id: k10, q: "Rank these film genres for {subject}.", items: [Comedy, Thriller, Romance, Action] }
|
||||
- { id: k11, q: "Rank these for {subject}: what they'd miss most on a desert island.", items: [Phone, Coffee, Music, Their bed] }
|
||||
- { id: k12, q: "Rank these love languages for {subject}.", items: [Words, Time, Touch, Acts of service] }
|
||||
- { id: k13, q: "Rank these sounds for {subject}.", items: [Rain on a roof, Ocean waves, A crackling fire, Birds in the morning] }
|
||||
- { id: k14, q: "Rank these for {subject}: dream house feature.", items: [Big kitchen, Pool, Library, Big garden] }
|
||||
- { id: k15, q: "Rank these drinks for {subject}.", items: [Coffee, Tea, Hot chocolate, Something fizzy] }
|
||||
- { id: k16, q: "Rank these for {subject}: best part of a holiday.", items: [Planning it, Getting there, Being there, The photos after] }
|
||||
@@ -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.** 🚗🔑
|
||||
@@ -3,7 +3,7 @@ engine: sync
|
||||
category: co-op
|
||||
title: In Sync
|
||||
tagline: How well do you really know each other? Prove it. Three keys, three rounds.
|
||||
estimatedMinutes: 20
|
||||
estimatedMinutes: 15
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#c2185b", accent: "#ffb3c7", background: "#14080d", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -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?)
|
||||
@@ -0,0 +1,422 @@
|
||||
id: kalahari-expedition
|
||||
engine: story
|
||||
category: murder-mystery
|
||||
title: The Kalahari Expedition
|
||||
tagline: 1931. A diamond find. Two bodies in the sand. Nobody leaves until the truck comes back.
|
||||
estimatedMinutes: 60
|
||||
difficulty: 5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#b5651d", accent: "#f4d58d", background: "#140d06", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 240
|
||||
timeLimitMinutes: 75
|
||||
roles:
|
||||
A:
|
||||
name: The Magistrate
|
||||
blurb: Sent from Upington with the post truck. You have the medical notes, the photographs, and the dead man's papers.
|
||||
B:
|
||||
name: The Camp Clerk
|
||||
blurb: You keep the expedition's books. Rotas, inventories, maps, and every rumour around the fire.
|
||||
intro: |
|
||||
**The southern Kalahari, March 1931.**
|
||||
|
||||
Professor Edwin Marlowe's expedition has been digging for six weeks. Two weeks ago, the sponsor,
|
||||
Felix Dunmore, announced a find that made headlines in London: **diamonds**, lying in the sand.
|
||||
|
||||
Three nights ago, Marlowe was found dead at the bottom of the excavation pit.
|
||||
Last night, the camp cook, **Samuel Brink**, was found dead by the water barrels. "A snake," they say.
|
||||
|
||||
The post truck won't be back for days. Someone in this camp has killed twice.
|
||||
|
||||
☠️ *Nightmare case. One accusation. No second chances. The clock is running.*
|
||||
|
||||
suspects:
|
||||
- id: helena
|
||||
name: Dr Helena Marlowe
|
||||
role: Botanist, the professor's wife
|
||||
description: Calm, clinical, and inheriting everything. Acted as camp doctor.
|
||||
- id: reyes
|
||||
name: Captain Arthur Reyes
|
||||
role: Guide and quartermaster
|
||||
description: Ex-army. Ran the camp with a sergeant's eye. Smokes a pipe.
|
||||
- id: dunmore
|
||||
name: Mr Felix Dunmore
|
||||
role: Sponsor
|
||||
description: Financier. His Dunmore Kalahari Diamond Company shares have tripled since the find.
|
||||
- id: bell
|
||||
name: Miss Clara Bell
|
||||
role: Photographer and journalist
|
||||
description: Sent by a London paper. Up all night photographing the stars.
|
||||
- id: kessler
|
||||
name: Dr Otto Kessler
|
||||
role: Geologist
|
||||
description: Marlowe's old rival. Said loudly at dinner that "the find makes no geological sense."
|
||||
- id: pienaar
|
||||
name: Jan Pienaar
|
||||
role: Driver and mechanic
|
||||
description: Keeps the two trucks alive. Quiet. Noticed things.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Star Trails
|
||||
narrative: |
|
||||
Nobody saw Marlowe leave camp. But Clara Bell left her camera open to the sky all night,
|
||||
and the dig site is in the corner of her photograph.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-photo
|
||||
title: Clara's long-exposure photograph
|
||||
type: photo
|
||||
body: |
|
||||
*The night sky drawn into curved white arcs around the south celestial pole.
|
||||
In the bottom corner, the dig. A small orange streak shows a lantern there.
|
||||
Measured against the star arcs, the lantern streak **begins 60°** into the arc and **ends 75°** in.*
|
||||
- id: a1-medical
|
||||
title: Dr Helena Marlowe's notes on her husband
|
||||
type: report
|
||||
body: |
|
||||
Skull fracture, back of the head. Sand in the wound, but **no sand in the mouth or nose**.
|
||||
He did not breathe after he fell. He was dead **before** he went into the pit.
|
||||
B:
|
||||
- id: b1-photolog
|
||||
title: Clara's photo log
|
||||
type: log
|
||||
body: |
|
||||
Plate 14 · Star trails, camera facing south.
|
||||
Shutter **opened 22:00**. Closed 05:00.
|
||||
*(Note to self: the sky turns **15° every hour**, so a 7-hour plate gives 105° arcs.)*
|
||||
- id: b1-clara
|
||||
title: Clara's statement (given privately to you)
|
||||
type: transcript
|
||||
body: |
|
||||
"Arthur, Captain Reyes, sat with me by the camera from ten until four. We were... talking.
|
||||
Please don't put that in the official report. My editor would have a fit."
|
||||
lock:
|
||||
id: lock-lantern
|
||||
prompt: At what time did the lantern at the dig light up?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["0200", "200", "200AM"]
|
||||
hints:
|
||||
- The star arcs grow at a steady rate from the moment the shutter opened.
|
||||
- 60° at 15° per hour is 4 hours.
|
||||
- 22:00 plus 4 hours = 02:00. (It went out at 03:00.)
|
||||
successText: |
|
||||
**02:00.** Someone was at the dig with a lantern from 02:00 to 03:00.
|
||||
Marlowe was killed then, before he was put in the pit.
|
||||
keyName: Starlight Key
|
||||
|
||||
- id: ch2
|
||||
title: Footprints
|
||||
narrative: |
|
||||
The wind dropped that night. Two sets of prints lead to the pit. One is Marlowe's (size 9, hobnail).
|
||||
The other set leads there and back again.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-sketch
|
||||
title: Police sketch of the second set of prints
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
Print length (heel to toe): 31.5 cm
|
||||
Tread pattern: >>>> chevron
|
||||
Stride: even, about 78 cm. No limp.
|
||||
```
|
||||
*Note: in loose sand, a print spreads about **1 cm longer** than the sole that made it.*
|
||||
B:
|
||||
- id: b2-boots
|
||||
title: Quartermaster's boot inventory
|
||||
type: table
|
||||
body: |
|
||||
| Person | UK size | Tread |
|
||||
|-----------------|---------|-------|
|
||||
| Prof. Marlowe | 9 | hobnail |
|
||||
| Dr H. Marlowe | 5 | smooth |
|
||||
| Capt. Reyes | 11 | chevron (army issue) |
|
||||
| Mr Dunmore | 11 | chevron ("insisted on the same as the Captain's") |
|
||||
| Miss Bell | 6 | smooth |
|
||||
| Dr Kessler | 11 | hobnail |
|
||||
| Mr Pienaar | 10 | chevron |
|
||||
| Samuel Brink | 8 | smooth |
|
||||
- id: b2-sizes
|
||||
title: Bootmaker's size card (sole length)
|
||||
type: table
|
||||
body: |
|
||||
| UK size | Sole length |
|
||||
|---------|-------------|
|
||||
| 8 | 27.5 cm |
|
||||
| 9 | 28.5 cm |
|
||||
| 10 | 29.5 cm |
|
||||
| 11 | 30.5 cm |
|
||||
| 12 | 31.5 cm |
|
||||
lock:
|
||||
id: lock-boot
|
||||
prompt: What size boot made the second set of prints?
|
||||
inputType: code
|
||||
format: UK size
|
||||
answers: ["11"]
|
||||
hints:
|
||||
- Don't read the print length straight off the size card. Sand changes it.
|
||||
- The sole is about 1 cm shorter than the print, so 30.5 cm.
|
||||
- 30.5 cm is a size 11.
|
||||
successText: |
|
||||
**Size 11, chevron tread, even stride.** Only two people in camp own that boot.
|
||||
Check the inventory again. And remember who has an alibi from ten until four.
|
||||
keyName: Boot Key
|
||||
|
||||
- id: ch3
|
||||
title: The Watch Rota
|
||||
narrative: |
|
||||
The camp keeps a fire watch every night. Whoever was on watch at 02:00 might have seen the lantern leave.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-rule
|
||||
title: Captain Reyes' standing orders
|
||||
type: document
|
||||
body: |
|
||||
**Fire watch.** Two-hour shifts: 20:00, 22:00, 00:00, 02:00, 04:00.
|
||||
Watch passes through the six watchkeepers in **alphabetical order of surname**, wrapping round.
|
||||
Each night, the **first shift** starts with the **next person** after the one who started the night before.
|
||||
- id: a3-samuel
|
||||
title: Dr Helena Marlowe on Samuel's death
|
||||
type: report
|
||||
body: |
|
||||
Two puncture marks on the ankle, **4 cm apart**, perfectly parallel.
|
||||
A puff adder's fangs are under 1.5 cm apart. Whatever this was, it wasn't a snake.
|
||||
Traces of a sedative in his coffee cup.
|
||||
B:
|
||||
- id: b3-roster
|
||||
title: Watchkeepers and the first night
|
||||
type: log
|
||||
body: |
|
||||
Watchkeepers: **Reyes, Bell, Dunmore, Kessler, H. Marlowe, Pienaar**
|
||||
(the Professor and the cook were excused).
|
||||
**Night 1:** first watch taken by Capt. Reyes (he drew the short straw).
|
||||
Marlowe died on **Night 3**.
|
||||
lock:
|
||||
id: lock-watch
|
||||
prompt: Who was on fire watch at 02:00 on the night Marlowe died?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: helena, label: Dr Helena Marlowe }
|
||||
- { id: reyes, label: Captain Arthur Reyes }
|
||||
- { id: dunmore, label: Mr Felix Dunmore }
|
||||
- { id: bell, label: Miss Clara Bell }
|
||||
- { id: kessler, label: Dr Otto Kessler }
|
||||
- { id: pienaar, label: Jan Pienaar }
|
||||
answers: ["pienaar"]
|
||||
hints:
|
||||
- Put the six watchkeepers in alphabetical order by surname first.
|
||||
- "Order: Bell, Dunmore, Kessler, Marlowe, Pienaar, Reyes. Night 1 starts with Reyes, Night 2 with Bell, Night 3 with Dunmore."
|
||||
- "Night 3: Dunmore 20:00, Kessler 22:00, Marlowe 00:00, Pienaar 02:00."
|
||||
successText: |
|
||||
**Jan Pienaar.** He tells you:
|
||||
*"Ten past two, someone walked out to the dig with a lantern. Tall, too far to see a face.
|
||||
Came back around five past three and went into the **north row** of tents. Samuel was up too,
|
||||
brought me coffee at half two. He said, 'I know who that is.' I told him to leave it."*
|
||||
North row tents: **Dunmore, Kessler, Reyes.**
|
||||
keyName: Firewatch Key
|
||||
|
||||
- id: ch4
|
||||
title: Samuel's Note
|
||||
narrative: |
|
||||
In Samuel's recipe tin: a scrap of paper in his careful capitals. It's nonsense. On purpose.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-note
|
||||
title: Note from Samuel's recipe tin
|
||||
type: note
|
||||
body: |
|
||||
## RS XRMPVSI TN TSDJ XUSUS
|
||||
|
||||
*On the back: "Prof taught me the secret way. My word goes under the letters,
|
||||
over and over. Count back by each letter of my word: A is 0, B is 1, C is 2..."*
|
||||
- id: a4-alphabet
|
||||
title: Alphabet strip
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
|
||||
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
||||
```
|
||||
B:
|
||||
- id: b4-samuel
|
||||
title: What everyone knew about Samuel
|
||||
type: note
|
||||
body: |
|
||||
Samuel greeted every single person, every single morning, with the same word, and painted it
|
||||
on his enamel mug: **KOFFIE**. "Everything good starts with it," he'd say.
|
||||
- id: b4-smokers
|
||||
title: Camp stores, tobacco issued
|
||||
type: table
|
||||
body: |
|
||||
| Person | Tobacco |
|
||||
|--------------|---------|
|
||||
| Capt. Reyes | Pipe tobacco, 2 tins |
|
||||
| Mr Dunmore | Pipe tobacco, 3 tins (Dutch cherry) |
|
||||
| Dr Kessler | None ("gave up in '29, doctor's orders") |
|
||||
| Miss Bell | Cigarettes |
|
||||
| Mr Pienaar | Cigarettes |
|
||||
lock:
|
||||
id: lock-samuel
|
||||
prompt: What did Samuel's note say?
|
||||
inputType: text
|
||||
format: The message (5 words)
|
||||
answers: ["HESMELLEDOFPIPESMOKE"]
|
||||
hints:
|
||||
- The key word is Samuel's word, KOFFIE, repeated under the message and skipping spaces.
|
||||
- "R − K(10) = H. S − O(14) = E. X − F(5) = S."
|
||||
- "The message: HE SMELLED OF PIPE SMOKE."
|
||||
successText: |
|
||||
**"HE SMELLED OF PIPE SMOKE."** The figure at the dig, returning to the north row, smelled of a pipe.
|
||||
Samuel knew who, and two nights later he was dead.
|
||||
keyName: Coffee Tin Key
|
||||
|
||||
- id: ch5
|
||||
title: The Diamonds
|
||||
narrative: |
|
||||
Dr Kessler said the find "made no geological sense." Marlowe sent three stones to a gemologist
|
||||
in Cape Town. The reply arrived in the post bag the day after he died. Nobody had opened it.
|
||||
clues:
|
||||
A:
|
||||
- id: a5-gem
|
||||
title: Gemologist's report
|
||||
type: report
|
||||
body: |
|
||||
The three stones are genuine diamonds. However:
|
||||
- They are **sharp-edged octahedra**. Diamonds carried by rivers into the desert sand
|
||||
are always **water-worn and rounded**. These have never been near water.
|
||||
- Their trace-element profile matches **reference sample K-3** in our collection exactly.
|
||||
|
||||
*Conclusion: these stones were not found where you say they were.*
|
||||
B:
|
||||
- id: b5-reference
|
||||
title: Reference samples (Cape Town Gem Society)
|
||||
type: table
|
||||
body: |
|
||||
| Sample | Source mine |
|
||||
|--------|-------------|
|
||||
| K-1 | Jagersfontein |
|
||||
| K-2 | Koffiefontein |
|
||||
| K-3 | **Kimberley**, the Big Hole |
|
||||
| K-4 | Premier Mine |
|
||||
- id: b5-prospectus
|
||||
title: Dunmore Kalahari Diamond Co. prospectus
|
||||
type: document
|
||||
body: |
|
||||
*"Alluvial diamonds, discovered personally by our chairman **Mr F. Dunmore** at Site 7.
|
||||
Shares available now at a very favourable price."*
|
||||
lock:
|
||||
id: lock-mine
|
||||
prompt: Where did the "Kalahari" diamonds really come from?
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["KIMBERLEY", "KIMBERLY", "BIGHOLE", "THEBIGHOLE", "KIMBERLEYBIGHOLE"]
|
||||
hints:
|
||||
- The report gives a sample code. The clerk has the codes.
|
||||
- Sample K-3.
|
||||
- Kimberley.
|
||||
successText: |
|
||||
**Kimberley.** Bought stones, scattered in the sand to look like a find.
|
||||
The "discovery" was a fraud, and the man who found them personally was selling the shares.
|
||||
keyName: Diamond Key
|
||||
|
||||
- id: ch6
|
||||
title: The Unsent Telegram
|
||||
narrative: |
|
||||
Marlowe's last act was a telegram he never got to send. Like Samuel, he didn't trust plain words.
|
||||
clues:
|
||||
A:
|
||||
- id: a6-telegram
|
||||
title: Marlowe's draft telegram
|
||||
type: letter
|
||||
body: |
|
||||
```
|
||||
TO: SOUTH AFRICAN POLICE, UPINGTON
|
||||
1.4 3.3 3.9 5.4 7.1 6.2 5.2 4.5 6.7
|
||||
```
|
||||
*Pencilled underneath: "Line, then word. The rules we all signed."*
|
||||
B:
|
||||
- id: b6-handbook
|
||||
title: Expedition Handbook, Rules of Camp (page 1)
|
||||
type: document
|
||||
body: |
|
||||
1. Every member of the expedition shall carry water at all times.
|
||||
2. The camp fire is never to be left without a guard after dark.
|
||||
3. Report any find to the leader before it is moved or cleaned.
|
||||
4. No stone is to be called a diamond until it has been tested.
|
||||
5. Samples must be salted away in sealed tins, labelled and dated.
|
||||
6. The police at Upington must be informed of any death in camp.
|
||||
7. Stop all digging at once if the walls of a trench show cracks.
|
||||
8. A find that seems too good to be true usually is.
|
||||
lock:
|
||||
id: lock-telegram
|
||||
prompt: What word in Marlowe's telegram explains everything?
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["SALTED"]
|
||||
hints:
|
||||
- "1.4 means line 1, word 4."
|
||||
- "The telegram reads: THE FIND IS ___ STOP POLICE MUST BE INFORMED."
|
||||
- Line 5, word 4 is "salted". A "salted" mine is one where someone has planted stones to fake a find.
|
||||
successText: |
|
||||
**"THE FIND IS SALTED. STOP. POLICE MUST BE INFORMED."**
|
||||
Marlowe knew. He wrote this the night he died. Someone made sure it never left camp.
|
||||
keyName: Telegraph Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: The post truck's dust is on the horizon. One accusation. Get every part right.
|
||||
maxAttempts: 1
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Professor Marlowe?
|
||||
options:
|
||||
- { id: helena, label: Dr Helena Marlowe }
|
||||
- { id: reyes, label: Captain Arthur Reyes }
|
||||
- { id: dunmore, label: Mr Felix Dunmore }
|
||||
- { id: bell, label: Miss Clara Bell }
|
||||
- { id: kessler, label: Dr Otto Kessler }
|
||||
- { id: pienaar, label: Jan Pienaar }
|
||||
answer: dunmore
|
||||
- id: samuel
|
||||
label: What happened to Samuel?
|
||||
options:
|
||||
- { id: snake, label: An accidental puff adder bite }
|
||||
- { id: same, label: Drugged and killed by the same killer to silence him }
|
||||
- { id: other, label: "Killed by a second, different killer" }
|
||||
- { id: illness, label: Heart failure from the heat }
|
||||
answer: same
|
||||
- id: how
|
||||
label: How did Marlowe die?
|
||||
options:
|
||||
- { id: fall, label: He fell into the pit in the dark }
|
||||
- { id: struck, label: "Lured to the dig at 02:00, struck on the head, then put in the pit" }
|
||||
- { id: poison, label: "Poisoned at dinner, found dead at the pit" }
|
||||
- { id: shot, label: Shot with the Captain's rifle }
|
||||
answer: struck
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: inherit, label: To inherit his estate }
|
||||
- { id: rival, label: Professional jealousy over the find }
|
||||
- { id: fraud, label: Marlowe discovered the diamond find was salted and was going to the police }
|
||||
- { id: affair, label: A love affair gone wrong }
|
||||
answer: fraud
|
||||
successEnding: |
|
||||
Dunmore lights his pipe with steady hands. Dutch cherry. Samuel was right.
|
||||
|
||||
"Do you know what those shares are worth? Do you know what I'd have lost?"
|
||||
|
||||
He scattered Kimberley stones at Site 7 and "discovered" them himself. When Marlowe found out,
|
||||
Dunmore asked him to the dig at two in the morning "to explain", struck him with a survey pole,
|
||||
and rolled him into the pit. He wore the same boots as the Captain on purpose.
|
||||
When Samuel let slip that he knew, Dunmore drugged his coffee and faked a snakebite.
|
||||
|
||||
**Case closed.** The post truck takes one extra passenger back to Upington. 🏜️🔑
|
||||
failEnding: |
|
||||
The truck leaves with the wrong person in handcuffs. Dunmore's shares hit a record high the next month.
|
||||
reveal: |
|
||||
**Felix Dunmore** lured Marlowe to the dig at **02:00**, **struck him and put him in the pit**,
|
||||
then **drugged and killed Samuel** to silence him, all to hide that his diamond find was **salted**
|
||||
with stones from Kimberley.
|
||||
@@ -0,0 +1,309 @@
|
||||
id: kestrel-heist
|
||||
engine: story
|
||||
category: escape-room
|
||||
title: The Kestrel Bank Job
|
||||
tagline: A crooked banker stole Gran's necklace. Tonight you're taking it back. In and out. No mistakes.
|
||||
estimatedMinutes: 50
|
||||
difficulty: 5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1b1b1e", accent: "#00e5a0", background: "#050506", headingFont: "JetBrains Mono" }
|
||||
rules:
|
||||
hintPenaltySeconds: 240
|
||||
timeLimitMinutes: 55
|
||||
timeLimitLabel: "Until the morning shift"
|
||||
roles:
|
||||
A:
|
||||
name: The Inside Man
|
||||
blurb: You're in the building. You see the doors, panels, lasers and guards. You don't have the plans.
|
||||
B:
|
||||
name: The Wheel
|
||||
blurb: You're in the van outside with the laptop, the blueprints, and everything you've dug up on the banker.
|
||||
intro: |
|
||||
Six months ago, banker **Vincent Kestrel** "held" your gran's emerald necklace as security
|
||||
on a loan, then forged the paperwork and kept it. The police shrugged. The lawyers shrugged.
|
||||
|
||||
So tonight, you're getting it back. **Kestrel Private Bank, 21:58.**
|
||||
One of you is inside in a cleaner's uniform with an earpiece. The other is in the van with a laptop.
|
||||
|
||||
Get to his private box. Get the necklace. Get out before the morning shift.
|
||||
|
||||
☠️ *Nightmare tier. Longer hint penalty. A hard time limit. Trust each other.*
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Staff Door
|
||||
narrative: |
|
||||
The staff entrance has a four-digit keypad. Kestrel uses it himself every morning.
|
||||
The Inside Man dusts the keypad with the cleaner's talc.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-keypad
|
||||
title: Dusted keypad
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
[1] [2] [3] Worn, greasy keys: 0, 2, 6
|
||||
[4] [5] [6] The 0 is the MOST worn.
|
||||
[7] [8] [9] Every other key is clean.
|
||||
[0]
|
||||
```
|
||||
Code length: 4 digits.
|
||||
B:
|
||||
- id: b1-assistant
|
||||
title: Message from Kestrel's ex-assistant
|
||||
type: transcript
|
||||
body: |
|
||||
"Every code he has is a date, **day then month**, two digits each.
|
||||
Always someone in his family. He's not as clever as he thinks."
|
||||
- id: b1-social
|
||||
title: Kestrel's social media, a few posts
|
||||
type: document
|
||||
body: |
|
||||
- *14 Feb:* "Happy Valentine's to the wife. ❤️" *(deleted a week later)*
|
||||
- *20 Jun:* "Happy birthday to my princess Mia! 🎂 Daddy loves you."
|
||||
- *03 Sep:* "Proud dad. Liam made the first XV!"
|
||||
- *11 Nov:* "Happy birthday Noah, my little man!"
|
||||
lock:
|
||||
id: lock-door
|
||||
prompt: Enter the staff door code.
|
||||
inputType: code
|
||||
format: 4 digits (DDMM)
|
||||
answers: ["2006"]
|
||||
hints:
|
||||
- The code uses only the digits 0, 2 and 6, with one of them used twice.
|
||||
- Which family date uses exactly 0, 2 and 6, with 0 twice?
|
||||
- Mia's birthday, 20 June = 2006.
|
||||
successText: |
|
||||
**2006.** Green light. You're in. Mop bucket rolling, cap pulled low.
|
||||
Ahead: the long marble corridor to the vault, criss-crossed with red light.
|
||||
keyName: Staff Door Key
|
||||
|
||||
- id: ch2
|
||||
title: The Laser Hall
|
||||
narrative: |
|
||||
Five columns of floor tiles. Five rows of laser beams across them.
|
||||
Exactly one column of tiles is never crossed by a beam.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-hall
|
||||
title: What the Inside Man sees
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
col1 col2 col3 col4 col5
|
||||
row1 ▢ ▢ ▢ ▢ ▢
|
||||
row2 ▢ ▢ ▢ ▢ ▢
|
||||
row3 ▢ ▢ ▢ ▢ ▢
|
||||
row4 ▢ ▢ ▢ ▢ ▢
|
||||
row5 ▢ ▢ ▢ ▢ ▢
|
||||
```
|
||||
The beams are too faint to see by eye in this light.
|
||||
Sticker on the laser control box: **"MASK BITS: col1 = 16, col2 = 8, col3 = 4, col4 = 2, col5 = 1"**
|
||||
B:
|
||||
- id: b2-config
|
||||
title: Hacked security config (laptop)
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
[LASER_HALL]
|
||||
ROW_MASK_1 = 21
|
||||
ROW_MASK_2 = 12
|
||||
ROW_MASK_3 = 25
|
||||
ROW_MASK_4 = 17
|
||||
ROW_MASK_5 = 8
|
||||
; a set bit means the beam crosses that tile
|
||||
```
|
||||
lock:
|
||||
id: lock-lasers
|
||||
prompt: Which column is safe to walk?
|
||||
inputType: code
|
||||
format: Column number
|
||||
answers: ["4"]
|
||||
hints:
|
||||
- Each row mask is a sum of the column values its beam crosses. Break each number into 16, 8, 4, 2, 1.
|
||||
- "21 = 16 + 4 + 1 → columns 1, 3, 5."
|
||||
- "Rows cross: (1,3,5), (2,3), (1,2,5), (1,5), (2). Column 4 is never crossed."
|
||||
successText: |
|
||||
**Column 4.** Tile by tile, heel to toe. Not a single alarm. Halfway there.
|
||||
keyName: Laser Key
|
||||
|
||||
- id: ch3
|
||||
title: The Guards
|
||||
narrative: |
|
||||
Past the hall, the vault corridor. Three guards walk through it on fixed loops.
|
||||
Crossing it to the vault door takes **4 full minutes** of picking the outer lock.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-watching
|
||||
title: What the Inside Man has clocked from the vent
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
Guard 1 (big guy, whistles): passes at 22:03, then every 7 minutes
|
||||
Guard 2 (torch, limps): passes at 22:05, then every 11 minutes
|
||||
```
|
||||
It's 22:00 now.
|
||||
B:
|
||||
- id: b3-roster
|
||||
title: Security rota (hacked)
|
||||
type: document
|
||||
body: |
|
||||
**Guard 3 (Dlamini):** corridor sweep at **22:02**, then **every 6 minutes**.
|
||||
*A guard occupies the corridor for the whole minute they pass through it.*
|
||||
You need **4 clear minutes in a row** with no guard passing.
|
||||
lock:
|
||||
id: lock-guards
|
||||
prompt: At what time does the first safe 4-minute window start?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["2233", "1033", "1033PM"]
|
||||
hints:
|
||||
- "List the busy minutes: Guard 1 → :03, :10, :17, :24, :31, :38..."
|
||||
- "Guard 2 → :05, :16, :27, :38. Guard 3 → :02, :08, :14, :20, :26, :32, :38."
|
||||
- "Gaps before :33 are at most 3 minutes long. :33, :34, :35, :36 are all clear."
|
||||
successText: |
|
||||
**22:33.** You slip in behind Guard 3, pick the outer lock in three minutes fifty, and close the door
|
||||
a breath before Guard 1 whistles past. You're in the vault anteroom. One huge dial.
|
||||
keyName: Shadow Key
|
||||
|
||||
- id: ch4
|
||||
title: The Vault Dial
|
||||
narrative: |
|
||||
Three-number combination dial. Kestrel left himself a reminder taped under the anteroom desk.
|
||||
It's not in any language you know.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-photo
|
||||
title: Photo on the anteroom desk
|
||||
type: photo
|
||||
body: |
|
||||
*Kestrel with his three sons, a joint birthday party this year. Each boy holds a cake
|
||||
with candles shaped like numerals: Noah's says **VII**, Liam's says **XIX**, Ben's says **XII**.
|
||||
Frame engraved: "My boys."*
|
||||
B:
|
||||
- id: b4-note
|
||||
title: Note taped under the desk (Inside Man reads it out letter by letter)
|
||||
type: note
|
||||
body: |
|
||||
## XLNYRMZGRLM: NB HLMH ZTVH, VOWVHG URIHG
|
||||
|
||||
*Your laptop's notes on Kestrel: "Ancient-cipher nerd. Favourite: the mirror alphabet. A↔Z, B↔Y, C↔X..."*
|
||||
lock:
|
||||
id: lock-dial
|
||||
prompt: Dial the combination.
|
||||
inputType: code
|
||||
format: "NN-NN-NN"
|
||||
answers: ["191207", "19127"]
|
||||
hints:
|
||||
- "Mirror alphabet: A↔Z, B↔Y, C↔X. X → C, L → O, N → M..."
|
||||
- "The note says: COMBINATION: MY SONS AGES, ELDEST FIRST."
|
||||
- "Ages are XIX = 19, XII = 12, VII = 7. Eldest first: 19-12-07."
|
||||
successText: |
|
||||
**19-12-07.** Twelve tons of steel swing open on silent hinges.
|
||||
A wall of safe deposit boxes. Hundreds of them. **Which one is his?**
|
||||
keyName: Vault Key
|
||||
|
||||
- id: ch5
|
||||
title: Box Number
|
||||
narrative: |
|
||||
Kestrel doesn't keep stolen goods under his own name. He uses aliases, all birds of prey.
|
||||
On the anteroom terminal, a sticky note with a database query in his handwriting.
|
||||
clues:
|
||||
A:
|
||||
- id: a5-query
|
||||
title: Sticky note on the terminal
|
||||
type: note
|
||||
body: |
|
||||
```sql
|
||||
SELECT b.box_no
|
||||
FROM boxes b
|
||||
JOIN clients c ON c.client_id = b.client_id
|
||||
WHERE c.alias LIKE 'K%'
|
||||
AND b.size = 'L'
|
||||
AND b.last_opened >= '2026-06-01';
|
||||
```
|
||||
- id: a5-clients
|
||||
title: Terminal screen, CLIENTS table
|
||||
type: table
|
||||
body: |
|
||||
| client_id | alias |
|
||||
|-----------|------------|
|
||||
| 101 | Kestrel |
|
||||
| 102 | Heron |
|
||||
| 103 | Kite |
|
||||
| 104 | Kingfisher |
|
||||
| 105 | Osprey |
|
||||
B:
|
||||
- id: b5-boxes
|
||||
title: Downloaded BOXES table (laptop)
|
||||
type: table
|
||||
body: |
|
||||
| box_no | client_id | size | last_opened |
|
||||
|--------|-----------|------|-------------|
|
||||
| 214 | 101 | M | 2026-07-14 |
|
||||
| 233 | 103 | L | 2026-05-31 |
|
||||
| 309 | 103 | L | 2026-03-02 |
|
||||
| 317 | 104 | L | 2026-08-30 |
|
||||
| 402 | 102 | L | 2026-09-01 |
|
||||
| 411 | 101 | L | 2026-05-29 |
|
||||
| 518 | 105 | L | 2026-09-10 |
|
||||
| 520 | 104 | S | 2026-09-11 |
|
||||
lock:
|
||||
id: lock-box
|
||||
prompt: Which box do you open?
|
||||
inputType: code
|
||||
format: Box number
|
||||
answers: ["317"]
|
||||
hints:
|
||||
- "Aliases starting with K: Kestrel (101), Kite (103), Kingfisher (104)."
|
||||
- "Large boxes for those clients: 233, 309, 317, 411."
|
||||
- Only 317 was opened on or after 1 June 2026.
|
||||
successText: |
|
||||
**Box 317.** Inside, wrapped in a bank cloth: Gran's emerald necklace. And a stack of other
|
||||
"held as security" jewellery with little tags. You photograph every tag for the police.
|
||||
keyName: Emerald Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: Alarms trip as you leave the vault. The getaway meet point is in a coded text from your driver.
|
||||
clues:
|
||||
A:
|
||||
- id: af-text
|
||||
title: Text from the driver (on the Inside Man's burner)
|
||||
type: transcript
|
||||
body: |
|
||||
```
|
||||
MUTLEETSTHODIREAEP
|
||||
```
|
||||
*"Zig-zag it. Rails = floors in that building. You know the rest."*
|
||||
B:
|
||||
- id: bf-blueprint
|
||||
title: Kestrel Private Bank blueprint, cover sheet
|
||||
type: document
|
||||
body: |
|
||||
**Floors:** Ground (banking hall), First (offices), Second (vault and anteroom).
|
||||
*Basement: none. The vault is upstairs, which is why the lift is reinforced.*
|
||||
- id: bf-railfence
|
||||
title: Your notes on zig-zag (rail fence) ciphers
|
||||
type: note
|
||||
body: |
|
||||
Write the message in a zig-zag down and up across N rails, then read each rail left to right.
|
||||
To decode: work out how many letters sit on each rail, split the text, then zig-zag back through.
|
||||
lock:
|
||||
id: lock-getaway
|
||||
prompt: Where's the meet point?
|
||||
inputType: text
|
||||
format: The place (a few words)
|
||||
answers: ["OLDPIER", "THEOLDPIER", "MEETUSATTHEOLDPIER"]
|
||||
hints:
|
||||
- The building has 3 floors, so 3 rails.
|
||||
- "18 letters on 3 rails split 5 / 9 / 4: MUTLE · ETSTHODIR · EAEP."
|
||||
- Zig-zag back through and it reads MEET US AT THE OLD PIER.
|
||||
successEnding: |
|
||||
**The old pier.** You sprint out the loading dock as sirens wake up three blocks away. The van's door
|
||||
is already open. Gran's necklace is in your pocket, and Kestrel's box of stolen tags is on its way to a
|
||||
journalist who's been waiting six months for exactly this.
|
||||
|
||||
**You got out!** 💎🔑
|
||||
failEnding: |
|
||||
The morning shift arrives. You're still inside, holding a mop. (It's a game. Run it back?)
|
||||
@@ -3,8 +3,8 @@ engine: labyrinth
|
||||
category: co-op
|
||||
title: Labyrinth of Lanterns
|
||||
tagline: One of you is lost in the dark. The other holds the only map.
|
||||
estimatedMinutes: 25
|
||||
difficulty: 2
|
||||
estimatedMinutes: 10
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#3d2c6b", accent: "#ffb347", background: "#09070f", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
|
||||
@@ -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!** 🎩🔑
|
||||
@@ -0,0 +1,313 @@
|
||||
id: masquerade-montague
|
||||
engine: story
|
||||
category: murder-mystery
|
||||
title: The Masquerade at Montague House
|
||||
tagline: 1894. Five masks. One swap. A lord who never unmasked.
|
||||
estimatedMinutes: 40
|
||||
difficulty: 4
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#4a1942", accent: "#e0c068", background: "#0f0a12", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
roles:
|
||||
A:
|
||||
name: The Inspector
|
||||
blurb: Scotland Yard, called in at midnight. You have the staff's statements and the gardener's notes.
|
||||
B:
|
||||
name: The Lady's Maid
|
||||
blurb: You've served Montague House for years. You saw the guests arrive, and you hear everything.
|
||||
intro: |
|
||||
**Montague House, Surrey. New Year's Eve, 1894.**
|
||||
|
||||
Lord Julian Montague's masquerade is the event of the season. At midnight, the guests
|
||||
unmask for the toast. Lord Julian doesn't. He's found in the glass conservatory,
|
||||
a jewelled dagger from his own armoury missing from its case and nowhere to be found.
|
||||
|
||||
Five guests. Five masks. **And at least one of them is lying about which mask they wore.**
|
||||
|
||||
⚠️ *Expert case: the locks only give you facts. A wrong accusation costs you dearly.*
|
||||
|
||||
suspects:
|
||||
- id: agatha
|
||||
name: Lady Agatha Stroud
|
||||
role: Dowager
|
||||
description: Julian's aunt. Controls the family purse strings and hates parties.
|
||||
- id: rupert
|
||||
name: Colonel Rupert Blackwood
|
||||
role: Retired soldier
|
||||
description: Owes Julian a fortune in card debts. Loud, red-faced, and very nervous tonight.
|
||||
- id: ivy
|
||||
name: Miss Ivy Marsh
|
||||
role: Young guest
|
||||
description: Nobody quite knows who invited her. Charming, quiet, watches Julian closely.
|
||||
- id: silas
|
||||
name: Mr Silas Penhaligon
|
||||
role: Business partner
|
||||
description: Julian's partner in a shipping company that is quietly going bankrupt.
|
||||
- id: odette
|
||||
name: Mrs Odette Vance
|
||||
role: Widow
|
||||
description: Julian courted her for a year, then dropped her for a duke's daughter. Everyone saw her scene at dinner.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Who Wore What
|
||||
narrative: |
|
||||
Every guest arrived masked and cloaked. The footman logged the masks: **Fox, Raven, Swan, Lion, Stag**.
|
||||
The cloaks were **crimson, green, black, silver, gold**. Nobody wrote down who wore what.
|
||||
You'll have to work it out together.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-butler
|
||||
title: The butler's recollections
|
||||
type: note
|
||||
body: |
|
||||
1. The Colonel wore **green**. "Like a proper huntsman," he kept saying.
|
||||
2. The **Swan** wore **black**. Some joke about black swans.
|
||||
3. Mr Penhaligon did **not** wear a bird mask.
|
||||
4. Whoever wore **gold** had a **bird** mask. (Birds: Raven, Swan.)
|
||||
B:
|
||||
- id: b1-maid
|
||||
title: What you noticed in the cloakroom
|
||||
type: note
|
||||
body: |
|
||||
5. The **Lion** wore **silver**.
|
||||
6. None of the three ladies wore the **Fox** or the **Lion**.
|
||||
7. Miss Marsh did **not** wear gold.
|
||||
8. Lady Agatha calls feathers "vulgar". She would **never** wear a bird mask.
|
||||
shared:
|
||||
- id: s1-guests
|
||||
title: The guest list
|
||||
type: document
|
||||
body: |
|
||||
**Ladies:** Lady Agatha Stroud, Miss Ivy Marsh, Mrs Odette Vance
|
||||
**Gentlemen:** Colonel Rupert Blackwood, Mr Silas Penhaligon
|
||||
Each guest wore exactly one mask and one cloak. No two guests shared either.
|
||||
lock:
|
||||
id: lock-raven
|
||||
prompt: Who arrived wearing the Raven mask?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: agatha, label: Lady Agatha Stroud }
|
||||
- { id: rupert, label: Colonel Rupert Blackwood }
|
||||
- { id: ivy, label: Miss Ivy Marsh }
|
||||
- { id: silas, label: Mr Silas Penhaligon }
|
||||
- { id: odette, label: Mrs Odette Vance }
|
||||
answers: ["odette"]
|
||||
hints:
|
||||
- Start with the men. Clue 6 says the ladies never wore Fox or Lion, so the two men wore Fox and Lion.
|
||||
- The Colonel wore green and the Lion wore silver, so the Colonel is the Fox and Penhaligon is the Lion (silver). Agatha can't wear a bird, so she's the Stag.
|
||||
- Ivy and Odette share Raven and Swan. The Swan wears black; gold goes to a bird; Ivy isn't gold. So Ivy is the black Swan, and Odette is the gold Raven.
|
||||
successText: |
|
||||
**Mrs Odette Vance arrived as the Raven, in gold.**
|
||||
The full picture: Agatha (Stag, crimson) · Rupert (Fox, green) · Ivy (Swan, black) · Silas (Lion, silver) · Odette (Raven, gold).
|
||||
Write that down. You'll need it.
|
||||
keyName: Feather Key
|
||||
|
||||
- id: ch2
|
||||
title: The Raven in the Conservatory
|
||||
narrative: |
|
||||
One witness saw someone go into the conservatory just before midnight.
|
||||
But a masquerade is a very good place to be someone else.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-footman
|
||||
title: Footman's statement
|
||||
type: transcript
|
||||
body: |
|
||||
"Ten to midnight, sir. Someone in the **Raven** mask went into the conservatory. Quick, like.
|
||||
I'd swear to the mask. And the cloak was **black**. I remember thinking it looked like a funeral."
|
||||
- id: a2-odette
|
||||
title: Mrs Vance's statement
|
||||
type: transcript
|
||||
body: |
|
||||
"Yes, I hated him. Half the room heard me say so at dinner. But I was in the ballroom
|
||||
from eleven onward. Ask anyone who danced with the **Swan**." *(She doesn't seem to notice what she said.)*
|
||||
B:
|
||||
- id: b2-cloakroom
|
||||
title: What you saw in the cloakroom at 23:00
|
||||
type: note
|
||||
body: |
|
||||
Two of the ladies came in giggling and **traded masks**, the Raven and the Swan.
|
||||
They **kept their own cloaks**. One of them said, "The Colonel will never guess, it'll be such a lark."
|
||||
- id: b2-colonel
|
||||
title: The Colonel, overheard
|
||||
type: transcript
|
||||
body: |
|
||||
"Julian gave me till Friday to pay. Friday! I'd sooner see him at the bottom of the lake."
|
||||
*(He was in the card room from 23:30 until midnight with Mr Penhaligon. Both swear to it.)*
|
||||
lock:
|
||||
id: lock-seen
|
||||
prompt: Who did the footman really see enter the conservatory?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: agatha, label: Lady Agatha Stroud }
|
||||
- { id: rupert, label: Colonel Rupert Blackwood }
|
||||
- { id: ivy, label: Miss Ivy Marsh }
|
||||
- { id: silas, label: Mr Silas Penhaligon }
|
||||
- { id: odette, label: Mrs Odette Vance }
|
||||
answers: ["ivy"]
|
||||
hints:
|
||||
- The swap was Raven ↔ Swan. Who had those masks at the start, and what colour were their cloaks?
|
||||
- After 23:00 the Raven mask was worn with whichever cloak the Swan had arrived in.
|
||||
- Ivy arrived as the black Swan. After the swap she wore the Raven with her own black cloak.
|
||||
successText: |
|
||||
**Miss Ivy Marsh.** After the swap, Ivy wore the Raven with her black cloak. Odette wore the Swan with gold.
|
||||
Anyone looking for "the Raven" would blame Odette, the jilted widow who'd made a scene at dinner.
|
||||
keyName: Mask Key
|
||||
|
||||
- id: ch3
|
||||
title: The Missing Dagger
|
||||
narrative: |
|
||||
The jewelled dagger isn't on the body or in the house. The police think it's still in the conservatory,
|
||||
buried in one of the sixteen numbered plant pots. They can't dig up every pot before dawn.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-gardener
|
||||
title: The gardener's evening notes
|
||||
type: note
|
||||
body: |
|
||||
*Watered every pot at 10 o'clock tonight, soaked through, **except the prime-numbered pots**.
|
||||
Those are His Lordship's cacti. Water rots them.*
|
||||
B:
|
||||
- id: b3-police
|
||||
title: Constable's conservatory notes
|
||||
type: report
|
||||
body: |
|
||||
- Fresh disturbance in the soil of **pots 4, 9, 11 and 15**.
|
||||
- A pair of **white evening gloves** found burning in the conservatory stove. Unburnt fingertips are **spotless, no mud at all**.
|
||||
- Pots 4, 9 and 15 were re-potted by the under-gardener this afternoon (his note on the potting bench).
|
||||
- id: b3-map
|
||||
title: Sketch of the conservatory pots
|
||||
type: map
|
||||
body: |
|
||||
```
|
||||
[ 1] [ 2] [ 3] [ 4]
|
||||
[ 5] [ 6] [ 7] [ 8]
|
||||
[ 9] [10] [11] [12]
|
||||
[13] [14] [15] [16]
|
||||
```
|
||||
lock:
|
||||
id: lock-pot
|
||||
prompt: Which pot is the dagger buried in?
|
||||
inputType: code
|
||||
format: Pot number
|
||||
answers: ["11"]
|
||||
hints:
|
||||
- At midnight, which pots would be muddy? Which would be dry?
|
||||
- Clean gloves means the killer dug in dry soil. Dry soil means a prime-numbered pot.
|
||||
- Of the disturbed pots, only 11 is prime, and the others have an innocent explanation anyway.
|
||||
successText: |
|
||||
**Pot 11.** Under the cactus: the jewelled dagger. Wrapped around the hilt is a strip of black silk
|
||||
torn from a cloak.
|
||||
keyName: Dagger Key
|
||||
|
||||
- id: ch4
|
||||
title: Julian's Confession
|
||||
narrative: |
|
||||
In Lord Julian's desk, a sealed envelope addressed *"To be opened after my death."*
|
||||
Inside: rows of numbers, and nothing else. Julian was fond of the old soldiers' square cipher.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-numbers
|
||||
title: The letter inside the envelope
|
||||
type: letter
|
||||
body: |
|
||||
```
|
||||
44 23 13 25 31
|
||||
52 23 25
|
||||
42 21 21 12 35 22 21 14
|
||||
```
|
||||
*Below, in Julian's hand: "God forgive me. I built my square on our motto, as Father taught me."*
|
||||
- id: a4-howto
|
||||
title: Police cipher handbook, page 12
|
||||
type: document
|
||||
body: |
|
||||
**The square cipher (Polybius).** Write a keyword into a 5×5 square, row by row, skipping
|
||||
repeated letters, then fill the rest with the unused alphabet (I and J share a square).
|
||||
Each letter is written as **row then column**, e.g. 23 = row 2, column 3.
|
||||
B:
|
||||
- id: b4-crest
|
||||
title: The Montague crest above the fireplace
|
||||
type: photo
|
||||
body: |
|
||||
*A carved stone shield with a lion and a ship. Beneath it, the family motto:*
|
||||
**FORTUNE FAVOURS THE BOLD**
|
||||
- id: b4-marsh
|
||||
title: Servants' gossip about the Marsh family
|
||||
type: note
|
||||
body: |
|
||||
Twenty years ago, Mr Edward Marsh owned this house. Lord Julian accused him of cheating at cards.
|
||||
Marsh lost everything, the house went to Julian, and Marsh died in debtors' prison.
|
||||
*He had a little daughter. Nobody knows what became of her.*
|
||||
lock:
|
||||
id: lock-confession
|
||||
prompt: What did Julian confess?
|
||||
inputType: text
|
||||
format: Three words
|
||||
answers: ["MARSHWASINNOCENT"]
|
||||
hints:
|
||||
- Build the square from FORTUNE FAVOURS THE BOLD, skipping repeats.
|
||||
- "Row 1: F O R T U. Row 2: N E A V S. Row 3: H B L D C. Row 4: G I K M P. Row 5: Q W X Y Z."
|
||||
- 44 = row 4, column 4 = M. The message starts MARSH.
|
||||
successText: |
|
||||
**"MARSH WAS INNOCENT."** Julian framed Edward Marsh and took his house.
|
||||
And Edward Marsh had a daughter. **Ivy** Marsh.
|
||||
keyName: Crest Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: The clocks strike one. Make your accusation, all four parts.
|
||||
maxAttempts: 1
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Lord Julian?
|
||||
options:
|
||||
- { id: agatha, label: Lady Agatha Stroud }
|
||||
- { id: rupert, label: Colonel Rupert Blackwood }
|
||||
- { id: ivy, label: Miss Ivy Marsh }
|
||||
- { id: silas, label: Mr Silas Penhaligon }
|
||||
- { id: odette, label: Mrs Odette Vance }
|
||||
answer: ivy
|
||||
- id: mask
|
||||
label: Which mask was the killer wearing in the conservatory?
|
||||
options:
|
||||
- { id: fox, label: Fox }
|
||||
- { id: raven, label: Raven }
|
||||
- { id: swan, label: Swan }
|
||||
- { id: lion, label: Lion }
|
||||
- { id: stag, label: Stag }
|
||||
answer: raven
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: dagger, label: "Stabbed with the armoury dagger, then buried it in a dry cactus pot" }
|
||||
- { id: poison, label: Poison in his champagne }
|
||||
- { id: lake, label: Drowned in the conservatory pond }
|
||||
- { id: pistol, label: Shot with a silenced pistol }
|
||||
answer: dagger
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: jilted, label: He jilted her for a duke's daughter }
|
||||
- { id: debts, label: To escape gambling debts }
|
||||
- { id: father, label: He framed her father and stole the family home }
|
||||
- { id: business, label: To save a failing shipping company }
|
||||
answer: father
|
||||
successEnding: |
|
||||
Ivy takes off the Raven mask and sets it on the table, very gently.
|
||||
|
||||
"He took our house. He took my father. Twenty years I've practised being a stranger in it."
|
||||
|
||||
She suggested the mask swap as "a lark" so that Odette, the famous jilted widow, would be seen
|
||||
as the Raven. She followed Julian to the conservatory at ten to midnight, used the dagger
|
||||
she'd lifted during the Grand March, and buried it in the one pot she knew would be dry.
|
||||
She forgot one thing: she swapped masks, but not cloaks.
|
||||
|
||||
**Case closed.** 🎭🔑
|
||||
failEnding: |
|
||||
The Inspector makes an arrest. The real killer curtsies on her way out the door, and is never seen again.
|
||||
reveal: |
|
||||
**Miss Ivy Marsh**, wearing **Odette's Raven mask** after the swap, stabbed Julian with the
|
||||
**armoury dagger** and buried it in **cactus pot 11**, because Julian had **framed her father
|
||||
and stolen Montague House**.
|
||||
@@ -0,0 +1,94 @@
|
||||
id: mirror-maze
|
||||
engine: labyrinth
|
||||
category: co-op
|
||||
title: The Mirror Maze
|
||||
tagline: The map is backwards. Your partner can't tell north from left. Seven traps. Good luck.
|
||||
estimatedMinutes: 30
|
||||
difficulty: 5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#5f0f40", accent: "#c0fdff", background: "#07040a", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Navigator
|
||||
blurb: You hold the only map of the maze. It was copied from a mirror, so it's flipped left to right.
|
||||
B:
|
||||
name: Explorer
|
||||
blurb: You're inside a hall of mirrors. You have no compass, only forward, back, left and right.
|
||||
intro: |
|
||||
An abandoned carnival on the edge of town. Its famous **Mirror Maze** has four **glass lanterns** hidden inside.
|
||||
Legend says couples who carry all four out together never lose each other.
|
||||
|
||||
☠️ **Nightmare rules:**
|
||||
- **Explorer:** you have no compass. Your controls are **Forward / Left / Right / Back**, relative to the way you're facing.
|
||||
Whenever you move, you turn to face that way.
|
||||
- **Navigator:** the only surviving map was **traced from a mirror**. Everything on it is flipped **left to right**.
|
||||
- Some carvings appear **twice** in the maze. Don't trust a single glyph.
|
||||
- **Seven** hidden traps. Four lanterns needed.
|
||||
|
||||
# Grid as it REALLY is (server truth). The Navigator is served the mirror image (see navigatorView).
|
||||
grid:
|
||||
- "###############"
|
||||
- "#S..#.........#"
|
||||
- "###.#.#.#####.#"
|
||||
- "#K#.....#...#.#"
|
||||
- "#.####.##.#.#.#"
|
||||
- "#..T.....T....#"
|
||||
- "#.###T#.###..T#"
|
||||
- "#...#T#.#.....#"
|
||||
- "#.#.#.#.#.###.#"
|
||||
- "#..K#.#.....#K#"
|
||||
- "#.#.#.#.###.#T#"
|
||||
- "#.#.........#.#"
|
||||
- "#.#.#.#.###.#.#"
|
||||
- "#KT...#......E#"
|
||||
- "###############"
|
||||
|
||||
lanternsRequired: 4
|
||||
|
||||
explorer:
|
||||
compass: relative # controls are forward/left/right/back instead of N/E/S/W
|
||||
startFacing: e # the only open way out of S
|
||||
# Server converts relative → absolute using the Explorer's current facing, then sets facing = move direction.
|
||||
# Explorer's view lists open directions as relative ("Open: forward, right") and never shows N/E/S/W.
|
||||
|
||||
glyphs:
|
||||
- { x: 1, y: 1, glyph: ticket, label: "🎟 A torn carnival ticket (the entrance)" }
|
||||
- { x: 3, y: 1, glyph: crescent, label: "☾ A crescent moon etched in the glass" }
|
||||
- { x: 11, y: 11, glyph: crescent, label: "☾ A crescent moon etched in the glass" }
|
||||
- { x: 9, y: 3, glyph: star, label: "✦ A four-pointed star" }
|
||||
- { x: 7, y: 11, glyph: star, label: "✦ A four-pointed star" }
|
||||
- { x: 13, y: 1, glyph: shard, label: "🪞 A shattered mirror panel" }
|
||||
- { x: 5, y: 5, glyph: bell, label: "🔔 A tiny brass bell hanging from the ceiling" }
|
||||
- { x: 11, y: 7, glyph: butterfly, label: "🦋 A painted butterfly" }
|
||||
- { x: 1, y: 7, glyph: feather, label: "🪶 A feather on the floor" }
|
||||
- { x: 9, y: 9, glyph: rose, label: "🌹 A paper rose" }
|
||||
- { x: 5, y: 13, glyph: heart, label: "♥ Two hearts scratched into the glass" }
|
||||
|
||||
lanterns:
|
||||
- { x: 1, y: 3, name: Lantern of Reflection }
|
||||
- { x: 1, y: 13, name: Lantern of Patience }
|
||||
- { x: 3, y: 9, name: Lantern of Trust }
|
||||
- { x: 13, y: 9, name: Lantern of Home }
|
||||
|
||||
navigatorView:
|
||||
mapTransform: mirror-x # serve each row reversed; glyph/lantern/trap x becomes (width - 1 - x)
|
||||
showMoveCount: true
|
||||
banner: "This map was traced from a mirror. Left is right. Right is left."
|
||||
|
||||
explorerView:
|
||||
flavour:
|
||||
blank: "Your own reflection stares back from every side."
|
||||
trap: "The mirror floor cracks and you drop through... and wake up at the entrance, facing the way in."
|
||||
lantern: "A glass lantern glows to life in your hands. ({name})"
|
||||
exitLocked: "A mirrored door with four lantern hooks. It won't budge."
|
||||
exitOpen: "You hang four lanterns. Every mirror in the maze lights up at once, and the door opens onto the fairground."
|
||||
|
||||
scoring:
|
||||
trapPenaltySeconds: 90
|
||||
parMoves: 110
|
||||
|
||||
successEnding: |
|
||||
The Explorer stumbles out into the empty fairground. The Navigator is waiting by the ferris wheel,
|
||||
holding the backwards map upside down, grinning.
|
||||
|
||||
**You never lost each other.** 🪞🔑
|
||||
@@ -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**.
|
||||
@@ -3,8 +3,8 @@ engine: story
|
||||
category: murder-mystery
|
||||
title: Murder on the Southern Star
|
||||
tagline: An overnight train through the Karoo. A diamond king. A long, cold night.
|
||||
estimatedMinutes: 70
|
||||
difficulty: 3
|
||||
estimatedMinutes: 25
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2e4a3a", accent: "#c9a227", background: "#0d1411", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
|
||||
@@ -3,7 +3,7 @@ engine: story
|
||||
category: escape-room
|
||||
title: "Signal Lost: Station Echo"
|
||||
tagline: An Antarctic research station. The power's out. The air is running thin.
|
||||
estimatedMinutes: 45
|
||||
estimatedMinutes: 15
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2b6f8a", accent: "#7fe3ff", background: "#060d12", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -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,303 @@
|
||||
id: tick-tock-overtime
|
||||
engine: defuse
|
||||
category: co-op
|
||||
title: "Tick Tock: Overtime"
|
||||
tagline: Six modules. Four levels. A manual that fights back.
|
||||
estimatedMinutes: 20
|
||||
difficulty: 4
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#ff3b30", accent: "#00d1ff", background: "#0a0a0a", headingFont: "JetBrains Mono" }
|
||||
roles:
|
||||
A:
|
||||
name: Technician
|
||||
blurb: You see the device. You can't see the manual. Describe everything, exactly.
|
||||
B:
|
||||
name: Expert
|
||||
blurb: You have the manual. You can't see the device. Ask the right questions, in the right order.
|
||||
intro: |
|
||||
Remember the training device? This is the sequel. Someone upgraded it.
|
||||
New modules, new rules, indicator lights, and a manual written by someone who clearly hates couples.
|
||||
|
||||
**Technician:** start by reading out the serial number, battery count, and every indicator (lit and unlit).
|
||||
**Expert:** you'll need all of it.
|
||||
|
||||
# ---------- Device definition ----------
|
||||
ruleset: overtime # which manual the server's solver implements (server/src/engines/defuse.ts)
|
||||
device:
|
||||
serial:
|
||||
length: 6
|
||||
alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # last char is ALWAYS a digit
|
||||
batteries: { min: 0, max: 4 }
|
||||
indicators:
|
||||
labels: [LUV, HUG, BAE, XOX, DTE]
|
||||
count: { min: 1, max: 3 } # each shown indicator is randomly lit or unlit
|
||||
|
||||
levels:
|
||||
- id: lvl1
|
||||
title: Warm-up (that isn't)
|
||||
timeSeconds: 240
|
||||
modules: [wires, button, keypad]
|
||||
keyName: Spark Key
|
||||
- id: lvl2
|
||||
title: Words & Waves
|
||||
timeSeconds: 270
|
||||
modules: [wires, passwords, morse]
|
||||
keyName: Frequency Key
|
||||
- id: lvl3
|
||||
title: Say It Back
|
||||
timeSeconds: 300
|
||||
modules: [wires, button, passwords, simon]
|
||||
keyName: Echo Key
|
||||
- id: lvl4
|
||||
title: Overtime
|
||||
timeSeconds: 360
|
||||
strikesOverride: 2 # only 2 strikes on this level
|
||||
modules: [wires, button, keypad, passwords, morse, simon]
|
||||
keyName: Overtime Key
|
||||
|
||||
strikes: { max: 3, speedUpPerStrike: 0.25 }
|
||||
|
||||
# ---------- Module generation ----------
|
||||
modules:
|
||||
wires:
|
||||
count: { min: 3, max: 6 }
|
||||
colors: [red, blue, yellow, green, white, black]
|
||||
# Technician: wires top to bottom, colour swatch + colour NAME label. Action { type: "cut", index }
|
||||
button:
|
||||
labels: [HOLD, PRESS, ABORT, DETONATE, LOVE]
|
||||
colors: [red, blue, white, yellow]
|
||||
stripColors: [blue, white, yellow, red]
|
||||
# Strip only shown WHILE holding. Actions: tap | hold → release { timerText }
|
||||
keypad:
|
||||
symbols:
|
||||
heart: "♥ Heart"
|
||||
star: "★ Star"
|
||||
moon: "☾ Moon"
|
||||
sun: "☀ Sun"
|
||||
club: "♣ Club"
|
||||
flower: "✿ Flower"
|
||||
key: "⚷ Key"
|
||||
umbrella: "☂ Umbrella"
|
||||
plane: "✈ Plane"
|
||||
music: "♫ Music note"
|
||||
anchor: "⚓ Anchor"
|
||||
crown: "♛ Crown"
|
||||
diamond: "♦ Diamond"
|
||||
spade: "♠ Spade"
|
||||
bolt: "⚡ Bolt"
|
||||
cloud: "☁ Cloud"
|
||||
columns:
|
||||
- [heart, star, moon, sun, club, flower, key]
|
||||
- [umbrella, heart, plane, music, star, anchor, crown]
|
||||
- [diamond, sun, spade, umbrella, flower, music, bolt]
|
||||
- [anchor, spade, club, plane, moon, diamond, key]
|
||||
- [crown, bolt, heart, diamond, plane, sun, cloud]
|
||||
- [cloud, music, key, spade, club, crown, star]
|
||||
# Generator: pick a column, pick 4 of its symbols, shuffle. Re-roll if any OTHER column also
|
||||
# contains all 4 (validated: no 4-subset of these columns is ambiguous).
|
||||
passwords:
|
||||
columnsCount: 5
|
||||
lettersPerColumn: 6
|
||||
words: [ADORE, ANGEL, BLISS, BLUSH, CANDY, CHARM, CRUSH, DANCE, DREAM, FLAME, FLIRT, HEART,
|
||||
HONEY, LOVER, MATCH, PEACH, RINGS, ROSES, SMILE, SPARK, SUGAR, SWEET, TEDDY, TRUST,
|
||||
TULIP, UNITY, WALTZ, BLOOM, CHOSE, PEARL, VOWED, QUEEN]
|
||||
# Technician sees 5 columns with up/down arrows; each column cycles through 6 letters.
|
||||
# Generator: pick a target word; fill each column with its letter + 5 random letters; re-roll until
|
||||
# EXACTLY ONE word in the list can be formed. Action { type: "submit", word }
|
||||
morse:
|
||||
# Technician sees a blinking light (reuse the signal renderer, §6.3) and a frequency dial
|
||||
# from 3.500 to 3.600 MHz in 0.001 steps + a TX button. Action { type: "transmit", frequency }
|
||||
words:
|
||||
HUG: "3.505"
|
||||
KISS: "3.515"
|
||||
LOVE: "3.522"
|
||||
DATE: "3.532"
|
||||
RING: "3.535"
|
||||
ROSE: "3.542"
|
||||
WINE: "3.545"
|
||||
DUET: "3.552"
|
||||
DANCE: "3.555"
|
||||
HEART: "3.565"
|
||||
SWEET: "3.572"
|
||||
HONEY: "3.575"
|
||||
CUDDLE: "3.582"
|
||||
SPARK: "3.592"
|
||||
VOWS: "3.595"
|
||||
BLISS: "3.600"
|
||||
simon:
|
||||
colors: [red, blue, green, yellow]
|
||||
stages: { min: 3, max: 5 }
|
||||
# Device flashes a sequence; each stage adds one flash. Technician presses the MAPPED colours
|
||||
# for the whole sequence so far. Mapping depends on (serial has a vowel) and CURRENT strike count.
|
||||
# Action { type: "press", color }
|
||||
|
||||
# ---------- The manual (shown ONLY to the Expert) ----------
|
||||
manual: |
|
||||
# Defusal Manual: Overtime Edition
|
||||
|
||||
**Before anything else, ask for:** serial number · battery count · every indicator and whether it's **lit**.
|
||||
A "vowel" means A, E, I, O or U.
|
||||
|
||||
---
|
||||
|
||||
## Module: Wires (3 to 6 wires)
|
||||
Wires are numbered **top to bottom** from 1. Cut exactly one. Find the section for the number of wires,
|
||||
then use the **first** rule that applies.
|
||||
|
||||
**3 wires**
|
||||
1. If all three wires are **different colours** → cut the **second** wire.
|
||||
2. If there are **two or more blue** → cut the **first blue** wire.
|
||||
3. If the serial contains a **vowel** → cut the **last** wire.
|
||||
4. Otherwise → cut the **first** wire.
|
||||
|
||||
**4 wires**
|
||||
1. If there is **exactly one green** wire and **2 or more batteries** → cut the **green** wire.
|
||||
2. If the **first and last** wires are the **same colour** → cut the **third** wire.
|
||||
3. If there are **no white** wires → cut the **second** wire.
|
||||
4. Otherwise → cut the **last white** wire.
|
||||
|
||||
**5 wires**
|
||||
1. If there are **more black than red** wires → cut the **first black** wire.
|
||||
2. If the serial's **last digit is odd** → cut the **fourth** wire.
|
||||
3. If there is **exactly one yellow** → cut the wire **directly after** the yellow (if yellow is last, cut the **first**).
|
||||
4. Otherwise → cut the **second** wire.
|
||||
|
||||
**6 wires**
|
||||
1. If there's a **lit LUV** indicator → cut the **sixth** wire.
|
||||
2. If there are **exactly three red** wires → cut the **third red** wire.
|
||||
3. If there are **no yellow** wires → cut the **third** wire.
|
||||
4. Otherwise → cut the **fifth** wire.
|
||||
|
||||
---
|
||||
|
||||
## Module: The Button
|
||||
First rule that applies:
|
||||
1. **Blue** button that says **ABORT** → **hold**.
|
||||
2. **More than 1 battery** and says **DETONATE** → **tap**.
|
||||
3. **White** button and a **lit BAE** → **hold**.
|
||||
4. **More than 2 batteries** and a **lit HUG** → **tap**.
|
||||
5. **Yellow** button → **hold**.
|
||||
6. **Red** button that says **HOLD** → **tap**.
|
||||
7. Otherwise → **hold**.
|
||||
|
||||
**If holding**, a strip lights up. Release when the timer shows this digit **anywhere**:
|
||||
Blue → **4** · White → **1** · Yellow → **5** · Red → **3**
|
||||
|
||||
---
|
||||
|
||||
## Module: Keypad
|
||||
Exactly one column below contains all four symbols on the device.
|
||||
Press them in the order they appear **top to bottom** in that column.
|
||||
|
||||
| 1 | 2 | 3 | 4 | 5 | 6 |
|
||||
|---|---|---|---|---|---|
|
||||
| ♥ Heart | ☂ Umbrella | ♦ Diamond | ⚓ Anchor | ♛ Crown | ☁ Cloud |
|
||||
| ★ Star | ♥ Heart | ☀ Sun | ♠ Spade | ⚡ Bolt | ♫ Music note |
|
||||
| ☾ Moon | ✈ Plane | ♠ Spade | ♣ Club | ♥ Heart | ⚷ Key |
|
||||
| ☀ Sun | ♫ Music note | ☂ Umbrella | ✈ Plane | ♦ Diamond | ♠ Spade |
|
||||
| ♣ Club | ★ Star | ✿ Flower | ☾ Moon | ✈ Plane | ♣ Club |
|
||||
| ✿ Flower | ⚓ Anchor | ♫ Music note | ♦ Diamond | ☀ Sun | ♛ Crown |
|
||||
| ⚷ Key | ♛ Crown | ⚡ Bolt | ⚷ Key | ☁ Cloud | ★ Star |
|
||||
|
||||
---
|
||||
|
||||
## Module: Passwords
|
||||
The Technician can cycle five letter columns. Exactly **one** word below can be spelled.
|
||||
Find it and have them submit it.
|
||||
|
||||
ADORE · ANGEL · BLISS · BLOOM · BLUSH · CANDY · CHARM · CHOSE · CRUSH · DANCE · DREAM
|
||||
FLAME · FLIRT · HEART · HONEY · LOVER · MATCH · PEACH · PEARL · QUEEN · RINGS · ROSES
|
||||
SMILE · SPARK · SUGAR · SWEET · TEDDY · TRUST · TULIP · UNITY · VOWED · WALTZ
|
||||
|
||||
*Tip: ask for the letters in column 1 first, then 2. That usually narrows it down fast.*
|
||||
|
||||
---
|
||||
|
||||
## Module: Morse Light
|
||||
The light blinks a word in Morse, over and over, with a long pause between repeats.
|
||||
Short = dot · Long = dash. Decode the word, then have them tune and transmit:
|
||||
|
||||
| Word | MHz | Word | MHz |
|
||||
|------|-----|------|-----|
|
||||
| HUG | 3.505 | DUET | 3.552 |
|
||||
| KISS | 3.515 | DANCE | 3.555 |
|
||||
| LOVE | 3.522 | HEART | 3.565 |
|
||||
| DATE | 3.532 | SWEET | 3.572 |
|
||||
| RING | 3.535 | HONEY | 3.575 |
|
||||
| ROSE | 3.542 | CUDDLE | 3.582 |
|
||||
| WINE | 3.545 | SPARK | 3.592 |
|
||||
| BLISS | 3.600 | VOWS | 3.595 |
|
||||
|
||||
`A .- B -... C -.-. D -.. E . F ..-. G --. H .... I .. J .--- K -.- L .-.. M --`
|
||||
`N -. O --- P .--. Q --.- R .-. S ... T - U ..- V ...- W .-- X -..- Y -.-- Z --..`
|
||||
|
||||
---
|
||||
|
||||
## Module: Say It Back (Simon)
|
||||
The device flashes colours. The Technician must press the **mapped** colour for every flash so far.
|
||||
⚠️ The mapping **changes with the strike count**. Re-check after every strike.
|
||||
|
||||
**Serial HAS a vowel**
|
||||
| Strikes | Red → | Blue → | Green → | Yellow → |
|
||||
|---------|-------|--------|---------|----------|
|
||||
| 0 | Blue | Red | Yellow | Green |
|
||||
| 1 | Yellow | Green | Blue | Red |
|
||||
| 2 | Green | Red | Yellow | Blue |
|
||||
|
||||
**Serial has NO vowel**
|
||||
| Strikes | Red → | Blue → | Green → | Yellow → |
|
||||
|---------|-------|--------|---------|----------|
|
||||
| 0 | Blue | Yellow | Green | Red |
|
||||
| 1 | Red | Blue | Yellow | Green |
|
||||
| 2 | Yellow | Green | Blue | Red |
|
||||
|
||||
# ---------- Solver fixtures (MUST pass as unit tests) ----------
|
||||
# Generated from the reference solver (GAMES-NEW.md, Appendix A), which implements the manual
|
||||
# above exactly. Its TypeScript port is the `overtime` ruleset in server/src/engines/defuse.ts.
|
||||
fixtures:
|
||||
wires:
|
||||
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
|
||||
- {"serial": "QX7M35", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "blue"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "ZP2R44", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "white"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "BC2D48", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "green"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "AB9C11", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["green", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "LM3N06", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "yellow", "blue"], "expect": {"type": "cut", "index": 3}}
|
||||
- {"serial": "RT5W22", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "yellow", "red", "black"], "expect": {"type": "cut", "index": 2}}
|
||||
- {"serial": "HK8P71", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "white"], "expect": {"type": "cut", "index": 4}}
|
||||
- {"serial": "XY3Z45", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["black", "black", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "PQ7R18", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}}
|
||||
- {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}}
|
||||
- {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "white", "white", "yellow"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "ST4V20", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "green", "black"], "expect": {"type": "cut", "index": 2}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": ["LUV"], "unlit": []}, "wires": ["red", "red", "red", "blue", "white", "green"], "expect": {"type": "cut", "index": 6}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": ["LUV"]}, "wires": ["red", "blue", "red", "white", "red", "green"], "expect": {"type": "cut", "index": 5}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 3}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["yellow", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 5}}
|
||||
button:
|
||||
- {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "ABORT", "color": "blue", "strip": "yellow", "expect": {"type": "hold", "releaseDigit": 5}}
|
||||
- {"batteries": 2, "indicators": {"lit": [], "unlit": []}, "label": "DETONATE", "color": "red", "strip": "blue", "expect": {"type": "tap"}}
|
||||
- {"batteries": 1, "indicators": {"lit": ["BAE"], "unlit": []}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}}
|
||||
- {"batteries": 3, "indicators": {"lit": ["HUG"], "unlit": []}, "label": "PRESS", "color": "red", "strip": "white", "expect": {"type": "tap"}}
|
||||
- {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "PRESS", "color": "yellow", "strip": "blue", "expect": {"type": "hold", "releaseDigit": 4}}
|
||||
- {"batteries": 1, "indicators": {"lit": [], "unlit": []}, "label": "HOLD", "color": "red", "strip": "white", "expect": {"type": "tap"}}
|
||||
- {"batteries": 1, "indicators": {"lit": [], "unlit": ["HUG"]}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}}
|
||||
keypad:
|
||||
- {"symbols": ["star", "flower", "heart", "sun"], "expect": ["heart", "star", "sun", "flower"]}
|
||||
- {"symbols": ["spade", "plane", "anchor", "moon"], "expect": ["anchor", "spade", "plane", "moon"]}
|
||||
- {"symbols": ["music", "umbrella", "diamond", "sun"], "expect": ["diamond", "sun", "umbrella", "music"]}
|
||||
- {"symbols": ["cloud", "crown", "bolt", "plane"], "expect": ["crown", "bolt", "plane", "cloud"]}
|
||||
- {"symbols": ["star", "key", "club", "crown"], "expect": ["key", "club", "crown", "star"]}
|
||||
passwords:
|
||||
- {"columns": ["XAPUDI", "RVCSEX", "TGAHRQ", "RASIYZ", "CDGKYL"], "expect": "PEARL"}
|
||||
- {"columns": ["BZRWXO", "GLTUAS", "YHCDLJ", "FVTGIL", "OFZIUM"], "expect": "WALTZ"}
|
||||
- {"columns": ["KYBZTH", "UPOMRG", "SLXUMN", "SEFZUD", "ROTIAQ"], "expect": "TRUST"}
|
||||
morse:
|
||||
- {"signal": "..../..-/--.", "expect": {"word": "HUG", "frequency": "3.505"}}
|
||||
- {"signal": "...././.-/.-./-", "expect": {"word": "HEART", "frequency": "3.565"}}
|
||||
- {"signal": "-.../.-../../.../...", "expect": {"word": "BLISS", "frequency": "3.600"}}
|
||||
- {"signal": "...-/---/.--/...", "expect": {"word": "VOWS", "frequency": "3.595"}}
|
||||
simon:
|
||||
- {"serial": "TK4E82", "strikes": 0, "flashes": ["red", "green", "yellow"], "expect": ["blue", "yellow", "green"]}
|
||||
- {"serial": "TK4E82", "strikes": 1, "flashes": ["red", "green", "yellow"], "expect": ["yellow", "blue", "red"]}
|
||||
- {"serial": "ZP2R44", "strikes": 0, "flashes": ["blue", "blue", "green", "yellow"], "expect": ["yellow", "yellow", "green", "red"]}
|
||||
- {"serial": "ZP2R44", "strikes": 2, "flashes": ["yellow", "red", "green"], "expect": ["red", "yellow", "blue"]}
|
||||
@@ -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. 💣🔑
|
||||
@@ -3,8 +3,8 @@ engine: defuse
|
||||
category: co-op
|
||||
title: Tick Tock Together
|
||||
tagline: One of you sees the bomb. One of you has the manual. Talk fast.
|
||||
estimatedMinutes: 25
|
||||
difficulty: 2
|
||||
estimatedMinutes: 10
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#b3261e", accent: "#ffcc00", background: "#0e0e0e", headingFont: "JetBrains Mono" }
|
||||
roles:
|
||||
|
||||
@@ -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.** 🪞🔑
|
||||
@@ -3,7 +3,7 @@ engine: story
|
||||
category: murder-mystery
|
||||
title: Death at Vineyard Hollow
|
||||
tagline: A harvest gala. A priceless vintage. A host who never made it to dessert.
|
||||
estimatedMinutes: 60
|
||||
estimatedMinutes: 20
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#7b1e3a", accent: "#d4a373", background: "#160c10", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -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.0.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "two-keys",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.0.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.0.1",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"express": "^4.21.0",
|
||||
@@ -7332,7 +7332,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "two-keys",
|
||||
"private": true,
|
||||
"version": "1.0.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.0.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)
|
||||
|
||||
+234
-23
@@ -1,28 +1,65 @@
|
||||
// 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,
|
||||
GameSummary,
|
||||
LabyrinthView,
|
||||
LockView,
|
||||
MoveDir,
|
||||
Role,
|
||||
StoryView,
|
||||
SyncView,
|
||||
TwinView,
|
||||
} from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { remainingAt, type DefuseState } from '../engines/defuse';
|
||||
import { totalKeys } from '../engines/index';
|
||||
import { DELTAS, cellAt, isWall, type LabyrinthState } from '../engines/labyrinth';
|
||||
import { NUDGE_AFTER_WRONG, type StoryState } from '../engines/story';
|
||||
import { canReplay, type SyncState } from '../engines/sync';
|
||||
import { 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,
|
||||
REL_DIRS,
|
||||
cellAt,
|
||||
facingOf,
|
||||
gateBlocks,
|
||||
isWall,
|
||||
toAbsolute,
|
||||
type LabyrinthState,
|
||||
} from '../engines/labyrinth';
|
||||
import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story';
|
||||
import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync';
|
||||
import {
|
||||
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 {
|
||||
@@ -36,6 +73,7 @@ export function gameSummary(game: Game): GameSummary {
|
||||
theme: { ...game.theme },
|
||||
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
|
||||
totalKeys: totalKeys(game),
|
||||
timeLimitMinutes: timeLimitSeconds(game) === null ? null : timeLimitSeconds(game)! / 60,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +89,7 @@ function clueView(clue: Clue, audience: ClueView['audience']): ClueView {
|
||||
return view;
|
||||
}
|
||||
|
||||
function lockView(lock: Lock, s: StoryState): LockView {
|
||||
function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView {
|
||||
const solved = s.solved.includes(lock.id);
|
||||
const wrongAttempts = s.wrong[lock.id] ?? 0;
|
||||
return {
|
||||
@@ -63,6 +101,8 @@ function lockView(lock: Lock, s: StoryState): LockView {
|
||||
solved,
|
||||
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
|
||||
hintsTotal: lock.hints.length,
|
||||
hintPenaltySeconds: hintPenalty(game),
|
||||
timeLimited: timeLimitSeconds(game) !== null,
|
||||
wrongAttempts,
|
||||
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
|
||||
successText: solved ? (lock.successText ?? null) : null,
|
||||
@@ -81,7 +121,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
|
||||
...ch.clues[role].map((c) => clueView(c, 'private')),
|
||||
...ch.clues.shared.map((c) => clueView(c, 'shared')),
|
||||
],
|
||||
lock: lockView(ch.lock, s),
|
||||
lock: lockView(game, ch.lock, s),
|
||||
}));
|
||||
|
||||
let finale: StoryView['finale'] = null;
|
||||
@@ -108,7 +148,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
|
||||
type: 'escape',
|
||||
prompt: f.prompt,
|
||||
clues: f.clues[role].map((c) => clueView(c, 'private')),
|
||||
lock: lockView(f.lock, s),
|
||||
lock: lockView(game, f.lock, s),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,33 +160,46 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
|
||||
totalChapters: total,
|
||||
awaitingContinue: s.awaitingContinue,
|
||||
finale,
|
||||
pins: [...(s.pins?.[role] ?? [])],
|
||||
// Only a count: which clues the partner starred would hint at what they hold.
|
||||
partnerPins: s.pins?.[otherRole(role)]?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- defuse ----------
|
||||
|
||||
/** The Morse dial runs from 3.500 to 3.600 MHz. */
|
||||
const MORSE_BAND = { minKhz: 3500, maxKhz: 3600 };
|
||||
|
||||
export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: number): DefuseView {
|
||||
const level = game.levels[s.level];
|
||||
const 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 },
|
||||
status: s.status,
|
||||
failReason: s.failReason,
|
||||
strikes: s.strikes,
|
||||
maxStrikes: game.strikes.max,
|
||||
maxStrikes: maxStrikes(game, s.level),
|
||||
clock: s.clock
|
||||
? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' }
|
||||
: null,
|
||||
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',
|
||||
@@ -170,14 +223,39 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu
|
||||
})),
|
||||
});
|
||||
}
|
||||
return { ...common, seat: 'technician', device: { serial: d.serial, batteries: d.batteries, modules } };
|
||||
if (d.passwords) modules.push({ id: 'passwords', solved: d.passwords.solved, columns: [...d.passwords.columns] });
|
||||
if (d.morse) {
|
||||
// The light is the puzzle: the Technician gets the blink pattern, never the word.
|
||||
modules.push({ id: 'morse', solved: d.morse.solved, signal: toSignal(d.morse.word), ...MORSE_BAND });
|
||||
}
|
||||
if (d.simon) {
|
||||
modules.push({
|
||||
id: 'simon',
|
||||
solved: d.simon.solved,
|
||||
colors: [...(game.modules.simon?.colors ?? [])],
|
||||
flashes: d.simon.sequence.slice(0, d.simon.stage),
|
||||
stage: d.simon.stage,
|
||||
stages: d.simon.sequence.length,
|
||||
entered: d.simon.progress,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
seat: 'technician',
|
||||
device: {
|
||||
serial: d.serial,
|
||||
batteries: d.batteries,
|
||||
indicators: (d.indicators ?? []).map((i) => ({ label: i.label, lit: i.lit })),
|
||||
modules,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- sync ----------
|
||||
|
||||
export function syncView(game: SyncGame, s: SyncState, role: Role): SyncView {
|
||||
const round = game.rounds[s.round];
|
||||
const subject = round.type === 'predict' ? (round.subject ?? 'A') : null;
|
||||
const subject = subjectOf(round, s.q);
|
||||
const q = s.status === 'question' || s.status === 'reveal' ? s.questions[s.q] : undefined;
|
||||
const answers = q ? (s.answers[q.id] ?? {}) : {};
|
||||
const both = answers.A !== undefined && answers.B !== undefined;
|
||||
@@ -196,20 +274,20 @@ export function syncView(game: SyncGame, s: SyncState, role: Role): SyncView {
|
||||
isReplay: s.attempt > 0,
|
||||
questionNumber: s.q + 1,
|
||||
questionsPerRound: s.questions.length,
|
||||
pointsForKey: game.rules.pointsForKey,
|
||||
pointsForKey: pointsForKey(game, round),
|
||||
points: s.marks.filter(Boolean).length,
|
||||
question: q
|
||||
? {
|
||||
id: q.id,
|
||||
text: q.q.replaceAll('{subject}', subject ? game.roles[subject].name : ''),
|
||||
options: [...q.options],
|
||||
options: [...(q.options ?? q.items ?? [])],
|
||||
}
|
||||
: null,
|
||||
iAmSubject: subject === role,
|
||||
myAnswer: answers[role] ?? null,
|
||||
// Whether the partner has answered, never what they answered, until both are in.
|
||||
partnerAnswered: answers[otherRole(role)] !== undefined,
|
||||
reveal: both ? { A: answers.A!, B: answers.B!, match: answers.A === answers.B } : null,
|
||||
reveal: both ? { A: answers.A!, B: answers.B!, match: isMatch(round.type, answers.A!, answers.B!) } : null,
|
||||
marks: [...s.marks],
|
||||
summary:
|
||||
s.status === 'summary'
|
||||
@@ -226,12 +304,25 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
|
||||
|
||||
// Navigator: the whole map, but never the Explorer's coordinates.
|
||||
if (role === 'A') {
|
||||
// A mirrored map is flipped left to right: rows reversed, x counted from the other side.
|
||||
const mirror = game.navigatorView.mapTransform === 'mirror-x';
|
||||
const width = game.grid[0].length;
|
||||
const fx = (x: number) => (mirror ? width - 1 - x : x);
|
||||
return {
|
||||
engine: 'labyrinth',
|
||||
seat: 'navigator',
|
||||
grid: [...game.grid],
|
||||
glyphs: game.glyphs.map((g) => ({ x: g.x, y: g.y, label: g.label })),
|
||||
lanterns: game.lanterns.map((l, i) => ({ x: l.x, y: l.y, name: l.name, collected: s.collected[i] })),
|
||||
grid: game.grid.map((row) => (mirror ? [...row].reverse().join('') : row)),
|
||||
glyphs: game.glyphs.map((g) => ({ x: fx(g.x), y: g.y, label: g.label })),
|
||||
lanterns: game.lanterns.map((l, i) => ({ x: fx(l.x), y: l.y, name: l.name, collected: s.collected[i] })),
|
||||
oneWay: game.oneWay.map((g) => ({
|
||||
x: fx(g.x),
|
||||
y: g.y,
|
||||
dir: mirror && (g.dir === 'e' || g.dir === 'w') ? OPPOSITE[g.dir] : g.dir,
|
||||
})),
|
||||
// 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,
|
||||
moves: game.navigatorView.showMoveCount ? s.moves : null,
|
||||
@@ -240,22 +331,136 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
|
||||
}
|
||||
|
||||
// Explorer: only the cell under their feet. No grid, no coordinates, no traps.
|
||||
const open = {} as Record<Dir, boolean>;
|
||||
for (const dir of Object.keys(DELTAS) as Dir[]) {
|
||||
open[dir] = !isWall(game.grid, s.x + DELTAS[dir].x, s.y + DELTAS[dir].y);
|
||||
// With no compass, the ways out are forward/left/right/back and never N/E/S/W.
|
||||
const relative = game.explorer.compass === 'relative';
|
||||
const facing = facingOf(game, s);
|
||||
const open: Partial<Record<MoveDir, boolean>> = {};
|
||||
const gates: MoveDir[] = [];
|
||||
for (const move of relative ? REL_DIRS : (Object.keys(DELTAS) as Dir[])) {
|
||||
const dir = relative ? toAbsolute(facing, move as (typeof REL_DIRS)[number]) : (move as Dir);
|
||||
const wall = isWall(game.grid, s.x + DELTAS[dir].x, s.y + DELTAS[dir].y);
|
||||
const gate = !wall && gateBlocks(game.oneWay, s.x, s.y, dir);
|
||||
open[move] = !wall && !gate;
|
||||
if (gate) gates.push(move);
|
||||
}
|
||||
const glyph = game.glyphs.find((g) => g.x === s.x && g.y === s.y);
|
||||
return {
|
||||
engine: 'labyrinth',
|
||||
seat: 'explorer',
|
||||
glyphLabel: glyph?.label ?? null,
|
||||
compass: game.explorer.compass,
|
||||
open,
|
||||
gates,
|
||||
keysCarried,
|
||||
totalKeys: total,
|
||||
atExit: cellAt(game.grid, s.x, s.y) === 'E',
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,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);
|
||||
}
|
||||
}
|
||||
|
||||
+211
-10
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { CLUE_TYPES } from '@two-keys/shared';
|
||||
import { CLUE_TYPES, DEFUSE_MODULES, SYNC_ROUND_TYPES } from '@two-keys/shared';
|
||||
|
||||
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected a #rrggbb colour');
|
||||
const roleInfo = z.object({ name: z.string().min(1), blurb: z.string() });
|
||||
@@ -10,7 +10,7 @@ const header = {
|
||||
title: z.string().min(1),
|
||||
tagline: z.string().min(1),
|
||||
estimatedMinutes: z.number().positive(),
|
||||
difficulty: z.number().int().min(1).max(3),
|
||||
difficulty: z.number().int().min(1).max(5),
|
||||
players: z.object({ min: z.literal(2), max: z.literal(2) }),
|
||||
theme: z.object({ primary: hex, accent: hex, background: hex, headingFont: z.string().min(1) }),
|
||||
roles: z.object({ A: roleInfo, B: roleInfo }),
|
||||
@@ -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),
|
||||
@@ -81,12 +88,20 @@ const finaleSchema = z.discriminatedUnion('type', [
|
||||
clues: z.object({ A: z.array(clueSchema).default([]), B: z.array(clueSchema).default([]) }),
|
||||
lock: lockSchema,
|
||||
successEnding: z.string().min(1),
|
||||
/** Shown if a time limit runs out. */
|
||||
failEnding: z.string().min(1).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const storySchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('story'),
|
||||
rules: z
|
||||
.object({
|
||||
hintPenaltySeconds: z.number().int().nonnegative().optional(),
|
||||
...timeLimitRules,
|
||||
})
|
||||
.default({}),
|
||||
suspects: z
|
||||
.array(z.object({ id: z.string(), name: z.string(), role: z.string(), description: z.string() }))
|
||||
.optional(),
|
||||
@@ -97,14 +112,22 @@ const storySchema = z.object({
|
||||
// ---------- defuse ----------
|
||||
|
||||
const range = z.object({ min: z.number().int().nonnegative(), max: z.number().int().nonnegative() });
|
||||
const moduleId = z.enum(['wires', 'button', 'keypad']);
|
||||
const moduleId = z.enum(DEFUSE_MODULES);
|
||||
const indicators = z.object({ lit: z.array(z.string()), unlit: z.array(z.string()) }).optional();
|
||||
|
||||
const defuseSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('defuse'),
|
||||
/** Which manual the solver implements. Each manual's rules live in engines/defuse.ts. */
|
||||
ruleset: z.enum(['classic', 'overtime']).default('classic'),
|
||||
/** 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,
|
||||
indicators: z.object({ labels: z.array(z.string().min(1)).min(1), count: range }).optional(),
|
||||
}),
|
||||
levels: z
|
||||
.array(
|
||||
@@ -113,6 +136,7 @@ const defuseSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
timeSeconds: z.number().int().positive(),
|
||||
modules: z.array(moduleId).min(1),
|
||||
strikesOverride: z.number().int().positive().optional(),
|
||||
keyName: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
@@ -129,13 +153,25 @@ const defuseSchema = z.object({
|
||||
symbols: z.record(z.string()),
|
||||
columns: z.array(z.array(z.string()).min(4)).min(1),
|
||||
}),
|
||||
passwords: z
|
||||
.object({
|
||||
columnsCount: z.number().int().positive(),
|
||||
lettersPerColumn: z.number().int().min(2).max(26),
|
||||
words: z.array(z.string().regex(/^[A-Z]+$/)).min(2),
|
||||
})
|
||||
.optional(),
|
||||
morse: z.object({ words: z.record(z.string().regex(/^\d\.\d{3}$/)) }).optional(),
|
||||
simon: z.object({ colors: z.array(z.string()).min(2), stages: range }).optional(),
|
||||
}),
|
||||
manual: z.string().min(1),
|
||||
/** Shown when the last level is cleared. */
|
||||
successEnding: z.string().min(1).optional(),
|
||||
fixtures: z.object({
|
||||
wires: z.array(
|
||||
z.object({
|
||||
serial: z.string(),
|
||||
batteries: z.number().int(),
|
||||
indicators,
|
||||
wires: z.array(z.string()),
|
||||
expect: z.object({ type: z.literal('cut'), index: z.number().int() }),
|
||||
}),
|
||||
@@ -143,6 +179,7 @@ const defuseSchema = z.object({
|
||||
button: z.array(
|
||||
z.object({
|
||||
batteries: z.number().int(),
|
||||
indicators,
|
||||
label: z.string(),
|
||||
color: z.string(),
|
||||
strip: z.string(),
|
||||
@@ -153,6 +190,20 @@ const defuseSchema = z.object({
|
||||
}),
|
||||
),
|
||||
keypad: z.array(z.object({ symbols: z.array(z.string()), expect: z.array(z.string()) })),
|
||||
passwords: z.array(z.object({ columns: z.array(z.string()), expect: z.string() })).default([]),
|
||||
morse: z
|
||||
.array(z.object({ signal: z.string(), expect: z.object({ word: z.string(), frequency: z.string() }) }))
|
||||
.default([]),
|
||||
simon: z
|
||||
.array(
|
||||
z.object({
|
||||
serial: z.string(),
|
||||
strikes: z.number().int(),
|
||||
flashes: z.array(z.string()),
|
||||
expect: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -163,13 +214,20 @@ const questionSchema = z.object({
|
||||
q: z.string().min(1),
|
||||
options: z.array(z.coerce.string()).min(2),
|
||||
});
|
||||
const telepathySchema = z.object({ id: z.string().min(1), q: z.string().min(1) });
|
||||
const rankSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
q: z.string().min(1),
|
||||
items: z.array(z.coerce.string()).min(3),
|
||||
});
|
||||
|
||||
const syncSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('sync'),
|
||||
rules: z.object({
|
||||
questionsPerRound: z.number().int().positive(),
|
||||
pointsForKey: z.number().int().positive(),
|
||||
/** Default for rounds that don't set their own. */
|
||||
pointsForKey: z.number().int().positive().default(3),
|
||||
replaysPerRound: z.number().int().nonnegative(),
|
||||
}),
|
||||
rounds: z
|
||||
@@ -177,19 +235,28 @@ const syncSchema = z.object({
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
type: z.enum(['match', 'predict']),
|
||||
type: z.enum(SYNC_ROUND_TYPES),
|
||||
subject: z.enum(['A', 'B']).optional(),
|
||||
/** rank rounds: who does the ranking on each question, cycled. */
|
||||
subjectPattern: z.array(z.enum(['A', 'B'])).min(1).optional(),
|
||||
pointsForKey: z.number().int().positive().optional(),
|
||||
blurb: z.string(),
|
||||
keyName: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
match: z.array(questionSchema),
|
||||
predict: z.array(questionSchema),
|
||||
match: z.array(questionSchema).default([]),
|
||||
predict: z.array(questionSchema).default([]),
|
||||
telepathy: z.array(telepathySchema).default([]),
|
||||
rank: z.array(rankSchema).default([]),
|
||||
/** Shown when every key is earned. */
|
||||
successEnding: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
// ---------- labyrinth ----------
|
||||
|
||||
const dir = z.enum(['n', 'e', 's', 'w']);
|
||||
|
||||
const labyrinthSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('labyrinth'),
|
||||
@@ -198,6 +265,33 @@ const labyrinthSchema = z.object({
|
||||
z.object({ x: z.number().int(), y: z.number().int(), glyph: z.string(), label: z.string().min(1) }),
|
||||
),
|
||||
lanterns: z.array(z.object({ x: z.number().int(), y: z.number().int(), name: z.string().min(1) })),
|
||||
lanternsRequired: z.number().int().positive().default(3),
|
||||
/** One-way gates: you may step from (x, y) in `dir`, never back the other way. */
|
||||
oneWay: z.array(z.object({ x: z.number().int(), y: z.number().int(), dir })).default([]),
|
||||
/** 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. */
|
||||
compass: z.enum(['absolute', 'relative']).default('absolute'),
|
||||
startFacing: dir.default('n'),
|
||||
})
|
||||
.default({}),
|
||||
explorerView: z.object({
|
||||
flavour: z.object({
|
||||
blank: z.string(),
|
||||
@@ -205,25 +299,132 @@ const labyrinthSchema = z.object({
|
||||
lantern: z.string(),
|
||||
exitLocked: z.string(),
|
||||
exitOpen: z.string(),
|
||||
gate: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
navigatorView: z.object({ showMoveCount: z.boolean().default(true) }),
|
||||
navigatorView: z.object({
|
||||
showMoveCount: z.boolean().default(true),
|
||||
/** `mirror-x` serves the map flipped left to right. */
|
||||
mapTransform: z.enum(['none', 'mirror-x']).default('none'),
|
||||
banner: z.string().optional(),
|
||||
}),
|
||||
scoring: z.object({ trapPenaltySeconds: z.number().nonnegative(), parMoves: z.number().int().positive() }),
|
||||
successEnding: z.string().min(1),
|
||||
});
|
||||
|
||||
// ---------- 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>;
|
||||
export type SyncQuestion = z.infer<typeof questionSchema>;
|
||||
export type SyncQuestion = { id: string; q: string; options?: string[]; items?: string[] };
|
||||
|
||||
+240
-15
@@ -1,7 +1,30 @@
|
||||
import { normalizeAnswer } from '@two-keys/shared';
|
||||
import { solveButton, solveKeypad, solveWires } from '../engines/defuse';
|
||||
import { cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
|
||||
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
|
||||
import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared';
|
||||
import {
|
||||
OVERTIME_TABLES,
|
||||
buttonSolution,
|
||||
solveKeypad,
|
||||
solveMorse,
|
||||
solvePassword,
|
||||
solveSimon,
|
||||
toSignal,
|
||||
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 { gridA } from '../engines/twin';
|
||||
import type {
|
||||
BlueprintGame,
|
||||
Clue,
|
||||
DefuseGame,
|
||||
DialGame,
|
||||
Game,
|
||||
LabyrinthGame,
|
||||
Lock,
|
||||
StoryGame,
|
||||
SyncGame,
|
||||
TwinGame,
|
||||
} from './schemas';
|
||||
|
||||
export const MIN_SYNC_QUESTIONS = 15;
|
||||
|
||||
@@ -16,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>();
|
||||
@@ -75,14 +188,14 @@ function validateStory(game: StoryGame): string[] {
|
||||
function validateDefuse(game: DefuseGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const { fixtures, modules } = game;
|
||||
const { fixtures, modules, ruleset } = game;
|
||||
|
||||
fixtures.wires.forEach((f, i) => {
|
||||
const got = solveWires(f.serial, f.wires);
|
||||
const got = wireSolution(ruleset, f, f.wires);
|
||||
if (got.index !== f.expect.index) errors.push(`wires fixture ${i + 1}: expected cut ${f.expect.index}, solver says ${got.index}`);
|
||||
});
|
||||
fixtures.button.forEach((f, i) => {
|
||||
const got = solveButton(f);
|
||||
const got = buttonSolution(ruleset, { serial: '', ...f }, f);
|
||||
if (!same(got, f.expect)) errors.push(`button fixture ${i + 1}: expected ${JSON.stringify(f.expect)}, solver says ${JSON.stringify(got)}`);
|
||||
});
|
||||
fixtures.keypad.forEach((f, i) => {
|
||||
@@ -90,6 +203,62 @@ function validateDefuse(game: DefuseGame): string[] {
|
||||
if (!same(got, f.expect)) errors.push(`keypad fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got?.join(',') ?? 'no unique column'}`);
|
||||
});
|
||||
|
||||
fixtures.passwords.forEach((f, i) => {
|
||||
const p = modules.passwords;
|
||||
if (p && (f.columns.length !== p.columnsCount || f.columns.some((c) => c.length !== p.lettersPerColumn))) {
|
||||
errors.push(`passwords fixture ${i + 1}: needs ${p.columnsCount} columns of exactly ${p.lettersPerColumn} letters`);
|
||||
}
|
||||
const got = solvePassword(f.columns, modules.passwords?.words ?? []);
|
||||
if (got !== f.expect) errors.push(`passwords fixture ${i + 1}: expected ${f.expect}, solver says ${got ?? 'no unique word'}`);
|
||||
});
|
||||
fixtures.morse.forEach((f, i) => {
|
||||
const got = solveMorse(f.signal, modules.morse?.words ?? {});
|
||||
if (!same(got, f.expect)) errors.push(`morse fixture ${i + 1}: expected ${JSON.stringify(f.expect)}, solver says ${JSON.stringify(got)}`);
|
||||
});
|
||||
fixtures.simon.forEach((f, i) => {
|
||||
const got = solveSimon(f.serial, f.strikes, f.flashes);
|
||||
if (!same(got, f.expect)) errors.push(`simon fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got.join(',')}`);
|
||||
});
|
||||
|
||||
for (const level of game.levels) {
|
||||
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${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) {
|
||||
const { words, columnsCount } = modules.passwords;
|
||||
for (const w of words) if (w.length !== columnsCount) errors.push(`password "${w}" is not ${columnsCount} letters`);
|
||||
if (new Set(words).size !== words.length) errors.push('password list has duplicates');
|
||||
}
|
||||
if (modules.morse) {
|
||||
const entries = Object.entries(modules.morse.words);
|
||||
for (const [word] of entries) {
|
||||
if (!solveMorse(toSignal(word), modules.morse.words)) errors.push(`morse word "${word}" can't be sent in Morse`);
|
||||
}
|
||||
if (new Set(entries.map(([, f]) => f)).size !== entries.length) errors.push('two morse words share a frequency');
|
||||
}
|
||||
if (ruleset === 'overtime') {
|
||||
// The manual's tables must be the ones the solver was written against.
|
||||
const t = OVERTIME_TABLES;
|
||||
if (!same(modules.keypad.columns, t.keypadColumns)) errors.push("keypad columns don't match the Overtime solver's");
|
||||
if (!same([...(modules.passwords?.words ?? [])].sort(), [...t.passwordWords].sort())) {
|
||||
errors.push("password word list doesn't match the Overtime solver's");
|
||||
}
|
||||
const morse = modules.morse?.words ?? {};
|
||||
if (Object.keys(morse).length !== Object.keys(t.morseFrequencies).length) errors.push("morse word list doesn't match the Overtime solver's");
|
||||
for (const [word, freq] of Object.entries(morse)) {
|
||||
if (t.morseFrequencies[word] !== freq) errors.push(`morse word "${word}" should be on ${t.morseFrequencies[word] ?? 'no frequency'}, the YAML says ${freq}`);
|
||||
}
|
||||
}
|
||||
if (modules.simon && modules.simon.stages.min < 1) errors.push('simon needs at least one stage');
|
||||
|
||||
if (!/\d/.test(game.device.serial.alphabet)) errors.push('serial alphabet has no digits');
|
||||
if (modules.wires.count.min < 1 || modules.wires.count.max < modules.wires.count.min) errors.push('bad wire count range');
|
||||
const { columns, symbols } = modules.keypad;
|
||||
@@ -106,18 +275,37 @@ function validateDefuse(game: DefuseGame): string[] {
|
||||
function validateSync(game: SyncGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const perRound = game.rules.questionsPerRound * (1 + game.rules.replaysPerRound);
|
||||
for (const type of ['match', 'predict'] as const) {
|
||||
const rounds = game.rounds.filter((r) => r.type === type).length;
|
||||
if (rounds === 0) continue;
|
||||
for (const type of SYNC_ROUND_TYPES) {
|
||||
const rounds = game.rounds.filter((r) => r.type === type);
|
||||
if (rounds.length === 0) continue;
|
||||
const bank = game[type];
|
||||
if (bank.length < MIN_SYNC_QUESTIONS) errors.push(`${type} bank has ${bank.length} questions, needs at least ${MIN_SYNC_QUESTIONS}`);
|
||||
if (bank.length < rounds * perRound) errors.push(`${type} bank has ${bank.length} questions, but ${rounds * perRound} are needed to never repeat`);
|
||||
// A question never repeats, except that a spotlight question may be asked once about each partner.
|
||||
const groups = new Map<string, number>();
|
||||
for (const r of rounds) groups.set(groupOf(r), (groups.get(groupOf(r)) ?? 0) + 1);
|
||||
const needed = Math.max(...groups.values()) * perRound;
|
||||
if (bank.length < needed) errors.push(`${type} bank has ${bank.length} questions, but ${needed} are needed to never repeat`);
|
||||
if (new Set(bank.map((q) => q.id)).size !== bank.length) errors.push(`${type} bank has duplicate question ids`);
|
||||
}
|
||||
for (const 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) {
|
||||
errors.push(`round ${r.id}: subjectPattern should name a subject for each of the ${game.rules.questionsPerRound} questions`);
|
||||
}
|
||||
if (pointsForKey(game, r) > game.rules.questionsPerRound) errors.push(`round ${r.id}: pointsForKey is more than questionsPerRound`);
|
||||
}
|
||||
if (game.rules.pointsForKey > game.rules.questionsPerRound) errors.push('pointsForKey is more than questionsPerRound');
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -131,24 +319,61 @@ function validateLabyrinth(game: LabyrinthGame): string[] {
|
||||
const keys = findCells(grid, 'K');
|
||||
if (starts.length !== 1) errors.push(`grid needs exactly one S, found ${starts.length}`);
|
||||
if (exits.length !== 1) errors.push(`grid needs exactly one E, found ${exits.length}`);
|
||||
if (keys.length !== 3) errors.push(`grid needs exactly 3 K, found ${keys.length}`);
|
||||
const required = game.lanternsRequired;
|
||||
if (keys.length !== required) errors.push(`grid needs exactly ${required} K, found ${keys.length}`);
|
||||
|
||||
if (game.lanterns.length !== keys.length) errors.push('lantern list does not match the K cells in the grid');
|
||||
for (const l of game.lanterns) {
|
||||
if (cellAt(grid, l.x, l.y) !== 'K') errors.push(`lantern "${l.name}" at (${l.x},${l.y}) is not on a K cell`);
|
||||
}
|
||||
for (const g of game.glyphs) {
|
||||
if (cellAt(grid, g.x, g.y) === '#') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is inside a wall`);
|
||||
const c = cellAt(grid, g.x, g.y);
|
||||
if (c === '#' || c === 'T') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is on a ${c === '#' ? 'wall' : 'trap'}`);
|
||||
}
|
||||
|
||||
for (const g of game.oneWay) {
|
||||
const through = DELTAS[g.dir];
|
||||
const ends = [cellAt(grid, g.x, g.y), cellAt(grid, g.x + through.x, g.y + through.y)];
|
||||
if (ends.includes('#')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} opens into a wall`);
|
||||
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);
|
||||
// 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, 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})`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+319
-28
@@ -2,12 +2,26 @@ import type { DefuseAction, DefuseStatus, Role } from '@two-keys/shared';
|
||||
import type { DefuseGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
// ---------- Solver: implements the manual exactly ----------
|
||||
// ---------- Solver: implements each manual exactly ----------
|
||||
// `classic` is the Tick Tock Together manual, `overtime` the Tick Tock: Overtime one.
|
||||
|
||||
export type WireSolution = { type: 'cut'; index: number };
|
||||
export type ButtonSolution = { type: 'tap' } | { type: 'hold'; releaseDigit: number };
|
||||
export interface Indicators {
|
||||
lit: string[];
|
||||
unlit: string[];
|
||||
}
|
||||
/** What the Technician reads out before anything else. */
|
||||
export interface Edgework {
|
||||
serial: string;
|
||||
batteries: number;
|
||||
indicators?: Indicators;
|
||||
}
|
||||
|
||||
/** Returns the 1-based index of the wire to cut. */
|
||||
const hasVowel = (serial: string) => /[AEIOU]/.test(serial);
|
||||
const isLit = (e: { indicators?: Indicators }, label: string) => e.indicators?.lit.includes(label) ?? false;
|
||||
|
||||
/** Classic manual. Returns the 1-based index of the wire to cut. */
|
||||
export function solveWires(serial: string, wires: string[]): WireSolution {
|
||||
const indexesOf = (color: string) => wires.flatMap((w, i) => (w === color ? [i + 1] : []));
|
||||
const reds = indexesOf('red');
|
||||
@@ -21,6 +35,43 @@ export function solveWires(serial: string, wires: string[]): WireSolution {
|
||||
return { type: 'cut', index: 1 };
|
||||
}
|
||||
|
||||
/** Overtime manual: one section per wire count, first rule that applies. */
|
||||
export function solveWiresOvertime(e: Edgework, wires: string[]): WireSolution {
|
||||
const indexesOf = (color: string) => wires.flatMap((w, i) => (w === color ? [i + 1] : []));
|
||||
const cut = (index: number): WireSolution => ({ type: 'cut', index });
|
||||
const n = wires.length;
|
||||
|
||||
if (n === 3) {
|
||||
if (new Set(wires).size === 3) return cut(2);
|
||||
if (indexesOf('blue').length >= 2) return cut(indexesOf('blue')[0]);
|
||||
if (hasVowel(e.serial)) return cut(3);
|
||||
return cut(1);
|
||||
}
|
||||
if (n === 4) {
|
||||
const greens = indexesOf('green');
|
||||
const whites = indexesOf('white');
|
||||
if (greens.length === 1 && e.batteries >= 2) return cut(greens[0]);
|
||||
if (wires[0] === wires[3]) return cut(3);
|
||||
if (whites.length === 0) return cut(2);
|
||||
return cut(whites[whites.length - 1]);
|
||||
}
|
||||
if (n === 5) {
|
||||
const blacks = indexesOf('black');
|
||||
const yellows = indexesOf('yellow');
|
||||
const lastDigit = Number(e.serial.match(/\d(?=\D*$)/)?.[0] ?? NaN);
|
||||
if (blacks.length > indexesOf('red').length) return cut(blacks[0]);
|
||||
if (lastDigit % 2 === 1) return cut(4);
|
||||
if (yellows.length === 1) return cut(yellows[0] === 5 ? 1 : yellows[0] + 1);
|
||||
return cut(2);
|
||||
}
|
||||
const reds = indexesOf('red');
|
||||
if (isLit(e, 'LUV')) return cut(6);
|
||||
if (reds.length === 3) return cut(reds[2]);
|
||||
if (!wires.includes('yellow')) return cut(3);
|
||||
return cut(5);
|
||||
}
|
||||
|
||||
/** Classic manual. */
|
||||
export function solveButton(b: {
|
||||
batteries: number;
|
||||
label: string;
|
||||
@@ -34,6 +85,85 @@ export function solveButton(b: {
|
||||
return { type: 'hold', releaseDigit };
|
||||
}
|
||||
|
||||
export function solveButtonOvertime(b: {
|
||||
batteries: number;
|
||||
indicators?: Indicators;
|
||||
label: string;
|
||||
color: string;
|
||||
strip: string;
|
||||
}): ButtonSolution {
|
||||
const releaseDigit = { blue: 4, white: 1, yellow: 5, red: 3 }[b.strip] ?? 1;
|
||||
const hold: ButtonSolution = { type: 'hold', releaseDigit };
|
||||
if (b.color === 'blue' && b.label === 'ABORT') return hold;
|
||||
if (b.batteries > 1 && b.label === 'DETONATE') return { type: 'tap' };
|
||||
if (b.color === 'white' && isLit(b, 'BAE')) return hold;
|
||||
if (b.batteries > 2 && isLit(b, 'HUG')) return { type: 'tap' };
|
||||
if (b.color === 'yellow') return hold;
|
||||
if (b.color === 'red' && b.label === 'HOLD') return { type: 'tap' };
|
||||
return hold;
|
||||
}
|
||||
|
||||
type Ruleset = DefuseGame['ruleset'];
|
||||
|
||||
export function wireSolution(ruleset: Ruleset, e: Edgework, wires: string[]): WireSolution {
|
||||
return ruleset === 'overtime' ? solveWiresOvertime(e, wires) : solveWires(e.serial, wires);
|
||||
}
|
||||
|
||||
export function buttonSolution(
|
||||
ruleset: Ruleset,
|
||||
e: Edgework,
|
||||
button: { label: string; color: string; strip: string },
|
||||
): ButtonSolution {
|
||||
const b = { ...button, batteries: e.batteries, indicators: e.indicators };
|
||||
return ruleset === 'overtime' ? solveButtonOvertime(b) : solveButton(b);
|
||||
}
|
||||
|
||||
/** The one listed word the letter columns can spell, or null unless there is exactly one. */
|
||||
export function solvePassword(columns: string[], words: string[]): string | null {
|
||||
const matches = words.filter(
|
||||
(word) => word.length === columns.length && [...word].every((letter, i) => columns[i].includes(letter)),
|
||||
);
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
const MORSE: Record<string, string> = {
|
||||
A: '.-', B: '-...', C: '-.-.', D: '-..', E: '.', F: '..-.', G: '--.', H: '....', I: '..', J: '.---',
|
||||
K: '-.-', L: '.-..', M: '--', N: '-.', O: '---', P: '.--.', Q: '--.-', R: '.-.', S: '...', T: '-',
|
||||
U: '..-', V: '...-', W: '.--', X: '-..-', Y: '-.--', Z: '--..',
|
||||
};
|
||||
|
||||
/** "HUG" → "..../..-/--." (the signal format of §6.3). */
|
||||
export const toSignal = (word: string): string => [...word].map((letter) => MORSE[letter] ?? '').join('/');
|
||||
|
||||
export function solveMorse(
|
||||
signal: string,
|
||||
words: Record<string, string>,
|
||||
): { word: string; frequency: string } | null {
|
||||
const letters = signal.split('/').map((code) => Object.keys(MORSE).find((l) => MORSE[l] === code));
|
||||
const word = letters.join('');
|
||||
return letters.every(Boolean) && word in words ? { word, frequency: words[word] } : null;
|
||||
}
|
||||
|
||||
// Say It Back: flashed colour → colour to press, one row per strike count.
|
||||
const SIMON_MAPS: Record<'vowel' | 'noVowel', Record<string, string>[]> = {
|
||||
vowel: [
|
||||
{ red: 'blue', blue: 'red', green: 'yellow', yellow: 'green' },
|
||||
{ red: 'yellow', blue: 'green', green: 'blue', yellow: 'red' },
|
||||
{ red: 'green', blue: 'red', green: 'yellow', yellow: 'blue' },
|
||||
],
|
||||
noVowel: [
|
||||
{ red: 'blue', blue: 'yellow', green: 'green', yellow: 'red' },
|
||||
{ red: 'red', blue: 'blue', green: 'yellow', yellow: 'green' },
|
||||
{ red: 'yellow', blue: 'green', green: 'blue', yellow: 'red' },
|
||||
],
|
||||
};
|
||||
|
||||
export function solveSimon(serial: string, strikes: number, flashes: string[]): string[] {
|
||||
const rows = SIMON_MAPS[hasVowel(serial) ? 'vowel' : 'noVowel'];
|
||||
const map = rows[Math.min(Math.max(strikes, 0), rows.length - 1)];
|
||||
return flashes.map((color) => map[color] ?? color);
|
||||
}
|
||||
|
||||
/** The press order, or null unless exactly one column contains every symbol. */
|
||||
export function solveKeypad(symbols: string[], columns: string[][]): string[] | null {
|
||||
const matches = columns.filter((col) => symbols.every((s) => col.includes(s)));
|
||||
@@ -41,6 +171,31 @@ export function solveKeypad(symbols: string[], columns: string[][]): string[] |
|
||||
return matches[0].filter((s) => symbols.includes(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Overtime manual's tables, as the reference solver has them. The YAML carries its own copy
|
||||
* (the generator reads that one); validation checks the two agree.
|
||||
*/
|
||||
export const OVERTIME_TABLES = {
|
||||
keypadColumns: [
|
||||
['heart', 'star', 'moon', 'sun', 'club', 'flower', 'key'],
|
||||
['umbrella', 'heart', 'plane', 'music', 'star', 'anchor', 'crown'],
|
||||
['diamond', 'sun', 'spade', 'umbrella', 'flower', 'music', 'bolt'],
|
||||
['anchor', 'spade', 'club', 'plane', 'moon', 'diamond', 'key'],
|
||||
['crown', 'bolt', 'heart', 'diamond', 'plane', 'sun', 'cloud'],
|
||||
['cloud', 'music', 'key', 'spade', 'club', 'crown', 'star'],
|
||||
],
|
||||
passwordWords: [
|
||||
'ADORE', 'ANGEL', 'BLISS', 'BLUSH', 'CANDY', 'CHARM', 'CRUSH', 'DANCE', 'DREAM', 'FLAME', 'FLIRT',
|
||||
'HEART', 'HONEY', 'LOVER', 'MATCH', 'PEACH', 'RINGS', 'ROSES', 'SMILE', 'SPARK', 'SUGAR', 'SWEET',
|
||||
'TEDDY', 'TRUST', 'TULIP', 'UNITY', 'WALTZ', 'BLOOM', 'CHOSE', 'PEARL', 'VOWED', 'QUEEN',
|
||||
],
|
||||
morseFrequencies: {
|
||||
HUG: '3.505', KISS: '3.515', LOVE: '3.522', DATE: '3.532', RING: '3.535', ROSE: '3.542', WINE: '3.545',
|
||||
DUET: '3.552', DANCE: '3.555', HEART: '3.565', SWEET: '3.572', HONEY: '3.575', CUDDLE: '3.582',
|
||||
SPARK: '3.592', VOWS: '3.595', BLISS: '3.600',
|
||||
} as Record<string, string>,
|
||||
};
|
||||
|
||||
// ---------- Generator ----------
|
||||
|
||||
export function mulberry32(seed: number): () => number {
|
||||
@@ -54,12 +209,38 @@ 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 }[];
|
||||
passwords?: { columns: string[]; solved: boolean };
|
||||
morse?: { word: string; solved: boolean };
|
||||
/** `stage` flashes are showing; `progress` of them have been answered. */
|
||||
simon?: { sequence: string[]; stage: number; progress: number; solved: boolean };
|
||||
}
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
export function edgework(d: DefuseDevice): Edgework {
|
||||
const shown = d.indicators ?? [];
|
||||
return {
|
||||
serial: d.serial,
|
||||
batteries: d.batteries,
|
||||
indicators: {
|
||||
lit: shown.filter((i) => i.lit).map((i) => i.label),
|
||||
unlit: shown.filter((i) => !i.lit).map((i) => i.label),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function generateDevice(game: DefuseGame, levelIndex: number, seed: number): DefuseDevice {
|
||||
@@ -83,11 +264,18 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
|
||||
serial += pick(digits); // last char is always a digit
|
||||
|
||||
const device: DefuseDevice = { serial, batteries: int(batteries.min, batteries.max) };
|
||||
const ind = game.device.indicators;
|
||||
if (ind) {
|
||||
const shown = shuffle(ind.labels).slice(0, int(ind.count.min, ind.count.max));
|
||||
device.indicators = shown.map((label) => ({ label, lit: rng() < 0.5 }));
|
||||
}
|
||||
const mods = game.modules;
|
||||
for (const id of game.levels[levelIndex].modules) {
|
||||
if (id === 'wires') {
|
||||
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),
|
||||
@@ -96,21 +284,59 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
|
||||
holding: false,
|
||||
solved: false,
|
||||
};
|
||||
} else {
|
||||
const column = pick(mods.keypad.columns);
|
||||
device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false };
|
||||
} else if (id === 'keypad') {
|
||||
// Re-roll until exactly one column holds all four symbols.
|
||||
let symbols: string[] = [];
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
symbols = shuffle(pick(mods.keypad.columns)).slice(0, 4);
|
||||
if (solveKeypad(symbols, mods.keypad.columns)) break;
|
||||
}
|
||||
device.keypad = { symbols, pressed: [], solved: false };
|
||||
} else if (id === 'passwords' && mods.passwords) {
|
||||
const { words, lettersPerColumn } = mods.passwords;
|
||||
// Re-roll until exactly one listed word can be spelled.
|
||||
let columns: string[] = [];
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const target = pick(words);
|
||||
columns = [...target].map((letter) => {
|
||||
const others = shuffle([...ALPHABET].filter((l) => l !== letter)).slice(0, lettersPerColumn - 1);
|
||||
return shuffle([letter, ...others]).join('');
|
||||
});
|
||||
if (solvePassword(columns, words) === target) break;
|
||||
}
|
||||
device.passwords = { columns, solved: false };
|
||||
} else if (id === 'morse' && mods.morse) {
|
||||
device.morse = { word: pick(Object.keys(mods.morse.words)), solved: false };
|
||||
} else if (id === 'simon' && mods.simon) {
|
||||
const { colors, stages } = mods.simon;
|
||||
const sequence = Array.from({ length: int(stages.min, stages.max) }, () => pick(colors));
|
||||
device.simon = { sequence, stage: 1, progress: 0, solved: false };
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (device.wires) {
|
||||
const { index } = solveWires(device.serial, device.wires.colors);
|
||||
if (index < 1 || index > device.wires.colors.length) return false;
|
||||
if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) 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;
|
||||
if (device.morse && !solveMorse(toSignal(device.morse.word), game.modules.morse?.words ?? {})) return false;
|
||||
if (device.simon && device.simon.sequence.length < 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -125,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);
|
||||
@@ -135,6 +363,17 @@ export function initDefuse(): DefuseState {
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as DefuseState;
|
||||
|
||||
/** Strikes allowed on a level (some levels are stricter than the game default). */
|
||||
export const maxStrikes = (game: DefuseGame, level: number): number =>
|
||||
game.levels[level]?.strikesOverride ?? game.strikes.max;
|
||||
|
||||
const deviceModules = (d: DefuseDevice) =>
|
||||
[d.wires, ...(d.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;
|
||||
if (s.status !== 'active') return s.clock.remaining;
|
||||
@@ -162,20 +401,22 @@ function strike(game: DefuseGame, ctx: Ctx, s: DefuseState) {
|
||||
s.strikes++;
|
||||
ctx.room.wrongGuesses++;
|
||||
s.clock = { remaining, anchor: ctx.now, speed: 1 + game.strikes.speedUpPerStrike * s.strikes };
|
||||
if (s.strikes >= game.strikes.max) explode(ctx, s, 'strikes');
|
||||
else ctx.toast('all', `Strike ${s.strikes} of ${game.strikes.max}! The clock speeds up.`);
|
||||
const max = maxStrikes(game, s.level);
|
||||
// The Say It Back mapping moves with the strike count, so the stage starts over.
|
||||
if (s.device?.simon) s.device.simon.progress = 0;
|
||||
if (s.strikes >= max) explode(ctx, s, 'strikes');
|
||||
else ctx.toast('all', `Strike ${s.strikes} of ${max}! The clock speeds up.`);
|
||||
}
|
||||
|
||||
function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
|
||||
const d = s.device!;
|
||||
const modules = [d.wires, d.button, d.keypad].filter((m) => m !== undefined);
|
||||
if (!modules.every((m) => m.solved)) return;
|
||||
if (!deviceModules(s.device!).every((m) => m.solved)) return;
|
||||
freezeClock(s, ctx.now);
|
||||
s.status = 'cleared';
|
||||
ctx.addKey(game.levels[s.level].keyName);
|
||||
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. 💣🔑',
|
||||
);
|
||||
}
|
||||
@@ -185,11 +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);
|
||||
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') {
|
||||
@@ -197,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;
|
||||
}
|
||||
|
||||
@@ -209,20 +473,22 @@ 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');
|
||||
return OK;
|
||||
}
|
||||
const d = s.device;
|
||||
const edge = edgework(d);
|
||||
|
||||
if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') {
|
||||
if (action.index === solveWires(d.serial, d.wires.colors).index) d.wires.solved = true;
|
||||
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;
|
||||
const solution = solveButton({ ...b, batteries: d.batteries });
|
||||
const solution = buttonSolution(game.ruleset, edge, b);
|
||||
if (action.type === 'tap' && !b.holding) {
|
||||
if (solution.type === 'tap') b.solved = true;
|
||||
else strike(game, ctx, s);
|
||||
@@ -242,16 +508,41 @@ export function defuseAction(
|
||||
}
|
||||
} else if (moduleId === 'keypad' && d.keypad && !d.keypad.solved && action.type === 'press') {
|
||||
const k = d.keypad;
|
||||
if (!k.symbols.includes(action.symbol) || k.pressed.includes(action.symbol)) {
|
||||
const symbol = action.symbol ?? '';
|
||||
if (!k.symbols.includes(symbol) || k.pressed.includes(symbol)) {
|
||||
return fail('bad_action', 'That key is not available.');
|
||||
}
|
||||
const order = solveKeypad(k.symbols, game.modules.keypad.columns)!;
|
||||
if (order[k.pressed.length] === action.symbol) {
|
||||
k.pressed.push(action.symbol);
|
||||
if (order[k.pressed.length] === symbol) {
|
||||
k.pressed.push(symbol);
|
||||
if (k.pressed.length === order.length) k.solved = true;
|
||||
} else {
|
||||
strike(game, ctx, s);
|
||||
}
|
||||
} else if (moduleId === 'passwords' && d.passwords && !d.passwords.solved && action.type === 'submit') {
|
||||
const word = action.word.trim().toUpperCase();
|
||||
if (word === solvePassword(d.passwords.columns, game.modules.passwords?.words ?? [])) d.passwords.solved = true;
|
||||
else strike(game, ctx, s);
|
||||
} else if (moduleId === 'morse' && d.morse && !d.morse.solved && action.type === 'transmit') {
|
||||
const khz = Math.round(Number(action.frequency) * 1000);
|
||||
if (!Number.isFinite(khz)) return fail('bad_action', "That isn't a frequency.");
|
||||
const target = Number(game.modules.morse?.words[d.morse.word]);
|
||||
if (khz === Math.round(target * 1000)) d.morse.solved = true;
|
||||
else strike(game, ctx, s);
|
||||
} else if (moduleId === 'simon' && d.simon && !d.simon.solved && action.type === 'press') {
|
||||
const sim = d.simon;
|
||||
const color = action.color ?? '';
|
||||
if (!game.modules.simon?.colors.includes(color)) return fail('bad_action', 'That pad is not on the device.');
|
||||
const [expected] = solveSimon(d.serial, s.strikes, [sim.sequence[sim.progress]]);
|
||||
if (color !== expected) {
|
||||
strike(game, ctx, s); // also restarts the stage
|
||||
} else if (++sim.progress >= sim.stage) {
|
||||
if (sim.stage >= sim.sequence.length) sim.solved = true;
|
||||
else {
|
||||
sim.stage++;
|
||||
sim.progress = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fail('bad_action', "That module isn't on this device.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dir, Role } from '@two-keys/shared';
|
||||
import type { Dir, MoveDir, RelDir, Role } from '@two-keys/shared';
|
||||
import type { LabyrinthGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
@@ -7,16 +7,35 @@ export interface Point {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** You may step from (x, y) in `dir`, never back the other way. */
|
||||
export interface Gate extends Point {
|
||||
dir: Dir;
|
||||
}
|
||||
|
||||
export const DELTAS: Record<Dir, Point> = {
|
||||
n: { x: 0, y: -1 },
|
||||
e: { x: 1, y: 0 },
|
||||
s: { x: 0, y: 1 },
|
||||
w: { x: -1, y: 0 },
|
||||
};
|
||||
export const OPPOSITE: Record<Dir, Dir> = { n: 's', e: 'w', s: 'n', w: 'e' };
|
||||
export const REL_DIRS: readonly RelDir[] = ['f', 'r', 'b', 'l'];
|
||||
const CLOCKWISE: readonly Dir[] = ['n', 'e', 's', 'w'];
|
||||
|
||||
/** Forward / right / back / left from the way you face → a compass direction. */
|
||||
export function toAbsolute(facing: Dir, rel: RelDir): Dir {
|
||||
return CLOCKWISE[(CLOCKWISE.indexOf(facing) + REL_DIRS.indexOf(rel)) % 4];
|
||||
}
|
||||
|
||||
export const cellAt = (grid: string[], x: number, y: number): string => grid[y]?.[x] ?? '#';
|
||||
export const isWall = (grid: string[], x: number, y: number): boolean => cellAt(grid, x, y) === '#';
|
||||
|
||||
/** True if a one-way gate stops a step from (x, y) in `dir`. */
|
||||
export function gateBlocks(gates: readonly Gate[], x: number, y: number, dir: Dir): boolean {
|
||||
const d = DELTAS[dir];
|
||||
return gates.some((g) => g.x === x + d.x && g.y === y + d.y && g.dir === OPPOSITE[dir]);
|
||||
}
|
||||
|
||||
export function findCells(grid: string[], char: string): Point[] {
|
||||
const found: Point[] = [];
|
||||
grid.forEach((row, y) => [...row].forEach((c, x) => c === char && found.push({ x, y })));
|
||||
@@ -26,7 +45,7 @@ export function findCells(grid: string[], char: string): Point[] {
|
||||
const key = (p: Point) => `${p.x},${p.y}`;
|
||||
|
||||
/** Every cell reachable from S without ever stepping on a trap. */
|
||||
export function safeReachable(grid: string[]): Set<string> {
|
||||
export function safeReachable(grid: string[], gates: readonly Gate[] = []): Set<string> {
|
||||
const [start] = findCells(grid, 'S');
|
||||
const seen = new Set<string>();
|
||||
if (!start) return seen;
|
||||
@@ -34,10 +53,10 @@ export function safeReachable(grid: string[]): Set<string> {
|
||||
seen.add(key(start));
|
||||
while (queue.length > 0) {
|
||||
const p = queue.shift()!;
|
||||
for (const d of Object.values(DELTAS)) {
|
||||
const next = { x: p.x + d.x, y: p.y + d.y };
|
||||
for (const dir of CLOCKWISE) {
|
||||
const next = { x: p.x + DELTAS[dir].x, y: p.y + DELTAS[dir].y };
|
||||
const c = cellAt(grid, next.x, next.y);
|
||||
if (c === '#' || c === 'T' || seen.has(key(next))) continue;
|
||||
if (c === '#' || c === 'T' || seen.has(key(next)) || gateBlocks(gates, p.x, p.y, dir)) continue;
|
||||
seen.add(key(next));
|
||||
queue.push(next);
|
||||
}
|
||||
@@ -47,38 +66,169 @@ export function safeReachable(grid: string[]): Set<string> {
|
||||
|
||||
export const isReachable = (reach: Set<string>, p: Point) => reach.has(key(p));
|
||||
|
||||
/**
|
||||
* Plays the maze out as a graph of (cell, lanterns held). Returns whether a trap-free run can
|
||||
* finish, and any cell where the Explorer could end up with no way left to finish (one-way
|
||||
* gates make that possible). Falling into a trap counts as a way out: it leads back to S.
|
||||
*/
|
||||
export function analyseMaze(
|
||||
grid: string[],
|
||||
gates: readonly Gate[],
|
||||
lanterns: readonly Point[],
|
||||
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: [], 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 = 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 };
|
||||
const first: Node = { p: start, mask: 0, safe: true };
|
||||
const seen = new Map<string, Node>([[id(start, 0, true), first]]);
|
||||
const reverse = new Map<string, string[]>();
|
||||
const queue: Node[] = [first];
|
||||
const goals: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
const from = id(node.p, node.mask, node.safe);
|
||||
if (cellAt(grid, node.p.x, node.p.y) === 'E' && (node.mask & full) === 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) || doorShut(step, node.mask)) continue;
|
||||
const next: Node =
|
||||
c === 'T'
|
||||
? { p: start, mask: node.mask, safe: false }
|
||||
: { p: step, mask: pickUp(step, node.mask), safe: node.safe };
|
||||
const to = id(next.p, next.mask, next.safe);
|
||||
reverse.set(to, [...(reverse.get(to) ?? []), from]);
|
||||
if (!seen.has(to)) {
|
||||
seen.set(to, next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canFinish = new Set<string>(goals);
|
||||
const back = [...goals];
|
||||
while (back.length > 0) {
|
||||
for (const from of reverse.get(back.pop()!) ?? []) {
|
||||
if (!canFinish.has(from)) {
|
||||
canFinish.add(from);
|
||||
back.push(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stuck = new Map<string, Point>();
|
||||
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 {
|
||||
x: number;
|
||||
y: number;
|
||||
/** The way the Explorer faces: the direction of their last step. */
|
||||
facing?: Dir;
|
||||
collected: boolean[];
|
||||
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 {
|
||||
const [start] = findCells(game.grid, 'S');
|
||||
return { ...start, collected: game.lanterns.map(() => false), moves: 0, falls: 0, message: '' };
|
||||
return {
|
||||
...start,
|
||||
facing: game.explorer.startFacing,
|
||||
collected: game.lanterns.map(() => false),
|
||||
moves: 0,
|
||||
falls: 0,
|
||||
message: '',
|
||||
inventory: [],
|
||||
opened: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, dir: Dir): ActionResult {
|
||||
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 {
|
||||
const s = ctx.room.engine as LabyrinthState;
|
||||
if (role !== 'B') return fail('wrong_role', 'Only the Explorer can move.');
|
||||
|
||||
const relative = game.explorer.compass === 'relative';
|
||||
const isRel = (REL_DIRS as readonly string[]).includes(move);
|
||||
if (relative !== isRel) return fail('bad_dir', relative ? 'You have no compass in here.' : "You can't move like that here.");
|
||||
const dir = isRel ? toAbsolute(facingOf(game, s), move as RelDir) : (move as Dir);
|
||||
|
||||
const d = DELTAS[dir];
|
||||
const next = { x: s.x + d.x, y: s.y + d.y };
|
||||
if (isWall(game.grid, next.x, next.y)) return fail('wall', "Solid stone. You can't go that way.");
|
||||
if (gateBlocks(game.oneWay, s.x, s.y, dir)) return fail('gate', "The gate won't open from this side.");
|
||||
|
||||
const 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++;
|
||||
s.x = next.x;
|
||||
s.y = next.y;
|
||||
s.message = '';
|
||||
s.facing = dir;
|
||||
s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : '';
|
||||
|
||||
const 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');
|
||||
s.x = start.x;
|
||||
s.y = start.y;
|
||||
s.facing = game.explorer.startFacing;
|
||||
s.falls++;
|
||||
s.message = flavour.trap;
|
||||
ctx.addPenalty(game.scoring.trapPenaltySeconds);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { matchesAnswer, type Role } from '@two-keys/shared';
|
||||
import { formatClock, matchesAnswer, type Role } from '@two-keys/shared';
|
||||
import type { Lock, StoryGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StoryState {
|
||||
agreed: boolean;
|
||||
attempts: number;
|
||||
};
|
||||
/** Starred clue ids per role. The partner only ever learns how many. */
|
||||
pins?: Partial<Record<Role, string[]>>;
|
||||
}
|
||||
|
||||
export function initStory(): StoryState {
|
||||
@@ -30,10 +32,34 @@ export function initStory(): StoryState {
|
||||
wrong: {},
|
||||
lastSubmit: {},
|
||||
finale: { proposal: null, agreed: false, attempts: 0 },
|
||||
pins: {},
|
||||
};
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as StoryState;
|
||||
|
||||
/** What one hint costs in this game. */
|
||||
export const hintPenalty = (game: StoryGame): number => game.rules.hintPenaltySeconds ?? HINT_PENALTY_SECONDS;
|
||||
|
||||
/** The hard limit on elapsed + penalty time, for the games that have one. */
|
||||
export const timeLimitSeconds = (game: StoryGame): number | null =>
|
||||
game.rules.timeLimitMinutes ? Math.round(game.rules.timeLimitMinutes * 60) : null;
|
||||
|
||||
/** Called on a timer; returns true if the room changed (the time limit ran out). */
|
||||
export function tickStory(game: StoryGame, ctx: Ctx): boolean {
|
||||
const limit = timeLimitSeconds(game);
|
||||
const { room } = ctx;
|
||||
if (limit === null || room.phase !== 'playing' || room.startedAt === null) return false;
|
||||
if ((ctx.now - room.startedAt) / 1000 + room.penaltySeconds < limit) return false;
|
||||
const { finale } = game;
|
||||
ctx.finish(
|
||||
false,
|
||||
finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?",
|
||||
finale.type === 'accusation' ? finale.reveal : null,
|
||||
true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const atFinale = (game: StoryGame, s: StoryState) => s.chapter >= game.chapters.length;
|
||||
|
||||
/** The one lock that can currently be worked on, if any. */
|
||||
@@ -82,8 +108,26 @@ export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionRe
|
||||
if (tier >= open.lock.hints.length) return fail('no_more_hints', "That's every hint for this lock.");
|
||||
s.hints[lockId] = tier + 1;
|
||||
ctx.room.hintsUsed++;
|
||||
ctx.addPenalty(HINT_PENALTY_SECONDS);
|
||||
ctx.toast('all', `Hint ${tier + 1} revealed (+2:00)`);
|
||||
ctx.addPenalty(hintPenalty(game));
|
||||
ctx.toast('all', `Hint ${tier + 1} revealed (+${formatClock(hintPenalty(game))})`);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Every clue id this role can see so far. */
|
||||
export function visibleClueIds(game: StoryGame, s: StoryState, role: Role): string[] {
|
||||
const chapters = game.chapters.slice(0, Math.min(s.chapter, game.chapters.length - 1) + 1);
|
||||
const ids = chapters.flatMap((ch) => [...ch.clues[role], ...ch.clues.shared].map((c) => c.id));
|
||||
if (atFinale(game, s) && game.finale.type === 'escape') ids.push(...game.finale.clues[role].map((c) => c.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Stars or un-stars one of your own clues. */
|
||||
export function togglePin(game: StoryGame, ctx: Ctx, role: Role, clueId: string): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (!visibleClueIds(game, s, role).includes(clueId)) return fail('no_clue', "You don't have that clue.");
|
||||
const pins = (s.pins ??= {});
|
||||
const mine = pins[role] ?? [];
|
||||
pins[role] = mine.includes(clueId) ? mine.filter((id) => id !== clueId) : [...mine, clueId];
|
||||
return OK;
|
||||
}
|
||||
|
||||
|
||||
+81
-14
@@ -1,7 +1,11 @@
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import { normalizeAnswer, type Role, type SyncAnswer } from '@two-keys/shared';
|
||||
import type { SyncGame, SyncQuestion } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
export const TELEPATHY_MAX_LENGTH = 30;
|
||||
|
||||
type Round = SyncGame['rounds'][number];
|
||||
|
||||
export interface SyncState {
|
||||
round: number;
|
||||
/** 0 on the first try, 1+ on a replay. */
|
||||
@@ -9,10 +13,10 @@ export interface SyncState {
|
||||
status: 'intro' | 'question' | 'reveal' | 'summary';
|
||||
questions: SyncQuestion[];
|
||||
q: number;
|
||||
answers: Record<string, Partial<Record<Role, number>>>;
|
||||
answers: Record<string, Partial<Record<Role, SyncAnswer>>>;
|
||||
marks: boolean[];
|
||||
earned: boolean;
|
||||
/** Question ids already drawn this session, so nothing repeats. */
|
||||
/** Questions already drawn this session, as `group|id`, so nothing repeats. */
|
||||
used: string[];
|
||||
}
|
||||
|
||||
@@ -34,14 +38,38 @@ export function initSync(game: SyncGame): SyncState {
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as SyncState;
|
||||
|
||||
export const pointsForKey = (game: SyncGame, round: Round): number => round.pointsForKey ?? game.rules.pointsForKey;
|
||||
|
||||
/** Who question number `q` (0-based) of a round is about, if anyone. */
|
||||
export function subjectOf(round: Round, q: number): Role | null {
|
||||
if (round.type === 'predict') return round.subject ?? 'A';
|
||||
if (round.type === 'rank') {
|
||||
const pattern = round.subjectPattern ?? ['A', 'B'];
|
||||
return pattern[q % pattern.length];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rounds that ask the same question in the same way share a group. */
|
||||
export const groupOf = (round: Round): string => `${round.type}:${round.type === 'predict' ? (round.subject ?? 'A') : ''}`;
|
||||
|
||||
/** Draws a fresh hand of questions for the current round. */
|
||||
function deal(game: SyncGame, s: SyncState, random: () => number = Math.random) {
|
||||
const bank = game[game.rounds[s.round].type].filter((q) => !s.used.includes(q.id));
|
||||
const round = game.rounds[s.round];
|
||||
const group = groupOf(round);
|
||||
const all: SyncQuestion[] = game[round.type];
|
||||
const usedIds = new Set(s.used.map((u) => u.slice(u.lastIndexOf('|') + 1)));
|
||||
// Never-seen questions first. A spotlight question may come back only to be asked about the other partner.
|
||||
const fresh = all.filter((q) => !usedIds.has(q.id));
|
||||
const recycled = all.filter((q) => usedIds.has(q.id) && !s.used.includes(`${group}|${q.id}`));
|
||||
|
||||
const hand: SyncQuestion[] = [];
|
||||
while (hand.length < game.rules.questionsPerRound && bank.length > 0) {
|
||||
hand.push(...bank.splice(Math.floor(random() * bank.length), 1));
|
||||
for (const bank of [fresh, recycled]) {
|
||||
while (hand.length < game.rules.questionsPerRound && bank.length > 0) {
|
||||
hand.push(...bank.splice(Math.floor(random() * bank.length), 1));
|
||||
}
|
||||
}
|
||||
s.used.push(...hand.map((q) => q.id));
|
||||
s.used.push(...hand.map((q) => `${group}|${q.id}`));
|
||||
s.questions = hand;
|
||||
s.q = 0;
|
||||
s.answers = {};
|
||||
@@ -53,27 +81,65 @@ export function canReplay(game: SyncGame, s: SyncState): boolean {
|
||||
return !s.earned && s.attempt < game.rules.replaysPerRound;
|
||||
}
|
||||
|
||||
/** Telepathy: case, spaces, punctuation and a trailing S are ignored. */
|
||||
export function normalizeWord(input: string): string {
|
||||
const word = normalizeAnswer(input);
|
||||
return word.length > 1 ? word.replace(/S$/, '') : word;
|
||||
}
|
||||
|
||||
/** Checks an answer against the question's shape and returns it in stored form, or null if it doesn't fit. */
|
||||
function cleanAnswer(type: Round['type'], question: SyncQuestion, answer: unknown): SyncAnswer | null {
|
||||
if (type === 'telepathy') {
|
||||
if (typeof answer !== 'string') return null;
|
||||
const text = answer.trim();
|
||||
return text.length > TELEPATHY_MAX_LENGTH || normalizeAnswer(text) === '' ? null : text;
|
||||
}
|
||||
if (type === 'rank') {
|
||||
const count = question.items?.length ?? 0;
|
||||
if (!Array.isArray(answer) || answer.length !== count) return null;
|
||||
const valid = answer.every((i) => Number.isInteger(i) && i >= 0 && i < count);
|
||||
return valid && new Set(answer).size === count ? [...answer] : null;
|
||||
}
|
||||
const count = question.options?.length ?? 0;
|
||||
return typeof answer === 'number' && Number.isInteger(answer) && answer >= 0 && answer < count ? answer : null;
|
||||
}
|
||||
|
||||
export function isMatch(type: Round['type'], a: SyncAnswer, b: SyncAnswer): boolean {
|
||||
if (type === 'telepathy') return normalizeWord(String(a)) === normalizeWord(String(b));
|
||||
if (type === 'rank') {
|
||||
// Only the top and the bottom of the ranking have to agree.
|
||||
const [x, y] = [a as number[], b as number[]];
|
||||
return x[0] === y[0] && x[x.length - 1] === y[y.length - 1];
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
export function answerSync(
|
||||
game: SyncGame,
|
||||
ctx: Ctx,
|
||||
role: Role,
|
||||
questionId: string,
|
||||
option: number,
|
||||
answer: SyncAnswer | undefined,
|
||||
): ActionResult {
|
||||
const s = state(ctx);
|
||||
const question = s.questions[s.q];
|
||||
if (s.status !== 'question' || !question || question.id !== questionId) {
|
||||
return fail('not_open', "That question isn't open.");
|
||||
}
|
||||
if (!Number.isInteger(option) || option < 0 || option >= question.options.length) {
|
||||
return fail('bad_option', 'Pick one of the options.');
|
||||
const { type } = game.rounds[s.round];
|
||||
const clean = cleanAnswer(type, question, answer);
|
||||
if (clean === null) {
|
||||
return fail(
|
||||
'bad_option',
|
||||
type === 'telepathy' ? 'Type one word.' : type === 'rank' ? 'Put every item in order.' : 'Pick one of the options.',
|
||||
);
|
||||
}
|
||||
const answers = (s.answers[questionId] ??= {});
|
||||
if (answers[role] !== undefined) return fail('answered', "You've already locked in an answer.");
|
||||
answers[role] = option;
|
||||
answers[role] = clean;
|
||||
|
||||
if (answers.A !== undefined && answers.B !== undefined) {
|
||||
s.marks.push(answers.A === answers.B);
|
||||
s.marks.push(isMatch(type, answers.A, answers.B));
|
||||
s.status = 'reveal';
|
||||
}
|
||||
return OK;
|
||||
@@ -92,7 +158,7 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
|
||||
s.q++;
|
||||
s.status = 'question';
|
||||
} else {
|
||||
s.earned = s.marks.filter(Boolean).length >= game.rules.pointsForKey;
|
||||
s.earned = s.marks.filter(Boolean).length >= pointsForKey(game, round);
|
||||
if (s.earned) ctx.addKey(round.keyName);
|
||||
s.status = 'summary';
|
||||
}
|
||||
@@ -110,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;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export interface Ctx {
|
||||
toast(to: Role | 'all', message: string): void;
|
||||
addKey(name: string): void;
|
||||
addPenalty(seconds: number): void;
|
||||
finish(success: boolean, ending?: string | null, reveal?: string | null): void;
|
||||
finish(success: boolean, ending?: string | null, reveal?: string | null, outOfTime?: boolean): void;
|
||||
}
|
||||
|
||||
export function createCtx(room: Room, now = Date.now()): Ctx {
|
||||
@@ -26,10 +26,10 @@ export function createCtx(room: Room, now = Date.now()): Ctx {
|
||||
toast: (to, message) => void toasts.push({ to, message }),
|
||||
addKey: (name) => void room.keys.push(name),
|
||||
addPenalty: (seconds) => void (room.penaltySeconds += seconds),
|
||||
finish(success, ending = null, reveal = null) {
|
||||
finish(success, ending = null, reveal = null, outOfTime = false) {
|
||||
room.phase = 'finished';
|
||||
room.finishedAt = now;
|
||||
room.result = { success, ending, reveal };
|
||||
room.result = { success, ending, reveal, outOfTime };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +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 { timeLimitLabel, timeLimitSeconds, totalKeys } from '../engines/index';
|
||||
import type { PlayerRec, Room } from './store';
|
||||
|
||||
export function computeResult(room: Room, game: Game): GameResult | null {
|
||||
@@ -11,6 +11,7 @@ export function computeResult(room: Room, game: Game): GameResult | null {
|
||||
const elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
|
||||
const finalSeconds = elapsedSeconds + room.penaltySeconds;
|
||||
const keys = totalKeys(game);
|
||||
const outOfTime = room.result.outOfTime ?? false;
|
||||
return {
|
||||
success: room.result.success,
|
||||
elapsedSeconds,
|
||||
@@ -27,7 +28,11 @@ export function computeResult(room: Room, game: Game): GameResult | null {
|
||||
estimatedMinutes: game.estimatedMinutes,
|
||||
keysEarned: room.keys.length,
|
||||
totalKeys: keys,
|
||||
outOfTime,
|
||||
}),
|
||||
outOfTime,
|
||||
// Difficulty 5, finished without a single hint.
|
||||
nightmareSurvivors: room.result.success && game.difficulty >= 5 && room.hintsUsed === 0,
|
||||
ending: room.result.ending,
|
||||
reveal: room.result.reveal,
|
||||
};
|
||||
@@ -69,6 +74,8 @@ export function buildState(
|
||||
finishedAt: room.finishedAt,
|
||||
penaltySeconds: room.penaltySeconds,
|
||||
serverNow: now,
|
||||
limitSeconds: timeLimitSeconds(game),
|
||||
limitLabel: timeLimitLabel(game),
|
||||
},
|
||||
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
|
||||
views,
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface Room {
|
||||
wrongGuesses: number;
|
||||
/** Engine-specific state, owned by the game's engine. */
|
||||
engine: unknown;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null } | null;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null; outOfTime?: boolean } | null;
|
||||
}
|
||||
|
||||
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+105
-15
@@ -1,13 +1,32 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
import { z } from 'zod';
|
||||
import { PING_EMOJIS, otherRole, type JoinAck, type Phase, type Role } from '@two-keys/shared';
|
||||
import {
|
||||
DEFUSE_MODULES,
|
||||
PING_EMOJIS,
|
||||
otherRole,
|
||||
type JoinAck,
|
||||
type MoveDir,
|
||||
type Phase,
|
||||
type RelDir,
|
||||
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, proposeAccusation, requestHint, submitAccusation, submitLock } from '../engines/story';
|
||||
import { answerSync } from '../engines/sync';
|
||||
import {
|
||||
agreeAccusation,
|
||||
proposeAccusation,
|
||||
requestHint,
|
||||
submitAccusation,
|
||||
submitLock,
|
||||
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';
|
||||
@@ -24,20 +43,46 @@ const schemas = {
|
||||
pickRole: z.object({ role }),
|
||||
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
|
||||
hint: z.object({ lockId: z.string(), ...asRole }),
|
||||
pin: z.object({ clueId: z.string().max(100), ...asRole }),
|
||||
propose: z.object({ answers: z.record(z.string()), ...asRole }),
|
||||
defuse: z.object({
|
||||
moduleId: z.enum(['wires', 'button', 'keypad']),
|
||||
// `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') }),
|
||||
z.object({ type: z.literal('hold') }),
|
||||
z.object({ type: z.literal('release') }),
|
||||
z.object({ type: z.literal('press'), symbol: z.string() }),
|
||||
z.object({ type: z.literal('press'), symbol: z.string().optional(), color: z.string().optional() }),
|
||||
z.object({ type: z.literal('submit'), word: z.string().max(20) }),
|
||||
z.object({ type: z.literal('transmit'), frequency: z.string().max(10) }),
|
||||
]),
|
||||
...asRole,
|
||||
}),
|
||||
sync: z.object({ questionId: z.string(), optionId: z.number().int(), ...asRole }),
|
||||
move: z.object({ dir: z.enum(['n', 'e', 's', 'w']), ...asRole }),
|
||||
// One of: an option index, a typed word (telepathy), or item indexes in order (rank).
|
||||
sync: z.object({
|
||||
questionId: z.string(),
|
||||
optionId: z.number().int().optional(),
|
||||
text: z.string().max(TELEPATHY_MAX_LENGTH * 2).optional(),
|
||||
order: z.array(z.number().int()).max(12).optional(),
|
||||
...asRole,
|
||||
}),
|
||||
move: z.object({
|
||||
dir: z
|
||||
.enum(['n', 'e', 's', 'w', 'f', 'l', 'r', 'b', 'forward', 'left', 'right', 'back'])
|
||||
.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) }),
|
||||
@@ -101,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 ?? {});
|
||||
@@ -120,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,6 +250,10 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
game.engine === 'story' ? requestHint(game, ctx, data.lockId) : fail('wrong_engine', 'No hints here.'),
|
||||
);
|
||||
|
||||
on('clue:pin', schemas.pin, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'story' ? togglePin(game, ctx, role, data.clueId) : fail('wrong_engine', 'No clues here.'),
|
||||
);
|
||||
|
||||
on('chapter:continue', empty, 'playing', ({ game, ctx }) => continueGame(game, ctx));
|
||||
|
||||
on('finale:propose', schemas.propose, 'playing', ({ game, data, role, ctx }) =>
|
||||
@@ -226,7 +276,7 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
|
||||
on('sync:answer', schemas.sync, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'sync'
|
||||
? answerSync(game, ctx, role, data.questionId, data.optionId)
|
||||
? answerSync(game, ctx, role, data.questionId, data.order ?? data.text ?? data.optionId)
|
||||
: fail('wrong_engine', 'No questions here.'),
|
||||
);
|
||||
|
||||
@@ -234,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(
|
||||
@@ -265,13 +350,18 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
|
||||
});
|
||||
});
|
||||
|
||||
// The defuse clock 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?.engine !== 'defuse') continue;
|
||||
if (room.phase !== 'playing' || !game || !hasClock(game)) continue;
|
||||
const ctx = createCtx(room);
|
||||
if (tickDefuse(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,13 +7,21 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
|
||||
import { games, getGame, labyrinth, sync } from './helpers';
|
||||
|
||||
describe('content', () => {
|
||||
it('loads all 8 games with no validation errors', () => {
|
||||
it('loads all 40 games with no validation errors', () => {
|
||||
const files = loadAllFiles();
|
||||
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
|
||||
expect(files).toHaveLength(8);
|
||||
expect([...games.values()].map((g) => g.engine).sort()).toEqual(
|
||||
['defuse', 'labyrinth', 'story', 'story', 'story', 'story', 'story', 'sync'],
|
||||
);
|
||||
expect(files).toHaveLength(40);
|
||||
const engines = [...games.values()].map((g) => g.engine);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a story chapter with no clue for one player', () => {
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// The second wave of games: Overtime (defuse), Deep Cuts (sync), the Drowned Catacombs and
|
||||
// the Mirror Maze (labyrinth), and the story games with their own penalties and time limits.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Dir, MoveDir, Role } from '@two-keys/shared';
|
||||
import {
|
||||
buttonSolution,
|
||||
continueDefuse,
|
||||
defuseAction,
|
||||
edgework,
|
||||
generateDevice,
|
||||
isSolvable,
|
||||
remainingAt,
|
||||
solveKeypad,
|
||||
solveMorse,
|
||||
solvePassword,
|
||||
solveSimon,
|
||||
timerText,
|
||||
toSignal,
|
||||
wireSolution,
|
||||
type DefuseState,
|
||||
} from '../src/engines/defuse';
|
||||
import { validateGame } from '../src/content/validate';
|
||||
import { DELTAS, cellAt, findCells, gateBlocks, moveExplorer, toAbsolute, type LabyrinthState } from '../src/engines/labyrinth';
|
||||
import { continueChapter, requestHint, submitLock, tickStory, type StoryState } from '../src/engines/story';
|
||||
import { answerSync, continueSync, normalizeWord, type SyncState } from '../src/engines/sync';
|
||||
import { buildState } from '../src/rooms/state';
|
||||
import type { Room } from '../src/rooms/store';
|
||||
import { clock, games, getGame, playingRoom } from './helpers';
|
||||
|
||||
const overtime = () => getGame('tick-tock-overtime', 'defuse');
|
||||
const deepCuts = () => getGame('in-sync-deep-cuts', 'sync');
|
||||
const catacombs = () => getGame('drowned-catacombs', 'labyrinth');
|
||||
const mirrorMaze = () => getGame('mirror-maze', 'labyrinth');
|
||||
|
||||
const viewOf = (room: Room, role: Role, now = 2_000_000) => {
|
||||
const player = room.players.find((p) => p.seat === role)!;
|
||||
return JSON.parse(JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now)));
|
||||
};
|
||||
|
||||
describe('defuse: Overtime manual', () => {
|
||||
const game = overtime();
|
||||
const { fixtures, modules, ruleset } = game;
|
||||
|
||||
it.each(fixtures.wires)('wires fixture %#', (f) => {
|
||||
expect(wireSolution(ruleset, f, f.wires)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.button)('button fixture %#', (f) => {
|
||||
expect(buttonSolution(ruleset, { serial: '', ...f }, f)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.keypad)('keypad fixture %#', (f) => {
|
||||
expect(solveKeypad(f.symbols, modules.keypad.columns)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.passwords)('passwords fixture %#', (f) => {
|
||||
expect(solvePassword(f.columns, modules.passwords!.words)).toBe(f.expect);
|
||||
});
|
||||
it.each(fixtures.morse)('morse fixture %#', (f) => {
|
||||
expect(solveMorse(f.signal, modules.morse!.words)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.simon)('simon fixture %#', (f) => {
|
||||
expect(solveSimon(f.serial, f.strikes, f.flashes)).toEqual(f.expect);
|
||||
});
|
||||
|
||||
it('never generates a device the solver cannot solve, on any level', () => {
|
||||
for (let level = 0; level < game.levels.length; level++) {
|
||||
for (let seed = 1; seed <= 400; seed++) {
|
||||
const device = generateDevice(game, level, seed);
|
||||
expect(isSolvable(game, device), `level ${level} seed ${seed}`).toBe(true);
|
||||
expect(device.indicators!.length).toBeGreaterThanOrEqual(1);
|
||||
expect(device.indicators!.length).toBeLessThanOrEqual(3);
|
||||
if (device.passwords) {
|
||||
expect(device.passwords.columns).toHaveLength(5);
|
||||
for (const column of device.passwords.columns) expect(new Set(column).size).toBe(6);
|
||||
}
|
||||
if (device.simon) {
|
||||
expect(device.simon.sequence.length).toBeGreaterThanOrEqual(3);
|
||||
expect(device.simon.sequence.length).toBeLessThanOrEqual(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Solves whatever is on the device, using only the solver. */
|
||||
function solveAll(room: Room, t: ReturnType<typeof clock>) {
|
||||
const s = room.engine as DefuseState;
|
||||
const d = s.device!;
|
||||
const act = (id: string, action: Parameters<typeof defuseAction>[4], ms = 100) =>
|
||||
expect(defuseAction(game, t.ctx(ms), 'A', id, action).ok, id).toBe(true);
|
||||
|
||||
if (d.wires) act('wires', wireSolution(ruleset, edgework(d), d.wires.colors));
|
||||
if (d.keypad) for (const symbol of solveKeypad(d.keypad.symbols, modules.keypad.columns)!) act('keypad', { type: 'press', symbol });
|
||||
if (d.passwords) act('passwords', { type: 'submit', word: solvePassword(d.passwords.columns, modules.passwords!.words)! });
|
||||
if (d.morse) act('morse', { type: 'transmit', frequency: solveMorse(toSignal(d.morse.word), modules.morse!.words)!.frequency });
|
||||
if (d.simon) {
|
||||
for (let stage = 1; stage <= d.simon.sequence.length; stage++) {
|
||||
for (const color of solveSimon(d.serial, s.strikes, d.simon.sequence.slice(0, stage))) act('simon', { type: 'press', color });
|
||||
}
|
||||
}
|
||||
if (d.button) {
|
||||
const solution = buttonSolution(ruleset, edgework(d), d.button);
|
||||
if (solution.type === 'tap') act('button', { type: 'tap' });
|
||||
else {
|
||||
act('button', { type: 'hold' });
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break;
|
||||
t.ctx(500);
|
||||
}
|
||||
act('button', { type: 'release' }, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('clears all four levels for a key each and finishes the game', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (let level = 0; level < game.levels.length; level++) {
|
||||
expect(continueDefuse(game, t.ctx()).ok).toBe(true);
|
||||
solveAll(room, t);
|
||||
const s = room.engine as DefuseState;
|
||||
expect(s.strikes, `level ${level}`).toBe(0);
|
||||
expect(s.status, `level ${level}`).toBe('cleared');
|
||||
if (level < game.levels.length - 1) continueDefuse(game, t.ctx());
|
||||
}
|
||||
expect(room.keys).toEqual(game.levels.map((l) => l.keyName));
|
||||
expect(room.result?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('Say It Back: a wrong pad strikes, restarts the stage, and moves the mapping', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 2;
|
||||
continueDefuse(game, t.ctx());
|
||||
const sim = s.device!.simon!;
|
||||
const expected = (strikes: number) => solveSimon(s.device!.serial, strikes, [sim.sequence[0]])[0];
|
||||
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) });
|
||||
expect(sim).toMatchObject({ stage: 2, progress: 0 });
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) });
|
||||
expect(sim.progress).toBe(1);
|
||||
|
||||
const wrong = modules.simon!.colors.find((c) => c !== solveSimon(s.device!.serial, 0, [sim.sequence[1]])[0])!;
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: wrong });
|
||||
expect(s.strikes).toBe(1);
|
||||
expect(sim).toMatchObject({ stage: 2, progress: 0 });
|
||||
|
||||
// One strike in, the first flash now maps to a different pad.
|
||||
expect(expected(1)).not.toBe(expected(0));
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(1) });
|
||||
expect(s.strikes).toBe(1);
|
||||
expect(sim.progress).toBe(1);
|
||||
});
|
||||
|
||||
it('a wrong password or frequency is a strike, and the last level only allows two', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 3;
|
||||
continueDefuse(game, t.ctx());
|
||||
expect(viewOf(room, 'A', t.now).views.A.maxStrikes).toBe(2);
|
||||
|
||||
defuseAction(game, t.ctx(), 'A', 'passwords', { type: 'submit', word: 'ZZZZZ' });
|
||||
expect(s).toMatchObject({ strikes: 1, status: 'active' });
|
||||
expect(defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: 'loud' }).ok).toBe(false);
|
||||
expect(s.strikes).toBe(1);
|
||||
defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: '3.501' });
|
||||
expect(s).toMatchObject({ strikes: 2, status: 'exploded', failReason: 'strikes' });
|
||||
});
|
||||
|
||||
it('the Technician gets the blinking pattern and the letters, never the word or the manual', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 3;
|
||||
continueDefuse(game, t.ctx());
|
||||
const d = s.device!;
|
||||
|
||||
const tech = viewOf(room, 'A', t.now).views.A;
|
||||
const json = JSON.stringify(tech);
|
||||
expect(json).not.toContain('Defusal Manual');
|
||||
expect(tech.device.indicators).toEqual(d.indicators);
|
||||
const byId = Object.fromEntries(tech.device.modules.map((m: { id: string }) => [m.id, m]));
|
||||
expect(Object.keys(byId).sort()).toEqual([...game.levels[3].modules].sort());
|
||||
expect(byId.morse.signal).toBe(toSignal(d.morse!.word));
|
||||
expect(byId.morse).not.toHaveProperty('word');
|
||||
expect(byId.passwords.columns).toEqual(d.passwords!.columns);
|
||||
// Only the flashes of the current stage, not the whole sequence.
|
||||
expect(byId.simon.flashes).toEqual(d.simon!.sequence.slice(0, 1));
|
||||
expect(json).not.toContain('"sequence"');
|
||||
|
||||
const expert = JSON.stringify(viewOf(room, 'B', t.now).views.B);
|
||||
expect(expert).toContain('Overtime Edition');
|
||||
expect(expert).not.toContain(d.serial);
|
||||
for (const key of ['device', 'indicators', 'signal', 'columns', 'flashes']) expect(expert).not.toContain(`"${key}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync: Deep Cuts', () => {
|
||||
const game = deepCuts();
|
||||
|
||||
it('telepathy ignores case, spacing, punctuation and a trailing S', () => {
|
||||
expect(normalizeWord(' Ice-Cream ')).toBe(normalizeWord('ice cream'));
|
||||
expect(normalizeWord('Apples')).toBe(normalizeWord('apple'));
|
||||
expect(normalizeWord('kiss')).toBe(normalizeWord('KISS'));
|
||||
expect(normalizeWord('pear')).not.toBe(normalizeWord('peach'));
|
||||
});
|
||||
|
||||
/** Plays the open round; `matches` of the questions are answered in sync. */
|
||||
function playRound(room: Room, t: ReturnType<typeof clock>, matches: number, seen: string[]) {
|
||||
const s = room.engine as SyncState;
|
||||
const { type } = game.rounds[s.round];
|
||||
expect(s.questions, `round ${s.round} attempt ${s.attempt}`).toHaveLength(game.rules.questionsPerRound);
|
||||
for (let i = 0; i < game.rules.questionsPerRound; i++) {
|
||||
const q = s.questions[s.q];
|
||||
seen.push(`${s.round}:${q.id}`);
|
||||
const hit = i < matches;
|
||||
if (type === 'telepathy') {
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 2).ok).toBe(false); // no options to pick from
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, ' ').ok).toBe(false);
|
||||
answerSync(game, t.ctx(), 'A', q.id, 'Mangos');
|
||||
answerSync(game, t.ctx(), 'B', q.id, hit ? ' mango' : 'pear');
|
||||
} else if (type === 'rank') {
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2]).ok).toBe(false); // every item, once
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 1, 3]).ok).toBe(false);
|
||||
answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2, 3]);
|
||||
// Top and bottom agree (the middle doesn't have to), or only the top does.
|
||||
answerSync(game, t.ctx(), 'B', q.id, hit ? [0, 2, 1, 3] : [0, 1, 3, 2]);
|
||||
} else {
|
||||
answerSync(game, t.ctx(), 'A', q.id, 0);
|
||||
answerSync(game, t.ctx(), 'B', q.id, hit ? 0 : 1);
|
||||
}
|
||||
expect(s.status).toBe('reveal');
|
||||
expect(s.marks[i]).toBe(hit);
|
||||
continueSync(game, t.ctx());
|
||||
}
|
||||
expect(s.status).toBe('summary');
|
||||
}
|
||||
|
||||
it('scores each round type, with its own bar for the key', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as SyncState;
|
||||
const seen: string[] = [];
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
playRound(room, t, 3, seen); // telepathy needs 3
|
||||
expect(room.keys).toEqual(['Telepathy Key']);
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
continueSync(game, t.ctx());
|
||||
expect(viewOf(room, 'A').views.A).toMatchObject({ pointsForKey: 4, round: { type: 'predict', subject: 'A' }, iAmSubject: true });
|
||||
playRound(room, t, 3, seen); // spotlight needs 4: not enough
|
||||
expect(s.earned).toBe(false);
|
||||
continueSync(game, t.ctx()); // replay
|
||||
playRound(room, t, 4, seen);
|
||||
expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A']);
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
continueSync(game, t.ctx());
|
||||
playRound(room, t, 0, seen);
|
||||
continueSync(game, t.ctx());
|
||||
playRound(room, t, 0, seen); // both tries missed
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
expect(s.round).toBe(3);
|
||||
continueSync(game, t.ctx());
|
||||
// The spotlight alternates A, B, A, B, A through the ranking round.
|
||||
expect(viewOf(room, 'B').views.B).toMatchObject({ round: { type: 'rank', subject: 'A' }, iAmSubject: false });
|
||||
expect(viewOf(room, 'B').views.B.question.options).toHaveLength(4);
|
||||
playRound(room, t, 3, seen);
|
||||
expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A', 'Ranking Key']);
|
||||
continueSync(game, t.ctx());
|
||||
expect(room.phase).toBe('finished');
|
||||
|
||||
// Nothing was asked twice in the same way (a spotlight question may return for the other partner).
|
||||
const groups = seen.map((x) => x.replace(/^[12]:/, (m) => (m === '1:' ? 'A:' : 'B:')));
|
||||
expect(new Set(groups).size).toBe(groups.length);
|
||||
});
|
||||
|
||||
it("keeps a typed word private until both are in", () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueSync(game, t.ctx());
|
||||
const q = (room.engine as SyncState).questions[0];
|
||||
answerSync(game, t.ctx(), 'A', q.id, 'Banana');
|
||||
expect(JSON.stringify(viewOf(room, 'B').views.B)).not.toContain('Banana');
|
||||
expect(viewOf(room, 'B').views.B).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
|
||||
answerSync(game, t.ctx(), 'B', q.id, 'bananas');
|
||||
expect(viewOf(room, 'B').views.B.reveal).toEqual({ A: 'Banana', B: 'bananas', match: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('labyrinth: one-way gates (Drowned Catacombs)', () => {
|
||||
const game = catacombs();
|
||||
|
||||
it('lets the Explorer through a gate one way only', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
const [gate] = game.oneWay; // (5,9) → east
|
||||
Object.assign(s, { x: gate.x, y: gate.y });
|
||||
|
||||
expect(viewOf(room, 'B').views.B).toMatchObject({ compass: 'absolute', gates: [] });
|
||||
expect(moveExplorer(game, t.ctx(), 'B', gate.dir).ok).toBe(true);
|
||||
expect(s.message).toBe(game.explorerView.flavour.gate);
|
||||
|
||||
const explorer = viewOf(room, 'B').views.B;
|
||||
expect(explorer.open.w).toBe(false);
|
||||
expect(explorer.gates).toEqual(['w']);
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'w')).toMatchObject({ ok: false, code: 'gate' });
|
||||
expect(s).toMatchObject({ x: gate.x + 1, y: gate.y });
|
||||
expect(viewOf(room, 'A').views.A.oneWay).toEqual(game.oneWay);
|
||||
});
|
||||
|
||||
it('rejects a maze where a gate can trap the Explorer for good', () => {
|
||||
const sealed = structuredClone(game);
|
||||
// A dead-end pocket behind a gate, with no trap to fall out through.
|
||||
sealed.grid = ['#####', '#S.E#', '#.###', '#.KK#', '#.#K#', '#####'];
|
||||
sealed.lanterns = [
|
||||
{ x: 2, y: 3, name: 'a' },
|
||||
{ x: 3, y: 3, name: 'b' },
|
||||
{ x: 3, y: 4, name: 'c' },
|
||||
];
|
||||
sealed.glyphs = [];
|
||||
sealed.oneWay = [{ x: 1, y: 3, dir: 'e' }];
|
||||
expect(validateGame(sealed).join('\n')).toMatch(/stuck for good/);
|
||||
sealed.oneWay = [];
|
||||
expect(validateGame(sealed)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labyrinth: no compass, mirrored map (Mirror Maze)', () => {
|
||||
const game = mirrorMaze();
|
||||
|
||||
it('turns forward/left/right/back into steps from the way the Explorer faces', () => {
|
||||
expect((['f', 'r', 'b', 'l'] as const).map((rel) => toAbsolute('e', rel))).toEqual(['e', 's', 'w', 'n']);
|
||||
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
expect(s).toMatchObject({ x: 1, y: 1, facing: 'e' });
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'e')).toMatchObject({ ok: false, code: 'bad_dir' }); // no compass
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'l')).toMatchObject({ ok: false, code: 'wall' });
|
||||
|
||||
moveExplorer(game, t.ctx(), 'B', 'f');
|
||||
moveExplorer(game, t.ctx(), 'B', 'f'); // (3,1), still facing east
|
||||
expect(s).toMatchObject({ x: 3, y: 1, facing: 'e' });
|
||||
moveExplorer(game, t.ctx(), 'B', 'r'); // right of east is south
|
||||
expect(s).toMatchObject({ x: 3, y: 2, facing: 's' });
|
||||
|
||||
const explorer = viewOf(room, 'B').views.B;
|
||||
expect(explorer.compass).toBe('relative');
|
||||
expect(explorer.open).toEqual({ f: true, r: false, b: true, l: false });
|
||||
expect(JSON.stringify(explorer)).not.toMatch(/"(facing|n|e|s|w|x|y|grid)"/);
|
||||
});
|
||||
|
||||
it('serves the Navigator the map flipped left to right', () => {
|
||||
const room = playingRoom(game);
|
||||
const navigator = viewOf(room, 'A').views.A;
|
||||
const width = game.grid[0].length;
|
||||
expect(navigator.banner).toMatch(/traced from a mirror/);
|
||||
expect(navigator.grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
|
||||
expect(navigator.grid).not.toEqual(game.grid);
|
||||
game.lanterns.forEach((l, i) => {
|
||||
expect(navigator.lanterns[i]).toMatchObject({ x: width - 1 - l.x, y: l.y });
|
||||
expect(navigator.grid[l.y][navigator.lanterns[i].x]).toBe('K');
|
||||
});
|
||||
game.glyphs.forEach((g, i) => expect(navigator.glyphs[i].x).toBe(width - 1 - g.x));
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([catacombs(), mirrorMaze()].map((g) => [g.id, g] as const))('labyrinth: %s', (_id, game) => {
|
||||
it('can be completed without a single trap', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
const state = (p: { x: number; y: number }, held: boolean[]) => `${p.x},${p.y},${held.map(Number).join('')}`;
|
||||
|
||||
// Breadth-first over (cell, lanterns held): gates make the order matter.
|
||||
type Node = { x: number; y: number; held: boolean[]; path: Dir[] };
|
||||
const queue: Node[] = [{ x: s.x, y: s.y, held: game.lanterns.map(() => false), path: [] }];
|
||||
const seen = new Set([state(queue[0], queue[0].held)]);
|
||||
let route: Dir[] | null = null;
|
||||
while (queue.length && !route) {
|
||||
const node = queue.shift()!;
|
||||
for (const dir of Object.keys(DELTAS) as Dir[]) {
|
||||
const next = { x: node.x + DELTAS[dir].x, y: node.y + DELTAS[dir].y };
|
||||
const c = cellAt(game.grid, next.x, next.y);
|
||||
if (c === '#' || c === 'T' || gateBlocks(game.oneWay, node.x, node.y, dir)) continue;
|
||||
const held = node.held.map((h, i) => h || (game.lanterns[i].x === next.x && game.lanterns[i].y === next.y));
|
||||
if (seen.has(state(next, held))) continue;
|
||||
seen.add(state(next, held));
|
||||
const path = [...node.path, dir];
|
||||
if (c === 'E' && held.every(Boolean)) route = path;
|
||||
queue.push({ ...next, held, path });
|
||||
}
|
||||
}
|
||||
expect(route).not.toBeNull();
|
||||
|
||||
const relative = game.explorer.compass === 'relative';
|
||||
for (const dir of route!) {
|
||||
const move: MoveDir = relative ? (['f', 'r', 'b', 'l'] as const).find((rel) => toAbsolute(s.facing!, rel) === dir)! : dir;
|
||||
expect(moveExplorer(game, t.ctx(), 'B', move).ok).toBe(true);
|
||||
}
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(s.falls).toBe(0);
|
||||
expect(room.keys).toHaveLength(game.lanternsRequired);
|
||||
expect(findCells(game.grid, 'K')).toHaveLength(game.lanternsRequired);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story: per-game hint penalties and time limits', () => {
|
||||
it('charges the hint penalty the game sets', () => {
|
||||
const game = getGame('kestrel-heist', 'story');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const ctx = t.ctx();
|
||||
requestHint(game, ctx, game.chapters[0].lock.id);
|
||||
expect(room.penaltySeconds).toBe(240);
|
||||
expect(ctx.toasts[0].message).toBe('Hint 1 revealed (+4:00)');
|
||||
expect(viewOf(room, 'A').views.A.chapters[0].lock.hintPenaltySeconds).toBe(240);
|
||||
|
||||
const classic = getGame('vineyard-hollow', 'story');
|
||||
const other = playingRoom(classic);
|
||||
requestHint(classic, clock(other).ctx(), classic.chapters[0].lock.id);
|
||||
expect(other.penaltySeconds).toBe(120);
|
||||
});
|
||||
|
||||
it('ends the game when the clock (elapsed + penalties) passes the limit', () => {
|
||||
const game = getGame('deep-lab-nereid', 'story');
|
||||
const limit = game.rules.timeLimitMinutes! * 60;
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
expect(viewOf(room, 'A').timer).toMatchObject({ limitSeconds: limit, limitLabel: 'Water level' });
|
||||
|
||||
expect(tickStory(game, t.ctx((limit - 100) * 1000))).toBe(false);
|
||||
requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over
|
||||
expect(tickStory(game, t.ctx())).toBe(true);
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null, outOfTime: true });
|
||||
expect(tickStory(game, t.ctx())).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the reveal when an accusation game times out, and leaves untimed games alone', () => {
|
||||
const game = getGame('kalahari-expedition', 'story');
|
||||
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true);
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: true });
|
||||
|
||||
const untimed = getGame('vineyard-hollow', 'story');
|
||||
const calm = playingRoom(untimed);
|
||||
expect(tickStory(untimed, clock(calm).ctx(24 * 3600_000))).toBe(false);
|
||||
expect(viewOf(calm, 'A').timer).toMatchObject({ limitSeconds: null, limitLabel: null });
|
||||
});
|
||||
|
||||
it('plays a timed escape room through to the end inside the limit', () => {
|
||||
const game = getGame('deep-lab-nereid', 'story');
|
||||
if (game.finale.type !== 'escape') throw new Error('expected escape');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toMatchObject({ correct: true });
|
||||
continueChapter(game, t.ctx());
|
||||
}
|
||||
expect((room.engine as StoryState).chapter).toBe(game.chapters.length);
|
||||
expect(tickStory(game, t.ctx())).toBe(false);
|
||||
submitLock(game, t.ctx(), game.finale.lock.id, 'surface');
|
||||
expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null, outOfTime: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
// GAMES-NEW.md requirements for games 9–18 that the other suites don't already cover:
|
||||
// sanitized payloads for every new game, Evidence Locker pins, "Out of time" and
|
||||
// Nightmare Survivors results, one-shot accusations, and the extra content checks.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { Game } from '../src/content/schemas';
|
||||
import { validateGame } from '../src/content/validate';
|
||||
import { continueDefuse, type DefuseState } from '../src/engines/defuse';
|
||||
import type { LabyrinthState } from '../src/engines/labyrinth';
|
||||
import {
|
||||
agreeAccusation,
|
||||
continueChapter,
|
||||
proposeAccusation,
|
||||
submitAccusation,
|
||||
submitLock,
|
||||
tickStory,
|
||||
togglePin,
|
||||
} from '../src/engines/story';
|
||||
import { TELEPATHY_MAX_LENGTH, answerSync, continueSync, type SyncState } from '../src/engines/sync';
|
||||
import { buildState } from '../src/rooms/state';
|
||||
import type { Room } from '../src/rooms/store';
|
||||
import { clock, games, getGame, playingRoom } from './helpers';
|
||||
|
||||
const NEW_GAMES = [
|
||||
'dead-air',
|
||||
'masquerade-montague',
|
||||
'kalahari-expedition',
|
||||
'alchemists-vault',
|
||||
'deep-lab-nereid',
|
||||
'kestrel-heist',
|
||||
'tick-tock-overtime',
|
||||
'in-sync-deep-cuts',
|
||||
'drowned-catacombs',
|
||||
'mirror-maze',
|
||||
];
|
||||
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'seed', 'sequence', 'facing', 'word'];
|
||||
|
||||
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 json = (room: Room, role: Role, now?: number) => JSON.stringify(stateOf(room, role, now));
|
||||
|
||||
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 each new 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') {
|
||||
(room.engine as DefuseState).level = game.levels.length - 1; // every module at once
|
||||
continueDefuse(game, t.ctx());
|
||||
}
|
||||
if (game.engine === 'sync') {
|
||||
continueSync(game, t.ctx());
|
||||
const q = (room.engine as SyncState).questions[0];
|
||||
answerSync(game, t.ctx(), 'A', q.id, 'Lighthouse');
|
||||
}
|
||||
return { room, t };
|
||||
}
|
||||
|
||||
describe.each(NEW_GAMES.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
|
||||
it('never carry answers, fixtures or solver state for either role', () => {
|
||||
expect(game).toBeDefined();
|
||||
const { room, t } = started(game);
|
||||
for (const role of ['A', 'B'] as const) {
|
||||
const keys = keysOf(JSON.parse(json(room, role, t.now)));
|
||||
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (game.engine === 'defuse') {
|
||||
it('keeps the device from the Expert and the manual from the Technician', () => {
|
||||
const { room, t } = started(game);
|
||||
const tech = json(room, 'A', t.now);
|
||||
const expert = json(room, 'B', t.now);
|
||||
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
|
||||
for (const key of ['device', 'indicators', 'serial', 'modules']) expect(keysOf(JSON.parse(expert))).not.toContain(key);
|
||||
// Fixture answers don't ride along in any form.
|
||||
for (const f of game.fixtures.passwords) expect(expert + tech).not.toContain(JSON.stringify(f.columns));
|
||||
});
|
||||
}
|
||||
|
||||
if (game.engine === 'sync') {
|
||||
it('keeps a typed word and a ranking private until both are in', () => {
|
||||
const { room } = started(game);
|
||||
expect(json(room, 'B')).not.toContain('Lighthouse');
|
||||
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerAnswered: true, reveal: null });
|
||||
});
|
||||
}
|
||||
|
||||
if (game.engine === 'labyrinth') {
|
||||
it("never sends the Explorer the map or where they are, or the Navigator the Explorer's position", () => {
|
||||
const { room } = started(game);
|
||||
const s = room.engine as LabyrinthState;
|
||||
const explorer = JSON.parse(json(room, 'B')).views.B;
|
||||
for (const key of ['grid', 'glyphs', 'lanterns', 'oneWay', 'x', 'y', 'facing']) expect(keysOf(explorer)).not.toContain(key);
|
||||
// With no compass, not even a compass letter.
|
||||
if (game.explorer.compass === 'relative') expect(JSON.stringify(explorer)).not.toMatch(/"[nesw]"/);
|
||||
const navigator = JSON.parse(json(room, 'A')).views.A;
|
||||
const { glyphs: _g, lanterns: _l, oneWay: _o, grid, ...rest } = navigator;
|
||||
expect(keysOf(rest)).not.toContain('x');
|
||||
if (game.navigatorView.mapTransform === 'mirror-x') {
|
||||
// Only the mirrored map is ever served.
|
||||
expect(grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
|
||||
expect(grid).not.toEqual(game.grid);
|
||||
} else {
|
||||
expect(grid).toEqual(game.grid);
|
||||
}
|
||||
expect(s.x).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('story: Evidence Locker pins', () => {
|
||||
const game = getGame('kalahari-expedition', 'story');
|
||||
|
||||
it('stars your own clues; your partner only sees how many', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const [mine] = game.chapters[0].clues.A;
|
||||
const [theirs] = game.chapters[0].clues.B;
|
||||
|
||||
expect(togglePin(game, t.ctx(), 'A', theirs.id).ok).toBe(false);
|
||||
expect(togglePin(game, t.ctx(), 'A', game.chapters[1].clues.A[0].id).ok).toBe(false); // not dealt yet
|
||||
expect(togglePin(game, t.ctx(), 'A', mine.id).ok).toBe(true);
|
||||
|
||||
expect(stateOf(room, 'A').views.A).toMatchObject({ pins: [mine.id], partnerPins: 0 });
|
||||
const b = stateOf(room, 'B').views.B;
|
||||
expect(b).toMatchObject({ pins: [], partnerPins: 1 });
|
||||
expect(JSON.stringify(b)).not.toContain(mine.id);
|
||||
|
||||
togglePin(game, t.ctx(), 'A', mine.id);
|
||||
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerPins: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('story: one-shot, four-question accusations', () => {
|
||||
const game = getGame('masquerade-montague', 'story');
|
||||
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
const finale = game.finale;
|
||||
|
||||
function atFinale() {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
continueChapter(game, t.ctx());
|
||||
}
|
||||
return { room, t };
|
||||
}
|
||||
|
||||
it('has four questions and a single attempt', () => {
|
||||
expect(finale.questions).toHaveLength(4);
|
||||
expect(finale.maxAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('a wrong accusation goes straight to the fail ending and the reveal', () => {
|
||||
const { room, t } = atFinale();
|
||||
const wrong = Object.fromEntries(finale.questions.map((q) => [q.id, q.options.find((o) => o.id !== q.answer)!.id]));
|
||||
proposeAccusation(game, t.ctx(), 'A', wrong);
|
||||
agreeAccusation(game, t.ctx(), 'B');
|
||||
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: false });
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toMatchObject({ success: false, ending: finale.failEnding, reveal: finale.reveal });
|
||||
});
|
||||
|
||||
it('the right one wins', () => {
|
||||
const { room, t } = atFinale();
|
||||
proposeAccusation(game, t.ctx(), 'B', Object.fromEntries(finale.questions.map((q) => [q.id, q.answer])));
|
||||
agreeAccusation(game, t.ctx(), 'A');
|
||||
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
|
||||
expect(room.result?.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('results', () => {
|
||||
it('a time limit running out shows "Out of time", not a rank', () => {
|
||||
const game = getGame('kestrel-heist', 'story');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000));
|
||||
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);
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
continueChapter(game, t.ctx());
|
||||
}
|
||||
if (game.finale.type !== 'escape') throw new Error('expected escape');
|
||||
submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0]);
|
||||
expect(stateOf(room, 'A').result).toMatchObject({ success: true, outOfTime: false, nightmareSurvivors: true });
|
||||
|
||||
// Not for an easier game.
|
||||
const easy = getGame('alchemists-vault', 'story');
|
||||
const other = playingRoom(easy);
|
||||
const t2 = clock(other);
|
||||
for (const ch of easy.chapters) {
|
||||
submitLock(easy, t2.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
continueChapter(easy, t2.ctx());
|
||||
}
|
||||
if (easy.finale.type !== 'escape') throw new Error('expected escape');
|
||||
submitLock(easy, t2.ctx(), easy.finale.lock.id, easy.finale.lock.answers[0]);
|
||||
expect(stateOf(other, 'A').result).toMatchObject({ success: true, nightmareSurvivors: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync: telepathy input', () => {
|
||||
it(`rejects empty answers and anything over ${TELEPATHY_MAX_LENGTH} characters`, () => {
|
||||
const game = getGame('in-sync-deep-cuts', 'sync');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueSync(game, t.ctx());
|
||||
const q = (room.engine as SyncState).questions[0];
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, '').ok).toBe(false);
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH + 1)).ok).toBe(false);
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content validation additions', () => {
|
||||
const clone = <T>(game: T): T => structuredClone(game);
|
||||
|
||||
it('rejects a rank question without exactly 4 items', () => {
|
||||
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
|
||||
game.rank[0].items = game.rank[0].items.slice(0, 3);
|
||||
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';
|
||||
game.modules.morse!.words.HUG = '3.506';
|
||||
const errors = validateGame(game).join('\n');
|
||||
expect(errors).toMatch(/password word list/);
|
||||
expect(errors).toMatch(/morse word "HUG" should be on 3.505/);
|
||||
});
|
||||
|
||||
it('rejects a password fixture column without 6 letters', () => {
|
||||
const game = clone(getGame('tick-tock-overtime', 'defuse'));
|
||||
game.fixtures.passwords[0].columns[0] = 'XAPUD';
|
||||
expect(validateGame(game).join('\n')).toMatch(/exactly 6 letters/);
|
||||
});
|
||||
|
||||
it('rejects a glyph on a trap', () => {
|
||||
const game = clone(getGame('mirror-maze', 'labyrinth'));
|
||||
const [trap] = game.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
|
||||
game.glyphs[0] = { ...game.glyphs[0], ...trap };
|
||||
expect(validateGame(game).join('\n')).toMatch(/is on a trap/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,6 @@ describe('story rules', () => {
|
||||
}
|
||||
}
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: false });
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅'] as const;
|
||||
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅', 'Out of time'] as const;
|
||||
export type Rank = (typeof RANKS)[number];
|
||||
|
||||
export interface RankInput {
|
||||
@@ -8,11 +8,14 @@ export interface RankInput {
|
||||
estimatedMinutes: number;
|
||||
keysEarned: number;
|
||||
totalKeys: number;
|
||||
/** A time limit ran out: no rank badge, just "Out of time". */
|
||||
outOfTime?: boolean;
|
||||
}
|
||||
|
||||
export function rankFor(r: RankInput): Rank {
|
||||
const estimate = r.estimatedMinutes * 60;
|
||||
const allKeys = r.keysEarned >= r.totalKeys;
|
||||
if (r.outOfTime) return 'Out of time';
|
||||
if (!r.success) return 'Love Is Patient 😅';
|
||||
if (allKeys && r.hintsUsed === 0 && r.finalSeconds <= estimate) return 'Soulmate Sleuths';
|
||||
if (allKeys && r.hintsUsed <= 3 && r.finalSeconds <= estimate * 1.5) return 'Dream Team';
|
||||
|
||||
+207
-9
@@ -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 {
|
||||
@@ -35,6 +35,8 @@ export interface GameSummary {
|
||||
theme: Theme;
|
||||
roles: Record<Role, RoleInfo>;
|
||||
totalKeys: number;
|
||||
/** Hard time limit, for the games that have one. */
|
||||
timeLimitMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface GameMeta extends GameSummary {
|
||||
@@ -83,6 +85,10 @@ export interface LockView {
|
||||
/** Revealed hint tiers only. */
|
||||
hints: string[];
|
||||
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;
|
||||
@@ -130,6 +136,10 @@ export interface StoryView {
|
||||
totalChapters: number;
|
||||
awaitingContinue: boolean;
|
||||
finale: AccusationView | EscapeFinaleView | null;
|
||||
/** Clue ids you starred. */
|
||||
pins: string[];
|
||||
/** How many clues your partner starred (never which). */
|
||||
partnerPins: number;
|
||||
}
|
||||
|
||||
// ---------- Defuse engine ----------
|
||||
@@ -146,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[];
|
||||
}
|
||||
@@ -162,7 +176,42 @@ export interface KeypadModuleView {
|
||||
solved: boolean;
|
||||
symbols: { id: string; label: string; pressed: boolean }[];
|
||||
}
|
||||
export type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView;
|
||||
export interface PasswordsModuleView {
|
||||
id: 'passwords';
|
||||
solved: boolean;
|
||||
/** The letters each column cycles through, top to bottom. */
|
||||
columns: string[];
|
||||
}
|
||||
export interface MorseModuleView {
|
||||
id: 'morse';
|
||||
solved: boolean;
|
||||
/** Same format as a signal clue. Never print it as text. */
|
||||
signal: string;
|
||||
/** Dial range in kHz (3500 = 3.500 MHz). */
|
||||
minKhz: number;
|
||||
maxKhz: number;
|
||||
}
|
||||
export interface SimonModuleView {
|
||||
id: 'simon';
|
||||
solved: boolean;
|
||||
colors: string[];
|
||||
/** The flashes of the current stage. */
|
||||
flashes: string[];
|
||||
stage: number;
|
||||
stages: number;
|
||||
/** Presses accepted so far in this stage. */
|
||||
entered: number;
|
||||
}
|
||||
export type DefuseModuleView =
|
||||
| WiresModuleView
|
||||
| ButtonModuleView
|
||||
| KeypadModuleView
|
||||
| PasswordsModuleView
|
||||
| MorseModuleView
|
||||
| SimonModuleView;
|
||||
|
||||
export const DEFUSE_MODULES = ['wires', 'button', 'keypad', 'passwords', 'morse', 'simon'] as const;
|
||||
export type DefuseModuleId = (typeof DEFUSE_MODULES)[number];
|
||||
|
||||
interface DefuseCommon {
|
||||
engine: 'defuse';
|
||||
@@ -172,10 +221,21 @@ 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';
|
||||
device: { serial: string; batteries: number; modules: DefuseModuleView[] } | null;
|
||||
device: {
|
||||
serial: string;
|
||||
batteries: number;
|
||||
indicators: { label: string; lit: boolean }[];
|
||||
modules: DefuseModuleView[];
|
||||
} | null;
|
||||
}
|
||||
export interface ExpertView extends DefuseCommon {
|
||||
seat: 'expert';
|
||||
@@ -188,10 +248,18 @@ export type DefuseAction =
|
||||
| { type: 'tap' }
|
||||
| { type: 'hold' }
|
||||
| { type: 'release' }
|
||||
| { type: 'press'; symbol: string };
|
||||
| { type: 'press'; symbol?: string; color?: string }
|
||||
| { type: 'submit'; word: string }
|
||||
| { type: 'transmit'; frequency: string };
|
||||
|
||||
// ---------- Sync engine ----------
|
||||
|
||||
export const SYNC_ROUND_TYPES = ['match', 'predict', 'telepathy', 'rank'] as const;
|
||||
export type SyncRoundType = (typeof SYNC_ROUND_TYPES)[number];
|
||||
|
||||
/** An option index (match/predict), a typed word (telepathy), or item indexes, best first (rank). */
|
||||
export type SyncAnswer = number | string | number[];
|
||||
|
||||
export interface SyncView {
|
||||
engine: 'sync';
|
||||
status: 'intro' | 'question' | 'reveal' | 'summary';
|
||||
@@ -199,8 +267,9 @@ export interface SyncView {
|
||||
index: number;
|
||||
total: number;
|
||||
title: string;
|
||||
type: 'match' | 'predict';
|
||||
type: SyncRoundType;
|
||||
blurb: string;
|
||||
/** Who the current question is about (predict and rank rounds). */
|
||||
subject: Role | null;
|
||||
};
|
||||
isReplay: boolean;
|
||||
@@ -208,12 +277,13 @@ export interface SyncView {
|
||||
questionsPerRound: number;
|
||||
pointsForKey: number;
|
||||
points: number;
|
||||
/** `options` holds the items to order in a rank round, and is empty for telepathy. */
|
||||
question: { id: string; text: string; options: string[] } | null;
|
||||
iAmSubject: boolean;
|
||||
myAnswer: number | null;
|
||||
myAnswer: SyncAnswer | null;
|
||||
partnerAnswered: boolean;
|
||||
/** Both answers, only once both are in. */
|
||||
reveal: { A: number; B: number; match: boolean } | null;
|
||||
reveal: { A: SyncAnswer; B: SyncAnswer; match: boolean } | null;
|
||||
/** Match / miss for each question revealed so far this round. */
|
||||
marks: boolean[];
|
||||
summary: { earned: boolean; canReplay: boolean; keyName: string } | null;
|
||||
@@ -222,18 +292,27 @@ export interface SyncView {
|
||||
// ---------- Labyrinth engine ----------
|
||||
|
||||
export type Dir = 'n' | 'e' | 's' | 'w';
|
||||
/** Forward / left / right / back, for mazes where the Explorer has no compass. */
|
||||
export type RelDir = 'f' | 'l' | 'r' | 'b';
|
||||
export type MoveDir = Dir | RelDir;
|
||||
|
||||
export interface ExplorerView {
|
||||
engine: 'labyrinth';
|
||||
seat: 'explorer';
|
||||
glyphLabel: string | null;
|
||||
open: Record<Dir, boolean>;
|
||||
compass: 'absolute' | 'relative';
|
||||
/** Keyed by n/e/s/w, or by f/l/r/b when the compass is relative. */
|
||||
open: Partial<Record<MoveDir, boolean>>;
|
||||
/** Directions blocked by a one-way gate rather than a wall. */
|
||||
gates: MoveDir[];
|
||||
keysCarried: number;
|
||||
totalKeys: number;
|
||||
atExit: boolean;
|
||||
message: string;
|
||||
moves: number;
|
||||
falls: number;
|
||||
/** Items carried (picked up on entry, kept after traps). */
|
||||
inventory: string[];
|
||||
}
|
||||
|
||||
export interface NavigatorView {
|
||||
@@ -242,6 +321,11 @@ export interface NavigatorView {
|
||||
grid: string[];
|
||||
glyphs: { x: number; y: number; label: string }[];
|
||||
lanterns: { x: number; y: number; name: string; collected: boolean }[];
|
||||
/** One-way gates: passable from (x, y) in `dir` only. */
|
||||
oneWay: { x: number; y: number; dir: Dir }[];
|
||||
items: { x: number; y: number; name: string; collected: boolean }[];
|
||||
doors: { x: number; y: number; name: string }[];
|
||||
banner: string | null;
|
||||
keysCarried: number;
|
||||
totalKeys: number;
|
||||
moves: number | null;
|
||||
@@ -249,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 ----------
|
||||
|
||||
@@ -263,6 +456,8 @@ export interface GameResult {
|
||||
keysEarned: number;
|
||||
totalKeys: number;
|
||||
rank: string;
|
||||
outOfTime: boolean;
|
||||
nightmareSurvivors: boolean;
|
||||
ending: string | null;
|
||||
reveal: string | null;
|
||||
}
|
||||
@@ -281,6 +476,9 @@ export interface RoomState {
|
||||
finishedAt: number | null;
|
||||
penaltySeconds: number;
|
||||
serverNow: number;
|
||||
/** Hard limit on elapsed + penalty time, if the game has one. */
|
||||
limitSeconds: number | null;
|
||||
limitLabel: string | null;
|
||||
};
|
||||
stats: { hintsUsed: number; wrongGuesses: number };
|
||||
/** One view per role this socket may see: own role, or both in couch mode. */
|
||||
|
||||
Reference in New Issue
Block a user