From 6669be11e5911a01dd360836f5c0ef52cf78253f Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Wed, 14 Oct 2026 08:44:28 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 28 +-- GAMES.md | 160 ++++++++++------ client/index.html | 2 +- client/package.json | 2 +- client/src/components/ClueCard.tsx | 43 +++-- client/src/components/GameCard.tsx | 16 +- client/src/components/Timer.tsx | 21 +++ client/src/engines/LabyrinthGame.tsx | 13 +- client/src/engines/StoryGame.tsx | 155 +++++++++++++--- client/src/engines/SyncGame.tsx | 152 ++++++++++----- client/src/index.css | 24 ++- client/src/lib/session.ts | 8 +- client/src/lib/theme.ts | 1 + client/src/pages/Library.tsx | 50 +++-- client/src/pages/Play.tsx | 5 +- client/src/pages/Results.tsx | 11 +- client/tailwind.config.js | 11 +- package-lock.json | 10 +- package.json | 2 +- server/package.json | 2 +- server/src/content/sanitize.ts | 3 + server/src/content/validate.ts | 33 +++- server/src/engines/defuse.ts | 34 +++- server/src/engines/story.ts | 22 +++ server/src/engines/sync.ts | 6 +- server/src/engines/types.ts | 6 +- server/src/rooms/state.ts | 5 + server/src/rooms/store.ts | 2 +- server/src/socket/handlers.ts | 24 ++- server/test/expansion.test.ts | 6 +- server/test/games-new.test.ts | 264 +++++++++++++++++++++++++++ server/test/story.test.ts | 2 +- shared/package.json | 2 +- shared/src/scoring.ts | 5 +- shared/src/types.ts | 6 + 35 files changed, 921 insertions(+), 215 deletions(-) create mode 100644 server/test/games-new.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 43123a0..229637b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/GAMES.md b/GAMES.md index 89876bd..03ab456 100644 --- a/GAMES.md +++ b/GAMES.md @@ -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# +############### +``` diff --git a/client/index.html b/client/index.html index f5860fb..a1279a7 100644 --- a/client/index.html +++ b/client/index.html @@ -9,7 +9,7 @@ Two Keys · Co-op mysteries for couples diff --git a/client/package.json b/client/package.json index 7eb2aaa..b1f9145 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/client", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/ClueCard.tsx b/client/src/components/ClueCard.tsx index d3919b3..b8bd59c 100644 --- a/client/src/components/ClueCard.tsx +++ b/client/src/components/ClueCard.tsx @@ -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 (
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:

{clue.title}

