Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6669be11e5 | ||
|
|
bbe44e84c4 | ||
|
|
62245ca67a | ||
|
|
d1a9c7d0f7 |
@@ -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
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ restored on start. Players reconnect by themselves within a few seconds.
|
||||
| `EACCES` during `npm ci`, build, or at start-up | Folder not owned by your user. Re-run the `chown` in step 2. |
|
||||
| `EADDRINUSE` in `pm2 logs` | Port 3010 is taken. Change `PORT` in `ecosystem.config.cjs` and `localPort` in frpc, then `pm2 restart two-keys --update-env` and restart frpc. |
|
||||
| Local `curl` works, domain gives frp's "page not found" | `customDomains` doesn't match the host name exactly, frpc wasn't restarted, or the front proxy rewrites `Host`. |
|
||||
| Site loads but "Can't reach the server" toasts, or games feel laggy | Websocket upgrade headers missing on the central server's HTTPS proxy. |
|
||||
| Site loads but games feel laggy, or `/socket.io` requests with `transport=websocket` fail in the browser's network tab | Websocket upgrade headers missing on the central server's HTTPS proxy. The game still works over polling. |
|
||||
| Site loads but no games listed | `npm run build` or `validate-content` failed. Check `pm2 logs two-keys`. |
|
||||
|
||||
Do not run more than one instance (no pm2 cluster mode, no second copy behind a load balancer):
|
||||
|
||||
@@ -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#
|
||||
###############
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ Co-op mystery games for couples. Two players, different clues, every lock needs
|
||||
|------|------------|
|
||||
| `CLAUDE.md` | Full build spec: stack, architecture, security, engines, socket events, design, phases |
|
||||
| `GAMES.md` | Spoiler guide with every answer (don't let your partner read this 👀) |
|
||||
| `content/games/*.yaml` | All 8 games: 3 murder mysteries, 2 escape rooms, defuse, In Sync, labyrinth |
|
||||
| `content/games/*.yaml` | All 18 games: 6 murder mysteries, 5 escape rooms, 2 defuse, 2 In Sync, 3 labyrinths |
|
||||
| `package.json` | Root workspace config |
|
||||
|
||||
## Adding a new game later
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Two Keys · Co-op mysteries for couples</title>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -36,7 +36,13 @@ function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
|
||||
/** Starring a clue: yours to see; your partner only learns how many you starred. */
|
||||
export interface PinProps {
|
||||
pinned?: boolean;
|
||||
onPin?: () => void;
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono, pinned, onPin }: { clue: ClueView; light: boolean; mono?: boolean } & PinProps) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0">
|
||||
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:
|
||||
</div>
|
||||
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
|
||||
</div>
|
||||
<Audience clue={clue} light={light} />
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Audience clue={clue} light={light} />
|
||||
{onPin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? `Unstar ${clue.title}` : `Star ${clue.title}`}
|
||||
className={`-m-1 flex h-9 w-9 items-center justify-center rounded-full font-sans text-lg not-italic transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
pinned ? 'text-amber-400' : light ? 'text-stone-500 hover:text-stone-800' : 'text-stone-500 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
{pinned ? '★' : '☆'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,19 +119,19 @@ export function SignalLamp({ pattern }: { pattern: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
export function ClueCard({ clue, ...pin }: { clue: ClueView } & PinProps) {
|
||||
switch (clue.type) {
|
||||
case 'note':
|
||||
return (
|
||||
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'letter':
|
||||
return (
|
||||
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
@@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Header clue={clue} light mono />
|
||||
<Header {...pin} clue={clue} light mono />
|
||||
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
case 'diagram':
|
||||
return (
|
||||
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'transcript':
|
||||
return (
|
||||
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
|
||||
<Header clue={clue} light={false} />
|
||||
<Header {...pin} clue={clue} light={false} />
|
||||
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'signal':
|
||||
return (
|
||||
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
{clue.signal && <SignalLamp pattern={clue.signal} />}
|
||||
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
||||
</article>
|
||||
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
case 'table':
|
||||
return (
|
||||
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
|
||||
import { getBest, saveSession } from '../lib/session';
|
||||
import { request } from '../lib/socket';
|
||||
import { CATEGORY_LABEL, difficultyKeys, themeVars } from '../lib/theme';
|
||||
import { 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
@@ -14,11 +14,16 @@ interface Props {
|
||||
send: Send;
|
||||
}
|
||||
|
||||
function Clues({ clues }: { clues: ClueView[] }) {
|
||||
interface Pins {
|
||||
pins: string[];
|
||||
onPin: (clueId: string) => void;
|
||||
}
|
||||
|
||||
function Clues({ clues, pins, onPin }: { clues: ClueView[] } & Pins) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{clues.map((clue) => (
|
||||
<ClueCard key={clue.id} clue={clue} />
|
||||
<ClueCard key={clue.id} clue={clue} pinned={pins.includes(clue.id)} onPin={() => onPin(clue.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -71,11 +76,28 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
|
||||
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
|
||||
const partnerName = state.game.roles[otherRole(role)].name;
|
||||
const mine = finale.proposal?.by === role;
|
||||
const lastShot = finale.attemptsLeft === 1;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
useEffect(() => setConfirming(false), [proposalKey, finale.agreed]);
|
||||
|
||||
let action;
|
||||
if (matchesProposal && finale.agreed) {
|
||||
if (matchesProposal && finale.agreed && confirming) {
|
||||
action = (
|
||||
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
|
||||
<div className="rounded-2xl border border-red-400/50 bg-red-500/10 p-3 text-center" role="alertdialog" aria-label="Confirm accusation">
|
||||
<p className="font-semibold">One shot. Are you both sure?</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="btn-primary flex-1" onClick={() => send('finale:submit')}>
|
||||
Yes, accuse
|
||||
</button>
|
||||
<button className="btn flex-1" onClick={() => setConfirming(false)}>
|
||||
Not yet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (matchesProposal && finale.agreed) {
|
||||
action = (
|
||||
<button className="btn-primary w-full" onClick={() => (lastShot ? setConfirming(true) : send('finale:submit'))}>
|
||||
You both agree. Make the accusation ⚖️
|
||||
</button>
|
||||
);
|
||||
@@ -136,7 +158,9 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
|
||||
<p className="mb-2 text-center text-xs text-stone-400" aria-live="polite">
|
||||
{finale.attemptsLeft} of {finale.maxAttempts} attempts left
|
||||
{finale.maxAttempts === 1
|
||||
? 'One attempt only. A wrong accusation ends the game.'
|
||||
: `${finale.attemptsLeft} of ${finale.maxAttempts} attempts left`}
|
||||
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
|
||||
</p>
|
||||
{action}
|
||||
@@ -145,9 +169,70 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
|
||||
const textOf = (clue: ClueView) => `${clue.title}\n${clue.body}`.toLowerCase();
|
||||
|
||||
/** Search, filter and star your own clues. Everything here is already on this device. */
|
||||
function EvidenceLocker({
|
||||
view,
|
||||
chapters,
|
||||
finaleClues,
|
||||
currentChapter,
|
||||
pins,
|
||||
onPin,
|
||||
}: { view: StoryView; chapters: ChapterView[]; finaleClues: ClueView[]; currentChapter: number } & Pins) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [only, setOnly] = useState<number | 'all' | 'starred'>('all');
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtering = q !== '' || only !== 'all';
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const all = [
|
||||
...chapters.map((ch, i) => ({ key: ch.id, index: i, title: `Chapter ${i + 1}: ${ch.title}`, chapter: ch, clues: ch.clues })),
|
||||
...(finaleClues.length > 0 ? [{ key: 'finale', index: chapters.length, title: 'Finale', chapter: null, clues: finaleClues }] : []),
|
||||
];
|
||||
return all
|
||||
.filter((s) => typeof only !== 'number' || s.index === only)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
clues: s.clues.filter((c) => (only !== 'starred' || pins.includes(c.id)) && (q === '' || textOf(c).includes(q))),
|
||||
}));
|
||||
}, [chapters, finaleClues, only, q, pins]);
|
||||
const found = sections.reduce((n, s) => n + s.clues.length, 0);
|
||||
|
||||
const chip = (value: typeof only, label: string) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
aria-pressed={only === value}
|
||||
onClick={() => setOnly(only === value ? 'all' : value)}
|
||||
className={`min-h-[36px] shrink-0 rounded-full border px-3 py-1 text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
only === value ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="search"
|
||||
className="field"
|
||||
placeholder="Search your clues…"
|
||||
aria-label="Search your clues"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1" role="group" aria-label="Show">
|
||||
{chip('all', 'All')}
|
||||
{chip('starred', `★ Starred (${pins.length})`)}
|
||||
{chapters.map((_, i) => chip(i, `Ch ${i + 1}`))}
|
||||
</div>
|
||||
<p className="text-xs text-stone-400" aria-live="polite">
|
||||
{filtering ? `${found} clue${found === 1 ? '' : 's'} found · ` : ''}
|
||||
Your partner has starred {view.partnerPins} of their clue{view.partnerPins === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
{view.suspects.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
|
||||
@@ -163,22 +248,25 @@ function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: Chapter
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{chapters.length === 0 && view.suspects.length === 0 && (
|
||||
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
|
||||
)}
|
||||
{chapters.map((ch, i) => (
|
||||
<details key={ch.id} className="group" open={i === chapters.length - 1}>
|
||||
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
||||
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
||||
Chapter {i + 1}: {ch.title}
|
||||
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 px-1">
|
||||
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
|
||||
<Clues clues={ch.clues} />
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{filtering && found === 0 && <p className="text-stone-400">Nothing matches.</p>}
|
||||
{sections
|
||||
.filter((s) => !filtering || s.clues.length > 0)
|
||||
.map((s) => (
|
||||
// Filtering opens every match; otherwise the latest solved chapter starts open.
|
||||
<details key={`${s.key}:${filtering}`} className="group" open={filtering || s.index === currentChapter - 1}>
|
||||
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
||||
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
||||
{s.title}
|
||||
{s.chapter?.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {s.chapter.lock.keyName}</span>}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 px-1">
|
||||
{!filtering && s.chapter?.lock.successText && (
|
||||
<Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{s.chapter.lock.successText}</Markdown>
|
||||
)}
|
||||
<Clues clues={s.clues} pins={pins} onPin={onPin} />
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -188,8 +276,10 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
const atFinale = view.finale !== null;
|
||||
const chapter = atFinale ? null : view.chapters[view.currentChapter];
|
||||
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
|
||||
// Everything already solved: all chapters once the finale opens, earlier ones before that.
|
||||
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
|
||||
const pins = view.pins ?? [];
|
||||
const onPin = (clueId: string) => send('clue:pin', { clueId });
|
||||
const finaleClues = view.finale?.type === 'escape' ? view.finale.clues : [];
|
||||
const clueCount = view.chapters.reduce((n, c) => n + c.clues.length, 0) + finaleClues.length;
|
||||
|
||||
// Tell the partner what we're looking at.
|
||||
useEffect(() => {
|
||||
@@ -208,7 +298,7 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
|
||||
</div>
|
||||
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
|
||||
🗄️ Evidence{past.length > 0 ? ` (${past.reduce((n, c) => n + c.clues.length, 0)})` : ''}
|
||||
🗄️ Evidence ({clueCount}){pins.length > 0 ? ` · ★${pins.length}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
@@ -226,7 +316,7 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
{chapter && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
|
||||
<Clues clues={chapter.clues} />
|
||||
<Clues clues={chapter.clues} pins={pins} onPin={onPin} />
|
||||
{chapter.lock.solved && view.awaitingContinue ? (
|
||||
<Unlocked
|
||||
lock={chapter.lock}
|
||||
@@ -249,13 +339,20 @@ export function StoryGame({ view, state, role, send }: Props) {
|
||||
{view.finale?.type === 'escape' && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
||||
<Clues clues={view.finale.clues} />
|
||||
<Clues clues={view.finale.clues} pins={pins} onPin={onPin} />
|
||||
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
|
||||
<EvidenceLocker view={view} chapters={past} />
|
||||
<EvidenceLocker
|
||||
view={view}
|
||||
chapters={view.chapters}
|
||||
finaleClues={finaleClues}
|
||||
currentChapter={view.currentChapter}
|
||||
pins={pins}
|
||||
onPin={onPin}
|
||||
/>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
|
||||
+192
-33
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState, type FormEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Role, RoomState, SyncView } from '@two-keys/shared';
|
||||
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { RoleBadge } from '../components/ui';
|
||||
import type { Send } from '../lib/socket';
|
||||
@@ -34,7 +35,7 @@ function Marks({ view }: { view: SyncView }) {
|
||||
}
|
||||
|
||||
/** One answer card that flips over when both answers are in. */
|
||||
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: string; delay: number }) {
|
||||
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: ReactNode; delay: number }) {
|
||||
return (
|
||||
<div className="flex-1 [perspective:800px]">
|
||||
<motion.div
|
||||
@@ -50,6 +51,132 @@ function FlipCard({ role, name, answer, delay }: { role: Role; name: string; ans
|
||||
);
|
||||
}
|
||||
|
||||
/** Telepathy: one typed word, no options to lean on. */
|
||||
function WordInput({ onSubmit }: { onSubmit: (text: string) => void }) {
|
||||
const [text, setText] = useState('');
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) onSubmit(text.trim());
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} className="flex gap-2">
|
||||
<input
|
||||
className="field text-lg"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
maxLength={30}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="One word"
|
||||
aria-label="Your word"
|
||||
/>
|
||||
<button type="submit" className="btn-primary shrink-0" disabled={!text.trim()}>
|
||||
Lock in
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranking: put the items in order, most at the top. Drag the grip (mouse or touch), or use the
|
||||
* up/down buttons (keyboard and screen readers).
|
||||
*/
|
||||
function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) {
|
||||
const [order, setOrder] = useState<number[]>(() => items.map((_, i) => i));
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const rows = useRef<(HTMLLIElement | null)[]>([]);
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= order.length || from === to) return;
|
||||
setOrder((o) => {
|
||||
const next = [...o];
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
});
|
||||
setAnnounce(`${items[order[from]]} moved to number ${to + 1}.`);
|
||||
};
|
||||
|
||||
// Pointer dragging: whichever row the pointer is over becomes the dragged item's new place.
|
||||
const onPointerMove = (e: ReactPointerEvent) => {
|
||||
if (dragging === null) return;
|
||||
const from = order.indexOf(dragging);
|
||||
const to = rows.current.findIndex((row) => {
|
||||
const r = row?.getBoundingClientRect();
|
||||
return r !== undefined && e.clientY >= r.top && e.clientY <= r.bottom;
|
||||
});
|
||||
if (to >= 0 && to !== from) move(from, to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-xs text-stone-400">Most at the top, least at the bottom.</p>
|
||||
<ol className="grid gap-2 touch-none select-none" onPointerMove={onPointerMove} onPointerUp={() => setDragging(null)} onPointerCancel={() => setDragging(null)}>
|
||||
{order.map((item, place) => (
|
||||
<li
|
||||
key={item}
|
||||
ref={(el) => (rows.current[place] = el)}
|
||||
className={`flex min-h-[56px] items-center gap-2 rounded-xl border px-2 py-2 text-lg font-medium transition-colors ${
|
||||
dragging === item ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded-lg text-stone-400 active:cursor-grabbing"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest('ol') as HTMLElement | null)?.setPointerCapture(e.pointerId);
|
||||
setDragging(item);
|
||||
}}
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
|
||||
place === 0 || place === order.length - 1 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-400'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{place + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">{items[item]}</span>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn !min-h-[40px] !px-2.5 !py-1"
|
||||
disabled={place === 0}
|
||||
onClick={() => move(place, place - 1)}
|
||||
aria-label={`Move ${items[item]} up, now number ${place + 1}`}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn !min-h-[40px] !px-2.5 !py-1"
|
||||
disabled={place === order.length - 1}
|
||||
onClick={() => move(place, place + 1)}
|
||||
aria-label={`Move ${items[item]} down, now number ${place + 1}`}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => onSubmit(order)}>
|
||||
Lock in this order
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncGame({ view, state, role, send }: Props) {
|
||||
const roles = state.game.roles;
|
||||
const partnerName = roles[otherRole(role)].name;
|
||||
@@ -108,8 +235,40 @@ export function SyncGame({ view, state, role, send }: Props) {
|
||||
|
||||
const q = view.question;
|
||||
if (!q) return null;
|
||||
const predict = view.round.type === 'predict';
|
||||
const { type } = view.round;
|
||||
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
|
||||
const answered = view.myAnswer !== null;
|
||||
|
||||
let instruction: string;
|
||||
if (type === 'match') instruction = 'Pick what you think your partner will pick.';
|
||||
else if (type === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.';
|
||||
else if (type === 'rank') {
|
||||
instruction = view.iAmSubject
|
||||
? "This one's about you. Put them in order, most to least. Be honest!"
|
||||
: `Put them in the order ${subjectName} would, most to least. The top and the bottom have to match.`;
|
||||
} else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`;
|
||||
|
||||
/** An answer as the reveal cards show it. */
|
||||
const show = (answer: SyncAnswer): ReactNode => {
|
||||
if (typeof answer === 'string') return answer;
|
||||
if (typeof answer === 'number') return q.options[answer];
|
||||
return (
|
||||
<ol className="w-full space-y-1 text-left text-sm sm:text-base">
|
||||
{answer.map((item, place) => {
|
||||
// #1 and the last place are the ones that score.
|
||||
const scores = place === 0 || place === answer.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={item}
|
||||
className={scores ? 'rounded-md bg-accent/20 px-1.5 py-0.5 text-accent' : 'px-1.5 font-normal text-stone-400'}
|
||||
>
|
||||
{place + 1}. {q.options[item]}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -120,39 +279,39 @@ export function SyncGame({ view, state, role, send }: Props) {
|
||||
Question {view.questionNumber} of {view.questionsPerRound}
|
||||
</div>
|
||||
<p className="mt-2 font-display text-2xl font-bold leading-snug">{q.text}</p>
|
||||
{view.status === 'question' && (
|
||||
<p className="mt-2 text-sm text-accent">
|
||||
{!predict
|
||||
? 'Pick what you think your partner will pick.'
|
||||
: view.iAmSubject
|
||||
? "This one's about you. Answer honestly!"
|
||||
: `Guess what ${subjectName} will say.`}
|
||||
</p>
|
||||
)}
|
||||
{view.status === 'question' && <p className="mt-2 text-sm text-accent">{instruction}</p>}
|
||||
</div>
|
||||
|
||||
{view.status === 'question' && (
|
||||
<>
|
||||
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
|
||||
{q.options.map((option, i) => (
|
||||
<button
|
||||
key={i}
|
||||
role="radio"
|
||||
aria-checked={view.myAnswer === i}
|
||||
disabled={view.myAnswer !== null}
|
||||
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
|
||||
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
view.myAnswer === i
|
||||
? 'border-accent bg-accent/25'
|
||||
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{answered && type !== 'match' && type !== 'predict' ? (
|
||||
<div className="card text-center font-display text-lg font-bold">{show(view.myAnswer!)}</div>
|
||||
) : type === 'telepathy' ? (
|
||||
<WordInput key={q.id} onSubmit={(text) => send('sync:answer', { questionId: q.id, text })} />
|
||||
) : type === 'rank' ? (
|
||||
<RankPicker key={q.id} items={q.options} onSubmit={(order) => send('sync:answer', { questionId: q.id, order })} />
|
||||
) : (
|
||||
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
|
||||
{q.options.map((option, i) => (
|
||||
<button
|
||||
key={i}
|
||||
role="radio"
|
||||
aria-checked={view.myAnswer === i}
|
||||
disabled={answered}
|
||||
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
|
||||
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
view.myAnswer === i
|
||||
? 'border-accent bg-accent/25'
|
||||
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-sm text-stone-400" aria-live="polite">
|
||||
{view.myAnswer !== null
|
||||
{answered
|
||||
? couch
|
||||
? `Locked in. Pass the device to ${partnerName}.`
|
||||
: `Locked in. Waiting for ${partnerName}…`
|
||||
@@ -166,8 +325,8 @@ export function SyncGame({ view, state, role, send }: Props) {
|
||||
{view.status === 'reveal' && view.reveal && (
|
||||
<div className="space-y-4" aria-live="polite">
|
||||
<div className="flex gap-3">
|
||||
<FlipCard role="A" name={roles.A.name} answer={q.options[view.reveal.A]} delay={0} />
|
||||
<FlipCard role="B" name={roles.B.name} answer={q.options[view.reveal.B]} delay={0.15} />
|
||||
<FlipCard role="A" name={roles.A.name} answer={show(view.reveal.A)} delay={0} />
|
||||
<FlipCard role="B" name={roles.B.name} answer={show(view.reveal.B)} delay={0.15} />
|
||||
</div>
|
||||
<motion.p
|
||||
className="text-center font-display text-2xl font-bold"
|
||||
|
||||
+21
-3
@@ -101,18 +101,24 @@ body {
|
||||
.md th:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Stencil grids and box-drawing keypads need tight lines so the boxes join up. */
|
||||
.md pre {
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
max-width: 100%;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
line-height: 1.15;
|
||||
white-space: pre;
|
||||
}
|
||||
.md pre code.language-sql {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md pre code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.md code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.md .scroll-x {
|
||||
@@ -131,6 +137,18 @@ body {
|
||||
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
|
||||
}
|
||||
|
||||
/* ---------- Water gauge (Deep Lab Nereid) ---------- */
|
||||
.water-wave {
|
||||
background: radial-gradient(ellipse 12px 6px at 12px 6px, rgb(56 189 248 / 0.55) 98%, transparent) repeat-x;
|
||||
background-size: 24px 12px;
|
||||
animation: wave 2.4s linear infinite;
|
||||
}
|
||||
@keyframes wave {
|
||||
to {
|
||||
background-position-x: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
20%,
|
||||
60% {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface BestResult {
|
||||
rank: string;
|
||||
hintsUsed: number;
|
||||
keysEarned: number;
|
||||
/** Ever finished this (difficulty 5) game with zero hints. Kept once earned. */
|
||||
nightmareSurvivors?: boolean;
|
||||
}
|
||||
|
||||
export function getBest(gameId: string): BestResult | null {
|
||||
@@ -53,6 +55,10 @@ export function saveBest(gameId: string, result: BestResult): boolean {
|
||||
!best ||
|
||||
result.keysEarned > best.keysEarned ||
|
||||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
|
||||
const nightmareSurvivors = Boolean(result.nightmareSurvivors || best?.nightmareSurvivors);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify({ ...result, nightmareSurvivors }));
|
||||
else if (nightmareSurvivors && !best?.nightmareSurvivors) {
|
||||
write(`twokeys:best:${gameId}`, JSON.stringify({ ...best, nightmareSurvivors }));
|
||||
}
|
||||
return better;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { io } from 'socket.io-client';
|
||||
import type { JoinAck } from '@two-keys/shared';
|
||||
import { pushToast } from './toasts';
|
||||
|
||||
export const socket = io({ transports: ['websocket', 'polling'] });
|
||||
// Polling first, then upgrade: a websocket-first client never falls back, so a proxy that
|
||||
// drops the Upgrade header would leave it unable to connect at all.
|
||||
export const socket = io();
|
||||
|
||||
socket.on('toast', ({ message }: { message: string }) => pushToast(message));
|
||||
socket.on('error', ({ message }: { message: string }) => pushToast(message, 'error'));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
🔑🔑
|
||||
|
||||
@@ -38,6 +38,7 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
||||
rank: result.rank,
|
||||
hintsUsed: result.hintsUsed,
|
||||
keysEarned: result.keysEarned,
|
||||
nightmareSurvivors: result.nightmareSurvivors,
|
||||
}),
|
||||
);
|
||||
}, [game.id, result]);
|
||||
@@ -55,9 +56,15 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
||||
>
|
||||
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
{result.outOfTime ? '⌛' : result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
</motion.div>
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
|
||||
{/* Out of time gets no rank badge. */}
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.outOfTime ? 'Out of time' : result.rank}</h1>
|
||||
{result.nightmareSurvivors && (
|
||||
<p className="mx-auto mt-3 w-fit rounded-full bg-red-500/15 px-4 py-1.5 font-semibold text-red-200">
|
||||
☠️ Nightmare Survivors: a difficulty-5 game with zero hints
|
||||
</p>
|
||||
)}
|
||||
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -12,12 +12,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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
id: alchemists-vault
|
||||
engine: story
|
||||
category: escape-room
|
||||
title: The Alchemist's Vault
|
||||
tagline: Prague, 1602. The alchemist's cellar. The door only opens for those who understand the elements.
|
||||
estimatedMinutes: 25
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2d5a27", accent: "#d4af37", background: "#0b100a", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
roles:
|
||||
A:
|
||||
name: The Apprentice
|
||||
blurb: You're at the workbench. Flasks, scales, plaques and the vault door.
|
||||
B:
|
||||
name: The Scribe
|
||||
blurb: You're in the library alcove. Tables, charts, and the master's books.
|
||||
intro: |
|
||||
You came to the old alchemist's house to deliver a package. The cellar door swung shut behind you
|
||||
and a bar dropped across it.
|
||||
|
||||
A brass speaking-tube carries your voices between the workbench and the library alcove.
|
||||
Carved above the vault door:
|
||||
|
||||
> *"Gold is not made. It is understood. — Master Kelley"*
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Five Flasks
|
||||
narrative: |
|
||||
Five sealed flasks stand on the workbench, each etched with a number and a colour of flame.
|
||||
A wooden cabinet beside them has a six-letter word lock.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-flasks
|
||||
title: The five flasks (left to right as they stand)
|
||||
type: table
|
||||
body: |
|
||||
| Flask | Etched number | Flame colour |
|
||||
|-------|---------------|--------------|
|
||||
| 1 | 92 | Green |
|
||||
| 2 | 7 | Orange |
|
||||
| 3 | 16 | Violet |
|
||||
| 4 | 32 | Red |
|
||||
| 5 | 53 | Yellow |
|
||||
B:
|
||||
- id: b1-table
|
||||
title: Master Kelley's table of elements (a page)
|
||||
type: table
|
||||
body: |
|
||||
| No. | Symbol | No. | Symbol | No. | Symbol |
|
||||
|-----|--------|-----|--------|-----|--------|
|
||||
| 6 | C | 26 | Fe | 53 | I |
|
||||
| 7 | N | 29 | Cu | 79 | Au |
|
||||
| 8 | O | 30 | Zn | 80 | Hg |
|
||||
| 16 | S | 32 | Ge | 82 | Pb |
|
||||
| 19 | K | 47 | Ag | 92 | U |
|
||||
- id: b1-recipe
|
||||
title: Recipe card on the lectern
|
||||
type: note
|
||||
body: |
|
||||
*Read the flasks by their fire, as the rainbow burns:
|
||||
**red, orange, yellow, green, violet.** Their signs will name what I see in my pupil.*
|
||||
lock:
|
||||
id: lock-flasks
|
||||
prompt: Enter the cabinet word.
|
||||
inputType: text
|
||||
format: 6 letters
|
||||
answers: ["GENIUS"]
|
||||
hints:
|
||||
- Order the flasks by flame colour, using the rainbow order from the recipe card.
|
||||
- "Red is flask 4 (32), orange is flask 2 (7), yellow is flask 5 (53)..."
|
||||
- 32 Ge, 7 N, 53 I, 92 U, 16 S = GeNIUS.
|
||||
successText: |
|
||||
**GENIUS.** The cabinet opens: a set of brass weights and a lump of dark, heavy stone wrapped in cloth.
|
||||
Painted on the inside of the door: *"Weigh the stone."*
|
||||
keyName: Flame Key
|
||||
|
||||
- id: ch2
|
||||
title: The Philosopher's Stone
|
||||
narrative: |
|
||||
Two balance scales stand on the workbench, already loaded, perfectly level.
|
||||
A dial on the wall wants the weight of the stone in drams.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-scales
|
||||
title: The two balance scales
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
SCALE ONE (balanced)
|
||||
left pan: the STONE + 1 lead weight
|
||||
right pan: 2 gold weights + 1 silver weight
|
||||
|
||||
SCALE TWO (balanced)
|
||||
left pan: 2 lead weights
|
||||
right pan: 1 gold weight + 1 silver weight
|
||||
```
|
||||
The weights carry the symbols of their metals, but no numbers.
|
||||
B:
|
||||
- id: b2-ledger
|
||||
title: Weights ledger, margin notes
|
||||
type: note
|
||||
body: |
|
||||
*The silver weight is stamped **5 drams**. I lost the other tags.
|
||||
I remember only that a gold weight is **three times** a silver one.*
|
||||
lock:
|
||||
id: lock-stone
|
||||
prompt: How many drams does the stone weigh?
|
||||
inputType: code
|
||||
format: A number
|
||||
answers: ["25"]
|
||||
hints:
|
||||
- Work out gold first, then use scale two to find lead.
|
||||
- Gold = 15. Scale two says 2 lead = 15 + 5, so lead = 10.
|
||||
- "Scale one: stone + 10 = 15 + 15 + 5 = 35, so the stone is 25."
|
||||
successText: |
|
||||
**25 drams.** The dial clicks, and a stone panel slides aside, revealing a brass plaque of raised dots.
|
||||
keyName: Scale Key
|
||||
|
||||
- id: ch3
|
||||
title: The Dotted Plaque
|
||||
narrative: |
|
||||
The plaque is covered in raised dots. Beneath it, a two-digit brass dial.
|
||||
Master Kelley went blind in his last years. He wrote his secrets for his fingers.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-plaque
|
||||
title: The plaque
|
||||
type: diagram
|
||||
body: |
|
||||
## ⠍⠑⠗⠉⠥⠗⠽
|
||||
|
||||
*Under it, engraved: "Turn the dial to my **number**."*
|
||||
B:
|
||||
- id: b3-braille
|
||||
title: A card of raised-dot letters (from the master's desk)
|
||||
type: table
|
||||
body: |
|
||||
| ⠁ A | ⠃ B | ⠉ C | ⠙ D | ⠑ E | ⠋ F | ⠛ G | ⠓ H | ⠊ I |
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|-----|
|
||||
| ⠚ J | ⠅ K | ⠇ L | ⠍ M | ⠝ N | ⠕ O | ⠏ P | ⠟ Q | ⠗ R |
|
||||
| ⠎ S | ⠞ T | ⠥ U | ⠧ V | ⠺ W | ⠭ X | ⠽ Y | ⠵ Z | |
|
||||
- id: b3-names
|
||||
title: Old names for the metals (bookmark)
|
||||
type: note
|
||||
body: |
|
||||
Quicksilver = **Mercury** = Hg. *The only metal that runs like water.*
|
||||
lock:
|
||||
id: lock-plaque
|
||||
prompt: Set the two-digit dial.
|
||||
inputType: code
|
||||
format: 2 digits
|
||||
answers: ["80"]
|
||||
hints:
|
||||
- Decode the plaque letter by letter using the dot card.
|
||||
- The word is MERCURY. "My number" is its number in the table of elements.
|
||||
- Mercury (Hg) is 80.
|
||||
successText: |
|
||||
**80.** Somewhere in the walls, chains rattle. The vault door's last lock lights up:
|
||||
four alchemical symbols glowing in a row.
|
||||
keyName: Quicksilver Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: The vault door has a three-digit wheel and four glowing symbols.
|
||||
clues:
|
||||
A:
|
||||
- id: af-door
|
||||
title: The vault door
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
☉ ☽ ♂ ♄
|
||||
```
|
||||
*Carved around the wheel: "Add up the hearts of my four metals."*
|
||||
B:
|
||||
- id: bf-symbols
|
||||
title: Chart of the seven metals
|
||||
type: table
|
||||
body: |
|
||||
| Symbol | Planet | Metal |
|
||||
|--------|---------|---------|
|
||||
| ☉ | Sun | Gold |
|
||||
| ☽ | Moon | Silver |
|
||||
| ☿ | Mercury | Mercury |
|
||||
| ♀ | Venus | Copper |
|
||||
| ♂ | Mars | Iron |
|
||||
| ♃ | Jupiter | Tin |
|
||||
| ♄ | Saturn | Lead |
|
||||
*"A metal's heart is its number."*
|
||||
lock:
|
||||
id: lock-vault
|
||||
prompt: Turn the wheel to the right number.
|
||||
inputType: code
|
||||
format: 3 digits
|
||||
answers: ["234"]
|
||||
hints:
|
||||
- Name the four metals from the chart, then find each one's number in the table of elements from the first chapter.
|
||||
- Gold 79, Silver 47, Iron 26, Lead 82.
|
||||
- 79 + 47 + 26 + 82 = 234.
|
||||
successEnding: |
|
||||
**234.** The vault swings open. No gold inside. Just a mirror, and a note:
|
||||
|
||||
> *"You found it together. That was always the gold."*
|
||||
|
||||
The cellar door's bar lifts. **You escaped!** ⚗️🔑
|
||||
@@ -3,8 +3,8 @@ engine: story
|
||||
category: murder-mystery
|
||||
title: The Last Set at the Blue Lantern
|
||||
tagline: Cape Town, 1958. The band played on. The boss didn't.
|
||||
estimatedMinutes: 50
|
||||
difficulty: 2
|
||||
estimatedMinutes: 10
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1f3a68", accent: "#e8b04a", background: "#0b0f1c", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
|
||||
@@ -3,7 +3,7 @@ engine: story
|
||||
category: escape-room
|
||||
title: The Clockmaker's Attic
|
||||
tagline: Locked in an old clockmaker's attic. Every clock is ticking. So are you.
|
||||
estimatedMinutes: 40
|
||||
estimatedMinutes: 15
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#8a5a2b", accent: "#f2c66d", background: "#17110b", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
id: dead-air
|
||||
engine: story
|
||||
category: murder-mystery
|
||||
title: Dead Air
|
||||
tagline: A legendary DJ goes silent live on air. Four minutes of nothing. One killer in the building.
|
||||
estimatedMinutes: 30
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#d7263d", accent: "#f9c80e", background: "#0c0a12", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
roles:
|
||||
A:
|
||||
name: The Detective
|
||||
blurb: You have the police file. Lab results, statements, staff records.
|
||||
B:
|
||||
name: The Night Intern
|
||||
blurb: You work the graveyard shift at Night Owl FM. Logs, run sheets, cameras, gossip.
|
||||
intro: |
|
||||
**Night Owl FM, Johannesburg. 23:31.**
|
||||
|
||||
Rex Daniels has hosted the midnight show for 22 years. Tonight he promised listeners
|
||||
"the biggest announcement in the history of this station, at midnight sharp."
|
||||
|
||||
He never made it. A song ended, and nothing came after it. Just hiss.
|
||||
The producer found him slumped over the desk, his tea still warm.
|
||||
|
||||
Five people were in the building. **Every one of them had a reason.**
|
||||
|
||||
⚠️ *Harder case: the locks give you facts, not the answer. You'll have to connect them yourselves in the accusation.*
|
||||
|
||||
suspects:
|
||||
- id: zanele
|
||||
name: Zanele Dube
|
||||
role: Producer
|
||||
description: Runs the desk, makes Rex's tea, knows his every habit. Has been passed over for promotion three times.
|
||||
- id: kyle
|
||||
name: Kyle Pretorius
|
||||
role: Junior DJ
|
||||
description: Ambitious, loud, and openly waiting for Rex's slot.
|
||||
- id: marisa
|
||||
name: Marisa Venter
|
||||
role: Station manager
|
||||
description: Co-owns the station with Rex. Was "in meetings" all night.
|
||||
- id: tumi
|
||||
name: Tumi Nkosi
|
||||
role: Sound engineer
|
||||
description: Keeps the old place running on duct tape and prayer. Rex once got him written up.
|
||||
- id: owen
|
||||
name: Owen Hart
|
||||
role: Superfan
|
||||
description: Calls in every night. Turned up at the gate tonight with flowers for Rex.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Ghost Signal
|
||||
narrative: |
|
||||
The automation computer kept playing what it was told. The dead-air alarm caught the silence.
|
||||
First question: **which song was playing when Rex went quiet for good?**
|
||||
clues:
|
||||
A:
|
||||
- id: a1-automation
|
||||
title: Automation log, tonight's queue
|
||||
type: log
|
||||
body: |
|
||||
| # | Track | Length | Status |
|
||||
|---|-----------------------------|--------|----------|
|
||||
| 1 | Neon Rain | 4:12 | PLAYED |
|
||||
| 2 | Highveld Heart | 3:48 | PLAYED |
|
||||
| 3 | Slow Dance in a Fast Car | 5:05 | PLAYED |
|
||||
| 4 | Paper Moons | 3:55 | PLAYED |
|
||||
| 5 | Ghost Signal | 6:20 | PLAYED |
|
||||
| 6 | Last Train Home | 4:40 | NOT PLAYED (needs manual start) |
|
||||
- id: a1-security
|
||||
title: Gate security log
|
||||
type: log
|
||||
body: |
|
||||
22:30 · Owen Hart at gate with flowers. Not on guest list. Refused entry.
|
||||
22:30–23:50 · Owen sits in reception with guard Sipho. "Wouldn't stop talking about Rex."
|
||||
B:
|
||||
- id: b1-runsheet
|
||||
title: Producer's run sheet
|
||||
type: document
|
||||
body: |
|
||||
**Midnight Show, standard format**
|
||||
- Show opens **23:00:00** sharp. Rex's intro link: **2:00**.
|
||||
- Rex talks for **exactly 1:30** after every song. He's never late. Ever.
|
||||
- Songs play straight from automation. Rex must start each song manually.
|
||||
- id: b1-alarm
|
||||
title: Dead-air alarm printout
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
** DEAD AIR ALARM ** 23:31:35
|
||||
Trigger: silence > 15 seconds on main output
|
||||
```
|
||||
lock:
|
||||
id: lock-song
|
||||
prompt: Which song was the last thing listeners heard?
|
||||
inputType: text
|
||||
format: Song title
|
||||
answers: ["GHOSTSIGNAL"]
|
||||
hints:
|
||||
- The alarm fires 15 seconds after silence starts. Work out when the silence started, then build the show's timeline.
|
||||
- Silence started at 23:31:20. Add the intro, each song, and each 1:30 talk break, starting from 23:00:00.
|
||||
- Ghost Signal ran from 23:25:00 to 23:31:20, and Rex never came back to talk after it.
|
||||
successText: |
|
||||
**Ghost Signal**, 23:25:00 to 23:31:20. Rex's last words on air ended at 23:25:00:
|
||||
*"Longest song of the night, which means it's tea time. Back in six, Joburg."*
|
||||
He never started Last Train Home. Whatever happened, it happened during Ghost Signal.
|
||||
keyName: Needle Key
|
||||
|
||||
- id: ch2
|
||||
title: The Last Cup
|
||||
narrative: |
|
||||
The paramedics say anaphylactic shock. Rex had a severe peanut allergy and everyone knew it.
|
||||
His EpiPen was not in his bag. And he'd been drinking tea all night.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-lab
|
||||
title: Lab report, rushed
|
||||
type: report
|
||||
body: |
|
||||
**Cause of death:** anaphylaxis (peanut protein, Ara h 2).
|
||||
| Item tested | Peanut protein |
|
||||
|---------------------------------------|----------------|
|
||||
| Mug "ON AIR" (in Rex's hand) | **POSITIVE** |
|
||||
| Mug "WORLD'S OKAYEST DJ" (studio sink)| negative (honey residue present) |
|
||||
| Jar labelled "REX ONLY" (honey) | **POSITIVE** |
|
||||
| Tea bags, kettle water, milk | negative |
|
||||
- id: a2-minutes
|
||||
title: Boardroom meeting minutes
|
||||
type: document
|
||||
body: |
|
||||
**Quick staff meeting, 23:08–23:20.** Topic: Rex's "midnight announcement".
|
||||
Present: Zanele Dube, Tumi Nkosi, Kyle Pretorius, Marisa Venter (chair).
|
||||
*23:11 · Marisa steps out "to take a call from the lawyers". Returns 23:17.*
|
||||
Nobody else left the room. (Rex on air, Owen in reception.)
|
||||
B:
|
||||
- id: b2-camera
|
||||
title: Kitchen camera log (motion triggered)
|
||||
type: log
|
||||
body: |
|
||||
| Time | Who | What |
|
||||
|-------|---------|------|
|
||||
| 22:45 | Rex | Makes himself rooibos. **Black, no honey.** |
|
||||
| 22:58 | Tumi | Fills water bottle |
|
||||
| 23:05 | Kyle | Instant coffee |
|
||||
| 23:06 | Zanele | Makes Rex's tea in the **WORLD'S OKAYEST DJ** mug, honey from **REX ONLY** jar. Takes it to studio. |
|
||||
| 23:07 | (empty) | · |
|
||||
| 23:09 | · | **CAMERA OFFLINE** |
|
||||
| 23:21 | · | Camera back online. Kitchen empty. |
|
||||
| 23:24 | Zanele | Makes Rex's tea in the **ON AIR** mug, honey from **REX ONLY** jar. Takes it to studio. |
|
||||
- id: b2-maintenance
|
||||
title: Tumi's maintenance whiteboard
|
||||
type: note
|
||||
body: |
|
||||
*Generator test TONIGHT 23:09 (booked by M. Venter this afternoon, last minute??).
|
||||
Cameras + kitchen plugs drop for about 12 min. Studio is on the UPS, stays live.*
|
||||
lock:
|
||||
id: lock-safe-honey
|
||||
prompt: What's the last time the honey was definitely safe?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["2306", "1106", "1106PM"]
|
||||
hints:
|
||||
- The honey jar is positive now. Was it always? Look at the first mug with honey in it.
|
||||
- The OKAYEST DJ mug had honey and tested negative. When was that cup made?
|
||||
- Zanele made the OKAYEST DJ cup at 23:06, and Rex drank it and was fine.
|
||||
successText: |
|
||||
**23:06.** The honey was clean at 23:06 and poisoned by 23:24.
|
||||
The camera was on from 23:06 to 23:09 and from 23:21 to 23:24, and nobody touched the jar then.
|
||||
So the honey was poisoned **while the camera was off, 23:09 to 23:21.**
|
||||
keyName: Honey Key
|
||||
|
||||
- id: ch3
|
||||
title: The Studio Door
|
||||
narrative: |
|
||||
Rex's EpiPen always lived in the side pocket of his bag, which sits in the studio lounge.
|
||||
The lounge has a card reader. Someone went in while Rex was live in the booth.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-register
|
||||
title: Staff access card register
|
||||
type: table
|
||||
body: |
|
||||
| Staff member | Card number |
|
||||
|-------------------|-------------|
|
||||
| Rex Daniels | 401 |
|
||||
| Zanele Dube | 409 |
|
||||
| Marisa Venter | 412 |
|
||||
| Kyle Pretorius | 417 |
|
||||
| Tumi Nkosi | 423 |
|
||||
- id: a3-kyle
|
||||
title: Kyle's statement
|
||||
type: transcript
|
||||
body: |
|
||||
"I had a one-on-one with Marisa in the boardroom at half ten. Left my card on the table.
|
||||
Went back for it at ten to eleven and it was gone. I reported it to security, check the book.
|
||||
Then I was stuck in that meeting from eight past till twenty past. Ask anyone."
|
||||
B:
|
||||
- id: b3-doorlog
|
||||
title: Studio lounge door reader
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
CARD READER v2.1 · IDs IN HEX (Tumi: "don't ask")
|
||||
22:45:02 ID 0x191 IN
|
||||
23:12:40 ID 0x1A1 IN
|
||||
23:13:55 ID 0x1A1 OUT
|
||||
23:24:31 ID 0x199 IN
|
||||
23:31:50 ID 0x199 IN (panic button pressed)
|
||||
```
|
||||
- id: b3-reception
|
||||
title: Reception lost-property book
|
||||
type: note
|
||||
body: |
|
||||
*22:52 · Kyle P. reports staff card missing. Says left in boardroom. Card NOT cancelled (system down for generator test prep).*
|
||||
lock:
|
||||
id: lock-card
|
||||
prompt: Whose card opened the studio lounge at 23:12?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: rex, label: Rex Daniels }
|
||||
- { id: zanele, label: Zanele Dube }
|
||||
- { id: marisa, label: Marisa Venter }
|
||||
- { id: kyle, label: Kyle Pretorius }
|
||||
- { id: tumi, label: Tumi Nkosi }
|
||||
answers: ["kyle"]
|
||||
hints:
|
||||
- The reader shows hexadecimal. The register is in normal (decimal) numbers.
|
||||
- "0x1A1 = 1×256 + 10×16 + 1."
|
||||
- 0x1A1 = 417, which is Kyle's card.
|
||||
successText: |
|
||||
**Kyle's card.** 23:12 to 23:13, in and out in 75 seconds, just long enough to empty a bag pocket.
|
||||
But Kyle reported that card stolen at 22:52... and the minutes put him in the boardroom at 23:12.
|
||||
keyName: Access Key
|
||||
|
||||
- id: ch4
|
||||
title: The Midnight Announcement
|
||||
narrative: |
|
||||
What was Rex going to say at midnight? His notes were in his jacket: a page of crossword-looking
|
||||
letters, and a piece of card with little windows cut into it.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-grid
|
||||
title: Page from Rex's notebook
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
G S C A L C
|
||||
H C T E B I
|
||||
D H W W S F
|
||||
O U R B H B
|
||||
T G E O E T
|
||||
D U D T Y F
|
||||
```
|
||||
*Scrawled underneath: "Zan knows how to read these. Lay the card on top."*
|
||||
B:
|
||||
- id: b4-stencil
|
||||
title: Cut card from Rex's jacket
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
■ □ ■ □ □ ■
|
||||
■ ■ ■ □ ■ □
|
||||
■ ■ ■ ■ □ □
|
||||
□ ■ □ ■ ■ ■
|
||||
■ □ □ ■ ■ ■
|
||||
■ ■ □ ■ ■ ■
|
||||
```
|
||||
■ = solid card · □ = window cut out. Same size as a notebook page, 6 × 6 squares.
|
||||
Zanele says: "His old trick. Read the windows left to right, top to bottom."
|
||||
- id: b4-gossip
|
||||
title: Overheard in the corridor (last week)
|
||||
type: transcript
|
||||
body: |
|
||||
**Tumi:** "Marisa says some media group's buying us. Rex says he never signed anything."
|
||||
**Zanele:** "He'd never sell. He'd sooner die on air."
|
||||
lock:
|
||||
id: lock-stencil
|
||||
prompt: What did Rex's hidden message say?
|
||||
inputType: text
|
||||
format: The message (3 words)
|
||||
answers: ["SALEISFORGED", "THESALEISFORGED"]
|
||||
hints:
|
||||
- The Detective reads out the letters that sit under each □ window.
|
||||
- "Row 1 windows are columns 2, 4 and 5: S, A, L."
|
||||
- S-A-L-E-I-S-F-O-R-G-E-D.
|
||||
successText: |
|
||||
**"SALE IS FORGED."** The station's sale contract carries Rex's signature. Rex never signed it.
|
||||
At midnight, he was going to tell the whole city, live.
|
||||
The only other owner who could sell the station: **Marisa Venter.**
|
||||
keyName: Microphone Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: Four keys. Put it together. Who killed Rex Daniels?
|
||||
maxAttempts: 2
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Rex?
|
||||
options:
|
||||
- { id: zanele, label: Zanele Dube }
|
||||
- { id: kyle, label: Kyle Pretorius }
|
||||
- { id: marisa, label: Marisa Venter }
|
||||
- { id: tumi, label: Tumi Nkosi }
|
||||
- { id: owen, label: Owen Hart }
|
||||
answer: marisa
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: tea, label: Peanut in the tea Zanele served him at 23:24 }
|
||||
- { id: honey, label: Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card }
|
||||
- { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day }
|
||||
- { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers }
|
||||
answer: honey
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: slot, label: To take over the midnight slot }
|
||||
- { id: writeup, label: Revenge for a disciplinary write-up }
|
||||
- { id: forged, label: Rex was about to reveal on air that she'd forged his signature to sell the station }
|
||||
- { id: obsession, label: Rejected obsession }
|
||||
answer: forged
|
||||
successEnding: |
|
||||
Marisa doesn't argue. She just looks at the "ON AIR" light, still glowing red.
|
||||
|
||||
She booked the generator test so the kitchen camera would drop. She took Kyle's card off
|
||||
the boardroom table at 22:30. At 23:11 she "stepped out for a call", used Kyle's card to get
|
||||
into the lounge at 23:12, took the EpiPen from Rex's bag, then slipped peanut oil into his honey.
|
||||
Back in the meeting by 23:17. Zanele carried the poison in herself, never knowing.
|
||||
|
||||
**Case closed.** At midnight, Zanele reads Rex's announcement on air. 📻🔑
|
||||
failEnding: |
|
||||
The wrong person is arrested. The sale goes through. Night Owl FM becomes a 24-hour playlist with no DJs.
|
||||
reveal: |
|
||||
**Marisa Venter** poisoned the honey with **peanut oil** during the generator test she booked,
|
||||
and used **Kyle's stolen card** to take Rex's **EpiPen**, because Rex was going to reveal live
|
||||
that she'd **forged his signature to sell the station**.
|
||||
@@ -0,0 +1,211 @@
|
||||
id: deep-lab-nereid
|
||||
engine: story
|
||||
category: escape-room
|
||||
title: "Submerged: Deep Lab Nereid"
|
||||
tagline: 300 metres under the sea. The lab is flooding. The clock is literal.
|
||||
estimatedMinutes: 35
|
||||
difficulty: 4
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#0b4f6c", accent: "#20bf55", background: "#03111a", headingFont: "JetBrains Mono" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
timeLimitMinutes: 40
|
||||
timeLimitLabel: "Water level" # show the countdown as a rising water gauge
|
||||
roles:
|
||||
A:
|
||||
name: Pilot
|
||||
blurb: You're in the control room. Panels, sonar, and the launch console.
|
||||
B:
|
||||
name: Diver
|
||||
blurb: You're in the wet lab. Pipe diagrams, tank labels, the station map, and a lot of water.
|
||||
intro: |
|
||||
**Deep Lab Nereid**, 300 m below the Agulhas Current. Your shift was supposed to be quiet.
|
||||
|
||||
At 03:12, a pressure seal fails. Alarms. Red lights. Water pouring through the wet lab.
|
||||
The rest of the crew made it to the first escape pod. You two are on opposite sides of a sealed hatch.
|
||||
|
||||
The second pod is your only way out. **The water gauge is your timer. When it hits the ceiling, it's over.**
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Pressure Valves
|
||||
narrative: |
|
||||
The hatch between you won't open while any pressure warning light is on.
|
||||
The Diver can reach five valves. Turning a valve flips some of the warning lights.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-lights
|
||||
title: Pressure warning panel (control room)
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
LIGHT: L1 L2 L3 L4 L5
|
||||
STATE: ON ON ON off ON
|
||||
```
|
||||
HATCH RELEASE: requires ALL LIGHTS OFF.
|
||||
Valve entry: type the numbers of the valves to turn, smallest first. Each valve once only.
|
||||
B:
|
||||
- id: b1-pipes
|
||||
title: Valve wiring plate (wet lab wall)
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
VALVE 1 → flips L1, L2
|
||||
VALVE 2 → flips L2, L3, L5
|
||||
VALVE 3 → flips L1, L4
|
||||
VALVE 4 → flips L3, L4
|
||||
VALVE 5 → flips L4, L5
|
||||
```
|
||||
"Flip" = ON becomes off, off becomes ON. Turning a valve twice undoes it.
|
||||
lock:
|
||||
id: lock-valves
|
||||
prompt: Which valves do you turn?
|
||||
inputType: code
|
||||
format: Valve numbers, smallest first (e.g. 23)
|
||||
answers: ["145"]
|
||||
hints:
|
||||
- Try it on paper. Each valve flips its lights, and order doesn't matter.
|
||||
- L1 is ON. Only valves 1 and 3 touch L1, so you need exactly one of them.
|
||||
- "Valves 1, 4 and 5. Check: L1 flips once, L2 once, L3 once, L4 twice (back to off), L5 once."
|
||||
successText: |
|
||||
**Valves 1, 4, 5.** Every light dies. The hatch hisses open, and you can finally see each other.
|
||||
Ankle-deep water. Keep moving.
|
||||
keyName: Valve Key
|
||||
|
||||
- id: ch2
|
||||
title: Breathing Gas
|
||||
narrative: |
|
||||
The pod's air tanks are empty. The lab's mixing rig can fill them, but the mix has to be exactly right.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-spec
|
||||
title: Pod life-support spec (launch console)
|
||||
type: document
|
||||
body: |
|
||||
**Fill pod tank to 210 litres.**
|
||||
Oxygen content must be exactly **21%**.
|
||||
Mixer input: litres drawn from **Tank X**. The rest is topped up from Tank Y automatically.
|
||||
B:
|
||||
- id: b2-tanks
|
||||
title: Tank labels (wet lab)
|
||||
type: table
|
||||
body: |
|
||||
| Tank | Contents | Oxygen |
|
||||
|------|-------------------|--------|
|
||||
| X | Nitrox | 36% |
|
||||
| Y | Low-oxygen mix | 15% |
|
||||
| Z | Pure helium | 0% |
|
||||
*Tank Z is not connected to the mixer.*
|
||||
lock:
|
||||
id: lock-mix
|
||||
prompt: How many litres from Tank X?
|
||||
inputType: code
|
||||
format: Litres
|
||||
answers: ["60"]
|
||||
hints:
|
||||
- You need 21% of 210 litres to be oxygen. That's 44.1 litres of oxygen.
|
||||
- If x litres come from X, oxygen = 0.36x + 0.15(210 − x).
|
||||
- 0.21x = 44.1 − 31.5 = 12.6, so x = 60.
|
||||
successText: |
|
||||
**60 litres.** The mixer hums. The pod's oxygen gauge settles at 21.0%. Water is at your knees.
|
||||
keyName: Oxygen Key
|
||||
|
||||
- id: ch3
|
||||
title: Find the Pod
|
||||
narrative: |
|
||||
The second pod broke loose from its dock in the quake and drifted. Its beacon is pinging.
|
||||
You need to steer the tether crawler to the right square of the seabed.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-sonar
|
||||
title: Sonar contact (control room)
|
||||
type: report
|
||||
body: |
|
||||
```
|
||||
CONTACT: ESCAPE POD 2
|
||||
Leg 1: 300 m, bearing 090 (due east) from the lab
|
||||
Leg 2: 200 m, bearing 180 (due south) from the end of leg 1
|
||||
```
|
||||
B:
|
||||
- id: b3-chart
|
||||
title: Seabed chart (wet lab)
|
||||
type: map
|
||||
body: |
|
||||
```
|
||||
A B C D E F G H I J
|
||||
1 . . . . . . . . . .
|
||||
2 . . . . . . . . . .
|
||||
3 . . . . . . . . . .
|
||||
4 . . . . . . . . . .
|
||||
5 . . . . [N] . . . . .
|
||||
6 . . . . . . . . . .
|
||||
7 . . . . . . . . . .
|
||||
8 . . . . . . . . . .
|
||||
```
|
||||
[N] = Lab Nereid. Each square = **100 m**. North is up.
|
||||
lock:
|
||||
id: lock-grid
|
||||
prompt: Which chart square is the pod in?
|
||||
inputType: text
|
||||
format: e.g. B4
|
||||
answers: ["H7"]
|
||||
hints:
|
||||
- Start at the lab's square, E5.
|
||||
- 300 m east is three squares east. 200 m south is two squares down.
|
||||
- E → F → G → H, then 5 → 6 → 7. H7.
|
||||
successText: |
|
||||
**H7.** The crawler latches on and reels the pod in. It docks with a clunk you feel in your teeth.
|
||||
Water is at your waist.
|
||||
keyName: Sonar Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: The pod's launch console wants a code word, typed on the ancient intercom keypad.
|
||||
clues:
|
||||
A:
|
||||
- id: af-log
|
||||
title: Station commander's last log entry
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
LAUNCH WORD (typed on the intercom keypad, pauses between letters):
|
||||
555 · 4 · 3 · 66 · 55 · 9 · 222
|
||||
```
|
||||
*The intercom is not a normal phone keypad. The engineers rewired it. Ask the wet lab.*
|
||||
B:
|
||||
- id: bf-keypad
|
||||
title: Intercom keypad (wet lab wall)
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
┌──────┬──────┬───────┐
|
||||
│ 1 │ 2 │ 3 │
|
||||
│ │ QWE │ RTY │
|
||||
├──────┼──────┼───────┤
|
||||
│ 4 │ 5 │ 6 │
|
||||
│ UIO │ PAS │ DFG │
|
||||
├──────┼──────┼───────┤
|
||||
│ 7 │ 8 │ 9 │
|
||||
│ HJK │ LZX │ CVBNM │
|
||||
└──────┴──────┴───────┘
|
||||
```
|
||||
Press a key once for its first letter, twice for its second, and so on.
|
||||
lock:
|
||||
id: lock-launch
|
||||
prompt: Enter the launch word.
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["SURFACE"]
|
||||
hints:
|
||||
- "555 means key 5 pressed three times."
|
||||
- Key 5 is P, A, S. Three presses = S.
|
||||
- S · U · R · F · A · C · E.
|
||||
successEnding: |
|
||||
**SURFACE.** The pod seals. A thump, a lurch, and you're rising, fast.
|
||||
|
||||
Through the porthole, the dark turns blue, then green, then gold. You break the surface
|
||||
into the sunrise, holding hands and laughing like idiots.
|
||||
|
||||
**You escaped!** 🌊🔑
|
||||
failEnding: |
|
||||
The water reaches the ceiling. The lights flicker out. (Don't worry, it's a game. Try again?)
|
||||
@@ -0,0 +1,90 @@
|
||||
id: drowned-catacombs
|
||||
engine: labyrinth
|
||||
category: co-op
|
||||
title: The Drowned Catacombs
|
||||
tagline: Ankle-deep water. Gates that only swing one way. Five places the floor gives out.
|
||||
estimatedMinutes: 20
|
||||
difficulty: 3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1e3d59", accent: "#7ec8e3", background: "#050b10", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Navigator
|
||||
blurb: You hold a water-stained survey map. Walls, carvings, traps, and the old one-way flood gates.
|
||||
B:
|
||||
name: Explorer
|
||||
blurb: You're down in the dark with a torch. You see the floor under you and the ways you can go. That's it.
|
||||
intro: |
|
||||
Under the old harbour town, the catacombs flood with every spring tide. Three **tide lanterns**
|
||||
were left down there a century ago. Light all three at the sea gate, and the water drains away.
|
||||
|
||||
**The flood gates swing one way only.** Go through one and you can't come back that way.
|
||||
And five stretches of the floor have rotted through.
|
||||
|
||||
Navigator, you have the map. Explorer, you have the torch. Talk.
|
||||
|
||||
# Grid legend: # wall · . floor · S start · E exit (needs all lanterns) · K lantern · T trap
|
||||
grid:
|
||||
- "#############"
|
||||
- "#S..#.......#"
|
||||
- "###.#.###.#.#"
|
||||
- "#.T...#K#...#"
|
||||
- "#.#.#.#.#.###"
|
||||
- "#.#.T.#...TK#"
|
||||
- "#.##.##.....#"
|
||||
- "#.#.T...#.#.#"
|
||||
- "#.#.###.#.#.#"
|
||||
- "#.......#.#.#"
|
||||
- "#.###.#.#.#.#"
|
||||
- "#..K#.....TE#"
|
||||
- "#############"
|
||||
|
||||
lanternsRequired: 3
|
||||
|
||||
# One-way gates. {x, y, dir}: you may step FROM (x,y) in direction `dir`, but never back the other way.
|
||||
# Navigator sees an arrow on the map. Explorer only sees "a heavy gate swings shut behind you" after passing,
|
||||
# and the reverse direction button is disabled with the label "Gate (won't open from this side)".
|
||||
oneWay:
|
||||
- { x: 5, y: 9, dir: e }
|
||||
- { x: 5, y: 11, dir: n }
|
||||
- { x: 7, y: 8, dir: n }
|
||||
|
||||
# Only some cells have carvings. Blank cells say nothing, so the Navigator must count steps.
|
||||
glyphs:
|
||||
- { x: 1, y: 1, glyph: arch, label: "⌒ A stone archway (the entrance)" }
|
||||
- { x: 3, y: 1, glyph: urn, label: "⚱ A cracked urn" }
|
||||
- { x: 9, y: 1, glyph: candle, label: "🕯 A melted candle stub" }
|
||||
- { x: 5, y: 3, glyph: bones, label: "☠ Crossed bones carved in the floor" }
|
||||
- { x: 10, y: 3, glyph: drip, label: "💧 Water dripping from the ceiling" }
|
||||
- { x: 1, y: 5, glyph: shell, label: "🐚 A seashell set in the stone" }
|
||||
- { x: 8, y: 5, glyph: eye, label: "👁 A carved eye" }
|
||||
- { x: 7, y: 6, glyph: anchor, label: "⚓ An anchor carving" }
|
||||
- { x: 3, y: 9, glyph: wave, label: "🌊 Three wavy lines" }
|
||||
- { x: 9, y: 9, glyph: fish, label: "🐟 A fish" }
|
||||
- { x: 11, y: 8, glyph: moon, label: "☾ A crescent moon" }
|
||||
- { x: 6, y: 11, glyph: keyhole, label: "🗝 A rusted keyhole in the wall" }
|
||||
|
||||
lanterns:
|
||||
- { x: 3, y: 11, name: Lantern of the Low Tide }
|
||||
- { x: 7, y: 3, name: Lantern of the Deep }
|
||||
- { x: 11, y: 5, name: Lantern of the Turning Tide }
|
||||
|
||||
explorerView:
|
||||
flavour:
|
||||
blank: "Cold water around your ankles. Nothing carved here."
|
||||
trap: "The floor collapses into black water! You surface, gasping... back at the archway."
|
||||
lantern: "You strike the tide lantern and it flares blue. ({name})"
|
||||
exitLocked: "A rusted sea gate with three lantern brackets. Locked tight."
|
||||
exitOpen: "You hang the lanterns. Somewhere, a huge drain groans open. The sea gate rises."
|
||||
gate: "A heavy flood gate swings shut behind you. It won't open from this side."
|
||||
|
||||
navigatorView:
|
||||
showMoveCount: true
|
||||
|
||||
scoring:
|
||||
trapPenaltySeconds: 60
|
||||
parMoves: 80
|
||||
|
||||
successEnding: |
|
||||
The water drains with a roar, and the Explorer climbs the sea-gate stairs into the harbour moonlight.
|
||||
The Navigator's map is soaked, but nobody cares. 🏮🔑
|
||||
@@ -0,0 +1,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] }
|
||||
@@ -3,7 +3,7 @@ engine: sync
|
||||
category: co-op
|
||||
title: In Sync
|
||||
tagline: How well do you really know each other? Prove it. Three keys, three rounds.
|
||||
estimatedMinutes: 20
|
||||
estimatedMinutes: 15
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#c2185b", accent: "#ffb3c7", background: "#14080d", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
id: kalahari-expedition
|
||||
engine: story
|
||||
category: murder-mystery
|
||||
title: The Kalahari Expedition
|
||||
tagline: 1931. A diamond find. Two bodies in the sand. Nobody leaves until the truck comes back.
|
||||
estimatedMinutes: 60
|
||||
difficulty: 5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#b5651d", accent: "#f4d58d", background: "#140d06", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 240
|
||||
timeLimitMinutes: 75
|
||||
roles:
|
||||
A:
|
||||
name: The Magistrate
|
||||
blurb: Sent from Upington with the post truck. You have the medical notes, the photographs, and the dead man's papers.
|
||||
B:
|
||||
name: The Camp Clerk
|
||||
blurb: You keep the expedition's books. Rotas, inventories, maps, and every rumour around the fire.
|
||||
intro: |
|
||||
**The southern Kalahari, March 1931.**
|
||||
|
||||
Professor Edwin Marlowe's expedition has been digging for six weeks. Two weeks ago, the sponsor,
|
||||
Felix Dunmore, announced a find that made headlines in London: **diamonds**, lying in the sand.
|
||||
|
||||
Three nights ago, Marlowe was found dead at the bottom of the excavation pit.
|
||||
Last night, the camp cook, **Samuel Brink**, was found dead by the water barrels. "A snake," they say.
|
||||
|
||||
The post truck won't be back for days. Someone in this camp has killed twice.
|
||||
|
||||
☠️ *Nightmare case. One accusation. No second chances. The clock is running.*
|
||||
|
||||
suspects:
|
||||
- id: helena
|
||||
name: Dr Helena Marlowe
|
||||
role: Botanist, the professor's wife
|
||||
description: Calm, clinical, and inheriting everything. Acted as camp doctor.
|
||||
- id: reyes
|
||||
name: Captain Arthur Reyes
|
||||
role: Guide and quartermaster
|
||||
description: Ex-army. Ran the camp with a sergeant's eye. Smokes a pipe.
|
||||
- id: dunmore
|
||||
name: Mr Felix Dunmore
|
||||
role: Sponsor
|
||||
description: Financier. His Dunmore Kalahari Diamond Company shares have tripled since the find.
|
||||
- id: bell
|
||||
name: Miss Clara Bell
|
||||
role: Photographer and journalist
|
||||
description: Sent by a London paper. Up all night photographing the stars.
|
||||
- id: kessler
|
||||
name: Dr Otto Kessler
|
||||
role: Geologist
|
||||
description: Marlowe's old rival. Said loudly at dinner that "the find makes no geological sense."
|
||||
- id: pienaar
|
||||
name: Jan Pienaar
|
||||
role: Driver and mechanic
|
||||
description: Keeps the two trucks alive. Quiet. Noticed things.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Star Trails
|
||||
narrative: |
|
||||
Nobody saw Marlowe leave camp. But Clara Bell left her camera open to the sky all night,
|
||||
and the dig site is in the corner of her photograph.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-photo
|
||||
title: Clara's long-exposure photograph
|
||||
type: photo
|
||||
body: |
|
||||
*The night sky drawn into curved white arcs around the south celestial pole.
|
||||
In the bottom corner, the dig. A small orange streak shows a lantern there.
|
||||
Measured against the star arcs, the lantern streak **begins 60°** into the arc and **ends 75°** in.*
|
||||
- id: a1-medical
|
||||
title: Dr Helena Marlowe's notes on her husband
|
||||
type: report
|
||||
body: |
|
||||
Skull fracture, back of the head. Sand in the wound, but **no sand in the mouth or nose**.
|
||||
He did not breathe after he fell. He was dead **before** he went into the pit.
|
||||
B:
|
||||
- id: b1-photolog
|
||||
title: Clara's photo log
|
||||
type: log
|
||||
body: |
|
||||
Plate 14 · Star trails, camera facing south.
|
||||
Shutter **opened 22:00**. Closed 05:00.
|
||||
*(Note to self: the sky turns **15° every hour**, so a 7-hour plate gives 105° arcs.)*
|
||||
- id: b1-clara
|
||||
title: Clara's statement (given privately to you)
|
||||
type: transcript
|
||||
body: |
|
||||
"Arthur, Captain Reyes, sat with me by the camera from ten until four. We were... talking.
|
||||
Please don't put that in the official report. My editor would have a fit."
|
||||
lock:
|
||||
id: lock-lantern
|
||||
prompt: At what time did the lantern at the dig light up?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["0200", "200", "200AM"]
|
||||
hints:
|
||||
- The star arcs grow at a steady rate from the moment the shutter opened.
|
||||
- 60° at 15° per hour is 4 hours.
|
||||
- 22:00 plus 4 hours = 02:00. (It went out at 03:00.)
|
||||
successText: |
|
||||
**02:00.** Someone was at the dig with a lantern from 02:00 to 03:00.
|
||||
Marlowe was killed then, before he was put in the pit.
|
||||
keyName: Starlight Key
|
||||
|
||||
- id: ch2
|
||||
title: Footprints
|
||||
narrative: |
|
||||
The wind dropped that night. Two sets of prints lead to the pit. One is Marlowe's (size 9, hobnail).
|
||||
The other set leads there and back again.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-sketch
|
||||
title: Police sketch of the second set of prints
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
Print length (heel to toe): 31.5 cm
|
||||
Tread pattern: >>>> chevron
|
||||
Stride: even, about 78 cm. No limp.
|
||||
```
|
||||
*Note: in loose sand, a print spreads about **1 cm longer** than the sole that made it.*
|
||||
B:
|
||||
- id: b2-boots
|
||||
title: Quartermaster's boot inventory
|
||||
type: table
|
||||
body: |
|
||||
| Person | UK size | Tread |
|
||||
|-----------------|---------|-------|
|
||||
| Prof. Marlowe | 9 | hobnail |
|
||||
| Dr H. Marlowe | 5 | smooth |
|
||||
| Capt. Reyes | 11 | chevron (army issue) |
|
||||
| Mr Dunmore | 11 | chevron ("insisted on the same as the Captain's") |
|
||||
| Miss Bell | 6 | smooth |
|
||||
| Dr Kessler | 11 | hobnail |
|
||||
| Mr Pienaar | 10 | chevron |
|
||||
| Samuel Brink | 8 | smooth |
|
||||
- id: b2-sizes
|
||||
title: Bootmaker's size card (sole length)
|
||||
type: table
|
||||
body: |
|
||||
| UK size | Sole length |
|
||||
|---------|-------------|
|
||||
| 8 | 27.5 cm |
|
||||
| 9 | 28.5 cm |
|
||||
| 10 | 29.5 cm |
|
||||
| 11 | 30.5 cm |
|
||||
| 12 | 31.5 cm |
|
||||
lock:
|
||||
id: lock-boot
|
||||
prompt: What size boot made the second set of prints?
|
||||
inputType: code
|
||||
format: UK size
|
||||
answers: ["11"]
|
||||
hints:
|
||||
- Don't read the print length straight off the size card. Sand changes it.
|
||||
- The sole is about 1 cm shorter than the print, so 30.5 cm.
|
||||
- 30.5 cm is a size 11.
|
||||
successText: |
|
||||
**Size 11, chevron tread, even stride.** Only two people in camp own that boot.
|
||||
Check the inventory again. And remember who has an alibi from ten until four.
|
||||
keyName: Boot Key
|
||||
|
||||
- id: ch3
|
||||
title: The Watch Rota
|
||||
narrative: |
|
||||
The camp keeps a fire watch every night. Whoever was on watch at 02:00 might have seen the lantern leave.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-rule
|
||||
title: Captain Reyes' standing orders
|
||||
type: document
|
||||
body: |
|
||||
**Fire watch.** Two-hour shifts: 20:00, 22:00, 00:00, 02:00, 04:00.
|
||||
Watch passes through the six watchkeepers in **alphabetical order of surname**, wrapping round.
|
||||
Each night, the **first shift** starts with the **next person** after the one who started the night before.
|
||||
- id: a3-samuel
|
||||
title: Dr Helena Marlowe on Samuel's death
|
||||
type: report
|
||||
body: |
|
||||
Two puncture marks on the ankle, **4 cm apart**, perfectly parallel.
|
||||
A puff adder's fangs are under 1.5 cm apart. Whatever this was, it wasn't a snake.
|
||||
Traces of a sedative in his coffee cup.
|
||||
B:
|
||||
- id: b3-roster
|
||||
title: Watchkeepers and the first night
|
||||
type: log
|
||||
body: |
|
||||
Watchkeepers: **Reyes, Bell, Dunmore, Kessler, H. Marlowe, Pienaar**
|
||||
(the Professor and the cook were excused).
|
||||
**Night 1:** first watch taken by Capt. Reyes (he drew the short straw).
|
||||
Marlowe died on **Night 3**.
|
||||
lock:
|
||||
id: lock-watch
|
||||
prompt: Who was on fire watch at 02:00 on the night Marlowe died?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: helena, label: Dr Helena Marlowe }
|
||||
- { id: reyes, label: Captain Arthur Reyes }
|
||||
- { id: dunmore, label: Mr Felix Dunmore }
|
||||
- { id: bell, label: Miss Clara Bell }
|
||||
- { id: kessler, label: Dr Otto Kessler }
|
||||
- { id: pienaar, label: Jan Pienaar }
|
||||
answers: ["pienaar"]
|
||||
hints:
|
||||
- Put the six watchkeepers in alphabetical order by surname first.
|
||||
- "Order: Bell, Dunmore, Kessler, Marlowe, Pienaar, Reyes. Night 1 starts with Reyes, Night 2 with Bell, Night 3 with Dunmore."
|
||||
- "Night 3: Dunmore 20:00, Kessler 22:00, Marlowe 00:00, Pienaar 02:00."
|
||||
successText: |
|
||||
**Jan Pienaar.** He tells you:
|
||||
*"Ten past two, someone walked out to the dig with a lantern. Tall, too far to see a face.
|
||||
Came back around five past three and went into the **north row** of tents. Samuel was up too,
|
||||
brought me coffee at half two. He said, 'I know who that is.' I told him to leave it."*
|
||||
North row tents: **Dunmore, Kessler, Reyes.**
|
||||
keyName: Firewatch Key
|
||||
|
||||
- id: ch4
|
||||
title: Samuel's Note
|
||||
narrative: |
|
||||
In Samuel's recipe tin: a scrap of paper in his careful capitals. It's nonsense. On purpose.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-note
|
||||
title: Note from Samuel's recipe tin
|
||||
type: note
|
||||
body: |
|
||||
## RS XRMPVSI TN TSDJ XUSUS
|
||||
|
||||
*On the back: "Prof taught me the secret way. My word goes under the letters,
|
||||
over and over. Count back by each letter of my word: A is 0, B is 1, C is 2..."*
|
||||
- id: a4-alphabet
|
||||
title: Alphabet strip
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
|
||||
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
||||
```
|
||||
B:
|
||||
- id: b4-samuel
|
||||
title: What everyone knew about Samuel
|
||||
type: note
|
||||
body: |
|
||||
Samuel greeted every single person, every single morning, with the same word, and painted it
|
||||
on his enamel mug: **KOFFIE**. "Everything good starts with it," he'd say.
|
||||
- id: b4-smokers
|
||||
title: Camp stores, tobacco issued
|
||||
type: table
|
||||
body: |
|
||||
| Person | Tobacco |
|
||||
|--------------|---------|
|
||||
| Capt. Reyes | Pipe tobacco, 2 tins |
|
||||
| Mr Dunmore | Pipe tobacco, 3 tins (Dutch cherry) |
|
||||
| Dr Kessler | None ("gave up in '29, doctor's orders") |
|
||||
| Miss Bell | Cigarettes |
|
||||
| Mr Pienaar | Cigarettes |
|
||||
lock:
|
||||
id: lock-samuel
|
||||
prompt: What did Samuel's note say?
|
||||
inputType: text
|
||||
format: The message (5 words)
|
||||
answers: ["HESMELLEDOFPIPESMOKE"]
|
||||
hints:
|
||||
- The key word is Samuel's word, KOFFIE, repeated under the message and skipping spaces.
|
||||
- "R − K(10) = H. S − O(14) = E. X − F(5) = S."
|
||||
- "The message: HE SMELLED OF PIPE SMOKE."
|
||||
successText: |
|
||||
**"HE SMELLED OF PIPE SMOKE."** The figure at the dig, returning to the north row, smelled of a pipe.
|
||||
Samuel knew who, and two nights later he was dead.
|
||||
keyName: Coffee Tin Key
|
||||
|
||||
- id: ch5
|
||||
title: The Diamonds
|
||||
narrative: |
|
||||
Dr Kessler said the find "made no geological sense." Marlowe sent three stones to a gemologist
|
||||
in Cape Town. The reply arrived in the post bag the day after he died. Nobody had opened it.
|
||||
clues:
|
||||
A:
|
||||
- id: a5-gem
|
||||
title: Gemologist's report
|
||||
type: report
|
||||
body: |
|
||||
The three stones are genuine diamonds. However:
|
||||
- They are **sharp-edged octahedra**. Diamonds carried by rivers into the desert sand
|
||||
are always **water-worn and rounded**. These have never been near water.
|
||||
- Their trace-element profile matches **reference sample K-3** in our collection exactly.
|
||||
|
||||
*Conclusion: these stones were not found where you say they were.*
|
||||
B:
|
||||
- id: b5-reference
|
||||
title: Reference samples (Cape Town Gem Society)
|
||||
type: table
|
||||
body: |
|
||||
| Sample | Source mine |
|
||||
|--------|-------------|
|
||||
| K-1 | Jagersfontein |
|
||||
| K-2 | Koffiefontein |
|
||||
| K-3 | **Kimberley**, the Big Hole |
|
||||
| K-4 | Premier Mine |
|
||||
- id: b5-prospectus
|
||||
title: Dunmore Kalahari Diamond Co. prospectus
|
||||
type: document
|
||||
body: |
|
||||
*"Alluvial diamonds, discovered personally by our chairman **Mr F. Dunmore** at Site 7.
|
||||
Shares available now at a very favourable price."*
|
||||
lock:
|
||||
id: lock-mine
|
||||
prompt: Where did the "Kalahari" diamonds really come from?
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["KIMBERLEY", "KIMBERLY", "BIGHOLE", "THEBIGHOLE", "KIMBERLEYBIGHOLE"]
|
||||
hints:
|
||||
- The report gives a sample code. The clerk has the codes.
|
||||
- Sample K-3.
|
||||
- Kimberley.
|
||||
successText: |
|
||||
**Kimberley.** Bought stones, scattered in the sand to look like a find.
|
||||
The "discovery" was a fraud, and the man who found them personally was selling the shares.
|
||||
keyName: Diamond Key
|
||||
|
||||
- id: ch6
|
||||
title: The Unsent Telegram
|
||||
narrative: |
|
||||
Marlowe's last act was a telegram he never got to send. Like Samuel, he didn't trust plain words.
|
||||
clues:
|
||||
A:
|
||||
- id: a6-telegram
|
||||
title: Marlowe's draft telegram
|
||||
type: letter
|
||||
body: |
|
||||
```
|
||||
TO: SOUTH AFRICAN POLICE, UPINGTON
|
||||
1.4 3.3 3.9 5.4 7.1 6.2 5.2 4.5 6.7
|
||||
```
|
||||
*Pencilled underneath: "Line, then word. The rules we all signed."*
|
||||
B:
|
||||
- id: b6-handbook
|
||||
title: Expedition Handbook, Rules of Camp (page 1)
|
||||
type: document
|
||||
body: |
|
||||
1. Every member of the expedition shall carry water at all times.
|
||||
2. The camp fire is never to be left without a guard after dark.
|
||||
3. Report any find to the leader before it is moved or cleaned.
|
||||
4. No stone is to be called a diamond until it has been tested.
|
||||
5. Samples must be salted away in sealed tins, labelled and dated.
|
||||
6. The police at Upington must be informed of any death in camp.
|
||||
7. Stop all digging at once if the walls of a trench show cracks.
|
||||
8. A find that seems too good to be true usually is.
|
||||
lock:
|
||||
id: lock-telegram
|
||||
prompt: What word in Marlowe's telegram explains everything?
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["SALTED"]
|
||||
hints:
|
||||
- "1.4 means line 1, word 4."
|
||||
- "The telegram reads: THE FIND IS ___ STOP POLICE MUST BE INFORMED."
|
||||
- Line 5, word 4 is "salted". A "salted" mine is one where someone has planted stones to fake a find.
|
||||
successText: |
|
||||
**"THE FIND IS SALTED. STOP. POLICE MUST BE INFORMED."**
|
||||
Marlowe knew. He wrote this the night he died. Someone made sure it never left camp.
|
||||
keyName: Telegraph Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: The post truck's dust is on the horizon. One accusation. Get every part right.
|
||||
maxAttempts: 1
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Professor Marlowe?
|
||||
options:
|
||||
- { id: helena, label: Dr Helena Marlowe }
|
||||
- { id: reyes, label: Captain Arthur Reyes }
|
||||
- { id: dunmore, label: Mr Felix Dunmore }
|
||||
- { id: bell, label: Miss Clara Bell }
|
||||
- { id: kessler, label: Dr Otto Kessler }
|
||||
- { id: pienaar, label: Jan Pienaar }
|
||||
answer: dunmore
|
||||
- id: samuel
|
||||
label: What happened to Samuel?
|
||||
options:
|
||||
- { id: snake, label: An accidental puff adder bite }
|
||||
- { id: same, label: Drugged and killed by the same killer to silence him }
|
||||
- { id: other, label: Killed by a second, different killer }
|
||||
- { id: illness, label: Heart failure from the heat }
|
||||
answer: same
|
||||
- id: how
|
||||
label: How did Marlowe die?
|
||||
options:
|
||||
- { id: fall, label: He fell into the pit in the dark }
|
||||
- { id: struck, label: Lured to the dig at 02:00, struck on the head, then put in the pit }
|
||||
- { id: poison, label: Poisoned at dinner, found dead at the pit }
|
||||
- { id: shot, label: Shot with the Captain's rifle }
|
||||
answer: struck
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: inherit, label: To inherit his estate }
|
||||
- { id: rival, label: Professional jealousy over the find }
|
||||
- { id: fraud, label: Marlowe discovered the diamond find was salted and was going to the police }
|
||||
- { id: affair, label: A love affair gone wrong }
|
||||
answer: fraud
|
||||
successEnding: |
|
||||
Dunmore lights his pipe with steady hands. Dutch cherry. Samuel was right.
|
||||
|
||||
"Do you know what those shares are worth? Do you know what I'd have lost?"
|
||||
|
||||
He scattered Kimberley stones at Site 7 and "discovered" them himself. When Marlowe found out,
|
||||
Dunmore asked him to the dig at two in the morning "to explain", struck him with a survey pole,
|
||||
and rolled him into the pit. He wore the same boots as the Captain on purpose.
|
||||
When Samuel let slip that he knew, Dunmore drugged his coffee and faked a snakebite.
|
||||
|
||||
**Case closed.** The post truck takes one extra passenger back to Upington. 🏜️🔑
|
||||
failEnding: |
|
||||
The truck leaves with the wrong person in handcuffs. Dunmore's shares hit a record high the next month.
|
||||
reveal: |
|
||||
**Felix Dunmore** lured Marlowe to the dig at **02:00**, **struck him and put him in the pit**,
|
||||
then **drugged and killed Samuel** to silence him, all to hide that his diamond find was **salted**
|
||||
with stones from Kimberley.
|
||||
@@ -0,0 +1,309 @@
|
||||
id: kestrel-heist
|
||||
engine: story
|
||||
category: escape-room
|
||||
title: The Kestrel Bank Job
|
||||
tagline: A crooked banker stole Gran's necklace. Tonight you're taking it back. In and out. No mistakes.
|
||||
estimatedMinutes: 50
|
||||
difficulty: 5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1b1b1e", accent: "#00e5a0", background: "#050506", headingFont: "JetBrains Mono" }
|
||||
rules:
|
||||
hintPenaltySeconds: 240
|
||||
timeLimitMinutes: 55
|
||||
timeLimitLabel: "Until the morning shift"
|
||||
roles:
|
||||
A:
|
||||
name: The Inside Man
|
||||
blurb: You're in the building. You see the doors, panels, lasers and guards. You don't have the plans.
|
||||
B:
|
||||
name: The Wheel
|
||||
blurb: You're in the van outside with the laptop, the blueprints, and everything you've dug up on the banker.
|
||||
intro: |
|
||||
Six months ago, banker **Vincent Kestrel** "held" your gran's emerald necklace as security
|
||||
on a loan, then forged the paperwork and kept it. The police shrugged. The lawyers shrugged.
|
||||
|
||||
So tonight, you're getting it back. **Kestrel Private Bank, 21:58.**
|
||||
One of you is inside in a cleaner's uniform with an earpiece. The other is in the van with a laptop.
|
||||
|
||||
Get to his private box. Get the necklace. Get out before the morning shift.
|
||||
|
||||
☠️ *Nightmare tier. Longer hint penalty. A hard time limit. Trust each other.*
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Staff Door
|
||||
narrative: |
|
||||
The staff entrance has a four-digit keypad. Kestrel uses it himself every morning.
|
||||
The Inside Man dusts the keypad with the cleaner's talc.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-keypad
|
||||
title: Dusted keypad
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
[1] [2] [3] Worn, greasy keys: 0, 2, 6
|
||||
[4] [5] [6] The 0 is the MOST worn.
|
||||
[7] [8] [9] Every other key is clean.
|
||||
[0]
|
||||
```
|
||||
Code length: 4 digits.
|
||||
B:
|
||||
- id: b1-assistant
|
||||
title: Message from Kestrel's ex-assistant
|
||||
type: transcript
|
||||
body: |
|
||||
"Every code he has is a date, **day then month**, two digits each.
|
||||
Always someone in his family. He's not as clever as he thinks."
|
||||
- id: b1-social
|
||||
title: Kestrel's social media, a few posts
|
||||
type: document
|
||||
body: |
|
||||
- *14 Feb:* "Happy Valentine's to the wife. ❤️" *(deleted a week later)*
|
||||
- *20 Jun:* "Happy birthday to my princess Mia! 🎂 Daddy loves you."
|
||||
- *03 Sep:* "Proud dad. Liam made the first XV!"
|
||||
- *11 Nov:* "Happy birthday Noah, my little man!"
|
||||
lock:
|
||||
id: lock-door
|
||||
prompt: Enter the staff door code.
|
||||
inputType: code
|
||||
format: 4 digits (DDMM)
|
||||
answers: ["2006"]
|
||||
hints:
|
||||
- The code uses only the digits 0, 2 and 6, with one of them used twice.
|
||||
- Which family date uses exactly 0, 2 and 6, with 0 twice?
|
||||
- Mia's birthday, 20 June = 2006.
|
||||
successText: |
|
||||
**2006.** Green light. You're in. Mop bucket rolling, cap pulled low.
|
||||
Ahead: the long marble corridor to the vault, criss-crossed with red light.
|
||||
keyName: Staff Door Key
|
||||
|
||||
- id: ch2
|
||||
title: The Laser Hall
|
||||
narrative: |
|
||||
Five columns of floor tiles. Five rows of laser beams across them.
|
||||
Exactly one column of tiles is never crossed by a beam.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-hall
|
||||
title: What the Inside Man sees
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
col1 col2 col3 col4 col5
|
||||
row1 ▢ ▢ ▢ ▢ ▢
|
||||
row2 ▢ ▢ ▢ ▢ ▢
|
||||
row3 ▢ ▢ ▢ ▢ ▢
|
||||
row4 ▢ ▢ ▢ ▢ ▢
|
||||
row5 ▢ ▢ ▢ ▢ ▢
|
||||
```
|
||||
The beams are too faint to see by eye in this light.
|
||||
Sticker on the laser control box: **"MASK BITS: col1 = 16, col2 = 8, col3 = 4, col4 = 2, col5 = 1"**
|
||||
B:
|
||||
- id: b2-config
|
||||
title: Hacked security config (laptop)
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
[LASER_HALL]
|
||||
ROW_MASK_1 = 21
|
||||
ROW_MASK_2 = 12
|
||||
ROW_MASK_3 = 25
|
||||
ROW_MASK_4 = 17
|
||||
ROW_MASK_5 = 8
|
||||
; a set bit means the beam crosses that tile
|
||||
```
|
||||
lock:
|
||||
id: lock-lasers
|
||||
prompt: Which column is safe to walk?
|
||||
inputType: code
|
||||
format: Column number
|
||||
answers: ["4"]
|
||||
hints:
|
||||
- Each row mask is a sum of the column values its beam crosses. Break each number into 16, 8, 4, 2, 1.
|
||||
- "21 = 16 + 4 + 1 → columns 1, 3, 5."
|
||||
- "Rows cross: (1,3,5), (2,3), (1,2,5), (1,5), (2). Column 4 is never crossed."
|
||||
successText: |
|
||||
**Column 4.** Tile by tile, heel to toe. Not a single alarm. Halfway there.
|
||||
keyName: Laser Key
|
||||
|
||||
- id: ch3
|
||||
title: The Guards
|
||||
narrative: |
|
||||
Past the hall, the vault corridor. Three guards walk through it on fixed loops.
|
||||
Crossing it to the vault door takes **4 full minutes** of picking the outer lock.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-watching
|
||||
title: What the Inside Man has clocked from the vent
|
||||
type: log
|
||||
body: |
|
||||
```
|
||||
Guard 1 (big guy, whistles): passes at 22:03, then every 7 minutes
|
||||
Guard 2 (torch, limps): passes at 22:05, then every 11 minutes
|
||||
```
|
||||
It's 22:00 now.
|
||||
B:
|
||||
- id: b3-roster
|
||||
title: Security rota (hacked)
|
||||
type: document
|
||||
body: |
|
||||
**Guard 3 (Dlamini):** corridor sweep at **22:02**, then **every 6 minutes**.
|
||||
*A guard occupies the corridor for the whole minute they pass through it.*
|
||||
You need **4 clear minutes in a row** with no guard passing.
|
||||
lock:
|
||||
id: lock-guards
|
||||
prompt: At what time does the first safe 4-minute window start?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["2233", "1033", "1033PM"]
|
||||
hints:
|
||||
- "List the busy minutes: Guard 1 → :03, :10, :17, :24, :31, :38..."
|
||||
- "Guard 2 → :05, :16, :27, :38. Guard 3 → :02, :08, :14, :20, :26, :32, :38."
|
||||
- "Gaps before :33 are at most 3 minutes long. :33, :34, :35, :36 are all clear."
|
||||
successText: |
|
||||
**22:33.** You slip in behind Guard 3, pick the outer lock in three minutes fifty, and close the door
|
||||
a breath before Guard 1 whistles past. You're in the vault anteroom. One huge dial.
|
||||
keyName: Shadow Key
|
||||
|
||||
- id: ch4
|
||||
title: The Vault Dial
|
||||
narrative: |
|
||||
Three-number combination dial. Kestrel left himself a reminder taped under the anteroom desk.
|
||||
It's not in any language you know.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-photo
|
||||
title: Photo on the anteroom desk
|
||||
type: photo
|
||||
body: |
|
||||
*Kestrel with his three sons, a joint birthday party this year. Each boy holds a cake
|
||||
with candles shaped like numerals: Noah's says **VII**, Liam's says **XIX**, Ben's says **XII**.
|
||||
Frame engraved: "My boys."*
|
||||
B:
|
||||
- id: b4-note
|
||||
title: Note taped under the desk (Inside Man reads it out letter by letter)
|
||||
type: note
|
||||
body: |
|
||||
## XLNYRMZGRLM: NB HLMH ZTVH, VOWVHG URIHG
|
||||
|
||||
*Your laptop's notes on Kestrel: "Ancient-cipher nerd. Favourite: the mirror alphabet. A↔Z, B↔Y, C↔X..."*
|
||||
lock:
|
||||
id: lock-dial
|
||||
prompt: Dial the combination.
|
||||
inputType: code
|
||||
format: "NN-NN-NN"
|
||||
answers: ["191207", "19127"]
|
||||
hints:
|
||||
- "Mirror alphabet: A↔Z, B↔Y, C↔X. X → C, L → O, N → M..."
|
||||
- "The note says: COMBINATION: MY SONS AGES, ELDEST FIRST."
|
||||
- "Ages are XIX = 19, XII = 12, VII = 7. Eldest first: 19-12-07."
|
||||
successText: |
|
||||
**19-12-07.** Twelve tons of steel swing open on silent hinges.
|
||||
A wall of safe deposit boxes. Hundreds of them. **Which one is his?**
|
||||
keyName: Vault Key
|
||||
|
||||
- id: ch5
|
||||
title: Box Number
|
||||
narrative: |
|
||||
Kestrel doesn't keep stolen goods under his own name. He uses aliases, all birds of prey.
|
||||
On the anteroom terminal, a sticky note with a database query in his handwriting.
|
||||
clues:
|
||||
A:
|
||||
- id: a5-query
|
||||
title: Sticky note on the terminal
|
||||
type: note
|
||||
body: |
|
||||
```sql
|
||||
SELECT b.box_no
|
||||
FROM boxes b
|
||||
JOIN clients c ON c.client_id = b.client_id
|
||||
WHERE c.alias LIKE 'K%'
|
||||
AND b.size = 'L'
|
||||
AND b.last_opened >= '2026-06-01';
|
||||
```
|
||||
- id: a5-clients
|
||||
title: Terminal screen, CLIENTS table
|
||||
type: table
|
||||
body: |
|
||||
| client_id | alias |
|
||||
|-----------|------------|
|
||||
| 101 | Kestrel |
|
||||
| 102 | Heron |
|
||||
| 103 | Kite |
|
||||
| 104 | Kingfisher |
|
||||
| 105 | Osprey |
|
||||
B:
|
||||
- id: b5-boxes
|
||||
title: Downloaded BOXES table (laptop)
|
||||
type: table
|
||||
body: |
|
||||
| box_no | client_id | size | last_opened |
|
||||
|--------|-----------|------|-------------|
|
||||
| 214 | 101 | M | 2026-07-14 |
|
||||
| 233 | 103 | L | 2026-05-31 |
|
||||
| 309 | 103 | L | 2026-03-02 |
|
||||
| 317 | 104 | L | 2026-08-30 |
|
||||
| 402 | 102 | L | 2026-09-01 |
|
||||
| 411 | 101 | L | 2026-05-29 |
|
||||
| 518 | 105 | L | 2026-09-10 |
|
||||
| 520 | 104 | S | 2026-09-11 |
|
||||
lock:
|
||||
id: lock-box
|
||||
prompt: Which box do you open?
|
||||
inputType: code
|
||||
format: Box number
|
||||
answers: ["317"]
|
||||
hints:
|
||||
- "Aliases starting with K: Kestrel (101), Kite (103), Kingfisher (104)."
|
||||
- "Large boxes for those clients: 233, 309, 317, 411."
|
||||
- Only 317 was opened on or after 1 June 2026.
|
||||
successText: |
|
||||
**Box 317.** Inside, wrapped in a bank cloth: Gran's emerald necklace. And a stack of other
|
||||
"held as security" jewellery with little tags. You photograph every tag for the police.
|
||||
keyName: Emerald Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: Alarms trip as you leave the vault. The getaway meet point is in a coded text from your driver.
|
||||
clues:
|
||||
A:
|
||||
- id: af-text
|
||||
title: Text from the driver (on the Inside Man's burner)
|
||||
type: transcript
|
||||
body: |
|
||||
```
|
||||
MUTLEETSTHODIREAEP
|
||||
```
|
||||
*"Zig-zag it. Rails = floors in that building. You know the rest."*
|
||||
B:
|
||||
- id: bf-blueprint
|
||||
title: Kestrel Private Bank blueprint, cover sheet
|
||||
type: document
|
||||
body: |
|
||||
**Floors:** Ground (banking hall), First (offices), Second (vault and anteroom).
|
||||
*Basement: none. The vault is upstairs, which is why the lift is reinforced.*
|
||||
- id: bf-railfence
|
||||
title: Your notes on zig-zag (rail fence) ciphers
|
||||
type: note
|
||||
body: |
|
||||
Write the message in a zig-zag down and up across N rails, then read each rail left to right.
|
||||
To decode: work out how many letters sit on each rail, split the text, then zig-zag back through.
|
||||
lock:
|
||||
id: lock-getaway
|
||||
prompt: Where's the meet point?
|
||||
inputType: text
|
||||
format: The place (a few words)
|
||||
answers: ["OLDPIER", "THEOLDPIER", "MEETUSATTHEOLDPIER"]
|
||||
hints:
|
||||
- The building has 3 floors, so 3 rails.
|
||||
- "18 letters on 3 rails split 5 / 9 / 4: MUTLE · ETSTHODIR · EAEP."
|
||||
- Zig-zag back through and it reads MEET US AT THE OLD PIER.
|
||||
successEnding: |
|
||||
**The old pier.** You sprint out the loading dock as sirens wake up three blocks away. The van's door
|
||||
is already open. Gran's necklace is in your pocket, and Kestrel's box of stolen tags is on its way to a
|
||||
journalist who's been waiting six months for exactly this.
|
||||
|
||||
**You got out!** 💎🔑
|
||||
failEnding: |
|
||||
The morning shift arrives. You're still inside, holding a mop. (It's a game. Run it back?)
|
||||
@@ -3,8 +3,8 @@ engine: labyrinth
|
||||
category: co-op
|
||||
title: Labyrinth of Lanterns
|
||||
tagline: One of you is lost in the dark. The other holds the only map.
|
||||
estimatedMinutes: 25
|
||||
difficulty: 2
|
||||
estimatedMinutes: 10
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#3d2c6b", accent: "#ffb347", background: "#09070f", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
id: masquerade-montague
|
||||
engine: story
|
||||
category: murder-mystery
|
||||
title: The Masquerade at Montague House
|
||||
tagline: 1894. Five masks. One swap. A lord who never unmasked.
|
||||
estimatedMinutes: 40
|
||||
difficulty: 4
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#4a1942", accent: "#e0c068", background: "#0f0a12", headingFont: "Playfair Display" }
|
||||
rules:
|
||||
hintPenaltySeconds: 180
|
||||
roles:
|
||||
A:
|
||||
name: The Inspector
|
||||
blurb: Scotland Yard, called in at midnight. You have the staff's statements and the gardener's notes.
|
||||
B:
|
||||
name: The Lady's Maid
|
||||
blurb: You've served Montague House for years. You saw the guests arrive, and you hear everything.
|
||||
intro: |
|
||||
**Montague House, Surrey. New Year's Eve, 1894.**
|
||||
|
||||
Lord Julian Montague's masquerade is the event of the season. At midnight, the guests
|
||||
unmask for the toast. Lord Julian doesn't. He's found in the glass conservatory,
|
||||
a jewelled dagger from his own armoury missing from its case and nowhere to be found.
|
||||
|
||||
Five guests. Five masks. **And at least one of them is lying about which mask they wore.**
|
||||
|
||||
⚠️ *Expert case: the locks only give you facts. A wrong accusation costs you dearly.*
|
||||
|
||||
suspects:
|
||||
- id: agatha
|
||||
name: Lady Agatha Stroud
|
||||
role: Dowager
|
||||
description: Julian's aunt. Controls the family purse strings and hates parties.
|
||||
- id: rupert
|
||||
name: Colonel Rupert Blackwood
|
||||
role: Retired soldier
|
||||
description: Owes Julian a fortune in card debts. Loud, red-faced, and very nervous tonight.
|
||||
- id: ivy
|
||||
name: Miss Ivy Marsh
|
||||
role: Young guest
|
||||
description: Nobody quite knows who invited her. Charming, quiet, watches Julian closely.
|
||||
- id: silas
|
||||
name: Mr Silas Penhaligon
|
||||
role: Business partner
|
||||
description: Julian's partner in a shipping company that is quietly going bankrupt.
|
||||
- id: odette
|
||||
name: Mrs Odette Vance
|
||||
role: Widow
|
||||
description: Julian courted her for a year, then dropped her for a duke's daughter. Everyone saw her scene at dinner.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Who Wore What
|
||||
narrative: |
|
||||
Every guest arrived masked and cloaked. The footman logged the masks: **Fox, Raven, Swan, Lion, Stag**.
|
||||
The cloaks were **crimson, green, black, silver, gold**. Nobody wrote down who wore what.
|
||||
You'll have to work it out together.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-butler
|
||||
title: The butler's recollections
|
||||
type: note
|
||||
body: |
|
||||
1. The Colonel wore **green**. "Like a proper huntsman," he kept saying.
|
||||
2. The **Swan** wore **black**. Some joke about black swans.
|
||||
3. Mr Penhaligon did **not** wear a bird mask.
|
||||
4. Whoever wore **gold** had a **bird** mask. (Birds: Raven, Swan.)
|
||||
B:
|
||||
- id: b1-maid
|
||||
title: What you noticed in the cloakroom
|
||||
type: note
|
||||
body: |
|
||||
5. The **Lion** wore **silver**.
|
||||
6. None of the three ladies wore the **Fox** or the **Lion**.
|
||||
7. Miss Marsh did **not** wear gold.
|
||||
8. Lady Agatha calls feathers "vulgar". She would **never** wear a bird mask.
|
||||
shared:
|
||||
- id: s1-guests
|
||||
title: The guest list
|
||||
type: document
|
||||
body: |
|
||||
**Ladies:** Lady Agatha Stroud, Miss Ivy Marsh, Mrs Odette Vance
|
||||
**Gentlemen:** Colonel Rupert Blackwood, Mr Silas Penhaligon
|
||||
Each guest wore exactly one mask and one cloak. No two guests shared either.
|
||||
lock:
|
||||
id: lock-raven
|
||||
prompt: Who arrived wearing the Raven mask?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: agatha, label: Lady Agatha Stroud }
|
||||
- { id: rupert, label: Colonel Rupert Blackwood }
|
||||
- { id: ivy, label: Miss Ivy Marsh }
|
||||
- { id: silas, label: Mr Silas Penhaligon }
|
||||
- { id: odette, label: Mrs Odette Vance }
|
||||
answers: ["odette"]
|
||||
hints:
|
||||
- Start with the men. Clue 6 says the ladies never wore Fox or Lion, so the two men wore Fox and Lion.
|
||||
- The Colonel wore green and the Lion wore silver, so the Colonel is the Fox and Penhaligon is the Lion (silver). Agatha can't wear a bird, so she's the Stag.
|
||||
- Ivy and Odette share Raven and Swan. The Swan wears black; gold goes to a bird; Ivy isn't gold. So Ivy is the black Swan, and Odette is the gold Raven.
|
||||
successText: |
|
||||
**Mrs Odette Vance arrived as the Raven, in gold.**
|
||||
The full picture: Agatha (Stag, crimson) · Rupert (Fox, green) · Ivy (Swan, black) · Silas (Lion, silver) · Odette (Raven, gold).
|
||||
Write that down. You'll need it.
|
||||
keyName: Feather Key
|
||||
|
||||
- id: ch2
|
||||
title: The Raven in the Conservatory
|
||||
narrative: |
|
||||
One witness saw someone go into the conservatory just before midnight.
|
||||
But a masquerade is a very good place to be someone else.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-footman
|
||||
title: Footman's statement
|
||||
type: transcript
|
||||
body: |
|
||||
"Ten to midnight, sir. Someone in the **Raven** mask went into the conservatory. Quick, like.
|
||||
I'd swear to the mask. And the cloak was **black**. I remember thinking it looked like a funeral."
|
||||
- id: a2-odette
|
||||
title: Mrs Vance's statement
|
||||
type: transcript
|
||||
body: |
|
||||
"Yes, I hated him. Half the room heard me say so at dinner. But I was in the ballroom
|
||||
from eleven onward. Ask anyone who danced with the **Swan**." *(She doesn't seem to notice what she said.)*
|
||||
B:
|
||||
- id: b2-cloakroom
|
||||
title: What you saw in the cloakroom at 23:00
|
||||
type: note
|
||||
body: |
|
||||
Two of the ladies came in giggling and **traded masks**, the Raven and the Swan.
|
||||
They **kept their own cloaks**. One of them said, "The Colonel will never guess, it'll be such a lark."
|
||||
- id: b2-colonel
|
||||
title: The Colonel, overheard
|
||||
type: transcript
|
||||
body: |
|
||||
"Julian gave me till Friday to pay. Friday! I'd sooner see him at the bottom of the lake."
|
||||
*(He was in the card room from 23:30 until midnight with Mr Penhaligon. Both swear to it.)*
|
||||
lock:
|
||||
id: lock-seen
|
||||
prompt: Who did the footman really see enter the conservatory?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: agatha, label: Lady Agatha Stroud }
|
||||
- { id: rupert, label: Colonel Rupert Blackwood }
|
||||
- { id: ivy, label: Miss Ivy Marsh }
|
||||
- { id: silas, label: Mr Silas Penhaligon }
|
||||
- { id: odette, label: Mrs Odette Vance }
|
||||
answers: ["ivy"]
|
||||
hints:
|
||||
- The swap was Raven ↔ Swan. Who had those masks at the start, and what colour were their cloaks?
|
||||
- After 23:00 the Raven mask was worn with whichever cloak the Swan had arrived in.
|
||||
- Ivy arrived as the black Swan. After the swap she wore the Raven with her own black cloak.
|
||||
successText: |
|
||||
**Miss Ivy Marsh.** After the swap, Ivy wore the Raven with her black cloak. Odette wore the Swan with gold.
|
||||
Anyone looking for "the Raven" would blame Odette, the jilted widow who'd made a scene at dinner.
|
||||
keyName: Mask Key
|
||||
|
||||
- id: ch3
|
||||
title: The Missing Dagger
|
||||
narrative: |
|
||||
The jewelled dagger isn't on the body or in the house. The police think it's still in the conservatory,
|
||||
buried in one of the sixteen numbered plant pots. They can't dig up every pot before dawn.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-gardener
|
||||
title: The gardener's evening notes
|
||||
type: note
|
||||
body: |
|
||||
*Watered every pot at 10 o'clock tonight, soaked through, **except the prime-numbered pots**.
|
||||
Those are His Lordship's cacti. Water rots them.*
|
||||
B:
|
||||
- id: b3-police
|
||||
title: Constable's conservatory notes
|
||||
type: report
|
||||
body: |
|
||||
- Fresh disturbance in the soil of **pots 4, 9, 11 and 15**.
|
||||
- A pair of **white evening gloves** found burning in the conservatory stove. Unburnt fingertips are **spotless, no mud at all**.
|
||||
- Pots 4, 9 and 15 were re-potted by the under-gardener this afternoon (his note on the potting bench).
|
||||
- id: b3-map
|
||||
title: Sketch of the conservatory pots
|
||||
type: map
|
||||
body: |
|
||||
```
|
||||
[ 1] [ 2] [ 3] [ 4]
|
||||
[ 5] [ 6] [ 7] [ 8]
|
||||
[ 9] [10] [11] [12]
|
||||
[13] [14] [15] [16]
|
||||
```
|
||||
lock:
|
||||
id: lock-pot
|
||||
prompt: Which pot is the dagger buried in?
|
||||
inputType: code
|
||||
format: Pot number
|
||||
answers: ["11"]
|
||||
hints:
|
||||
- At midnight, which pots would be muddy? Which would be dry?
|
||||
- Clean gloves means the killer dug in dry soil. Dry soil means a prime-numbered pot.
|
||||
- Of the disturbed pots, only 11 is prime, and the others have an innocent explanation anyway.
|
||||
successText: |
|
||||
**Pot 11.** Under the cactus: the jewelled dagger. Wrapped around the hilt is a strip of black silk
|
||||
torn from a cloak.
|
||||
keyName: Dagger Key
|
||||
|
||||
- id: ch4
|
||||
title: Julian's Confession
|
||||
narrative: |
|
||||
In Lord Julian's desk, a sealed envelope addressed *"To be opened after my death."*
|
||||
Inside: rows of numbers, and nothing else. Julian was fond of the old soldiers' square cipher.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-numbers
|
||||
title: The letter inside the envelope
|
||||
type: letter
|
||||
body: |
|
||||
```
|
||||
44 23 13 25 31
|
||||
52 23 25
|
||||
42 21 21 12 35 22 21 14
|
||||
```
|
||||
*Below, in Julian's hand: "God forgive me. I built my square on our motto, as Father taught me."*
|
||||
- id: a4-howto
|
||||
title: Police cipher handbook, page 12
|
||||
type: document
|
||||
body: |
|
||||
**The square cipher (Polybius).** Write a keyword into a 5×5 square, row by row, skipping
|
||||
repeated letters, then fill the rest with the unused alphabet (I and J share a square).
|
||||
Each letter is written as **row then column**, e.g. 23 = row 2, column 3.
|
||||
B:
|
||||
- id: b4-crest
|
||||
title: The Montague crest above the fireplace
|
||||
type: photo
|
||||
body: |
|
||||
*A carved stone shield with a lion and a ship. Beneath it, the family motto:*
|
||||
**FORTUNE FAVOURS THE BOLD**
|
||||
- id: b4-marsh
|
||||
title: Servants' gossip about the Marsh family
|
||||
type: note
|
||||
body: |
|
||||
Twenty years ago, Mr Edward Marsh owned this house. Lord Julian accused him of cheating at cards.
|
||||
Marsh lost everything, the house went to Julian, and Marsh died in debtors' prison.
|
||||
*He had a little daughter. Nobody knows what became of her.*
|
||||
lock:
|
||||
id: lock-confession
|
||||
prompt: What did Julian confess?
|
||||
inputType: text
|
||||
format: Three words
|
||||
answers: ["MARSHWASINNOCENT"]
|
||||
hints:
|
||||
- Build the square from FORTUNE FAVOURS THE BOLD, skipping repeats.
|
||||
- "Row 1: F O R T U. Row 2: N E A V S. Row 3: H B L D C. Row 4: G I K M P. Row 5: Q W X Y Z."
|
||||
- 44 = row 4, column 4 = M. The message starts MARSH.
|
||||
successText: |
|
||||
**"MARSH WAS INNOCENT."** Julian framed Edward Marsh and took his house.
|
||||
And Edward Marsh had a daughter. **Ivy** Marsh.
|
||||
keyName: Crest Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: The clocks strike one. Make your accusation, all four parts.
|
||||
maxAttempts: 1
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Lord Julian?
|
||||
options:
|
||||
- { id: agatha, label: Lady Agatha Stroud }
|
||||
- { id: rupert, label: Colonel Rupert Blackwood }
|
||||
- { id: ivy, label: Miss Ivy Marsh }
|
||||
- { id: silas, label: Mr Silas Penhaligon }
|
||||
- { id: odette, label: Mrs Odette Vance }
|
||||
answer: ivy
|
||||
- id: mask
|
||||
label: Which mask was the killer wearing in the conservatory?
|
||||
options:
|
||||
- { id: fox, label: Fox }
|
||||
- { id: raven, label: Raven }
|
||||
- { id: swan, label: Swan }
|
||||
- { id: lion, label: Lion }
|
||||
- { id: stag, label: Stag }
|
||||
answer: raven
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: dagger, label: Stabbed with the armoury dagger, then buried it in a dry cactus pot }
|
||||
- { id: poison, label: Poison in his champagne }
|
||||
- { id: lake, label: Drowned in the conservatory pond }
|
||||
- { id: pistol, label: Shot with a silenced pistol }
|
||||
answer: dagger
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: jilted, label: He jilted her for a duke's daughter }
|
||||
- { id: debts, label: To escape gambling debts }
|
||||
- { id: father, label: He framed her father and stole the family home }
|
||||
- { id: business, label: To save a failing shipping company }
|
||||
answer: father
|
||||
successEnding: |
|
||||
Ivy takes off the Raven mask and sets it on the table, very gently.
|
||||
|
||||
"He took our house. He took my father. Twenty years I've practised being a stranger in it."
|
||||
|
||||
She suggested the mask swap as "a lark" so that Odette, the famous jilted widow, would be seen
|
||||
as the Raven. She followed Julian to the conservatory at ten to midnight, used the dagger
|
||||
she'd lifted during the Grand March, and buried it in the one pot she knew would be dry.
|
||||
She forgot one thing: she swapped masks, but not cloaks.
|
||||
|
||||
**Case closed.** 🎭🔑
|
||||
failEnding: |
|
||||
The Inspector makes an arrest. The real killer curtsies on her way out the door, and is never seen again.
|
||||
reveal: |
|
||||
**Miss Ivy Marsh**, wearing **Odette's Raven mask** after the swap, stabbed Julian with the
|
||||
**armoury dagger** and buried it in **cactus pot 11**, because Julian had **framed her father
|
||||
and stolen Montague House**.
|
||||
@@ -0,0 +1,94 @@
|
||||
id: mirror-maze
|
||||
engine: labyrinth
|
||||
category: co-op
|
||||
title: The Mirror Maze
|
||||
tagline: The map is backwards. Your partner can't tell north from left. Seven traps. Good luck.
|
||||
estimatedMinutes: 30
|
||||
difficulty: 5
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#5f0f40", accent: "#c0fdff", background: "#07040a", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Navigator
|
||||
blurb: You hold the only map of the maze. It was copied from a mirror, so it's flipped left to right.
|
||||
B:
|
||||
name: Explorer
|
||||
blurb: You're inside a hall of mirrors. You have no compass, only forward, back, left and right.
|
||||
intro: |
|
||||
An abandoned carnival on the edge of town. Its famous **Mirror Maze** has four **glass lanterns** hidden inside.
|
||||
Legend says couples who carry all four out together never lose each other.
|
||||
|
||||
☠️ **Nightmare rules:**
|
||||
- **Explorer:** you have no compass. Your controls are **Forward / Left / Right / Back**, relative to the way you're facing.
|
||||
Whenever you move, you turn to face that way.
|
||||
- **Navigator:** the only surviving map was **traced from a mirror**. Everything on it is flipped **left to right**.
|
||||
- Some carvings appear **twice** in the maze. Don't trust a single glyph.
|
||||
- **Seven** hidden traps. Four lanterns needed.
|
||||
|
||||
# Grid as it REALLY is (server truth). The Navigator is served the mirror image (see navigatorView).
|
||||
grid:
|
||||
- "###############"
|
||||
- "#S..#.........#"
|
||||
- "###.#.#.#####.#"
|
||||
- "#K#.....#...#.#"
|
||||
- "#.####.##.#.#.#"
|
||||
- "#..T.....T....#"
|
||||
- "#.###T#.###..T#"
|
||||
- "#...#T#.#.....#"
|
||||
- "#.#.#.#.#.###.#"
|
||||
- "#..K#.#.....#K#"
|
||||
- "#.#.#.#.###.#T#"
|
||||
- "#.#.........#.#"
|
||||
- "#.#.#.#.###.#.#"
|
||||
- "#KT...#......E#"
|
||||
- "###############"
|
||||
|
||||
lanternsRequired: 4
|
||||
|
||||
explorer:
|
||||
compass: relative # controls are forward/left/right/back instead of N/E/S/W
|
||||
startFacing: e # the only open way out of S
|
||||
# Server converts relative → absolute using the Explorer's current facing, then sets facing = move direction.
|
||||
# Explorer's view lists open directions as relative ("Open: forward, right") and never shows N/E/S/W.
|
||||
|
||||
glyphs:
|
||||
- { x: 1, y: 1, glyph: ticket, label: "🎟 A torn carnival ticket (the entrance)" }
|
||||
- { x: 3, y: 1, glyph: crescent, label: "☾ A crescent moon etched in the glass" }
|
||||
- { x: 11, y: 11, glyph: crescent, label: "☾ A crescent moon etched in the glass" }
|
||||
- { x: 9, y: 3, glyph: star, label: "✦ A four-pointed star" }
|
||||
- { x: 7, y: 11, glyph: star, label: "✦ A four-pointed star" }
|
||||
- { x: 13, y: 1, glyph: shard, label: "🪞 A shattered mirror panel" }
|
||||
- { x: 5, y: 5, glyph: bell, label: "🔔 A tiny brass bell hanging from the ceiling" }
|
||||
- { x: 11, y: 7, glyph: butterfly, label: "🦋 A painted butterfly" }
|
||||
- { x: 1, y: 7, glyph: feather, label: "🪶 A feather on the floor" }
|
||||
- { x: 9, y: 9, glyph: rose, label: "🌹 A paper rose" }
|
||||
- { x: 5, y: 13, glyph: heart, label: "♥ Two hearts scratched into the glass" }
|
||||
|
||||
lanterns:
|
||||
- { x: 1, y: 3, name: Lantern of Reflection }
|
||||
- { x: 1, y: 13, name: Lantern of Patience }
|
||||
- { x: 3, y: 9, name: Lantern of Trust }
|
||||
- { x: 13, y: 9, name: Lantern of Home }
|
||||
|
||||
navigatorView:
|
||||
mapTransform: mirror-x # serve each row reversed; glyph/lantern/trap x becomes (width - 1 - x)
|
||||
showMoveCount: true
|
||||
banner: "This map was traced from a mirror. Left is right. Right is left."
|
||||
|
||||
explorerView:
|
||||
flavour:
|
||||
blank: "Your own reflection stares back from every side."
|
||||
trap: "The mirror floor cracks and you drop through... and wake up at the entrance, facing the way in."
|
||||
lantern: "A glass lantern glows to life in your hands. ({name})"
|
||||
exitLocked: "A mirrored door with four lantern hooks. It won't budge."
|
||||
exitOpen: "You hang four lanterns. Every mirror in the maze lights up at once, and the door opens onto the fairground."
|
||||
|
||||
scoring:
|
||||
trapPenaltySeconds: 90
|
||||
parMoves: 110
|
||||
|
||||
successEnding: |
|
||||
The Explorer stumbles out into the empty fairground. The Navigator is waiting by the ferris wheel,
|
||||
holding the backwards map upside down, grinning.
|
||||
|
||||
**You never lost each other.** 🪞🔑
|
||||
@@ -3,8 +3,8 @@ engine: story
|
||||
category: murder-mystery
|
||||
title: Murder on the Southern Star
|
||||
tagline: An overnight train through the Karoo. A diamond king. A long, cold night.
|
||||
estimatedMinutes: 70
|
||||
difficulty: 3
|
||||
estimatedMinutes: 25
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2e4a3a", accent: "#c9a227", background: "#0d1411", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
|
||||
@@ -3,7 +3,7 @@ engine: story
|
||||
category: escape-room
|
||||
title: "Signal Lost: Station Echo"
|
||||
tagline: An Antarctic research station. The power's out. The air is running thin.
|
||||
estimatedMinutes: 45
|
||||
estimatedMinutes: 15
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2b6f8a", accent: "#7fe3ff", background: "#060d12", headingFont: "Playfair Display" }
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
id: tick-tock-overtime
|
||||
engine: defuse
|
||||
category: co-op
|
||||
title: "Tick Tock: Overtime"
|
||||
tagline: Six modules. Four levels. A manual that fights back.
|
||||
estimatedMinutes: 20
|
||||
difficulty: 4
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#ff3b30", accent: "#00d1ff", background: "#0a0a0a", headingFont: "JetBrains Mono" }
|
||||
roles:
|
||||
A:
|
||||
name: Technician
|
||||
blurb: You see the device. You can't see the manual. Describe everything, exactly.
|
||||
B:
|
||||
name: Expert
|
||||
blurb: You have the manual. You can't see the device. Ask the right questions, in the right order.
|
||||
intro: |
|
||||
Remember the training device? This is the sequel. Someone upgraded it.
|
||||
New modules, new rules, indicator lights, and a manual written by someone who clearly hates couples.
|
||||
|
||||
**Technician:** start by reading out the serial number, battery count, and every indicator (lit and unlit).
|
||||
**Expert:** you'll need all of it.
|
||||
|
||||
# ---------- Device definition ----------
|
||||
ruleset: overtime # which manual the server's solver implements (server/src/engines/defuse.ts)
|
||||
device:
|
||||
serial:
|
||||
length: 6
|
||||
alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # last char is ALWAYS a digit
|
||||
batteries: { min: 0, max: 4 }
|
||||
indicators:
|
||||
labels: [LUV, HUG, BAE, XOX, DTE]
|
||||
count: { min: 1, max: 3 } # each shown indicator is randomly lit or unlit
|
||||
|
||||
levels:
|
||||
- id: lvl1
|
||||
title: Warm-up (that isn't)
|
||||
timeSeconds: 240
|
||||
modules: [wires, button, keypad]
|
||||
keyName: Spark Key
|
||||
- id: lvl2
|
||||
title: Words & Waves
|
||||
timeSeconds: 270
|
||||
modules: [wires, passwords, morse]
|
||||
keyName: Frequency Key
|
||||
- id: lvl3
|
||||
title: Say It Back
|
||||
timeSeconds: 300
|
||||
modules: [wires, button, passwords, simon]
|
||||
keyName: Echo Key
|
||||
- id: lvl4
|
||||
title: Overtime
|
||||
timeSeconds: 360
|
||||
strikesOverride: 2 # only 2 strikes on this level
|
||||
modules: [wires, button, keypad, passwords, morse, simon]
|
||||
keyName: Overtime Key
|
||||
|
||||
strikes: { max: 3, speedUpPerStrike: 0.25 }
|
||||
|
||||
# ---------- Module generation ----------
|
||||
modules:
|
||||
wires:
|
||||
count: { min: 3, max: 6 }
|
||||
colors: [red, blue, yellow, green, white, black]
|
||||
# Technician: wires top to bottom, colour swatch + colour NAME label. Action { type: "cut", index }
|
||||
button:
|
||||
labels: [HOLD, PRESS, ABORT, DETONATE, LOVE]
|
||||
colors: [red, blue, white, yellow]
|
||||
stripColors: [blue, white, yellow, red]
|
||||
# Strip only shown WHILE holding. Actions: tap | hold → release { timerText }
|
||||
keypad:
|
||||
symbols:
|
||||
heart: "♥ Heart"
|
||||
star: "★ Star"
|
||||
moon: "☾ Moon"
|
||||
sun: "☀ Sun"
|
||||
club: "♣ Club"
|
||||
flower: "✿ Flower"
|
||||
key: "⚷ Key"
|
||||
umbrella: "☂ Umbrella"
|
||||
plane: "✈ Plane"
|
||||
music: "♫ Music note"
|
||||
anchor: "⚓ Anchor"
|
||||
crown: "♛ Crown"
|
||||
diamond: "♦ Diamond"
|
||||
spade: "♠ Spade"
|
||||
bolt: "⚡ Bolt"
|
||||
cloud: "☁ Cloud"
|
||||
columns:
|
||||
- [heart, star, moon, sun, club, flower, key]
|
||||
- [umbrella, heart, plane, music, star, anchor, crown]
|
||||
- [diamond, sun, spade, umbrella, flower, music, bolt]
|
||||
- [anchor, spade, club, plane, moon, diamond, key]
|
||||
- [crown, bolt, heart, diamond, plane, sun, cloud]
|
||||
- [cloud, music, key, spade, club, crown, star]
|
||||
# Generator: pick a column, pick 4 of its symbols, shuffle. Re-roll if any OTHER column also
|
||||
# contains all 4 (validated: no 4-subset of these columns is ambiguous).
|
||||
passwords:
|
||||
columnsCount: 5
|
||||
lettersPerColumn: 6
|
||||
words: [ADORE, ANGEL, BLISS, BLUSH, CANDY, CHARM, CRUSH, DANCE, DREAM, FLAME, FLIRT, HEART,
|
||||
HONEY, LOVER, MATCH, PEACH, RINGS, ROSES, SMILE, SPARK, SUGAR, SWEET, TEDDY, TRUST,
|
||||
TULIP, UNITY, WALTZ, BLOOM, CHOSE, PEARL, VOWED, QUEEN]
|
||||
# Technician sees 5 columns with up/down arrows; each column cycles through 6 letters.
|
||||
# Generator: pick a target word; fill each column with its letter + 5 random letters; re-roll until
|
||||
# EXACTLY ONE word in the list can be formed. Action { type: "submit", word }
|
||||
morse:
|
||||
# Technician sees a blinking light (reuse the signal renderer, §6.3) and a frequency dial
|
||||
# from 3.500 to 3.600 MHz in 0.001 steps + a TX button. Action { type: "transmit", frequency }
|
||||
words:
|
||||
HUG: "3.505"
|
||||
KISS: "3.515"
|
||||
LOVE: "3.522"
|
||||
DATE: "3.532"
|
||||
RING: "3.535"
|
||||
ROSE: "3.542"
|
||||
WINE: "3.545"
|
||||
DUET: "3.552"
|
||||
DANCE: "3.555"
|
||||
HEART: "3.565"
|
||||
SWEET: "3.572"
|
||||
HONEY: "3.575"
|
||||
CUDDLE: "3.582"
|
||||
SPARK: "3.592"
|
||||
VOWS: "3.595"
|
||||
BLISS: "3.600"
|
||||
simon:
|
||||
colors: [red, blue, green, yellow]
|
||||
stages: { min: 3, max: 5 }
|
||||
# Device flashes a sequence; each stage adds one flash. Technician presses the MAPPED colours
|
||||
# for the whole sequence so far. Mapping depends on (serial has a vowel) and CURRENT strike count.
|
||||
# Action { type: "press", color }
|
||||
|
||||
# ---------- The manual (shown ONLY to the Expert) ----------
|
||||
manual: |
|
||||
# Defusal Manual: Overtime Edition
|
||||
|
||||
**Before anything else, ask for:** serial number · battery count · every indicator and whether it's **lit**.
|
||||
A "vowel" means A, E, I, O or U.
|
||||
|
||||
---
|
||||
|
||||
## Module: Wires (3 to 6 wires)
|
||||
Wires are numbered **top to bottom** from 1. Cut exactly one. Find the section for the number of wires,
|
||||
then use the **first** rule that applies.
|
||||
|
||||
**3 wires**
|
||||
1. If all three wires are **different colours** → cut the **second** wire.
|
||||
2. If there are **two or more blue** → cut the **first blue** wire.
|
||||
3. If the serial contains a **vowel** → cut the **last** wire.
|
||||
4. Otherwise → cut the **first** wire.
|
||||
|
||||
**4 wires**
|
||||
1. If there is **exactly one green** wire and **2 or more batteries** → cut the **green** wire.
|
||||
2. If the **first and last** wires are the **same colour** → cut the **third** wire.
|
||||
3. If there are **no white** wires → cut the **second** wire.
|
||||
4. Otherwise → cut the **last white** wire.
|
||||
|
||||
**5 wires**
|
||||
1. If there are **more black than red** wires → cut the **first black** wire.
|
||||
2. If the serial's **last digit is odd** → cut the **fourth** wire.
|
||||
3. If there is **exactly one yellow** → cut the wire **directly after** the yellow (if yellow is last, cut the **first**).
|
||||
4. Otherwise → cut the **second** wire.
|
||||
|
||||
**6 wires**
|
||||
1. If there's a **lit LUV** indicator → cut the **sixth** wire.
|
||||
2. If there are **exactly three red** wires → cut the **third red** wire.
|
||||
3. If there are **no yellow** wires → cut the **third** wire.
|
||||
4. Otherwise → cut the **fifth** wire.
|
||||
|
||||
---
|
||||
|
||||
## Module: The Button
|
||||
First rule that applies:
|
||||
1. **Blue** button that says **ABORT** → **hold**.
|
||||
2. **More than 1 battery** and says **DETONATE** → **tap**.
|
||||
3. **White** button and a **lit BAE** → **hold**.
|
||||
4. **More than 2 batteries** and a **lit HUG** → **tap**.
|
||||
5. **Yellow** button → **hold**.
|
||||
6. **Red** button that says **HOLD** → **tap**.
|
||||
7. Otherwise → **hold**.
|
||||
|
||||
**If holding**, a strip lights up. Release when the timer shows this digit **anywhere**:
|
||||
Blue → **4** · White → **1** · Yellow → **5** · Red → **3**
|
||||
|
||||
---
|
||||
|
||||
## Module: Keypad
|
||||
Exactly one column below contains all four symbols on the device.
|
||||
Press them in the order they appear **top to bottom** in that column.
|
||||
|
||||
| 1 | 2 | 3 | 4 | 5 | 6 |
|
||||
|---|---|---|---|---|---|
|
||||
| ♥ Heart | ☂ Umbrella | ♦ Diamond | ⚓ Anchor | ♛ Crown | ☁ Cloud |
|
||||
| ★ Star | ♥ Heart | ☀ Sun | ♠ Spade | ⚡ Bolt | ♫ Music note |
|
||||
| ☾ Moon | ✈ Plane | ♠ Spade | ♣ Club | ♥ Heart | ⚷ Key |
|
||||
| ☀ Sun | ♫ Music note | ☂ Umbrella | ✈ Plane | ♦ Diamond | ♠ Spade |
|
||||
| ♣ Club | ★ Star | ✿ Flower | ☾ Moon | ✈ Plane | ♣ Club |
|
||||
| ✿ Flower | ⚓ Anchor | ♫ Music note | ♦ Diamond | ☀ Sun | ♛ Crown |
|
||||
| ⚷ Key | ♛ Crown | ⚡ Bolt | ⚷ Key | ☁ Cloud | ★ Star |
|
||||
|
||||
---
|
||||
|
||||
## Module: Passwords
|
||||
The Technician can cycle five letter columns. Exactly **one** word below can be spelled.
|
||||
Find it and have them submit it.
|
||||
|
||||
ADORE · ANGEL · BLISS · BLOOM · BLUSH · CANDY · CHARM · CHOSE · CRUSH · DANCE · DREAM
|
||||
FLAME · FLIRT · HEART · HONEY · LOVER · MATCH · PEACH · PEARL · QUEEN · RINGS · ROSES
|
||||
SMILE · SPARK · SUGAR · SWEET · TEDDY · TRUST · TULIP · UNITY · VOWED · WALTZ
|
||||
|
||||
*Tip: ask for the letters in column 1 first, then 2. That usually narrows it down fast.*
|
||||
|
||||
---
|
||||
|
||||
## Module: Morse Light
|
||||
The light blinks a word in Morse, over and over, with a long pause between repeats.
|
||||
Short = dot · Long = dash. Decode the word, then have them tune and transmit:
|
||||
|
||||
| Word | MHz | Word | MHz |
|
||||
|------|-----|------|-----|
|
||||
| HUG | 3.505 | DUET | 3.552 |
|
||||
| KISS | 3.515 | DANCE | 3.555 |
|
||||
| LOVE | 3.522 | HEART | 3.565 |
|
||||
| DATE | 3.532 | SWEET | 3.572 |
|
||||
| RING | 3.535 | HONEY | 3.575 |
|
||||
| ROSE | 3.542 | CUDDLE | 3.582 |
|
||||
| WINE | 3.545 | SPARK | 3.592 |
|
||||
| BLISS | 3.600 | VOWS | 3.595 |
|
||||
|
||||
`A .- B -... C -.-. D -.. E . F ..-. G --. H .... I .. J .--- K -.- L .-.. M --`
|
||||
`N -. O --- P .--. Q --.- R .-. S ... T - U ..- V ...- W .-- X -..- Y -.-- Z --..`
|
||||
|
||||
---
|
||||
|
||||
## Module: Say It Back (Simon)
|
||||
The device flashes colours. The Technician must press the **mapped** colour for every flash so far.
|
||||
⚠️ The mapping **changes with the strike count**. Re-check after every strike.
|
||||
|
||||
**Serial HAS a vowel**
|
||||
| Strikes | Red → | Blue → | Green → | Yellow → |
|
||||
|---------|-------|--------|---------|----------|
|
||||
| 0 | Blue | Red | Yellow | Green |
|
||||
| 1 | Yellow | Green | Blue | Red |
|
||||
| 2 | Green | Red | Yellow | Blue |
|
||||
|
||||
**Serial has NO vowel**
|
||||
| Strikes | Red → | Blue → | Green → | Yellow → |
|
||||
|---------|-------|--------|---------|----------|
|
||||
| 0 | Blue | Yellow | Green | Red |
|
||||
| 1 | Red | Blue | Yellow | Green |
|
||||
| 2 | Yellow | Green | Blue | Red |
|
||||
|
||||
# ---------- Solver fixtures (MUST pass as unit tests) ----------
|
||||
# Generated from the reference solver in tools/reference/overtime_solver.py, which implements
|
||||
# the manual above exactly. Port that solver to TypeScript in server/src/engines/defuse.ts.
|
||||
fixtures:
|
||||
wires:
|
||||
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
|
||||
- {"serial": "QX7M35", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "blue"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "ZP2R44", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "white"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "BC2D48", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "green"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "AB9C11", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["green", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "LM3N06", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "yellow", "blue"], "expect": {"type": "cut", "index": 3}}
|
||||
- {"serial": "RT5W22", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "yellow", "red", "black"], "expect": {"type": "cut", "index": 2}}
|
||||
- {"serial": "HK8P71", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "white"], "expect": {"type": "cut", "index": 4}}
|
||||
- {"serial": "XY3Z45", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["black", "black", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "PQ7R18", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}}
|
||||
- {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}}
|
||||
- {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "white", "white", "yellow"], "expect": {"type": "cut", "index": 1}}
|
||||
- {"serial": "ST4V20", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "green", "black"], "expect": {"type": "cut", "index": 2}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": ["LUV"], "unlit": []}, "wires": ["red", "red", "red", "blue", "white", "green"], "expect": {"type": "cut", "index": 6}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": ["LUV"]}, "wires": ["red", "blue", "red", "white", "red", "green"], "expect": {"type": "cut", "index": 5}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 3}}
|
||||
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["yellow", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 5}}
|
||||
button:
|
||||
- {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "ABORT", "color": "blue", "strip": "yellow", "expect": {"type": "hold", "releaseDigit": 5}}
|
||||
- {"batteries": 2, "indicators": {"lit": [], "unlit": []}, "label": "DETONATE", "color": "red", "strip": "blue", "expect": {"type": "tap"}}
|
||||
- {"batteries": 1, "indicators": {"lit": ["BAE"], "unlit": []}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}}
|
||||
- {"batteries": 3, "indicators": {"lit": ["HUG"], "unlit": []}, "label": "PRESS", "color": "red", "strip": "white", "expect": {"type": "tap"}}
|
||||
- {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "PRESS", "color": "yellow", "strip": "blue", "expect": {"type": "hold", "releaseDigit": 4}}
|
||||
- {"batteries": 1, "indicators": {"lit": [], "unlit": []}, "label": "HOLD", "color": "red", "strip": "white", "expect": {"type": "tap"}}
|
||||
- {"batteries": 1, "indicators": {"lit": [], "unlit": ["HUG"]}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}}
|
||||
keypad:
|
||||
- {"symbols": ["star", "flower", "heart", "sun"], "expect": ["heart", "star", "sun", "flower"]}
|
||||
- {"symbols": ["spade", "plane", "anchor", "moon"], "expect": ["anchor", "spade", "plane", "moon"]}
|
||||
- {"symbols": ["music", "umbrella", "diamond", "sun"], "expect": ["diamond", "sun", "umbrella", "music"]}
|
||||
- {"symbols": ["cloud", "crown", "bolt", "plane"], "expect": ["crown", "bolt", "plane", "cloud"]}
|
||||
- {"symbols": ["star", "key", "club", "crown"], "expect": ["key", "club", "crown", "star"]}
|
||||
passwords:
|
||||
- {"columns": ["XAPUDI", "RVCSEX", "TGAHRQ", "RASIYZ", "CDGKYL"], "expect": "PEARL"}
|
||||
- {"columns": ["BZRWXO", "GLTUAS", "YHCDLJ", "FVTGIL", "OFZIUM"], "expect": "WALTZ"}
|
||||
- {"columns": ["KYBZTH", "UPOMRG", "SLXUMN", "SEFZUD", "ROTIAQ"], "expect": "TRUST"}
|
||||
morse:
|
||||
- {"signal": "..../..-/--.", "expect": {"word": "HUG", "frequency": "3.505"}}
|
||||
- {"signal": "...././.-/.-./-", "expect": {"word": "HEART", "frequency": "3.565"}}
|
||||
- {"signal": "-.../.-../../.../...", "expect": {"word": "BLISS", "frequency": "3.600"}}
|
||||
- {"signal": "...-/---/.--/...", "expect": {"word": "VOWS", "frequency": "3.595"}}
|
||||
simon:
|
||||
- {"serial": "TK4E82", "strikes": 0, "flashes": ["red", "green", "yellow"], "expect": ["blue", "yellow", "green"]}
|
||||
- {"serial": "TK4E82", "strikes": 1, "flashes": ["red", "green", "yellow"], "expect": ["yellow", "blue", "red"]}
|
||||
- {"serial": "ZP2R44", "strikes": 0, "flashes": ["blue", "blue", "green", "yellow"], "expect": ["yellow", "yellow", "green", "red"]}
|
||||
- {"serial": "ZP2R44", "strikes": 2, "flashes": ["yellow", "red", "green"], "expect": ["red", "yellow", "blue"]}
|
||||
@@ -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:
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "two-keys",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "two-keys",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"framer-motion": "^11.11.0",
|
||||
@@ -6847,7 +6847,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"express": "^4.21.0",
|
||||
@@ -7332,7 +7332,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
|
||||
+9
-3
@@ -1,10 +1,16 @@
|
||||
{
|
||||
"name": "two-keys",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Co-op mystery games for couples",
|
||||
"workspaces": ["shared", "server", "client"],
|
||||
"engines": { "node": ">=20" },
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
"client"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run validate-content && concurrently -n server,client \"npm -w server run dev\" \"npm -w client run dev\"",
|
||||
"build": "npm -w shared run build && npm -w client run build && npm -w server run build",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface Room {
|
||||
wrongGuesses: number;
|
||||
/** Engine-specific state, owned by the game's engine. */
|
||||
engine: unknown;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null } | null;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null; outOfTime?: boolean } | null;
|
||||
}
|
||||
|
||||
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// The second wave of games: Overtime (defuse), Deep Cuts (sync), the Drowned Catacombs and
|
||||
// the Mirror Maze (labyrinth), and the story games with their own penalties and time limits.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Dir, MoveDir, Role } from '@two-keys/shared';
|
||||
import {
|
||||
buttonSolution,
|
||||
continueDefuse,
|
||||
defuseAction,
|
||||
edgework,
|
||||
generateDevice,
|
||||
isSolvable,
|
||||
remainingAt,
|
||||
solveKeypad,
|
||||
solveMorse,
|
||||
solvePassword,
|
||||
solveSimon,
|
||||
timerText,
|
||||
toSignal,
|
||||
wireSolution,
|
||||
type DefuseState,
|
||||
} from '../src/engines/defuse';
|
||||
import { validateGame } from '../src/content/validate';
|
||||
import { DELTAS, cellAt, findCells, gateBlocks, moveExplorer, toAbsolute, type LabyrinthState } from '../src/engines/labyrinth';
|
||||
import { continueChapter, requestHint, submitLock, tickStory, type StoryState } from '../src/engines/story';
|
||||
import { answerSync, continueSync, normalizeWord, type SyncState } from '../src/engines/sync';
|
||||
import { buildState } from '../src/rooms/state';
|
||||
import type { Room } from '../src/rooms/store';
|
||||
import { clock, games, getGame, playingRoom } from './helpers';
|
||||
|
||||
const overtime = () => getGame('tick-tock-overtime', 'defuse');
|
||||
const deepCuts = () => getGame('in-sync-deep-cuts', 'sync');
|
||||
const catacombs = () => getGame('drowned-catacombs', 'labyrinth');
|
||||
const mirrorMaze = () => getGame('mirror-maze', 'labyrinth');
|
||||
|
||||
const viewOf = (room: Room, role: Role, now = 2_000_000) => {
|
||||
const player = room.players.find((p) => p.seat === role)!;
|
||||
return JSON.parse(JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now)));
|
||||
};
|
||||
|
||||
describe('defuse: Overtime manual', () => {
|
||||
const game = overtime();
|
||||
const { fixtures, modules, ruleset } = game;
|
||||
|
||||
it.each(fixtures.wires)('wires fixture %#', (f) => {
|
||||
expect(wireSolution(ruleset, f, f.wires)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.button)('button fixture %#', (f) => {
|
||||
expect(buttonSolution(ruleset, { serial: '', ...f }, f)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.keypad)('keypad fixture %#', (f) => {
|
||||
expect(solveKeypad(f.symbols, modules.keypad.columns)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.passwords)('passwords fixture %#', (f) => {
|
||||
expect(solvePassword(f.columns, modules.passwords!.words)).toBe(f.expect);
|
||||
});
|
||||
it.each(fixtures.morse)('morse fixture %#', (f) => {
|
||||
expect(solveMorse(f.signal, modules.morse!.words)).toEqual(f.expect);
|
||||
});
|
||||
it.each(fixtures.simon)('simon fixture %#', (f) => {
|
||||
expect(solveSimon(f.serial, f.strikes, f.flashes)).toEqual(f.expect);
|
||||
});
|
||||
|
||||
it('never generates a device the solver cannot solve, on any level', () => {
|
||||
for (let level = 0; level < game.levels.length; level++) {
|
||||
for (let seed = 1; seed <= 400; seed++) {
|
||||
const device = generateDevice(game, level, seed);
|
||||
expect(isSolvable(game, device), `level ${level} seed ${seed}`).toBe(true);
|
||||
expect(device.indicators!.length).toBeGreaterThanOrEqual(1);
|
||||
expect(device.indicators!.length).toBeLessThanOrEqual(3);
|
||||
if (device.passwords) {
|
||||
expect(device.passwords.columns).toHaveLength(5);
|
||||
for (const column of device.passwords.columns) expect(new Set(column).size).toBe(6);
|
||||
}
|
||||
if (device.simon) {
|
||||
expect(device.simon.sequence.length).toBeGreaterThanOrEqual(3);
|
||||
expect(device.simon.sequence.length).toBeLessThanOrEqual(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Solves whatever is on the device, using only the solver. */
|
||||
function solveAll(room: Room, t: ReturnType<typeof clock>) {
|
||||
const s = room.engine as DefuseState;
|
||||
const d = s.device!;
|
||||
const act = (id: string, action: Parameters<typeof defuseAction>[4], ms = 100) =>
|
||||
expect(defuseAction(game, t.ctx(ms), 'A', id, action).ok, id).toBe(true);
|
||||
|
||||
if (d.wires) act('wires', wireSolution(ruleset, edgework(d), d.wires.colors));
|
||||
if (d.keypad) for (const symbol of solveKeypad(d.keypad.symbols, modules.keypad.columns)!) act('keypad', { type: 'press', symbol });
|
||||
if (d.passwords) act('passwords', { type: 'submit', word: solvePassword(d.passwords.columns, modules.passwords!.words)! });
|
||||
if (d.morse) act('morse', { type: 'transmit', frequency: solveMorse(toSignal(d.morse.word), modules.morse!.words)!.frequency });
|
||||
if (d.simon) {
|
||||
for (let stage = 1; stage <= d.simon.sequence.length; stage++) {
|
||||
for (const color of solveSimon(d.serial, s.strikes, d.simon.sequence.slice(0, stage))) act('simon', { type: 'press', color });
|
||||
}
|
||||
}
|
||||
if (d.button) {
|
||||
const solution = buttonSolution(ruleset, edgework(d), d.button);
|
||||
if (solution.type === 'tap') act('button', { type: 'tap' });
|
||||
else {
|
||||
act('button', { type: 'hold' });
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break;
|
||||
t.ctx(500);
|
||||
}
|
||||
act('button', { type: 'release' }, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('clears all four levels for a key each and finishes the game', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (let level = 0; level < game.levels.length; level++) {
|
||||
expect(continueDefuse(game, t.ctx()).ok).toBe(true);
|
||||
solveAll(room, t);
|
||||
const s = room.engine as DefuseState;
|
||||
expect(s.strikes, `level ${level}`).toBe(0);
|
||||
expect(s.status, `level ${level}`).toBe('cleared');
|
||||
if (level < game.levels.length - 1) continueDefuse(game, t.ctx());
|
||||
}
|
||||
expect(room.keys).toEqual(game.levels.map((l) => l.keyName));
|
||||
expect(room.result?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('Say It Back: a wrong pad strikes, restarts the stage, and moves the mapping', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 2;
|
||||
continueDefuse(game, t.ctx());
|
||||
const sim = s.device!.simon!;
|
||||
const expected = (strikes: number) => solveSimon(s.device!.serial, strikes, [sim.sequence[0]])[0];
|
||||
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) });
|
||||
expect(sim).toMatchObject({ stage: 2, progress: 0 });
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) });
|
||||
expect(sim.progress).toBe(1);
|
||||
|
||||
const wrong = modules.simon!.colors.find((c) => c !== solveSimon(s.device!.serial, 0, [sim.sequence[1]])[0])!;
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: wrong });
|
||||
expect(s.strikes).toBe(1);
|
||||
expect(sim).toMatchObject({ stage: 2, progress: 0 });
|
||||
|
||||
// One strike in, the first flash now maps to a different pad.
|
||||
expect(expected(1)).not.toBe(expected(0));
|
||||
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(1) });
|
||||
expect(s.strikes).toBe(1);
|
||||
expect(sim.progress).toBe(1);
|
||||
});
|
||||
|
||||
it('a wrong password or frequency is a strike, and the last level only allows two', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 3;
|
||||
continueDefuse(game, t.ctx());
|
||||
expect(viewOf(room, 'A', t.now).views.A.maxStrikes).toBe(2);
|
||||
|
||||
defuseAction(game, t.ctx(), 'A', 'passwords', { type: 'submit', word: 'ZZZZZ' });
|
||||
expect(s).toMatchObject({ strikes: 1, status: 'active' });
|
||||
expect(defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: 'loud' }).ok).toBe(false);
|
||||
expect(s.strikes).toBe(1);
|
||||
defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: '3.501' });
|
||||
expect(s).toMatchObject({ strikes: 2, status: 'exploded', failReason: 'strikes' });
|
||||
});
|
||||
|
||||
it('the Technician gets the blinking pattern and the letters, never the word or the manual', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 3;
|
||||
continueDefuse(game, t.ctx());
|
||||
const d = s.device!;
|
||||
|
||||
const tech = viewOf(room, 'A', t.now).views.A;
|
||||
const json = JSON.stringify(tech);
|
||||
expect(json).not.toContain('Defusal Manual');
|
||||
expect(tech.device.indicators).toEqual(d.indicators);
|
||||
const byId = Object.fromEntries(tech.device.modules.map((m: { id: string }) => [m.id, m]));
|
||||
expect(Object.keys(byId).sort()).toEqual([...game.levels[3].modules].sort());
|
||||
expect(byId.morse.signal).toBe(toSignal(d.morse!.word));
|
||||
expect(byId.morse).not.toHaveProperty('word');
|
||||
expect(byId.passwords.columns).toEqual(d.passwords!.columns);
|
||||
// Only the flashes of the current stage, not the whole sequence.
|
||||
expect(byId.simon.flashes).toEqual(d.simon!.sequence.slice(0, 1));
|
||||
expect(json).not.toContain('"sequence"');
|
||||
|
||||
const expert = JSON.stringify(viewOf(room, 'B', t.now).views.B);
|
||||
expect(expert).toContain('Overtime Edition');
|
||||
expect(expert).not.toContain(d.serial);
|
||||
for (const key of ['device', 'indicators', 'signal', 'columns', 'flashes']) expect(expert).not.toContain(`"${key}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync: Deep Cuts', () => {
|
||||
const game = deepCuts();
|
||||
|
||||
it('telepathy ignores case, spacing, punctuation and a trailing S', () => {
|
||||
expect(normalizeWord(' Ice-Cream ')).toBe(normalizeWord('ice cream'));
|
||||
expect(normalizeWord('Apples')).toBe(normalizeWord('apple'));
|
||||
expect(normalizeWord('kiss')).toBe(normalizeWord('KISS'));
|
||||
expect(normalizeWord('pear')).not.toBe(normalizeWord('peach'));
|
||||
});
|
||||
|
||||
/** Plays the open round; `matches` of the questions are answered in sync. */
|
||||
function playRound(room: Room, t: ReturnType<typeof clock>, matches: number, seen: string[]) {
|
||||
const s = room.engine as SyncState;
|
||||
const { type } = game.rounds[s.round];
|
||||
expect(s.questions, `round ${s.round} attempt ${s.attempt}`).toHaveLength(game.rules.questionsPerRound);
|
||||
for (let i = 0; i < game.rules.questionsPerRound; i++) {
|
||||
const q = s.questions[s.q];
|
||||
seen.push(`${s.round}:${q.id}`);
|
||||
const hit = i < matches;
|
||||
if (type === 'telepathy') {
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 2).ok).toBe(false); // no options to pick from
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, ' ').ok).toBe(false);
|
||||
answerSync(game, t.ctx(), 'A', q.id, 'Mangos');
|
||||
answerSync(game, t.ctx(), 'B', q.id, hit ? ' mango' : 'pear');
|
||||
} else if (type === 'rank') {
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2]).ok).toBe(false); // every item, once
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 1, 3]).ok).toBe(false);
|
||||
answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2, 3]);
|
||||
// Top and bottom agree (the middle doesn't have to), or only the top does.
|
||||
answerSync(game, t.ctx(), 'B', q.id, hit ? [0, 2, 1, 3] : [0, 1, 3, 2]);
|
||||
} else {
|
||||
answerSync(game, t.ctx(), 'A', q.id, 0);
|
||||
answerSync(game, t.ctx(), 'B', q.id, hit ? 0 : 1);
|
||||
}
|
||||
expect(s.status).toBe('reveal');
|
||||
expect(s.marks[i]).toBe(hit);
|
||||
continueSync(game, t.ctx());
|
||||
}
|
||||
expect(s.status).toBe('summary');
|
||||
}
|
||||
|
||||
it('scores each round type, with its own bar for the key', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as SyncState;
|
||||
const seen: string[] = [];
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
playRound(room, t, 3, seen); // telepathy needs 3
|
||||
expect(room.keys).toEqual(['Telepathy Key']);
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
continueSync(game, t.ctx());
|
||||
expect(viewOf(room, 'A').views.A).toMatchObject({ pointsForKey: 4, round: { type: 'predict', subject: 'A' }, iAmSubject: true });
|
||||
playRound(room, t, 3, seen); // spotlight needs 4: not enough
|
||||
expect(s.earned).toBe(false);
|
||||
continueSync(game, t.ctx()); // replay
|
||||
playRound(room, t, 4, seen);
|
||||
expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A']);
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
continueSync(game, t.ctx());
|
||||
playRound(room, t, 0, seen);
|
||||
continueSync(game, t.ctx());
|
||||
playRound(room, t, 0, seen); // both tries missed
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
expect(s.round).toBe(3);
|
||||
continueSync(game, t.ctx());
|
||||
// The spotlight alternates A, B, A, B, A through the ranking round.
|
||||
expect(viewOf(room, 'B').views.B).toMatchObject({ round: { type: 'rank', subject: 'A' }, iAmSubject: false });
|
||||
expect(viewOf(room, 'B').views.B.question.options).toHaveLength(4);
|
||||
playRound(room, t, 3, seen);
|
||||
expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A', 'Ranking Key']);
|
||||
continueSync(game, t.ctx());
|
||||
expect(room.phase).toBe('finished');
|
||||
|
||||
// Nothing was asked twice in the same way (a spotlight question may return for the other partner).
|
||||
const groups = seen.map((x) => x.replace(/^[12]:/, (m) => (m === '1:' ? 'A:' : 'B:')));
|
||||
expect(new Set(groups).size).toBe(groups.length);
|
||||
});
|
||||
|
||||
it("keeps a typed word private until both are in", () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueSync(game, t.ctx());
|
||||
const q = (room.engine as SyncState).questions[0];
|
||||
answerSync(game, t.ctx(), 'A', q.id, 'Banana');
|
||||
expect(JSON.stringify(viewOf(room, 'B').views.B)).not.toContain('Banana');
|
||||
expect(viewOf(room, 'B').views.B).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
|
||||
answerSync(game, t.ctx(), 'B', q.id, 'bananas');
|
||||
expect(viewOf(room, 'B').views.B.reveal).toEqual({ A: 'Banana', B: 'bananas', match: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('labyrinth: one-way gates (Drowned Catacombs)', () => {
|
||||
const game = catacombs();
|
||||
|
||||
it('lets the Explorer through a gate one way only', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
const [gate] = game.oneWay; // (5,9) → east
|
||||
Object.assign(s, { x: gate.x, y: gate.y });
|
||||
|
||||
expect(viewOf(room, 'B').views.B).toMatchObject({ compass: 'absolute', gates: [] });
|
||||
expect(moveExplorer(game, t.ctx(), 'B', gate.dir).ok).toBe(true);
|
||||
expect(s.message).toBe(game.explorerView.flavour.gate);
|
||||
|
||||
const explorer = viewOf(room, 'B').views.B;
|
||||
expect(explorer.open.w).toBe(false);
|
||||
expect(explorer.gates).toEqual(['w']);
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'w')).toMatchObject({ ok: false, code: 'gate' });
|
||||
expect(s).toMatchObject({ x: gate.x + 1, y: gate.y });
|
||||
expect(viewOf(room, 'A').views.A.oneWay).toEqual(game.oneWay);
|
||||
});
|
||||
|
||||
it('rejects a maze where a gate can trap the Explorer for good', () => {
|
||||
const sealed = structuredClone(game);
|
||||
// A dead-end pocket behind a gate, with no trap to fall out through.
|
||||
sealed.grid = ['#####', '#S.E#', '#.###', '#.KK#', '#.#K#', '#####'];
|
||||
sealed.lanterns = [
|
||||
{ x: 2, y: 3, name: 'a' },
|
||||
{ x: 3, y: 3, name: 'b' },
|
||||
{ x: 3, y: 4, name: 'c' },
|
||||
];
|
||||
sealed.glyphs = [];
|
||||
sealed.oneWay = [{ x: 1, y: 3, dir: 'e' }];
|
||||
expect(validateGame(sealed).join('\n')).toMatch(/stuck for good/);
|
||||
sealed.oneWay = [];
|
||||
expect(validateGame(sealed)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labyrinth: no compass, mirrored map (Mirror Maze)', () => {
|
||||
const game = mirrorMaze();
|
||||
|
||||
it('turns forward/left/right/back into steps from the way the Explorer faces', () => {
|
||||
expect((['f', 'r', 'b', 'l'] as const).map((rel) => toAbsolute('e', rel))).toEqual(['e', 's', 'w', 'n']);
|
||||
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
expect(s).toMatchObject({ x: 1, y: 1, facing: 'e' });
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'e')).toMatchObject({ ok: false, code: 'bad_dir' }); // no compass
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'l')).toMatchObject({ ok: false, code: 'wall' });
|
||||
|
||||
moveExplorer(game, t.ctx(), 'B', 'f');
|
||||
moveExplorer(game, t.ctx(), 'B', 'f'); // (3,1), still facing east
|
||||
expect(s).toMatchObject({ x: 3, y: 1, facing: 'e' });
|
||||
moveExplorer(game, t.ctx(), 'B', 'r'); // right of east is south
|
||||
expect(s).toMatchObject({ x: 3, y: 2, facing: 's' });
|
||||
|
||||
const explorer = viewOf(room, 'B').views.B;
|
||||
expect(explorer.compass).toBe('relative');
|
||||
expect(explorer.open).toEqual({ f: true, r: false, b: true, l: false });
|
||||
expect(JSON.stringify(explorer)).not.toMatch(/"(facing|n|e|s|w|x|y|grid)"/);
|
||||
});
|
||||
|
||||
it('serves the Navigator the map flipped left to right', () => {
|
||||
const room = playingRoom(game);
|
||||
const navigator = viewOf(room, 'A').views.A;
|
||||
const width = game.grid[0].length;
|
||||
expect(navigator.banner).toMatch(/traced from a mirror/);
|
||||
expect(navigator.grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
|
||||
expect(navigator.grid).not.toEqual(game.grid);
|
||||
game.lanterns.forEach((l, i) => {
|
||||
expect(navigator.lanterns[i]).toMatchObject({ x: width - 1 - l.x, y: l.y });
|
||||
expect(navigator.grid[l.y][navigator.lanterns[i].x]).toBe('K');
|
||||
});
|
||||
game.glyphs.forEach((g, i) => expect(navigator.glyphs[i].x).toBe(width - 1 - g.x));
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([catacombs(), mirrorMaze()].map((g) => [g.id, g] as const))('labyrinth: %s', (_id, game) => {
|
||||
it('can be completed without a single trap', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
const state = (p: { x: number; y: number }, held: boolean[]) => `${p.x},${p.y},${held.map(Number).join('')}`;
|
||||
|
||||
// Breadth-first over (cell, lanterns held): gates make the order matter.
|
||||
type Node = { x: number; y: number; held: boolean[]; path: Dir[] };
|
||||
const queue: Node[] = [{ x: s.x, y: s.y, held: game.lanterns.map(() => false), path: [] }];
|
||||
const seen = new Set([state(queue[0], queue[0].held)]);
|
||||
let route: Dir[] | null = null;
|
||||
while (queue.length && !route) {
|
||||
const node = queue.shift()!;
|
||||
for (const dir of Object.keys(DELTAS) as Dir[]) {
|
||||
const next = { x: node.x + DELTAS[dir].x, y: node.y + DELTAS[dir].y };
|
||||
const c = cellAt(game.grid, next.x, next.y);
|
||||
if (c === '#' || c === 'T' || gateBlocks(game.oneWay, node.x, node.y, dir)) continue;
|
||||
const held = node.held.map((h, i) => h || (game.lanterns[i].x === next.x && game.lanterns[i].y === next.y));
|
||||
if (seen.has(state(next, held))) continue;
|
||||
seen.add(state(next, held));
|
||||
const path = [...node.path, dir];
|
||||
if (c === 'E' && held.every(Boolean)) route = path;
|
||||
queue.push({ ...next, held, path });
|
||||
}
|
||||
}
|
||||
expect(route).not.toBeNull();
|
||||
|
||||
const relative = game.explorer.compass === 'relative';
|
||||
for (const dir of route!) {
|
||||
const move: MoveDir = relative ? (['f', 'r', 'b', 'l'] as const).find((rel) => toAbsolute(s.facing!, rel) === dir)! : dir;
|
||||
expect(moveExplorer(game, t.ctx(), 'B', move).ok).toBe(true);
|
||||
}
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(s.falls).toBe(0);
|
||||
expect(room.keys).toHaveLength(game.lanternsRequired);
|
||||
expect(findCells(game.grid, 'K')).toHaveLength(game.lanternsRequired);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story: per-game hint penalties and time limits', () => {
|
||||
it('charges the hint penalty the game sets', () => {
|
||||
const game = getGame('kestrel-heist', 'story');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const ctx = t.ctx();
|
||||
requestHint(game, ctx, game.chapters[0].lock.id);
|
||||
expect(room.penaltySeconds).toBe(240);
|
||||
expect(ctx.toasts[0].message).toBe('Hint 1 revealed (+4:00)');
|
||||
expect(viewOf(room, 'A').views.A.chapters[0].lock.hintPenaltySeconds).toBe(240);
|
||||
|
||||
const classic = getGame('vineyard-hollow', 'story');
|
||||
const other = playingRoom(classic);
|
||||
requestHint(classic, clock(other).ctx(), classic.chapters[0].lock.id);
|
||||
expect(other.penaltySeconds).toBe(120);
|
||||
});
|
||||
|
||||
it('ends the game when the clock (elapsed + penalties) passes the limit', () => {
|
||||
const game = getGame('deep-lab-nereid', 'story');
|
||||
const limit = game.rules.timeLimitMinutes! * 60;
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
expect(viewOf(room, 'A').timer).toMatchObject({ limitSeconds: limit, limitLabel: 'Water level' });
|
||||
|
||||
expect(tickStory(game, t.ctx((limit - 100) * 1000))).toBe(false);
|
||||
requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over
|
||||
expect(tickStory(game, t.ctx())).toBe(true);
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null, outOfTime: true });
|
||||
expect(tickStory(game, t.ctx())).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the reveal when an accusation game times out, and leaves untimed games alone', () => {
|
||||
const game = getGame('kalahari-expedition', 'story');
|
||||
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true);
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: true });
|
||||
|
||||
const untimed = getGame('vineyard-hollow', 'story');
|
||||
const calm = playingRoom(untimed);
|
||||
expect(tickStory(untimed, clock(calm).ctx(24 * 3600_000))).toBe(false);
|
||||
expect(viewOf(calm, 'A').timer).toMatchObject({ limitSeconds: null, limitLabel: null });
|
||||
});
|
||||
|
||||
it('plays a timed escape room through to the end inside the limit', () => {
|
||||
const game = getGame('deep-lab-nereid', 'story');
|
||||
if (game.finale.type !== 'escape') throw new Error('expected escape');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toMatchObject({ correct: true });
|
||||
continueChapter(game, t.ctx());
|
||||
}
|
||||
expect((room.engine as StoryState).chapter).toBe(game.chapters.length);
|
||||
expect(tickStory(game, t.ctx())).toBe(false);
|
||||
submitLock(game, t.ctx(), game.finale.lock.id, 'surface');
|
||||
expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null, outOfTime: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
+4
-2
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user