4 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 db2653c1ab V1.2.1
Point the Overtime fixtures comment at the real solver: GAMES-NEW.md Appendix A
and its TypeScript port in server/src/engines/defuse.ts, not the missing
tools/reference/overtime_solver.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:48:20 +02:00
joshuaandClaude Opus 5.5 6669be11e5 V1.2.0
Bring games 9-18 in line with GAMES-NEW.md:
- Library sorts easiest first, filters by category and difficulty; difficulty 5
  shows a Nightmare chip, and a no-hint finish earns Nightmare Survivors.
- Time limits end on "Out of time" (no rank); Deep Lab Nereid's countdown is a
  rising water gauge behind the header.
- Evidence Locker search, chapter filter and starred clues (partner sees a count).
- One-shot accusations ask "One shot. Are you both sure?" first.
- Symbol font fallback, tight monospace blocks for grids/keypads/SQL.
- Rank rounds reorder by drag or up/down buttons; reveal highlights #1 and #4.
- Telepathy capped at 30 characters; maze:move accepts forward/left/right/back;
  gate buttons read "Gate (won't open from this side)".
- Overtime solver tables checked against Appendix A; keypad generator re-rolls.
- More content checks (rank items, password fixtures, glyphs/gates on traps) and
  sanitize tests for all ten new games.
- GAMES.md carries the full spoiler guide for 9-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:44:28 +02:00
joshuaandClaude Opus 5.5 bbe44e84c4 V1.1.0
Add ten new games (18 in total) and the engine features they need.

Story: Dead Air, The Masquerade at Montague House, The Kalahari Expedition,
The Alchemist's Vault, Submerged: Deep Lab Nereid, The Kestrel Bank Job.
Games can now set their own hint penalty and a hard time limit, shown as a
countdown; running out ends the game with the fail ending.

Defuse: Tick Tock: Overtime. Manuals are now rulesets, so each game has its
own solver. Adds indicator lights, per-level strike limits, and the
Passwords, Morse Light and Say It Back modules.

Sync: In Sync: Deep Cuts. Adds telepathy (typed word) and ranking rounds,
and a per-round bar for the key.

Labyrinth: The Drowned Catacombs and The Mirror Maze. Adds one-way gates, a
relative (no compass) Explorer, a mirrored Navigator map, and mazes with a
lantern count other than three. Validation now rejects a maze where a gate
can leave the Explorer stuck.