- +
+ + {onPin && ( + + )} +
); } @@ -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 (
-
+
{clue.body}
); case 'letter': return (
-
+
{clue.body}
); @@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) { {clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
-
+
{clue.body}
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) { {clue.body}
-
+
); @@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) { case 'diagram': return (
-
+
{clue.body}
); case 'transcript': return (
-
+
{clue.body}
); case 'signal': return (
-
+
{clue.signal && } {clue.body}
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) { case 'table': return (
-
+
{clue.body}
); diff --git a/client/src/components/GameCard.tsx b/client/src/components/GameCard.tsx index 5e79d48..e8f59bd 100644 --- a/client/src/components/GameCard.tsx +++ b/client/src/components/GameCard.tsx @@ -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
{CATEGORY_LABEL[game.category]} ⏱ {game.estimatedMinutes} min - + {difficultyKeys(game.difficulty)} {difficultyKeys(MAX_DIFFICULTY - game.difficulty)} + {game.difficulty >= MAX_DIFFICULTY && ( + ☠️ Nightmare + )}

{game.title}

{game.tagline}

+ {best?.nightmareSurvivors && ( +
+ ☠️ Nightmare Survivors +
+ )}
{best ? `🏆 Best ${formatClock(best.finalSeconds)} · ${best.rank}` : ENGINE_BLURB[game.engine]} Play → diff --git a/client/src/components/Timer.tsx b/client/src/components/Timer.tsx index ffcef91..51e8842 100644 --- a/client/src/components/Timer.tsx +++ b/client/src/components/Timer.tsx @@ -46,3 +46,24 @@ export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receiv
); } + +/** 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 ( +
+
+ {fill > 0 && fill < 100 &&
} +
+
+ ); +} diff --git a/client/src/engines/LabyrinthGame.tsx b/client/src/engines/LabyrinthGame.tsx index d49789c..942067b 100644 --- a/client/src/engines/LabyrinthGame.tsx +++ b/client/src/engines/LabyrinthGame.tsx @@ -179,7 +179,7 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: )} {view.message &&

{view.message}

}

- Open passages: {open.join(', ') || 'none'} + Open: {open.join(', ') || 'none'}

{gated.length > 0 && (

@@ -191,17 +191,22 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:

{controls.map((d) => { const gate = view.gates.includes(d.dir); - const blocked = gate ? " (gate, won't open from this side)" : ' (wall)'; + return ( ); })} diff --git a/client/src/engines/StoryGame.tsx b/client/src/engines/StoryGame.tsx index ce34e18..d857cba 100644 --- a/client/src/engines/StoryGame.tsx +++ b/client/src/engines/StoryGame.tsx @@ -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 (
{clues.map((clue) => ( - + onPin(clue.id)} /> ))}
); @@ -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 = ( - + +
+
+ ); + } else if (matchesProposal && finale.agreed) { + action = ( + ); @@ -136,7 +158,9 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol

- {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`}

{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('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) => ( + + ); + return (
+
+ setQuery(e.target.value)} + /> +
+ {chip('all', 'All')} + {chip('starred', `★ Starred (${pins.length})`)} + {chapters.map((_, i) => chip(i, `Ch ${i + 1}`))} +
+

+ {filtering ? `${found} clue${found === 1 ? '' : 's'} found · ` : ''} + Your partner has starred {view.partnerPins} of their clue{view.partnerPins === 1 ? '' : 's'}. +

+
{view.suspects.length > 0 && (

Suspects

@@ -163,22 +248,25 @@ function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: Chapter
)} - {chapters.length === 0 && view.suspects.length === 0 && ( -

Clues from earlier chapters will collect here.

- )} - {chapters.map((ch, i) => ( -
- - ▸ - Chapter {i + 1}: {ch.title} - {ch.lock.keyName && 🔑 {ch.lock.keyName}} - -
- {ch.lock.successText && {ch.lock.successText}} - -
-
- ))} + {filtering && found === 0 &&

Nothing matches.

} + {sections + .filter((s) => !filtering || s.clues.length > 0) + .map((s) => ( + // Filtering opens every match; otherwise the latest solved chapter starts open. +
+ + ▸ + {s.title} + {s.chapter?.lock.keyName && 🔑 {s.chapter.lock.keyName}} + +
+ {!filtering && s.chapter?.lock.successText && ( + {s.chapter.lock.successText} + )} + +
+
+ ))}
); } @@ -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}`}
@@ -226,7 +316,7 @@ export function StoryGame({ view, state, role, send }: Props) { {chapter && ( <> {chapter.narrative} - + {chapter.lock.solved && view.awaitingContinue ? ( {view.finale.prompt} - + )} setLockerOpen(false)} title="Evidence Locker"> - +
); diff --git a/client/src/engines/SyncGame.tsx b/client/src/engines/SyncGame.tsx index 792aead..3714802 100644 --- a/client/src/engines/SyncGame.tsx +++ b/client/src/engines/SyncGame.tsx @@ -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([]); - const toggle = (i: number) => setOrder((o) => (o.includes(i) ? o.filter((x) => x !== i) : [...o, i])); + const [order, setOrder] = useState(() => items.map((_, i) => i)); + const [dragging, setDragging] = useState(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 ( -
-
- {items.map((item, i) => { - const place = order.indexOf(i); - return ( +
+

Most at the top, least at the bottom.

+
    setDragging(null)} onPointerCancel={() => setDragging(null)}> + {order.map((item, place) => ( +
  1. (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' + }`} + > - ); - })} -
-
- - -
+ + {place + 1} + + {items[item]} +
+ + +
+ + ))} + +

+ {announce} +

+
); } @@ -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 ( -
    - {answer.map((item, place) => ( -
  1. - {place + 1}. {q.options[item]} -
  2. - ))} +
      + {answer.map((item, place) => { + // #1 and the last place are the ones that score. + const scores = place === 0 || place === answer.length - 1; + return ( +
    1. + {place + 1}. {q.options[item]} +
    2. + ); + })}
    ); }; diff --git a/client/src/index.css b/client/src/index.css index 70cc097..b21243c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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% { diff --git a/client/src/lib/session.ts b/client/src/lib/session.ts index b8554b4..83b7218 100644 --- a/client/src/lib/session.ts +++ b/client/src/lib/session.ts @@ -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; } diff --git a/client/src/lib/theme.ts b/client/src/lib/theme.ts index 29aec9e..43ad378 100644 --- a/client/src/lib/theme.ts +++ b/client/src/lib/theme.ts @@ -43,4 +43,5 @@ export const ROLE_BORDER: Record = { A: 'border-roleA/60', B: 'bor export const ROLE_BG: Record = { A: 'bg-roleA/15', B: 'bg-roleB/15' }; export const MAX_DIFFICULTY = 5; +export const DIFFICULTY_LABEL: Record = { 1: 'Easy', 2: 'Medium', 3: 'Hard', 4: 'Expert', 5: 'Nightmare' }; export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n)); diff --git a/client/src/pages/Library.tsx b/client/src/pages/Library.tsx index 7006e9e..2b1aac8 100644 --- a/client/src/pages/Library.tsx +++ b/client/src/pages/Library.tsx @@ -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 ( + + ); +} export function Library() { const games = useGames(); const [filter, setFilter] = useState('all'); - const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter); + const [level, setLevel] = useState(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 ( @@ -21,16 +42,20 @@ export function Library() {
    {FILTERS.map((f) => ( - + + ))} +
    +
    + setLevel(null)}> + Any difficulty + + {LEVELS.map((d) => ( + setLevel(level === d ? null : d)}> + {DIFFICULTY_LABEL[d]} + {d === MAX_DIFFICULTY ? ' ☠️' : ''} + ))}
    @@ -43,6 +68,7 @@ export function Library() { {shown.map((game) => ( ))} + {shown.length === 0 &&

    No games match those filters.

    }
)} diff --git a/client/src/pages/Play.tsx b/client/src/pages/Play.tsx index 34a0d3c..4d4c1ed 100644 --- a/client/src/pages/Play.tsx +++ b/client/src/pages/Play.tsx @@ -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 (
+ {isWaterLevel(state.timer) && }
🔑🔑 diff --git a/client/src/pages/Results.tsx b/client/src/pages/Results.tsx index 3d3944b..9c24522 100644 --- a/client/src/pages/Results.tsx +++ b/client/src/pages/Results.tsx @@ -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] ?? '🔑') : '🕯️'} -

{result.rank}

+ {/* Out of time gets no rank badge. */} +

