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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:44:28 +02:00
36 changed files with 923 additions and 217 deletions
+15 -13
View File
@@ -157,7 +157,8 @@ Use a zod discriminated union on `finale.type`.
- `photo`: polaroid frame; body text is the photo description in italics (no image assets needed)
- `map` / `diagram`: monospace block, preserve whitespace, grid tables
- `signal`: animated blinking light driven by `signal` field (see §6.3)
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer.
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer. The locker searches clue titles and bodies (client-side, own clues only), filters by chapter, and lets each player star clues (`clue:pin`). The partner only ever gets the **count** of stars, never which clues.
- Clue text uses `Noto Sans Symbols 2` as a fallback (Braille, alchemy symbols). Code fences (stencil grids, box-drawing keypads, SQL) are monospace at `line-height: 1.15`; wide tables and blocks scroll inside their card, never the page.
### Other engines
See each YAML for its schema. Summaries in §6.
@@ -179,8 +180,8 @@ See each YAML for its schema. Summaries in §6.
- 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 (or the game's `rules.hintPenaltySeconds`). Confirm dialog before revealing tier 3 ("This basically solves it. Sure?").
- **Time limit:** games with `rules.timeLimitMinutes` show a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game with `failEnding` (plus `reveal` for an accusation).
- **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`.
- **Time limit:** games with `rules.timeLimitMinutes` show a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game with `failEnding` (plus `reveal` for an accusation) and the results page shows **Out of time** instead of a rank. A `timeLimitLabel` containing "water" (Deep Lab Nereid) also draws the countdown as water rising behind the header (no wave with `prefers-reduced-motion`).
- **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`. Any number of questions works. On the last attempt (always, with `maxAttempts: 1`) submitting asks "One shot. Are you both sure?" first.
- For `escape` finales, it's just the last lock; on success show `successEnding`.
### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml`
@@ -201,8 +202,8 @@ See each YAML for its schema. Summaries in §6.
- `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. (When a `predict` bank is too small for that, a question may come back once, asked about the other partner.)
- `telepathy` rounds (Deep Cuts): no options, both type one word. Same word = point; case, spaces, punctuation and a trailing S are ignored.
- `rank` rounds (Deep Cuts): the subject (cycled through `subjectPattern`) orders four items, the partner predicts. Point only if **both** the top and the bottom match.
- `telepathy` rounds (Deep Cuts): no options, both type one word (max 30 characters). Same word = point; case, spaces, punctuation and a trailing S are ignored.
- `rank` rounds (Deep Cuts): the subject (cycled through `subjectPattern`) orders four items (drag, or up/down buttons), the partner predicts. Point only if **both** the top and the bottom match; the reveal highlights both.
- A round can set its own `pointsForKey`.
### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml`
@@ -220,10 +221,10 @@ See each YAML for its schema. Summaries in §6.
Client → server:
`room:create {gameId}`, `room:join {code, playerToken?}`, `room:pickRole {role}`, `room:ready`,
`lock:submit {lockId, value}`, `hint:request {lockId}`, `chapter:continue`,
`lock:submit {lockId, value}`, `hint:request {lockId}`, `clue:pin {clueId}`, `chapter:continue`,
`finale:propose {answers}`, `finale:agree`, `finale:submit`,
`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId | text | order}`,
`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` with a relative compass), `notepad:update {text}`, `ping {emoji}`
`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` / `forward/left/right/back` with a relative compass), `notepad:update {text}`, `ping {emoji}`
Server → client:
`room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`,
@@ -234,14 +235,15 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
## 8. Scoring & results
- `finalTime = elapsed + penaltySeconds`
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅 / **Out of time** (a time limit ran out)
- **Nightmare Survivors ☠️**: a difficulty-5 game finished with zero hints. Shown on the results page and, once earned, on that game's library card.
- Results page: time, hints used, keys earned, wrong guesses, plus a "Play another" carousel. Store best results per game in localStorage.
## 9. Design direction
- Vibe: **date-night cozy meets noir**. Dark mode default, warm candlelit accents, per-game theme from YAML `theme`.
- Fonts: Playfair Display / Inter / Caveat (handwritten notes) / JetBrains Mono (logs, maps) from Google Fonts.
- Home page: hero "Two players. One mystery. Every key needs both of you." + game library grid (cards show category chip, time, difficulty 🔑 out of 5).
- Home page: hero "Two players. One mystery. Every key needs both of you." + game library grid (cards show category chip, time, difficulty 🔑 out of 5; difficulty 5 adds a **☠️ Nightmare** chip). Difficulty: 1 Easy · 2 Medium · 3 Hard · 4 Expert · 5 Nightmare. The library sorts easiest first and filters by category and difficulty.
- **Mobile-first.** Most couples will play on two phones. Clue cards stack vertically; lock panel is sticky at the bottom; evidence locker is a bottom sheet.
- Accessibility: all locks keyboard-operable, `prefers-reduced-motion` respected, colorblind-safe (defuse wires also show their color name as text label).
- Role color-coding: A = warm (amber), B = cool (teal), shown as a badge on every clue card ("Only you can see this").
@@ -250,10 +252,10 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
`npm run validate-content` must check:
- Every YAML parses and passes its engine's zod schema
- Story: every chapter has ≥1 clue for A **and** ≥1 for B; every lock has ≥1 answer and exactly 3 hints; finale answers exist in options
- Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined
- Labyrinth: grid rows equal length; exactly one S and E; exactly `lanternsRequired` K; every K and E reachable from S **without stepping on a trap** (BFS, respecting one-way gates); no cell where a gate leaves the Explorer stuck for good
- Sync: ≥15 questions per round type in use
- Story: every chapter has ≥1 clue for A **and** ≥1 for B; every lock has ≥1 answer and exactly 3 hints; finale answers exist in options; clue ids are unique; every signal clue has a `signal`; escape finales have A and B clues
- Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined; for `overtime`, the keypad columns, password words and Morse frequencies match the solver's tables (`OVERTIME_TABLES`) and every password fixture column has exactly `lettersPerColumn` letters
- Labyrinth: grid rows equal length; exactly one S and E; exactly `lanternsRequired` K; every K and E reachable from S **without stepping on a trap** (BFS, respecting one-way gates); no cell where a gate leaves the Explorer stuck for good; glyphs and gates never on walls or traps; every `lanterns` entry sits on a K
- Sync: ≥15 questions per round type in use; every rank question has exactly 4 items
Run it in CI and as a pre-start step.
## 11. Build phases (do in order, commit after each)
+104 -56
View File
@@ -99,88 +99,136 @@ Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3
---
Difficulty: 1 Easy · 2 Medium · 3 Hard · 4 Expert · 5 Nightmare ☠️
In the harder murder mysteries, **locks only give facts**. The killer is never handed to you; the accusation needs facts from several chapters.
## 9. Dead Air (murder mystery · 30 min · 🔑🔑🔑)
Hint penalty +3:00. Two accusation attempts.
Roles: **Detective** (A) / **Night Intern** (B). Hint penalty 3:00.
| Ch | Lock | Answer |
|----|------|--------|
| 1 | Ghost Signal | `GHOST SIGNAL` |
| 2 | The Last Cup | `23:06` |
| 3 | The Studio Door | Kyle |
| 4 | The Midnight Announcement | `THE SALE IS FORGED` |
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Last song | Track lengths | Run sheet (23:00 start, 2:00 intro, 1:30 talk) + alarm 23:31:35 (15 s rule) | `GHOST SIGNAL` (23:25:00–23:31:20) |
| 2 | Honey last safe | Lab: OKAYEST DJ mug negative, ON AIR mug + honey positive; meeting minutes | Kitchen camera log (offline 23:09–23:21); generator test booked by M. Venter | `23:06` |
| 3 | Studio door card | Card register (decimal); Kyle lost card at 22:30 meeting with Marisa | Door log in hex (0x1A1 at 23:12) | Kyle's card (0x1A1 = 417) |
| 4 | Stencil message | 6×6 letter grid | Card with cut-out windows | `SALE IS FORGED` |
**Accusation:** `marisa` · `honey` · `forged`.
**Killer:** Marisa Venter. Booked the generator test to kill the camera, left the meeting 23:11–23:17, used Kyle's stolen card at 23:12 to take the EpiPen, put peanut oil in the honey. Motive: Rex would reveal on air she forged his signature to sell the station.
**Red herrings:** Zanele (served the fatal tea), Kyle (his card), Tumi (engineer), Owen (superfan, stuck in reception).
---
## 10. The Masquerade at Montague House (murder mystery · 40 min · 🔑🔑🔑🔑)
Hint penalty +3:00. **One** accusation attempt.
Roles: **Inspector** (A) / **Lady's Maid** (B). Hint penalty 3:00. **1 accusation attempt, 4 questions.**
| Ch | Lock | Answer |
|----|------|--------|
| 1 | Who Wore What | Odette |
| 2 | The Raven in the Conservatory | Ivy |
| 3 | The Missing Dagger | `11` |
| 4 | Julian's Confession | `MARSH WAS INNOCENT` |
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Who arrived as Raven | Logic clues 1–4 | Logic clues 5–8 | Odette (verified unique: Agatha Stag/crimson, Rupert Fox/green, Ivy Swan/black, Silas Lion/silver, Odette Raven/gold) |
| 2 | Who the footman saw | Footman: Raven + black cloak | Cloakroom: Raven ↔ Swan swapped at 23:00, cloaks kept | Ivy |
| 3 | Dagger pot | Gardener: primes not watered | Disturbed pots 4, 9, 11, 15; spotless gloves | `11` |
| 4 | Julian's confession | Polybius numbers + method | Motto FORTUNE FAVOURS THE BOLD; Marsh family gossip | `MARSH WAS INNOCENT` |
**Accusation:** `ivy` · `raven` · `dagger` · `father`.
**Killer:** Ivy Marsh · wearing Odette's **Raven** mask · armoury dagger, buried in cactus pot 11 · Julian framed her father and took Montague House.
## 11. The Kalahari Expedition (murder mystery · 60 min · 🔑🔑🔑🔑🔑)
Hint penalty +4:00. **75-minute limit.** **One** accusation attempt.
---
| Ch | Lock | Answer |
|----|------|--------|
| 1 | Star Trails | `02:00` |
| 2 | Footprints | `11` |
| 3 | The Watch Rota | Pienaar |
| 4 | Samuel's Note | `HE SMELLED OF PIPE SMOKE` |
| 5 | The Diamonds | `KIMBERLEY` (the Big Hole) |
| 6 | The Unsent Telegram | `SALTED` |
## 11. The Kalahari Expedition (murder mystery · 60 min · ☠️ Nightmare)
Roles: **Magistrate** (A) / **Camp Clerk** (B). Hint penalty 4:00. **75-minute time limit. 1 attempt, 4 questions.**
**Accusation:** `dunmore` · `same` · `struck` · `fraud`.
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Lantern time | Star-trail photo: lantern at 60°–75° of arc | Shutter opened 22:00, sky turns 15°/hr; Clara alibis Reyes 22:00–04:00 | `02:00` |
| 2 | Boot size | Print 31.5 cm, chevron, sand adds ~1 cm | Size card + boot inventory | `11` (Reyes & Dunmore have size 11 chevron) |
| 3 | Watchman at 02:00 | Rota rules; Samuel's fake snakebite (4 cm marks, sedative) | Watchkeepers + Night 1 = Reyes; murder on Night 3 | Pienaar → figure went to north row (Dunmore, Kessler, Reyes) |
| 4 | Samuel's note | Vigenère ciphertext + method | Key word KOFFIE; tobacco list (pipe: Reyes, Dunmore) | `HE SMELLED OF PIPE SMOKE` |
| 5 | Diamond source | Gem report: sample K-3, never water-worn | Reference samples; prospectus "found by F. Dunmore" | `KIMBERLEY` |
| 6 | Telegram | Book-cipher numbers | Expedition handbook | `SALTED` |
**Elimination:** size 11 chevron → Reyes, Dunmore · Reyes alibied by Clara · pipe smoke → Dunmore.
**Killer:** Felix Dunmore · struck Marlowe at the dig at 02:00 and put him in the pit · drugged Samuel and faked a snakebite · the diamond find was salted with Kimberley stones.
---
## 12. The Alchemist's Vault (escape room · 25 min · 🔑🔑🔑)
Hint penalty +3:00.
Roles: **Apprentice** (A) / **Scribe** (B).
| Lock | Answer |
|------|--------|
| The Five Flasks | `GENIUS` |
| The Philosopher's Stone | `25` |
| The Dotted Plaque | `80` |
| Door (finale) | `234` |
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Cabinet | Flasks: numbers + flame colours | Element table + rainbow order | `GENIUS` (Ge N I U S) |
| Stone dial | Two balanced scales | Silver = 5, gold = 3 × silver | `25` |
| Dotted plaque | Braille ⠍⠑⠗⠉⠥⠗⠽ | Braille card; Mercury = Hg | `80` |
| Vault (finale) | ☉ ☽ ♂ ♄ | Symbol → metal chart (+ element table from ch1) | `234` (79 + 47 + 26 + 82) |
---
## 13. Submerged: Deep Lab Nereid (escape room · 35 min · 🔑🔑🔑🔑)
Roles: **Pilot** (A) / **Diver** (B). Hint penalty +3:00. **40-minute limit**, shown as the water level.
Roles: **Pilot** (A) / **Diver** (B). **40-minute time limit shown as a rising water gauge.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Pressure valves | Warning lights: ON ON ON off ON | Which lights each valve flips | `145` |
| Breathing gas | 210 L at exactly 21% oxygen | Tank X 36%, Tank Y 15% | `60` |
| Find the pod | 300 m east, then 200 m south | Chart: lab at E5, 100 m squares | `H7` |
| Launch word (finale) | Keypresses 555·4·3·66·55·9·222 | The rewired keypad | `SURFACE` |
| Hatch valves | Lights L1 L2 L3 L5 on | Valve → light wiring | `145` (unique solution) |
| Breathing gas | 210 L at 21% | Tank X 36%, Y 15% | `60` |
| Pod location | Sonar legs 300 m E, 200 m S | Chart, lab at E5, 100 m squares | `H7` |
| Launch (finale) | Multi-tap code 555·4·3·66·55·9·222 | Rewired intercom keypad | `SURFACE` |
## 14. The Kestrel Bank Job (escape room · 50 min · 🔑🔑🔑🔑🔑)
Roles: **Inside Man** (A) / **The Wheel** (B). Hint penalty +4:00. **55-minute limit.**
---
## 14. The Kestrel Bank Job (escape room · 50 min · ☠️ Nightmare)
Roles: **Inside Man** (A) / **The Wheel** (B). Hint penalty 4:00. **55-minute limit.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Staff door | Worn keys 0, 2, 6 (0 most) | Codes are family dates DDMM; Mia born 20 Jun | `2006` |
| Laser hall | Column bit values 16/8/4/2/1 | Row masks 21, 12, 25, 17, 8 | `4` |
| Guards | Guard 1 from 22:03 every 7, Guard 2 from 22:05 every 11 | Guard 3 from 22:02 every 6; need 4 clear minutes | `22:33` |
| Vault dial | Sons' ages in Roman numerals: VII, XIX, XII | Atbash note: "my sons' ages, eldest first" | `19-12-07` |
| Box number | SQL query + CLIENTS table | BOXES table | `317` |
| Getaway (finale) | `MUTLEETSTHODIREAEP`, "rails = floors" | 3 floors; how a rail fence works | `OLD PIER` |
| Staff door | Worn keys 0, 2, 6 (0 most worn) | Codes are DDMM family dates; social posts | `2006` (Mia, 20 June) |
| Laser hall | Mask bit values per column | Row masks 21, 12, 25, 17, 8 | Column `4` |
| Guards | Guards 1 & 2 timings | Guard 3 timing; need 4 clear minutes | `22:33` |
| Vault dial | Sons' ages in Roman numerals | Atbash note "MY SONS AGES, ELDEST FIRST" | `19-12-07` |
| Box | SQL query + CLIENTS | BOXES table | `317` |
| Getaway (finale) | Rail-fence text | Building has 3 floors (= 3 rails) | `OLD PIER` |
---
## 15. Tick Tock: Overtime (co-op defuse · 20 min · 🔑🔑🔑🔑)
The sequel: its own manual (`ruleset: overtime`), indicator lights, and three new modules (Passwords, Morse Light, Say It Back).
4 levels = 4 keys; the last level has all six modules and only 2 strikes. Fixtures in the YAML are the unit tests.
4 levels = 4 keys. New: 3–6 wires, indicators (LUV, HUG, BAE, XOX, DTE), passwords, morse, simon. Level 4 runs all six modules with only 2 strikes.
Rules are the GAMES-NEW.md Appendix A solver, ported to `server/src/engines/defuse.ts` (`ruleset: overtime`); all 40 fixtures in the YAML pass as unit tests.
## 16. In Sync: Deep Cuts (couples connection · 25 min · 🔑🔑🔑)
Round 1 telepathy (type the same word, 3/5). Rounds 2 and 3 predict about A, then B (4/5).
Round 4 ranking, subject alternating A/B (top **and** bottom must match, 3/5). One replay per round.
Telepathy (type the same word, 3/5) · Spotlight A (4/5) · Spotlight B (4/5) · Ranking (top AND bottom must match, 3/5). 4 keys.
## 17. The Drowned Catacombs (co-op navigation · 20 min · 🔑🔑🔑)
3 lanterns, 5 traps (1:00 each), and three one-way flood gates:
(5,9) east only · (5,11) north only · (7,8) north only.
13×13, 5 traps, 3 lanterns, **3 one-way flood gates**, sparse carvings.
## 18. The Mirror Maze (co-op navigation · 30 min · 🔑🔑🔑🔑🔑)
4 lanterns, 7 traps (1:30 each). The Navigator's map is served **mirrored left to right**; the Explorer has no compass
(forward/left/right/back from the way they face, starting east). Two glyphs (crescent, star) appear twice.
```
############# S (1,1) E (11,11)
#S..#.......# K (3,11) (7,3) (11,5)
###.#.###.#.# T (2,3) (4,5) (4,7) (10,5) (10,11)
#.T...#K#...# One-way: (5,9)→E (5,11)→N (7,8)→N
#.#.#.#.#.###
#.#.T.#...TK# Verified: all lanterns + exit reachable without traps,
#.##.##.....# respecting gates, and no softlocks.
#.#.T...#.#.#
#.#.###.#.#.#
#.......#.#.#
#.###.#.#.#.#
#..K#.....TE#
#############
```
## 18. The Mirror Maze (co-op navigation · 30 min · ☠️ Nightmare)
15×15, 7 traps, **4 lanterns**, Explorer has **relative controls only**, Navigator's map is **mirrored left-right**, two glyphs appear twice (☾ and ✦).
```
############### True layout (Navigator sees each row reversed)
#S..#.........# S (1,1) facing east E (13,13)
###.#.#.#####.# K (1,3) (1,13) (3,9) (13,9)
#K#.....#...#.# T (2,13) (3,5) (5,6) (5,7) (9,5) (13,6) (13,10)
#.####.##.#.#.#
#..T.....T....#
#.###T#.###..T#
#...#T#.#.....#
#.#.#.#.#.###.#
#..K#.#.....#K#
#.#.#.#.###.#T#
#.#.........#.#
#.#.#.#.###.#.#
#KT...#......E#
###############
```
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
rel="stylesheet"
/>
<title>Two Keys · Co-op mysteries for couples</title>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/client",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
+32 -11
View File
@@ -36,7 +36,13 @@ function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
);
}
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
/** Starring a clue: yours to see; your partner only learns how many you starred. */
export interface PinProps {
pinned?: boolean;
onPin?: () => void;
}
function Header({ clue, light, mono, pinned, onPin }: { clue: ClueView; light: boolean; mono?: boolean } & PinProps) {
return (
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="min-w-0">
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:
</div>
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
</div>
<Audience clue={clue} light={light} />
<div className="flex shrink-0 items-center gap-1.5">
<Audience clue={clue} light={light} />
{onPin && (
<button
type="button"
onClick={onPin}
aria-pressed={pinned}
aria-label={pinned ? `Unstar ${clue.title}` : `Star ${clue.title}`}
className={`-m-1 flex h-9 w-9 items-center justify-center rounded-full font-sans text-lg not-italic transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
pinned ? 'text-amber-400' : light ? 'text-stone-500 hover:text-stone-800' : 'text-stone-500 hover:text-stone-200'
}`}
>
{pinned ? '★' : '☆'}
</button>
)}
</div>
</div>
);
}
@@ -98,19 +119,19 @@ export function SignalLamp({ pattern }: { pattern: string }) {
);
}
export function ClueCard({ clue }: { clue: ClueView }) {
export function ClueCard({ clue, ...pin }: { clue: ClueView } & PinProps) {
switch (clue.type) {
case 'note':
return (
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
<Header clue={clue} light />
<Header {...pin} clue={clue} light />
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
</article>
);
case 'letter':
return (
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
<Header clue={clue} light />
<Header {...pin} clue={clue} light />
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
</article>
);
@@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
</div>
<div className="p-4">
<Header clue={clue} light mono />
<Header {...pin} clue={clue} light mono />
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
</div>
</article>
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
<Markdown className="italic leading-6">{clue.body}</Markdown>
</div>
<div className="mt-3">
<Header clue={clue} light />
<Header {...pin} clue={clue} light />
</div>
</article>
);
@@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) {
case 'diagram':
return (
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
<Header clue={clue} light={false} mono />
<Header {...pin} clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
case 'transcript':
return (
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
<Header clue={clue} light={false} />
<Header {...pin} clue={clue} light={false} />
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
</article>
);
case 'signal':
return (
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
<Header clue={clue} light={false} mono />
<Header {...pin} clue={clue} light={false} mono />
{clue.signal && <SignalLamp pattern={clue.signal} />}
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
</article>
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
case 'table':
return (
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
<Header clue={clue} light={false} mono />
<Header {...pin} clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
+14 -2
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
import { getBest, saveSession } from '../lib/session';
import { request } from '../lib/socket';
import { CATEGORY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme';
import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme';
import { pushToast } from '../lib/toasts';
import { RoleBadge, Sheet } from './ui';
@@ -39,13 +39,25 @@ export function GameCard({ game, className = '' }: { game: GameSummary; classNam
<div className="flex flex-wrap items-center gap-2">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span className="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
<span className="text-xs" aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}`}>
<span
className="text-xs"
aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}: ${DIFFICULTY_LABEL[game.difficulty]}`}
title={DIFFICULTY_LABEL[game.difficulty]}
>
{difficultyKeys(game.difficulty)}
<span className="opacity-25 grayscale">{difficultyKeys(MAX_DIFFICULTY - game.difficulty)}</span>
</span>
{game.difficulty >= MAX_DIFFICULTY && (
<span className="chip border-red-400/40 text-red-200">☠️ Nightmare</span>
)}
</div>
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.tagline}</p>
{best?.nightmareSurvivors && (
<div className="mt-3 self-start rounded-full bg-red-500/15 px-2.5 py-1 text-xs font-semibold text-red-200">
☠️ Nightmare Survivors
</div>
)}
<div className="mt-4 flex items-center justify-between gap-2 text-xs text-stone-400">
<span>{best ? `🏆 Best ${formatClock(best.finalSeconds)} · ${best.rank}` : ENGINE_BLURB[game.engine]}</span>
<span className="font-semibold text-accent transition group-hover:translate-x-0.5">Play →</span>
+21
View File
@@ -46,3 +46,24 @@ export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receiv
</div>
);
}
/** A countdown called a water level is drawn as water rising behind the header, 0% → 100% as time runs out. */
export const isWaterLevel = (timer: RoomState['timer']) => timer.limitSeconds !== null && /water/i.test(timer.limitLabel ?? '');
export function WaterGauge({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
const now = useNow(1000);
if (timer.startedAt === null || timer.limitSeconds === null) return null;
const serverNow = timer.serverNow + (now - receivedAt);
const used = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000 + timer.penaltySeconds;
const fill = Math.min(100, Math.max(0, (used / timer.limitSeconds) * 100));
return (
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden" aria-hidden>
<div
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-sky-700/70 to-sky-500/40 transition-[height] duration-1000"
style={{ height: `${fill}%` }}
>
{fill > 0 && fill < 100 && <div className="water-wave absolute inset-x-0 -top-1.5 h-3 motion-reduce:hidden" />}
</div>
</div>
);
}
+9 -4
View File
@@ -179,7 +179,7 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
)}
{view.message && <p className="mt-3 text-lg italic text-stone-200">{view.message}</p>}
<p className="mt-3 text-sm text-stone-400">
Open passages: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
Open: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
</p>
{gated.length > 0 && (
<p className="mt-1 text-sm text-stone-400">
@@ -191,17 +191,22 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
<div className="mx-auto grid w-56 grid-cols-3 grid-rows-3 gap-2" role="group" aria-label="Move">
{controls.map((d) => {
const gate = view.gates.includes(d.dir);
const blocked = gate ? " (gate, won't open from this side)" : ' (wall)';
return (
<button
key={d.dir}
className={`btn aspect-square flex-col gap-0 text-2xl ${d.area}`}
disabled={!view.open[d.dir]}
onClick={() => send('maze:move', { dir: d.dir })}
aria-label={`Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : blocked}`}
aria-label={gate ? `${d.label}: Gate (won't open from this side)` : `Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : ' (wall)'}`}
title={gate ? "Gate (won't open from this side)" : undefined}
>
{gate ? '⛩' : d.arrow}
{relative && <span className="text-[10px] font-semibold uppercase tracking-wide">{d.label}</span>}
{gate ? (
<span className="text-[10px] font-semibold uppercase leading-tight tracking-wide">Gate</span>
) : (
relative && <span className="text-[10px] font-semibold uppercase tracking-wide">{d.label}</span>
)}
</button>
);
})}
+126 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
@@ -14,11 +14,16 @@ interface Props {
send: Send;
}
function Clues({ clues }: { clues: ClueView[] }) {
interface Pins {
pins: string[];
onPin: (clueId: string) => void;
}
function Clues({ clues, pins, onPin }: { clues: ClueView[] } & Pins) {
return (
<div className="space-y-5">
{clues.map((clue) => (
<ClueCard key={clue.id} clue={clue} />
<ClueCard key={clue.id} clue={clue} pinned={pins.includes(clue.id)} onPin={() => onPin(clue.id)} />
))}
</div>
);
@@ -71,11 +76,28 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
const partnerName = state.game.roles[otherRole(role)].name;
const mine = finale.proposal?.by === role;
const lastShot = finale.attemptsLeft === 1;
const [confirming, setConfirming] = useState(false);
useEffect(() => setConfirming(false), [proposalKey, finale.agreed]);
let action;
if (matchesProposal && finale.agreed) {
if (matchesProposal && finale.agreed && confirming) {
action = (
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
<div className="rounded-2xl border border-red-400/50 bg-red-500/10 p-3 text-center" role="alertdialog" aria-label="Confirm accusation">
<p className="font-semibold">One shot. Are you both sure?</p>
<div className="mt-3 flex gap-2">
<button className="btn-primary flex-1" onClick={() => send('finale:submit')}>
Yes, accuse
</button>
<button className="btn flex-1" onClick={() => setConfirming(false)}>
Not yet
</button>
</div>
</div>
);
} else if (matchesProposal && finale.agreed) {
action = (
<button className="btn-primary w-full" onClick={() => (lastShot ? setConfirming(true) : send('finale:submit'))}>
You both agree. Make the accusation ⚖️
</button>
);
@@ -136,7 +158,9 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
<div className="sticky bottom-0 -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
<p className="mb-2 text-center text-xs text-stone-400" aria-live="polite">
{finale.attemptsLeft} of {finale.maxAttempts} attempts left
{finale.maxAttempts === 1
? 'One attempt only. A wrong accusation ends the game.'
: `${finale.attemptsLeft} of ${finale.maxAttempts} attempts left`}
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
</p>
{action}
@@ -145,9 +169,70 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
);
}
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
const textOf = (clue: ClueView) => `${clue.title}\n${clue.body}`.toLowerCase();
/** Search, filter and star your own clues. Everything here is already on this device. */
function EvidenceLocker({
view,
chapters,
finaleClues,
currentChapter,
pins,
onPin,
}: { view: StoryView; chapters: ChapterView[]; finaleClues: ClueView[]; currentChapter: number } & Pins) {
const [query, setQuery] = useState('');
const [only, setOnly] = useState<number | 'all' | 'starred'>('all');
const q = query.trim().toLowerCase();
const filtering = q !== '' || only !== 'all';
const sections = useMemo(() => {
const all = [
...chapters.map((ch, i) => ({ key: ch.id, index: i, title: `Chapter ${i + 1}: ${ch.title}`, chapter: ch, clues: ch.clues })),
...(finaleClues.length > 0 ? [{ key: 'finale', index: chapters.length, title: 'Finale', chapter: null, clues: finaleClues }] : []),
];
return all
.filter((s) => typeof only !== 'number' || s.index === only)
.map((s) => ({
...s,
clues: s.clues.filter((c) => (only !== 'starred' || pins.includes(c.id)) && (q === '' || textOf(c).includes(q))),
}));
}, [chapters, finaleClues, only, q, pins]);
const found = sections.reduce((n, s) => n + s.clues.length, 0);
const chip = (value: typeof only, label: string) => (
<button
key={String(value)}
aria-pressed={only === value}
onClick={() => setOnly(only === value ? 'all' : value)}
className={`min-h-[36px] shrink-0 rounded-full border px-3 py-1 text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
only === value ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
{label}
</button>
);
return (
<div className="space-y-6">
<div className="space-y-2">
<input
type="search"
className="field"
placeholder="Search your clues…"
aria-label="Search your clues"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1" role="group" aria-label="Show">
{chip('all', 'All')}
{chip('starred', `★ Starred (${pins.length})`)}
{chapters.map((_, i) => chip(i, `Ch ${i + 1}`))}
</div>
<p className="text-xs text-stone-400" aria-live="polite">
{filtering ? `${found} clue${found === 1 ? '' : 's'} found · ` : ''}
Your partner has starred {view.partnerPins} of their clue{view.partnerPins === 1 ? '' : 's'}.
</p>
</div>
{view.suspects.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
@@ -163,22 +248,25 @@ function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: Chapter
</ul>
</section>
)}
{chapters.length === 0 && view.suspects.length === 0 && (
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
)}
{chapters.map((ch, i) => (
<details key={ch.id} className="group" open={i === chapters.length - 1}>
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
Chapter {i + 1}: {ch.title}
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
</summary>
<div className="mt-4 space-y-4 px-1">
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
<Clues clues={ch.clues} />
</div>
</details>
))}
{filtering && found === 0 && <p className="text-stone-400">Nothing matches.</p>}
{sections
.filter((s) => !filtering || s.clues.length > 0)
.map((s) => (
// Filtering opens every match; otherwise the latest solved chapter starts open.
<details key={`${s.key}:${filtering}`} className="group" open={filtering || s.index === currentChapter - 1}>
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
{s.title}
{s.chapter?.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {s.chapter.lock.keyName}</span>}
</summary>
<div className="mt-4 space-y-4 px-1">
{!filtering && s.chapter?.lock.successText && (
<Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{s.chapter.lock.successText}</Markdown>
)}
<Clues clues={s.clues} pins={pins} onPin={onPin} />
</div>
</details>
))}
</div>
);
}
@@ -188,8 +276,10 @@ export function StoryGame({ view, state, role, send }: Props) {
const atFinale = view.finale !== null;
const chapter = atFinale ? null : view.chapters[view.currentChapter];
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
// Everything already solved: all chapters once the finale opens, earlier ones before that.
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
const pins = view.pins ?? [];
const onPin = (clueId: string) => send('clue:pin', { clueId });
const finaleClues = view.finale?.type === 'escape' ? view.finale.clues : [];
const clueCount = view.chapters.reduce((n, c) => n + c.clues.length, 0) + finaleClues.length;
// Tell the partner what we're looking at.
useEffect(() => {
@@ -208,7 +298,7 @@ export function StoryGame({ view, state, role, send }: Props) {
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
</div>
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
🗄️ Evidence{past.length > 0 ? ` (${past.reduce((n, c) => n + c.clues.length, 0)})` : ''}
🗄️ Evidence ({clueCount}){pins.length > 0 ? ` · ★${pins.length}` : ''}
</button>
</div>
<div className="mt-1">
@@ -226,7 +316,7 @@ export function StoryGame({ view, state, role, send }: Props) {
{chapter && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
<Clues clues={chapter.clues} />
<Clues clues={chapter.clues} pins={pins} onPin={onPin} />
{chapter.lock.solved && view.awaitingContinue ? (
<Unlocked
lock={chapter.lock}
@@ -249,13 +339,20 @@ export function StoryGame({ view, state, role, send }: Props) {
{view.finale?.type === 'escape' && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
<Clues clues={view.finale.clues} />
<Clues clues={view.finale.clues} pins={pins} onPin={onPin} />
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
</>
)}
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
<EvidenceLocker view={view} chapters={past} />
<EvidenceLocker
view={view}
chapters={view.chapters}
finaleClues={finaleClues}
currentChapter={view.currentChapter}
pins={pins}
onPin={onPin}
/>
</Sheet>
</div>
);
+107 -45
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useRef, useState, type FormEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
@@ -64,7 +64,7 @@ function WordInput({ onSubmit }: { onSubmit: (text: string) => void }) {
className="field text-lg"
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={40}
maxLength={30}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
@@ -78,46 +78,101 @@ function WordInput({ onSubmit }: { onSubmit: (text: string) => void }) {
);
}
/** Ranking: tap the items in order, most to least. Tap a placed one to take it back out. */
/**
* Ranking: put the items in order, most at the top. Drag the grip (mouse or touch), or use the
* up/down buttons (keyboard and screen readers).
*/
function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) {
const [order, setOrder] = useState<number[]>([]);
const toggle = (i: number) => setOrder((o) => (o.includes(i) ? o.filter((x) => x !== i) : [...o, i]));
const [order, setOrder] = useState<number[]>(() => items.map((_, i) => i));
const [dragging, setDragging] = useState<number | null>(null);
const [announce, setAnnounce] = useState('');
const rows = useRef<(HTMLLIElement | null)[]>([]);
const move = (from: number, to: number) => {
if (to < 0 || to >= order.length || from === to) return;
setOrder((o) => {
const next = [...o];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
});
setAnnounce(`${items[order[from]]} moved to number ${to + 1}.`);
};
// Pointer dragging: whichever row the pointer is over becomes the dragged item's new place.
const onPointerMove = (e: ReactPointerEvent) => {
if (dragging === null) return;
const from = order.indexOf(dragging);
const to = rows.current.findIndex((row) => {
const r = row?.getBoundingClientRect();
return r !== undefined && e.clientY >= r.top && e.clientY <= r.bottom;
});
if (to >= 0 && to !== from) move(from, to);
};
return (
<div className="space-y-2">
<div className="grid gap-2" role="group" aria-label="Tap in order, most to least">
{items.map((item, i) => {
const place = order.indexOf(i);
return (
<div className="space-y-3">
<p className="text-center text-xs text-stone-400">Most at the top, least at the bottom.</p>
<ol className="grid gap-2 touch-none select-none" onPointerMove={onPointerMove} onPointerUp={() => setDragging(null)} onPointerCancel={() => setDragging(null)}>
{order.map((item, place) => (
<li
key={item}
ref={(el) => (rows.current[place] = el)}
className={`flex min-h-[56px] items-center gap-2 rounded-xl border px-2 py-2 text-lg font-medium transition-colors ${
dragging === item ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5'
}`}
>
<button
key={i}
onClick={() => toggle(i)}
aria-pressed={place >= 0}
aria-label={place >= 0 ? `${item}, ranked number ${place + 1}. Tap to remove.` : `${item}, not ranked yet`}
className={`flex min-h-[52px] items-center gap-3 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 ${
place >= 0 ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
type="button"
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded-lg text-stone-400 active:cursor-grabbing"
aria-hidden
tabIndex={-1}
onPointerDown={(e) => {
e.preventDefault();
(e.currentTarget.closest('ol') as HTMLElement | null)?.setPointerCapture(e.pointerId);
setDragging(item);
}}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
place >= 0 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-500'
}`}
aria-hidden
>
{place >= 0 ? place + 1 : '·'}
</span>
{item}
⠿
</button>
);
})}
</div>
<div className="flex gap-2">
<button className="btn" disabled={order.length === 0} onClick={() => setOrder([])}>
Start over
</button>
<button className="btn-primary flex-1" disabled={order.length !== items.length} onClick={() => onSubmit(order)}>
{order.length === items.length ? 'Lock in this order' : `Tap ${items.length - order.length} more`}
</button>
</div>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
place === 0 || place === order.length - 1 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-400'
}`}
aria-hidden
>
{place + 1}
</span>
<span className="min-w-0 flex-1">{items[item]}</span>
<div className="flex shrink-0 gap-1">
<button
type="button"
className="btn !min-h-[40px] !px-2.5 !py-1"
disabled={place === 0}
onClick={() => move(place, place - 1)}
aria-label={`Move ${items[item]} up, now number ${place + 1}`}
>
▲
</button>
<button
type="button"
className="btn !min-h-[40px] !px-2.5 !py-1"
disabled={place === order.length - 1}
onClick={() => move(place, place + 1)}
aria-label={`Move ${items[item]} down, now number ${place + 1}`}
>
▼
</button>
</div>
</li>
))}
</ol>
<p className="sr-only" aria-live="polite">
{announce}
</p>
<button className="btn-primary w-full" onClick={() => onSubmit(order)}>
Lock in this order
</button>
</div>
);
}
@@ -189,8 +244,8 @@ export function SyncGame({ view, state, role, send }: Props) {
else if (type === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.';
else if (type === 'rank') {
instruction = view.iAmSubject
? "This one's about you. Tap them in order, most to least. Be honest!"
: `Tap them in the order ${subjectName} would, most to least. The top and the bottom have to match.`;
? "This one's about you. Put them in order, most to least. Be honest!"
: `Put them in the order ${subjectName} would, most to least. The top and the bottom have to match.`;
} else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`;
/** An answer as the reveal cards show it. */
@@ -198,12 +253,19 @@ export function SyncGame({ view, state, role, send }: Props) {
if (typeof answer === 'string') return answer;
if (typeof answer === 'number') return q.options[answer];
return (
<ol className="space-y-0.5 text-left text-base">
{answer.map((item, place) => (
<li key={item} className={place === 0 || place === answer.length - 1 ? '' : 'font-normal text-stone-400'}>
{place + 1}. {q.options[item]}
</li>
))}
<ol className="w-full space-y-1 text-left text-sm sm:text-base">
{answer.map((item, place) => {
// #1 and the last place are the ones that score.
const scores = place === 0 || place === answer.length - 1;
return (
<li
key={item}
className={scores ? 'rounded-md bg-accent/20 px-1.5 py-0.5 text-accent' : 'px-1.5 font-normal text-stone-400'}
>
{place + 1}. {q.options[item]}
</li>
);
})}
</ol>
);
};
+21 -3
View File
@@ -101,18 +101,24 @@ body {
.md th:empty {
display: none;
}
/* Stencil grids and box-drawing keypads need tight lines so the boxes join up. */
.md pre {
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
max-width: 100%;
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
font-size: 0.9em;
line-height: 1.5;
line-height: 1.15;
white-space: pre;
}
.md pre code.language-sql {
display: block;
line-height: 1.5;
}
.md pre code {
font-size: inherit;
}
.md code {
font-family: 'JetBrains Mono', monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
font-size: 0.92em;
}
.md .scroll-x {
@@ -131,6 +137,18 @@ body {
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
}
/* ---------- Water gauge (Deep Lab Nereid) ---------- */
.water-wave {
background: radial-gradient(ellipse 12px 6px at 12px 6px, rgb(56 189 248 / 0.55) 98%, transparent) repeat-x;
background-size: 24px 12px;
animation: wave 2.4s linear infinite;
}
@keyframes wave {
to {
background-position-x: 24px;
}
}
@keyframes shake {
20%,
60% {
+7 -1
View File
@@ -36,6 +36,8 @@ export interface BestResult {
rank: string;
hintsUsed: number;
keysEarned: number;
/** Ever finished this (difficulty 5) game with zero hints. Kept once earned. */
nightmareSurvivors?: boolean;
}
export function getBest(gameId: string): BestResult | null {
@@ -53,6 +55,10 @@ export function saveBest(gameId: string, result: BestResult): boolean {
!best ||
result.keysEarned > best.keysEarned ||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
const nightmareSurvivors = Boolean(result.nightmareSurvivors || best?.nightmareSurvivors);
if (better) write(`twokeys:best:${gameId}`, JSON.stringify({ ...result, nightmareSurvivors }));
else if (nightmareSurvivors && !best?.nightmareSurvivors) {
write(`twokeys:best:${gameId}`, JSON.stringify({ ...best, nightmareSurvivors }));
}
return better;
}
+1
View File
@@ -43,4 +43,5 @@ export const ROLE_BORDER: Record<Role, string> = { A: 'border-roleA/60', B: 'bor
export const ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
export const MAX_DIFFICULTY = 5;
export const DIFFICULTY_LABEL: Record<number, string> = { 1: 'Easy', 2: 'Medium', 3: 'Hard', 4: 'Expert', 5: 'Nightmare' };
export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n));
+38 -12
View File
@@ -1,16 +1,37 @@
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import type { Category } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { CATEGORY_LABEL } from '../lib/theme';
import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY } from '../lib/theme';
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
const LEVELS = Array.from({ length: MAX_DIFFICULTY }, (_, i) => i + 1);
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
aria-pressed={active}
onClick={onClick}
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
active ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
{children}
</button>
);
}
export function Library() {
const games = useGames();
const [filter, setFilter] = useState<Category | 'all'>('all');
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
const [level, setLevel] = useState<number | null>(null);
// Easiest first; ties keep the library's own order.
const shown = (games ?? [])
.filter((g) => (filter === 'all' || g.category === filter) && (level === null || g.difficulty === level))
.map((g, i) => ({ g, i }))
.sort((a, b) => a.g.difficulty - b.g.difficulty || a.i - b.i)
.map(({ g }) => g);
return (
<SiteShell>
@@ -21,16 +42,20 @@ export function Library() {
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
{FILTERS.map((f) => (
<button
key={f}
aria-pressed={filter === f}
onClick={() => setFilter(f)}
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
</button>
</Chip>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2" role="group" aria-label="Filter by difficulty">
<Chip active={level === null} onClick={() => setLevel(null)}>
Any difficulty
</Chip>
{LEVELS.map((d) => (
<Chip key={d} active={level === d} onClick={() => setLevel(level === d ? null : d)}>
{DIFFICULTY_LABEL[d]}
{d === MAX_DIFFICULTY ? ' ☠️' : ''}
</Chip>
))}
</div>
@@ -43,6 +68,7 @@ export function Library() {
{shown.map((game) => (
<GameCard key={game.id} game={game} />
))}
{shown.length === 0 && <p className="text-stone-400">No games match those filters.</p>}
</div>
)}
</SiteShell>
+3 -2
View File
@@ -5,7 +5,7 @@ import { KeyRing } from '../components/KeyRing';
import { Notepad } from '../components/Notepad';
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
import { PassDeviceScreen } from '../components/PassDeviceScreen';
import { Timer } from '../components/Timer';
import { Timer, WaterGauge, isWaterLevel } from '../components/Timer';
import { RoleBadge } from '../components/ui';
import { DefuseGame } from '../engines/DefuseGame';
import { LabyrinthGame } from '../engines/LabyrinthGame';
@@ -45,8 +45,9 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
return (
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
<header
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} isolate z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
>
{isWaterLevel(state.timer) && <WaterGauge timer={state.timer} receivedAt={receivedAt} />}
<div className="flex items-center justify-between gap-3">
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
🔑🔑
+9 -2
View File
@@ -38,6 +38,7 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
rank: result.rank,
hintsUsed: result.hintsUsed,
keysEarned: result.keysEarned,
nightmareSurvivors: result.nightmareSurvivors,
}),
);
}, [game.id, result]);
@@ -55,9 +56,15 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
>
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
{result.outOfTime ? '⌛' : result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
</motion.div>
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
{/* Out of time gets no rank badge. */}
<h1 className="mt-3 font-display text-4xl font-bold">{result.outOfTime ? 'Out of time' : result.rank}</h1>
{result.nightmareSurvivors && (
<p className="mx-auto mt-3 w-fit rounded-full bg-red-500/15 px-4 py-1.5 font-semibold text-red-200">
☠️ Nightmare Survivors: a difficulty-5 game with zero hints
</p>
)}
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
</header>
+6 -5
View File
@@ -12,12 +12,13 @@ export default {
roleA: '#f5b942',
roleB: '#2dd4bf',
},
// Noto Sans Symbols 2 covers the Braille and alchemy symbols (⠍ ☉ ♄ ☿) the clues use.
fontFamily: {
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
serif: ['Playfair Display', 'Georgia', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
hand: ['Caveat', 'cursive'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
serif: ['Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
sans: ['Inter', 'system-ui', 'Noto Sans Symbols 2', 'sans-serif'],
hand: ['Caveat', 'Noto Sans Symbols 2', 'cursive'],
mono: ['JetBrains Mono', 'ui-monospace', 'Noto Sans Symbols 2', 'monospace'],
},
},
},
+2 -2
View File
@@ -252,8 +252,8 @@ manual: |
| 2 | Yellow | Green | Blue | Red |
# ---------- Solver fixtures (MUST pass as unit tests) ----------
# Generated from the reference solver in tools/reference/overtime_solver.py, which implements
# the manual above exactly. Port that solver to TypeScript in server/src/engines/defuse.ts.
# Generated from the reference solver (GAMES-NEW.md, Appendix A), which implements the manual
# above exactly. Its TypeScript port is the `overtime` ruleset in server/src/engines/defuse.ts.
fixtures:
wires:
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "two-keys",
"version": "1.1.0",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "two-keys",
"version": "1.1.0",
"version": "1.2.1",
"workspaces": [
"shared",
"server",
@@ -23,7 +23,7 @@
},
"client": {
"name": "@two-keys/client",
"version": "1.1.0",
"version": "1.2.1",
"dependencies": {
"@two-keys/shared": "*",
"framer-motion": "^11.11.0",
@@ -6847,7 +6847,7 @@
},
"server": {
"name": "@two-keys/server",
"version": "1.1.0",
"version": "1.2.1",
"dependencies": {
"@two-keys/shared": "*",
"express": "^4.21.0",
@@ -7332,7 +7332,7 @@
},
"shared": {
"name": "@two-keys/shared",
"version": "1.1.0",
"version": "1.2.1",
"devDependencies": {
"vitest": "^2.1.0"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "two-keys",
"private": true,
"version": "1.1.0",
"version": "1.2.1",
"description": "Co-op mystery games for couples",
"workspaces": [
"shared",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/server",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -133,6 +133,9 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
totalChapters: total,
awaitingContinue: s.awaitingContinue,
finale,
pins: [...(s.pins?.[role] ?? [])],
// Only a count: which clues the partner starred would hint at what they hold.
partnerPins: s.pins?.[otherRole(role)]?.length ?? 0,
};
}
+29 -4
View File
@@ -1,5 +1,6 @@
import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared';
import {
OVERTIME_TABLES,
buttonSolution,
solveKeypad,
solveMorse,
@@ -100,6 +101,10 @@ function validateDefuse(game: DefuseGame): string[] {
});
fixtures.passwords.forEach((f, i) => {
const p = modules.passwords;
if (p && (f.columns.length !== p.columnsCount || f.columns.some((c) => c.length !== p.lettersPerColumn))) {
errors.push(`passwords fixture ${i + 1}: needs ${p.columnsCount} columns of exactly ${p.lettersPerColumn} letters`);
}
const got = solvePassword(f.columns, modules.passwords?.words ?? []);
if (got !== f.expect) errors.push(`passwords fixture ${i + 1}: expected ${f.expect}, solver says ${got ?? 'no unique word'}`);
});
@@ -129,6 +134,19 @@ function validateDefuse(game: DefuseGame): string[] {
}
if (new Set(entries.map(([, f]) => f)).size !== entries.length) errors.push('two morse words share a frequency');
}
if (ruleset === 'overtime') {
// The manual's tables must be the ones the solver was written against.
const t = OVERTIME_TABLES;
if (!same(modules.keypad.columns, t.keypadColumns)) errors.push("keypad columns don't match the Overtime solver's");
if (!same([...(modules.passwords?.words ?? [])].sort(), [...t.passwordWords].sort())) {
errors.push("password word list doesn't match the Overtime solver's");
}
const morse = modules.morse?.words ?? {};
if (Object.keys(morse).length !== Object.keys(t.morseFrequencies).length) errors.push("morse word list doesn't match the Overtime solver's");
for (const [word, freq] of Object.entries(morse)) {
if (t.morseFrequencies[word] !== freq) errors.push(`morse word "${word}" should be on ${t.morseFrequencies[word] ?? 'no frequency'}, the YAML says ${freq}`);
}
}
if (modules.simon && modules.simon.stages.min < 1) errors.push('simon needs at least one stage');
if (!/\d/.test(game.device.serial.alphabet)) errors.push('serial alphabet has no digits');
@@ -159,8 +177,14 @@ function validateSync(game: SyncGame): string[] {
if (bank.length < needed) errors.push(`${type} bank has ${bank.length} questions, but ${needed} are needed to never repeat`);
if (new Set(bank.map((q) => q.id)).size !== bank.length) errors.push(`${type} bank has duplicate question ids`);
}
for (const q of game.rank) {
if (q.items.length !== 4) errors.push(`rank question ${q.id}: needs exactly 4 items, has ${q.items.length}`);
}
for (const r of game.rounds) {
if (r.type === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`);
if (r.type === 'rank' && r.subjectPattern && r.subjectPattern.length !== game.rules.questionsPerRound) {
errors.push(`round ${r.id}: subjectPattern should name a subject for each of the ${game.rules.questionsPerRound} questions`);
}
if (pointsForKey(game, r) > game.rules.questionsPerRound) errors.push(`round ${r.id}: pointsForKey is more than questionsPerRound`);
}
return errors;
@@ -184,14 +208,15 @@ function validateLabyrinth(game: LabyrinthGame): string[] {
if (cellAt(grid, l.x, l.y) !== 'K') errors.push(`lantern "${l.name}" at (${l.x},${l.y}) is not on a K cell`);
}
for (const g of game.glyphs) {
if (cellAt(grid, g.x, g.y) === '#') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is inside a wall`);
const c = cellAt(grid, g.x, g.y);
if (c === '#' || c === 'T') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is on a ${c === '#' ? 'wall' : 'trap'}`);
}
for (const g of game.oneWay) {
const through = DELTAS[g.dir];
if (cellAt(grid, g.x, g.y) === '#' || cellAt(grid, g.x + through.x, g.y + through.y) === '#') {
errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} opens into a wall`);
}
const ends = [cellAt(grid, g.x, g.y), cellAt(grid, g.x + through.x, g.y + through.y)];
if (ends.includes('#')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} opens into a wall`);
if (ends.includes('T')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} sits on a trap`);
}
if (starts.length === 1) {
+32 -2
View File
@@ -171,6 +171,31 @@ export function solveKeypad(symbols: string[], columns: string[][]): string[] |
return matches[0].filter((s) => symbols.includes(s));
}
/**
* The Overtime manual's tables, as the reference solver has them. The YAML carries its own copy
* (the generator reads that one); validation checks the two agree.
*/
export const OVERTIME_TABLES = {
keypadColumns: [
['heart', 'star', 'moon', 'sun', 'club', 'flower', 'key'],
['umbrella', 'heart', 'plane', 'music', 'star', 'anchor', 'crown'],
['diamond', 'sun', 'spade', 'umbrella', 'flower', 'music', 'bolt'],
['anchor', 'spade', 'club', 'plane', 'moon', 'diamond', 'key'],
['crown', 'bolt', 'heart', 'diamond', 'plane', 'sun', 'cloud'],
['cloud', 'music', 'key', 'spade', 'club', 'crown', 'star'],
],
passwordWords: [
'ADORE', 'ANGEL', 'BLISS', 'BLUSH', 'CANDY', 'CHARM', 'CRUSH', 'DANCE', 'DREAM', 'FLAME', 'FLIRT',
'HEART', 'HONEY', 'LOVER', 'MATCH', 'PEACH', 'RINGS', 'ROSES', 'SMILE', 'SPARK', 'SUGAR', 'SWEET',
'TEDDY', 'TRUST', 'TULIP', 'UNITY', 'WALTZ', 'BLOOM', 'CHOSE', 'PEARL', 'VOWED', 'QUEEN',
],
morseFrequencies: {
HUG: '3.505', KISS: '3.515', LOVE: '3.522', DATE: '3.532', RING: '3.535', ROSE: '3.542', WINE: '3.545',
DUET: '3.552', DANCE: '3.555', HEART: '3.565', SWEET: '3.572', HONEY: '3.575', CUDDLE: '3.582',
SPARK: '3.592', VOWS: '3.595', BLISS: '3.600',
} as Record<string, string>,
};
// ---------- Generator ----------
export function mulberry32(seed: number): () => number {
@@ -251,8 +276,13 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
solved: false,
};
} else if (id === 'keypad') {
const column = pick(mods.keypad.columns);
device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false };
// Re-roll until exactly one column holds all four symbols.
let symbols: string[] = [];
for (let attempt = 0; attempt < 200; attempt++) {
symbols = shuffle(pick(mods.keypad.columns)).slice(0, 4);
if (solveKeypad(symbols, mods.keypad.columns)) break;
}
device.keypad = { symbols, pressed: [], solved: false };
} else if (id === 'passwords' && mods.passwords) {
const { words, lettersPerColumn } = mods.passwords;
// Re-roll until exactly one listed word can be spelled.
+22
View File
@@ -19,6 +19,8 @@ export interface StoryState {
agreed: boolean;
attempts: number;
};
/** Starred clue ids per role. The partner only ever learns how many. */
pins?: Partial<Record<Role, string[]>>;
}
export function initStory(): StoryState {
@@ -30,6 +32,7 @@ export function initStory(): StoryState {
wrong: {},
lastSubmit: {},
finale: { proposal: null, agreed: false, attempts: 0 },
pins: {},
};
}
@@ -53,6 +56,7 @@ export function tickStory(game: StoryGame, ctx: Ctx): boolean {
false,
finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?",
finale.type === 'accusation' ? finale.reveal : null,
true,
);
return true;
}
@@ -109,6 +113,24 @@ export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionRe
return OK;
}
/** Every clue id this role can see so far. */
export function visibleClueIds(game: StoryGame, s: StoryState, role: Role): string[] {
const chapters = game.chapters.slice(0, Math.min(s.chapter, game.chapters.length - 1) + 1);
const ids = chapters.flatMap((ch) => [...ch.clues[role], ...ch.clues.shared].map((c) => c.id));
if (atFinale(game, s) && game.finale.type === 'escape') ids.push(...game.finale.clues[role].map((c) => c.id));
return ids;
}
/** Stars or un-stars one of your own clues. */
export function togglePin(game: StoryGame, ctx: Ctx, role: Role, clueId: string): ActionResult {
const s = state(ctx);
if (!visibleClueIds(game, s, role).includes(clueId)) return fail('no_clue', "You don't have that clue.");
const pins = (s.pins ??= {});
const mine = pins[role] ?? [];
pins[role] = mine.includes(clueId) ? mine.filter((id) => id !== clueId) : [...mine, clueId];
return OK;
}
export function continueChapter(game: StoryGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (!s.awaitingContinue) return fail('not_ready', 'Solve the lock first.');
+3 -3
View File
@@ -2,7 +2,7 @@ import { normalizeAnswer, type Role, type SyncAnswer } from '@two-keys/shared';
import type { SyncGame, SyncQuestion } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export const TELEPATHY_MAX_LENGTH = 40;
export const TELEPATHY_MAX_LENGTH = 30;
type Round = SyncGame['rounds'][number];
@@ -91,8 +91,8 @@ export function normalizeWord(input: string): string {
function cleanAnswer(type: Round['type'], question: SyncQuestion, answer: unknown): SyncAnswer | null {
if (type === 'telepathy') {
if (typeof answer !== 'string') return null;
const text = answer.trim().slice(0, TELEPATHY_MAX_LENGTH);
return normalizeAnswer(text) === '' ? null : text;
const text = answer.trim();
return text.length > TELEPATHY_MAX_LENGTH || normalizeAnswer(text) === '' ? null : text;
}
if (type === 'rank') {
const count = question.items?.length ?? 0;
+3 -3
View File
@@ -14,7 +14,7 @@ export interface Ctx {
toast(to: Role | 'all', message: string): void;
addKey(name: string): void;
addPenalty(seconds: number): void;
finish(success: boolean, ending?: string | null, reveal?: string | null): void;
finish(success: boolean, ending?: string | null, reveal?: string | null, outOfTime?: boolean): void;
}
export function createCtx(room: Room, now = Date.now()): Ctx {
@@ -26,10 +26,10 @@ export function createCtx(room: Room, now = Date.now()): Ctx {
toast: (to, message) => void toasts.push({ to, message }),
addKey: (name) => void room.keys.push(name),
addPenalty: (seconds) => void (room.penaltySeconds += seconds),
finish(success, ending = null, reveal = null) {
finish(success, ending = null, reveal = null, outOfTime = false) {
room.phase = 'finished';
room.finishedAt = now;
room.result = { success, ending, reveal };
room.result = { success, ending, reveal, outOfTime };
},
};
}
+5
View File
@@ -12,6 +12,7 @@ export function computeResult(room: Room, game: Game): GameResult | null {
const elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
const finalSeconds = elapsedSeconds + room.penaltySeconds;
const keys = totalKeys(game);
const outOfTime = room.result.outOfTime ?? false;
return {
success: room.result.success,
elapsedSeconds,
@@ -28,7 +29,11 @@ export function computeResult(room: Room, game: Game): GameResult | null {
estimatedMinutes: game.estimatedMinutes,
keysEarned: room.keys.length,
totalKeys: keys,
outOfTime,
}),
outOfTime,
// Difficulty 5, finished without a single hint.
nightmareSurvivors: room.result.success && game.difficulty >= 5 && room.hintsUsed === 0,
ending: room.result.ending,
reveal: room.result.reveal,
};
+1 -1
View File
@@ -28,7 +28,7 @@ export interface Room {
wrongGuesses: number;
/** Engine-specific state, owned by the game's engine. */
engine: unknown;
result: { success: boolean; ending: string | null; reveal: string | null } | null;
result: { success: boolean; ending: string | null; reveal: string | null; outOfTime?: boolean } | null;
}
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
+22 -2
View File
@@ -1,7 +1,16 @@
import { randomUUID } from 'node:crypto';
import type { Server, Socket } from 'socket.io';
import { z } from 'zod';
import { DEFUSE_MODULES, PING_EMOJIS, otherRole, type JoinAck, type Phase, type Role } from '@two-keys/shared';
import {
DEFUSE_MODULES,
PING_EMOJIS,
otherRole,
type JoinAck,
type MoveDir,
type Phase,
type RelDir,
type Role,
} from '@two-keys/shared';
import type { Game } from '../content/schemas';
import { defuseAction, tickDefuse } from '../engines/defuse';
import { continueGame, initEngine } from '../engines/index';
@@ -13,6 +22,7 @@ import {
submitAccusation,
submitLock,
tickStory,
togglePin,
} from '../engines/story';
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
@@ -31,6 +41,7 @@ const schemas = {
pickRole: z.object({ role }),
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
hint: z.object({ lockId: z.string(), ...asRole }),
pin: z.object({ clueId: z.string().max(100), ...asRole }),
propose: z.object({ answers: z.record(z.string()), ...asRole }),
defuse: z.object({
moduleId: z.enum(DEFUSE_MODULES),
@@ -53,7 +64,12 @@ const schemas = {
order: z.array(z.number().int()).max(12).optional(),
...asRole,
}),
move: z.object({ dir: z.enum(['n', 'e', 's', 'w', 'f', 'l', 'r', 'b']), ...asRole }),
move: z.object({
dir: z
.enum(['n', 'e', 's', 'w', 'f', 'l', 'r', 'b', 'forward', 'left', 'right', 'back'])
.transform((d): MoveDir => (d.length > 1 ? (d[0] as RelDir) : (d as MoveDir))),
...asRole,
}),
notepad: z.object({ text: z.string() }),
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
view: z.object({ label: z.string().max(80) }),
@@ -220,6 +236,10 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
game.engine === 'story' ? requestHint(game, ctx, data.lockId) : fail('wrong_engine', 'No hints here.'),
);
on('clue:pin', schemas.pin, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'story' ? togglePin(game, ctx, role, data.clueId) : fail('wrong_engine', 'No clues here.'),
);
on('chapter:continue', empty, 'playing', ({ game, ctx }) => continueGame(game, ctx));
on('finale:propose', schemas.propose, 'playing', ({ game, data, role, ctx }) =>
+3 -3
View File
@@ -436,7 +436,7 @@ describe('story: per-game hint penalties and time limits', () => {
requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over
expect(tickStory(game, t.ctx())).toBe(true);
expect(room.phase).toBe('finished');
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null });
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null, outOfTime: true });
expect(tickStory(game, t.ctx())).toBe(false);
});
@@ -446,7 +446,7 @@ describe('story: per-game hint penalties and time limits', () => {
const room = playingRoom(game);
const t = clock(room);
expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true);
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: true });
const untimed = getGame('vineyard-hollow', 'story');
const calm = playingRoom(untimed);
@@ -466,6 +466,6 @@ describe('story: per-game hint penalties and time limits', () => {
expect((room.engine as StoryState).chapter).toBe(game.chapters.length);
expect(tickStory(game, t.ctx())).toBe(false);
submitLock(game, t.ctx(), game.finale.lock.id, 'surface');
expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null });
expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null, outOfTime: false });
});
});
+264
View File
@@ -0,0 +1,264 @@
// GAMES-NEW.md requirements for games 9–18 that the other suites don't already cover:
// sanitized payloads for every new game, Evidence Locker pins, "Out of time" and
// Nightmare Survivors results, one-shot accusations, and the extra content checks.
import { describe, expect, it } from 'vitest';
import type { Role } from '@two-keys/shared';
import type { Game } from '../src/content/schemas';
import { validateGame } from '../src/content/validate';
import { continueDefuse, type DefuseState } from '../src/engines/defuse';
import type { LabyrinthState } from '../src/engines/labyrinth';
import {
agreeAccusation,
continueChapter,
proposeAccusation,
submitAccusation,
submitLock,
tickStory,
togglePin,
} from '../src/engines/story';
import { TELEPATHY_MAX_LENGTH, answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, games, getGame, playingRoom } from './helpers';
const NEW_GAMES = [
'dead-air',
'masquerade-montague',
'kalahari-expedition',
'alchemists-vault',
'deep-lab-nereid',
'kestrel-heist',
'tick-tock-overtime',
'in-sync-deep-cuts',
'drowned-catacombs',
'mirror-maze',
];
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'seed', 'sequence', 'facing', 'word'];
const stateOf = (room: Room, role: Role, now = 2_000_000) => {
const player = room.players.find((p) => p.seat === role)!;
return buildState(room, player, games.get(room.gameId)!, () => true, now);
};
const json = (room: Room, role: Role, now?: number) => JSON.stringify(stateOf(room, role, now));
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (!Array.isArray(value)) out.add(k);
keysOf(v, out);
}
}
return out;
}
/** Moves each new game a little way in, so there is real state to leak. */
function started(game: Game) {
const room = playingRoom(game);
const t = clock(room);
if (game.engine === 'defuse') {
(room.engine as DefuseState).level = game.levels.length - 1; // every module at once
continueDefuse(game, t.ctx());
}
if (game.engine === 'sync') {
continueSync(game, t.ctx());
const q = (room.engine as SyncState).questions[0];
answerSync(game, t.ctx(), 'A', q.id, 'Lighthouse');
}
return { room, t };
}
describe.each(NEW_GAMES.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
it('never carry answers, fixtures or solver state for either role', () => {
expect(game).toBeDefined();
const { room, t } = started(game);
for (const role of ['A', 'B'] as const) {
const keys = keysOf(JSON.parse(json(room, role, t.now)));
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
}
});
if (game.engine === 'defuse') {
it('keeps the device from the Expert and the manual from the Technician', () => {
const { room, t } = started(game);
const tech = json(room, 'A', t.now);
const expert = json(room, 'B', t.now);
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
for (const key of ['device', 'indicators', 'serial', 'modules']) expect(keysOf(JSON.parse(expert))).not.toContain(key);
// Fixture answers don't ride along in any form.
for (const f of game.fixtures.passwords) expect(expert + tech).not.toContain(JSON.stringify(f.columns));
});
}
if (game.engine === 'sync') {
it('keeps a typed word and a ranking private until both are in', () => {
const { room } = started(game);
expect(json(room, 'B')).not.toContain('Lighthouse');
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerAnswered: true, reveal: null });
});
}
if (game.engine === 'labyrinth') {
it("never sends the Explorer the map or where they are, or the Navigator the Explorer's position", () => {
const { room } = started(game);
const s = room.engine as LabyrinthState;
const explorer = JSON.parse(json(room, 'B')).views.B;
for (const key of ['grid', 'glyphs', 'lanterns', 'oneWay', 'x', 'y', 'facing']) expect(keysOf(explorer)).not.toContain(key);
// With no compass, not even a compass letter.
if (game.explorer.compass === 'relative') expect(JSON.stringify(explorer)).not.toMatch(/"[nesw]"/);
const navigator = JSON.parse(json(room, 'A')).views.A;
const { glyphs: _g, lanterns: _l, oneWay: _o, grid, ...rest } = navigator;
expect(keysOf(rest)).not.toContain('x');
if (game.navigatorView.mapTransform === 'mirror-x') {
// Only the mirrored map is ever served.
expect(grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
expect(grid).not.toEqual(game.grid);
} else {
expect(grid).toEqual(game.grid);
}
expect(s.x).toBeGreaterThan(0);
});
}
});
describe('story: Evidence Locker pins', () => {
const game = getGame('kalahari-expedition', 'story');
it('stars your own clues; your partner only sees how many', () => {
const room = playingRoom(game);
const t = clock(room);
const [mine] = game.chapters[0].clues.A;
const [theirs] = game.chapters[0].clues.B;
expect(togglePin(game, t.ctx(), 'A', theirs.id).ok).toBe(false);
expect(togglePin(game, t.ctx(), 'A', game.chapters[1].clues.A[0].id).ok).toBe(false); // not dealt yet
expect(togglePin(game, t.ctx(), 'A', mine.id).ok).toBe(true);
expect(stateOf(room, 'A').views.A).toMatchObject({ pins: [mine.id], partnerPins: 0 });
const b = stateOf(room, 'B').views.B;
expect(b).toMatchObject({ pins: [], partnerPins: 1 });
expect(JSON.stringify(b)).not.toContain(mine.id);
togglePin(game, t.ctx(), 'A', mine.id);
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerPins: 0 });
});
});
describe('story: one-shot, four-question accusations', () => {
const game = getGame('masquerade-montague', 'story');
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
const finale = game.finale;
function atFinale() {
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
continueChapter(game, t.ctx());
}
return { room, t };
}
it('has four questions and a single attempt', () => {
expect(finale.questions).toHaveLength(4);
expect(finale.maxAttempts).toBe(1);
});
it('a wrong accusation goes straight to the fail ending and the reveal', () => {
const { room, t } = atFinale();
const wrong = Object.fromEntries(finale.questions.map((q) => [q.id, q.options.find((o) => o.id !== q.answer)!.id]));
proposeAccusation(game, t.ctx(), 'A', wrong);
agreeAccusation(game, t.ctx(), 'B');
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: false });
expect(room.phase).toBe('finished');
expect(room.result).toMatchObject({ success: false, ending: finale.failEnding, reveal: finale.reveal });
});
it('the right one wins', () => {
const { room, t } = atFinale();
proposeAccusation(game, t.ctx(), 'B', Object.fromEntries(finale.questions.map((q) => [q.id, q.answer])));
agreeAccusation(game, t.ctx(), 'A');
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
expect(room.result?.success).toBe(true);
});
});
describe('results', () => {
it('a time limit running out shows "Out of time", not a rank', () => {
const game = getGame('kestrel-heist', 'story');
const room = playingRoom(game);
const t = clock(room);
tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000));
expect(stateOf(room, 'A').result).toMatchObject({ success: false, outOfTime: true, rank: 'Out of time', nightmareSurvivors: false });
});
it('a difficulty-5 game with zero hints earns Nightmare Survivors', () => {
const game = getGame('kestrel-heist', 'story');
expect(game.difficulty).toBe(5);
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
continueChapter(game, t.ctx());
}
if (game.finale.type !== 'escape') throw new Error('expected escape');
submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0]);
expect(stateOf(room, 'A').result).toMatchObject({ success: true, outOfTime: false, nightmareSurvivors: true });
// Not for an easier game.
const easy = getGame('alchemists-vault', 'story');
const other = playingRoom(easy);
const t2 = clock(other);
for (const ch of easy.chapters) {
submitLock(easy, t2.ctx(), ch.lock.id, ch.lock.answers[0]);
continueChapter(easy, t2.ctx());
}
if (easy.finale.type !== 'escape') throw new Error('expected escape');
submitLock(easy, t2.ctx(), easy.finale.lock.id, easy.finale.lock.answers[0]);
expect(stateOf(other, 'A').result).toMatchObject({ success: true, nightmareSurvivors: false });
});
});
describe('sync: telepathy input', () => {
it(`rejects empty answers and anything over ${TELEPATHY_MAX_LENGTH} characters`, () => {
const game = getGame('in-sync-deep-cuts', 'sync');
const room = playingRoom(game);
const t = clock(room);
continueSync(game, t.ctx());
const q = (room.engine as SyncState).questions[0];
expect(answerSync(game, t.ctx(), 'A', q.id, '').ok).toBe(false);
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH + 1)).ok).toBe(false);
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH)).ok).toBe(true);
});
});
describe('content validation additions', () => {
const clone = <T>(game: T): T => structuredClone(game);
it('rejects a rank question without exactly 4 items', () => {
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
game.rank[0].items = game.rank[0].items.slice(0, 3);
expect(validateGame(game).join('\n')).toMatch(/exactly 4 items/);
});
it("rejects Overtime tables that don't match the solver's", () => {
const game = clone(getGame('tick-tock-overtime', 'defuse'));
game.modules.passwords!.words[0] = 'ZEBRA';
game.modules.morse!.words.HUG = '3.506';
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/password word list/);
expect(errors).toMatch(/morse word "HUG" should be on 3.505/);
});
it('rejects a password fixture column without 6 letters', () => {
const game = clone(getGame('tick-tock-overtime', 'defuse'));
game.fixtures.passwords[0].columns[0] = 'XAPUD';
expect(validateGame(game).join('\n')).toMatch(/exactly 6 letters/);
});
it('rejects a glyph on a trap', () => {
const game = clone(getGame('mirror-maze', 'labyrinth'));
const [trap] = game.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
game.glyphs[0] = { ...game.glyphs[0], ...trap };
expect(validateGame(game).join('\n')).toMatch(/is on a trap/);
});
});
+1 -1
View File
@@ -122,6 +122,6 @@ describe('story rules', () => {
}
}
expect(room.phase).toBe('finished');
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: false });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/shared",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
+4 -1
View File
@@ -1,4 +1,4 @@
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅'] as const;
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅', 'Out of time'] as const;
export type Rank = (typeof RANKS)[number];
export interface RankInput {
@@ -8,11 +8,14 @@ export interface RankInput {
estimatedMinutes: number;
keysEarned: number;
totalKeys: number;
/** A time limit ran out: no rank badge, just "Out of time". */
outOfTime?: boolean;
}
export function rankFor(r: RankInput): Rank {
const estimate = r.estimatedMinutes * 60;
const allKeys = r.keysEarned >= r.totalKeys;
if (r.outOfTime) return 'Out of time';
if (!r.success) return 'Love Is Patient 😅';
if (allKeys && r.hintsUsed === 0 && r.finalSeconds <= estimate) return 'Soulmate Sleuths';
if (allKeys && r.hintsUsed <= 3 && r.finalSeconds <= estimate * 1.5) return 'Dream Team';
+6
View File
@@ -134,6 +134,10 @@ export interface StoryView {
totalChapters: number;
awaitingContinue: boolean;
finale: AccusationView | EscapeFinaleView | null;
/** Clue ids you starred. */
pins: string[];
/** How many clues your partner starred (never which). */
partnerPins: number;
}
// ---------- Defuse engine ----------
@@ -327,6 +331,8 @@ export interface GameResult {
keysEarned: number;
totalKeys: number;
rank: string;
outOfTime: boolean;
nightmareSurvivors: boolean;
ending: string | null;
reveal: string | null;
}