Difficulty runs from 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:34:11 +02:00
joshuaandClaude Opus 5.5 62245ca67a Make game time estimates and difficulties realistic
Estimates were 2-8x longer than actual playthroughs. Base them on
recorded best times, rounded up to a multiple of 5 minutes, and drop
difficulties to 1-2 keys since no game warrants 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:41:41 +02:00
60 changed files with 5008 additions and 298 deletions
+44 -17
View File
@@ -9,7 +9,7 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
**Core rule of every game: Player A and Player B never see the same clue set.** Every lock needs info from both.
### Game library (8 games, 4 engines)
### Game library (18 games, 4 engines)
| # | Game | Category | Engine | File |
|---|------|----------|--------|------|
@@ -21,6 +21,16 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
| 6 | Tick Tock Together | Co-op Defuse | `defuse` | `tick-tock-together.yaml` |
| 7 | In Sync | Couples Connection | `sync` | `in-sync.yaml` |
| 8 | Labyrinth of Lanterns | Co-op Navigation | `labyrinth` | `labyrinth-of-lanterns.yaml` |
| 9 | Dead Air | Murder Mystery | `story` | `dead-air.yaml` |
| 10 | The Masquerade at Montague House | Murder Mystery | `story` | `masquerade-montague.yaml` |
| 11 | The Kalahari Expedition | Murder Mystery | `story` | `kalahari-expedition.yaml` |
| 12 | The Alchemist's Vault | Escape Room | `story` | `alchemists-vault.yaml` |
| 13 | Submerged: Deep Lab Nereid | Escape Room | `story` | `deep-lab-nereid.yaml` |
| 14 | The Kestrel Bank Job | Escape Room | `story` | `kestrel-heist.yaml` |
| 15 | Tick Tock: Overtime | Co-op Defuse | `defuse` | `tick-tock-overtime.yaml` |
| 16 | In Sync: Deep Cuts | Couples Connection | `sync` | `in-sync-deep-cuts.yaml` |
| 17 | The Drowned Catacombs | Co-op Navigation | `labyrinth` | `drowned-catacombs.yaml` |
| 18 | The Mirror Maze | Co-op Navigation | `labyrinth` | `mirror-maze.yaml` |
## 2. Tech stack
@@ -90,16 +100,20 @@ engine: story | defuse | sync | labyrinth
category: murder-mystery | escape-room | co-op
title, tagline: string
estimatedMinutes: number
difficulty: 1-3
difficulty: 1-5
players: { min: 2, max: 2 }
theme: { primary, accent, background, headingFont }
roles: { A: {name, blurb}, B: {name, blurb} }
intro: markdown
```
### `story` engine (games 1–5)
### `story` engine (games 1–5, 9–14)
```yaml
rules: # optional
hintPenaltySeconds: number # default 120
timeLimitMinutes: number # hard limit on elapsed + penalties; running out ends the game
timeLimitLabel: string # what the countdown is called, e.g. "Water level"
suspects: # optional (murder mysteries)
- { id, name, role, description }
chapters:
@@ -129,6 +143,7 @@ finale:
lock: Lock # same shape as a chapter lock (no keyName needed)
# both:
successEnding: markdown
failEnding: markdown # optional on escape: shown when the time limit runs out
```
Use a zod discriminated union on `finale.type`.
@@ -142,7 +157,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.
@@ -163,8 +179,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 +190,8 @@ See each YAML for its schema. Summaries in §6.
- Implement the fixtures in the YAML as unit tests. They must pass.
- Strikes: 3 per level. Timer per level. Clock speeds up 25% after each strike.
- Each level cleared = 1 key. Fail a level → retry with a new seed (no key lost).
- **Rulesets:** each manual's rules are code in `engines/defuse.ts`, picked by the YAML's `ruleset` (`classic` = Tick Tock Together, `overtime` = Tick Tock: Overtime). A new manual needs a new ruleset.
- **Overtime** adds indicator lights, a per-level `strikesOverride`, and three modules: `passwords` (cycle five letter columns, submit the one listed word they spell), `morse` (read a blinking light, tune 3.500–3.600 MHz, transmit) and `simon` (press the mapped colour for every flash; the mapping moves with the strike count). The Technician gets the Morse pattern only as a light, and only the flashes of the current stage.
### 6.3 Signal clues (game 5)
`signal: "./-.-./..../---"`: `.` = 250ms on, `-` = 750ms on, 250ms gap between symbols, `/` = 1000ms gap between letters, 3000ms pause, then loop. Render as a glowing circle. Add a "Replay" button. Never render the dot/dash text to that player (they must read the light).
@@ -182,7 +201,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 +212,19 @@ See each YAML for its schema. Summaries in §6.
- Traps are invisible to the Explorer. Stepping on one sends them back to Start (keys kept) and adds 1:00 penalty.
- Each lantern key collected = key. Exit only opens with all 3.
- Server validates every move.
- `lanternsRequired` sets how many lanterns the maze has (default 3).
- `oneWay` gates (Drowned Catacombs): passable from `{x, y}` in `dir` only. The Navigator sees an arrow; the Explorer sees the way back as a gate, not a wall.
- `explorer.compass: relative` (Mirror Maze): the Explorer moves forward/left/right/back from the way they face (their last step), and never sees N/E/S/W.
- `navigatorView.mapTransform: mirror-x` (Mirror Maze): the Navigator's map, and everything on it, is served flipped left to right.
## 7. Socket events
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), `notepad:update {text}`, `ping {emoji}`
Server → client:
`room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`,
@@ -209,14 +235,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 🔑🔑🔑).
- 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.
- **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 +252,10 @@ 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; for `overtime`, the keypad columns, password words and Morse frequencies match the solver's tables (`OVERTIME_TABLES`) and every password fixture column has exactly `lettersPerColumn` letters
- Labyrinth: grid rows equal length; exactly one S and E; exactly `lanternsRequired` K; every K and E reachable from S **without stepping on a trap** (BFS, respecting one-way gates); no cell where a gate leaves the Explorer stuck for good; glyphs and gates never on walls or traps; every `lanterns` entry sits on a K
- Sync: ≥15 questions per round type in use; every rank question has exactly 4 items
Run it in CI and as a pre-start step.
## 11. Build phases (do in order, commit after each)
@@ -242,7 +269,7 @@ Run it in CI and as a pre-start step.
## 12. Acceptance criteria
- [ ] Two phones can play any of the 8 games start to finish
- [ ] Two phones can play any of the 18 games start to finish
- [ ] Player A cannot find B's clues or any answer via DevTools / network tab
- [ ] Refreshing mid-game rejoins the same role and progress
- [ ] All answers listed in the YAML are accepted; normalizer handles spaces/case/punctuation
+144 -8
View File
@@ -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,139 @@ 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#
###############
```
+1 -1
View File
@@ -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
View File
@@ -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+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
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/client",
"version": "1.0.1",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
+32 -11
View File
@@ -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>
);
+15 -3
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
import { getBest, saveSession } from '../lib/session';
import { request } from '../lib/socket';
import { CATEGORY_LABEL, difficultyKeys, themeVars } from '../lib/theme';
import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme';
import { pushToast } from '../lib/toasts';
import { RoleBadge, Sheet } from './ui';
@@ -39,13 +39,25 @@ export function GameCard({ game, className = '' }: { game: GameSummary; classNam
<div className="flex flex-wrap items-center gap-2">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</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>
+5 -4
View File
@@ -1,15 +1,16 @@
import { useState } from 'react';
import type { LockView } from '@two-keys/shared';
import { formatClock, type LockView } from '@two-keys/shared';
import type { Send } from '../lib/socket';
import { Sheet } from './ui';
/** Hints unlock in order, cost +2:00 each, and both players see them. */
/** Hints unlock in order, cost penalty time each, and both players see them. */
export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
const [open, setOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const used = lock.hints.length;
const left = lock.hintsTotal - used;
const nextIsLast = used === lock.hintsTotal - 1;
const cost = `+${formatClock(lock.hintPenaltySeconds)}`;
const reveal = () => {
setConfirming(false);
@@ -29,7 +30,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
<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
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">{cost}</strong> to
your time, and you both see it.
</p>
<ol className="space-y-2">
@@ -53,7 +54,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>
+46 -1
View File
@@ -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>
);
}
+170 -12
View File
@@ -1,12 +1,17 @@
import { useEffect, useState } from 'react';
import type {
ButtonModuleView,
DefuseAction,
DefuseView,
KeypadModuleView,
MorseModuleView,
PasswordsModuleView,
RoomState,
SimonModuleView,
TechnicianView,
WiresModuleView,
} 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 +30,7 @@ const SWATCH: Record<string, string> = {
yellow: '#facc15',
green: '#22c55e',
white: '#f5f5f4',
black: '#0c0a09',
};
const darkText = (color: string) => color === 'yellow' || color === 'white';
@@ -98,7 +104,7 @@ function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction
{module.wires.map((color, i) => (
<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 +193,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 +337,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.id} module={m} act={act(m.id)} />;
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>
);
}
@@ -250,7 +408,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')}>
+62 -18
View File
@@ -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,21 @@ 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]));
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">
@@ -61,6 +69,11 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
else if (cell === 'E') { content = '🚪'; tone = 'bg-emerald-950'; }
else if (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; }
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,6 +106,9 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
{l.collected && <span className="no-underline"> ✓</span>}
</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 text-stone-400"><span className="w-6 text-center" aria-hidden>▪</span>Most floor is bare stone</li>
@@ -104,30 +120,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">
@@ -151,27 +179,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>
);
+126 -29
View File
@@ -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
View File
@@ -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"
+21 -3
View File
@@ -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% {
+7 -1
View File
@@ -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;
}
+3 -1
View File
@@ -42,4 +42,6 @@ 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));
+38 -12
View File
@@ -1,16 +1,37 @@
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import type { Category } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { CATEGORY_LABEL } from '../lib/theme';
import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY } from '../lib/theme';
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
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 [level, setLevel] = useState<number | null>(null);
// Easiest first; ties keep the library's own order.
const shown = (games ?? [])
.filter((g) => (filter === 'all' || g.category === filter) && (level === null || g.difficulty === level))
.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 +42,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'
}`}
>
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
</button>
</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 +68,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>
+3 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import type { Role, RoomState } from '@two-keys/shared';
import { Markdown, RoleBadge } from '../components/ui';
import { socket } from '../lib/socket';
import { CATEGORY_LABEL, ROLE_BORDER, difficultyKeys } from '../lib/theme';
import { CATEGORY_LABEL, MAX_DIFFICULTY, ROLE_BORDER, difficultyKeys } from '../lib/theme';
function ShareBox({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
@@ -51,7 +51,8 @@ export function Lobby({ state }: { state: RoomState }) {
<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>⏱ {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>
+3 -2
View File
@@ -5,7 +5,7 @@ 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 { DefuseGame } from '../engines/DefuseGame';
import { LabyrinthGame } from '../engines/LabyrinthGame';
@@ -45,8 +45,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">
🔑🔑
+9 -2
View File
@@ -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>
+6 -5
View File
@@ -12,12 +12,13 @@ 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'],
},
},
},
+208
View File
@@ -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!** ⚗️🔑
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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" }
+340
View File
@@ -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**.
+211
View File
@@ -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?)
+90
View File
@@ -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. 🏮🔑
+118
View File
@@ -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] }
+1 -1
View File
@@ -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" }
+422
View File
@@ -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.
+309
View File
@@ -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?)
+2 -2
View File
@@ -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:
+313
View File
@@ -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**.
+94
View File
@@ -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.** 🪞🔑
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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" }
+303
View File
@@ -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"]}
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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" }
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "two-keys",
"version": "1.0.1",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "two-keys",
"version": "1.0.1",
"version": "1.2.1",
"workspaces": [
"shared",
"server",
@@ -23,7 +23,7 @@
},
"client": {
"name": "@two-keys/client",
"version": "1.0.1",
"version": "1.2.1",
"dependencies": {
"@two-keys/shared": "*",
"framer-motion": "^11.11.0",
@@ -6847,7 +6847,7 @@
},
"server": {
"name": "@two-keys/server",
"version": "1.0.1",
"version": "1.2.1",
"dependencies": {
"@two-keys/shared": "*",
"express": "^4.21.0",
@@ -7332,7 +7332,7 @@
},
"shared": {
"name": "@two-keys/shared",
"version": "1.0.1",
"version": "1.2.1",
"devDependencies": {
"vitest": "^2.1.0"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "two-keys",
"private": true,
"version": "1.0.1",
"version": "1.2.1",
"description": "Co-op mystery games for couples",
"workspaces": [
"shared",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/server",
"version": "1.0.1",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
+83 -19
View File
@@ -11,16 +11,27 @@ import type {
GameSummary,
LabyrinthView,
LockView,
MoveDir,
Role,
StoryView,
SyncView,
} from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { remainingAt, type DefuseState } from '../engines/defuse';
import { maxStrikes, remainingAt, toSignal, type DefuseState } from '../engines/defuse';
import { totalKeys } from '../engines/index';
import { DELTAS, cellAt, isWall, type LabyrinthState } from '../engines/labyrinth';
import { NUDGE_AFTER_WRONG, type StoryState } from '../engines/story';
import { canReplay, type SyncState } from '../engines/sync';
import {
DELTAS,
OPPOSITE,
REL_DIRS,
cellAt,
facingOf,
gateBlocks,
isWall,
toAbsolute,
type LabyrinthState,
} from '../engines/labyrinth';
import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story';
import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync';
import type { Room } from '../rooms/store';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
@@ -36,6 +47,7 @@ export function gameSummary(game: Game): GameSummary {
theme: { ...game.theme },
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
totalKeys: totalKeys(game),
timeLimitMinutes: game.engine === 'story' ? (game.rules.timeLimitMinutes ?? null) : null,
};
}
@@ -51,7 +63,7 @@ function clueView(clue: Clue, audience: ClueView['audience']): ClueView {
return view;
}
function lockView(lock: Lock, s: StoryState): LockView {
function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView {
const solved = s.solved.includes(lock.id);
const wrongAttempts = s.wrong[lock.id] ?? 0;
return {
@@ -63,6 +75,7 @@ function lockView(lock: Lock, s: StoryState): LockView {
solved,
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
hintsTotal: lock.hints.length,
hintPenaltySeconds: hintPenalty(game),
wrongAttempts,
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
successText: solved ? (lock.successText ?? null) : null,
@@ -81,7 +94,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
...ch.clues[role].map((c) => clueView(c, 'private')),
...ch.clues.shared.map((c) => clueView(c, 'shared')),
],
lock: lockView(ch.lock, s),
lock: lockView(game, ch.lock, s),
}));
let finale: StoryView['finale'] = null;
@@ -108,7 +121,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
type: 'escape',
prompt: f.prompt,
clues: f.clues[role].map((c) => clueView(c, 'private')),
lock: lockView(f.lock, s),
lock: lockView(game, f.lock, s),
};
}
@@ -120,11 +133,17 @@ 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 common = {
@@ -133,7 +152,7 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu
status: s.status,
failReason: s.failReason,
strikes: s.strikes,
maxStrikes: game.strikes.max,
maxStrikes: maxStrikes(game, s.level),
clock: s.clock
? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' }
: null,
@@ -170,14 +189,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 +240,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 +270,22 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
// Navigator: the whole map, but never the Explorer's coordinates.
if (role === 'A') {
// A mirrored map is flipped left to right: rows reversed, x counted from the other side.
const mirror = game.navigatorView.mapTransform === 'mirror-x';
const width = game.grid[0].length;
const fx = (x: number) => (mirror ? width - 1 - x : x);
return {
engine: 'labyrinth',
seat: 'navigator',
grid: [...game.grid],
glyphs: game.glyphs.map((g) => ({ x: g.x, y: g.y, label: g.label })),
lanterns: game.lanterns.map((l, i) => ({ x: l.x, y: l.y, name: l.name, collected: s.collected[i] })),
grid: game.grid.map((row) => (mirror ? [...row].reverse().join('') : row)),
glyphs: game.glyphs.map((g) => ({ x: fx(g.x), y: g.y, label: g.label })),
lanterns: game.lanterns.map((l, i) => ({ x: fx(l.x), y: l.y, name: l.name, collected: s.collected[i] })),
oneWay: game.oneWay.map((g) => ({
x: fx(g.x),
y: g.y,
dir: mirror && (g.dir === 'e' || g.dir === 'w') ? OPPOSITE[g.dir] : g.dir,
})),
banner: game.navigatorView.banner ?? null,
keysCarried,
totalKeys: total,
moves: game.navigatorView.showMoveCount ? s.moves : null,
@@ -240,16 +294,26 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
}
// Explorer: only the cell under their feet. No grid, no coordinates, no traps.
const open = {} as Record<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',
+79 -9
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { CLUE_TYPES } from '@two-keys/shared';
import { CLUE_TYPES, DEFUSE_MODULES, SYNC_ROUND_TYPES } from '@two-keys/shared';
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected a #rrggbb colour');
const roleInfo = z.object({ name: z.string().min(1), blurb: z.string() });
@@ -10,7 +10,7 @@ const header = {
title: z.string().min(1),
tagline: z.string().min(1),
estimatedMinutes: z.number().positive(),
difficulty: z.number().int().min(1).max(3),
difficulty: z.number().int().min(1).max(5),
players: z.object({ min: z.literal(2), max: z.literal(2) }),
theme: z.object({ primary: hex, accent: hex, background: hex, headingFont: z.string().min(1) }),
roles: z.object({ A: roleInfo, B: roleInfo }),
@@ -81,12 +81,22 @@ const finaleSchema = z.discriminatedUnion('type', [
clues: z.object({ A: z.array(clueSchema).default([]), B: z.array(clueSchema).default([]) }),
lock: lockSchema,
successEnding: z.string().min(1),
/** Shown if a time limit runs out. */
failEnding: z.string().min(1).optional(),
}),
]);
const storySchema = z.object({
...header,
engine: z.literal('story'),
rules: z
.object({
hintPenaltySeconds: z.number().int().nonnegative().optional(),
/** Hard limit on the clock (elapsed + penalties). Running out ends the game. */
timeLimitMinutes: z.number().positive().optional(),
timeLimitLabel: z.string().min(1).optional(),
})
.default({}),
suspects: z
.array(z.object({ id: z.string(), name: z.string(), role: z.string(), description: z.string() }))
.optional(),
@@ -97,14 +107,18 @@ const storySchema = z.object({
// ---------- defuse ----------
const range = z.object({ min: z.number().int().nonnegative(), max: z.number().int().nonnegative() });
const moduleId = z.enum(['wires', 'button', 'keypad']);
const moduleId = z.enum(DEFUSE_MODULES);
const indicators = z.object({ lit: z.array(z.string()), unlit: z.array(z.string()) }).optional();
const defuseSchema = z.object({
...header,
engine: z.literal('defuse'),
/** Which manual the solver implements. Each manual's rules live in engines/defuse.ts. */
ruleset: z.enum(['classic', 'overtime']).default('classic'),
device: z.object({
serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }),
batteries: range,
indicators: z.object({ labels: z.array(z.string().min(1)).min(1), count: range }).optional(),
}),
levels: z
.array(
@@ -113,6 +127,7 @@ const defuseSchema = z.object({
title: z.string().min(1),
timeSeconds: z.number().int().positive(),
modules: z.array(moduleId).min(1),
strikesOverride: z.number().int().positive().optional(),
keyName: z.string().min(1),
}),
)
@@ -129,6 +144,15 @@ const defuseSchema = z.object({
symbols: z.record(z.string()),
columns: z.array(z.array(z.string()).min(4)).min(1),
}),
passwords: z
.object({
columnsCount: z.number().int().positive(),
lettersPerColumn: z.number().int().min(2).max(26),
words: z.array(z.string().regex(/^[A-Z]+$/)).min(2),
})
.optional(),
morse: z.object({ words: z.record(z.string().regex(/^\d\.\d{3}$/)) }).optional(),
simon: z.object({ colors: z.array(z.string()).min(2), stages: range }).optional(),
}),
manual: z.string().min(1),
fixtures: z.object({
@@ -136,6 +160,7 @@ const defuseSchema = z.object({
z.object({
serial: z.string(),
batteries: z.number().int(),
indicators,
wires: z.array(z.string()),
expect: z.object({ type: z.literal('cut'), index: z.number().int() }),
}),
@@ -143,6 +168,7 @@ const defuseSchema = z.object({
button: z.array(
z.object({
batteries: z.number().int(),
indicators,
label: z.string(),
color: z.string(),
strip: z.string(),
@@ -153,6 +179,20 @@ const defuseSchema = z.object({
}),
),
keypad: z.array(z.object({ symbols: z.array(z.string()), expect: z.array(z.string()) })),
passwords: z.array(z.object({ columns: z.array(z.string()), expect: z.string() })).default([]),
morse: z
.array(z.object({ signal: z.string(), expect: z.object({ word: z.string(), frequency: z.string() }) }))
.default([]),
simon: z
.array(
z.object({
serial: z.string(),
strikes: z.number().int(),
flashes: z.array(z.string()),
expect: z.array(z.string()),
}),
)
.default([]),
}),
});
@@ -163,13 +203,20 @@ const questionSchema = z.object({
q: z.string().min(1),
options: z.array(z.coerce.string()).min(2),
});
const telepathySchema = z.object({ id: z.string().min(1), q: z.string().min(1) });
const rankSchema = z.object({
id: z.string().min(1),
q: z.string().min(1),
items: z.array(z.coerce.string()).min(3),
});
const syncSchema = z.object({
...header,
engine: z.literal('sync'),
rules: z.object({
questionsPerRound: z.number().int().positive(),
pointsForKey: z.number().int().positive(),
/** Default for rounds that don't set their own. */
pointsForKey: z.number().int().positive().default(3),
replaysPerRound: z.number().int().nonnegative(),
}),
rounds: z
@@ -177,19 +224,26 @@ const syncSchema = z.object({
z.object({
id: z.string().min(1),
title: z.string().min(1),
type: z.enum(['match', 'predict']),
type: z.enum(SYNC_ROUND_TYPES),
subject: z.enum(['A', 'B']).optional(),
/** rank rounds: who does the ranking on each question, cycled. */
subjectPattern: z.array(z.enum(['A', 'B'])).min(1).optional(),
pointsForKey: z.number().int().positive().optional(),
blurb: z.string(),
keyName: z.string().min(1),
}),
)
.min(1),
match: z.array(questionSchema),
predict: z.array(questionSchema),
match: z.array(questionSchema).default([]),
predict: z.array(questionSchema).default([]),
telepathy: z.array(telepathySchema).default([]),
rank: z.array(rankSchema).default([]),
});
// ---------- labyrinth ----------
const dir = z.enum(['n', 'e', 's', 'w']);
const labyrinthSchema = z.object({
...header,
engine: z.literal('labyrinth'),
@@ -198,6 +252,16 @@ const labyrinthSchema = z.object({
z.object({ x: z.number().int(), y: z.number().int(), glyph: z.string(), label: z.string().min(1) }),
),
lanterns: z.array(z.object({ x: z.number().int(), y: z.number().int(), name: z.string().min(1) })),
lanternsRequired: z.number().int().positive().default(3),
/** One-way gates: you may step from (x, y) in `dir`, never back the other way. */
oneWay: z.array(z.object({ x: z.number().int(), y: z.number().int(), dir })).default([]),
explorer: z
.object({
/** `relative` swaps N/E/S/W for forward/left/right/back from the way the Explorer faces. */
compass: z.enum(['absolute', 'relative']).default('absolute'),
startFacing: dir.default('n'),
})
.default({}),
explorerView: z.object({
flavour: z.object({
blank: z.string(),
@@ -205,9 +269,15 @@ const labyrinthSchema = z.object({
lantern: z.string(),
exitLocked: z.string(),
exitOpen: z.string(),
gate: z.string().optional(),
}),
}),
navigatorView: z.object({ showMoveCount: z.boolean().default(true) }),
navigatorView: z.object({
showMoveCount: z.boolean().default(true),
/** `mirror-x` serves the map flipped left to right. */
mapTransform: z.enum(['none', 'mirror-x']).default('none'),
banner: z.string().optional(),
}),
scoring: z.object({ trapPenaltySeconds: z.number().nonnegative(), parMoves: z.number().int().positive() }),
successEnding: z.string().min(1),
});
@@ -226,4 +296,4 @@ export type LabyrinthGame = z.infer<typeof labyrinthSchema>;
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[] };
+97 -14
View File
@@ -1,6 +1,16 @@
import { normalizeAnswer } from '@two-keys/shared';
import { solveButton, solveKeypad, solveWires } from '../engines/defuse';
import { cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared';
import {
OVERTIME_TABLES,
buttonSolution,
solveKeypad,
solveMorse,
solvePassword,
solveSimon,
toSignal,
wireSolution,
} from '../engines/defuse';
import { DELTAS, analyseMaze, cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
import { groupOf, pointsForKey } from '../engines/sync';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
export const MIN_SYNC_QUESTIONS = 15;
@@ -75,14 +85,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 +100,55 @@ 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) {
for (const id of level.modules) {
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules`);
}
}
if (modules.passwords) {
const { words, columnsCount } = modules.passwords;
for (const w of words) if (w.length !== columnsCount) errors.push(`password "${w}" is not ${columnsCount} letters`);
if (new Set(words).size !== words.length) errors.push('password list has duplicates');
}
if (modules.morse) {
const entries = Object.entries(modules.morse.words);
for (const [word] of entries) {
if (!solveMorse(toSignal(word), modules.morse.words)) errors.push(`morse word "${word}" can't be sent in Morse`);
}
if (new Set(entries.map(([, f]) => f)).size !== entries.length) errors.push('two morse words share a frequency');
}
if (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 +165,28 @@ 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}`);
}
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 +200,38 @@ 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`);
}
if (starts.length === 1) {
const reach = safeReachable(grid);
const reach = safeReachable(grid, game.oneWay);
for (const k of keys) {
if (!isReachable(reach, k)) errors.push(`lantern at (${k.x},${k.y}) is not reachable from S without a trap`);
}
for (const e of exits) {
if (!isReachable(reach, e)) errors.push(`exit at (${e.x},${e.y}) is not reachable from S without a trap`);
}
if (errors.length === 0) {
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns);
if (!winnable) errors.push('no trap-free route collects every lantern and then reaches the exit');
for (const p of stuck) errors.push(`the Explorer can get stuck for good at (${p.x},${p.y})`);
}
}
return errors;
}
+262 -16
View File
@@ -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 {
@@ -60,6 +215,25 @@ export interface DefuseDevice {
wires?: { colors: string[]; solved: boolean };
button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean };
keypad?: { symbols: string[]; pressed: string[]; solved: boolean };
indicators?: { label: string; lit: boolean }[];
passwords?: { columns: string[]; solved: boolean };
morse?: { word: string; solved: boolean };
/** `stage` flashes are showing; `progress` of them have been answered. */
simon?: { sequence: string[]; stage: number; progress: number; solved: boolean };
}
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function edgework(d: DefuseDevice): Edgework {
const shown = d.indicators ?? [];
return {
serial: d.serial,
batteries: d.batteries,
indicators: {
lit: shown.filter((i) => i.lit).map((i) => i.label),
unlit: shown.filter((i) => !i.lit).map((i) => i.label),
},
};
}
export function generateDevice(game: DefuseGame, levelIndex: number, seed: number): DefuseDevice {
@@ -83,6 +257,11 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
serial += pick(digits); // last char is always a digit
const device: DefuseDevice = { serial, batteries: int(batteries.min, batteries.max) };
const ind = game.device.indicators;
if (ind) {
const shown = shuffle(ind.labels).slice(0, int(ind.count.min, ind.count.max));
device.indicators = shown.map((label) => ({ label, lit: rng() < 0.5 }));
}
const mods = game.modules;
for (const id of game.levels[levelIndex].modules) {
if (id === 'wires') {
@@ -96,9 +275,33 @@ 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;
@@ -106,11 +309,15 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
/** A generated device must always be solvable by the manual. */
export function isSolvable(game: DefuseGame, device: DefuseDevice): boolean {
if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) return false;
if (device.wires) {
const { index } = solveWires(device.serial, device.wires.colors);
const { index } = wireSolution(game.ruleset, edgework(device), device.wires.colors);
if (index < 1 || index > device.wires.colors.length) return false;
}
if (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false;
if (device.passwords && !solvePassword(device.passwords.columns, game.modules.passwords?.words ?? [])) return false;
if (device.morse && !solveMorse(toSignal(device.morse.word), game.modules.morse?.words ?? {})) return false;
if (device.simon && device.simon.sequence.length < 1) return false;
return true;
}
@@ -135,6 +342,13 @@ export function initDefuse(): DefuseState {
const state = (ctx: Ctx) => ctx.room.engine as DefuseState;
/** Strikes allowed on a level (some levels are stricter than the game default). */
export const maxStrikes = (game: DefuseGame, level: number): number =>
game.levels[level]?.strikesOverride ?? game.strikes.max;
const deviceModules = (d: DefuseDevice) =>
[d.wires, d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
export function remainingAt(s: DefuseState, now: number): number {
if (!s.clock) return 0;
if (s.status !== 'active') return s.clock.remaining;
@@ -162,14 +376,15 @@ function strike(game: DefuseGame, ctx: Ctx, s: DefuseState) {
s.strikes++;
ctx.room.wrongGuesses++;
s.clock = { remaining, anchor: ctx.now, speed: 1 + game.strikes.speedUpPerStrike * s.strikes };
if (s.strikes >= game.strikes.max) explode(ctx, s, 'strikes');
else ctx.toast('all', `Strike ${s.strikes} of ${game.strikes.max}! The clock speeds up.`);
const max = maxStrikes(game, s.level);
// The Say It Back mapping moves with the strike count, so the stage starts over.
if (s.device?.simon) s.device.simon.progress = 0;
if (s.strikes >= max) explode(ctx, s, 'strikes');
else ctx.toast('all', `Strike ${s.strikes} of ${max}! The clock speeds up.`);
}
function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
const d = s.device!;
const modules = [d.wires, d.button, d.keypad].filter((m) => m !== undefined);
if (!modules.every((m) => m.solved)) return;
if (!deviceModules(s.device!).every((m) => m.solved)) return;
freezeClock(s, ctx.now);
s.status = 'cleared';
ctx.addKey(game.levels[s.level].keyName);
@@ -186,6 +401,11 @@ export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
s.device = generateDevice(game, s.level, s.seed);
// Never arm a device the solver can't solve.
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
s.seed = newSeed();
s.device = generateDevice(game, s.level, s.seed);
}
s.strikes = 0;
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
s.status = 'active';
@@ -216,13 +436,14 @@ export function defuseAction(
return OK;
}
const d = s.device;
const edge = edgework(d);
if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') {
if (action.index === solveWires(d.serial, d.wires.colors).index) d.wires.solved = true;
if (action.index === wireSolution(game.ruleset, edge, d.wires.colors).index) d.wires.solved = true;
else strike(game, ctx, s);
} else if (moduleId === 'button' && d.button && !d.button.solved) {
const b = d.button;
const solution = solveButton({ ...b, batteries: d.batteries });
const solution = buttonSolution(game.ruleset, edge, b);
if (action.type === 'tap' && !b.holding) {
if (solution.type === 'tap') b.solved = true;
else strike(game, ctx, s);
@@ -242,16 +463,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.");
}
+115 -8
View File
@@ -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,9 +66,78 @@ 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[],
): { winnable: boolean; stuck: Point[] } {
const [start] = findCells(grid, 'S');
if (!start) return { winnable: false, stuck: [] };
const full = (1 << lanterns.length) - 1;
const id = (p: Point, mask: number, safe: boolean) => `${p.x},${p.y},${mask},${safe ? 1 : 0}`;
const pickUp = (p: Point, mask: number) => {
const i = lanterns.findIndex((l) => l.x === p.x && l.y === p.y);
return i >= 0 ? mask | (1 << i) : mask;
};
// `safe` tracks whether the run so far is trap-free.
type Node = { p: Point; mask: number; safe: boolean };
const first: Node = { p: start, mask: 0, safe: true };
const seen = new Map<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) {
goals.push(from);
continue;
}
for (const dir of CLOCKWISE) {
const step = { x: node.p.x + DELTAS[dir].x, y: node.p.y + DELTAS[dir].y };
const c = cellAt(grid, step.x, step.y);
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir)) continue;
const next: Node =
c === 'T'
? { p: start, mask: node.mask, safe: false }
: { p: step, mask: pickUp(step, node.mask), safe: node.safe };
const to = id(next.p, next.mask, next.safe);
reverse.set(to, [...(reverse.get(to) ?? []), from]);
if (!seen.has(to)) {
seen.set(to, next);
queue.push(next);
}
}
}
const canFinish = new Set<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>();
for (const [nodeId, node] of seen) if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()] };
}
export interface LabyrinthState {
x: number;
y: number;
/** The way the Explorer faces: the direction of their last step. */
facing?: Dir;
collected: boolean[];
moves: number;
falls: number;
@@ -58,27 +146,46 @@ export interface LabyrinthState {
export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
const [start] = findCells(game.grid, 'S');
return { ...start, collected: game.lanterns.map(() => false), moves: 0, falls: 0, message: '' };
return {
...start,
facing: game.explorer.startFacing,
collected: game.lanterns.map(() => false),
moves: 0,
falls: 0,
message: '',
};
}
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, dir: Dir): ActionResult {
export const facingOf = (game: LabyrinthGame, s: LabyrinthState): Dir => s.facing ?? game.explorer.startFacing;
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: MoveDir): ActionResult {
const s = ctx.room.engine as LabyrinthState;
if (role !== 'B') return fail('wrong_role', 'Only the Explorer can move.');
const relative = game.explorer.compass === 'relative';
const isRel = (REL_DIRS as readonly string[]).includes(move);
if (relative !== isRel) return fail('bad_dir', relative ? 'You have no compass in here.' : "You can't move like that here.");
const dir = isRel ? toAbsolute(facingOf(game, s), move as RelDir) : (move as Dir);
const d = DELTAS[dir];
const next = { x: s.x + d.x, y: s.y + d.y };
if (isWall(game.grid, next.x, next.y)) return fail('wall', "Solid stone. You can't go that way.");
if (gateBlocks(game.oneWay, s.x, s.y, dir)) return fail('gate', "The gate won't open from this side.");
const flavour = game.explorerView.flavour;
const throughGate = game.oneWay.some((g) => g.x === s.x && g.y === s.y && g.dir === dir);
s.moves++;
s.x = next.x;
s.y = next.y;
s.message = '';
s.facing = dir;
s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : '';
const cell = cellAt(game.grid, next.x, next.y);
if (cell === 'T') {
const [start] = findCells(game.grid, 'S');
s.x = start.x;
s.y = start.y;
s.facing = game.explorer.startFacing;
s.falls++;
s.message = flavour.trap;
ctx.addPenalty(game.scoring.trapPenaltySeconds);
+47 -3
View File
@@ -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;
}
+79 -13
View File
@@ -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';
}
+3 -3
View File
@@ -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 };
},
};
}
+8
View File
@@ -2,6 +2,7 @@ import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/s
import type { Game } from '../content/schemas';
import { engineView, gameMeta } from '../content/sanitize';
import { totalKeys } from '../engines/index';
import { timeLimitSeconds } from '../engines/story';
import type { PlayerRec, Room } from './store';
export function computeResult(room: Room, game: Game): GameResult | null {
@@ -11,6 +12,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 +29,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 +75,8 @@ export function buildState(
finishedAt: room.finishedAt,
penaltySeconds: room.penaltySeconds,
serverNow: now,
limitSeconds: game.engine === 'story' ? timeLimitSeconds(game) : null,
limitLabel: game.engine === 'story' ? (game.rules.timeLimitLabel ?? null) : null,
},
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
views,
+1 -1
View File
@@ -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;
+48 -11
View File
@@ -1,13 +1,30 @@
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 { moveExplorer } from '../engines/labyrinth';
import { agreeAccusation, proposeAccusation, requestHint, submitAccusation, submitLock } from '../engines/story';
import { answerSync } from '../engines/sync';
import {
agreeAccusation,
proposeAccusation,
requestHint,
submitAccusation,
submitLock,
tickStory,
togglePin,
} from '../engines/story';
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
import { cleanCode } from '../rooms/codes';
import { buildState } from '../rooms/state';
@@ -24,20 +41,35 @@ 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']),
moduleId: z.enum(DEFUSE_MODULES),
action: z.discriminatedUnion('type', [
z.object({ type: z.literal('cut'), index: z.number().int() }),
z.object({ type: z.literal('tap') }),
z.object({ type: z.literal('hold') }),
z.object({ type: z.literal('release') }),
z.object({ type: z.literal('press'), symbol: z.string() }),
z.object({ type: z.literal('press'), symbol: z.string().optional(), color: z.string().optional() }),
z.object({ type: z.literal('submit'), word: z.string().max(20) }),
z.object({ type: z.literal('transmit'), frequency: z.string().max(10) }),
]),
...asRole,
}),
sync: z.object({ questionId: z.string(), optionId: z.number().int(), ...asRole }),
move: z.object({ dir: z.enum(['n', 'e', 's', 'w']), ...asRole }),
// One of: an option index, a typed word (telepathy), or item indexes in order (rank).
sync: z.object({
questionId: z.string(),
optionId: z.number().int().optional(),
text: z.string().max(TELEPATHY_MAX_LENGTH * 2).optional(),
order: z.array(z.number().int()).max(12).optional(),
...asRole,
}),
move: z.object({
dir: z
.enum(['n', 'e', 's', 'w', 'f', 'l', 'r', 'b', 'forward', 'left', 'right', 'back'])
.transform((d): MoveDir => (d.length > 1 ? (d[0] as RelDir) : (d as MoveDir))),
...asRole,
}),
notepad: z.object({ text: z.string() }),
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
view: z.object({ label: z.string().max(80) }),
@@ -204,6 +236,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 +262,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.'),
);
@@ -265,13 +301,14 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
});
});
// The defuse clock can run out with nobody touching anything.
// A defuse clock or a story time limit can run out with nobody touching anything.
const ticker = setInterval(() => {
for (const room of store.rooms.values()) {
const game = games.get(room.gameId);
if (room.phase !== 'playing' || game?.engine !== 'defuse') continue;
if (room.phase !== 'playing' || !game) continue;
if (game.engine !== 'defuse' && !(game.engine === 'story' && game.rules.timeLimitMinutes)) continue;
const ctx = createCtx(room);
if (tickDefuse(ctx)) {
if (game.engine === 'defuse' ? tickDefuse(ctx) : tickStory(game, ctx)) {
flushToasts(room, ctx);
broadcast(room);
}
+9 -5
View File
@@ -7,13 +7,17 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
import { games, getGame, labyrinth, sync } from './helpers';
describe('content', () => {
it('loads all 8 games with no validation errors', () => {
it('loads all 18 games with no validation errors', () => {
const files = loadAllFiles();
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
expect(files).toHaveLength(8);
expect([...games.values()].map((g) => g.engine).sort()).toEqual(
['defuse', 'labyrinth', 'story', 'story', 'story', 'story', 'story', 'sync'],
);
expect(files).toHaveLength(18);
const engines = [...games.values()].map((g) => g.engine);
expect(Object.fromEntries(['story', 'defuse', 'sync', 'labyrinth'].map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
story: 11,
defuse: 2,
sync: 2,
labyrinth: 3,
});
});
it('rejects a story chapter with no clue for one player', () => {
+471
View File
@@ -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 });
});
});
+264
View File
@@ -0,0 +1,264 @@
// 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('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("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/);
});
});
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/shared",
"version": "1.0.1",
"version": "1.2.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
+4 -1
View File
@@ -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';
+80 -7
View File
@@ -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,8 @@ export interface LockView {
/** Revealed hint tiers only. */
hints: string[];
hintsTotal: number;
/** What the next hint costs. */
hintPenaltySeconds: number;
wrongAttempts: number;
nudge: boolean;
successText: string | null;
@@ -130,6 +134,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 ----------
@@ -162,7 +170,42 @@ export interface KeypadModuleView {
solved: boolean;
symbols: { id: string; label: string; pressed: boolean }[];
}
export type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView;
export interface PasswordsModuleView {
id: 'passwords';
solved: boolean;
/** The letters each column cycles through, top to bottom. */
columns: string[];
}
export interface MorseModuleView {
id: 'morse';
solved: boolean;
/** Same format as a signal clue. Never print it as text. */
signal: string;
/** Dial range in kHz (3500 = 3.500 MHz). */
minKhz: number;
maxKhz: number;
}
export interface SimonModuleView {
id: 'simon';
solved: boolean;
colors: string[];
/** The flashes of the current stage. */
flashes: string[];
stage: number;
stages: number;
/** Presses accepted so far in this stage. */
entered: number;
}
export type DefuseModuleView =
| WiresModuleView
| ButtonModuleView
| KeypadModuleView
| PasswordsModuleView
| MorseModuleView
| SimonModuleView;
export const DEFUSE_MODULES = ['wires', 'button', 'keypad', 'passwords', 'morse', 'simon'] as const;
export type DefuseModuleId = (typeof DEFUSE_MODULES)[number];
interface DefuseCommon {
engine: 'defuse';
@@ -175,7 +218,12 @@ interface DefuseCommon {
}
export interface TechnicianView extends DefuseCommon {
seat: 'technician';
device: { serial: string; batteries: number; modules: DefuseModuleView[] } | null;
device: {
serial: string;
batteries: number;
indicators: { label: string; lit: boolean }[];
modules: DefuseModuleView[];
} | null;
}
export interface ExpertView extends DefuseCommon {
seat: 'expert';
@@ -188,10 +236,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 +255,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 +265,13 @@ export interface SyncView {
questionsPerRound: number;
pointsForKey: number;
points: number;
/** `options` holds the items to order in a rank round, and is empty for telepathy. */
question: { id: string; text: string; options: string[] } | null;
iAmSubject: boolean;
myAnswer: number | null;
myAnswer: SyncAnswer | null;
partnerAnswered: boolean;
/** Both answers, only once both are in. */
reveal: { A: number; B: number; match: boolean } | null;
reveal: { A: SyncAnswer; B: SyncAnswer; match: boolean } | null;
/** Match / miss for each question revealed so far this round. */
marks: boolean[];
summary: { earned: boolean; canReplay: boolean; keyName: string } | null;
@@ -222,12 +280,19 @@ export interface SyncView {
// ---------- Labyrinth engine ----------
export type Dir = 'n' | 'e' | 's' | 'w';
/** Forward / left / right / back, for mazes where the Explorer has no compass. */
export type RelDir = 'f' | 'l' | 'r' | 'b';
export type MoveDir = Dir | RelDir;
export interface ExplorerView {
engine: 'labyrinth';
seat: 'explorer';
glyphLabel: string | null;
open: Record<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;
@@ -242,6 +307,9 @@ export interface NavigatorView {
grid: string[];
glyphs: { x: number; y: number; label: string }[];
lanterns: { x: number; y: number; name: string; collected: boolean }[];
/** One-way gates: passable from (x, y) in `dir` only. */
oneWay: { x: number; y: number; dir: Dir }[];
banner: string | null;
keysCarried: number;
totalKeys: number;
moves: number | null;
@@ -263,6 +331,8 @@ export interface GameResult {
keysEarned: number;
totalKeys: number;
rank: string;
outOfTime: boolean;
nightmareSurvivors: boolean;
ending: string | null;
reveal: string | null;
}
@@ -281,6 +351,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. */