{result.outOfTime ? 'Out of time' : result.rank}

+ {result.nightmareSurvivors && ( +

+ ☠️ Nightmare Survivors: a difficulty-5 game with zero hints +

+ )} {isBest &&

🏆 New personal best

}
diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 95b336e..363b2e7 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -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'], }, }, }, diff --git a/package-lock.json b/package-lock.json index 3a6d440..8f7a8a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "two-keys", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "two-keys", - "version": "1.1.0", + "version": "1.2.0", "workspaces": [ "shared", "server", @@ -23,7 +23,7 @@ }, "client": { "name": "@two-keys/client", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@two-keys/shared": "*", "framer-motion": "^11.11.0", @@ -6847,7 +6847,7 @@ }, "server": { "name": "@two-keys/server", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@two-keys/shared": "*", "express": "^4.21.0", @@ -7332,7 +7332,7 @@ }, "shared": { "name": "@two-keys/shared", - "version": "1.1.0", + "version": "1.2.0", "devDependencies": { "vitest": "^2.1.0" } diff --git a/package.json b/package.json index b367661..f69d62a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "two-keys", "private": true, - "version": "1.1.0", + "version": "1.2.0", "description": "Co-op mystery games for couples", "workspaces": [ "shared", diff --git a/server/package.json b/server/package.json index 707472b..bba2c86 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/server", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/server/src/content/sanitize.ts b/server/src/content/sanitize.ts index d27e86f..2a5c99c 100644 --- a/server/src/content/sanitize.ts +++ b/server/src/content/sanitize.ts @@ -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, }; } diff --git a/server/src/content/validate.ts b/server/src/content/validate.ts index 73d752d..382027e 100644 --- a/server/src/content/validate.ts +++ b/server/src/content/validate.ts @@ -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) { diff --git a/server/src/engines/defuse.ts b/server/src/engines/defuse.ts index a46704a..65627cc 100644 --- a/server/src/engines/defuse.ts +++ b/server/src/engines/defuse.ts @@ -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, +}; + // ---------- 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. diff --git a/server/src/engines/story.ts b/server/src/engines/story.ts index 95c3f64..23a91e0 100644 --- a/server/src/engines/story.ts +++ b/server/src/engines/story.ts @@ -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>; } 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.'); diff --git a/server/src/engines/sync.ts b/server/src/engines/sync.ts index bbd807d..6b87a4f 100644 --- a/server/src/engines/sync.ts +++ b/server/src/engines/sync.ts @@ -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; diff --git a/server/src/engines/types.ts b/server/src/engines/types.ts index 613cf56..e602289 100644 --- a/server/src/engines/types.ts +++ b/server/src/engines/types.ts @@ -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 }; }, }; } diff --git a/server/src/rooms/state.ts b/server/src/rooms/state.ts index b065505..d4780df 100644 --- a/server/src/rooms/state.ts +++ b/server/src/rooms/state.ts @@ -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, }; diff --git a/server/src/rooms/store.ts b/server/src/rooms/store.ts index 03b8833..6eea374 100644 --- a/server/src/rooms/store.ts +++ b/server/src/rooms/store.ts @@ -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; diff --git a/server/src/socket/handlers.ts b/server/src/socket/handlers.ts index a709913..339a2b7 100644 --- a/server/src/socket/handlers.ts +++ b/server/src/socket/handlers.ts @@ -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, 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 }) => diff --git a/server/test/expansion.test.ts b/server/test/expansion.test.ts index aa8311f..808ed0e 100644 --- a/server/test/expansion.test.ts +++ b/server/test/expansion.test.ts @@ -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 }); }); }); diff --git a/server/test/games-new.test.ts b/server/test/games-new.test.ts new file mode 100644 index 0000000..46e8a28 --- /dev/null +++ b/server/test/games-new.test.ts @@ -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()): Set { + 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 = (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/); + }); +}); diff --git a/server/test/story.test.ts b/server/test/story.test.ts index 3ed91f1..2ed6ae6 100644 --- a/server/test/story.test.ts +++ b/server/test/story.test.ts @@ -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 }); }); }); diff --git a/shared/package.json b/shared/package.json index 9c23dd2..6bd5aad 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/shared", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/shared/src/scoring.ts b/shared/src/scoring.ts index 0faf21f..53aa5d2 100644 --- a/shared/src/scoring.ts +++ b/shared/src/scoring.ts @@ -1,4 +1,4 @@ -export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅'] as const; +export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅', 'Out of time'] as const; export type Rank = (typeof RANKS)[number]; export interface RankInput { @@ -8,11 +8,14 @@ export interface RankInput { estimatedMinutes: number; keysEarned: number; totalKeys: number; + /** A time limit ran out: no rank badge, just "Out of time". */ + outOfTime?: boolean; } export function rankFor(r: RankInput): Rank { const estimate = r.estimatedMinutes * 60; const allKeys = r.keysEarned >= r.totalKeys; + if (r.outOfTime) return 'Out of time'; if (!r.success) return 'Love Is Patient 😅'; if (allKeys && r.hintsUsed === 0 && r.finalSeconds <= estimate) return 'Soulmate Sleuths'; if (allKeys && r.hintsUsed <= 3 && r.finalSeconds <= estimate * 1.5) return 'Dream Team'; diff --git a/shared/src/types.ts b/shared/src/types.ts index c7d032b..08666e2 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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; }