V1.0.0
Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
server/data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
temp/
|
||||
@@ -0,0 +1,250 @@
|
||||
# Two Keys: Co-op Mysteries for Couples
|
||||
|
||||
> Build spec for Claude Code. Read this whole file before writing any code.
|
||||
> Game content lives in `content/games/*.yaml`. **Never hard-code puzzle answers in client code.**
|
||||
|
||||
## 1. What we're building
|
||||
|
||||
A website where **two players (a couple)** play co-op mystery games together. Each player sees **different information**, so neither can solve anything alone. They talk it out, pool clues, and enter answers into shared locks. Every lock solved earns a **key**. Enough keys unravel the mystery.
|
||||
|
||||
**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 | Category | Engine | File |
|
||||
|---|------|----------|--------|------|
|
||||
| 1 | Death at Vineyard Hollow | Murder Mystery | `story` | `vineyard-hollow.yaml` |
|
||||
| 2 | The Last Set at the Blue Lantern | Murder Mystery | `story` | `blue-lantern.yaml` |
|
||||
| 3 | Murder on the Southern Star | Murder Mystery | `story` | `southern-star.yaml` |
|
||||
| 4 | The Clockmaker's Attic | Escape Room | `story` | `clockmakers-attic.yaml` |
|
||||
| 5 | Signal Lost: Station Echo | Escape Room | `story` | `station-echo.yaml` |
|
||||
| 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` |
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
- **Monorepo** with npm workspaces: `client/`, `server/`, `shared/`, `content/`
|
||||
- **Client:** Vite + React 18 + TypeScript + Tailwind CSS + React Router + `socket.io-client` + Framer Motion (subtle transitions only)
|
||||
- **Server:** Node 20 + TypeScript + Express + Socket.io + `js-yaml` + `zod`
|
||||
- **Shared:** TypeScript types + the answer normalizer (used by tests; the server is the only thing that checks answers)
|
||||
- **Tests:** Vitest (server + shared), Playwright smoke test (optional, phase 6)
|
||||
- **Storage:** In-memory room store, snapshotted to `server/data/rooms.json` every 10s and on shutdown so a restart doesn't kill a game in progress. Rooms expire after 24h idle.
|
||||
- **Deploy target:** single Node process serves the built client + websocket (Render / Railway / Fly.io). No serverless, as we need persistent websockets.
|
||||
|
||||
### Folder structure
|
||||
|
||||
```
|
||||
two-keys/
|
||||
CLAUDE.md
|
||||
GAMES.md # spoiler guide (designer reference, never served)
|
||||
package.json # workspaces
|
||||
content/games/*.yaml # all game content (source of truth)
|
||||
shared/src/
|
||||
types.ts # Game, Chapter, Clue, Lock, Room, etc.
|
||||
normalize.ts # answer normalizer
|
||||
server/src/
|
||||
index.ts # express + socket.io bootstrap
|
||||
content/loader.ts # loads + zod-validates YAML at boot
|
||||
content/sanitize.ts # strips answers, filters clues by role
|
||||
rooms/store.ts # in-memory store + snapshot
|
||||
rooms/codes.ts # 5-char room codes (no 0/O/1/I)
|
||||
engines/story.ts
|
||||
engines/defuse.ts # generator + solver
|
||||
engines/sync.ts
|
||||
engines/labyrinth.ts
|
||||
socket/handlers.ts
|
||||
scripts/validate-content.ts
|
||||
client/src/
|
||||
pages/Home.tsx Library.tsx Lobby.tsx Play.tsx Results.tsx HowToPlay.tsx
|
||||
engines/StoryGame/ DefuseGame/ SyncGame/ LabyrinthGame/
|
||||
components/ClueCard.tsx LockPanel.tsx KeyRing.tsx HintButton.tsx
|
||||
Notepad.tsx PartnerStatus.tsx Timer.tsx PassDeviceScreen.tsx
|
||||
lib/socket.ts session.ts theme.ts
|
||||
```
|
||||
|
||||
## 3. Rooms, roles, and play modes
|
||||
|
||||
### Two-device mode (default)
|
||||
1. Player creates a game from the library → server creates a room with a 5-char code (e.g. `K7MRX`) and a share link `/join/K7MRX`.
|
||||
2. Creator picks role **A** or **B** (each game names its roles, e.g. "Inspector" / "Estate Insider"). Partner joins and gets the other role.
|
||||
3. Game starts when both are in and both tap **Ready**.
|
||||
4. Each browser stores `{roomCode, playerToken}` in `localStorage` (wrapped in try/catch) for **reconnect**. Refreshing the page rejoins the same seat.
|
||||
|
||||
### Couch mode (one device)
|
||||
- One phone/laptop, both players. Role views alternate behind a **"Pass to [Role B name] 👀 (no peeking)"** cover screen with a tap-to-reveal.
|
||||
- Locks and the key ring are visible in both views.
|
||||
|
||||
### Partner presence
|
||||
- Show partner online/offline dot, which chapter they're viewing, and a **"Ping"** button (sends a little 👋 / 🔍 / 💡 / ❤️ reaction that floats on the partner's screen).
|
||||
- **Shared notepad** synced live (debounced 300ms, last-write-wins is fine).
|
||||
- No in-app chat. Couples are on the couch or on a call. Add a line on the lobby: "Best played on a call or side by side."
|
||||
|
||||
## 4. Content model (YAML → zod)
|
||||
|
||||
All game files share a header:
|
||||
|
||||
```yaml
|
||||
id: string # matches filename
|
||||
engine: story | defuse | sync | labyrinth
|
||||
category: murder-mystery | escape-room | co-op
|
||||
title, tagline: string
|
||||
estimatedMinutes: number
|
||||
difficulty: 1-3
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary, accent, background, headingFont }
|
||||
roles: { A: {name, blurb}, B: {name, blurb} }
|
||||
intro: markdown
|
||||
```
|
||||
|
||||
### `story` engine (games 1–5)
|
||||
|
||||
```yaml
|
||||
suspects: # optional (murder mysteries)
|
||||
- { id, name, role, description }
|
||||
chapters:
|
||||
- id, title, narrative (markdown, both players see it)
|
||||
clues:
|
||||
A: [Clue] # only Player A ever receives these
|
||||
B: [Clue] # only Player B
|
||||
shared: [Clue] # both
|
||||
lock:
|
||||
id, prompt
|
||||
inputType: code | text | choice
|
||||
format: string # shown under the input, e.g. "4 digits", "HH:MM"
|
||||
options: [...] # only for choice
|
||||
answers: [string] # SERVER ONLY, any match = correct
|
||||
hints: [string, string, string] # tier 1 nudge → tier 3 near-solution
|
||||
successText: markdown # revealed to both on unlock
|
||||
keyName: string # name of the key earned, e.g. "Cellar Key"
|
||||
finale:
|
||||
type: accusation | escape
|
||||
prompt
|
||||
# accusation only:
|
||||
questions: [{ id, label, options: [{id, label}], answer }] # answer SERVER ONLY
|
||||
maxAttempts: number
|
||||
failEnding, reveal: markdown
|
||||
# escape only:
|
||||
clues: { A: [Clue], B: [Clue] } # finale gets its own split clues
|
||||
lock: Lock # same shape as a chapter lock (no keyName needed)
|
||||
# both:
|
||||
successEnding: markdown
|
||||
```
|
||||
|
||||
Use a zod discriminated union on `finale.type`.
|
||||
|
||||
**Clue object:** `{ id, title, type, body, signal? }`
|
||||
- `type` ∈ `note | letter | document | report | log | table | photo | map | transcript | signal | diagram`
|
||||
- `body` is markdown (tables allowed). Render each type with its own card style:
|
||||
- `note`: handwritten-ish (Caveat font), slight rotation
|
||||
- `letter`: paper texture, serif
|
||||
- `report` / `document` / `log`: typewriter / monospace header strip
|
||||
- `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.
|
||||
|
||||
### Other engines
|
||||
See each YAML for its schema. Summaries in §6.
|
||||
|
||||
## 5. Security & answer checking (important)
|
||||
|
||||
- The server loads YAML and keeps the full version in memory.
|
||||
- `sanitize.ts` builds the **per-role payload**: removes every `answers`, `answer`, `solution`, finale `answer` field, and removes the *other* role's clues. Write a unit test asserting no sanitized payload contains any answer string.
|
||||
- Also strip per engine: **defuse** → Technician never gets `manual`, Expert never gets the device state; nobody gets `fixtures` or computed solutions. **labyrinth** → Explorer never gets `grid`, `glyphs`, `lanterns` positions; Navigator never gets the Explorer's coordinates. **sync** → never send the partner's answer until both have answered. **signal clues** → send the `signal` pattern only for clues of type `signal`, and the client must never print it as text.
|
||||
- Hints are only sent once revealed (never ship unrevealed hint tiers to the client).
|
||||
- **Only the server checks answers.** Client sends `lock:submit {lockId, value}`.
|
||||
- **Normalizer** (`shared/normalize.ts`): uppercase → strip accents → remove everything except `A-Z0-9`. So `"20:40"`, `"20 40"` → `2040`; `"Fire Swart, Friday!"` → `FIRESWARTFRIDAY`. Compare normalized input to each normalized entry in `answers`.
|
||||
- Rate-limit submissions: max 1 per 2s per room per lock; after 5 wrong answers on one lock, show "Need a hint?" nudge.
|
||||
|
||||
## 6. Engines
|
||||
|
||||
### 6.1 Story engine (games 1–5)
|
||||
- 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`.
|
||||
- For `escape` finales, it's just the last lock; on success show `successEnding`.
|
||||
|
||||
### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml`
|
||||
- Player A (**Technician**) sees the device. Player B (**Expert**) sees only the manual.
|
||||
- Server **generates** each device from a seeded RNG and computes the solution with a **solver implementing the manual rules exactly**. Never generate a device the solver can't solve.
|
||||
- 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).
|
||||
|
||||
### 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).
|
||||
|
||||
### 6.4 Sync engine (game 7). See `in-sync.yaml`
|
||||
- Rounds of 5 questions. Both answer privately; answers reveal simultaneously with a flip animation.
|
||||
- `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.
|
||||
|
||||
### 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.
|
||||
- Player A (**Navigator**) sees the full map with glyphs, keys, traps, and exit, but **NOT** the Explorer's position. The Navigator has to work out where they are from what the Explorer describes.
|
||||
- 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.
|
||||
|
||||
## 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`,
|
||||
`finale:propose {answers}`, `finale:agree`, `finale:submit`,
|
||||
`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId}`,
|
||||
`maze:move {dir}`, `notepad:update {text}`, `ping {emoji}`
|
||||
|
||||
Server → client:
|
||||
`room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`,
|
||||
`toast {message}`, `ping {from, emoji}`, `error {code, message}`
|
||||
|
||||
Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to each socket.
|
||||
|
||||
## 8. Scoring & results
|
||||
|
||||
- `finalTime = elapsed + penaltySeconds`
|
||||
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅
|
||||
- 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 🔑🔑🔑).
|
||||
- **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").
|
||||
|
||||
## 10. Content validation script
|
||||
|
||||
`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
|
||||
Run it in CI and as a pre-start step.
|
||||
|
||||
## 11. Build phases (do in order, commit after each)
|
||||
|
||||
1. **Scaffold** monorepo, shared types, normalizer + tests, YAML loader + zod schemas + validate script.
|
||||
2. **Rooms**: create/join/role/ready/reconnect, lobby UI, home + library pages.
|
||||
3. **Story engine** end-to-end with `vineyard-hollow`. Then confirm games 2–5 load and play with no code changes.
|
||||
4. **Hints, timer, penalties, finale agreement flow, results page.**
|
||||
5. **Defuse, Sync, Labyrinth** engines.
|
||||
6. Couch mode, notepad, pings, polish, theming, mobile QA, Playwright smoke test (two browser contexts complete Clockmaker's Attic).
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
- [ ] Two phones can play any of the 8 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
|
||||
- [ ] `npm run validate-content` passes; all unit tests pass
|
||||
- [ ] Works at 375px wide with no horizontal scroll
|
||||
@@ -0,0 +1,98 @@
|
||||
# GAMES.md: Spoiler Guide (designer reference, NEVER served to clients)
|
||||
|
||||
Every lock below needs info from **both** players. "A has / B has" shows the split.
|
||||
|
||||
---
|
||||
|
||||
## 1. Death at Vineyard Hollow (murder mystery · 60 min · 🔑🔑)
|
||||
Roles: **Inspector** (A) / **Estate Insider** (B)
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
|----|------|-------|-------|--------|
|
||||
| 1 | Cellar keypad | Butler: "year it began, read backwards" | Gate plaque: Est. 1874 | `4781` |
|
||||
| 2 | Time of death | ME temp 34.5 °C at 23:10, 1 °C/hr | Keycard log | `20:40` |
|
||||
| 3 | Poisoned bottle | Lab: red, decanted, cork ends in 7 | Cellar book | `1998` Cabernet Reserve |
|
||||
| 4 | Diary cipher | Ciphertext "ORAN BFJAC OARMJH", "rack-shift" | Rack 9 = his heart | `FIRE SWART FRIDAY` (shift −9) |
|
||||
| 5 | Poison source | Tox: fresh *Digitalis* leaves; Vos's tablets sealed | Foxglove bed behind Swart's workshop; card in his apron | `FOXGLOVE` |
|
||||
|
||||
**Killer:** Pieter Swart · foxglove extract in the 1998 Reserve · to avoid being fired and exposed for label fraud.
|
||||
**Red herrings:** Dr Vos (digoxin, near the time window), Marcus (heir, in cellar too late), Isabelle (wanted the estate).
|
||||
|
||||
---
|
||||
|
||||
## 2. The Last Set at the Blue Lantern (murder mystery · 50 min · 🔑🔑)
|
||||
Roles: **Detective** (A) / **Regular** (B)
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
|----|------|-------|-------|--------|
|
||||
| 1 | Drum solo time | Setlist with song lengths | Set started 23:00; solo = last 2 min of Caravan | `23:20` |
|
||||
| 2 | No alibi | Band walks off during drum solos; Ruby sits out instrumentals | Bar/coat-check alibis (Ruby at coat-check 23:21) | Sammy |
|
||||
| 3 | Safe | Sheet music notes C·A·F·E | Piano sticker numbers C1…B7 | `1643` |
|
||||
| 4 | Gun location | Search log: piano not searched | Max: dead "thunk" on low keys | `PIANO` |
|
||||
|
||||
**Killer:** Sammy Keys · Vincent's .38 under the drum solo, hidden in the piano · Vincent stole his songs and was about to sell them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Murder on the Southern Star (murder mystery · 70 min · 🔑🔑🔑)
|
||||
Roles: **Passenger Detective** (A) / **Railway Inspector** (B)
|
||||
|
||||
| Ch | Lock | A has | B has | Answer |
|
||||
|----|------|-------|-------|--------|
|
||||
| 1 | Master key box | Rule: carriage × compartment, 4 digits | Plan: Gold Suite = carriage 6, comp 23 | `0138` |
|
||||
| 2 | Real time of death | Watch 01:15 Greenwich; local is +2h; needle wound | Timetable: Matjiesfontein 03:10–03:20; thump at a platform | `03:15` |
|
||||
| 3 | Telegram sender | Telegram text | "Name hidden where every line begins" | `HUGO` (acrostic) |
|
||||
| 4 | Who lied | Corridor watched, nobody passed; only Vera owns a fur coat | Porter: fur-coat lady off car 6, back on car 4 | Vera |
|
||||
| 5 | Motive word | "V. = 3·7·8·4·2·8", "ask the line" | Numbered stopping list | `FORGER` |
|
||||
|
||||
**Killer:** Countess Vera Orlova · hatpin to the base of the skull during the Matjiesfontein stop · Ashby had discovered she was a jewel forger.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Clockmaker's Attic (escape room · 40 min · 🔑) (best "first game")
|
||||
Roles: **Tinkerer** (A) / **Collector** (B)
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
|------|-------|-------|--------|
|
||||
| Trunk | Robin 3, Owl 7, Fox 9 | Sampler: dawn bird, hunter, night bird | `397` |
|
||||
| Music box | "Sky-bow top to bottom, skip what the pendulum stole" | Painting: red, gold, green, blue; pendulum is green | `RED GOLD BLUE` |
|
||||
| Gear drawer | Gears 48 → 16 → 24 teeth | Turn crank 5 times, count Gear C | `10` |
|
||||
| Door (finale) | "Order treasures by year made" | Inventory with years | `FOREVER` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Signal Lost: Station Echo (escape room · 45 min · 🔑🔑)
|
||||
Roles: **Comms Officer** (A) / **Engineer** (B)
|
||||
|
||||
| Lock | A has | B has | Answer |
|
||||
|------|-------|-------|--------|
|
||||
| Warning light | Morse chart | Blinking lamp `...-/./-./-` | `VENT` |
|
||||
| Breach | Current pressures | Normal pressures + ">30% drop" rule | `GRN-3` (Greenhouse, ~33% drop) |
|
||||
| Generator | Crew roster (5 aboard) | Call sign EC-127 + "× crew aboard" | `635` |
|
||||
| Beacon (finale, roles swap) | Blinking lamp `..../---/--/.` | Morse chart | `HOME` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Tick Tock Together (co-op defuse · 25 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 · 🔑)
|
||||
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 · 🔑🔑)
|
||||
Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3 hidden traps.
|
||||
|
||||
```
|
||||
########### S start (1,1) K lanterns (7,3) (9,5) (1,7)
|
||||
#S..#.....# E exit (9,9) T traps (4,5) (6,7) (3,9)
|
||||
#.#.#.###.#
|
||||
#.#...#K..# Verified by BFS: every lantern and the exit are
|
||||
#.#####.#.# reachable from S without stepping on a trap.
|
||||
#...T...#K#
|
||||
###.###.#.#
|
||||
#K..#.T.#.#
|
||||
#.###.#.#.#
|
||||
#..T....#E#
|
||||
###########
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# Two Keys 🔑🔑
|
||||
|
||||
Co-op mystery games for couples. Two players, different clues, every lock needs both of you.
|
||||
|
||||
## Hand this to Claude Code
|
||||
|
||||
1. Unzip this folder and open it in your terminal (or WebStorm's terminal).
|
||||
2. Run `claude`.
|
||||
3. Paste this as your first prompt:
|
||||
|
||||
> Read CLAUDE.md and GAMES.md fully, then every file in content/games/. Build phase 1 from §11 and stop so I can review. Don't change any YAML puzzle content without asking me.
|
||||
|
||||
4. After each phase, review, run `npm test` and `npm run validate-content`, then say **"next phase"**.
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | What it is |
|
||||
|------|------------|
|
||||
| `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 |
|
||||
| `package.json` | Root workspace config |
|
||||
|
||||
## Adding a new game later
|
||||
Copy a YAML in `content/games/`, change the `id` to match the filename, write your chapters,
|
||||
and run `npm run validate-content`. No code changes needed for new story games.
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#120c0e" />
|
||||
<meta name="description" content="Co-op mystery games for couples. Two players, different clues, every lock needs both of you." />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<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"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Two Keys · Co-op mysteries for couples</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"framer-motion": "^11.11.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#160c10"/><text x="32" y="44" font-size="34" text-anchor="middle">🔑</text></svg>
|
||||
|
After Width: | Height: | Size: 187 B |
@@ -0,0 +1,24 @@
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Toasts } from './components/ui';
|
||||
import { Home } from './pages/Home';
|
||||
import { HowToPlay } from './pages/HowToPlay';
|
||||
import { Library } from './pages/Library';
|
||||
import { JoinRedirect, Room } from './pages/Room';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
// Animations switch off for anyone who asks their device for reduced motion.
|
||||
<MotionConfig reducedMotion="user">
|
||||
<Toasts />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/games" element={<Library />} />
|
||||
<Route path="/how-to-play" element={<HowToPlay />} />
|
||||
<Route path="/join/:code" element={<JoinRedirect />} />
|
||||
<Route path="/room/:code" element={<Room />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ClueType, ClueView } from '@two-keys/shared';
|
||||
import { Markdown } from './ui';
|
||||
|
||||
const TYPE_LABEL: Record<ClueType, string> = {
|
||||
note: 'Note',
|
||||
letter: 'Letter',
|
||||
document: 'Document',
|
||||
report: 'Report',
|
||||
log: 'Log',
|
||||
table: 'Table',
|
||||
photo: 'Photo',
|
||||
map: 'Map',
|
||||
transcript: 'Transcript',
|
||||
signal: 'Signal',
|
||||
diagram: 'Diagram',
|
||||
};
|
||||
|
||||
/** Who can see this clue: tinted in the viewer's role colour when it's theirs alone. */
|
||||
function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
|
||||
const isPrivate = clue.audience === 'private';
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 font-sans text-[11px] font-semibold not-italic ${
|
||||
isPrivate
|
||||
? light
|
||||
? 'bg-stone-900 text-[color:var(--role)]'
|
||||
: 'bg-black/40 text-[color:var(--role)] ring-1 ring-[color:var(--role)]'
|
||||
: light
|
||||
? 'bg-stone-900/10 text-stone-700'
|
||||
: 'bg-white/10 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{isPrivate ? '👁 Only you can see this' : 'You both have this'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className={`text-[10px] font-bold uppercase tracking-[0.18em] opacity-60 ${mono ? 'font-mono' : 'font-sans'}`}>
|
||||
{TYPE_LABEL[clue.type]}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// `.` = 250ms on, `-` = 750ms on, 250ms between symbols, `/` = 1000ms between letters,
|
||||
// then a 3000ms pause and it loops.
|
||||
function signalTimeline(pattern: string): { on: boolean; ms: number }[] {
|
||||
const steps: { on: boolean; ms: number }[] = [];
|
||||
const symbols = [...pattern];
|
||||
symbols.forEach((symbol, i) => {
|
||||
if (symbol === '/') return;
|
||||
steps.push({ on: true, ms: symbol === '-' ? 750 : 250 });
|
||||
const next = symbols[i + 1];
|
||||
if (next === undefined) steps.push({ on: false, ms: 3000 });
|
||||
else steps.push({ on: false, ms: next === '/' ? 1000 : 250 });
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** A blinking light. The pattern is only ever shown as light, never as text. */
|
||||
export function SignalLamp({ pattern }: { pattern: string }) {
|
||||
const [on, setOn] = useState(false);
|
||||
const [run, setRun] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const steps = signalTimeline(pattern);
|
||||
if (steps.length === 0) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let i = 0;
|
||||
setOn(false);
|
||||
const tick = () => {
|
||||
const step = steps[i % steps.length];
|
||||
setOn(step.on);
|
||||
i++;
|
||||
timer = setTimeout(tick, step.ms);
|
||||
};
|
||||
timer = setTimeout(tick, 600); // a beat of darkness so the start is unmistakable
|
||||
return () => clearTimeout(timer);
|
||||
}, [pattern, run]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-3">
|
||||
<div className="rounded-full border-4 border-stone-700 bg-black p-3">
|
||||
<div role="img" aria-label="A blinking signal lamp" className={`lamp h-20 w-20 rounded-full ${on ? 'on' : ''}`} />
|
||||
</div>
|
||||
<button className="btn text-sm" onClick={() => setRun((n) => n + 1)}>
|
||||
↻ Replay from the start
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
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 />
|
||||
<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 />
|
||||
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'report':
|
||||
case 'document':
|
||||
case 'log':
|
||||
return (
|
||||
<article className="overflow-hidden rounded-md bg-[#ecebe4] text-stone-900 shadow-lg shadow-black/40">
|
||||
<div className="bg-stone-800 px-4 py-1.5 font-mono text-[10px] font-bold uppercase tracking-[0.25em] text-stone-200">
|
||||
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Header clue={clue} light mono />
|
||||
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
case 'photo':
|
||||
return (
|
||||
<article className="rotate-[0.5deg] rounded-sm bg-stone-100 p-3 pb-4 text-stone-900 shadow-lg shadow-black/50">
|
||||
<div className="flex min-h-[7rem] items-center rounded-sm bg-gradient-to-br from-stone-700 to-stone-900 p-4 text-stone-100">
|
||||
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Header clue={clue} light />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
case 'map':
|
||||
case 'diagram':
|
||||
return (
|
||||
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
|
||||
<Header 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} />
|
||||
<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 />
|
||||
{clue.signal && <SignalLamp pattern={clue.signal} />}
|
||||
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'table':
|
||||
return (
|
||||
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
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 { pushToast } from '../lib/toasts';
|
||||
import { RoleBadge, Sheet } from './ui';
|
||||
|
||||
const ENGINE_BLURB: Record<GameSummary['engine'], string> = {
|
||||
story: 'Split clues, shared locks',
|
||||
defuse: 'One sees it, one has the manual',
|
||||
sync: 'How well do you know each other?',
|
||||
labyrinth: 'One map, one explorer',
|
||||
};
|
||||
|
||||
/** A library card, themed with its game's own colours. Opens the "how do you want to play" sheet. */
|
||||
export function GameCard({ game, className = '' }: { game: GameSummary; className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const best = getBest(game.id);
|
||||
|
||||
const start = async (mode: PlayMode) => {
|
||||
setCreating(true);
|
||||
const res = await request('room:create', { gameId: game.id, mode });
|
||||
setCreating(false);
|
||||
if (!res.ok) return pushToast(res.message, 'error');
|
||||
saveSession(res.code, res.playerToken);
|
||||
navigate(`/room/${res.code}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={themeVars(game.theme)} className={className}>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex h-full w-full flex-col rounded-2xl border border-white/10 bg-bg bg-gradient-to-br from-primary/60 via-primary/15 to-transparent p-5 text-left shadow-lg shadow-black/30 transition hover:-translate-y-0.5 hover:border-accent/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
|
||||
<span className="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
|
||||
<span className="text-xs" aria-label={`Difficulty ${game.difficulty} of 3`}>
|
||||
{difficultyKeys(game.difficulty)}
|
||||
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Sheet open={open} onClose={() => setOpen(false)} title={game.title}>
|
||||
<p className="text-stone-300">{game.tagline}</p>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{(['A', 'B'] as const).map((role) => (
|
||||
<div key={role} className="card !p-3">
|
||||
<RoleBadge role={role} name={game.roles[role].name} />
|
||||
<p className="mt-2 text-sm text-stone-300">{game.roles[role].blurb}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="mt-6 text-xs font-bold uppercase tracking-widest text-accent">How are you playing?</h4>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<button className="btn-primary justify-start py-3 text-left" disabled={creating} onClick={() => start('duo')}>
|
||||
<span className="text-2xl" aria-hidden>📱📱</span>
|
||||
<span>
|
||||
Two devices
|
||||
<span className="block text-xs font-normal opacity-80">One phone each. You'll get a code to share.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn justify-start py-3 text-left" disabled={creating} onClick={() => start('couch')}>
|
||||
<span className="text-2xl" aria-hidden>🛋️</span>
|
||||
<span>
|
||||
Couch mode
|
||||
<span className="block text-xs font-normal opacity-80">One device, passed back and forth. No peeking.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import 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. */
|
||||
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 reveal = () => {
|
||||
setConfirming(false);
|
||||
send('hint:request', { lockId: lock.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn shrink-0 px-3 ${lock.nudge ? 'border-accent text-accent motion-safe:animate-pulse' : ''}`}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Hints, ${used} of ${lock.hintsTotal} revealed`}
|
||||
>
|
||||
💡<span className="text-xs tabular-nums">{used}/{lock.hintsTotal}</span>
|
||||
</button>
|
||||
|
||||
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
|
||||
<p className="mb-4 text-sm text-stone-400">
|
||||
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">+2:00</strong> to
|
||||
your time, and you both see it.
|
||||
</p>
|
||||
<ol className="space-y-2">
|
||||
{lock.hints.map((hint, i) => (
|
||||
<li key={i} className="card">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-accent">Hint {i + 1}</div>
|
||||
<p className="mt-1">{hint}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{left === 0 ? (
|
||||
<p className="mt-4 text-sm text-stone-400">That's every hint for this lock.</p>
|
||||
) : confirming ? (
|
||||
<div className="mt-4 rounded-2xl border border-accent/50 bg-accent/10 p-4">
|
||||
<p className="font-semibold">This basically solves it. Sure?</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="btn-primary" onClick={reveal}>Yes, reveal it</button>
|
||||
<button className="btn" onClick={() => setConfirming(false)}>Keep thinking</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
|
||||
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
|
||||
</button>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Sheet } from './ui';
|
||||
|
||||
/** Keys earned so far. A new key drops onto the ring with a little flourish. */
|
||||
export function KeyRing({ keys, total }: { keys: string[]; total: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex min-h-[36px] items-center gap-1 rounded-full border border-accent/40 bg-black/30 px-2.5 py-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Key ring: ${keys.length} of ${total} keys`}
|
||||
>
|
||||
{/* A long ring collapses to one key and the count on narrow phones, leaving room for the partner's name. */}
|
||||
{total > 3 && <span className="text-sm min-[440px]:hidden" aria-hidden>🔑</span>}
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} className={`relative h-5 w-4 text-sm leading-5 ${total > 3 ? 'hidden min-[440px]:inline-block' : 'inline-block'}`}>
|
||||
<span className="opacity-20 grayscale" aria-hidden>🔑</span>
|
||||
<AnimatePresence>
|
||||
{i < keys.length && (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="absolute inset-0"
|
||||
initial={{ y: -40, scale: 2.4, rotate: -90, opacity: 0 }}
|
||||
animate={{ y: 0, scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 16 }}
|
||||
>
|
||||
🔑
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-1 text-xs font-semibold tabular-nums text-accent">
|
||||
{keys.length}/{total}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Sheet open={open} onClose={() => setOpen(false)} title="Key ring">
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-stone-400">No keys yet. Every lock you open together earns one.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{keys.map((name, i) => (
|
||||
<li key={i} className="card flex items-center gap-3">
|
||||
<span className="text-2xl" aria-hidden>🔑</span>
|
||||
<span className="font-semibold">{name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-stone-400">
|
||||
{keys.length} of {total} keys collected.
|
||||
</p>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import type { LockView } from '@two-keys/shared';
|
||||
import { socket, type Send } from '../lib/socket';
|
||||
import { HintButton } from './HintButton';
|
||||
|
||||
/** The shared lock: both players see it, either can submit. Only the server knows the answer. */
|
||||
export function LockPanel({ lock, send }: { lock: LockView; send: Send }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [wrong, setWrong] = useState(false);
|
||||
const [shake, setShake] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onResult = ({ lockId, correct }: { lockId: string; correct: boolean }) => {
|
||||
if (lockId !== lock.id) return;
|
||||
setBusy(false);
|
||||
setWrong(!correct);
|
||||
if (!correct) setShake(true);
|
||||
};
|
||||
const onError = () => setBusy(false);
|
||||
socket.on('lock:result', onResult);
|
||||
socket.on('error', onError);
|
||||
return () => {
|
||||
socket.off('lock:result', onResult);
|
||||
socket.off('error', onError);
|
||||
};
|
||||
}, [lock.id]);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setWrong(false);
|
||||
send('lock:submit', { lockId: lock.id, value });
|
||||
};
|
||||
|
||||
const numeric = lock.inputType === 'code' && /digit|number/i.test(lock.format ?? '');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Lock"
|
||||
className={`${lock.inputType === 'choice' ? '' : 'sticky bottom-0 z-10'} -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`}
|
||||
>
|
||||
<form onSubmit={submit} onAnimationEnd={() => setShake(false)} className={`mx-auto max-w-2xl ${shake ? 'shake' : ''}`}>
|
||||
<label htmlFor={`lock-${lock.id}`} className="flex items-start gap-2 font-semibold leading-snug">
|
||||
<span aria-hidden>🔒</span>
|
||||
<span>{lock.prompt}</span>
|
||||
</label>
|
||||
|
||||
{lock.inputType === 'choice' && lock.options ? (
|
||||
<div role="radiogroup" aria-label={lock.prompt} className="mt-2 flex flex-wrap gap-2">
|
||||
{lock.options.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`cursor-pointer rounded-xl border px-3 py-2 text-sm font-medium transition focus-within:ring-2 focus-within:ring-accent ${
|
||||
value === o.id ? 'border-accent bg-accent/20' : 'border-white/20 bg-black/30 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`lock-${lock.id}`}
|
||||
className="sr-only"
|
||||
checked={value === o.id}
|
||||
onChange={() => setValue(o.id)}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
{lock.inputType !== 'choice' && (
|
||||
<input
|
||||
id={`lock-${lock.id}`}
|
||||
className={`field ${lock.inputType === 'code' ? 'font-mono tracking-[0.2em]' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
inputMode={numeric ? 'numeric' : 'text'}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
maxLength={80}
|
||||
placeholder={lock.format ?? 'Your answer'}
|
||||
aria-describedby={`lock-${lock.id}-help`}
|
||||
/>
|
||||
)}
|
||||
<button type="submit" className="btn-primary shrink-0" disabled={!value.trim() || busy}>
|
||||
{busy ? '…' : 'Unlock'}
|
||||
</button>
|
||||
<HintButton lock={lock} send={send} />
|
||||
</div>
|
||||
|
||||
<p id={`lock-${lock.id}-help`} className="mt-1.5 min-h-[1.25rem] text-xs text-stone-400" aria-live="polite">
|
||||
{wrong ? (
|
||||
<span className="font-semibold text-red-300">
|
||||
Not quite. {lock.nudge ? 'Need a hint? 💡' : 'Compare notes and try again.'}
|
||||
</span>
|
||||
) : lock.nudge ? (
|
||||
<span className="font-semibold text-accent">Stuck? Need a hint? 💡</span>
|
||||
) : lock.format && lock.inputType !== 'choice' ? (
|
||||
<>Format: {lock.format}</>
|
||||
) : (
|
||||
<>Either of you can answer. You both need to agree first!</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { socket } from '../lib/socket';
|
||||
import { Sheet } from './ui';
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
/** A notepad both players share, synced live. Last write wins. */
|
||||
export function Notepad({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const dirty = useRef(false);
|
||||
|
||||
// Take the partner's edits unless we have unsent typing of our own.
|
||||
useEffect(() => {
|
||||
if (!dirty.current) setDraft(text);
|
||||
}, [text]);
|
||||
|
||||
const flush = (value: string) => {
|
||||
clearTimeout(timer.current);
|
||||
if (!dirty.current) return;
|
||||
dirty.current = false;
|
||||
socket.emit('notepad:update', { text: value });
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setDraft(value);
|
||||
dirty.current = true;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => flush(value), DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex min-h-[36px] items-center gap-1 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open the shared notepad"
|
||||
>
|
||||
📝<span className="hidden sm:inline">Notes</span>
|
||||
{text.trim() !== '' && <span className="h-1.5 w-1.5 rounded-full bg-accent" aria-hidden />}
|
||||
</button>
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={() => {
|
||||
flush(draft);
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Shared notepad"
|
||||
>
|
||||
<p className="mb-2 text-sm text-stone-400">You both see this, live. Jot down times, names, half-baked theories.</p>
|
||||
<textarea
|
||||
className="field min-h-[40dvh] font-hand text-xl leading-7"
|
||||
value={draft}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={() => flush(draft)}
|
||||
maxLength={5000}
|
||||
placeholder="Our notes…"
|
||||
aria-label="Shared notes"
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { PING_EMOJIS, type Role, type RoomState } from '@two-keys/shared';
|
||||
import { socket } from '../lib/socket';
|
||||
import { ROLE_TEXT } from '../lib/theme';
|
||||
|
||||
/** Partner's online dot, what they're looking at, and the ping buttons. */
|
||||
export function PartnerStatus({ state }: { state: RoomState }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const partner = state.partner;
|
||||
const name = partner?.seat ? state.game.roles[partner.seat].name : 'Partner';
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<button
|
||||
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-label={`${name} is ${partner?.online ? 'online' : 'offline'}. Send a ping.`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${partner?.online ? 'bg-emerald-400 shadow-[0_0_6px_#34d399]' : 'bg-stone-500'}`}
|
||||
/>
|
||||
<span className={`truncate font-medium ${partner?.seat ? ROLE_TEXT[partner.seat] : ''}`}>{name}</span>
|
||||
<span aria-hidden>👋</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
// Anchored to the header bar (not the button) so it always fits a narrow screen.
|
||||
<div className="absolute inset-x-4 top-full z-30 mt-2 max-w-xs rounded-2xl border border-white/15 bg-bg p-3 shadow-2xl">
|
||||
<p className="text-xs text-stone-400">
|
||||
{partner?.online ? 'Online' : 'Offline. They can rejoin with the same link.'}
|
||||
{partner?.online && partner.viewing ? ` · looking at ${partner.viewing}` : ''}
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{PING_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className="btn flex-1 px-0 text-xl"
|
||||
disabled={!partner?.online}
|
||||
aria-label={`Send ${emoji}`}
|
||||
onClick={() => {
|
||||
socket.emit('ping', { emoji });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Ping {
|
||||
id: number;
|
||||
emoji: string;
|
||||
from: Role;
|
||||
x: number;
|
||||
}
|
||||
|
||||
/** Reactions from the partner float up the screen. */
|
||||
export function PingOverlay({ roles }: { roles: RoomState['game']['roles'] }) {
|
||||
const [pings, setPings] = useState<Ping[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let nextId = 1;
|
||||
const onPing = ({ from, emoji }: { from: Role; emoji: string }) => {
|
||||
const id = nextId++;
|
||||
setPings((p) => [...p, { id, emoji, from, x: 15 + Math.random() * 70 }]);
|
||||
setTimeout(() => setPings((p) => p.filter((x) => x.id !== id)), 2600);
|
||||
};
|
||||
socket.on('ping', onPing);
|
||||
return () => {
|
||||
socket.off('ping', onPing);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden" aria-live="polite">
|
||||
<AnimatePresence>
|
||||
{pings.map((p) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className="absolute bottom-24 flex flex-col items-center"
|
||||
style={{ left: `${p.x}%` }}
|
||||
initial={{ y: 0, opacity: 0, scale: 0.5 }}
|
||||
animate={{ y: -260, opacity: 1, scale: 1.4 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 2.2, ease: 'easeOut' }}
|
||||
>
|
||||
<span className="text-5xl">{p.emoji}</span>
|
||||
<span className={`mt-1 rounded-full bg-black/70 px-2 text-[10px] font-semibold ${ROLE_TEXT[p.from]}`}>
|
||||
{roles[p.from].name}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import { ROLE_TEXT } from '../lib/theme';
|
||||
|
||||
/** Couch mode: covers the screen while the device changes hands. */
|
||||
export function PassDeviceScreen({
|
||||
role,
|
||||
roleName,
|
||||
onReveal,
|
||||
}: {
|
||||
role: Role;
|
||||
roleName: string;
|
||||
onReveal: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-6 bg-bg px-6 text-center">
|
||||
<div className="text-6xl" aria-hidden>
|
||||
🙈
|
||||
</div>
|
||||
<h2 className="font-display text-3xl font-bold leading-tight">
|
||||
Pass to <span className={ROLE_TEXT[role]}>{roleName}</span> 👀
|
||||
</h2>
|
||||
<p className="text-stone-400">(no peeking)</p>
|
||||
<button className="btn-primary px-8 py-3 text-lg" onClick={onReveal} autoFocus>
|
||||
I'm {roleName}. Tap to reveal
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex min-h-[44px] items-center rounded-lg px-3 text-sm font-medium transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
isActive ? 'text-accent' : 'text-stone-300'
|
||||
}`;
|
||||
|
||||
export function SiteShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-5xl flex-col px-4">
|
||||
<header className="flex items-center justify-between gap-2 py-2">
|
||||
<Link to="/" className="flex min-h-[44px] items-center gap-1.5 font-display text-xl font-bold tracking-tight focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||
Two Keys <span aria-hidden>🔑🔑</span>
|
||||
</Link>
|
||||
<nav className="flex items-center" aria-label="Main">
|
||||
<NavLink to="/games" className={navClass}>Games</NavLink>
|
||||
<NavLink to="/how-to-play" className={navClass}>How to play</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 pb-16">{children}</main>
|
||||
<footer className="border-t border-white/10 py-6 text-center text-xs text-stone-500">
|
||||
Two players. Different clues. Every lock needs both of you.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { formatClock, type RoomState } from '@two-keys/shared';
|
||||
import { useNow } from '../lib/hooks';
|
||||
|
||||
/** Elapsed time plus penalties. 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;
|
||||
return (
|
||||
<div
|
||||
className="flex items-baseline gap-1.5 font-mono tabular-nums"
|
||||
role="timer"
|
||||
aria-label={`Time ${formatClock(elapsed + timer.penaltySeconds)}`}
|
||||
>
|
||||
<span className="text-base font-bold">{formatClock(elapsed + timer.penaltySeconds)}</span>
|
||||
{timer.penaltySeconds > 0 && (
|
||||
<span className="text-[11px] text-red-300" title="Penalty time from hints and traps">
|
||||
+{formatClock(timer.penaltySeconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import { useToasts } from '../lib/toasts';
|
||||
import { ROLE_BG, ROLE_BORDER, ROLE_TEXT } from '../lib/theme';
|
||||
|
||||
export function Markdown({ children, className = '' }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={`md ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Wide tables scroll inside their card instead of stretching the page.
|
||||
table: ({ node: _node, ...props }) => (
|
||||
<div className="scroll-x">
|
||||
<table {...props} />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleBadge({ role, name, className = '' }: { role: Role; name: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold ${ROLE_BORDER[role]} ${ROLE_BG[role]} ${ROLE_TEXT[role]} ${className}`}
|
||||
>
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useEscape(onClose: () => void, active: boolean) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [active, onClose]);
|
||||
}
|
||||
|
||||
/** A panel that slides up from the bottom (a centred dialog on wide screens). */
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useEscape(onClose, open);
|
||||
useEffect(() => {
|
||||
if (open) panel.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Rendered on <body>: the sticky bars use backdrop-filter, which would otherwise trap a fixed overlay inside them.
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40 flex items-end justify-center sm:items-center">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="relative flex max-h-[85dvh] w-full max-w-2xl flex-col rounded-t-3xl border border-white/15 bg-bg shadow-2xl outline-none sm:rounded-3xl"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'tween', duration: 0.25 }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
|
||||
<h2 className="font-display text-xl font-bold">{title}</h2>
|
||||
<button className="btn !min-h-[36px] px-3 py-1 text-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
const toasts = useToasts();
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed inset-x-0 top-3 z-50 flex flex-col items-center gap-2 px-4"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{toasts.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className={`max-w-sm rounded-xl border px-4 py-2 text-sm font-medium shadow-xl backdrop-blur ${
|
||||
t.kind === 'error'
|
||||
? 'border-red-400/40 bg-red-950/90 text-red-100'
|
||||
: 'border-accent/40 bg-black/85 text-stone-100'
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 text-center text-stone-300">
|
||||
<div className="text-4xl motion-safe:animate-pulse" aria-hidden>
|
||||
🔑🔑
|
||||
</div>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
ButtonModuleView,
|
||||
DefuseAction,
|
||||
DefuseView,
|
||||
KeypadModuleView,
|
||||
RoomState,
|
||||
TechnicianView,
|
||||
WiresModuleView,
|
||||
} from '@two-keys/shared';
|
||||
import { Markdown } from '../components/ui';
|
||||
import { useNow } from '../lib/hooks';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: DefuseView;
|
||||
state: RoomState;
|
||||
receivedAt: number;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
// Colours always come with their name as text, so nothing depends on telling hues apart.
|
||||
const SWATCH: Record<string, string> = {
|
||||
red: '#e5484d',
|
||||
blue: '#3b82f6',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
white: '#f5f5f4',
|
||||
};
|
||||
const darkText = (color: string) => color === 'yellow' || color === 'white';
|
||||
|
||||
function timerText(remaining: number): string {
|
||||
const t = Math.max(0, Math.ceil(remaining));
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function useRemaining(view: DefuseView, receivedAt: number): number {
|
||||
const now = useNow(200);
|
||||
if (!view.clock) return view.level.timeSeconds;
|
||||
if (!view.clock.running) return view.clock.remaining;
|
||||
return Math.max(0, view.clock.remaining - ((now - receivedAt) / 1000) * view.clock.speed);
|
||||
}
|
||||
|
||||
function StatusBar({ view, remaining }: { view: DefuseView; remaining: number }) {
|
||||
const urgent = view.status === 'active' && remaining <= 30;
|
||||
return (
|
||||
<div className="sticky top-0 z-10 -mx-4 flex items-center justify-between gap-3 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-accent">
|
||||
Level {view.level.index + 1} of {view.level.total}
|
||||
</div>
|
||||
<div className="truncate font-display font-bold">{view.level.title}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg border-2 bg-black px-3 py-1 font-mono text-3xl font-bold tabular-nums ${
|
||||
urgent ? 'border-red-500 text-red-400' : 'border-stone-700 text-red-500'
|
||||
}`}
|
||||
role="timer"
|
||||
aria-label={`Bomb timer ${timerText(remaining)}`}
|
||||
>
|
||||
{timerText(remaining)}
|
||||
</div>
|
||||
<div className="text-right" aria-label={`${view.strikes} of ${view.maxStrikes} strikes`}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Strikes</div>
|
||||
<div className="font-mono text-lg tracking-widest" aria-hidden>
|
||||
{Array.from({ length: view.maxStrikes }, (_, i) => (
|
||||
<span key={i} className={i < view.strikes ? 'text-red-500' : 'text-stone-600'}>
|
||||
✖
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{view.clock && view.clock.speed > 1 && (
|
||||
<div className="text-[10px] text-red-300">clock ×{view.clock.speed.toFixed(2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleFrame({ title, solved, children }: { title: string; solved: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className={`rounded-2xl border-2 p-4 ${solved ? 'border-emerald-500/60 bg-emerald-500/10' : 'border-stone-600 bg-stone-900/70'}`}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-mono text-sm font-bold uppercase tracking-widest">{title}</h3>
|
||||
<span className={`flex items-center gap-1.5 text-xs font-bold ${solved ? 'text-emerald-400' : 'text-stone-500'}`}>
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${solved ? 'bg-emerald-400' : 'bg-stone-600'}`} aria-hidden />
|
||||
{solved ? 'DISARMED' : 'ARMED'}
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction) => void }) {
|
||||
return (
|
||||
<ModuleFrame title="Wires" solved={module.solved}>
|
||||
<ol className="space-y-2">
|
||||
{module.wires.map((color, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<span className="w-5 font-mono text-sm text-stone-400">{i + 1}</span>
|
||||
<span className="h-3 flex-1 rounded-full shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
|
||||
<span className="w-16 font-mono text-sm font-bold uppercase">{color}</span>
|
||||
<button
|
||||
className="btn !min-h-[40px] px-3 text-sm"
|
||||
disabled={module.solved}
|
||||
onClick={() => act({ type: 'cut', index: i + 1 })}
|
||||
aria-label={`Cut wire ${i + 1}, ${color}`}
|
||||
>
|
||||
✂ Cut
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function BigButton({ module, act }: { module: ButtonModuleView; act: (a: DefuseAction) => void }) {
|
||||
const bg = SWATCH[module.color] ?? module.color;
|
||||
return (
|
||||
<ModuleFrame title="The Button" solved={module.solved}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-28 w-28 shrink-0 flex-col items-center justify-center rounded-full border-4 border-black/40 font-mono shadow-xl ${
|
||||
module.holding ? 'scale-95 shadow-inner' : ''
|
||||
} ${darkText(module.color) ? 'text-stone-900' : 'text-white'}`}
|
||||
style={{ backgroundColor: bg }}
|
||||
aria-label={`A ${module.color} button labelled ${module.label}`}
|
||||
role="img"
|
||||
>
|
||||
<span className="text-xl font-bold">{module.label}</span>
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">{module.color} button</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{module.holding ? (
|
||||
<>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-center font-mono text-sm font-bold uppercase ${darkText(module.strip ?? '') ? 'text-stone-900' : 'text-white'}`}
|
||||
style={{ backgroundColor: SWATCH[module.strip ?? ''] ?? '#444' }}
|
||||
aria-live="assertive"
|
||||
>
|
||||
Strip: {module.strip}
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => act({ type: 'release' })}>
|
||||
Release now
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'tap' })}>
|
||||
Tap it
|
||||
</button>
|
||||
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'hold' })}>
|
||||
Press & hold
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseAction) => void }) {
|
||||
return (
|
||||
<ModuleFrame title="Hearts & Symbols" solved={module.solved}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{module.symbols.map((s) => {
|
||||
const [glyph, ...name] = s.label.split(' ');
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`flex flex-col items-center rounded-xl border-2 px-2 py-3 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
s.pressed ? 'border-emerald-500 bg-emerald-500/20' : 'border-stone-500 bg-stone-800 hover:bg-stone-700'
|
||||
}`}
|
||||
disabled={s.pressed || module.solved}
|
||||
onClick={() => act({ type: 'press', symbol: s.id })}
|
||||
aria-label={`${name.join(' ')}${s.pressed ? ', pressed' : ''}`}
|
||||
>
|
||||
<span className="text-3xl" aria-hidden>{glyph}</span>
|
||||
<span className="mt-1 text-xs font-semibold">{name.join(' ')}{s.pressed ? ' ✓' : ''}</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;
|
||||
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">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Serial number</div>
|
||||
<div className="text-2xl font-bold tracking-[0.15em]">{serial}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Batteries</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
|
||||
</div>
|
||||
</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)} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DefuseGame({ view, state, receivedAt, send }: Props) {
|
||||
const remaining = useRemaining(view, receivedAt);
|
||||
const technician = view.seat === 'technician';
|
||||
const partner = state.game.roles[technician ? 'B' : 'A'].name;
|
||||
|
||||
let banner = null;
|
||||
if (view.status === 'ready') {
|
||||
banner = (
|
||||
<div className="card text-center">
|
||||
<div className="text-5xl" aria-hidden>💣</div>
|
||||
<h2 className="mt-2 font-display text-2xl font-bold">
|
||||
Level {view.level.index + 1}: {view.level.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-300">
|
||||
{timerText(view.level.timeSeconds)} on the clock. {view.maxStrikes} strikes and it's over.
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-stone-400">
|
||||
{technician
|
||||
? `You'll see the device. Describe everything to the ${partner}: serial number, batteries, every colour and label.`
|
||||
: `You have the manual below. Ask the ${partner} for the serial number and battery count first.`}
|
||||
</p>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
Start the clock ⏱
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (view.status === 'exploded') {
|
||||
banner = (
|
||||
<div className="rounded-2xl border-2 border-red-500/70 bg-red-950/60 p-5 text-center" aria-live="assertive">
|
||||
<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.
|
||||
</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')}>
|
||||
Try again with a new device
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (view.status === 'cleared') {
|
||||
banner = (
|
||||
<div className="rounded-2xl border-2 border-emerald-500/70 bg-emerald-950/50 p-5 text-center" aria-live="polite">
|
||||
<div className="text-5xl" aria-hidden>✅</div>
|
||||
<h2 className="mt-2 font-display text-2xl font-bold">Disarmed with {timerText(remaining)} to spare!</h2>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
Next level →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StatusBar view={view} remaining={remaining} />
|
||||
{banner}
|
||||
{view.seat === 'technician' && view.status !== 'ready' && (
|
||||
<div className={view.status === 'active' ? '' : 'pointer-events-none opacity-50'}>
|
||||
<Device view={view} send={send} />
|
||||
</div>
|
||||
)}
|
||||
{view.seat === 'technician' && view.status === 'active' && (
|
||||
<p className="text-center text-sm text-stone-400">You can't see the manual. Describe what you see out loud.</p>
|
||||
)}
|
||||
{view.seat === 'expert' && (
|
||||
<article className="rounded-md bg-[#ecebe4] p-5 text-stone-900 shadow-lg">
|
||||
<Markdown className="paper leading-7">{view.manual}</Markdown>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Dir, ExplorerView, LabyrinthView, NavigatorView, RoomState } from '@two-keys/shared';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: LabyrinthView;
|
||||
state: RoomState;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
/** "☀ A carved sun (the entrance)" → ["☀", "A carved sun (the entrance)"] */
|
||||
const splitLabel = (label: string): [string, string] => {
|
||||
const i = label.indexOf(' ');
|
||||
return i === -1 ? [label, ''] : [label.slice(0, i), label.slice(i + 1)];
|
||||
};
|
||||
|
||||
function Lanterns({ carried, total }: { carried: number; total: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1" aria-label={`${carried} of ${total} lanterns carried`}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} aria-hidden className={`text-2xl ${i < carried ? '' : 'opacity-25 grayscale'}`}>
|
||||
🏮
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Navigator: the whole map, but not where the Explorer is ----------
|
||||
|
||||
function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) {
|
||||
const width = view.grid[0].length;
|
||||
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">
|
||||
<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">
|
||||
{view.moves !== null && <div>{view.moves} moves</div>}
|
||||
{view.falls > 0 && <div className="text-red-300">{view.falls} trap{view.falls === 1 ? '' : 's'} sprung</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx-auto grid w-full max-w-md gap-px overflow-hidden rounded-lg border-2 border-accent/40 bg-black/60 p-1"
|
||||
style={{ gridTemplateColumns: `repeat(${width}, minmax(0, 1fr))` }}
|
||||
role="img"
|
||||
aria-label="Map of the labyrinth. The legend below lists every glyph, lantern, trap and the exit."
|
||||
>
|
||||
{view.grid.flatMap((row, y) =>
|
||||
[...row].map((cell, x) => {
|
||||
const key = `${x},${y}`;
|
||||
const glyph = glyphAt.get(key);
|
||||
const lantern = lanternAt.get(key);
|
||||
let content = '';
|
||||
let tone = 'bg-stone-800/40';
|
||||
if (cell === '#') tone = 'bg-stone-700';
|
||||
else if (cell === 'T') { content = '☠'; tone = 'bg-red-950 text-red-400'; }
|
||||
else if (cell === 'E') { content = '🚪'; tone = 'bg-emerald-950'; }
|
||||
else if (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; }
|
||||
else if (glyph) content = splitLabel(glyph.label)[0];
|
||||
return (
|
||||
<div key={key} className={`flex aspect-square items-center justify-center text-[clamp(11px,4vw,20px)] leading-none ${tone}`}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-stone-400">
|
||||
You can't see {explorerName}. Ask what's on the floor and which ways are open, then work out where they are.
|
||||
</p>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Map legend</h3>
|
||||
<ul className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
|
||||
{view.glyphs.map((g) => {
|
||||
const [symbol, text] = splitLabel(g.label);
|
||||
return (
|
||||
<li key={`${g.x},${g.y}`} className="flex gap-2">
|
||||
<span className="w-6 text-center" aria-hidden>{symbol}</span>
|
||||
{text}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{view.lanterns.map((l) => (
|
||||
<li key={l.name} className={`flex gap-2 ${l.collected ? 'text-stone-500 line-through' : ''}`}>
|
||||
<span className="w-6 text-center" aria-hidden>🏮</span>
|
||||
{l.name}
|
||||
{l.collected && <span className="no-underline"> ✓</span>}
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Explorer: one cell, four directions ----------
|
||||
|
||||
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) {
|
||||
// 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));
|
||||
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]);
|
||||
|
||||
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
|
||||
const open = DIRS.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
|
||||
<div className="text-sm text-stone-300">{view.moves} moves</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-2xl border border-accent/30 bg-[radial-gradient(circle_at_center,rgb(var(--accent)/0.22),transparent_70%)] p-6 text-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Under your feet</div>
|
||||
{view.glyphLabel ? (
|
||||
<>
|
||||
<div className="mt-2 text-7xl" aria-hidden>{symbol}</div>
|
||||
<p className="mt-2 font-display text-2xl font-bold">{text}</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 text-6xl opacity-60" aria-hidden>🕯️</div>
|
||||
)}
|
||||
{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>
|
||||
</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>
|
||||
</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!
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabyrinthGame({ view, state, send }: Props) {
|
||||
return view.seat === 'navigator' ? (
|
||||
<NavigatorMap view={view} explorerName={state.game.roles.B.name} />
|
||||
) : (
|
||||
<ExplorerCell view={view} send={send} navigatorName={state.game.roles.A.name} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useEffect, 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';
|
||||
import { ClueCard } from '../components/ClueCard';
|
||||
import { LockPanel } from '../components/LockPanel';
|
||||
import { Markdown, Sheet } from '../components/ui';
|
||||
import { socket, type Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: StoryView;
|
||||
state: RoomState;
|
||||
role: Role;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
function Clues({ clues }: { clues: ClueView[] }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{clues.map((clue) => (
|
||||
<ClueCard key={clue.id} clue={clue} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The lock springs open, the key drops onto the ring, either player continues. */
|
||||
function Unlocked({ lock, onContinue, last }: { lock: LockView; onContinue: () => void; last: boolean }) {
|
||||
return (
|
||||
<motion.section
|
||||
className="rounded-2xl border border-accent/50 bg-accent/10 p-5"
|
||||
initial={{ opacity: 0, scale: 0.94 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.span
|
||||
className="text-4xl"
|
||||
aria-hidden
|
||||
initial={{ rotate: -30, scale: 0.4 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 10 }}
|
||||
>
|
||||
🔓
|
||||
</motion.span>
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">Unlocked</div>
|
||||
{lock.keyName && <div className="font-display text-xl font-bold">🔑 {lock.keyName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{lock.successText && <Markdown className="mt-4 leading-7">{lock.successText}</Markdown>}
|
||||
<button className="btn-primary mt-5 w-full" onClick={onContinue}>
|
||||
{last ? 'On to the finale →' : 'Continue →'}
|
||||
</button>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Both players must agree: one proposes, the other agrees or changes it. */
|
||||
function Accusation({ finale, role, state, send }: { finale: AccusationView; role: Role; state: RoomState; send: Send }) {
|
||||
const proposalKey = JSON.stringify(finale.proposal?.answers ?? null);
|
||||
const [draft, setDraft] = useState<Record<string, string>>(finale.proposal?.answers ?? {});
|
||||
|
||||
// A new proposal (from either side) replaces whatever was being picked.
|
||||
useEffect(() => {
|
||||
if (finale.proposal) setDraft(finale.proposal.answers);
|
||||
}, [proposalKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const complete = finale.questions.every((q) => draft[q.id]);
|
||||
const matchesProposal =
|
||||
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;
|
||||
|
||||
let action;
|
||||
if (matchesProposal && finale.agreed) {
|
||||
action = (
|
||||
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
|
||||
You both agree. Make the accusation ⚖️
|
||||
</button>
|
||||
);
|
||||
} else if (matchesProposal && mine) {
|
||||
action = (
|
||||
<button className="btn w-full" disabled>
|
||||
Waiting for {partnerName} to agree…
|
||||
</button>
|
||||
);
|
||||
} else if (matchesProposal) {
|
||||
action = (
|
||||
<div className="space-y-2">
|
||||
<button className="btn-primary w-full" onClick={() => send('finale:agree')}>
|
||||
Agree with {partnerName} ✓
|
||||
</button>
|
||||
<p className="text-center text-xs text-stone-400">Disagree? Change any answer above to propose your own.</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
action = (
|
||||
<button className="btn-primary w-full" disabled={!complete} onClick={() => send('finale:propose', { answers: draft })}>
|
||||
{finale.proposal ? 'Propose this instead' : 'Propose this accusation'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
{finale.questions.map((q) => (
|
||||
<fieldset key={q.id} className="card">
|
||||
<legend className="float-left mb-3 w-full font-display text-xl font-bold">{q.label}</legend>
|
||||
<div className="clear-both space-y-2">
|
||||
{q.options.map((o) => {
|
||||
const picked = draft[q.id] === o.id;
|
||||
const proposed = finale.proposal?.answers[q.id] === o.id;
|
||||
return (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 transition focus-within:ring-2 focus-within:ring-accent ${
|
||||
picked ? 'border-accent bg-accent/20' : 'border-white/15 bg-black/20 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={q.id}
|
||||
className="h-4 w-4 accent-[rgb(var(--accent))]"
|
||||
checked={picked}
|
||||
onChange={() => setDraft((d) => ({ ...d, [q.id]: o.id }))}
|
||||
/>
|
||||
<span className="flex-1">{o.label}</span>
|
||||
{proposed && !picked && <span className="chip">proposed</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
|
||||
<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.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{view.suspects.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
|
||||
<ul className="space-y-2">
|
||||
{view.suspects.map((s) => (
|
||||
<li key={s.id} className="card !p-3">
|
||||
<div className="font-semibold">
|
||||
{s.name} <span className="font-normal text-stone-400">· {s.role}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-stone-300">{s.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoryGame({ view, state, role, send }: Props) {
|
||||
const [lockerOpen, setLockerOpen] = useState(false);
|
||||
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);
|
||||
|
||||
// Tell the partner what we're looking at.
|
||||
useEffect(() => {
|
||||
if (state.mode === 'duo') socket.emit('presence:view', { label: lockerOpen ? 'the evidence locker' : stageLabel });
|
||||
}, [lockerOpen, stageLabel, state.mode]);
|
||||
|
||||
// Braces matter: newer browsers return a promise from scrollTo, and an effect must not return one.
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, [view.currentChapter, atFinale]);
|
||||
|
||||
const heading = (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
{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)})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<h2 className="font-display text-3xl font-bold leading-tight">
|
||||
{chapter ? chapter.title : view.finale?.type === 'accusation' ? 'The Accusation' : 'The Way Out'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{heading}
|
||||
|
||||
{chapter && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
|
||||
<Clues clues={chapter.clues} />
|
||||
{chapter.lock.solved && view.awaitingContinue ? (
|
||||
<Unlocked
|
||||
lock={chapter.lock}
|
||||
last={view.currentChapter === view.totalChapters - 1}
|
||||
onContinue={() => send('chapter:continue')}
|
||||
/>
|
||||
) : (
|
||||
<LockPanel key={chapter.lock.id} lock={chapter.lock} send={send} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.finale?.type === 'accusation' && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
||||
<Accusation finale={view.finale} role={role} state={state} send={send} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.finale?.type === 'escape' && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
||||
<Clues clues={view.finale.clues} />
|
||||
<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} />
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Role, RoomState, SyncView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { RoleBadge } from '../components/ui';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: SyncView;
|
||||
state: RoomState;
|
||||
role: Role;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
function Marks({ view }: { view: SyncView }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2" aria-label={`${view.points} matches so far, ${view.pointsForKey} needed for the key`}>
|
||||
{Array.from({ length: view.questionsPerRound }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full border text-sm ${
|
||||
view.marks[i] === true
|
||||
? 'border-accent bg-accent text-stone-950'
|
||||
: view.marks[i] === false
|
||||
? 'border-white/20 bg-white/5 text-stone-500'
|
||||
: 'border-white/15 text-transparent'
|
||||
}`}
|
||||
>
|
||||
{view.marks[i] === true ? '♥' : view.marks[i] === false ? '✕' : '·'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
return (
|
||||
<div className="flex-1 [perspective:800px]">
|
||||
<motion.div
|
||||
className="card flex min-h-[7rem] flex-col items-center justify-center gap-2 text-center"
|
||||
initial={{ rotateY: 180, opacity: 0.4 }}
|
||||
animate={{ rotateY: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay }}
|
||||
>
|
||||
<RoleBadge role={role} name={name} />
|
||||
<div className="font-display text-lg font-bold leading-snug">{answer}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncGame({ view, state, role, send }: Props) {
|
||||
const roles = state.game.roles;
|
||||
const partnerName = roles[otherRole(role)].name;
|
||||
const couch = state.mode === 'couch';
|
||||
const header = (
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
Round {view.round.index + 1} of {view.round.total}
|
||||
{view.isReplay && ' · Replay'}
|
||||
</div>
|
||||
<h2 className="font-display text-3xl font-bold">{view.round.title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view.status === 'intro') {
|
||||
return (
|
||||
<div className="space-y-5 text-center">
|
||||
{header}
|
||||
<p className="text-lg text-stone-200">{view.round.blurb}</p>
|
||||
<p className="text-sm text-stone-400">
|
||||
{view.questionsPerRound} questions. Match on {view.pointsForKey} to earn the key. Answer privately, no peeking.
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
We're ready 💞
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.status === 'summary' && view.summary) {
|
||||
const { earned, canReplay, keyName } = view.summary;
|
||||
const last = view.round.index === view.round.total - 1;
|
||||
return (
|
||||
<div className="space-y-5 text-center" aria-live="polite">
|
||||
{header}
|
||||
<Marks view={view} />
|
||||
<motion.div initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-6xl" aria-hidden>
|
||||
{earned ? '🔑' : '🙃'}
|
||||
</motion.div>
|
||||
<p className="font-display text-2xl font-bold">
|
||||
{view.points} out of {view.questionsPerRound}
|
||||
</p>
|
||||
<p className="text-stone-300">
|
||||
{earned
|
||||
? `In sync! You earned the ${keyName}.`
|
||||
: canReplay
|
||||
? `Not quite ${view.pointsForKey}. You get one replay with fresh questions.`
|
||||
: 'No key for this round. Something to talk about over dessert.'}
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
{!earned && canReplay ? 'Replay this round ↻' : last ? 'See how we did →' : 'Next round →'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const q = view.question;
|
||||
if (!q) return null;
|
||||
const predict = view.round.type === 'predict';
|
||||
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{header}
|
||||
<Marks view={view} />
|
||||
<div className="card text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-stone-400">
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
<p className="text-center text-sm text-stone-400" aria-live="polite">
|
||||
{view.myAnswer !== null
|
||||
? couch
|
||||
? `Locked in. Pass the device to ${partnerName}.`
|
||||
: `Locked in. Waiting for ${partnerName}…`
|
||||
: view.partnerAnswered
|
||||
? `${partnerName} has answered. Your turn!`
|
||||
: 'Your answer stays hidden until you have both answered.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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} />
|
||||
</div>
|
||||
<motion.p
|
||||
className="text-center font-display text-2xl font-bold"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
{view.reveal.match ? '💞 In sync!' : '🙈 Not this time'}
|
||||
</motion.p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
{view.questionNumber < view.questionsPerRound ? 'Next question →' : 'See the round result →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Defaults; a room overrides these from its game's YAML theme. */
|
||||
--primary: 123 30 58;
|
||||
--accent: 232 176 74;
|
||||
--bg: 18 12 14;
|
||||
--heading-font: 'Playfair Display';
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg font-sans text-stone-100 antialiased;
|
||||
overflow-x: hidden;
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgb(var(--primary) / 0.35), transparent);
|
||||
background-attachment: fixed;
|
||||
transition: background-color 0.4s;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-2 font-semibold text-stone-100 transition hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl bg-accent px-4 py-2 font-semibold text-stone-950 shadow-lg shadow-black/30 transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-2xl border border-white/10 bg-white/[0.04] p-4;
|
||||
}
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/30 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide;
|
||||
}
|
||||
.field {
|
||||
@apply min-h-[44px] w-full rounded-xl border border-white/20 bg-black/40 px-3 py-2 text-base text-stone-100 placeholder:text-stone-500 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/40;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Markdown ---------- */
|
||||
.md {
|
||||
--line: rgb(255 255 255 / 0.18);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.md.paper {
|
||||
--line: rgb(0 0 0 / 0.22);
|
||||
}
|
||||
.md > * + * {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
.md h1,
|
||||
.md h2,
|
||||
.md h3 {
|
||||
font-family: var(--heading-font), 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.md h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.md h2 {
|
||||
font-size: 1.3em;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.md ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.md ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.md li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.md blockquote {
|
||||
border-left: 3px solid rgb(var(--accent) / 0.7);
|
||||
padding-left: 0.9em;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.md hr {
|
||||
border-color: var(--line);
|
||||
}
|
||||
.md table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md th,
|
||||
.md td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.3em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
.md th:empty {
|
||||
display: none;
|
||||
}
|
||||
.md pre {
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
.md pre code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.md code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.md .scroll-x {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Signal lamp ---------- */
|
||||
.lamp {
|
||||
transition: background-color 40ms, box-shadow 40ms;
|
||||
background-color: #2a0e0e;
|
||||
box-shadow: inset 0 0 12px rgb(0 0 0 / 0.8);
|
||||
}
|
||||
.lamp.on {
|
||||
background-color: #ff4d3d;
|
||||
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.35s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GameSummary, RoomState } from '@two-keys/shared';
|
||||
import { getToken, saveSession } from './session';
|
||||
import { request, socket } from './socket';
|
||||
|
||||
/** Re-renders every `ms` and returns the current time. */
|
||||
export function useNow(ms = 1000): number {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), ms);
|
||||
return () => clearInterval(id);
|
||||
}, [ms]);
|
||||
return now;
|
||||
}
|
||||
|
||||
let gamesCache: Promise<GameSummary[]> | null = null;
|
||||
|
||||
export function useGames(): GameSummary[] | null {
|
||||
const [games, setGames] = useState<GameSummary[] | null>(null);
|
||||
useEffect(() => {
|
||||
gamesCache ??= fetch('/api/games').then((r) => {
|
||||
if (!r.ok) throw new Error('failed to load games');
|
||||
return r.json();
|
||||
});
|
||||
let alive = true;
|
||||
gamesCache.then((g) => alive && setGames(g)).catch(() => {
|
||||
gamesCache = null;
|
||||
if (alive) setGames([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return games;
|
||||
}
|
||||
|
||||
export interface RoomSnapshot {
|
||||
state: RoomState;
|
||||
/** Local time the state arrived, for running clocks between updates. */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
// One join per room at a time, shared between effect runs (StrictMode mounts twice).
|
||||
const joins = new Map<string, ReturnType<typeof request>>();
|
||||
|
||||
function join(code: string) {
|
||||
let pending = joins.get(code);
|
||||
if (!pending) {
|
||||
pending = request('room:join', { code, playerToken: getToken(code) }).then((res) => {
|
||||
joins.delete(code);
|
||||
if (res.ok) saveSession(res.code, res.playerToken);
|
||||
return res;
|
||||
});
|
||||
joins.set(code, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Joins (or rejoins) a room and keeps its sanitized state up to date. */
|
||||
export function useRoom(code: string) {
|
||||
const [snapshot, setSnapshot] = useState<RoomSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(socket.connected);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const onConnect = () => {
|
||||
setConnected(true);
|
||||
join(code).then((res) => alive && setError(res.ok ? null : res.message));
|
||||
};
|
||||
const onDisconnect = () => setConnected(false);
|
||||
const onState = (state: RoomState) => {
|
||||
if (state.code === code) setSnapshot({ state, receivedAt: Date.now() });
|
||||
};
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
socket.on('room:state', onState);
|
||||
if (socket.connected) onConnect();
|
||||
return () => {
|
||||
alive = false;
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('disconnect', onDisconnect);
|
||||
socket.off('room:state', onState);
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
return { snapshot, error, connected };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// localStorage can throw (private mode, blocked storage), so every access is wrapped.
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string | null) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* play on without persistence */
|
||||
}
|
||||
}
|
||||
|
||||
const tokenKey = (code: string) => `twokeys:room:${code}`;
|
||||
const LAST_ROOM = 'twokeys:last-room';
|
||||
|
||||
/** The seat token for a room, so a refresh rejoins the same seat. */
|
||||
export const getToken = (code: string) => read(tokenKey(code));
|
||||
|
||||
export function saveSession(code: string, playerToken: string) {
|
||||
write(tokenKey(code), playerToken);
|
||||
write(LAST_ROOM, code);
|
||||
}
|
||||
|
||||
export const getLastRoom = () => read(LAST_ROOM);
|
||||
export const forgetLastRoom = () => write(LAST_ROOM, null);
|
||||
|
||||
export interface BestResult {
|
||||
finalSeconds: number;
|
||||
rank: string;
|
||||
hintsUsed: number;
|
||||
keysEarned: number;
|
||||
}
|
||||
|
||||
export function getBest(gameId: string): BestResult | null {
|
||||
try {
|
||||
return JSON.parse(read(`twokeys:best:${gameId}`) ?? 'null');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the best result per game: more keys first, then the faster time. */
|
||||
export function saveBest(gameId: string, result: BestResult): boolean {
|
||||
const best = getBest(gameId);
|
||||
const better =
|
||||
!best ||
|
||||
result.keysEarned > best.keysEarned ||
|
||||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
|
||||
return better;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import type { JoinAck } from '@two-keys/shared';
|
||||
import { pushToast } from './toasts';
|
||||
|
||||
export const socket = io({ transports: ['websocket', 'polling'] });
|
||||
|
||||
socket.on('toast', ({ message }: { message: string }) => pushToast(message));
|
||||
socket.on('error', ({ message }: { message: string }) => pushToast(message, 'error'));
|
||||
|
||||
/** Emit an event that the server acknowledges (room:create, room:join). */
|
||||
export function request(event: string, payload: object): Promise<JoinAck> {
|
||||
return new Promise((resolve) => {
|
||||
socket.timeout(8000).emit(event, payload, (err: Error | null, res: JoinAck) => {
|
||||
resolve(err ? { ok: false, code: 'timeout', message: "Can't reach the server. Check your connection." } : res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type Send = (event: string, payload?: Record<string, unknown>) => void;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Category, Role, Theme } from '@two-keys/shared';
|
||||
|
||||
/** "#7b1e3a" → "123 30 58", the form Tailwind's rgb(var(--x) / alpha) wants. */
|
||||
export function hexToRgb(hex: string): string {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
|
||||
}
|
||||
|
||||
export function themeVars(theme: Theme): CSSProperties {
|
||||
return {
|
||||
'--primary': hexToRgb(theme.primary),
|
||||
'--accent': hexToRgb(theme.accent),
|
||||
'--bg': hexToRgb(theme.background),
|
||||
'--heading-font': `'${theme.headingFont}'`,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
/** Applies a game's theme to the whole page while a room is open. */
|
||||
export function useTheme(theme: Theme | undefined) {
|
||||
const key = theme ? JSON.stringify(theme) : '';
|
||||
useEffect(() => {
|
||||
if (!key) return;
|
||||
const root = document.documentElement;
|
||||
const vars = themeVars(JSON.parse(key)) as Record<string, string>;
|
||||
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
|
||||
return () => {
|
||||
for (const name of Object.keys(vars)) root.style.removeProperty(name);
|
||||
};
|
||||
}, [key]);
|
||||
}
|
||||
|
||||
export const CATEGORY_LABEL: Record<Category, string> = {
|
||||
'murder-mystery': 'Murder Mystery',
|
||||
'escape-room': 'Escape Room',
|
||||
'co-op': 'Co-op',
|
||||
};
|
||||
|
||||
/** A is warm (amber), B is cool (teal). */
|
||||
export const ROLE_TEXT: Record<Role, string> = { A: 'text-roleA', B: 'text-roleB' };
|
||||
export const ROLE_BORDER: Record<Role, string> = { A: 'border-roleA/60', B: 'border-roleB/60' };
|
||||
export const ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
|
||||
|
||||
export const difficultyKeys = (n: number) => '🔑'.repeat(n);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: 'info' | 'error';
|
||||
}
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function set(next: Toast[]) {
|
||||
toasts = next;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function pushToast(message: string, kind: Toast['kind'] = 'info') {
|
||||
const id = nextId++;
|
||||
set([...toasts.slice(-2), { id, message, kind }]);
|
||||
setTimeout(() => set(toasts.filter((t) => t.id !== id)), 4500);
|
||||
}
|
||||
|
||||
export function useToasts(): Toast[] {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => toasts,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { GameCard } from '../components/GameCard';
|
||||
import { SiteShell } from '../components/SiteShell';
|
||||
import { useGames } from '../lib/hooks';
|
||||
import { getLastRoom } from '../lib/session';
|
||||
|
||||
function JoinForm() {
|
||||
const [code, setCode] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const clean = code.trim().toUpperCase();
|
||||
if (clean) navigate(`/join/${clean}`);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-xs gap-2">
|
||||
<input
|
||||
className="field text-center font-mono uppercase tracking-[0.3em]"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 5))}
|
||||
placeholder="CODE"
|
||||
aria-label="Room code from your partner"
|
||||
autoCapitalize="characters"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="btn shrink-0" disabled={code.length < 5}>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const games = useGames();
|
||||
const lastRoom = getLastRoom();
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<section className="flex flex-col items-center py-10 text-center sm:py-16">
|
||||
<p className="chip text-accent">Date night, solved</p>
|
||||
<h1 className="mt-5 max-w-3xl font-display text-4xl font-bold leading-[1.1] sm:text-6xl">
|
||||
Two players. One mystery. <span className="text-accent">Every key needs both of you.</span>
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-lg leading-8 text-stone-300">
|
||||
Co-op mysteries for couples. You each see different clues, so neither of you can crack it alone. Talk it
|
||||
out, pool what you know, and open the locks together.
|
||||
</p>
|
||||
<div className="mt-8 flex w-full flex-col items-center gap-3">
|
||||
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
|
||||
Pick a mystery
|
||||
</Link>
|
||||
<span className="text-sm text-stone-500">or join your partner's game</span>
|
||||
<JoinForm />
|
||||
{lastRoom && (
|
||||
<Link to={`/room/${lastRoom}`} className="py-3 text-sm text-accent underline underline-offset-4">
|
||||
Back to your last game ({lastRoom})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="library">
|
||||
<div className="mb-4 flex items-end justify-between">
|
||||
<h2 id="library" className="font-display text-2xl font-bold">
|
||||
The library
|
||||
</h2>
|
||||
<Link to="/how-to-play" className="-my-3 py-3 text-sm text-accent underline underline-offset-4">
|
||||
How does it work?
|
||||
</Link>
|
||||
</div>
|
||||
{games === null ? (
|
||||
<p className="text-stone-400">Loading games…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="text-red-300">Couldn't load the games. Is the server running?</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{games.map((game) => (
|
||||
<GameCard key={game.id} game={game} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SiteShell } from '../components/SiteShell';
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: '🎟️',
|
||||
title: 'Pick a game, share the code',
|
||||
body: 'One of you starts a game and gets a five-letter room code and a link. The other joins on their own phone. Only have one device? Choose couch mode and pass it back and forth.',
|
||||
},
|
||||
{
|
||||
icon: '🎭',
|
||||
title: 'Choose your roles',
|
||||
body: "Every game has two roles, like Inspector and Estate Insider. Your role decides which clues you get. Your partner gets a completely different set.",
|
||||
},
|
||||
{
|
||||
icon: '🗣️',
|
||||
title: 'Talk it out',
|
||||
body: "Describe what you see. Don't just show your screen, that's cheating (and less fun). Every lock needs something from each of you.",
|
||||
},
|
||||
{
|
||||
icon: '🔑',
|
||||
title: 'Open the locks together',
|
||||
body: 'Either of you can type the answer. Each lock you open earns a key. Collect them all to reach the finale.',
|
||||
},
|
||||
];
|
||||
|
||||
const TIPS = [
|
||||
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes on your final time, and you both see it.'],
|
||||
['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'],
|
||||
['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."],
|
||||
['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'],
|
||||
['🏆 Ranks', 'Finish under the estimated time with no hints to become Soulmate Sleuths.'],
|
||||
] as const;
|
||||
|
||||
export function HowToPlay() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<h1 className="mt-4 font-display text-4xl font-bold">How to play</h1>
|
||||
<p className="mt-2 max-w-2xl text-lg text-stone-300">
|
||||
Best played on a call or side by side. There's no in-game chat on purpose: the talking is the game.
|
||||
</p>
|
||||
|
||||
<ol className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title} className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl" aria-hidden>{step.icon}</span>
|
||||
<h2 className="font-display text-xl font-bold">
|
||||
{i + 1}. {step.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-2 leading-7 text-stone-300">{step.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="mt-10 font-display text-2xl font-bold">Good to know</h2>
|
||||
<dl className="mt-4 space-y-3">
|
||||
{TIPS.map(([term, text]) => (
|
||||
<div key={term} className="card !py-3">
|
||||
<dt className="font-semibold">{term}</dt>
|
||||
<dd className="mt-0.5 text-stone-300">{text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
|
||||
Pick a mystery
|
||||
</Link>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } 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';
|
||||
|
||||
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
|
||||
|
||||
export function Library() {
|
||||
const games = useGames();
|
||||
const [filter, setFilter] = useState<Category | 'all'>('all');
|
||||
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<h1 className="mt-4 font-display text-4xl font-bold">Game library</h1>
|
||||
<p className="mt-2 text-stone-300">
|
||||
New here? Start with <strong>The Clockmaker's Attic</strong>. It's the gentlest first game.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
aria-pressed={filter === f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{games === null ? (
|
||||
<p className="mt-6 text-stone-400">Loading games…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="mt-6 text-red-300">Couldn't load the games. Is the server running?</p>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{shown.map((game) => (
|
||||
<GameCard key={game.id} game={game} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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';
|
||||
|
||||
function ShareBox({ code }: { code: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const link = `${window.location.origin}/join/${code}`;
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard blocked: the link is on screen to copy by hand */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="card text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Room code</div>
|
||||
<div className="mt-1 font-mono text-5xl font-bold tracking-[0.25em] text-accent" aria-label={`Room code ${code.split('').join(' ')}`}>
|
||||
{code}
|
||||
</div>
|
||||
<p className="mt-3 break-all font-mono text-xs text-stone-400">{link}</p>
|
||||
<button className="btn mt-3 w-full" onClick={copy}>
|
||||
{copied ? 'Copied ✓' : 'Copy invite link'}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Lobby({ state }: { state: RoomState }) {
|
||||
const { game, you, partner } = state;
|
||||
const couch = state.mode === 'couch';
|
||||
const mySeat = you.seat === 'A' || you.seat === 'B' ? you.seat : null;
|
||||
|
||||
const holder = (role: Role) => (couch ? null : mySeat === role ? 'You' : partner?.seat === role ? 'Your partner' : null);
|
||||
|
||||
let status: string;
|
||||
if (couch) status = 'One device, two detectives. Decide who is who, then start.';
|
||||
else if (!partner) status = 'Waiting for your partner to join…';
|
||||
else if (!mySeat) status = 'Pick a role. Your partner gets the other one.';
|
||||
else if (you.ready && !partner.ready) status = "You're ready. Waiting for your partner…";
|
||||
else if (!you.ready && partner.ready) status = 'Your partner is ready. Tap Ready when you are!';
|
||||
else status = "Both here. Tap Ready when you're set.";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-6">
|
||||
<header className="text-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-stone-300">
|
||||
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
|
||||
<span>⏱ {game.estimatedMinutes} min</span>
|
||||
<span aria-label={`Difficulty ${game.difficulty} of 3`}>{difficultyKeys(game.difficulty)}</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>
|
||||
</header>
|
||||
|
||||
{!couch && <ShareBox code={state.code} />}
|
||||
|
||||
<section aria-labelledby="roles">
|
||||
<h2 id="roles" className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">
|
||||
{couch ? 'The two roles' : 'Choose your role'}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(['A', 'B'] as const).map((role) => {
|
||||
const who = holder(role);
|
||||
const mine = mySeat === role;
|
||||
return (
|
||||
<div key={role} className={`card flex flex-col ${mine ? `${ROLE_BORDER[role]} bg-white/[0.08]` : ''}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<RoleBadge role={role} name={game.roles[role].name} />
|
||||
{who && <span className="text-xs font-semibold text-stone-300">{who}</span>}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.roles[role].blurb}</p>
|
||||
{!couch && (
|
||||
<button
|
||||
className="btn mt-3"
|
||||
disabled={mine}
|
||||
onClick={() => socket.emit('room:pickRole', { role })}
|
||||
>
|
||||
{mine ? 'This is you' : who ? 'Swap roles' : `Play as ${game.roles[role].name}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<Markdown className="leading-7 text-stone-200">{game.intro}</Markdown>
|
||||
</section>
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 border-t border-white/10 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-sm text-stone-300" aria-live="polite">
|
||||
{!couch && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${partner?.online ? 'bg-emerald-400' : 'bg-stone-500'}`}
|
||||
/>
|
||||
)}
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
className={you.ready ? 'btn w-full' : 'btn-primary w-full'}
|
||||
disabled={!couch && !mySeat}
|
||||
onClick={() => socket.emit('room:ready')}
|
||||
>
|
||||
{couch ? 'Start the game' : you.ready ? 'Ready ✓ (tap to undo)' : "I'm ready"}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-xs text-stone-500">Best played on a call or side by side.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useState, type CSSProperties } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { otherRole, type Role, type RoomState } from '@two-keys/shared';
|
||||
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 { RoleBadge } from '../components/ui';
|
||||
import { DefuseGame } from '../engines/DefuseGame';
|
||||
import { LabyrinthGame } from '../engines/LabyrinthGame';
|
||||
import { StoryGame } from '../engines/StoryGame';
|
||||
import { SyncGame } from '../engines/SyncGame';
|
||||
import { socket, type Send } from '../lib/socket';
|
||||
|
||||
const ROLE_COLOR: Record<Role, string> = { A: '#f5b942', B: '#2dd4bf' };
|
||||
|
||||
export function Play({ state, receivedAt }: { state: RoomState; receivedAt: number }) {
|
||||
const couch = state.you.seat === 'both';
|
||||
const [activeRole, setActiveRole] = useState<Role>(couch ? 'A' : (state.you.seat as Role));
|
||||
// Couch mode: the role waiting behind the "pass the device" cover, if it's up.
|
||||
const [cover, setCover] = useState<Role | null>(couch ? 'A' : null);
|
||||
|
||||
const send = useCallback<Send>(
|
||||
(event, payload = {}) => socket.emit(event, couch ? { ...payload, asRole: activeRole } : payload),
|
||||
[couch, activeRole],
|
||||
);
|
||||
|
||||
const { game } = state;
|
||||
const view = state.views[activeRole];
|
||||
|
||||
if (cover) {
|
||||
return (
|
||||
<PassDeviceScreen
|
||||
role={cover}
|
||||
roleName={game.roles[cover].name}
|
||||
onReveal={() => {
|
||||
setActiveRole(cover);
|
||||
setCover(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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`}
|
||||
>
|
||||
<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">
|
||||
🔑🔑
|
||||
</Link>
|
||||
<h1 className="min-w-0 flex-1 truncate font-display text-base font-bold">{game.title}</h1>
|
||||
<Timer timer={state.timer} receivedAt={receivedAt} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<KeyRing keys={state.keys} total={game.totalKeys} />
|
||||
{couch ? (
|
||||
<button
|
||||
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setCover(otherRole(activeRole))}
|
||||
>
|
||||
<span className="truncate">Pass to {game.roles[otherRole(activeRole)].name}</span>
|
||||
<span aria-hidden>👀</span>
|
||||
</button>
|
||||
) : (
|
||||
<PartnerStatus state={state} />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Notepad text={state.notepad} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="pb-5 pt-3">
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-stone-400">
|
||||
<span>You are</span>
|
||||
<RoleBadge role={activeRole} name={game.roles[activeRole].name} />
|
||||
</div>
|
||||
{view?.engine === 'story' && <StoryGame view={view} state={state} role={activeRole} send={send} />}
|
||||
{view?.engine === 'defuse' && <DefuseGame view={view} state={state} receivedAt={receivedAt} send={send} />}
|
||||
{view?.engine === 'sync' && <SyncGame view={view} state={state} role={activeRole} send={send} />}
|
||||
{view?.engine === 'labyrinth' && <LabyrinthGame view={view} state={state} send={send} />}
|
||||
</main>
|
||||
|
||||
{!couch && <PingOverlay roles={game.roles} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatClock, type GameResult, type RoomState } from '@two-keys/shared';
|
||||
import { GameCard } from '../components/GameCard';
|
||||
import { Markdown } from '../components/ui';
|
||||
import { useGames } from '../lib/hooks';
|
||||
import { forgetLastRoom, saveBest } from '../lib/session';
|
||||
|
||||
const RANK_ICON: Record<string, string> = {
|
||||
'Soulmate Sleuths': '💞',
|
||||
'Dream Team': '🌟',
|
||||
'Getting There': '🧩',
|
||||
};
|
||||
|
||||
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||
return (
|
||||
<div className="card !p-3 text-center">
|
||||
<div className="font-mono text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xs uppercase tracking-wider text-stone-400">{label}</div>
|
||||
{note && <div className="mt-0.5 text-[11px] text-red-300">{note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Results({ state, result }: { state: RoomState; result: GameResult }) {
|
||||
const { game } = state;
|
||||
const games = useGames();
|
||||
const [isBest, setIsBest] = useState(false);
|
||||
|
||||
// Remember the best result for this game on this device.
|
||||
useEffect(() => {
|
||||
forgetLastRoom();
|
||||
if (!result.success) return;
|
||||
setIsBest(
|
||||
saveBest(game.id, {
|
||||
finalSeconds: result.finalSeconds,
|
||||
rank: result.rank,
|
||||
hintsUsed: result.hintsUsed,
|
||||
keysEarned: result.keysEarned,
|
||||
}),
|
||||
);
|
||||
}, [game.id, result]);
|
||||
|
||||
const others = (games ?? []).filter((g) => g.id !== game.id);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6 px-4 py-8">
|
||||
<header className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">{game.title}</div>
|
||||
<motion.div
|
||||
className="mt-3 text-7xl"
|
||||
aria-hidden
|
||||
initial={{ scale: 0.3, rotate: -20, opacity: 0 }}
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
||||
>
|
||||
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
</motion.div>
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
|
||||
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
|
||||
</header>
|
||||
|
||||
{result.ending && (
|
||||
<section className="card">
|
||||
<Markdown className="text-lg leading-8">{result.ending}</Markdown>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{result.reveal && (
|
||||
<section className="rounded-2xl border border-accent/40 bg-accent/10 p-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-accent">What really happened</h2>
|
||||
<Markdown className="mt-2 leading-7">{result.reveal}</Markdown>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-2 gap-3" aria-label="Your results">
|
||||
<Stat
|
||||
label="Final time"
|
||||
value={formatClock(result.finalSeconds)}
|
||||
note={result.penaltySeconds > 0 ? `includes +${formatClock(result.penaltySeconds)} penalty` : undefined}
|
||||
/>
|
||||
<Stat label="Keys earned" value={`${result.keysEarned}/${result.totalKeys}`} />
|
||||
<Stat label="Hints used" value={String(result.hintsUsed)} />
|
||||
<Stat label="Wrong guesses" value={String(result.wrongGuesses)} />
|
||||
</section>
|
||||
<p className="text-center text-xs text-stone-500">
|
||||
Estimated time for this game: {game.estimatedMinutes} minutes.
|
||||
</p>
|
||||
|
||||
{others.length > 0 && (
|
||||
<section aria-labelledby="another">
|
||||
<h2 id="another" className="mb-3 font-display text-2xl font-bold">
|
||||
Play another
|
||||
</h2>
|
||||
<div className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-3">
|
||||
{others.map((g) => (
|
||||
<GameCard key={g.id} game={g} className="w-[78%] max-w-xs shrink-0 snap-center" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Link to="/" className="btn">
|
||||
Back to the library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { Spinner } from '../components/ui';
|
||||
import { useRoom } from '../lib/hooks';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { Lobby } from './Lobby';
|
||||
import { Play } from './Play';
|
||||
import { Results } from './Results';
|
||||
|
||||
/** /join/K7MRX is the share link; the room page does the actual joining. */
|
||||
export function JoinRedirect() {
|
||||
const { code = '' } = useParams();
|
||||
return <Navigate to={`/room/${code.toUpperCase()}`} replace />;
|
||||
}
|
||||
|
||||
export function Room() {
|
||||
const code = (useParams().code ?? '').toUpperCase();
|
||||
const { snapshot, error, connected } = useRoom(code);
|
||||
useTheme(snapshot?.state.game.theme);
|
||||
|
||||
if (error && !snapshot) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="text-5xl" aria-hidden>🗝️</div>
|
||||
<h1 className="font-display text-3xl font-bold">This door won't open</h1>
|
||||
<p className="max-w-sm text-stone-300">{error}</p>
|
||||
<Link to="/" className="btn-primary">
|
||||
Back to the library
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!snapshot) return <Spinner label={`Opening room ${code}…`} />;
|
||||
|
||||
const { state, receivedAt } = snapshot;
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
{!connected && (
|
||||
<div role="status" className="sticky top-0 z-30 bg-red-900 px-4 py-1.5 text-center text-sm font-medium">
|
||||
Connection lost. Reconnecting…
|
||||
</div>
|
||||
)}
|
||||
{state.phase === 'lobby' && <Lobby state={state} />}
|
||||
{/* Keyed by seat so a couch/duo rejoin starts from the right role. */}
|
||||
{state.phase === 'playing' && <Play key={state.you.seat} state={state} receivedAt={receivedAt} />}
|
||||
{state.phase === 'finished' && state.result && <Results state={state} result={state.result} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Per-game theme, set as CSS variables from the YAML `theme` block.
|
||||
primary: 'rgb(var(--primary) / <alpha-value>)',
|
||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||
bg: 'rgb(var(--bg) / <alpha-value>)',
|
||||
// Role colours: A is warm, B is cool.
|
||||
roleA: '#f5b942',
|
||||
roleB: '#2dd4bf',
|
||||
},
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const server = 'http://localhost:3001';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': server,
|
||||
'/socket.io': { target: server, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
id: blue-lantern
|
||||
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
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#1f3a68", accent: "#e8b04a", background: "#0b0f1c", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: The Detective
|
||||
blurb: You have the band's paperwork, the police search log, and the stage manager's notes.
|
||||
B:
|
||||
name: The Regular
|
||||
blurb: You've drunk here every Friday for ten years. You know the staff, the bar, and the gossip.
|
||||
intro: |
|
||||
Friday night at **the Blue Lantern**, the hottest jazz club on Harbour Street.
|
||||
The last set kicks off at 23:00. Smoke, brass, a full house.
|
||||
|
||||
When the band takes its bow at 23:25, club owner **Vincent Cole** is found in his
|
||||
office behind the stage. One bullet. Nobody heard a thing.
|
||||
|
||||
One of you is the detective who caught the case. The other is the regular who knows
|
||||
everybody's business. **Put your heads together.**
|
||||
|
||||
suspects:
|
||||
- id: ruby
|
||||
name: Ruby Day
|
||||
role: Singer
|
||||
description: The voice of the Blue Lantern. Fought with Vincent about her pay last week.
|
||||
- id: sammy
|
||||
name: Sammy Keys
|
||||
role: Pianist
|
||||
description: Quiet genius. Writes most of the club's songs. Never talks about money.
|
||||
- id: frankie
|
||||
name: Frankie Moretti
|
||||
role: Moneylender
|
||||
description: Vincent owed him. A lot. Sat at the bar all night watching the door.
|
||||
- id: lou
|
||||
name: Lou Banner
|
||||
role: Bartender
|
||||
description: Sees everything, says nothing. Vincent's oldest employee.
|
||||
- id: grace
|
||||
name: Grace Cole
|
||||
role: Vincent's wife
|
||||
description: Hates jazz. Came tonight anyway. Nobody knows why.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: Under the Drums
|
||||
narrative: |
|
||||
The office is right behind the stage. A gunshot in there should have stopped the room cold.
|
||||
Unless something louder was playing at the time.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-setlist
|
||||
title: Last set, taped to the piano
|
||||
type: document
|
||||
body: |
|
||||
**THE LAST SET** (no breaks, straight through)
|
||||
1. Night Train · 4 min
|
||||
2. Blue Moon · 6 min
|
||||
3. Take Five · 5 min
|
||||
4. Caravan · 7 min
|
||||
5. Goodnight Sweetheart · 3 min
|
||||
B:
|
||||
- id: b1-lou
|
||||
title: What Lou the bartender told you
|
||||
type: note
|
||||
body: |
|
||||
"Band kicked off the last set at eleven on the dot. Max always closes **Caravan**
|
||||
with a drum solo, the **last two minutes** of the tune. Loud enough to rattle the glasses.
|
||||
If there was a shot, it was under that solo. Nothing else is that loud in here."
|
||||
lock:
|
||||
id: lock-solo
|
||||
prompt: At what time did the drum solo start?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["2320", "1120", "1120PM"]
|
||||
hints:
|
||||
- Add up the songs before Caravan, then work out when Caravan ended.
|
||||
- Caravan runs 23:15 to 23:22.
|
||||
- The solo is the last two minutes of Caravan, so it starts at 23:20.
|
||||
successText: |
|
||||
**23:20.** For two minutes, the whole room was drowned in drums.
|
||||
That's when Vincent died.
|
||||
keyName: Drumstick Key
|
||||
|
||||
- id: ch2
|
||||
title: Who Left the Stage?
|
||||
narrative: |
|
||||
Everyone has an alibi. But an alibi for "the evening" isn't an alibi for 23:20 to 23:22.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-stagemanager
|
||||
title: Stage manager's notes
|
||||
type: report
|
||||
body: |
|
||||
- Ruby only sings on vocal numbers. **Caravan is instrumental**, so she's off stage for it.
|
||||
- **During Max's drum solo, the rest of the band walks off** and comes back for the final bar. House tradition.
|
||||
- The stage-left door leads straight to the office corridor. Ten steps.
|
||||
B:
|
||||
- id: b2-gossip
|
||||
title: Who you saw, and who saw who
|
||||
type: note
|
||||
body: |
|
||||
- **Frankie** was at the bar the whole set. Lou poured him a whiskey right as the drums kicked in.
|
||||
- **Lou** never left the bar. You were sitting right in front of him.
|
||||
- **Grace** was in the coat-check queue from about 23:15 to 23:25, complaining to Dolly the coat girl.
|
||||
- **Ruby** came to the coat-check at 23:21 to borrow Dolly's lighter. Dolly lit it for her.
|
||||
lock:
|
||||
id: lock-alibi
|
||||
prompt: Who had NO alibi during the drum solo?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: ruby, label: Ruby Day }
|
||||
- { id: sammy, label: Sammy Keys }
|
||||
- { id: frankie, label: Frankie Moretti }
|
||||
- { id: lou, label: Lou Banner }
|
||||
- { id: grace, label: Grace Cole }
|
||||
answers: ["sammy"]
|
||||
hints:
|
||||
- The stage manager says something surprising about who's on stage during a drum solo.
|
||||
- Everyone in the band except Max leaves during the solo. Which of them has a witness?
|
||||
- Ruby was at the coat-check. Sammy was off stage, alone, ten steps from the office.
|
||||
successText: |
|
||||
**Sammy Keys.** Everyone assumed the pianist was at his piano.
|
||||
But during the solo, the piano bench sat empty.
|
||||
keyName: Stage Door Key
|
||||
|
||||
- id: ch3
|
||||
title: Vincent's Safe
|
||||
narrative: |
|
||||
Vincent's office safe is still locked. Four-digit dial. Lou says Vincent
|
||||
"kept the combination in his head and his heart."
|
||||
clues:
|
||||
A:
|
||||
- id: a3-sheet
|
||||
title: Sheet music on Vincent's desk
|
||||
type: document
|
||||
body: |
|
||||
*"Lantern Waltz", Vincent's favourite tune.*
|
||||
The first four notes are circled in red ink:
|
||||
|
||||
## C · A · F · E
|
||||
|
||||
Scribbled underneath: *"The safe plays my song."*
|
||||
B:
|
||||
- id: b3-piano
|
||||
title: Sticker strip on the rehearsal piano
|
||||
type: diagram
|
||||
body: |
|
||||
Sammy numbered the white keys for the beginners' class:
|
||||
```
|
||||
| C | D | E | F | G | A | B |
|
||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|
||||
```
|
||||
lock:
|
||||
id: lock-safe
|
||||
prompt: Enter the safe combination.
|
||||
inputType: code
|
||||
format: 4 digits
|
||||
answers: ["1643"]
|
||||
hints:
|
||||
- One of you has the notes. The other has the numbers for the notes.
|
||||
- Use the beginner sticker numbers, not the alphabet.
|
||||
- C=1, A=6, F=4, E=3.
|
||||
successText: |
|
||||
The safe swings open. Inside: a record contract from Galaxy Records.
|
||||
**Twelve songs, all written by Sammy Keys. All registered under Vincent Cole's name.**
|
||||
Signing date: Monday. Advance: more than Sammy would earn in twenty years.
|
||||
keyName: Safe Key
|
||||
|
||||
- id: ch4
|
||||
title: The Missing Gun
|
||||
narrative: |
|
||||
Vincent was shot with his own .38 from his desk drawer. The police have torn the club apart.
|
||||
No gun.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-search
|
||||
title: Police search log
|
||||
type: log
|
||||
body: |
|
||||
| Area | Searched | Result |
|
||||
|------------------|----------|--------|
|
||||
| Office | Yes | Empty drawer |
|
||||
| Dressing rooms | Yes | Nothing |
|
||||
| Bar + storeroom | Yes | Nothing |
|
||||
| Coat-check | Yes | Nothing |
|
||||
| Drum kit, bass case | Yes | Nothing |
|
||||
| Alley bins | Yes | Nothing |
|
||||
| Grand piano | **No** | Lid locked, owner's prized Steinway |
|
||||
B:
|
||||
- id: b4-max
|
||||
title: Overheard from Max the drummer
|
||||
type: transcript
|
||||
body: |
|
||||
"When we came back for the last chorus, Sammy's first chord sounded *wrong*.
|
||||
Dead. A thunk, like something heavy was lying on the strings.
|
||||
He played the rest of the night up on the high keys only. Never seen him do that."
|
||||
lock:
|
||||
id: lock-gun
|
||||
prompt: Where is the gun hidden?
|
||||
inputType: text
|
||||
format: One or two words
|
||||
answers: ["PIANO", "THEPIANO", "GRANDPIANO", "STEINWAY", "INSIDETHEPIANO", "INPIANO"]
|
||||
hints:
|
||||
- Something was sitting on some strings.
|
||||
- Which part of the club did the police not search?
|
||||
- The grand piano.
|
||||
successText: |
|
||||
The police force the lid. Lying across the low strings of the Steinway:
|
||||
**Vincent's .38**, one round fired.
|
||||
keyName: Piano Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: Four keys. One killer. Make the call together.
|
||||
maxAttempts: 2
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Vincent Cole?
|
||||
options:
|
||||
- { id: ruby, label: Ruby Day }
|
||||
- { id: sammy, label: Sammy Keys }
|
||||
- { id: frankie, label: Frankie Moretti }
|
||||
- { id: lou, label: Lou Banner }
|
||||
- { id: grace, label: Grace Cole }
|
||||
answer: sammy
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: gunsolo, label: Shot with Vincent's .38 under the drum solo, gun hidden in the piano }
|
||||
- { id: alley, label: Shot from the alley through the office window }
|
||||
- { id: poison, label: Poison in his whiskey, gunshot staged later }
|
||||
answer: gunsolo
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: debt, label: An unpaid gambling debt }
|
||||
- { id: songs, label: Vincent stole his songs and was about to sell them }
|
||||
- { id: pay, label: A fight over wages }
|
||||
- { id: love, label: A love triangle with Grace }
|
||||
answer: songs
|
||||
successEnding: |
|
||||
Sammy sits at the piano one last time and plays four notes. C, A, F, E.
|
||||
"I wrote that one too," he says. "Every song he called his. I found the contract Tuesday."
|
||||
|
||||
He slipped off stage when the band cleared for Max's solo, took Vincent's gun from the
|
||||
drawer, and was back for the final bar before anyone looked up.
|
||||
|
||||
**Case closed.** The band plays on. 🎷🔑
|
||||
failEnding: |
|
||||
The wrong person goes away. Sammy keeps playing, a little sadder every year.
|
||||
reveal: |
|
||||
**Sammy Keys** shot Vincent with his own **.38** during the **drum solo** and hid the gun
|
||||
**inside the piano**, because Vincent had **stolen his songs** and was about to sell them.
|
||||
@@ -0,0 +1,194 @@
|
||||
id: clockmakers-attic
|
||||
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
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#8a5a2b", accent: "#f2c66d", background: "#17110b", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: The Tinkerer
|
||||
blurb: You're on the workbench side. Clocks, gears, and the old man's letters.
|
||||
B:
|
||||
name: The Collector
|
||||
blurb: You're on the shelves side. Paintings, samplers, and his treasure inventory.
|
||||
intro: |
|
||||
You bought an old house on a whim. The attic door was painted shut.
|
||||
Tonight you finally pry it open, step inside together... and it **slams behind you**.
|
||||
|
||||
Dust. Brass. A hundred clocks, all still ticking. A letter on the workbench:
|
||||
|
||||
> *"Whoever finds my attic, welcome. I've hidden the way out in my favourite things.
|
||||
> Work together. Nothing in here opens for one pair of hands. — Old Mr Albrecht"*
|
||||
|
||||
A tall beam of shelves splits the room in half. You can hear each other, but you can't see each other's side.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Three Clocks
|
||||
narrative: |
|
||||
An old sea trunk sits in the middle of the room. Brass padlock, three dials.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-clocks
|
||||
title: Three clocks on the workbench
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
🐦 ROBIN clock ..... hour hand on 3
|
||||
🦉 OWL clock ....... hour hand on 7
|
||||
🦊 FOX clock ....... hour hand on 9
|
||||
```
|
||||
All three stopped. The trunk lock has three dials, 0–9.
|
||||
B:
|
||||
- id: b1-sampler
|
||||
title: Embroidered sampler on the wall
|
||||
type: note
|
||||
body: |
|
||||
*Stitched in faded thread:*
|
||||
|
||||
**First the bird that sings the dawn,
|
||||
then the hunter on the lawn,
|
||||
last the bird that rules the night.
|
||||
Set them so, and set them right.**
|
||||
lock:
|
||||
id: lock-trunk
|
||||
prompt: Set the trunk's three dials.
|
||||
inputType: code
|
||||
format: 3 digits
|
||||
answers: ["397"]
|
||||
hints:
|
||||
- The sampler gives you an order. The clocks give you numbers.
|
||||
- Dawn bird = robin. Hunter = fox. Night bird = owl.
|
||||
- Robin 3, fox 9, owl 7.
|
||||
successText: |
|
||||
The trunk creaks open. Inside, wrapped in velvet: a **music box** with four coloured buttons,
|
||||
and a small brass key.
|
||||
keyName: Trunk Key
|
||||
|
||||
- id: ch2
|
||||
title: The Music Box
|
||||
narrative: |
|
||||
The music box is silent. Four buttons on its lid: **red, blue, green, gold**.
|
||||
Press them in the right order and it should play.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-musicbox
|
||||
title: Engraving under the lid
|
||||
type: note
|
||||
body: |
|
||||
*"Play the colours of the sky-bow, top to bottom.
|
||||
All but the one my pendulum stole."*
|
||||
B:
|
||||
- id: b2-painting
|
||||
title: Small oil painting on the shelf
|
||||
type: photo
|
||||
body: |
|
||||
*A painted rainbow over a village. Only four bands, top to bottom:
|
||||
**red, gold, green, blue**.*
|
||||
- id: b2-pendulum
|
||||
title: The grandfather clock on your side
|
||||
type: photo
|
||||
body: |
|
||||
*A tall clock. Its brass pendulum has been painted a deep **green**.
|
||||
A label on the case: "Borrowed a colour for my favourite clock. — A."*
|
||||
lock:
|
||||
id: lock-musicbox
|
||||
prompt: Which order do you press the buttons?
|
||||
inputType: text
|
||||
format: Three colours, in order
|
||||
answers: ["REDGOLDBLUE"]
|
||||
hints:
|
||||
- The "sky-bow" is a rainbow. The Collector can see one.
|
||||
- The pendulum stole green. Leave it out.
|
||||
- Red, gold, blue.
|
||||
successText: |
|
||||
The music box plays a tinkling waltz. A drawer pops out of its base.
|
||||
Inside: a crank handle and a sketch of the **gear wall**.
|
||||
keyName: Music Box Key
|
||||
|
||||
- id: ch3
|
||||
title: The Gear Wall
|
||||
narrative: |
|
||||
On the far wall, three brass gears mesh together. A crank socket sits on the biggest one.
|
||||
A drawer below has a number dial.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-gears
|
||||
title: The gear wall
|
||||
type: diagram
|
||||
body: |
|
||||
```
|
||||
(Gear A: 48 teeth) ⟷ (Gear B: 16 teeth) ⟷ (Gear C: 24 teeth)
|
||||
↑ crank
|
||||
```
|
||||
Each gear meshes with the next. Every tooth that passes on one passes on the other.
|
||||
B:
|
||||
- id: b3-instructions
|
||||
title: Note pinned to the shelf
|
||||
type: note
|
||||
body: |
|
||||
*Turn my crank exactly **5 full turns**.
|
||||
The drawer wants to know how many full turns **Gear C** makes.*
|
||||
lock:
|
||||
id: lock-gears
|
||||
prompt: How many turns does Gear C make?
|
||||
inputType: code
|
||||
format: A number
|
||||
answers: ["10"]
|
||||
hints:
|
||||
- Count teeth. Five turns of Gear A moves 5 × 48 teeth past every gear.
|
||||
- Gear B doesn't change the answer. Only the first and last gear matter.
|
||||
- 240 teeth ÷ 24 teeth per turn = 10.
|
||||
successText: |
|
||||
The drawer slides open. Inside: Mr Albrecht's last letter, and his treasure inventory, complete.
|
||||
keyName: Gear Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: The attic door has a seven-letter word lock.
|
||||
clues:
|
||||
A:
|
||||
- id: af-letter
|
||||
title: Mr Albrecht's last letter
|
||||
type: letter
|
||||
body: |
|
||||
*My dears,*
|
||||
|
||||
*Line up my seven treasures **in the order I made them**, oldest first.
|
||||
Their first letters are the word I'd give the two of you.*
|
||||
|
||||
*— A.*
|
||||
B:
|
||||
- id: bf-inventory
|
||||
title: Treasure inventory (dates engraved on each)
|
||||
type: table
|
||||
body: |
|
||||
| Treasure | Year made |
|
||||
|----------------|-----------|
|
||||
| Violin | 1922 |
|
||||
| Fob watch | 1902 |
|
||||
| Rocking horse | 1915 |
|
||||
| Egg timer | 1931 |
|
||||
| Oil lamp | 1909 |
|
||||
| Rug loom | 1938 |
|
||||
| Easel | 1920 |
|
||||
lock:
|
||||
id: lock-door
|
||||
prompt: Enter the seven-letter word.
|
||||
inputType: text
|
||||
format: 7 letters
|
||||
answers: ["FOREVER"]
|
||||
hints:
|
||||
- Sort the treasures by year, oldest to newest.
|
||||
- "1902 Fob watch, 1909 Oil lamp, 1915 Rocking horse..."
|
||||
- F-O-R-E-V-E-R.
|
||||
successEnding: |
|
||||
**FOREVER.** The lock clicks. The door swings open onto the stairs.
|
||||
|
||||
On the back of the door, carved into the wood: *"Albrecht & Marta, 1901. Forever."*
|
||||
He built the whole attic as a puzzle for his wife. Now it was one for you.
|
||||
|
||||
**You escaped!** 🕰️🔑
|
||||
@@ -0,0 +1,92 @@
|
||||
id: in-sync
|
||||
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
|
||||
difficulty: 1
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#c2185b", accent: "#ffb3c7", background: "#14080d", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Partner A
|
||||
blurb: You'll be in the spotlight in Round 2.
|
||||
B:
|
||||
name: Partner B
|
||||
blurb: You'll be in the spotlight in Round 3.
|
||||
intro: |
|
||||
No clues. No killer. Just the two of you.
|
||||
|
||||
Answer privately. Answers flip over at the same time.
|
||||
**Match on 3 out of 5 to earn a key.** Miss a round and you get one replay with new questions.
|
||||
|
||||
No peeking at each other's screens. We'll know. (We won't. But you will.) 😏
|
||||
|
||||
rules:
|
||||
questionsPerRound: 5
|
||||
pointsForKey: 3
|
||||
replaysPerRound: 1
|
||||
|
||||
rounds:
|
||||
- id: r1
|
||||
title: Same Wavelength
|
||||
type: match
|
||||
blurb: Both pick the answer you think your partner will pick. Same answer = point.
|
||||
keyName: Wavelength Key
|
||||
- id: r2
|
||||
title: All About A
|
||||
type: predict
|
||||
subject: A
|
||||
blurb: Partner A answers honestly about themselves. Partner B guesses.
|
||||
keyName: Spotlight Key A
|
||||
- id: r3
|
||||
title: All About B
|
||||
type: predict
|
||||
subject: B
|
||||
blurb: Partner B answers honestly about themselves. Partner A guesses.
|
||||
keyName: Spotlight Key B
|
||||
|
||||
# MATCH bank: both see the same question and options.
|
||||
match:
|
||||
- { id: m01, q: "Perfect Friday night?", options: [Movie on the couch, Dinner out, Friends over, Early night] }
|
||||
- { id: m02, q: "Our dream holiday?", options: [Beach, Mountains, Big city, Road trip] }
|
||||
- { id: m03, q: "Best pizza topping?", options: [Pepperoni, Mushroom, Pineapple, Just cheese] }
|
||||
- { id: m04, q: "If we got a pet tomorrow?", options: [Dog, Cat, Fish, Absolutely not] }
|
||||
- { id: m05, q: "Who'd survive longer in a zombie movie?", options: [Me, My partner, Neither, We'd stick together] }
|
||||
- { id: m06, q: "Best season?", options: [Summer, Autumn, Winter, Spring] }
|
||||
- { id: m07, q: "Our go-to takeaway?", options: [Burgers, Pizza, Sushi, Curry] }
|
||||
- { id: m08, q: "Best way to make up after a fight?", options: [Talk it out, Hug first, Snacks, Give it an hour] }
|
||||
- { id: m09, q: "Morning person or night owl? (Us as a couple)", options: [Morning, Night, Depends on the coffee, Neither] }
|
||||
- { id: m10, q: "Which superpower would we share?", options: [Teleporting, Mind reading, Time travel, Flying] }
|
||||
- { id: m11, q: "Rainy Sunday plan?", options: [Board games, Series binge, Baking, Sleep in] }
|
||||
- { id: m12, q: "Who says 'I love you' more?", options: [Me, My partner, Even, We say it with snacks] }
|
||||
- { id: m13, q: "Best date we could do this month?", options: [Picnic, Concert, Cooking class, Stargazing] }
|
||||
- { id: m14, q: "Coffee or tea?", options: [Coffee, Tea, Both, Neither] }
|
||||
- { id: m15, q: "Our couple theme song genre?", options: [Hip-hop, Worship, Pop, Old-school love songs] }
|
||||
- { id: m16, q: "Ideal house?", options: [Farmhouse, City apartment, Beach cottage, Cabin in the woods] }
|
||||
|
||||
# PREDICT bank: subject answers about themselves; partner guesses. Text is shown with the
|
||||
# subject's name swapped in: "{subject}" → their role name, "you/your" for the subject's own view.
|
||||
predict:
|
||||
- { id: p01, q: "{subject}'s comfort food?", options: [Mac and cheese, Chocolate, Chips, Something spicy] }
|
||||
- { id: p02, q: "{subject}'s biggest pet peeve?", options: [Loud chewing, Being late, Messy kitchen, Slow walkers] }
|
||||
- { id: p03, q: "{subject}'s ideal birthday?", options: [Big party, Quiet dinner, Surprise trip, Day off doing nothing] }
|
||||
- { id: p04, q: "What would {subject} grab first in a fire (people and pets safe)?", options: [Phone, Photos, Laptop, A sentimental item] }
|
||||
- { id: p05, q: "{subject}'s love language?", options: [Words, Time together, Gifts, Acts of service, Touch] }
|
||||
- { id: p06, q: "{subject}'s secret talent?", options: [Singing, Cooking, Impressions, Dancing] }
|
||||
- { id: p07, q: "{subject} would rather...", options: [Never use social media again, Never eat dessert again] }
|
||||
- { id: p08, q: "{subject}'s scariest thing?", options: [Heights, Spiders, Deep water, Public speaking] }
|
||||
- { id: p09, q: "{subject}'s dream job (if money didn't matter)?", options: [Artist, Chef, Traveller, Teacher] }
|
||||
- { id: p10, q: "{subject}'s go-to karaoke move?", options: [Power ballad, Rap verse, Duet, Refuse entirely] }
|
||||
- { id: p11, q: "How does {subject} recharge?", options: [Alone time, Friends, Outdoors, Sleep] }
|
||||
- { id: p12, q: "{subject}'s favourite way to receive a compliment?", options: [In private, In front of people, In writing, With a hug] }
|
||||
- { id: p13, q: "{subject}'s worst habit?", options: [Phone scrolling, Procrastinating, Snacking late, Overthinking] }
|
||||
- { id: p14, q: "What does {subject} miss most about childhood?", options: [No responsibilities, School friends, Holidays, Mom's cooking] }
|
||||
- { id: p15, q: "{subject}'s perfect lazy breakfast?", options: [Pancakes, Toast and eggs, Cereal, Just coffee] }
|
||||
- { id: p16, q: "If {subject} won the lotto, first buy?", options: [House, Car, Trip, Pay off family's debts] }
|
||||
- { id: p17, q: "{subject} in a group project is...", options: [The leader, The ideas person, The quiet worker, The one who brings snacks] }
|
||||
- { id: p18, q: "{subject}'s favourite memory of us?", options: [Our first date, A trip we took, A random ordinary day, Something only we know] }
|
||||
- { id: p19, q: "When {subject} is stressed, they need...", options: [Space, A hug, A plan, Food] }
|
||||
- { id: p20, q: "{subject}'s favourite movie genre?", options: [Comedy, Action, Romance, Thriller] }
|
||||
- { id: p21, q: "What would {subject} name a pet goldfish?", options: [Something funny, A human name, A food name, A famous person's name] }
|
||||
- { id: p22, q: "{subject}'s most-used emoji?", options: ["😂", "❤️", "🙏", "🔥"] }
|
||||
@@ -0,0 +1,87 @@
|
||||
id: labyrinth-of-lanterns
|
||||
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
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#3d2c6b", accent: "#ffb347", background: "#09070f", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Navigator
|
||||
blurb: You hold the ancient map. You can see every wall, glyph and trap. You can't see your partner.
|
||||
B:
|
||||
name: Explorer
|
||||
blurb: You're inside the labyrinth with one candle. You only see the floor right under your feet.
|
||||
intro: |
|
||||
Beneath an old cathedral lies a labyrinth built for lovers. Legend says only a couple who
|
||||
truly listen to each other can carry the **three lanterns** out.
|
||||
|
||||
**Explorer:** describe every glyph you step on and which ways you can go.
|
||||
**Navigator:** work out where they are, guide them to the lanterns, and steer them **around the traps**.
|
||||
The Explorer can't see the traps. One wrong step and they wake up back at the entrance.
|
||||
|
||||
# Grid legend:
|
||||
# # wall . floor S start
|
||||
# E exit (opens only when all 3 lanterns are carried)
|
||||
# K lantern key (collected on entry)
|
||||
# T trap (invisible to Explorer; on entry → back to S, keys kept, +60s penalty)
|
||||
# Coordinates are (x, y) with (0,0) top-left.
|
||||
grid:
|
||||
- "###########"
|
||||
- "#S..#.....#"
|
||||
- "#.#.#.###.#"
|
||||
- "#.#...#K..#"
|
||||
- "#.#####.#.#"
|
||||
- "#...T...#K#"
|
||||
- "###.###.#.#"
|
||||
- "#K..#.T.#.#"
|
||||
- "#.###.#.#.#"
|
||||
- "#..T....#E#"
|
||||
- "###########"
|
||||
|
||||
# Floor glyphs: Explorer sees the glyph of their current cell; Navigator sees all of them on the map.
|
||||
# Many floor cells are deliberately blank so the Navigator has to track moves, not just glyphs.
|
||||
glyphs:
|
||||
- { x: 1, y: 1, glyph: sun, label: "☀ A carved sun (the entrance)" }
|
||||
- { x: 3, y: 1, glyph: spiral, label: "🌀 A spiral" }
|
||||
- { x: 5, y: 3, glyph: crescent, label: "☾ A crescent moon" }
|
||||
- { x: 9, y: 1, glyph: eye, label: "👁 An open eye" }
|
||||
- { x: 1, y: 5, glyph: feather, label: "🪶 A feather" }
|
||||
- { x: 3, y: 7, glyph: wave, label: "🌊 Three wavy lines" }
|
||||
- { x: 7, y: 5, glyph: crown, label: "👑 A crown" }
|
||||
- { x: 7, y: 9, glyph: star, label: "★ A star" }
|
||||
- { x: 9, y: 7, glyph: rose, label: "🌹 A rose" }
|
||||
- { x: 5, y: 9, glyph: heart, label: "♥ Two hearts, intertwined" }
|
||||
|
||||
lanterns:
|
||||
- { x: 7, y: 3, name: Lantern of Patience }
|
||||
- { x: 9, y: 5, name: Lantern of Trust }
|
||||
- { x: 1, y: 7, name: Lantern of Listening }
|
||||
|
||||
explorerView:
|
||||
# What the Explorer gets each move (server-built, never the grid):
|
||||
# { glyphLabel | null, open: { n, e, s, w }, lanternHere: bool, keysCarried: n, atExit: bool }
|
||||
flavour:
|
||||
blank: "Bare stone. Your candle flickers."
|
||||
trap: "The floor gives way! Darkness... you wake up at the carved sun."
|
||||
lantern: "A lantern glows to life in your hands! ({name})"
|
||||
exitLocked: "A heavy door with three empty lantern hooks. It won't budge."
|
||||
exitOpen: "You hang the three lanterns. The door swings open onto starlight."
|
||||
|
||||
navigatorView:
|
||||
# Navigator sees the full grid, glyphs, lanterns (dimmed once collected), traps, exit.
|
||||
# Navigator NEVER receives the Explorer's coordinates. They do get: keysCarried, and a
|
||||
# toast "Your Explorer fell into a trap and is back at the entrance." when it happens.
|
||||
showMoveCount: true
|
||||
|
||||
scoring:
|
||||
trapPenaltySeconds: 60
|
||||
parMoves: 60
|
||||
|
||||
successEnding: |
|
||||
The Explorer climbs out into the cathedral garden carrying three lanterns.
|
||||
The Navigator is waiting with the map rolled up and a grin.
|
||||
|
||||
**Patience. Trust. Listening.** Turns out that's the whole map. 🏮🔑
|
||||
@@ -0,0 +1,323 @@
|
||||
id: southern-star
|
||||
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
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2e4a3a", accent: "#c9a227", background: "#0d1411", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: The Passenger Detective
|
||||
blurb: You're travelling in carriage 5. You have the crew's notebooks and the passengers' papers.
|
||||
B:
|
||||
name: The Railway Inspector
|
||||
blurb: You work for the line. You have the train plans, the timetable, and the platform reports.
|
||||
intro: |
|
||||
1936. The **Southern Star** sleeper leaves Cape Town for Johannesburg at 18:00.
|
||||
Leather, brass, and a dining car that serves oysters in the middle of the desert.
|
||||
|
||||
At 06:30, the steward knocks on the Gold Suite with **Lord Henry Ashby's** morning tea.
|
||||
No answer. The door is locked.
|
||||
|
||||
Ashby made his fortune in diamonds and his enemies in everything else.
|
||||
The train won't stop until Kimberley. **You have until then.**
|
||||
|
||||
suspects:
|
||||
- id: thomas
|
||||
name: Thomas Webb
|
||||
role: Ashby's valet
|
||||
description: Served him for fifteen years. Knows every habit, every grudge.
|
||||
- id: vera
|
||||
name: Countess Vera Orlova
|
||||
role: Travelling aristocrat
|
||||
description: Glamorous, charming, dripping in diamonds. Dined with Ashby last night.
|
||||
- id: hugo
|
||||
name: Dr Hugo Brandt
|
||||
role: Physician
|
||||
description: Says he's travelling to a medical conference. Asks a lot of questions for a doctor.
|
||||
- id: penny
|
||||
name: Miss Penny Lowe
|
||||
role: Governess
|
||||
description: Travelling alone. Was seen crying in the dining car.
|
||||
- id: abel
|
||||
name: Abel Mokoena
|
||||
role: Conductor
|
||||
description: Twenty years on the line. Holds the master key box.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Gold Suite
|
||||
narrative: |
|
||||
The Gold Suite is locked from the inside. The conductor's master key box has a
|
||||
four-digit lock, and the conductor swears he "always works it out from the plans."
|
||||
clues:
|
||||
A:
|
||||
- id: a1-conductor
|
||||
title: Conductor's notebook (inside cover)
|
||||
type: note
|
||||
body: |
|
||||
*Master box, my little rule so I never write it down:*
|
||||
**carriage number × compartment number. Always four digits, pad with zeros at the front.**
|
||||
B:
|
||||
- id: b1-plan
|
||||
title: Southern Star, first-class plan
|
||||
type: map
|
||||
body: |
|
||||
```
|
||||
CARRIAGE 4 | Comp 10 Orlova | Comp 11 Lowe | Comp 12 (empty)
|
||||
CARRIAGE 5 | Comp 17 Brandt | Comp 18 (you) | Comp 19 Webb
|
||||
CARRIAGE 6 | Comp 23 ★ GOLD SUITE ★ (Lord Ashby) | Comp 24 Linen
|
||||
```
|
||||
lock:
|
||||
id: lock-master
|
||||
prompt: Open the master key box.
|
||||
inputType: code
|
||||
format: 4 digits
|
||||
answers: ["0138"]
|
||||
hints:
|
||||
- You need two numbers from the train plan and one rule from the conductor.
|
||||
- Gold Suite is carriage 6, compartment 23.
|
||||
- 6 × 23 = 138, padded to four digits.
|
||||
successText: |
|
||||
The box opens. The conductor's master key turns the lock.
|
||||
Lord Ashby lies on the floor of the Gold Suite. His gold pocket watch, shattered, lies beside him.
|
||||
keyName: Master Key
|
||||
|
||||
- id: ch2
|
||||
title: The Stopped Watch
|
||||
narrative: |
|
||||
The pocket watch smashed when he fell. Its hands are frozen.
|
||||
An obvious clue. Maybe too obvious.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-watch
|
||||
title: Ashby's broken pocket watch
|
||||
type: photo
|
||||
body: |
|
||||
*A gold hunter watch, glass cracked. The hands are stopped at exactly **01:15**.
|
||||
Engraved inside the lid: "Greenwich, always."*
|
||||
- id: a2-valet
|
||||
title: Statement of Thomas Webb, valet
|
||||
type: transcript
|
||||
body: |
|
||||
"His Lordship never changed that watch from **Greenwich time**. Drove me mad.
|
||||
He said a gentleman doesn't chase the sun. Out here it's always **two hours behind** the station clocks."
|
||||
- id: a2-doctor
|
||||
title: Dr Brandt's examination
|
||||
type: report
|
||||
body: |
|
||||
Single small puncture wound at the base of the skull. Very thin, very deep.
|
||||
Something like a long needle. Death almost instant.
|
||||
B:
|
||||
- id: b2-timetable
|
||||
title: Southern Star timetable (local time)
|
||||
type: table
|
||||
body: |
|
||||
| Station | Arrive | Depart |
|
||||
|------------------|--------|--------|
|
||||
| Cape Town | · | 18:00 |
|
||||
| Worcester | 21:05 | 21:15 |
|
||||
| Touws River | 23:40 | 23:50 |
|
||||
| **Matjiesfontein** | **03:10** | **03:20** |
|
||||
| Laingsburg | 04:05 | 04:10 |
|
||||
| Kimberley | 13:30 | · |
|
||||
- id: b2-steward
|
||||
title: Night steward's report
|
||||
type: report
|
||||
body: |
|
||||
Heard a heavy thump from carriage 6 while we stood at a platform.
|
||||
Didn't think anything of it. Luggage falls all the time.
|
||||
lock:
|
||||
id: lock-tod
|
||||
prompt: What was the real local time of the murder?
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["0315", "315", "315AM"]
|
||||
hints:
|
||||
- The watch shows a time, but not local time.
|
||||
- Greenwich is two hours behind local time here. Add two hours.
|
||||
- 01:15 + 2 hours = 03:15, while the train was stopped at Matjiesfontein.
|
||||
successText: |
|
||||
**03:15 local time.** The train was standing at **Matjiesfontein** platform from 03:10 to 03:20.
|
||||
That's when the thump was heard. Someone used those ten minutes.
|
||||
keyName: Watch Key
|
||||
|
||||
- id: ch3
|
||||
title: The Telegram
|
||||
narrative: |
|
||||
Tucked into Ashby's Bible: a telegram, collected at Worcester. No sender's name.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-telegram
|
||||
title: Telegram, received Worcester 21:10
|
||||
type: letter
|
||||
body: |
|
||||
```
|
||||
HAVE THE DOCUMENTS READY STOP
|
||||
USE THE SLEEPER NOT THE DAY COACH STOP
|
||||
GIVE NOTHING TO THE COUNTESS STOP
|
||||
ONLY CASH NO CHEQUES STOP
|
||||
DESTROY THIS MESSAGE STOP
|
||||
```
|
||||
B:
|
||||
- id: b3-ledger
|
||||
title: Note in Ashby's ledger
|
||||
type: note
|
||||
body: |
|
||||
*My private man never signs a telegram. He hides his name where every line begins.
|
||||
Clever chap. Worth every penny.*
|
||||
lock:
|
||||
id: lock-telegram
|
||||
prompt: Who sent the telegram?
|
||||
inputType: text
|
||||
format: A first name
|
||||
answers: ["HUGO", "DRHUGO", "HUGOBRANDT", "DRHUGOBRANDT", "BRANDT"]
|
||||
hints:
|
||||
- The ledger tells you exactly where to look.
|
||||
- Read the first letter of each line, top to bottom.
|
||||
- H · U · G · O.
|
||||
successText: |
|
||||
**HUGO.** "Dr" Hugo Brandt isn't going to a conference. He's Ashby's **private investigator**.
|
||||
And the telegram warned Ashby about one person by title: **the Countess**.
|
||||
keyName: Telegram Key
|
||||
|
||||
- id: ch4
|
||||
title: The Ten Minutes at Matjiesfontein
|
||||
narrative: |
|
||||
Everyone says they were asleep at 03:15. One of them is lying.
|
||||
clues:
|
||||
A:
|
||||
- id: a4-corridor
|
||||
title: Night steward's corridor watch
|
||||
type: log
|
||||
body: |
|
||||
I sat at the **connecting door between carriages 4, 5 and 6** from 02:00 to 04:00.
|
||||
**Nobody passed through the corridor.** I'd swear it on my mother.
|
||||
- id: a4-luggage
|
||||
title: Passenger luggage declarations
|
||||
type: table
|
||||
body: |
|
||||
| Passenger | Notable items |
|
||||
|----------------|---------------------------------------|
|
||||
| Countess Orlova| Sable **fur coat**, fur hat, jewel case |
|
||||
| Miss Lowe | Wool cardigan, two suitcases |
|
||||
| Dr Brandt | Doctor's bag, camera |
|
||||
| Mr Webb | Valet kit, uniform |
|
||||
B:
|
||||
- id: b4-porter
|
||||
title: Matjiesfontein platform porter's report
|
||||
type: report
|
||||
body: |
|
||||
03:12 · A lady in a **long fur coat** stepped down from **carriage 6** onto the platform.
|
||||
03:18 · Same lady walked quickly along the platform and climbed back aboard at **carriage 4**.
|
||||
She was holding her hat on with one hand. Her hatpin must have come loose.
|
||||
Nobody else left the train.
|
||||
lock:
|
||||
id: lock-liar
|
||||
prompt: Who lied about being asleep?
|
||||
inputType: choice
|
||||
options:
|
||||
- { id: thomas, label: Thomas Webb }
|
||||
- { id: vera, label: Countess Vera Orlova }
|
||||
- { id: hugo, label: Dr Hugo Brandt }
|
||||
- { id: penny, label: Miss Penny Lowe }
|
||||
- { id: abel, label: Abel Mokoena }
|
||||
answers: ["vera"]
|
||||
hints:
|
||||
- If nobody used the corridor, how did someone get from carriage 6 back to carriage 4?
|
||||
- The porter saw a lady in a fur coat. Who packed a fur coat?
|
||||
- Countess Orlova.
|
||||
successText: |
|
||||
**Countess Vera Orlova.** She walked the platform to dodge the steward in the corridor.
|
||||
Carriage 4 is hers. Carriage 6 is the Gold Suite. And her "loose" hatpin is now missing from her hat.
|
||||
keyName: Platform Key
|
||||
|
||||
- id: ch5
|
||||
title: Ashby's Last Note
|
||||
narrative: |
|
||||
In the Gold Suite's writing desk: Ashby's notebook. One line of numbers, underlined twice.
|
||||
clues:
|
||||
A:
|
||||
- id: a5-notebook
|
||||
title: Ashby's notebook, last entry
|
||||
type: note
|
||||
body: |
|
||||
*The "Countess". Her stones are paste. Ask the line, it knows:*
|
||||
|
||||
## V. = 3 · 7 · 8 · 4 · 2 · 8
|
||||
|
||||
*Confirm with Hugo at Kimberley. Then the police.*
|
||||
B:
|
||||
- id: b5-stops
|
||||
title: Karoo branch stopping list (numbered)
|
||||
type: document
|
||||
body: |
|
||||
1. Ashton
|
||||
2. Elandsdrift
|
||||
3. Fraserburg Road
|
||||
4. Grootvlei
|
||||
5. Klaarstroom
|
||||
6. Matjiesfontein
|
||||
7. Ottersrust
|
||||
8. Rietbron
|
||||
9. Touws River
|
||||
lock:
|
||||
id: lock-motive
|
||||
prompt: What did Ashby discover Vera was?
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["FORGER", "AFORGER", "JEWELFORGER"]
|
||||
hints:
|
||||
- '"Ask the line": the numbers point at the railway stops.'
|
||||
- Take the first letter of each numbered stop.
|
||||
- 3=F, 7=O, 8=R, 4=G, 2=E, 8=R.
|
||||
successText: |
|
||||
**FORGER.** Ashby, the diamond king, saw it over dinner: the Countess's diamonds were fakes.
|
||||
And so was the Countess. Vera Orlova had sold forged stones to half of Johannesburg society.
|
||||
keyName: Diamond Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: Kimberley is in sight. Name the killer before the train stops.
|
||||
maxAttempts: 2
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Lord Ashby?
|
||||
options:
|
||||
- { id: thomas, label: Thomas Webb }
|
||||
- { id: vera, label: Countess Vera Orlova }
|
||||
- { id: hugo, label: Dr Hugo Brandt }
|
||||
- { id: penny, label: Miss Penny Lowe }
|
||||
- { id: abel, label: Abel Mokoena }
|
||||
answer: vera
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: hatpin, label: Stabbed at the base of the skull with her hatpin }
|
||||
- { id: poison, label: Poison in his nightcap }
|
||||
- { id: strangled, label: Strangled with a silk scarf }
|
||||
- { id: watch, label: Struck with his own pocket watch }
|
||||
answer: hatpin
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: will, label: She was named in his will }
|
||||
- { id: expose, label: He had found out she was a jewel forger and was going to the police }
|
||||
- { id: love, label: He rejected her advances }
|
||||
- { id: debt, label: She owed him money }
|
||||
answer: expose
|
||||
successEnding: |
|
||||
The Countess laughs, then stops. "He looked at my necklace over the soup and *smiled*.
|
||||
I knew that smile. I've seen it on jewellers."
|
||||
|
||||
At Matjiesfontein she stepped onto the platform, slipped into the Gold Suite from outside,
|
||||
and drove her hatpin home while the train stood still. Ten minutes. Back in her bed by 03:20.
|
||||
|
||||
**Case closed.** The Southern Star rolls into Kimberley on time. 🚂🔑
|
||||
failEnding: |
|
||||
The Countess steps off at Kimberley, smiles at the police, and vanishes into the crowd.
|
||||
reveal: |
|
||||
**Countess Vera Orlova** stabbed Ashby with her **hatpin** during the Matjiesfontein stop,
|
||||
because he'd discovered she was a **jewel forger** and was going to the police.
|
||||
@@ -0,0 +1,196 @@
|
||||
id: station-echo
|
||||
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
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#2b6f8a", accent: "#7fe3ff", background: "#060d12", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: Comms Officer
|
||||
blurb: You're locked in the radio room. Manuals, crew records, the commander's logs.
|
||||
B:
|
||||
name: Engineer
|
||||
blurb: You're in the generator bay. Panels, plaques, sensors, and a blinking warning light.
|
||||
intro: |
|
||||
**Station Echo**, Antarctica. Winter. Six months of night.
|
||||
|
||||
A storm knocked out the main power at 02:14. The rest of the crew evacuated on the last shuttle.
|
||||
You two missed it. Now you're sealed in separate modules, talking over a crackly intercom.
|
||||
|
||||
Emergency air: **45 minutes.** Restore the generator, find the leak, and call home.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Warning Light
|
||||
narrative: |
|
||||
In the generator bay, a red warning lamp is blinking a pattern. Over and over.
|
||||
The station computer is down. That light is trying to tell you something.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-morse
|
||||
title: Radio room laminated card
|
||||
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 `--..` | | | | |
|
||||
|
||||
*Short flash = dot. Long flash = dash. Long dark gap = new letter.*
|
||||
B:
|
||||
- id: b1-lamp
|
||||
title: Generator bay warning lamp
|
||||
type: signal
|
||||
signal: "...-/./-./-"
|
||||
body: |
|
||||
A red lamp, pulsing in short and long flashes, then going dark and starting again.
|
||||
Describe what you see to the Comms Officer.
|
||||
lock:
|
||||
id: lock-lamp
|
||||
prompt: What is the warning light saying?
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["VENT"]
|
||||
hints:
|
||||
- The Engineer should call out "short" or "long" for each flash, and say when there's a gap.
|
||||
- There are four letters. The first is three short flashes and a long one.
|
||||
- V · E · N · T.
|
||||
successText: |
|
||||
**VENT.** The air vents are open to a breached module. Somewhere, your air is leaking out.
|
||||
A panel lights up: *SEAL BREACHED MODULE TO CONTINUE.*
|
||||
keyName: Signal Key
|
||||
|
||||
- id: ch2
|
||||
title: Find the Breach
|
||||
narrative: |
|
||||
Five modules. One of them is bleeding air. Seal the wrong one, and you waste the time you don't have.
|
||||
clues:
|
||||
A:
|
||||
- id: a2-readings
|
||||
title: Live pressure readings (radio room screen)
|
||||
type: table
|
||||
body: |
|
||||
| Module | Code | Pressure now (kPa) |
|
||||
|-----------------|-------|--------------------|
|
||||
| Habitat | HAB-1 | 98.1 |
|
||||
| Laboratory | LAB-2 | 85.0 |
|
||||
| Greenhouse | GRN-3 | 64.0 |
|
||||
| Comms | COM-4 | 70.2 |
|
||||
| Docking bay | DCK-5 | 110.0 |
|
||||
B:
|
||||
- id: b2-baseline
|
||||
title: Engineering plaque, generator bay
|
||||
type: document
|
||||
body: |
|
||||
**NORMAL OPERATING PRESSURE**
|
||||
HAB-1: 101.3 · LAB-2: 101.3 · GRN-3: 95.0 · COM-4: 90.0 · DCK-5: 120.0 (kPa)
|
||||
|
||||
⚠️ **A module is BREACHED when its pressure has dropped by MORE THAN 30% of normal.**
|
||||
lock:
|
||||
id: lock-breach
|
||||
prompt: Which module is breached? (module code)
|
||||
inputType: text
|
||||
format: e.g. ABC-1
|
||||
answers: ["GRN3", "GREENHOUSE", "GRN"]
|
||||
hints:
|
||||
- Work out each module's drop as a percentage of its own normal pressure.
|
||||
- Comms dropped about 22%. Lab dropped about 16%. One drops by over 30%.
|
||||
- "Greenhouse: 95 → 64 is a drop of about 33%."
|
||||
successText: |
|
||||
Blast doors thunder shut on the **Greenhouse**. The hissing stops.
|
||||
The air gauge steadies. Now get the power back on.
|
||||
keyName: Seal Key
|
||||
|
||||
- id: ch3
|
||||
title: Generator Restart
|
||||
narrative: |
|
||||
The generator console wants a restart code. A sticky note is taped to the console.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-roster
|
||||
title: Crew roster (radio room)
|
||||
type: table
|
||||
body: |
|
||||
| Crew member | Status at 02:30 |
|
||||
|---------------|-----------------|
|
||||
| Cmdr Okafor | Evacuated ✈️ |
|
||||
| Dr Lindqvist | Evacuated ✈️ |
|
||||
| Dr Petersen | Aboard |
|
||||
| Sgt Moyo | Aboard |
|
||||
| Dr Chen | Evacuated ✈️ |
|
||||
| Tech Ruiz | Evacuated ✈️ |
|
||||
| **You (Comms)** | Aboard |
|
||||
| **You (Engineer)** | Aboard |
|
||||
| Cook Dlamini | Aboard |
|
||||
B:
|
||||
- id: b3-sticky
|
||||
title: Sticky note on the console
|
||||
type: note
|
||||
body: |
|
||||
*Restart code = our call-sign number × crew still aboard. Don't forget to count yourselves! — Ruiz*
|
||||
- id: b3-callsign
|
||||
title: Station plaque
|
||||
type: photo
|
||||
body: |
|
||||
*A brass plaque by the door: **STATION ECHO · CALL SIGN EC-127**.*
|
||||
lock:
|
||||
id: lock-generator
|
||||
prompt: Enter the generator restart code.
|
||||
inputType: code
|
||||
format: Up to 4 digits
|
||||
answers: ["635"]
|
||||
hints:
|
||||
- You need the call-sign number and the number of people still aboard.
|
||||
- Five people are still aboard, including the two of you.
|
||||
- 127 × 5 = 635.
|
||||
successText: |
|
||||
The generator coughs, then roars. Lights flood every module.
|
||||
The radio room's long-range transmitter warms up. One more message and you're going home.
|
||||
keyName: Power Key
|
||||
|
||||
finale:
|
||||
type: escape
|
||||
prompt: The transmitter is receiving a coded beacon from the rescue plane. Reply with the same word to confirm.
|
||||
clues:
|
||||
A:
|
||||
- id: af-beacon
|
||||
title: Radio room beacon lamp
|
||||
type: signal
|
||||
signal: "..../---/--/."
|
||||
body: |
|
||||
The incoming-signal lamp is flashing a pattern from the rescue plane.
|
||||
Describe it to the Engineer.
|
||||
B:
|
||||
- id: bf-chart
|
||||
title: Morse chart taped inside the generator panel
|
||||
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 `--..` | | | | |
|
||||
lock:
|
||||
id: lock-beacon
|
||||
prompt: Transmit the beacon word.
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["HOME"]
|
||||
hints:
|
||||
- Roles have swapped. This time the Comms Officer reads the light.
|
||||
- Four letters. The first is four short flashes.
|
||||
- H · O · M · E.
|
||||
successEnding: |
|
||||
**HOME.** The radio crackles: *"Echo, we read you. Wheels down in ten. Put the kettle on."*
|
||||
|
||||
Through the frost on the window, landing lights cut through the polar night.
|
||||
|
||||
**You escaped!** 📡🔑
|
||||
@@ -0,0 +1,151 @@
|
||||
id: tick-tock-together
|
||||
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
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#b3261e", accent: "#ffcc00", background: "#0e0e0e", headingFont: "JetBrains Mono" }
|
||||
roles:
|
||||
A:
|
||||
name: Technician
|
||||
blurb: You see the device. You can't see the manual. Describe everything.
|
||||
B:
|
||||
name: Expert
|
||||
blurb: You have the manual. You can't see the device. Ask the right questions.
|
||||
intro: |
|
||||
A cartoonishly suspicious box has been left at your favourite restaurant, ticking.
|
||||
It's a training device (probably). Still, let's not find out.
|
||||
|
||||
**Technician:** describe what you see. **Expert:** find the rule and tell them what to do.
|
||||
Three strikes and it's over. Every strike makes the clock run faster.
|
||||
|
||||
# ---------- Device definition ----------
|
||||
device:
|
||||
serial:
|
||||
length: 6
|
||||
alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # last char is ALWAYS a digit
|
||||
batteries: { min: 0, max: 4 }
|
||||
|
||||
levels:
|
||||
- id: lvl1
|
||||
title: Wire Warm-up
|
||||
timeSeconds: 180
|
||||
modules: [wires]
|
||||
keyName: Red Wire Key
|
||||
- id: lvl2
|
||||
title: Push It
|
||||
timeSeconds: 240
|
||||
modules: [wires, button]
|
||||
keyName: Big Button Key
|
||||
- id: lvl3
|
||||
title: Hearts & Symbols
|
||||
timeSeconds: 300
|
||||
modules: [wires, button, keypad]
|
||||
keyName: Heart Key
|
||||
|
||||
strikes: { max: 3, speedUpPerStrike: 0.25 }
|
||||
|
||||
# ---------- Module generation ----------
|
||||
modules:
|
||||
wires:
|
||||
count: { min: 3, max: 5 }
|
||||
colors: [red, blue, yellow, green, white]
|
||||
# Technician view: wires listed top to bottom, each with colour swatch + colour NAME label.
|
||||
# Action: { type: "cut", index: 1-based }
|
||||
button:
|
||||
labels: [HOLD, PRESS, ABORT, LOVE]
|
||||
colors: [red, blue, white, yellow]
|
||||
stripColors: [blue, white, yellow, red]
|
||||
# Strip colour is generated up front but only shown to the Technician WHILE holding.
|
||||
# Actions: { type: "tap" } or { type: "hold" } then { type: "release", timerText }
|
||||
# Server checks the release against the timer value it computes itself.
|
||||
keypad:
|
||||
symbols:
|
||||
heart: "♥ Heart"
|
||||
star: "★ Star"
|
||||
moon: "☾ Moon"
|
||||
sun: "☀ Sun"
|
||||
club: "♣ Club"
|
||||
flower: "✿ Flower"
|
||||
umbrella: "☂ Umbrella"
|
||||
plane: "✈ Plane"
|
||||
music: "♫ Music note"
|
||||
anchor: "⚓ Anchor"
|
||||
diamond: "♦ Diamond"
|
||||
spade: "♠ Spade"
|
||||
columns:
|
||||
- [heart, star, moon, sun, club, flower]
|
||||
- [umbrella, heart, plane, music, star, anchor]
|
||||
- [diamond, sun, spade, umbrella, flower, music]
|
||||
- [anchor, spade, club, plane, moon, diamond]
|
||||
# Generator: pick a column at random, pick 4 symbols from it, shuffle.
|
||||
# Columns share at most 2 symbols, so every 4-symbol set matches exactly one column.
|
||||
# Action: { type: "press", symbol } one at a time.
|
||||
|
||||
# ---------- The manual (shown ONLY to the Expert) ----------
|
||||
manual: |
|
||||
# Bomb Defusal Manual (Couples' Edition)
|
||||
|
||||
**Ask your Technician for the serial number and battery count first. You'll need both.**
|
||||
|
||||
---
|
||||
|
||||
## Module: Wires
|
||||
Wires are numbered **top to bottom**, starting at 1. Cut exactly one.
|
||||
Check these rules **in order**. Use the **first** one that applies.
|
||||
|
||||
1. If there are **exactly two red** wires → cut the **second red** wire.
|
||||
2. If the serial number's **last digit is even** and there is **any yellow** wire → cut the **first yellow** wire.
|
||||
3. If there are **no blue** wires → cut the **last** wire.
|
||||
4. If there are **exactly five** wires → cut the **fourth** wire.
|
||||
5. Otherwise → cut the **first** wire.
|
||||
|
||||
---
|
||||
|
||||
## Module: The Button
|
||||
Check these rules **in order**. Use the **first** one that applies.
|
||||
|
||||
1. If the button says **LOVE** → **tap** it. (Obviously.)
|
||||
2. If the button is **red** and says **HOLD** → **tap** it.
|
||||
3. If there are **2 or more batteries** and the button says **PRESS** → **tap** it.
|
||||
4. Otherwise → **hold** the button. A coloured strip lights up beside it. Release when the timer shows:
|
||||
- **Blue** strip → a **4** anywhere on the timer
|
||||
- **Yellow** strip → a **5** anywhere on the timer
|
||||
- **Any other colour** → a **1** anywhere on the timer
|
||||
|
||||
---
|
||||
|
||||
## Module: Hearts & Symbols
|
||||
The keypad shows four symbols. Exactly **one** column below contains all four.
|
||||
Press the four symbols in the order they appear **from top to bottom** in that column.
|
||||
|
||||
| Column 1 | Column 2 | Column 3 | Column 4 |
|
||||
|----------|----------|----------|----------|
|
||||
| ♥ Heart | ☂ Umbrella | ♦ Diamond | ⚓ Anchor |
|
||||
| ★ Star | ♥ Heart | ☀ Sun | ♠ Spade |
|
||||
| ☾ Moon | ✈ Plane | ♠ Spade | ♣ Club |
|
||||
| ☀ Sun | ♫ Music note | ☂ Umbrella | ✈ Plane |
|
||||
| ♣ Club | ★ Star | ✿ Flower | ☾ Moon |
|
||||
| ✿ Flower | ⚓ Anchor | ♫ Music note | ♦ Diamond |
|
||||
|
||||
# ---------- Solver fixtures (MUST pass as unit tests) ----------
|
||||
fixtures:
|
||||
wires:
|
||||
- { serial: "TK4L82", batteries: 1, wires: [red, blue, red, white], expect: { type: cut, index: 3 } }
|
||||
- { serial: "QX7M35", batteries: 0, wires: [yellow, blue, white], expect: { type: cut, index: 1 } }
|
||||
- { serial: "ZP2R44", batteries: 2, wires: [green, yellow, white, yellow, red], expect: { type: cut, index: 2 } }
|
||||
- { serial: "AB9C11", batteries: 3, wires: [red, green, white, yellow], expect: { type: cut, index: 4 } }
|
||||
- { serial: "LM3N07", batteries: 1, wires: [blue, green, white, red, yellow], expect: { type: cut, index: 4 } }
|
||||
button:
|
||||
- { batteries: 0, label: LOVE, color: red, strip: blue, expect: { type: tap } }
|
||||
- { batteries: 0, label: HOLD, color: red, strip: white, expect: { type: tap } }
|
||||
- { batteries: 3, label: PRESS, color: blue, strip: yellow, expect: { type: tap } }
|
||||
- { batteries: 1, label: PRESS, color: white, strip: blue, expect: { type: hold, releaseDigit: 4 } }
|
||||
- { batteries: 2, label: ABORT, color: yellow, strip: yellow, expect: { type: hold, releaseDigit: 5 } }
|
||||
- { batteries: 4, label: HOLD, color: blue, strip: red, expect: { type: hold, releaseDigit: 1 } }
|
||||
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] }
|
||||
@@ -0,0 +1,363 @@
|
||||
id: vineyard-hollow
|
||||
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
|
||||
difficulty: 2
|
||||
players: { min: 2, max: 2 }
|
||||
theme: { primary: "#7b1e3a", accent: "#d4a373", background: "#160c10", headingFont: "Playfair Display" }
|
||||
roles:
|
||||
A:
|
||||
name: The Inspector
|
||||
blurb: You hold the police file. Statements, the medical examiner, the lab.
|
||||
B:
|
||||
name: The Estate Insider
|
||||
blurb: You know the estate. Logs, maps, the family's private papers.
|
||||
intro: |
|
||||
Saturday night. The Hale family's annual harvest gala at **Vineyard Hollow**.
|
||||
Candles, jazz on the lawn, two hundred guests in their best.
|
||||
|
||||
At 23:05, Clara Hale goes down to the barrel cellar to find her husband, **Edmund Hale**,
|
||||
who promised to open "something special" for the toast. She finds him slumped against
|
||||
Rack 9, a wine glass shattered beside him.
|
||||
|
||||
The police have locked down the estate. Nobody leaves until the truth comes out.
|
||||
One of you has the official file. The other knows every corner of this estate.
|
||||
**Neither of you can crack this alone.**
|
||||
|
||||
suspects:
|
||||
- id: clara
|
||||
name: Clara Hale
|
||||
role: Wife of the victim
|
||||
description: Hostess of the gala. Married to Edmund for 37 years. Composed. Too composed?
|
||||
- id: marcus
|
||||
name: Marcus Hale
|
||||
role: Son and heir
|
||||
description: Charming, broke, and very interested in his inheritance.
|
||||
- id: nina
|
||||
name: Dr Nina Vos
|
||||
role: Family doctor
|
||||
description: Treats Edmund's heart condition. Carries her medical bag everywhere.
|
||||
- id: pieter
|
||||
name: Pieter Swart
|
||||
role: Head winemaker
|
||||
description: Twenty years at Vineyard Hollow. Made the wines that made the Hale name.
|
||||
- id: isabelle
|
||||
name: Isabelle Rourke
|
||||
role: Wine buyer
|
||||
description: Represents a group that has been trying to buy the estate for two years.
|
||||
|
||||
chapters:
|
||||
- id: ch1
|
||||
title: The Cellar Door
|
||||
narrative: |
|
||||
The security system re-locked the cellar after the police went down.
|
||||
The keypad blinks red, waiting for a four-digit code. The officer who knew it went home.
|
||||
clues:
|
||||
A:
|
||||
- id: a1-butler
|
||||
title: Statement of Hendricks, the butler
|
||||
type: note
|
||||
body: |
|
||||
"Mr Hale changed the cellar code last month. He was very pleased with himself.
|
||||
He said it was *the year it all began*, read the way wine is tasted in his family:
|
||||
**from the finish back to the start.** I'm afraid I never worked it out."
|
||||
B:
|
||||
- id: b1-plaque
|
||||
title: Photo of the main gate
|
||||
type: photo
|
||||
body: |
|
||||
*A brass plaque on a stone gatepost, polished to a shine:*
|
||||
**VINEYARD HOLLOW · EST. 1874 · Stellenbosch Valley**
|
||||
- id: b1-wedding
|
||||
title: Framed photo in the hallway
|
||||
type: photo
|
||||
body: |
|
||||
*Edmund and Clara on their wedding day under an oak tree.
|
||||
Engraved on the frame: "E & C · 1989".*
|
||||
shared:
|
||||
- id: s1-suspects
|
||||
title: Who was at the gala
|
||||
type: document
|
||||
body: |
|
||||
Five people had reason to be near the cellar tonight. See the suspect list.
|
||||
Officers note everyone was in good spirits until around 20:00.
|
||||
lock:
|
||||
id: lock-cellar
|
||||
prompt: Enter the 4-digit cellar code.
|
||||
inputType: code
|
||||
format: 4 digits
|
||||
answers: ["4781"]
|
||||
hints:
|
||||
- The butler's note tells you HOW to build the code. The estate tells you WHAT number to use.
|
||||
- "\"The year it all began\" is the year the estate was founded."
|
||||
- 1874 read backwards is 4781.
|
||||
successText: |
|
||||
The keypad clicks green. Cold air rolls up the stone stairs.
|
||||
The barrel cellar smells of oak, and of something bitter underneath.
|
||||
keyName: Cellar Key
|
||||
|
||||
- id: ch2
|
||||
title: The Hour of Death
|
||||
narrative: |
|
||||
Edmund lies where he fell. The medical examiner has taken her readings.
|
||||
The cellar has a keycard reader that logs every entry and exit.
|
||||
When did Edmund die? And who was down here with him?
|
||||
clues:
|
||||
A:
|
||||
- id: a2-me
|
||||
title: Medical examiner's preliminary note
|
||||
type: report
|
||||
body: |
|
||||
**Subject:** Edmund Hale, 64
|
||||
**Core body temperature at 23:10:** 34.5 °C
|
||||
**Normal living temperature:** 37.0 °C
|
||||
|
||||
The cellar is held at a constant 14 °C. Under these conditions the body
|
||||
will have cooled at approximately **1.0 °C per hour**.
|
||||
Estimated margin: plus or minus 15 minutes.
|
||||
- id: a2-statements
|
||||
title: Suspect statements (summary)
|
||||
type: document
|
||||
body: |
|
||||
- **Clara:** "I was hosting on the lawn all night. I went down at five past eleven and found him."
|
||||
- **Marcus:** "I went down around half nine to talk to Dad. Lights were off. I figured he'd gone up."
|
||||
- **Dr Vos:** "Edmund asked me to check his heart in the cellar. I left him at half past eight, alive and grumbling about the band."
|
||||
- **Pieter Swart:** "I lost my keycard around eight. Someone must have taken it. I was in the tasting room all evening."
|
||||
- **Isabelle Rourke:** "I never went near the cellar. Check your little log."
|
||||
B:
|
||||
- id: b2-log
|
||||
title: Cellar keycard log, Saturday
|
||||
type: log
|
||||
body: |
|
||||
| Time | Card holder | Event |
|
||||
|-------|------------------|-------|
|
||||
| 20:10 | Edmund Hale | IN |
|
||||
| 20:18 | Dr Nina Vos | IN |
|
||||
| 20:31 | Dr Nina Vos | OUT |
|
||||
| 20:35 | Pieter Swart | IN |
|
||||
| 20:52 | Pieter Swart | OUT |
|
||||
| 21:30 | Marcus Hale | IN |
|
||||
| 21:33 | Marcus Hale | OUT |
|
||||
| 23:05 | Clara Hale | IN |
|
||||
| 23:10 | POLICE OVERRIDE | IN |
|
||||
|
||||
*Isabelle Rourke was not issued a cellar card.*
|
||||
lock:
|
||||
id: lock-tod
|
||||
prompt: What was Edmund's time of death? (24-hour time)
|
||||
inputType: code
|
||||
format: "HH:MM"
|
||||
answers: ["2040", "840PM", "840"]
|
||||
hints:
|
||||
- Work out how many degrees Edmund's body lost after death.
|
||||
- He lost 2.5 °C. At 1 °C per hour, that's 2 hours 30 minutes.
|
||||
- 23:10 minus 2h30 = 20:40.
|
||||
successText: |
|
||||
**Time of death: about 20:40 (± 15 minutes).**
|
||||
Check the log. In that window, two cards were in the cellar:
|
||||
**Dr Nina Vos** (until 20:31) and **Pieter Swart** (20:35 to 20:52).
|
||||
Marcus came much later. By 21:30, Edmund had been dead almost an hour.
|
||||
keyName: Pocket Watch Key
|
||||
|
||||
- id: ch3
|
||||
title: The Poisoned Bottle
|
||||
narrative: |
|
||||
No wound. No struggle. The lab rushed the glass by Edmund's hand.
|
||||
Six bottles were opened in the cellar tonight. Only one killed him.
|
||||
clues:
|
||||
A:
|
||||
- id: a3-lab
|
||||
title: Forensic lab, rush result
|
||||
type: report
|
||||
body: |
|
||||
Residue in the victim's glass: **a cardiac glycoside** (a heart toxin).
|
||||
The wine in the glass came from a source bottle that was:
|
||||
- a **red** wine
|
||||
- **decanted** before serving
|
||||
- sealed with a cork whose lot number **ends in 7**
|
||||
B:
|
||||
- id: b3-cellarbook
|
||||
title: Sommelier's cellar book, bottles opened tonight
|
||||
type: table
|
||||
body: |
|
||||
| Wine | Colour | Cork lot | Decanted? |
|
||||
|-----------------------|--------|----------|-----------|
|
||||
| 2015 Chenin Blanc | White | VH-1157 | Yes |
|
||||
| 2004 Pinotage | Red | VH-3302 | Yes |
|
||||
| 2010 Merlot | Red | VH-4417 | No |
|
||||
| 1998 Cabernet Reserve | Red | VH-2207 | Yes |
|
||||
| 2019 Rosé | Pink | VH-5527 | No |
|
||||
| 2012 Shiraz | Red | VH-6608 | Yes |
|
||||
|
||||
*Margin note: "1998 Reserve brought up from Rack 9 at Mr Hale's request."*
|
||||
lock:
|
||||
id: lock-bottle
|
||||
prompt: Which bottle was poisoned? (vintage year or name)
|
||||
inputType: text
|
||||
format: e.g. a year or wine name
|
||||
answers: ["1998", "1998CABERNETRESERVE", "CABERNETRESERVE", "1998RESERVE", "RESERVE", "VH2207", "2207"]
|
||||
hints:
|
||||
- Filter the cellar book using all three lab conditions at once.
|
||||
- Red + decanted leaves Pinotage, Cabernet Reserve and Shiraz. Now check the cork lots.
|
||||
- Only VH-2207 ends in 7, so it's the 1998 Cabernet Reserve.
|
||||
successText: |
|
||||
**The 1998 Cabernet Reserve.** Edmund's most treasured wine, the vintage that won
|
||||
Vineyard Hollow its first gold medal. He kept it in **Rack 9**, his private rack,
|
||||
and only opened it for the people closest to him.
|
||||
Whoever poisoned it knew exactly what he'd pour tonight.
|
||||
keyName: Silver Corkscrew Key
|
||||
|
||||
- id: ch4
|
||||
title: Edmund's Diary
|
||||
narrative: |
|
||||
In Edmund's jacket: a leather pocket diary. The last entry is scrambled.
|
||||
Edmund loved puzzles. His family says he "hid his secrets where he hid his heart."
|
||||
clues:
|
||||
A:
|
||||
- id: a4-diary
|
||||
title: Diary, final entry
|
||||
type: note
|
||||
body: |
|
||||
*Clara mustn't see this yet. Rack-shift, as always.*
|
||||
|
||||
## ORAN BFJAC OARMJH
|
||||
|
||||
*(Below, in ordinary ink:) Tell Rourke the sale is on, once the label audit is done.*
|
||||
- id: a4-alphabet
|
||||
title: Alphabet strip (from the evidence kit)
|
||||
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
|
||||
1 2 3 4 5 6 7 8 9 ... 26
|
||||
```
|
||||
B:
|
||||
- id: b4-map
|
||||
title: Barrel cellar map
|
||||
type: map
|
||||
body: |
|
||||
```
|
||||
[Rack 1] Chenin Blanc [Rack 6] Pinotage
|
||||
[Rack 2] Sauvignon Blanc [Rack 7] Shiraz
|
||||
[Rack 3] Rosé [Rack 8] Export stock
|
||||
[Rack 4] Merlot [Rack 9] 1998 Cabernet Reserve
|
||||
[Rack 5] Blends ♥ E.H. PRIVATE ♥
|
||||
```
|
||||
- id: b4-clara
|
||||
title: Clara's comment to the police
|
||||
type: note
|
||||
body: |
|
||||
"Edmund used to say Rack 9 was where he kept his heart. Sentimental old fool."
|
||||
lock:
|
||||
id: lock-diary
|
||||
prompt: Decode the diary entry.
|
||||
inputType: text
|
||||
format: The decoded message (3 words)
|
||||
answers: ["FIRESWARTFRIDAY"]
|
||||
hints:
|
||||
- Every letter is shifted along the alphabet by the same number. "Rack-shift" tells you which number.
|
||||
- His heart is in Rack 9. Shift every letter BACK by 9.
|
||||
- O→F, R→I, A→R, N→E… The first word is FIRE.
|
||||
successText: |
|
||||
**"FIRE SWART FRIDAY."**
|
||||
Edmund was about to fire his head winemaker of twenty years.
|
||||
And the line underneath, "once the label audit is done," starts to look a lot less innocent.
|
||||
An auditor's letter in the study confirms it: years of cheap blends sold as Reserve.
|
||||
keyName: Diary Key
|
||||
|
||||
- id: ch5
|
||||
title: The Source of the Poison
|
||||
narrative: |
|
||||
Dr Vos carries heart medication in her bag. The obvious answer. Too obvious?
|
||||
The full toxicology report has just come in.
|
||||
clues:
|
||||
A:
|
||||
- id: a5-tox
|
||||
title: Full toxicology addendum
|
||||
type: report
|
||||
body: |
|
||||
The glycoside is **not** pharmaceutical digoxin. No tablet binders, coatings, or fillers.
|
||||
Profile matches a **crude extract made from fresh leaves** of *Digitalis purpurea*,
|
||||
a common garden plant with tall purple bell-shaped flowers.
|
||||
- id: a5-bag
|
||||
title: Dr Vos, medical bag inventory
|
||||
type: table
|
||||
body: |
|
||||
| Item | Count |
|
||||
|--------------------------|-------|
|
||||
| Digoxin 0.25mg tablets | 30 / 30 (seal unbroken) |
|
||||
| Stethoscope | 1 |
|
||||
| BP cuff | 1 |
|
||||
B:
|
||||
- id: b5-groundskeeper
|
||||
title: Groundskeeper's notebook
|
||||
type: note
|
||||
body: |
|
||||
**Thu:** Someone's been cutting leaves in the foxglove bed behind the winery workshop.
|
||||
Only reachable through the workshop. Only key is Mr Swart's.
|
||||
|
||||
**Sat 22:00:** Tidying the workshop. Found a cellar keycard in Mr Swart's apron pocket.
|
||||
Put it on his bench. Thought he said he'd lost it?
|
||||
lock:
|
||||
id: lock-plant
|
||||
prompt: Name the plant the poison came from.
|
||||
inputType: text
|
||||
format: One word
|
||||
answers: ["FOXGLOVE", "DIGITALIS", "DIGITALISPURPUREA", "FOXGLOVES"]
|
||||
hints:
|
||||
- The lab gives the scientific name; the estate gives the common one.
|
||||
- Digitalis purpurea = the plant growing behind the workshop.
|
||||
- Foxglove.
|
||||
successText: |
|
||||
**Foxglove.** Dr Vos's tablets are untouched.
|
||||
The poison came from a bed only reachable through Pieter Swart's workshop.
|
||||
And the keycard he swore he'd lost was in his own apron, an hour after the murder.
|
||||
keyName: Workshop Key
|
||||
|
||||
finale:
|
||||
type: accusation
|
||||
prompt: You have all five keys. Make your accusation together.
|
||||
maxAttempts: 2
|
||||
questions:
|
||||
- id: who
|
||||
label: Who killed Edmund Hale?
|
||||
options:
|
||||
- { id: clara, label: Clara Hale }
|
||||
- { id: marcus, label: Marcus Hale }
|
||||
- { id: nina, label: Dr Nina Vos }
|
||||
- { id: pieter, label: Pieter Swart }
|
||||
- { id: isabelle, label: Isabelle Rourke }
|
||||
answer: pieter
|
||||
- id: how
|
||||
label: How?
|
||||
options:
|
||||
- { id: tablets, label: Crushed digoxin tablets from Dr Vos's bag }
|
||||
- { id: foxglove, label: Foxglove leaf extract in the 1998 Reserve }
|
||||
- { id: chenin, label: Poison in the Chenin Blanc }
|
||||
- { id: blow, label: A blow to the head in the dark }
|
||||
answer: foxglove
|
||||
- id: why
|
||||
label: Why?
|
||||
options:
|
||||
- { id: debts, label: To inherit and clear gambling debts }
|
||||
- { id: affair, label: Jealousy over an affair }
|
||||
- { id: fraud, label: To stop being fired and exposed for label fraud }
|
||||
- { id: sale, label: To sabotage the sale and buy the estate cheap }
|
||||
answer: fraud
|
||||
successEnding: |
|
||||
Pieter Swart sets down his glass and says nothing for a long time.
|
||||
"Twenty years," he finally says. "Every medal on that wall is mine. He was going to
|
||||
throw me out and tell the world I was a fraud."
|
||||
|
||||
He brewed the foxglove in his workshop, slipped into the cellar at 20:35 while Edmund
|
||||
decanted the Reserve for the toast, and claimed his card was "lost" to cover the log.
|
||||
|
||||
**Case closed.** Well played, detectives. 🍷🔑
|
||||
failEnding: |
|
||||
The police make an arrest on your theory. Three weeks later the case collapses
|
||||
and the real killer is long gone.
|
||||
reveal: |
|
||||
**Pieter Swart**, with **foxglove extract in the 1998 Reserve**, to stop Edmund
|
||||
**firing him and exposing years of label fraud**.
|
||||
Generated
+7341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "two-keys",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Co-op mystery games for couples",
|
||||
"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",
|
||||
"start": "npm run validate-content && node server/dist/index.js",
|
||||
"test": "npm -w shared test && npm -w server test",
|
||||
"validate-content": "tsx server/src/scripts/validate-content.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc --noEmit -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --packages=external --alias:@two-keys/shared=../shared/src/index.ts --outfile=dist/index.js",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"express": "^4.21.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.16.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import { Server } from 'socket.io';
|
||||
import { findRoot, gamesDir, loadGames } from './content/loader';
|
||||
import { gameSummary } from './content/sanitize';
|
||||
import { RoomStore } from './rooms/store';
|
||||
import { registerHandlers } from './socket/handlers';
|
||||
|
||||
export interface AppOptions {
|
||||
root?: string;
|
||||
/** Where rooms are snapshotted; null keeps them in memory only. */
|
||||
dataFile?: string | null;
|
||||
}
|
||||
|
||||
export function createApp(opts: AppOptions = {}) {
|
||||
const root = opts.root ?? findRoot();
|
||||
const games = loadGames(gamesDir(root));
|
||||
const dataFile = opts.dataFile === undefined ? path.join(root, 'server', 'data', 'rooms.json') : opts.dataFile;
|
||||
const store = new RoomStore(dataFile);
|
||||
store.load((gameId) => games.has(gameId));
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
const summaries = [...games.values()].map(gameSummary);
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, games: games.size, rooms: store.rooms.size }));
|
||||
app.get('/api/games', (_req, res) => res.json(summaries));
|
||||
|
||||
// In production the same process serves the built client.
|
||||
const clientDist = path.join(root, 'client', 'dist');
|
||||
if (fs.existsSync(path.join(clientDist, 'index.html'))) {
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/socket.io')) return next();
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, { maxHttpBufferSize: 64 * 1024 });
|
||||
const stopTicker = registerHandlers(io, games, store);
|
||||
|
||||
return {
|
||||
app,
|
||||
server,
|
||||
io,
|
||||
games,
|
||||
store,
|
||||
listen(port: number): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, () => {
|
||||
store.start();
|
||||
const address = server.address();
|
||||
resolve(typeof address === 'object' && address ? address.port : port);
|
||||
});
|
||||
});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
stopTicker();
|
||||
store.stop();
|
||||
await io.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { gameSchema, type Game } from './schemas';
|
||||
import { validateGame } from './validate';
|
||||
|
||||
/** The repo root: the nearest ancestor of this file that contains content/games. */
|
||||
export function findRoot(): string {
|
||||
if (process.env.TWO_KEYS_ROOT) return path.resolve(process.env.TWO_KEYS_ROOT);
|
||||
let dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
for (;;) {
|
||||
if (fs.existsSync(path.join(dir, 'content', 'games'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) throw new Error('Could not find content/games above ' + import.meta.url);
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export const gamesDir = (root = findRoot()) => path.join(root, 'content', 'games');
|
||||
|
||||
export interface LoadedFile {
|
||||
file: string;
|
||||
game: Game | null;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function loadGameFile(file: string): LoadedFile {
|
||||
const name = path.basename(file);
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = yaml.load(fs.readFileSync(file, 'utf8'));
|
||||
} catch (err) {
|
||||
return { file: name, game: null, errors: [`YAML parse error: ${(err as Error).message}`] };
|
||||
}
|
||||
const parsed = gameSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
|
||||
return { file: name, game: null, errors };
|
||||
}
|
||||
const game = parsed.data;
|
||||
const errors = validateGame(game);
|
||||
const expectedId = name.replace(/\.ya?ml$/, '');
|
||||
if (game.id !== expectedId) errors.push(`id "${game.id}" does not match filename "${name}"`);
|
||||
return { file: name, game, errors };
|
||||
}
|
||||
|
||||
export function loadAllFiles(dir = gamesDir()): LoadedFile[] {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => /\.ya?ml$/.test(f))
|
||||
.sort()
|
||||
.map((f) => loadGameFile(path.join(dir, f)));
|
||||
}
|
||||
|
||||
/** Loads every game at boot; refuses to start on invalid content. */
|
||||
export function loadGames(dir = gamesDir()): Map<string, Game> {
|
||||
const games = new Map<string, Game>();
|
||||
const problems: string[] = [];
|
||||
for (const { file, game, errors } of loadAllFiles(dir)) {
|
||||
if (errors.length > 0 || !game) problems.push(...errors.map((e) => `${file}: ${e}`));
|
||||
else games.set(game.id, game);
|
||||
}
|
||||
if (problems.length > 0) throw new Error(`Invalid game content:\n ${problems.join('\n ')}`);
|
||||
return games;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Builds everything that leaves the server. Views are assembled field by field (never by
|
||||
// copying a content object and deleting from it), so answers can't leak by accident.
|
||||
import type {
|
||||
ChapterView,
|
||||
ClueView,
|
||||
DefuseModuleView,
|
||||
DefuseView,
|
||||
Dir,
|
||||
EngineView,
|
||||
GameMeta,
|
||||
GameSummary,
|
||||
LabyrinthView,
|
||||
LockView,
|
||||
Role,
|
||||
StoryView,
|
||||
SyncView,
|
||||
} from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { remainingAt, type DefuseState } from '../engines/defuse';
|
||||
import { totalKeys } from '../engines/index';
|
||||
import { DELTAS, cellAt, isWall, type LabyrinthState } from '../engines/labyrinth';
|
||||
import { NUDGE_AFTER_WRONG, type StoryState } from '../engines/story';
|
||||
import { canReplay, type SyncState } from '../engines/sync';
|
||||
import type { Room } from '../rooms/store';
|
||||
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
|
||||
|
||||
export function gameSummary(game: Game): GameSummary {
|
||||
return {
|
||||
id: game.id,
|
||||
engine: game.engine,
|
||||
category: game.category,
|
||||
title: game.title,
|
||||
tagline: game.tagline,
|
||||
estimatedMinutes: game.estimatedMinutes,
|
||||
difficulty: game.difficulty,
|
||||
theme: { ...game.theme },
|
||||
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
|
||||
totalKeys: totalKeys(game),
|
||||
};
|
||||
}
|
||||
|
||||
export function gameMeta(game: Game): GameMeta {
|
||||
return { ...gameSummary(game), intro: game.intro };
|
||||
}
|
||||
|
||||
// ---------- story ----------
|
||||
|
||||
function clueView(clue: Clue, audience: ClueView['audience']): ClueView {
|
||||
const view: ClueView = { id: clue.id, title: clue.title, type: clue.type, body: clue.body, audience };
|
||||
if (clue.type === 'signal' && clue.signal) view.signal = clue.signal;
|
||||
return view;
|
||||
}
|
||||
|
||||
function lockView(lock: Lock, s: StoryState): LockView {
|
||||
const solved = s.solved.includes(lock.id);
|
||||
const wrongAttempts = s.wrong[lock.id] ?? 0;
|
||||
return {
|
||||
id: lock.id,
|
||||
prompt: lock.prompt,
|
||||
inputType: lock.inputType,
|
||||
format: lock.format ?? null,
|
||||
options: lock.options?.map((o) => ({ id: o.id, label: o.label })) ?? null,
|
||||
solved,
|
||||
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
|
||||
hintsTotal: lock.hints.length,
|
||||
wrongAttempts,
|
||||
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
|
||||
successText: solved ? (lock.successText ?? null) : null,
|
||||
keyName: solved ? (lock.keyName ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView {
|
||||
const total = game.chapters.length;
|
||||
const atFinale = s.chapter >= total;
|
||||
const chapters: ChapterView[] = game.chapters.slice(0, Math.min(s.chapter, total - 1) + 1).map((ch) => ({
|
||||
id: ch.id,
|
||||
title: ch.title,
|
||||
narrative: ch.narrative,
|
||||
clues: [
|
||||
...ch.clues[role].map((c) => clueView(c, 'private')),
|
||||
...ch.clues.shared.map((c) => clueView(c, 'shared')),
|
||||
],
|
||||
lock: lockView(ch.lock, s),
|
||||
}));
|
||||
|
||||
let finale: StoryView['finale'] = null;
|
||||
if (atFinale && game.finale.type === 'accusation') {
|
||||
const f = game.finale;
|
||||
finale = {
|
||||
type: 'accusation',
|
||||
prompt: f.prompt,
|
||||
questions: f.questions.map((q) => ({
|
||||
id: q.id,
|
||||
label: q.label,
|
||||
options: q.options.map((o) => ({ id: o.id, label: o.label })),
|
||||
})),
|
||||
proposal: s.finale.proposal
|
||||
? { answers: { ...s.finale.proposal.answers }, by: s.finale.proposal.by }
|
||||
: null,
|
||||
agreed: s.finale.agreed,
|
||||
attemptsLeft: Math.max(0, f.maxAttempts - s.finale.attempts),
|
||||
maxAttempts: f.maxAttempts,
|
||||
};
|
||||
} else if (atFinale && game.finale.type === 'escape') {
|
||||
const f = game.finale;
|
||||
finale = {
|
||||
type: 'escape',
|
||||
prompt: f.prompt,
|
||||
clues: f.clues[role].map((c) => clueView(c, 'private')),
|
||||
lock: lockView(f.lock, s),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
engine: 'story',
|
||||
suspects: (game.suspects ?? []).map((x) => ({ ...x })),
|
||||
chapters,
|
||||
currentChapter: Math.min(s.chapter, total),
|
||||
totalChapters: total,
|
||||
awaitingContinue: s.awaitingContinue,
|
||||
finale,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- defuse ----------
|
||||
|
||||
export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: number): DefuseView {
|
||||
const level = game.levels[s.level];
|
||||
const common = {
|
||||
engine: 'defuse' as const,
|
||||
level: { index: s.level, total: game.levels.length, title: level.title, timeSeconds: level.timeSeconds },
|
||||
status: s.status,
|
||||
failReason: s.failReason,
|
||||
strikes: s.strikes,
|
||||
maxStrikes: game.strikes.max,
|
||||
clock: s.clock
|
||||
? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' }
|
||||
: null,
|
||||
};
|
||||
|
||||
// The Expert gets the manual and nothing about the device.
|
||||
if (role === 'B') return { ...common, seat: 'expert', manual: game.manual };
|
||||
|
||||
// The Technician gets the device and never the manual.
|
||||
const d = s.device;
|
||||
if (!d) return { ...common, seat: 'technician', device: null };
|
||||
const modules: DefuseModuleView[] = [];
|
||||
if (d.wires) modules.push({ id: 'wires', solved: d.wires.solved, wires: [...d.wires.colors] });
|
||||
if (d.button) {
|
||||
modules.push({
|
||||
id: 'button',
|
||||
solved: d.button.solved,
|
||||
label: d.button.label,
|
||||
color: d.button.color,
|
||||
holding: d.button.holding,
|
||||
// The strip only lights up while the button is held.
|
||||
strip: d.button.holding ? d.button.strip : null,
|
||||
});
|
||||
}
|
||||
if (d.keypad) {
|
||||
const { pressed } = d.keypad;
|
||||
modules.push({
|
||||
id: 'keypad',
|
||||
solved: d.keypad.solved,
|
||||
symbols: d.keypad.symbols.map((id) => ({
|
||||
id,
|
||||
label: game.modules.keypad.symbols[id] ?? id,
|
||||
pressed: pressed.includes(id),
|
||||
})),
|
||||
});
|
||||
}
|
||||
return { ...common, seat: 'technician', device: { serial: d.serial, batteries: d.batteries, 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 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;
|
||||
|
||||
return {
|
||||
engine: 'sync',
|
||||
status: s.status,
|
||||
round: {
|
||||
index: s.round,
|
||||
total: game.rounds.length,
|
||||
title: round.title,
|
||||
type: round.type,
|
||||
blurb: round.blurb,
|
||||
subject,
|
||||
},
|
||||
isReplay: s.attempt > 0,
|
||||
questionNumber: s.q + 1,
|
||||
questionsPerRound: s.questions.length,
|
||||
pointsForKey: game.rules.pointsForKey,
|
||||
points: s.marks.filter(Boolean).length,
|
||||
question: q
|
||||
? {
|
||||
id: q.id,
|
||||
text: q.q.replaceAll('{subject}', subject ? game.roles[subject].name : ''),
|
||||
options: [...q.options],
|
||||
}
|
||||
: 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,
|
||||
marks: [...s.marks],
|
||||
summary:
|
||||
s.status === 'summary'
|
||||
? { earned: s.earned, canReplay: canReplay(game, s), keyName: round.keyName }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- labyrinth ----------
|
||||
|
||||
export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role): LabyrinthView {
|
||||
const keysCarried = s.collected.filter(Boolean).length;
|
||||
const total = game.lanterns.length;
|
||||
|
||||
// Navigator: the whole map, but never the Explorer's coordinates.
|
||||
if (role === 'A') {
|
||||
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] })),
|
||||
keysCarried,
|
||||
totalKeys: total,
|
||||
moves: game.navigatorView.showMoveCount ? s.moves : null,
|
||||
falls: s.falls,
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
const glyph = game.glyphs.find((g) => g.x === s.x && g.y === s.y);
|
||||
return {
|
||||
engine: 'labyrinth',
|
||||
seat: 'explorer',
|
||||
glyphLabel: glyph?.label ?? null,
|
||||
open,
|
||||
keysCarried,
|
||||
totalKeys: total,
|
||||
atExit: cellAt(game.grid, s.x, s.y) === 'E',
|
||||
message: s.message || (glyph ? '' : game.explorerView.flavour.blank),
|
||||
moves: s.moves,
|
||||
falls: s.falls,
|
||||
};
|
||||
}
|
||||
|
||||
export function engineView(game: Game, room: Room, role: Role, now: number): EngineView {
|
||||
switch (game.engine) {
|
||||
case 'story':
|
||||
return storyView(game, room.engine as StoryState, role);
|
||||
case 'defuse':
|
||||
return defuseView(game, room.engine as DefuseState, role, now);
|
||||
case 'sync':
|
||||
return syncView(game, room.engine as SyncState, role);
|
||||
case 'labyrinth':
|
||||
return labyrinthView(game, room.engine as LabyrinthState, role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { z } from 'zod';
|
||||
import { CLUE_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() });
|
||||
|
||||
const header = {
|
||||
id: z.string().regex(/^[a-z0-9-]+$/),
|
||||
category: z.enum(['murder-mystery', 'escape-room', 'co-op']),
|
||||
title: z.string().min(1),
|
||||
tagline: z.string().min(1),
|
||||
estimatedMinutes: z.number().positive(),
|
||||
difficulty: z.number().int().min(1).max(3),
|
||||
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 }),
|
||||
intro: z.string().min(1),
|
||||
};
|
||||
|
||||
// ---------- story ----------
|
||||
|
||||
const clueSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
type: z.enum(CLUE_TYPES),
|
||||
body: z.string(),
|
||||
signal: z
|
||||
.string()
|
||||
.regex(/^[.\-/]+$/, 'signal may only contain . - /')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) });
|
||||
|
||||
const lockSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
prompt: z.string().min(1),
|
||||
inputType: z.enum(['code', 'text', 'choice']),
|
||||
format: z.string().optional(),
|
||||
options: z.array(optionSchema).min(2).optional(),
|
||||
answers: z.array(z.string().min(1)).min(1),
|
||||
hints: z.array(z.string().min(1)).length(3),
|
||||
successText: z.string().optional(),
|
||||
keyName: z.string().optional(),
|
||||
});
|
||||
|
||||
const chapterSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
narrative: z.string(),
|
||||
clues: z.object({
|
||||
A: z.array(clueSchema).default([]),
|
||||
B: z.array(clueSchema).default([]),
|
||||
shared: z.array(clueSchema).default([]),
|
||||
}),
|
||||
lock: lockSchema.extend({ successText: z.string().min(1), keyName: z.string().min(1) }),
|
||||
});
|
||||
|
||||
const finaleSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('accusation'),
|
||||
prompt: z.string().min(1),
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
options: z.array(optionSchema).min(2),
|
||||
answer: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
maxAttempts: z.number().int().positive(),
|
||||
successEnding: z.string().min(1),
|
||||
failEnding: z.string().min(1),
|
||||
reveal: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('escape'),
|
||||
prompt: z.string().min(1),
|
||||
clues: z.object({ A: z.array(clueSchema).default([]), B: z.array(clueSchema).default([]) }),
|
||||
lock: lockSchema,
|
||||
successEnding: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
const storySchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('story'),
|
||||
suspects: z
|
||||
.array(z.object({ id: z.string(), name: z.string(), role: z.string(), description: z.string() }))
|
||||
.optional(),
|
||||
chapters: z.array(chapterSchema).min(1),
|
||||
finale: finaleSchema,
|
||||
});
|
||||
|
||||
// ---------- defuse ----------
|
||||
|
||||
const range = z.object({ min: z.number().int().nonnegative(), max: z.number().int().nonnegative() });
|
||||
const moduleId = z.enum(['wires', 'button', 'keypad']);
|
||||
|
||||
const defuseSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('defuse'),
|
||||
device: z.object({
|
||||
serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }),
|
||||
batteries: range,
|
||||
}),
|
||||
levels: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
timeSeconds: z.number().int().positive(),
|
||||
modules: z.array(moduleId).min(1),
|
||||
keyName: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
strikes: z.object({ max: z.number().int().positive(), speedUpPerStrike: z.number().nonnegative() }),
|
||||
modules: z.object({
|
||||
wires: z.object({ count: range, colors: z.array(z.string()).min(2) }),
|
||||
button: z.object({
|
||||
labels: z.array(z.string()).min(1),
|
||||
colors: z.array(z.string()).min(1),
|
||||
stripColors: z.array(z.string()).min(1),
|
||||
}),
|
||||
keypad: z.object({
|
||||
symbols: z.record(z.string()),
|
||||
columns: z.array(z.array(z.string()).min(4)).min(1),
|
||||
}),
|
||||
}),
|
||||
manual: z.string().min(1),
|
||||
fixtures: z.object({
|
||||
wires: z.array(
|
||||
z.object({
|
||||
serial: z.string(),
|
||||
batteries: z.number().int(),
|
||||
wires: z.array(z.string()),
|
||||
expect: z.object({ type: z.literal('cut'), index: z.number().int() }),
|
||||
}),
|
||||
),
|
||||
button: z.array(
|
||||
z.object({
|
||||
batteries: z.number().int(),
|
||||
label: z.string(),
|
||||
color: z.string(),
|
||||
strip: z.string(),
|
||||
expect: z.union([
|
||||
z.object({ type: z.literal('tap') }),
|
||||
z.object({ type: z.literal('hold'), releaseDigit: z.number().int() }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
keypad: z.array(z.object({ symbols: z.array(z.string()), expect: z.array(z.string()) })),
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------- sync ----------
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
q: z.string().min(1),
|
||||
options: z.array(z.coerce.string()).min(2),
|
||||
});
|
||||
|
||||
const syncSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('sync'),
|
||||
rules: z.object({
|
||||
questionsPerRound: z.number().int().positive(),
|
||||
pointsForKey: z.number().int().positive(),
|
||||
replaysPerRound: z.number().int().nonnegative(),
|
||||
}),
|
||||
rounds: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
type: z.enum(['match', 'predict']),
|
||||
subject: z.enum(['A', 'B']).optional(),
|
||||
blurb: z.string(),
|
||||
keyName: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
match: z.array(questionSchema),
|
||||
predict: z.array(questionSchema),
|
||||
});
|
||||
|
||||
// ---------- labyrinth ----------
|
||||
|
||||
const labyrinthSchema = z.object({
|
||||
...header,
|
||||
engine: z.literal('labyrinth'),
|
||||
grid: z.array(z.string().regex(/^[#.SEKT]+$/, 'grid may only contain # . S E K T')).min(3),
|
||||
glyphs: z.array(
|
||||
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) })),
|
||||
explorerView: z.object({
|
||||
flavour: z.object({
|
||||
blank: z.string(),
|
||||
trap: z.string(),
|
||||
lantern: z.string(),
|
||||
exitLocked: z.string(),
|
||||
exitOpen: z.string(),
|
||||
}),
|
||||
}),
|
||||
navigatorView: z.object({ showMoveCount: z.boolean().default(true) }),
|
||||
scoring: z.object({ trapPenaltySeconds: z.number().nonnegative(), parMoves: z.number().int().positive() }),
|
||||
successEnding: z.string().min(1),
|
||||
});
|
||||
|
||||
export const gameSchema = z.discriminatedUnion('engine', [
|
||||
storySchema,
|
||||
defuseSchema,
|
||||
syncSchema,
|
||||
labyrinthSchema,
|
||||
]);
|
||||
|
||||
export type StoryGame = z.infer<typeof storySchema>;
|
||||
export type DefuseGame = z.infer<typeof defuseSchema>;
|
||||
export type SyncGame = z.infer<typeof syncSchema>;
|
||||
export type LabyrinthGame = z.infer<typeof labyrinthSchema>;
|
||||
export type 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>;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { normalizeAnswer } from '@two-keys/shared';
|
||||
import { solveButton, solveKeypad, solveWires } from '../engines/defuse';
|
||||
import { cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
|
||||
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
|
||||
|
||||
export const MIN_SYNC_QUESTIONS = 15;
|
||||
|
||||
/** Checks beyond the zod schema. Returns human-readable problems (empty = valid). */
|
||||
export function validateGame(game: Game): string[] {
|
||||
switch (game.engine) {
|
||||
case 'story':
|
||||
return validateStory(game);
|
||||
case 'defuse':
|
||||
return validateDefuse(game);
|
||||
case 'sync':
|
||||
return validateSync(game);
|
||||
case 'labyrinth':
|
||||
return validateLabyrinth(game);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStory(game: StoryGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
const unique = (kind: string, id: string) => {
|
||||
if (ids.has(`${kind}:${id}`)) errors.push(`duplicate ${kind} id "${id}"`);
|
||||
ids.add(`${kind}:${id}`);
|
||||
};
|
||||
|
||||
const checkClues = (where: string, clues: Clue[]) => {
|
||||
for (const clue of clues) {
|
||||
unique('clue', clue.id);
|
||||
if (clue.type === 'signal' && !clue.signal) errors.push(`${where}: signal clue "${clue.id}" has no signal`);
|
||||
}
|
||||
};
|
||||
|
||||
const checkLock = (where: string, lock: Lock) => {
|
||||
unique('lock', lock.id);
|
||||
if (lock.answers.length < 1) errors.push(`${where}: lock has no answers`);
|
||||
if (lock.hints.length !== 3) errors.push(`${where}: lock needs exactly 3 hints`);
|
||||
if (lock.answers.some((a) => normalizeAnswer(a) === '')) errors.push(`${where}: an answer normalizes to nothing`);
|
||||
if (lock.inputType === 'choice') {
|
||||
const options = (lock.options ?? []).map((o) => normalizeAnswer(o.id));
|
||||
if (options.length === 0) errors.push(`${where}: choice lock has no options`);
|
||||
for (const a of lock.answers) {
|
||||
if (!options.includes(normalizeAnswer(a))) errors.push(`${where}: answer "${a}" is not one of the options`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const ch of game.chapters) {
|
||||
const where = `chapter ${ch.id}`;
|
||||
unique('chapter', ch.id);
|
||||
if (ch.clues.A.length < 1) errors.push(`${where}: needs at least one clue for A`);
|
||||
if (ch.clues.B.length < 1) errors.push(`${where}: needs at least one clue for B`);
|
||||
checkClues(where, [...ch.clues.A, ...ch.clues.B, ...ch.clues.shared]);
|
||||
checkLock(where, ch.lock);
|
||||
}
|
||||
|
||||
if (game.finale.type === 'accusation') {
|
||||
for (const q of game.finale.questions) {
|
||||
if (!q.options.some((o) => o.id === q.answer)) {
|
||||
errors.push(`finale question ${q.id}: answer "${q.answer}" is not one of the options`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (game.finale.clues.A.length < 1) errors.push('finale: needs at least one clue for A');
|
||||
if (game.finale.clues.B.length < 1) errors.push('finale: needs at least one clue for B');
|
||||
checkClues('finale', [...game.finale.clues.A, ...game.finale.clues.B]);
|
||||
checkLock('finale', game.finale.lock);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateDefuse(game: DefuseGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const { fixtures, modules } = game;
|
||||
|
||||
fixtures.wires.forEach((f, i) => {
|
||||
const got = solveWires(f.serial, 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);
|
||||
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) => {
|
||||
const got = solveKeypad(f.symbols, modules.keypad.columns);
|
||||
if (!same(got, f.expect)) errors.push(`keypad fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got?.join(',') ?? 'no unique column'}`);
|
||||
});
|
||||
|
||||
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;
|
||||
columns.forEach((col, i) => {
|
||||
for (const s of col) if (!(s in symbols)) errors.push(`keypad column ${i + 1}: unknown symbol "${s}"`);
|
||||
// Any 4 symbols must identify exactly one column.
|
||||
for (let j = i + 1; j < columns.length; j++) {
|
||||
if (col.filter((s) => columns[j].includes(s)).length > 3) errors.push(`keypad columns ${i + 1} and ${j + 1} share 4+ symbols`);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
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;
|
||||
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`);
|
||||
if (new Set(bank.map((q) => q.id)).size !== bank.length) errors.push(`${type} bank has duplicate question ids`);
|
||||
}
|
||||
for (const r of game.rounds) {
|
||||
if (r.type === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`);
|
||||
}
|
||||
if (game.rules.pointsForKey > game.rules.questionsPerRound) errors.push('pointsForKey is more than questionsPerRound');
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateLabyrinth(game: LabyrinthGame): string[] {
|
||||
const errors: string[] = [];
|
||||
const { grid } = game;
|
||||
if (new Set(grid.map((row) => row.length)).size !== 1) errors.push('grid rows are not all the same length');
|
||||
|
||||
const starts = findCells(grid, 'S');
|
||||
const exits = findCells(grid, 'E');
|
||||
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}`);
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
if (starts.length === 1) {
|
||||
const reach = safeReachable(grid);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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 ----------
|
||||
|
||||
export type WireSolution = { type: 'cut'; index: number };
|
||||
export type ButtonSolution = { type: 'tap' } | { type: 'hold'; releaseDigit: number };
|
||||
|
||||
/** 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');
|
||||
const yellows = indexesOf('yellow');
|
||||
const lastDigit = Number(serial.match(/\d(?=\D*$)/)?.[0] ?? NaN);
|
||||
|
||||
if (reds.length === 2) return { type: 'cut', index: reds[1] };
|
||||
if (lastDigit % 2 === 0 && yellows.length > 0) return { type: 'cut', index: yellows[0] };
|
||||
if (!wires.includes('blue')) return { type: 'cut', index: wires.length };
|
||||
if (wires.length === 5) return { type: 'cut', index: 4 };
|
||||
return { type: 'cut', index: 1 };
|
||||
}
|
||||
|
||||
export function solveButton(b: {
|
||||
batteries: number;
|
||||
label: string;
|
||||
color: string;
|
||||
strip: string;
|
||||
}): ButtonSolution {
|
||||
if (b.label === 'LOVE') return { type: 'tap' };
|
||||
if (b.color === 'red' && b.label === 'HOLD') return { type: 'tap' };
|
||||
if (b.batteries >= 2 && b.label === 'PRESS') return { type: 'tap' };
|
||||
const releaseDigit = b.strip === 'blue' ? 4 : b.strip === 'yellow' ? 5 : 1;
|
||||
return { type: 'hold', releaseDigit };
|
||||
}
|
||||
|
||||
/** 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)));
|
||||
if (matches.length !== 1) return null;
|
||||
return matches[0].filter((s) => symbols.includes(s));
|
||||
}
|
||||
|
||||
// ---------- Generator ----------
|
||||
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DefuseDevice {
|
||||
serial: string;
|
||||
batteries: number;
|
||||
wires?: { colors: string[]; solved: boolean };
|
||||
button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean };
|
||||
keypad?: { symbols: string[]; pressed: string[]; solved: boolean };
|
||||
}
|
||||
|
||||
export function generateDevice(game: DefuseGame, levelIndex: number, seed: number): DefuseDevice {
|
||||
const rng = mulberry32(seed);
|
||||
const int = (min: number, max: number) => min + Math.floor(rng() * (max - min + 1));
|
||||
const pick = <T>(items: readonly T[]): T => items[Math.floor(rng() * items.length)];
|
||||
const shuffle = <T>(items: T[]): T[] => {
|
||||
const out = [...items];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const { serial: serialDef, batteries } = game.device;
|
||||
const alphabet = [...serialDef.alphabet];
|
||||
const digits = alphabet.filter((c) => /\d/.test(c));
|
||||
let serial = '';
|
||||
for (let i = 0; i < serialDef.length - 1; i++) serial += pick(alphabet);
|
||||
serial += pick(digits); // last char is always a digit
|
||||
|
||||
const device: DefuseDevice = { serial, batteries: int(batteries.min, batteries.max) };
|
||||
const mods = game.modules;
|
||||
for (const id of game.levels[levelIndex].modules) {
|
||||
if (id === 'wires') {
|
||||
const count = int(mods.wires.count.min, mods.wires.count.max);
|
||||
device.wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
|
||||
} else if (id === 'button') {
|
||||
device.button = {
|
||||
label: pick(mods.button.labels),
|
||||
color: pick(mods.button.colors),
|
||||
strip: pick(mods.button.stripColors),
|
||||
holding: false,
|
||||
solved: false,
|
||||
};
|
||||
} else {
|
||||
const column = pick(mods.keypad.columns);
|
||||
device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false };
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
/** A generated device must always be solvable by the manual. */
|
||||
export function isSolvable(game: DefuseGame, device: DefuseDevice): boolean {
|
||||
if (device.wires) {
|
||||
const { index } = solveWires(device.serial, device.wires.colors);
|
||||
if (index < 1 || index > device.wires.colors.length) return false;
|
||||
}
|
||||
if (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- Level state ----------
|
||||
|
||||
export interface DefuseState {
|
||||
level: number;
|
||||
status: DefuseStatus;
|
||||
failReason: 'strikes' | 'time' | null;
|
||||
seed: number;
|
||||
device: DefuseDevice | null;
|
||||
strikes: number;
|
||||
/** `remaining` game-seconds as of `anchor`; ticks down at `speed` per real second. */
|
||||
clock: { remaining: number; anchor: number; speed: number } | null;
|
||||
}
|
||||
|
||||
const newSeed = () => Math.floor(Math.random() * 0xffffffff);
|
||||
|
||||
export function initDefuse(): DefuseState {
|
||||
return { level: 0, status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null };
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as DefuseState;
|
||||
|
||||
export function remainingAt(s: DefuseState, now: number): number {
|
||||
if (!s.clock) return 0;
|
||||
if (s.status !== 'active') return s.clock.remaining;
|
||||
return s.clock.remaining - ((now - s.clock.anchor) / 1000) * s.clock.speed;
|
||||
}
|
||||
|
||||
export function timerText(remaining: number): string {
|
||||
const t = Math.max(0, Math.ceil(remaining));
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function freezeClock(s: DefuseState, now: number) {
|
||||
if (s.clock) s.clock = { ...s.clock, remaining: Math.max(0, remainingAt(s, now)), anchor: now };
|
||||
}
|
||||
|
||||
function explode(ctx: Ctx, s: DefuseState, reason: 'strikes' | 'time') {
|
||||
freezeClock(s, ctx.now);
|
||||
s.status = 'exploded';
|
||||
s.failReason = reason;
|
||||
if (s.device?.button) s.device.button.holding = false;
|
||||
}
|
||||
|
||||
function strike(game: DefuseGame, ctx: Ctx, s: DefuseState) {
|
||||
const remaining = remainingAt(s, ctx.now);
|
||||
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.`);
|
||||
}
|
||||
|
||||
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;
|
||||
freezeClock(s, ctx.now);
|
||||
s.status = 'cleared';
|
||||
ctx.addKey(game.levels[s.level].keyName);
|
||||
if (s.level === game.levels.length - 1) {
|
||||
ctx.finish(
|
||||
true,
|
||||
'**Device disarmed.** The ticking stops. Somewhere inside the box, a tiny speaker plays a wedding march.\n\nOne of you had the hands, one of you had the manual. Neither of you could have done it alone. 💣🔑',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the level, retries after an explosion, or moves on after a clear. */
|
||||
export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (s.status === 'ready') {
|
||||
s.device = generateDevice(game, s.level, s.seed);
|
||||
s.strikes = 0;
|
||||
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
|
||||
s.status = 'active';
|
||||
return OK;
|
||||
}
|
||||
if (s.status === 'active') return fail('busy', 'The clock is running!');
|
||||
if (s.status === 'cleared') {
|
||||
if (s.level >= game.levels.length - 1) return fail('done', 'Every level is cleared.');
|
||||
s.level++;
|
||||
}
|
||||
// Retry (same level, no key lost) or next level: fresh seed, back to the briefing.
|
||||
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null });
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function defuseAction(
|
||||
game: DefuseGame,
|
||||
ctx: Ctx,
|
||||
role: Role,
|
||||
moduleId: string,
|
||||
action: DefuseAction,
|
||||
): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (role !== 'A') return fail('wrong_role', 'Only the Technician can touch the device.');
|
||||
if (s.status !== 'active' || !s.device) return fail('not_active', 'The device is not live.');
|
||||
if (remainingAt(s, ctx.now) <= 0) {
|
||||
explode(ctx, s, 'time');
|
||||
return OK;
|
||||
}
|
||||
const d = s.device;
|
||||
|
||||
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;
|
||||
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 });
|
||||
if (action.type === 'tap' && !b.holding) {
|
||||
if (solution.type === 'tap') b.solved = true;
|
||||
else strike(game, ctx, s);
|
||||
} else if (action.type === 'hold' && !b.holding) {
|
||||
if (solution.type === 'hold') b.holding = true;
|
||||
else strike(game, ctx, s);
|
||||
} else if (action.type === 'release' && b.holding && solution.type === 'hold') {
|
||||
// Checked against the server's own clock, with a second of grace for latency.
|
||||
const remaining = remainingAt(s, ctx.now);
|
||||
const digit = String(solution.releaseDigit);
|
||||
const shown = [remaining - 1, remaining, remaining + 1].map(timerText);
|
||||
b.holding = false;
|
||||
if (shown.some((text) => text.includes(digit))) b.solved = true;
|
||||
else strike(game, ctx, s);
|
||||
} else {
|
||||
return fail('bad_action', "That doesn't work on the button right now.");
|
||||
}
|
||||
} 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)) {
|
||||
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 (k.pressed.length === order.length) k.solved = true;
|
||||
} else {
|
||||
strike(game, ctx, s);
|
||||
}
|
||||
} else {
|
||||
return fail('bad_action', "That module isn't on this device.");
|
||||
}
|
||||
|
||||
if (state(ctx).status === 'active') checkCleared(game, ctx, s);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Called on a timer; returns true if the room changed (the clock ran out). */
|
||||
export function tickDefuse(ctx: Ctx): boolean {
|
||||
const s = state(ctx);
|
||||
if (!s || s.status !== 'active' || remainingAt(s, ctx.now) > 0) return false;
|
||||
explode(ctx, s, 'time');
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Game } from '../content/schemas';
|
||||
import { continueDefuse, initDefuse } from './defuse';
|
||||
import { initLabyrinth } from './labyrinth';
|
||||
import { continueChapter, initStory } from './story';
|
||||
import { continueSync, initSync } from './sync';
|
||||
import type { ActionResult, Ctx } from './types';
|
||||
|
||||
export function initEngine(game: Game): unknown {
|
||||
switch (game.engine) {
|
||||
case 'story':
|
||||
return initStory();
|
||||
case 'defuse':
|
||||
return initDefuse();
|
||||
case 'sync':
|
||||
return initSync(game);
|
||||
case 'labyrinth':
|
||||
return initLabyrinth(game);
|
||||
}
|
||||
}
|
||||
|
||||
export function totalKeys(game: Game): number {
|
||||
switch (game.engine) {
|
||||
case 'story':
|
||||
return game.chapters.length;
|
||||
case 'defuse':
|
||||
return game.levels.length;
|
||||
case 'sync':
|
||||
return game.rounds.length;
|
||||
case 'labyrinth':
|
||||
return game.lanterns.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** `chapter:continue` means "move on" in every engine that has stages. */
|
||||
export function continueGame(game: Game, ctx: Ctx): ActionResult {
|
||||
switch (game.engine) {
|
||||
case 'story':
|
||||
return continueChapter(game, ctx);
|
||||
case 'defuse':
|
||||
return continueDefuse(game, ctx);
|
||||
case 'sync':
|
||||
return continueSync(game, ctx);
|
||||
case 'labyrinth':
|
||||
return { ok: false, code: 'no_stages', message: 'Nothing to continue.' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Dir, Role } from '@two-keys/shared';
|
||||
import type { LabyrinthGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
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 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) === '#';
|
||||
|
||||
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 })));
|
||||
return found;
|
||||
}
|
||||
|
||||
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> {
|
||||
const [start] = findCells(grid, 'S');
|
||||
const seen = new Set<string>();
|
||||
if (!start) return seen;
|
||||
const queue: Point[] = [start];
|
||||
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 };
|
||||
const c = cellAt(grid, next.x, next.y);
|
||||
if (c === '#' || c === 'T' || seen.has(key(next))) continue;
|
||||
seen.add(key(next));
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
export const isReachable = (reach: Set<string>, p: Point) => reach.has(key(p));
|
||||
|
||||
export interface LabyrinthState {
|
||||
x: number;
|
||||
y: number;
|
||||
collected: boolean[];
|
||||
moves: number;
|
||||
falls: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
|
||||
const [start] = findCells(game.grid, 'S');
|
||||
return { ...start, collected: game.lanterns.map(() => false), moves: 0, falls: 0, message: '' };
|
||||
}
|
||||
|
||||
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, dir: Dir): ActionResult {
|
||||
const s = ctx.room.engine as LabyrinthState;
|
||||
if (role !== 'B') return fail('wrong_role', 'Only the Explorer can move.');
|
||||
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.");
|
||||
|
||||
const flavour = game.explorerView.flavour;
|
||||
s.moves++;
|
||||
s.x = next.x;
|
||||
s.y = next.y;
|
||||
s.message = '';
|
||||
|
||||
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.falls++;
|
||||
s.message = flavour.trap;
|
||||
ctx.addPenalty(game.scoring.trapPenaltySeconds);
|
||||
ctx.toast('A', 'Your Explorer fell into a trap and is back at the entrance.');
|
||||
} else if (cell === 'K') {
|
||||
const i = game.lanterns.findIndex((l) => l.x === next.x && l.y === next.y);
|
||||
if (i >= 0 && !s.collected[i]) {
|
||||
s.collected[i] = true;
|
||||
ctx.addKey(game.lanterns[i].name);
|
||||
s.message = flavour.lantern.replace('{name}', game.lanterns[i].name);
|
||||
}
|
||||
} else if (cell === 'E') {
|
||||
if (s.collected.every(Boolean)) {
|
||||
s.message = flavour.exitOpen;
|
||||
ctx.finish(true, game.successEnding);
|
||||
} else {
|
||||
s.message = flavour.exitLocked;
|
||||
}
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { matchesAnswer, type Role } from '@two-keys/shared';
|
||||
import type { Lock, StoryGame } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
export const HINT_PENALTY_SECONDS = 120;
|
||||
export const SUBMIT_COOLDOWN_MS = 2000;
|
||||
export const NUDGE_AFTER_WRONG = 5;
|
||||
|
||||
export interface StoryState {
|
||||
/** Index of the open chapter; equals chapters.length once the finale is open. */
|
||||
chapter: number;
|
||||
awaitingContinue: boolean;
|
||||
solved: string[];
|
||||
hints: Record<string, number>;
|
||||
wrong: Record<string, number>;
|
||||
lastSubmit: Record<string, number>;
|
||||
finale: {
|
||||
proposal: { answers: Record<string, string>; by: Role } | null;
|
||||
agreed: boolean;
|
||||
attempts: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function initStory(): StoryState {
|
||||
return {
|
||||
chapter: 0,
|
||||
awaitingContinue: false,
|
||||
solved: [],
|
||||
hints: {},
|
||||
wrong: {},
|
||||
lastSubmit: {},
|
||||
finale: { proposal: null, agreed: false, attempts: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as StoryState;
|
||||
const atFinale = (game: StoryGame, s: StoryState) => s.chapter >= game.chapters.length;
|
||||
|
||||
/** The one lock that can currently be worked on, if any. */
|
||||
export function openLock(game: StoryGame, s: StoryState): { lock: Lock; finale: boolean } | null {
|
||||
if (!atFinale(game, s)) {
|
||||
return s.awaitingContinue ? null : { lock: game.chapters[s.chapter].lock, finale: false };
|
||||
}
|
||||
if (game.finale.type === 'escape' && !s.solved.includes(game.finale.lock.id)) {
|
||||
return { lock: game.finale.lock, finale: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function submitLock(game: StoryGame, ctx: Ctx, lockId: string, value: string): ActionResult {
|
||||
const s = state(ctx);
|
||||
const open = openLock(game, s);
|
||||
if (!open || open.lock.id !== lockId) return fail('lock_closed', "That lock isn't open right now.");
|
||||
|
||||
const last = s.lastSubmit[lockId] ?? 0;
|
||||
if (ctx.now - last < SUBMIT_COOLDOWN_MS) {
|
||||
return fail('rate_limited', 'Easy! One guess every two seconds.');
|
||||
}
|
||||
s.lastSubmit[lockId] = ctx.now;
|
||||
|
||||
if (!matchesAnswer(value, open.lock.answers)) {
|
||||
s.wrong[lockId] = (s.wrong[lockId] ?? 0) + 1;
|
||||
ctx.room.wrongGuesses++;
|
||||
return { ok: true, correct: false };
|
||||
}
|
||||
|
||||
s.solved.push(lockId);
|
||||
if (open.finale) {
|
||||
ctx.finish(true, game.finale.successEnding);
|
||||
} else {
|
||||
ctx.addKey(open.lock.keyName ?? 'Key');
|
||||
s.awaitingContinue = true;
|
||||
}
|
||||
return { ok: true, correct: true };
|
||||
}
|
||||
|
||||
export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionResult {
|
||||
const s = state(ctx);
|
||||
const open = openLock(game, s);
|
||||
if (!open || open.lock.id !== lockId) return fail('lock_closed', "That lock isn't open right now.");
|
||||
const tier = s.hints[lockId] ?? 0;
|
||||
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)`);
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function continueChapter(game: StoryGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (!s.awaitingContinue) return fail('not_ready', 'Solve the lock first.');
|
||||
s.awaitingContinue = false;
|
||||
s.chapter++;
|
||||
return OK;
|
||||
}
|
||||
|
||||
function accusation(game: StoryGame, s: StoryState) {
|
||||
return atFinale(game, s) && game.finale.type === 'accusation' ? game.finale : null;
|
||||
}
|
||||
|
||||
export function proposeAccusation(
|
||||
game: StoryGame,
|
||||
ctx: Ctx,
|
||||
role: Role,
|
||||
answers: Record<string, string>,
|
||||
): ActionResult {
|
||||
const s = state(ctx);
|
||||
const finale = accusation(game, s);
|
||||
if (!finale) return fail('no_finale', "The finale isn't open yet.");
|
||||
const clean: Record<string, string> = {};
|
||||
for (const q of finale.questions) {
|
||||
const pick = answers[q.id];
|
||||
if (!q.options.some((o) => o.id === pick)) return fail('incomplete', 'Answer every question first.');
|
||||
clean[q.id] = pick;
|
||||
}
|
||||
s.finale.proposal = { answers: clean, by: role };
|
||||
s.finale.agreed = false;
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function agreeAccusation(game: StoryGame, ctx: Ctx, role: Role): ActionResult {
|
||||
const s = state(ctx);
|
||||
if (!accusation(game, s) || !s.finale.proposal) return fail('no_proposal', 'Nothing to agree to yet.');
|
||||
if (s.finale.proposal.by === role) return fail('own_proposal', 'Your partner has to agree, not you.');
|
||||
s.finale.agreed = true;
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function submitAccusation(game: StoryGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
const finale = accusation(game, s);
|
||||
if (!finale || !s.finale.proposal) return fail('no_proposal', 'Propose an accusation first.');
|
||||
if (!s.finale.agreed) return fail('not_agreed', 'You both have to agree before submitting.');
|
||||
|
||||
const { answers } = s.finale.proposal;
|
||||
const correct = finale.questions.every((q) => answers[q.id] === q.answer);
|
||||
if (correct) {
|
||||
ctx.finish(true, finale.successEnding);
|
||||
return { ok: true, correct: true };
|
||||
}
|
||||
|
||||
s.finale.attempts++;
|
||||
s.finale.proposal = null;
|
||||
s.finale.agreed = false;
|
||||
ctx.room.wrongGuesses++;
|
||||
const left = finale.maxAttempts - s.finale.attempts;
|
||||
if (left <= 0) {
|
||||
ctx.finish(false, finale.failEnding, finale.reveal);
|
||||
} else {
|
||||
// Deliberately doesn't say which part was wrong.
|
||||
ctx.toast('all', `Not quite. ${left} attempt${left === 1 ? '' : 's'} left.`);
|
||||
}
|
||||
return { ok: true, correct: false };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { SyncGame, SyncQuestion } from '../content/schemas';
|
||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
||||
|
||||
export interface SyncState {
|
||||
round: number;
|
||||
/** 0 on the first try, 1+ on a replay. */
|
||||
attempt: number;
|
||||
status: 'intro' | 'question' | 'reveal' | 'summary';
|
||||
questions: SyncQuestion[];
|
||||
q: number;
|
||||
answers: Record<string, Partial<Record<Role, number>>>;
|
||||
marks: boolean[];
|
||||
earned: boolean;
|
||||
/** Question ids already drawn this session, so nothing repeats. */
|
||||
used: string[];
|
||||
}
|
||||
|
||||
export function initSync(game: SyncGame): SyncState {
|
||||
const s: SyncState = {
|
||||
round: 0,
|
||||
attempt: 0,
|
||||
status: 'intro',
|
||||
questions: [],
|
||||
q: 0,
|
||||
answers: {},
|
||||
marks: [],
|
||||
earned: false,
|
||||
used: [],
|
||||
};
|
||||
deal(game, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
const state = (ctx: Ctx) => ctx.room.engine as SyncState;
|
||||
|
||||
/** 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 hand: SyncQuestion[] = [];
|
||||
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.questions = hand;
|
||||
s.q = 0;
|
||||
s.answers = {};
|
||||
s.marks = [];
|
||||
s.earned = false;
|
||||
}
|
||||
|
||||
export function canReplay(game: SyncGame, s: SyncState): boolean {
|
||||
return !s.earned && s.attempt < game.rules.replaysPerRound;
|
||||
}
|
||||
|
||||
export function answerSync(
|
||||
game: SyncGame,
|
||||
ctx: Ctx,
|
||||
role: Role,
|
||||
questionId: string,
|
||||
option: number,
|
||||
): 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 answers = (s.answers[questionId] ??= {});
|
||||
if (answers[role] !== undefined) return fail('answered', "You've already locked in an answer.");
|
||||
answers[role] = option;
|
||||
|
||||
if (answers.A !== undefined && answers.B !== undefined) {
|
||||
s.marks.push(answers.A === answers.B);
|
||||
s.status = 'reveal';
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
|
||||
const s = state(ctx);
|
||||
const round = game.rounds[s.round];
|
||||
|
||||
if (s.status === 'intro') {
|
||||
s.status = 'question';
|
||||
} else if (s.status === 'question') {
|
||||
return fail('waiting', 'Both of you need to answer first.');
|
||||
} else if (s.status === 'reveal') {
|
||||
if (s.q < s.questions.length - 1) {
|
||||
s.q++;
|
||||
s.status = 'question';
|
||||
} else {
|
||||
s.earned = s.marks.filter(Boolean).length >= game.rules.pointsForKey;
|
||||
if (s.earned) ctx.addKey(round.keyName);
|
||||
s.status = 'summary';
|
||||
}
|
||||
} else if (canReplay(game, s)) {
|
||||
s.attempt++;
|
||||
deal(game, s);
|
||||
s.status = 'question';
|
||||
} else if (s.round < game.rounds.length - 1) {
|
||||
s.round++;
|
||||
s.attempt = 0;
|
||||
deal(game, s);
|
||||
s.status = 'intro';
|
||||
} else {
|
||||
const keys = ctx.room.keys.length;
|
||||
const total = game.rounds.length;
|
||||
const ending =
|
||||
keys === total
|
||||
? `**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`
|
||||
: keys > 0
|
||||
? `**${keys} out of ${total} keys.** In sync where it counts, with a few surprises left to discover. That's the fun part. 💞`
|
||||
: `**No keys this time.** Which just means you have a lot of excellent conversations ahead of you. Rematch? 😏`;
|
||||
ctx.finish(keys > 0, ending);
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { Room } from '../rooms/store';
|
||||
|
||||
export interface Toast {
|
||||
to: Role | 'all';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** What an engine may do to a room while handling an action. */
|
||||
export interface Ctx {
|
||||
room: Room;
|
||||
now: number;
|
||||
toasts: Toast[];
|
||||
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;
|
||||
}
|
||||
|
||||
export function createCtx(room: Room, now = Date.now()): Ctx {
|
||||
const toasts: Toast[] = [];
|
||||
return {
|
||||
room,
|
||||
now,
|
||||
toasts,
|
||||
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) {
|
||||
room.phase = 'finished';
|
||||
room.finishedAt = now;
|
||||
room.result = { success, ending, reveal };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ActionResult =
|
||||
| { ok: true; correct?: boolean }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
export const OK: ActionResult = { ok: true };
|
||||
export const fail = (code: string, message: string): ActionResult => ({ ok: false, code, message });
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from './app';
|
||||
|
||||
const app = createApp();
|
||||
const port = await app.listen(Number(process.env.PORT) || 3001);
|
||||
console.log(`Two Keys server on http://localhost:${port} (${app.games.size} games, ${app.store.rooms.size} rooms restored)`);
|
||||
|
||||
// Snapshot rooms on the way out so a restart doesn't kill a game in progress.
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
app.close().finally(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
/** No 0/O/1/I, so codes survive being read out loud. */
|
||||
export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
export const CODE_LENGTH = 5;
|
||||
|
||||
export function makeCode(isTaken: (code: string) => boolean = () => false): string {
|
||||
for (;;) {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
||||
if (!isTaken(code)) return code;
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanCode(input: string): string {
|
||||
return input.trim().toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/shared';
|
||||
import type { Game } from '../content/schemas';
|
||||
import { engineView, gameMeta } from '../content/sanitize';
|
||||
import { totalKeys } from '../engines/index';
|
||||
import type { PlayerRec, Room } from './store';
|
||||
|
||||
export function computeResult(room: Room, game: Game): GameResult | null {
|
||||
if (room.phase !== 'finished' || !room.result || room.startedAt === null || room.finishedAt === null) {
|
||||
return null;
|
||||
}
|
||||
const elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
|
||||
const finalSeconds = elapsedSeconds + room.penaltySeconds;
|
||||
const keys = totalKeys(game);
|
||||
return {
|
||||
success: room.result.success,
|
||||
elapsedSeconds,
|
||||
penaltySeconds: room.penaltySeconds,
|
||||
finalSeconds,
|
||||
hintsUsed: room.hintsUsed,
|
||||
wrongGuesses: room.wrongGuesses,
|
||||
keysEarned: room.keys.length,
|
||||
totalKeys: keys,
|
||||
rank: rankFor({
|
||||
success: room.result.success,
|
||||
hintsUsed: room.hintsUsed,
|
||||
finalSeconds,
|
||||
estimatedMinutes: game.estimatedMinutes,
|
||||
keysEarned: room.keys.length,
|
||||
totalKeys: keys,
|
||||
}),
|
||||
ending: room.result.ending,
|
||||
reveal: room.result.reveal,
|
||||
};
|
||||
}
|
||||
|
||||
/** The full, sanitized state for one player. This is the only thing sockets ever receive. */
|
||||
export function buildState(
|
||||
room: Room,
|
||||
player: PlayerRec,
|
||||
game: Game,
|
||||
isOnline: (token: string) => boolean,
|
||||
now = Date.now(),
|
||||
): RoomState {
|
||||
const roles: Role[] = player.seat === 'both' ? ['A', 'B'] : player.seat ? [player.seat] : [];
|
||||
const views: RoomState['views'] = {};
|
||||
if (room.phase !== 'lobby' && room.engine) {
|
||||
for (const role of roles) views[role] = engineView(game, room, role, now);
|
||||
}
|
||||
|
||||
const partner = room.players.find((p) => p !== player);
|
||||
return {
|
||||
code: room.code,
|
||||
mode: room.mode,
|
||||
phase: room.phase,
|
||||
game: gameMeta(game),
|
||||
you: { seat: player.seat, ready: player.ready },
|
||||
partner: partner
|
||||
? {
|
||||
online: isOnline(partner.token),
|
||||
seat: partner.seat === 'both' ? null : partner.seat,
|
||||
ready: partner.ready,
|
||||
viewing: partner.viewing,
|
||||
}
|
||||
: null,
|
||||
notepad: room.notepad,
|
||||
keys: [...room.keys],
|
||||
timer: {
|
||||
startedAt: room.startedAt,
|
||||
finishedAt: room.finishedAt,
|
||||
penaltySeconds: room.penaltySeconds,
|
||||
serverNow: now,
|
||||
},
|
||||
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
|
||||
views,
|
||||
result: computeResult(room, game),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Phase, PlayMode, Role, Seat } from '@two-keys/shared';
|
||||
import { makeCode } from './codes';
|
||||
|
||||
export interface PlayerRec {
|
||||
token: string;
|
||||
seat: Seat | null;
|
||||
ready: boolean;
|
||||
viewing: string | null;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
code: string;
|
||||
gameId: string;
|
||||
mode: PlayMode;
|
||||
phase: Phase;
|
||||
players: PlayerRec[];
|
||||
createdAt: number;
|
||||
lastActivity: number;
|
||||
notepad: string;
|
||||
keys: string[];
|
||||
startedAt: number | null;
|
||||
finishedAt: number | null;
|
||||
penaltySeconds: number;
|
||||
hintsUsed: number;
|
||||
wrongGuesses: number;
|
||||
/** Engine-specific state, owned by the game's engine. */
|
||||
engine: unknown;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null } | null;
|
||||
}
|
||||
|
||||
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
export const SNAPSHOT_INTERVAL_MS = 10_000;
|
||||
|
||||
export function seatFor(room: Room, role: Role): PlayerRec | undefined {
|
||||
return room.players.find((p) => p.seat === role || p.seat === 'both');
|
||||
}
|
||||
|
||||
/** In-memory room store, snapshotted to disk so a restart doesn't kill a game in progress. */
|
||||
export class RoomStore {
|
||||
readonly rooms = new Map<string, Room>();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly file: string | null) {}
|
||||
|
||||
create(gameId: string, mode: PlayMode, now = Date.now()): { room: Room; player: PlayerRec } {
|
||||
const player: PlayerRec = {
|
||||
token: randomUUID(),
|
||||
seat: mode === 'couch' ? 'both' : null,
|
||||
ready: false,
|
||||
viewing: null,
|
||||
};
|
||||
const room: Room = {
|
||||
code: makeCode((code) => this.rooms.has(code)),
|
||||
gameId,
|
||||
mode,
|
||||
phase: 'lobby',
|
||||
players: [player],
|
||||
createdAt: now,
|
||||
lastActivity: now,
|
||||
notepad: '',
|
||||
keys: [],
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
penaltySeconds: 0,
|
||||
hintsUsed: 0,
|
||||
wrongGuesses: 0,
|
||||
engine: null,
|
||||
result: null,
|
||||
};
|
||||
this.rooms.set(room.code, room);
|
||||
return { room, player };
|
||||
}
|
||||
|
||||
get(code: string): Room | undefined {
|
||||
return this.rooms.get(code);
|
||||
}
|
||||
|
||||
/** Drop rooms idle for more than 24h. */
|
||||
sweep(now = Date.now()): number {
|
||||
let removed = 0;
|
||||
for (const [code, room] of this.rooms) {
|
||||
if (now - room.lastActivity > ROOM_TTL_MS) {
|
||||
this.rooms.delete(code);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
load(isKnownGame: (gameId: string) => boolean): void {
|
||||
if (!this.file || !fs.existsSync(this.file)) return;
|
||||
try {
|
||||
const rooms = JSON.parse(fs.readFileSync(this.file, 'utf8')) as Room[];
|
||||
for (const room of rooms) {
|
||||
if (room?.code && isKnownGame(room.gameId)) this.rooms.set(room.code, room);
|
||||
}
|
||||
this.sweep();
|
||||
} catch (err) {
|
||||
console.warn(`Could not restore rooms from ${this.file}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(): void {
|
||||
if (!this.file) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
||||
const tmp = `${this.file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify([...this.rooms.values()]));
|
||||
fs.renameSync(tmp, this.file);
|
||||
} catch (err) {
|
||||
console.warn('Room snapshot failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.sweep();
|
||||
this.snapshot();
|
||||
}, SNAPSHOT_INTERVAL_MS);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
this.snapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { loadAllFiles } from '../content/loader';
|
||||
|
||||
const files = loadAllFiles();
|
||||
let failed = 0;
|
||||
|
||||
for (const { file, game, errors } of files) {
|
||||
if (errors.length === 0 && game) {
|
||||
console.log(`✔ ${file} (${game.engine})`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`✖ ${file}`);
|
||||
for (const e of errors) console.error(` ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('No game files found in content/games.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} of ${files.length} game files failed validation.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nAll ${files.length} games are valid.`);
|
||||
@@ -0,0 +1,282 @@
|
||||
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 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 { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
|
||||
import { cleanCode } from '../rooms/codes';
|
||||
import { buildState } from '../rooms/state';
|
||||
import type { PlayerRec, Room, RoomStore } from '../rooms/store';
|
||||
|
||||
const NOTEPAD_MAX = 5000;
|
||||
const role = z.enum(['A', 'B']);
|
||||
const asRole = { asRole: role.optional() };
|
||||
const empty = z.object(asRole).default({});
|
||||
|
||||
const schemas = {
|
||||
create: z.object({ gameId: z.string(), mode: z.enum(['duo', 'couch']).default('duo') }),
|
||||
join: z.object({ code: z.string().min(1).max(12), playerToken: z.string().max(64).nullish() }),
|
||||
pickRole: z.object({ role }),
|
||||
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
|
||||
hint: z.object({ lockId: z.string(), ...asRole }),
|
||||
propose: z.object({ answers: z.record(z.string()), ...asRole }),
|
||||
defuse: z.object({
|
||||
moduleId: z.enum(['wires', 'button', 'keypad']),
|
||||
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() }),
|
||||
]),
|
||||
...asRole,
|
||||
}),
|
||||
sync: z.object({ questionId: z.string(), optionId: z.number().int(), ...asRole }),
|
||||
move: z.object({ dir: z.enum(['n', 'e', 's', 'w']), ...asRole }),
|
||||
notepad: z.object({ text: z.string() }),
|
||||
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
|
||||
view: z.object({ label: z.string().max(80) }),
|
||||
};
|
||||
|
||||
interface ActionArgs<T> {
|
||||
socket: Socket;
|
||||
room: Room;
|
||||
player: PlayerRec;
|
||||
game: Game;
|
||||
data: T;
|
||||
/** The role this action is taken as (couch players say which via `asRole`). */
|
||||
role: Role;
|
||||
ctx: Ctx;
|
||||
}
|
||||
|
||||
export function registerHandlers(io: Server, games: Map<string, Game>, store: RoomStore): () => void {
|
||||
const socketsIn = (code: string): Socket[] =>
|
||||
[...(io.sockets.adapter.rooms.get(code) ?? [])]
|
||||
.map((id) => io.sockets.sockets.get(id))
|
||||
.filter((s): s is Socket => s !== undefined);
|
||||
|
||||
const playerOf = (room: Room, socket: Socket) => room.players.find((p) => p.token === socket.data.token);
|
||||
|
||||
function broadcast(room: Room, except?: Socket) {
|
||||
const game = games.get(room.gameId);
|
||||
if (!game) return;
|
||||
const sockets = socketsIn(room.code);
|
||||
const isOnline = (token: string) => sockets.some((s) => s.data.token === token);
|
||||
const now = Date.now();
|
||||
for (const s of sockets) {
|
||||
const player = playerOf(room, s);
|
||||
if (player && s !== except) s.emit('room:state', buildState(room, player, game, isOnline, now));
|
||||
}
|
||||
}
|
||||
|
||||
function flushToasts(room: Room, ctx: Ctx) {
|
||||
for (const { to, message } of ctx.toasts) {
|
||||
for (const s of socketsIn(room.code)) {
|
||||
const seat = playerOf(room, s)?.seat;
|
||||
if (to === 'all' || seat === 'both' || seat === to) s.emit('toast', { message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendError = (socket: Socket, code: string, message: string) => socket.emit('error', { code, message });
|
||||
|
||||
function maybeStart(room: Room, game: Game) {
|
||||
if (room.phase !== 'lobby') return;
|
||||
const seated = room.mode === 'couch' ? room.players.length === 1 : room.players.length === 2;
|
||||
if (!seated || !room.players.every((p) => p.ready && p.seat)) return;
|
||||
room.phase = 'playing';
|
||||
room.startedAt = Date.now();
|
||||
room.engine = initEngine(game);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
/** Registers a room-scoped event: validate, resolve room + role, run, then broadcast. */
|
||||
function on<S extends z.ZodTypeAny>(
|
||||
event: string,
|
||||
schema: S,
|
||||
phase: Phase | null,
|
||||
handler: (args: ActionArgs<z.infer<S>>) => ActionResult | void,
|
||||
opts: { echo?: boolean } = {},
|
||||
) {
|
||||
socket.on(event, (payload: unknown) => {
|
||||
const parsed = schema.safeParse(payload ?? {});
|
||||
if (!parsed.success) return sendError(socket, 'bad_request', 'That request made no sense.');
|
||||
const room = store.get(socket.data.code);
|
||||
const player = room && playerOf(room, socket);
|
||||
const game = room && games.get(room.gameId);
|
||||
if (!room || !player || !game) return sendError(socket, 'not_in_room', 'Join a room first.');
|
||||
if (phase && room.phase !== phase) return sendError(socket, 'wrong_phase', "You can't do that right now.");
|
||||
|
||||
const data = parsed.data as z.infer<S> & { asRole?: Role };
|
||||
const actingAs: Role = player.seat === 'both' ? (data.asRole ?? 'A') : (player.seat ?? 'A');
|
||||
const ctx = createCtx(room);
|
||||
const result = handler({ socket, room, player, game, data, role: actingAs, ctx });
|
||||
if (result && !result.ok) sendError(socket, result.code, result.message);
|
||||
|
||||
room.lastActivity = ctx.now;
|
||||
flushToasts(room, ctx);
|
||||
broadcast(room, opts.echo === false ? socket : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('room:create', (payload: unknown, ack?: (res: JoinAck) => void) => {
|
||||
const reply = (res: JoinAck) => typeof ack === 'function' && ack(res);
|
||||
const parsed = schemas.create.safeParse(payload);
|
||||
if (!parsed.success || !games.has(parsed.data.gameId)) {
|
||||
return reply({ ok: false, code: 'unknown_game', message: "That game doesn't exist." });
|
||||
}
|
||||
const { room, player } = store.create(parsed.data.gameId, parsed.data.mode);
|
||||
attach(room, player);
|
||||
reply({ ok: true, code: room.code, playerToken: player.token });
|
||||
broadcast(room);
|
||||
});
|
||||
|
||||
socket.on('room:join', (payload: unknown, ack?: (res: JoinAck) => void) => {
|
||||
const reply = (res: JoinAck) => typeof ack === 'function' && ack(res);
|
||||
const parsed = schemas.join.safeParse(payload);
|
||||
const room = parsed.success ? store.get(cleanCode(parsed.data.code)) : undefined;
|
||||
if (!parsed.success || !room) {
|
||||
return reply({ ok: false, code: 'not_found', message: "We couldn't find that room. Check the code?" });
|
||||
}
|
||||
|
||||
// Reconnect: same token (or the same socket asking twice) gets the same seat back.
|
||||
const token = parsed.data.playerToken ?? (socket.data.code === room.code ? socket.data.token : null);
|
||||
let player = room.players.find((p) => p.token === token);
|
||||
if (!player) {
|
||||
const capacity = room.mode === 'couch' ? 1 : 2;
|
||||
if (room.players.length >= capacity) {
|
||||
return reply({ ok: false, code: 'room_full', message: 'This room already has two players.' });
|
||||
}
|
||||
const taken = room.players[0]?.seat;
|
||||
player = {
|
||||
token: randomUUID(),
|
||||
seat: taken === 'A' || taken === 'B' ? otherRole(taken) : null,
|
||||
ready: false,
|
||||
viewing: null,
|
||||
};
|
||||
room.players.push(player);
|
||||
}
|
||||
attach(room, player);
|
||||
room.lastActivity = Date.now();
|
||||
reply({ ok: true, code: room.code, playerToken: player.token });
|
||||
broadcast(room);
|
||||
});
|
||||
|
||||
function attach(room: Room, player: PlayerRec) {
|
||||
if (socket.data.code && socket.data.code !== room.code) socket.leave(socket.data.code);
|
||||
socket.data.code = room.code;
|
||||
socket.data.token = player.token;
|
||||
socket.join(room.code);
|
||||
}
|
||||
|
||||
// ----- lobby -----
|
||||
|
||||
on('room:pickRole', schemas.pickRole, 'lobby', ({ room, player, data }) => {
|
||||
if (room.mode === 'couch') return;
|
||||
// Picking a role hands your partner the other one.
|
||||
for (const p of room.players) {
|
||||
p.seat = p === player ? data.role : otherRole(data.role);
|
||||
p.ready = false;
|
||||
}
|
||||
});
|
||||
|
||||
on('room:ready', empty, 'lobby', ({ room, player, game }) => {
|
||||
if (!player.seat) return fail('no_role', 'Pick a role first.');
|
||||
player.ready = !player.ready;
|
||||
maybeStart(room, game);
|
||||
});
|
||||
|
||||
// ----- story -----
|
||||
|
||||
on('lock:submit', schemas.lockSubmit, 'playing', ({ room, game, data, ctx }) => {
|
||||
if (game.engine !== 'story') return fail('wrong_engine', 'This game has no locks.');
|
||||
const result = submitLock(game, ctx, data.lockId, data.value);
|
||||
if (result.ok) io.to(room.code).emit('lock:result', { lockId: data.lockId, correct: result.correct === true });
|
||||
return result;
|
||||
});
|
||||
|
||||
on('hint:request', schemas.hint, 'playing', ({ game, data, ctx }) =>
|
||||
game.engine === 'story' ? requestHint(game, ctx, data.lockId) : fail('wrong_engine', 'No hints here.'),
|
||||
);
|
||||
|
||||
on('chapter:continue', empty, 'playing', ({ game, ctx }) => continueGame(game, ctx));
|
||||
|
||||
on('finale:propose', schemas.propose, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'story' ? proposeAccusation(game, ctx, role, data.answers) : fail('wrong_engine', 'No finale here.'),
|
||||
);
|
||||
on('finale:agree', empty, 'playing', ({ game, role, ctx }) =>
|
||||
game.engine === 'story' ? agreeAccusation(game, ctx, role) : fail('wrong_engine', 'No finale here.'),
|
||||
);
|
||||
on('finale:submit', empty, 'playing', ({ game, ctx }) =>
|
||||
game.engine === 'story' ? submitAccusation(game, ctx) : fail('wrong_engine', 'No finale here.'),
|
||||
);
|
||||
|
||||
// ----- other engines -----
|
||||
|
||||
on('defuse:action', schemas.defuse, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'defuse'
|
||||
? defuseAction(game, ctx, role, data.moduleId, data.action)
|
||||
: fail('wrong_engine', 'Nothing to defuse.'),
|
||||
);
|
||||
|
||||
on('sync:answer', schemas.sync, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'sync'
|
||||
? answerSync(game, ctx, role, data.questionId, data.optionId)
|
||||
: fail('wrong_engine', 'No questions here.'),
|
||||
);
|
||||
|
||||
on('maze:move', schemas.move, 'playing', ({ game, data, role, ctx }) =>
|
||||
game.engine === 'labyrinth' ? moveExplorer(game, ctx, role, data.dir) : fail('wrong_engine', 'No maze here.'),
|
||||
);
|
||||
|
||||
// ----- presence -----
|
||||
|
||||
on(
|
||||
'notepad:update',
|
||||
schemas.notepad,
|
||||
null,
|
||||
({ room, data }) => {
|
||||
room.notepad = data.text.slice(0, NOTEPAD_MAX); // last write wins
|
||||
},
|
||||
{ echo: false },
|
||||
);
|
||||
|
||||
on('presence:view', schemas.view, null, ({ player, data }) => {
|
||||
player.viewing = data.label;
|
||||
}, { echo: false });
|
||||
|
||||
socket.on('ping', (payload: unknown) => {
|
||||
const parsed = schemas.ping.safeParse(payload);
|
||||
const room = store.get(socket.data.code);
|
||||
const player = room && playerOf(room, socket);
|
||||
if (!parsed.success || !room || !player) return;
|
||||
const from: Role = player.seat === 'both' ? (parsed.data.asRole ?? 'A') : (player.seat ?? 'A');
|
||||
socket.to(room.code).emit('ping', { from, emoji: parsed.data.emoji });
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const room = store.get(socket.data.code);
|
||||
if (room) broadcast(room);
|
||||
});
|
||||
});
|
||||
|
||||
// The defuse clock 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;
|
||||
const ctx = createCtx(room);
|
||||
if (tickDefuse(ctx)) {
|
||||
flushToasts(room, ctx);
|
||||
broadcast(room);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
ticker.unref();
|
||||
return () => clearInterval(ticker);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadAllFiles } from '../src/content/loader';
|
||||
import { gameSchema } from '../src/content/schemas';
|
||||
import { validateGame } from '../src/content/validate';
|
||||
import { CODE_ALPHABET, makeCode } from '../src/rooms/codes';
|
||||
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', () => {
|
||||
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'],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a story chapter with no clue for one player', () => {
|
||||
const game = structuredClone(getGame('clockmakers-attic', 'story'));
|
||||
game.chapters[0].clues.B = [];
|
||||
expect(validateGame(game).join('\n')).toMatch(/at least one clue for B/);
|
||||
});
|
||||
|
||||
it('rejects a finale answer that is not an option, and a lock without 3 hints', () => {
|
||||
const game = structuredClone(getGame('vineyard-hollow', 'story'));
|
||||
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
game.finale.questions[0].answer = 'nobody';
|
||||
expect(validateGame(game).join('\n')).toMatch(/not one of the options/);
|
||||
|
||||
const raw = structuredClone(getGame('vineyard-hollow', 'story')) as any;
|
||||
raw.chapters[0].lock.hints.pop();
|
||||
expect(gameSchema.safeParse(raw).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a labyrinth whose lantern is only reachable through a trap', () => {
|
||||
const game = structuredClone(labyrinth());
|
||||
// Wall off (1,6)... the only trap-free way to the Listening lantern is via (3,6)/(3,7).
|
||||
game.grid[6] = '#######.#.#';
|
||||
expect(validateGame(game).join('\n')).toMatch(/not reachable/);
|
||||
});
|
||||
|
||||
it('rejects a labyrinth with ragged rows or the wrong number of lanterns', () => {
|
||||
const ragged = structuredClone(labyrinth());
|
||||
ragged.grid[1] = ragged.grid[1] + '#';
|
||||
expect(validateGame(ragged).join('\n')).toMatch(/same length/);
|
||||
|
||||
const fewer = structuredClone(labyrinth());
|
||||
fewer.grid[7] = '#...#.T.#.#';
|
||||
expect(validateGame(fewer).join('\n')).toMatch(/exactly 3 K/);
|
||||
});
|
||||
|
||||
it('rejects a sync bank with fewer than 15 questions', () => {
|
||||
const game = structuredClone(sync());
|
||||
game.match = game.match.slice(0, 14);
|
||||
expect(validateGame(game).join('\n')).toMatch(/at least 15/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rooms', () => {
|
||||
it('makes 5-char codes with no confusable characters', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const code = makeCode();
|
||||
expect(code).toMatch(/^[A-Z2-9]{5}$/);
|
||||
expect(code).not.toMatch(/[0O1I]/);
|
||||
}
|
||||
expect(CODE_ALPHABET).not.toMatch(/[0O1I]/);
|
||||
});
|
||||
|
||||
it('expires rooms after 24h idle', () => {
|
||||
const store = new RoomStore(null);
|
||||
const { room } = store.create('in-sync', 'duo', 0);
|
||||
expect(store.sweep(ROOM_TTL_MS - 1)).toBe(0);
|
||||
expect(store.sweep(ROOM_TTL_MS + 1)).toBe(1);
|
||||
expect(store.get(room.code)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// End-to-end over real sockets: two clients complete The Clockmaker's Attic.
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { io as connect, type Socket } from 'socket.io-client';
|
||||
import type { JoinAck, RoomState, StoryView } from '@two-keys/shared';
|
||||
import { createApp } from '../src/app';
|
||||
|
||||
const app = createApp({ dataFile: null });
|
||||
let url = '';
|
||||
const sockets: Socket[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
url = `http://localhost:${await app.listen(0)}`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
sockets.forEach((s) => s.close());
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function client() {
|
||||
const socket = connect(url, { transports: ['websocket'], forceNew: true });
|
||||
sockets.push(socket);
|
||||
const seen: RoomState[] = [];
|
||||
let latest: RoomState | undefined;
|
||||
let wake = () => {};
|
||||
socket.on('room:state', (state: RoomState) => {
|
||||
latest = state;
|
||||
seen.push(state);
|
||||
wake();
|
||||
});
|
||||
return {
|
||||
socket,
|
||||
seen,
|
||||
get state() {
|
||||
return latest!;
|
||||
},
|
||||
ack: (event: string, payload: object) =>
|
||||
new Promise<JoinAck>((resolve) => socket.emit(event, payload, resolve)),
|
||||
/** Resolves once a received state satisfies the predicate. */
|
||||
async until(predicate: (s: RoomState) => boolean): Promise<RoomState> {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!(latest && predicate(latest))) {
|
||||
if (Date.now() > deadline) throw new Error('timed out waiting for room state');
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
}
|
||||
return latest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('two players over sockets', () => {
|
||||
it('create, join, pick roles, play Clockmaker\'s Attic to the end, and reconnect', async () => {
|
||||
const game = app.games.get('clockmakers-attic')!;
|
||||
if (game.engine !== 'story' || game.finale.type !== 'escape') throw new Error('unexpected game shape');
|
||||
|
||||
const a = client();
|
||||
const b = client();
|
||||
|
||||
const created = await a.ack('room:create', { gameId: game.id });
|
||||
if (!created.ok) throw new Error(created.message);
|
||||
expect(created.code).toMatch(/^[A-Z2-9]{5}$/);
|
||||
|
||||
a.socket.emit('room:pickRole', { role: 'A' });
|
||||
const joined = await b.ack('room:join', { code: created.code.toLowerCase() });
|
||||
if (!joined.ok) throw new Error(joined.message);
|
||||
await b.until((s) => s.you.seat === 'B'); // partner gets the other role
|
||||
|
||||
// A third player can't get in.
|
||||
const c = client();
|
||||
expect(await c.ack('room:join', { code: created.code })).toMatchObject({ ok: false, code: 'room_full' });
|
||||
expect(await c.ack('room:join', { code: 'ZZZZZ' })).toMatchObject({ ok: false, code: 'not_found' });
|
||||
|
||||
a.socket.emit('room:ready');
|
||||
b.socket.emit('room:ready');
|
||||
await a.until((s) => s.phase === 'playing');
|
||||
await b.until((s) => s.phase === 'playing');
|
||||
|
||||
for (const [i, chapter] of game.chapters.entries()) {
|
||||
// Either player can submit; alternate.
|
||||
const [me, partner] = i % 2 ? [b, a] : [a, b];
|
||||
me.socket.emit('lock:submit', { lockId: chapter.lock.id, value: chapter.lock.answers[0] });
|
||||
await partner.until((s) => s.keys.length === i + 1);
|
||||
partner.socket.emit('chapter:continue');
|
||||
await me.until((s) => (s.views[s.you.seat as 'A' | 'B'] as StoryView).currentChapter === i + 1);
|
||||
}
|
||||
|
||||
// Refresh mid-game: a new socket with the same token gets the same seat and progress.
|
||||
b.socket.close();
|
||||
await a.until((s) => s.partner?.online === false);
|
||||
const b2 = client();
|
||||
expect(await b2.ack('room:join', { code: created.code, playerToken: joined.playerToken })).toMatchObject({ ok: true });
|
||||
const restored = await b2.until((s) => s.phase === 'playing');
|
||||
expect(restored.you.seat).toBe('B');
|
||||
expect(restored.keys).toHaveLength(game.chapters.length);
|
||||
expect((restored.views.B as StoryView).finale?.type).toBe('escape');
|
||||
|
||||
b2.socket.emit('lock:submit', { lockId: game.finale.lock.id, value: 'forever' });
|
||||
const done = await a.until((s) => s.phase === 'finished');
|
||||
expect(done.result).toMatchObject({ success: true, keysEarned: 3, totalKeys: 3, hintsUsed: 0 });
|
||||
expect(done.result?.ending).toContain('FOREVER');
|
||||
|
||||
// Nothing A ever received contained B's clues, or any answers.
|
||||
const everything = JSON.stringify(a.seen);
|
||||
for (const chapter of game.chapters) {
|
||||
for (const clue of chapter.clues.B) expect(everything).not.toContain(JSON.stringify(clue.title));
|
||||
}
|
||||
expect(everything).not.toContain('"answers"');
|
||||
expect(everything).not.toContain(JSON.stringify(game.finale.clues.B[0].title));
|
||||
});
|
||||
|
||||
it('couch mode: one device holds both roles and sees both views', async () => {
|
||||
const solo = client();
|
||||
const created = await solo.ack('room:create', { gameId: 'labyrinth-of-lanterns', mode: 'couch' });
|
||||
if (!created.ok) throw new Error(created.message);
|
||||
solo.socket.emit('room:ready');
|
||||
const state = await solo.until((s) => s.phase === 'playing');
|
||||
expect(state.you.seat).toBe('both');
|
||||
expect(Object.keys(state.views).sort()).toEqual(['A', 'B']);
|
||||
|
||||
solo.socket.emit('maze:move', { dir: 'e', asRole: 'B' });
|
||||
await solo.until((s) => (s.views.B as { moves: number }).moves === 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Dir } from '@two-keys/shared';
|
||||
import {
|
||||
continueDefuse,
|
||||
defuseAction,
|
||||
generateDevice,
|
||||
isSolvable,
|
||||
remainingAt,
|
||||
solveButton,
|
||||
solveKeypad,
|
||||
solveWires,
|
||||
tickDefuse,
|
||||
timerText,
|
||||
type DefuseState,
|
||||
} from '../src/engines/defuse';
|
||||
import { DELTAS, cellAt, findCells, moveExplorer, type LabyrinthState } from '../src/engines/labyrinth';
|
||||
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
|
||||
import { clock, defuse, labyrinth, playingRoom, sync } from './helpers';
|
||||
|
||||
describe('defuse solver', () => {
|
||||
const game = defuse();
|
||||
|
||||
it.each(game.fixtures.wires)('wires fixture %#', (f) => {
|
||||
expect(solveWires(f.serial, f.wires)).toEqual(f.expect);
|
||||
});
|
||||
it.each(game.fixtures.button)('button fixture %#', (f) => {
|
||||
expect(solveButton(f)).toEqual(f.expect);
|
||||
});
|
||||
it.each(game.fixtures.keypad)('keypad fixture %#', (f) => {
|
||||
expect(solveKeypad(f.symbols, game.modules.keypad.columns)).toEqual(f.expect);
|
||||
});
|
||||
|
||||
it('never generates a device the solver cannot solve', () => {
|
||||
for (let seed = 1; seed <= 2000; seed++) {
|
||||
const device = generateDevice(game, 2, seed);
|
||||
expect(isSolvable(game, device), `seed ${seed}`).toBe(true);
|
||||
expect(device.serial).toMatch(/^[A-Z0-9]{5}\d$/);
|
||||
expect(device.wires!.colors.length).toBeGreaterThanOrEqual(3);
|
||||
expect(device.wires!.colors.length).toBeLessThanOrEqual(5);
|
||||
expect(device.keypad!.symbols).toHaveLength(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic per seed and only builds the modules of the level', () => {
|
||||
expect(generateDevice(game, 2, 42)).toEqual(generateDevice(game, 2, 42));
|
||||
const lvl1 = generateDevice(game, 0, 42);
|
||||
expect(lvl1.wires).toBeDefined();
|
||||
expect(lvl1.button).toBeUndefined();
|
||||
expect(lvl1.keypad).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats the timer like the device shows it', () => {
|
||||
expect(timerText(180)).toBe('3:00');
|
||||
expect(timerText(64.2)).toBe('1:05');
|
||||
expect(timerText(-3)).toBe('0:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defuse level flow', () => {
|
||||
const game = defuse();
|
||||
|
||||
/** Solves whatever is on the device, using only the solver. */
|
||||
function solveAll(room: ReturnType<typeof playingRoom>, t: ReturnType<typeof clock>) {
|
||||
const s = room.engine as DefuseState;
|
||||
const d = s.device!;
|
||||
if (d.wires) defuseAction(game, t.ctx(100), 'A', 'wires', solveWires(d.serial, d.wires.colors));
|
||||
if (d.keypad) {
|
||||
for (const symbol of solveKeypad(d.keypad.symbols, game.modules.keypad.columns)!) {
|
||||
defuseAction(game, t.ctx(100), 'A', 'keypad', { type: 'press', symbol });
|
||||
}
|
||||
}
|
||||
if (d.button) {
|
||||
const solution = solveButton({ ...d.button, batteries: d.batteries });
|
||||
if (solution.type === 'tap') {
|
||||
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'tap' });
|
||||
} else {
|
||||
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' });
|
||||
// Wait until the server's own timer shows the digit, then let go.
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break;
|
||||
t.ctx(500);
|
||||
}
|
||||
defuseAction(game, t.ctx(0), 'A', 'button', { type: 'release' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('clears all three 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); // start
|
||||
solveAll(room, t);
|
||||
const s = room.engine as DefuseState;
|
||||
expect(s.strikes, `level ${level}`).toBe(0);
|
||||
expect(s.status).toBe('cleared');
|
||||
if (level < game.levels.length - 1) continueDefuse(game, t.ctx());
|
||||
}
|
||||
expect(room.keys).toEqual(game.levels.map((l) => l.keyName));
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('strikes speed the clock up by 25% and three strikes blow the level without losing keys', () => {
|
||||
const room = playingRoom(game);
|
||||
room.keys.push('Earlier Key');
|
||||
const t = clock(room);
|
||||
continueDefuse(game, t.ctx());
|
||||
const s = room.engine as DefuseState;
|
||||
const right = solveWires(s.device!.serial, s.device!.wires!.colors).index;
|
||||
const wrong = right === 1 ? 2 : 1;
|
||||
|
||||
defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong });
|
||||
expect(s.strikes).toBe(1);
|
||||
expect(s.clock!.speed).toBeCloseTo(1.25);
|
||||
const before = remainingAt(s, t.now);
|
||||
expect(before - remainingAt(s, t.now + 4000)).toBeCloseTo(5);
|
||||
|
||||
expect(defuseAction(game, t.ctx(), 'B', 'wires', { type: 'cut', index: right }).ok).toBe(false); // Expert can't cut
|
||||
defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong });
|
||||
defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong });
|
||||
expect(s.status).toBe('exploded');
|
||||
expect(s.failReason).toBe('strikes');
|
||||
expect(room.keys).toEqual(['Earlier Key']);
|
||||
|
||||
const oldSeed = s.seed;
|
||||
continueDefuse(game, t.ctx()); // retry → briefing with a new seed
|
||||
expect(s).toMatchObject({ status: 'ready', level: 0, strikes: 0, device: null });
|
||||
expect(s.seed).not.toBe(oldSeed);
|
||||
});
|
||||
|
||||
it('explodes when the clock runs out', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueDefuse(game, t.ctx());
|
||||
expect(tickDefuse(t.ctx(10_000))).toBe(false);
|
||||
expect(tickDefuse(t.ctx(game.levels[0].timeSeconds * 1000))).toBe(true);
|
||||
expect(room.engine).toMatchObject({ status: 'exploded', failReason: 'time' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync', () => {
|
||||
const game = sync();
|
||||
|
||||
it('awards a key for 3+ matches, offers one replay otherwise, and never repeats a question', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as SyncState;
|
||||
const seen: string[] = [];
|
||||
|
||||
const playRound = (matches: number) => {
|
||||
for (let i = 0; i < game.rules.questionsPerRound; i++) {
|
||||
const q = s.questions[s.q];
|
||||
seen.push(q.id);
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 0).ok).toBe(true);
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 1).ok).toBe(false); // locked in
|
||||
expect(continueSync(game, t.ctx()).ok).toBe(false); // partner still thinking
|
||||
expect(answerSync(game, t.ctx(), 'B', q.id, i < matches ? 0 : 1).ok).toBe(true);
|
||||
expect(s.status).toBe('reveal');
|
||||
continueSync(game, t.ctx());
|
||||
}
|
||||
expect(s.status).toBe('summary');
|
||||
};
|
||||
|
||||
continueSync(game, t.ctx()); // intro → first question
|
||||
playRound(2); // fail
|
||||
expect(s.earned).toBe(false);
|
||||
continueSync(game, t.ctx()); // replay with fresh questions
|
||||
expect(s).toMatchObject({ round: 0, attempt: 1, status: 'question' });
|
||||
playRound(1); // fail again: no more replays
|
||||
continueSync(game, t.ctx());
|
||||
expect(s).toMatchObject({ round: 1, attempt: 0, status: 'intro' });
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
playRound(3);
|
||||
expect(room.keys).toEqual([game.rounds[1].keyName]);
|
||||
continueSync(game, t.ctx()); // earned → straight to round 3
|
||||
expect(s.round).toBe(2);
|
||||
|
||||
continueSync(game, t.ctx());
|
||||
playRound(5);
|
||||
continueSync(game, t.ctx());
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.keys).toHaveLength(2);
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labyrinth', () => {
|
||||
const game = labyrinth();
|
||||
const walk = (room: ReturnType<typeof playingRoom>, t: ReturnType<typeof clock>, path: string) =>
|
||||
[...path].map((dir) => moveExplorer(game, t.ctx(), 'B', dir as Dir));
|
||||
|
||||
it('validates every move on the server', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
expect(s).toMatchObject({ x: 1, y: 1 });
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'n')).toMatchObject({ ok: false, code: 'wall' });
|
||||
expect(moveExplorer(game, t.ctx(), 'A', 'e')).toMatchObject({ ok: false, code: 'wrong_role' });
|
||||
expect(s).toMatchObject({ x: 1, y: 1, moves: 0 });
|
||||
expect(moveExplorer(game, t.ctx(), 'B', 'e').ok).toBe(true);
|
||||
expect(s).toMatchObject({ x: 2, y: 1, moves: 1 });
|
||||
});
|
||||
|
||||
it('sends the Explorer back to the start on a trap, with a penalty, keys kept', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
walk(room, t, 'ssss'); // (1,5)
|
||||
walk(room, t, 'ee'); // (3,5)
|
||||
const ctx = t.ctx();
|
||||
moveExplorer(game, ctx, 'B', 'e'); // (4,5) is a trap
|
||||
expect(s).toMatchObject({ x: 1, y: 1, falls: 1 });
|
||||
expect(room.penaltySeconds).toBe(60);
|
||||
expect(ctx.toasts).toEqual([{ to: 'A', message: expect.stringMatching(/fell into a trap/) }]);
|
||||
});
|
||||
|
||||
it('can be completed: three lanterns, then the exit', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as LabyrinthState;
|
||||
|
||||
// Breadth-first route planner over safe cells, standing in for a good Navigator.
|
||||
const goTo = (target: { x: number; y: number }) => {
|
||||
const start = `${s.x},${s.y}`;
|
||||
const prev = new Map<string, { from: string; dir: Dir }>();
|
||||
const queue = [{ x: s.x, y: s.y }];
|
||||
const seen = new Set([start]);
|
||||
while (queue.length) {
|
||||
const p = queue.shift()!;
|
||||
for (const dir of Object.keys(DELTAS) as Dir[]) {
|
||||
const n = { x: p.x + DELTAS[dir].x, y: p.y + DELTAS[dir].y };
|
||||
const c = cellAt(game.grid, n.x, n.y);
|
||||
if (c === '#' || c === 'T' || seen.has(`${n.x},${n.y}`)) continue;
|
||||
seen.add(`${n.x},${n.y}`);
|
||||
prev.set(`${n.x},${n.y}`, { from: `${p.x},${p.y}`, dir });
|
||||
queue.push(n);
|
||||
}
|
||||
}
|
||||
const path: Dir[] = [];
|
||||
for (let at = `${target.x},${target.y}`; at !== start; at = prev.get(at)!.from) path.unshift(prev.get(at)!.dir);
|
||||
for (const dir of path) expect(moveExplorer(game, t.ctx(), 'B', dir).ok).toBe(true);
|
||||
};
|
||||
|
||||
const [exit] = findCells(game.grid, 'E');
|
||||
goTo(exit);
|
||||
expect(room.phase).toBe('playing'); // locked without lanterns
|
||||
expect(s.message).toBe(game.explorerView.flavour.exitLocked);
|
||||
|
||||
for (const lantern of game.lanterns) goTo(lantern);
|
||||
expect(room.keys).toEqual(expect.arrayContaining(game.lanterns.map((l) => l.name)));
|
||||
goTo(exit);
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(s.falls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { loadGames } from '../src/content/loader';
|
||||
import type { DefuseGame, Game, LabyrinthGame, StoryGame, SyncGame } from '../src/content/schemas';
|
||||
import { initEngine } from '../src/engines/index';
|
||||
import { createCtx } from '../src/engines/types';
|
||||
import { RoomStore, type Room } from '../src/rooms/store';
|
||||
|
||||
export const games = loadGames();
|
||||
|
||||
export function getGame<E extends Game['engine']>(id: string, engine: E): Extract<Game, { engine: E }> {
|
||||
const game = games.get(id);
|
||||
if (!game || game.engine !== engine) throw new Error(`no ${engine} game "${id}"`);
|
||||
return game as Extract<Game, { engine: E }>;
|
||||
}
|
||||
|
||||
export const storyGames = [...games.values()].filter((g): g is StoryGame => g.engine === 'story');
|
||||
export const defuse = () => getGame('tick-tock-together', 'defuse') as DefuseGame;
|
||||
export const sync = () => getGame('in-sync', 'sync') as SyncGame;
|
||||
export const labyrinth = () => getGame('labyrinth-of-lanterns', 'labyrinth') as LabyrinthGame;
|
||||
|
||||
/** A room that is already in the `playing` phase for the given game. */
|
||||
export function playingRoom(game: Game, startedAt = 1_000_000): Room {
|
||||
const { room } = new RoomStore(null).create(game.id, 'duo', startedAt);
|
||||
room.players[0].seat = 'A';
|
||||
room.players.push({ token: 'b', seat: 'B', ready: true, viewing: null });
|
||||
room.phase = 'playing';
|
||||
room.startedAt = startedAt;
|
||||
room.engine = initEngine(game);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** A clock for tests: every call to `ctx()` is 3 seconds later, clear of the rate limit. */
|
||||
export function clock(room: Room, start = 1_000_000) {
|
||||
let now = start;
|
||||
return {
|
||||
ctx: (advanceMs = 3000) => createCtx(room, (now += advanceMs)),
|
||||
get now() {
|
||||
return now;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { Clue, Lock, StoryGame } from '../src/content/schemas';
|
||||
import { continueDefuse, defuseAction, solveButton, type DefuseState } from '../src/engines/defuse';
|
||||
import type { LabyrinthState } from '../src/engines/labyrinth';
|
||||
import { continueChapter, requestHint, submitLock, type StoryState } from '../src/engines/story';
|
||||
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
|
||||
import { buildState } from '../src/rooms/state';
|
||||
import type { Room } from '../src/rooms/store';
|
||||
import { clock, defuse, games, labyrinth, playingRoom, storyGames, sync } from './helpers';
|
||||
|
||||
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'failEnding', 'successEnding'];
|
||||
|
||||
function payload(room: Room, role: Role, now = 2_000_000): string {
|
||||
const player = room.players.find((p) => p.seat === role)!;
|
||||
return JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, 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;
|
||||
}
|
||||
|
||||
const locksOf = (game: StoryGame): Lock[] => [
|
||||
...game.chapters.map((c) => c.lock),
|
||||
...(game.finale.type === 'escape' ? [game.finale.lock] : []),
|
||||
];
|
||||
|
||||
const cluesFor = (game: StoryGame, role: Role): Clue[] => [
|
||||
...game.chapters.flatMap((c) => c.clues[role]),
|
||||
...(game.finale.type === 'escape' ? game.finale.clues[role] : []),
|
||||
];
|
||||
|
||||
describe.each(storyGames.map((g) => [g.id, g] as const))('sanitized story payloads: %s', (_id, game) => {
|
||||
// Walk the whole game, checking both players' payloads at every step.
|
||||
it('never contain answers, unrevealed hints, unearned text, or the other role\'s clues', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as StoryState;
|
||||
|
||||
const check = () => {
|
||||
for (const role of ['A', 'B'] as const) {
|
||||
const json = payload(room, role);
|
||||
const other: Role = role === 'A' ? 'B' : 'A';
|
||||
|
||||
for (const key of keysOf(JSON.parse(json))) expect(FORBIDDEN_KEYS, `${role} got key "${key}"`).not.toContain(key);
|
||||
|
||||
// The other role's private clues never arrive, in any chapter.
|
||||
const prefix = (c: Clue) => JSON.stringify(c.body).slice(1, 60);
|
||||
const mine = cluesFor(game, role).map(prefix);
|
||||
for (const clue of cluesFor(game, other)) {
|
||||
expect(json, `${role} can see ${other}'s clue ${clue.id}`).not.toContain(JSON.stringify(clue.title));
|
||||
// (Station Echo hands the same Morse chart to each player in turn.)
|
||||
if (!mine.includes(prefix(clue))) expect(json).not.toContain(prefix(clue));
|
||||
}
|
||||
|
||||
for (const lock of locksOf(game)) {
|
||||
const revealed = s.hints[lock.id] ?? 0;
|
||||
lock.hints.forEach((hint, i) => {
|
||||
if (i >= revealed) expect(json, `${role} got unrevealed hint ${i + 1} of ${lock.id}`).not.toContain(JSON.stringify(hint));
|
||||
});
|
||||
if (!s.solved.includes(lock.id) && lock.successText) {
|
||||
expect(json, `${role} got successText of unsolved ${lock.id}`).not.toContain(JSON.stringify(lock.successText));
|
||||
}
|
||||
// Lock objects carry no answer list at all. (Answer *words* may legitimately
|
||||
// appear inside a player's own clues, so those can't be grepped for.)
|
||||
const sent = (JSON.parse(json).views[role].chapters as { lock: object }[]).map((c) => c.lock);
|
||||
for (const l of sent) expect(Object.keys(l)).not.toContain('answers');
|
||||
}
|
||||
|
||||
// Signal patterns only ever ride on signal clues.
|
||||
const view = JSON.parse(json).views[role];
|
||||
const clues = [...view.chapters.flatMap((c: { clues: Clue[] }) => c.clues), ...(view.finale?.clues ?? [])];
|
||||
for (const clue of clues) if (clue.type !== 'signal') expect(clue.signal).toBeUndefined();
|
||||
|
||||
if (room.phase !== 'finished') {
|
||||
expect(json).not.toContain(JSON.stringify(game.finale.successEnding));
|
||||
if (game.finale.type === 'accusation') expect(json).not.toContain(JSON.stringify(game.finale.reveal));
|
||||
}
|
||||
// Future chapters are not sent ahead of time.
|
||||
expect(view.chapters.length).toBeLessThanOrEqual(Math.min(s.chapter, game.chapters.length - 1) + 1);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
for (const ch of game.chapters) {
|
||||
requestHint(game, t.ctx(), ch.lock.id);
|
||||
check();
|
||||
submitLock(game, t.ctx(), ch.lock.id, 'wrong');
|
||||
check();
|
||||
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
check();
|
||||
continueChapter(game, t.ctx());
|
||||
check();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends each player only their own and shared clues', () => {
|
||||
const room = playingRoom(game);
|
||||
const view = JSON.parse(payload(room, 'A')).views.A;
|
||||
const ids = view.chapters[0].clues.map((c: Clue) => c.id);
|
||||
const ch = game.chapters[0];
|
||||
expect(ids).toEqual([...ch.clues.A, ...ch.clues.shared].map((c) => c.id));
|
||||
expect(JSON.parse(payload(room, 'A')).views.B).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitized payloads: other engines', () => {
|
||||
it('defuse: the Technician never gets the manual, the Expert never gets the device', () => {
|
||||
const game = defuse();
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueDefuse(game, t.ctx());
|
||||
const s = room.engine as DefuseState;
|
||||
|
||||
const tech = payload(room, 'A', t.now);
|
||||
const expert = payload(room, 'B', t.now);
|
||||
expect(tech).not.toContain('Bomb Defusal Manual');
|
||||
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
|
||||
expect(tech).toContain(s.device!.serial);
|
||||
|
||||
expect(expert).toContain('Bomb Defusal Manual');
|
||||
expect(expert).not.toContain(s.device!.serial);
|
||||
for (const key of ['device', 'serial', 'batteries', 'wires', 'modules', 'strip', 'seed']) {
|
||||
expect(keysOf(JSON.parse(expert)), `expert got "${key}"`).not.toContain(key);
|
||||
}
|
||||
for (const json of [tech, expert]) {
|
||||
for (const key of FORBIDDEN_KEYS) expect(keysOf(JSON.parse(json))).not.toContain(key);
|
||||
expect(keysOf(JSON.parse(json))).not.toContain('seed');
|
||||
}
|
||||
});
|
||||
|
||||
it('defuse: the strip colour is only sent while the button is held', () => {
|
||||
const game = defuse();
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const s = room.engine as DefuseState;
|
||||
s.level = 1;
|
||||
// Find a seed whose button has to be held.
|
||||
for (s.seed = 1; ; s.seed++) {
|
||||
s.status = 'ready';
|
||||
continueDefuse(game, t.ctx());
|
||||
if (solveButton({ ...s.device!.button!, batteries: s.device!.batteries }).type === 'hold') break;
|
||||
}
|
||||
const button = () => JSON.parse(payload(room, 'A', t.now)).views.A.device.modules.find((m: { id: string }) => m.id === 'button');
|
||||
expect(button().strip).toBeNull();
|
||||
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' });
|
||||
expect(button()).toMatchObject({ holding: true, strip: s.device!.button!.strip });
|
||||
});
|
||||
|
||||
it('sync: the partner\'s answer is hidden until both have answered', () => {
|
||||
const game = sync();
|
||||
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, 2);
|
||||
|
||||
const b = JSON.parse(payload(room, 'B')).views.B;
|
||||
expect(b).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
|
||||
expect(JSON.parse(payload(room, 'A')).views.A).toMatchObject({ myAnswer: 2, partnerAnswered: false, reveal: null });
|
||||
|
||||
answerSync(game, t.ctx(), 'B', q.id, 1);
|
||||
expect(JSON.parse(payload(room, 'B')).views.B.reveal).toEqual({ A: 2, B: 1, match: false });
|
||||
});
|
||||
|
||||
it('labyrinth: the Explorer never gets the map, the Navigator never gets the position', () => {
|
||||
const game = labyrinth();
|
||||
const room = playingRoom(game);
|
||||
const s = room.engine as LabyrinthState;
|
||||
Object.assign(s, { x: 5, y: 3 }); // standing on the crescent moon
|
||||
|
||||
const explorer = JSON.parse(payload(room, 'B')).views.B;
|
||||
for (const key of ['grid', 'glyphs', 'lanterns', 'x', 'y', 'traps']) {
|
||||
expect(keysOf(explorer), `explorer got "${key}"`).not.toContain(key);
|
||||
}
|
||||
expect(explorer.glyphLabel).toMatch(/crescent moon/);
|
||||
expect(explorer.open).toEqual({ n: true, e: false, s: false, w: true });
|
||||
expect(JSON.stringify(explorer)).not.toContain('#');
|
||||
|
||||
const navigator = JSON.parse(payload(room, 'A')).views.A;
|
||||
expect(navigator.grid).toEqual(game.grid);
|
||||
// The only coordinates the Navigator has are the static map features.
|
||||
const { glyphs, lanterns, grid, ...rest } = navigator;
|
||||
expect(keysOf(rest)).not.toContain('x');
|
||||
expect(keysOf(rest)).not.toContain('y');
|
||||
expect(Object.keys(navigator)).not.toEqual(expect.arrayContaining(['open', 'glyphLabel', 'atExit', 'message']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { StoryGame } from '../src/content/schemas';
|
||||
import {
|
||||
agreeAccusation,
|
||||
continueChapter,
|
||||
proposeAccusation,
|
||||
requestHint,
|
||||
submitAccusation,
|
||||
submitLock,
|
||||
type StoryState,
|
||||
} from '../src/engines/story';
|
||||
import { computeResult } from '../src/rooms/state';
|
||||
import { clock, getGame, playingRoom, storyGames } from './helpers';
|
||||
|
||||
const correctAccusation = (game: StoryGame) => {
|
||||
if (game.finale.type !== 'accusation') throw new Error('not an accusation finale');
|
||||
return Object.fromEntries(game.finale.questions.map((q) => [q.id, q.answer]));
|
||||
};
|
||||
|
||||
describe.each(storyGames.map((g) => [g.id, g] as const))('story: %s', (_id, game) => {
|
||||
it('accepts every answer listed in the YAML, for every lock', () => {
|
||||
const locks = [...game.chapters.map((c) => c.lock), ...(game.finale.type === 'escape' ? [game.finale.lock] : [])];
|
||||
for (const lock of locks) {
|
||||
for (const answer of lock.answers) {
|
||||
// Fresh room fast-forwarded to this lock.
|
||||
const room = playingRoom(game);
|
||||
const s = room.engine as StoryState;
|
||||
const index = game.chapters.findIndex((c) => c.lock.id === lock.id);
|
||||
s.chapter = index === -1 ? game.chapters.length : index;
|
||||
const t = clock(room);
|
||||
expect(submitLock(game, t.ctx(), lock.id, answer), `${lock.id}: ${answer}`).toEqual({ ok: true, correct: true });
|
||||
// Sloppy formatting is fine too.
|
||||
const room2 = playingRoom(game);
|
||||
(room2.engine as StoryState).chapter = s.chapter;
|
||||
const sloppy = ` ${answer.toLowerCase().split('').join(' ')}! `;
|
||||
expect(submitLock(game, clock(room2).ctx(), lock.id, sloppy).ok && true).toBe(true);
|
||||
expect((room2.engine as StoryState).solved).toContain(lock.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('plays start to finish and earns one key per chapter', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
expect(submitLock(game, t.ctx(), ch.lock.id, 'definitely wrong')).toEqual({ ok: true, correct: false });
|
||||
expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toEqual({ ok: true, correct: true });
|
||||
expect(continueChapter(game, t.ctx()).ok).toBe(true);
|
||||
}
|
||||
expect(room.keys).toEqual(game.chapters.map((c) => c.lock.keyName));
|
||||
|
||||
if (game.finale.type === 'escape') {
|
||||
expect(submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0])).toMatchObject({ correct: true });
|
||||
} else {
|
||||
expect(proposeAccusation(game, t.ctx(), 'A', correctAccusation(game)).ok).toBe(true);
|
||||
expect(submitAccusation(game, t.ctx()).ok).toBe(false); // not agreed yet
|
||||
expect(agreeAccusation(game, t.ctx(), 'A').ok).toBe(false); // can't agree with yourself
|
||||
expect(agreeAccusation(game, t.ctx(), 'B').ok).toBe(true);
|
||||
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
|
||||
}
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toMatchObject({ success: true, ending: game.finale.successEnding });
|
||||
expect(room.wrongGuesses).toBe(game.chapters.length);
|
||||
expect(computeResult(room, game)).toMatchObject({ success: true, keysEarned: game.chapters.length });
|
||||
});
|
||||
});
|
||||
|
||||
describe('story rules', () => {
|
||||
const game = getGame('vineyard-hollow', 'story');
|
||||
const lock = game.chapters[0].lock;
|
||||
|
||||
it('rate-limits to one submission per 2s per lock', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
expect(submitLock(game, t.ctx(), lock.id, '0000')).toMatchObject({ ok: true, correct: false });
|
||||
expect(submitLock(game, t.ctx(500), lock.id, lock.answers[0])).toMatchObject({ ok: false, code: 'rate_limited' });
|
||||
expect(submitLock(game, t.ctx(2000), lock.id, lock.answers[0])).toMatchObject({ ok: true, correct: true });
|
||||
});
|
||||
|
||||
it('only accepts the lock that is currently open', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const next = game.chapters[1].lock;
|
||||
expect(submitLock(game, t.ctx(), next.id, next.answers[0])).toMatchObject({ ok: false, code: 'lock_closed' });
|
||||
submitLock(game, t.ctx(), lock.id, lock.answers[0]);
|
||||
// Solved but not continued: neither lock is open.
|
||||
expect(submitLock(game, t.ctx(), next.id, next.answers[0])).toMatchObject({ ok: false });
|
||||
expect(continueChapter(game, t.ctx()).ok).toBe(true);
|
||||
expect(continueChapter(game, t.ctx()).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reveals hints in order with a 2:00 penalty each', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (let tier = 1; tier <= 3; tier++) {
|
||||
expect(requestHint(game, t.ctx(), lock.id).ok).toBe(true);
|
||||
expect((room.engine as StoryState).hints[lock.id]).toBe(tier);
|
||||
}
|
||||
expect(requestHint(game, t.ctx(), lock.id).ok).toBe(false);
|
||||
expect(room.hintsUsed).toBe(3);
|
||||
expect(room.penaltySeconds).toBe(360);
|
||||
});
|
||||
|
||||
it('fails the case after maxAttempts wrong accusations without saying which part was wrong', () => {
|
||||
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
const room = playingRoom(game);
|
||||
(room.engine as StoryState).chapter = game.chapters.length;
|
||||
const t = clock(room);
|
||||
const wrong = { ...correctAccusation(game), who: 'clara' };
|
||||
|
||||
for (let attempt = 1; attempt <= game.finale.maxAttempts; attempt++) {
|
||||
const by: Role = attempt % 2 ? 'A' : 'B';
|
||||
expect(proposeAccusation(game, t.ctx(), by, { who: 'clara' }).ok).toBe(false); // incomplete
|
||||
proposeAccusation(game, t.ctx(), by, wrong);
|
||||
agreeAccusation(game, t.ctx(), by === 'A' ? 'B' : 'A');
|
||||
const ctx = t.ctx();
|
||||
expect(submitAccusation(game, ctx)).toMatchObject({ ok: true, correct: false });
|
||||
if (attempt < game.finale.maxAttempts) {
|
||||
expect(ctx.toasts.map((x) => x.message).join()).toMatch(/Not quite\. 1 attempt left/);
|
||||
expect(room.phase).toBe('playing');
|
||||
}
|
||||
}
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './normalize';
|
||||
export * from './scoring';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesAnswer, normalizeAnswer } from './normalize';
|
||||
import { formatClock, rankFor } from './scoring';
|
||||
|
||||
describe('normalizeAnswer', () => {
|
||||
it('strips separators from times', () => {
|
||||
expect(normalizeAnswer('20:40')).toBe('2040');
|
||||
expect(normalizeAnswer('20 40')).toBe('2040');
|
||||
expect(normalizeAnswer(' 20.40 ')).toBe('2040');
|
||||
});
|
||||
|
||||
it('uppercases and removes punctuation', () => {
|
||||
expect(normalizeAnswer('Fire Swart, Friday!')).toBe('FIRESWARTFRIDAY');
|
||||
expect(normalizeAnswer('grn-3')).toBe('GRN3');
|
||||
});
|
||||
|
||||
it('strips accents', () => {
|
||||
expect(normalizeAnswer('Rosé')).toBe('ROSE');
|
||||
expect(normalizeAnswer('Zoë Müller')).toBe('ZOEMULLER');
|
||||
});
|
||||
|
||||
it('keeps leading zeros', () => {
|
||||
expect(normalizeAnswer('0138')).toBe('0138');
|
||||
});
|
||||
|
||||
it('returns an empty string for junk', () => {
|
||||
expect(normalizeAnswer(' ?! ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesAnswer', () => {
|
||||
it('matches any accepted answer, normalized on both sides', () => {
|
||||
expect(matchesAnswer('8:40 pm', ['2040', '840PM', '840'])).toBe(true);
|
||||
expect(matchesAnswer('red, gold, blue', ['REDGOLDBLUE'])).toBe(true);
|
||||
expect(matchesAnswer('sammy', ['sammy'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong and empty answers', () => {
|
||||
expect(matchesAnswer('2041', ['2040'])).toBe(false);
|
||||
expect(matchesAnswer('138', ['0138'])).toBe(false);
|
||||
expect(matchesAnswer('', [''])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoring', () => {
|
||||
const base = { success: true, hintsUsed: 0, finalSeconds: 1000, estimatedMinutes: 40, keysEarned: 3, totalKeys: 3 };
|
||||
|
||||
it('ranks by hints, time and keys', () => {
|
||||
expect(rankFor(base)).toBe('Soulmate Sleuths');
|
||||
expect(rankFor({ ...base, hintsUsed: 1 })).toBe('Dream Team');
|
||||
expect(rankFor({ ...base, finalSeconds: 40 * 60 * 2 })).toBe('Getting There');
|
||||
expect(rankFor({ ...base, keysEarned: 1 })).toBe('Getting There');
|
||||
expect(rankFor({ ...base, success: false })).toBe('Love Is Patient 😅');
|
||||
});
|
||||
|
||||
it('formats clocks', () => {
|
||||
expect(formatClock(0)).toBe('0:00');
|
||||
expect(formatClock(125)).toBe('2:05');
|
||||
expect(formatClock(3725)).toBe('1:02:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Answer normalizer: uppercase → strip accents → drop everything except A-Z and 0-9.
|
||||
* "20:40" and "20 40" → "2040"; "Fire Swart, Friday!" → "FIRESWARTFRIDAY".
|
||||
*/
|
||||
export function normalizeAnswer(input: string): string {
|
||||
return String(input)
|
||||
.toUpperCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
/** True if the normalized input equals any normalized accepted answer. */
|
||||
export function matchesAnswer(input: string, answers: readonly string[]): boolean {
|
||||
const value = normalizeAnswer(input);
|
||||
if (value === '') return false;
|
||||
return answers.some((answer) => normalizeAnswer(answer) === value);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅'] as const;
|
||||
export type Rank = (typeof RANKS)[number];
|
||||
|
||||
export interface RankInput {
|
||||
success: boolean;
|
||||
hintsUsed: number;
|
||||
finalSeconds: number;
|
||||
estimatedMinutes: number;
|
||||
keysEarned: number;
|
||||
totalKeys: number;
|
||||
}
|
||||
|
||||
export function rankFor(r: RankInput): Rank {
|
||||
const estimate = r.estimatedMinutes * 60;
|
||||
const allKeys = r.keysEarned >= r.totalKeys;
|
||||
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';
|
||||
return 'Getting There';
|
||||
}
|
||||
|
||||
export function formatClock(totalSeconds: number): string {
|
||||
const t = Math.max(0, Math.floor(totalSeconds));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
|
||||
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Types shared by server and client. Everything here describes what may travel over the
|
||||
// wire: sanitized, per-role views. Raw game content (with answers) is typed on the server.
|
||||
|
||||
export type Role = 'A' | 'B';
|
||||
export const ROLES: readonly Role[] = ['A', 'B'];
|
||||
export const otherRole = (role: Role): Role => (role === 'A' ? 'B' : 'A');
|
||||
|
||||
/** A couch-mode player holds both roles on one device. */
|
||||
export type Seat = Role | 'both';
|
||||
export type PlayMode = 'duo' | 'couch';
|
||||
export type Phase = 'lobby' | 'playing' | 'finished';
|
||||
export type EngineId = 'story' | 'defuse' | 'sync' | 'labyrinth';
|
||||
export type Category = 'murder-mystery' | 'escape-room' | 'co-op';
|
||||
|
||||
export interface Theme {
|
||||
primary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
headingFont: string;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
name: string;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
id: string;
|
||||
engine: EngineId;
|
||||
category: Category;
|
||||
title: string;
|
||||
tagline: string;
|
||||
estimatedMinutes: number;
|
||||
difficulty: number;
|
||||
theme: Theme;
|
||||
roles: Record<Role, RoleInfo>;
|
||||
totalKeys: number;
|
||||
}
|
||||
|
||||
export interface GameMeta extends GameSummary {
|
||||
intro: string;
|
||||
}
|
||||
|
||||
// ---------- Story engine ----------
|
||||
|
||||
export const CLUE_TYPES = [
|
||||
'note',
|
||||
'letter',
|
||||
'document',
|
||||
'report',
|
||||
'log',
|
||||
'table',
|
||||
'photo',
|
||||
'map',
|
||||
'transcript',
|
||||
'signal',
|
||||
'diagram',
|
||||
] as const;
|
||||
export type ClueType = (typeof CLUE_TYPES)[number];
|
||||
|
||||
export interface ClueView {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ClueType;
|
||||
body: string;
|
||||
/** Only present on clues of type `signal`. Never print it as text. */
|
||||
signal?: string;
|
||||
audience: 'private' | 'shared';
|
||||
}
|
||||
|
||||
export interface LockOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LockView {
|
||||
id: string;
|
||||
prompt: string;
|
||||
inputType: 'code' | 'text' | 'choice';
|
||||
format: string | null;
|
||||
options: LockOption[] | null;
|
||||
solved: boolean;
|
||||
/** Revealed hint tiers only. */
|
||||
hints: string[];
|
||||
hintsTotal: number;
|
||||
wrongAttempts: number;
|
||||
nudge: boolean;
|
||||
successText: string | null;
|
||||
keyName: string | null;
|
||||
}
|
||||
|
||||
export interface ChapterView {
|
||||
id: string;
|
||||
title: string;
|
||||
narrative: string;
|
||||
clues: ClueView[];
|
||||
lock: LockView;
|
||||
}
|
||||
|
||||
export interface Suspect {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AccusationView {
|
||||
type: 'accusation';
|
||||
prompt: string;
|
||||
questions: { id: string; label: string; options: LockOption[] }[];
|
||||
proposal: { answers: Record<string, string>; by: Role } | null;
|
||||
agreed: boolean;
|
||||
attemptsLeft: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
export interface EscapeFinaleView {
|
||||
type: 'escape';
|
||||
prompt: string;
|
||||
clues: ClueView[];
|
||||
lock: LockView;
|
||||
}
|
||||
|
||||
export interface StoryView {
|
||||
engine: 'story';
|
||||
suspects: Suspect[];
|
||||
/** Chapters unlocked so far (earlier ones feed the Evidence Locker). */
|
||||
chapters: ChapterView[];
|
||||
currentChapter: number;
|
||||
totalChapters: number;
|
||||
awaitingContinue: boolean;
|
||||
finale: AccusationView | EscapeFinaleView | null;
|
||||
}
|
||||
|
||||
// ---------- Defuse engine ----------
|
||||
|
||||
export type DefuseStatus = 'ready' | 'active' | 'exploded' | 'cleared';
|
||||
|
||||
export interface DefuseClock {
|
||||
/** Game-seconds left at the moment the state was built. */
|
||||
remaining: number;
|
||||
/** Game-seconds per real second (speeds up with strikes). */
|
||||
speed: number;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface WiresModuleView {
|
||||
id: 'wires';
|
||||
solved: boolean;
|
||||
wires: string[];
|
||||
}
|
||||
export interface ButtonModuleView {
|
||||
id: 'button';
|
||||
solved: boolean;
|
||||
label: string;
|
||||
color: string;
|
||||
holding: boolean;
|
||||
strip: string | null;
|
||||
}
|
||||
export interface KeypadModuleView {
|
||||
id: 'keypad';
|
||||
solved: boolean;
|
||||
symbols: { id: string; label: string; pressed: boolean }[];
|
||||
}
|
||||
export type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView;
|
||||
|
||||
interface DefuseCommon {
|
||||
engine: 'defuse';
|
||||
level: { index: number; total: number; title: string; timeSeconds: number };
|
||||
status: DefuseStatus;
|
||||
failReason: 'strikes' | 'time' | null;
|
||||
strikes: number;
|
||||
maxStrikes: number;
|
||||
clock: DefuseClock | null;
|
||||
}
|
||||
export interface TechnicianView extends DefuseCommon {
|
||||
seat: 'technician';
|
||||
device: { serial: string; batteries: number; modules: DefuseModuleView[] } | null;
|
||||
}
|
||||
export interface ExpertView extends DefuseCommon {
|
||||
seat: 'expert';
|
||||
manual: string;
|
||||
}
|
||||
export type DefuseView = TechnicianView | ExpertView;
|
||||
|
||||
export type DefuseAction =
|
||||
| { type: 'cut'; index: number }
|
||||
| { type: 'tap' }
|
||||
| { type: 'hold' }
|
||||
| { type: 'release' }
|
||||
| { type: 'press'; symbol: string };
|
||||
|
||||
// ---------- Sync engine ----------
|
||||
|
||||
export interface SyncView {
|
||||
engine: 'sync';
|
||||
status: 'intro' | 'question' | 'reveal' | 'summary';
|
||||
round: {
|
||||
index: number;
|
||||
total: number;
|
||||
title: string;
|
||||
type: 'match' | 'predict';
|
||||
blurb: string;
|
||||
subject: Role | null;
|
||||
};
|
||||
isReplay: boolean;
|
||||
questionNumber: number;
|
||||
questionsPerRound: number;
|
||||
pointsForKey: number;
|
||||
points: number;
|
||||
question: { id: string; text: string; options: string[] } | null;
|
||||
iAmSubject: boolean;
|
||||
myAnswer: number | null;
|
||||
partnerAnswered: boolean;
|
||||
/** Both answers, only once both are in. */
|
||||
reveal: { A: number; B: number; match: boolean } | null;
|
||||
/** Match / miss for each question revealed so far this round. */
|
||||
marks: boolean[];
|
||||
summary: { earned: boolean; canReplay: boolean; keyName: string } | null;
|
||||
}
|
||||
|
||||
// ---------- Labyrinth engine ----------
|
||||
|
||||
export type Dir = 'n' | 'e' | 's' | 'w';
|
||||
|
||||
export interface ExplorerView {
|
||||
engine: 'labyrinth';
|
||||
seat: 'explorer';
|
||||
glyphLabel: string | null;
|
||||
open: Record<Dir, boolean>;
|
||||
keysCarried: number;
|
||||
totalKeys: number;
|
||||
atExit: boolean;
|
||||
message: string;
|
||||
moves: number;
|
||||
falls: number;
|
||||
}
|
||||
|
||||
export interface NavigatorView {
|
||||
engine: 'labyrinth';
|
||||
seat: 'navigator';
|
||||
grid: string[];
|
||||
glyphs: { x: number; y: number; label: string }[];
|
||||
lanterns: { x: number; y: number; name: string; collected: boolean }[];
|
||||
keysCarried: number;
|
||||
totalKeys: number;
|
||||
moves: number | null;
|
||||
falls: number;
|
||||
}
|
||||
export type LabyrinthView = ExplorerView | NavigatorView;
|
||||
|
||||
export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView;
|
||||
|
||||
// ---------- Room ----------
|
||||
|
||||
export interface GameResult {
|
||||
success: boolean;
|
||||
elapsedSeconds: number;
|
||||
penaltySeconds: number;
|
||||
finalSeconds: number;
|
||||
hintsUsed: number;
|
||||
wrongGuesses: number;
|
||||
keysEarned: number;
|
||||
totalKeys: number;
|
||||
rank: string;
|
||||
ending: string | null;
|
||||
reveal: string | null;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
code: string;
|
||||
mode: PlayMode;
|
||||
phase: Phase;
|
||||
game: GameMeta;
|
||||
you: { seat: Seat | null; ready: boolean };
|
||||
partner: { online: boolean; seat: Role | null; ready: boolean; viewing: string | null } | null;
|
||||
notepad: string;
|
||||
keys: string[];
|
||||
timer: {
|
||||
startedAt: number | null;
|
||||
finishedAt: number | null;
|
||||
penaltySeconds: number;
|
||||
serverNow: number;
|
||||
};
|
||||
stats: { hintsUsed: number; wrongGuesses: number };
|
||||
/** One view per role this socket may see: own role, or both in couch mode. */
|
||||
views: Partial<Record<Role, EngineView>>;
|
||||
result: GameResult | null;
|
||||
}
|
||||
|
||||
export type JoinAck =
|
||||
| { ok: true; code: string; playerToken: string }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
export const PING_EMOJIS = ['👋', '🔍', '💡', '❤️'] as const;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user