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>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -9,7 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Two Keys · Co-op mysteries for couples</title>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -36,7 +36,13 @@ function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
|
||||
/** Starring a clue: yours to see; your partner only learns how many you starred. */
|
||||
export interface PinProps {
|
||||
pinned?: boolean;
|
||||
onPin?: () => void;
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono, pinned, onPin }: { clue: ClueView; light: boolean; mono?: boolean } & PinProps) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0">
|
||||
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:
|
||||
</div>
|
||||
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Audience clue={clue} light={light} />
|
||||
{onPin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? `Unstar ${clue.title}` : `Star ${clue.title}`}
|
||||
className={`-m-1 flex h-9 w-9 items-center justify-center rounded-full font-sans text-lg not-italic transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
pinned ? 'text-amber-400' : light ? 'text-stone-500 hover:text-stone-800' : 'text-stone-500 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
{pinned ? '★' : '☆'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,19 +119,19 @@ export function SignalLamp({ pattern }: { pattern: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
export function ClueCard({ clue, ...pin }: { clue: ClueView } & PinProps) {
|
||||
switch (clue.type) {
|
||||
case 'note':
|
||||
return (
|
||||
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'letter':
|
||||
return (
|
||||
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
@@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Header clue={clue} light mono />
|
||||
<Header {...pin} clue={clue} light mono />
|
||||
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Header clue={clue} light />
|
||||
<Header {...pin} clue={clue} light />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
case 'diagram':
|
||||
return (
|
||||
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'transcript':
|
||||
return (
|
||||
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
|
||||
<Header clue={clue} light={false} />
|
||||
<Header {...pin} clue={clue} light={false} />
|
||||
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'signal':
|
||||
return (
|
||||
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
{clue.signal && <SignalLamp pattern={clue.signal} />}
|
||||
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
||||
</article>
|
||||
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
case 'table':
|
||||
return (
|
||||
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Header {...pin} clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
|
||||
import { getBest, saveSession } from '../lib/session';
|
||||
import { request } from '../lib/socket';
|
||||
import { CATEGORY_LABEL, 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,19 +248,22 @@ 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}>
|
||||
{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>
|
||||
Chapter {i + 1}: {ch.title}
|
||||
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</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">
|
||||
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
|
||||
<Clues clues={ch.clues} />
|
||||
{!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>
|
||||
))}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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'
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-xs text-stone-400">Most at the top, least at the bottom.</p>
|
||||
<ol className="grid gap-2 touch-none select-none" onPointerMove={onPointerMove} onPointerUp={() => setDragging(null)} onPointerCancel={() => setDragging(null)}>
|
||||
{order.map((item, place) => (
|
||||
<li
|
||||
key={item}
|
||||
ref={(el) => (rows.current[place] = el)}
|
||||
className={`flex min-h-[56px] items-center gap-2 rounded-xl border px-2 py-2 text-lg font-medium transition-colors ${
|
||||
dragging === item ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded-lg text-stone-400 active:cursor-grabbing"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest('ol') as HTMLElement | null)?.setPointerCapture(e.pointerId);
|
||||
setDragging(item);
|
||||
}}
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
|
||||
place >= 0 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-500'
|
||||
place === 0 || place === order.length - 1 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-400'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{place >= 0 ? place + 1 : '·'}
|
||||
{place + 1}
|
||||
</span>
|
||||
{item}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
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'}>
|
||||
<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
@@ -101,18 +101,24 @@ body {
|
||||
.md th:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Stencil grids and box-drawing keypads need tight lines so the boxes join up. */
|
||||
.md pre {
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
max-width: 100%;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
line-height: 1.15;
|
||||
white-space: pre;
|
||||
}
|
||||
.md pre code.language-sql {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md pre code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.md code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.md .scroll-x {
|
||||
@@ -131,6 +137,18 @@ body {
|
||||
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
|
||||
}
|
||||
|
||||
/* ---------- Water gauge (Deep Lab Nereid) ---------- */
|
||||
.water-wave {
|
||||
background: radial-gradient(ellipse 12px 6px at 12px 6px, rgb(56 189 248 / 0.55) 98%, transparent) repeat-x;
|
||||
background-size: 24px 12px;
|
||||
animation: wave 2.4s linear infinite;
|
||||
}
|
||||
@keyframes wave {
|
||||
to {
|
||||
background-position-x: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
20%,
|
||||
60% {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface BestResult {
|
||||
rank: string;
|
||||
hintsUsed: number;
|
||||
keysEarned: number;
|
||||
/** Ever finished this (difficulty 5) game with zero hints. Kept once earned. */
|
||||
nightmareSurvivors?: boolean;
|
||||
}
|
||||
|
||||
export function getBest(gameId: string): BestResult | null {
|
||||
@@ -53,6 +55,10 @@ export function saveBest(gameId: string, result: BestResult): boolean {
|
||||
!best ||
|
||||
result.keysEarned > best.keysEarned ||
|
||||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
|
||||
const nightmareSurvivors = Boolean(result.nightmareSurvivors || best?.nightmareSurvivors);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify({ ...result, nightmareSurvivors }));
|
||||
else if (nightmareSurvivors && !best?.nightmareSurvivors) {
|
||||
write(`twokeys:best:${gameId}`, JSON.stringify({ ...best, nightmareSurvivors }));
|
||||
}
|
||||
return better;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { KeyRing } from '../components/KeyRing';
|
||||
import { Notepad } from '../components/Notepad';
|
||||
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
|
||||
import { PassDeviceScreen } from '../components/PassDeviceScreen';
|
||||
import { Timer } from '../components/Timer';
|
||||
import { Timer, WaterGauge, isWaterLevel } from '../components/Timer';
|
||||
import { RoleBadge } from '../components/ui';
|
||||
import { DefuseGame } from '../engines/DefuseGame';
|
||||
import { LabyrinthGame } from '../engines/LabyrinthGame';
|
||||
@@ -45,8 +45,9 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
|
||||
<header
|
||||
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
|
||||
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} isolate z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
|
||||
>
|
||||
{isWaterLevel(state.timer) && <WaterGauge timer={state.timer} receivedAt={receivedAt} />}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
|
||||
🔑🔑
|
||||
|
||||
@@ -38,6 +38,7 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
||||
rank: result.rank,
|
||||
hintsUsed: result.hintsUsed,
|
||||
keysEarned: result.keysEarned,
|
||||
nightmareSurvivors: result.nightmareSurvivors,
|
||||
}),
|
||||
);
|
||||
}, [game.id, result]);
|
||||
@@ -55,9 +56,15 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
||||
>
|
||||
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
{result.outOfTime ? '⌛' : result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
</motion.div>
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
|
||||
{/* Out of time gets no rank badge. */}
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.outOfTime ? 'Out of time' : result.rank}</h1>
|
||||
{result.nightmareSurvivors && (
|
||||
<p className="mx-auto mt-3 w-fit rounded-full bg-red-500/15 px-4 py-1.5 font-semibold text-red-200">
|
||||
☠️ Nightmare Survivors: a difficulty-5 game with zero hints
|
||||
</p>
|
||||
)}
|
||||
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ export default {
|
||||
roleA: '#f5b942',
|
||||
roleB: '#2dd4bf',
|
||||
},
|
||||
// Noto Sans Symbols 2 covers the Braille and alchemy symbols (⠍ ☉ ♄ ☿) the clues use.
|
||||
fontFamily: {
|
||||
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
|
||||
serif: ['Playfair Display', 'Georgia', 'serif'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
hand: ['Caveat', 'cursive'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
|
||||
serif: ['Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
|
||||
sans: ['Inter', 'system-ui', 'Noto Sans Symbols 2', 'sans-serif'],
|
||||
hand: ['Caveat', 'Noto Sans Symbols 2', 'cursive'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'Noto Sans Symbols 2', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -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"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface Room {
|
||||
wrongGuesses: number;
|
||||
/** Engine-specific state, owned by the game's engine. */
|
||||
engine: unknown;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null } | null;
|
||||
result: { success: boolean; ending: string | null; reveal: string | null; outOfTime?: boolean } | null;
|
||||
}
|
||||
|
||||
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -1,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 }) =>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// GAMES-NEW.md requirements for games 9–18 that the other suites don't already cover:
|
||||
// sanitized payloads for every new game, Evidence Locker pins, "Out of time" and
|
||||
// Nightmare Survivors results, one-shot accusations, and the extra content checks.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import type { Game } from '../src/content/schemas';
|
||||
import { validateGame } from '../src/content/validate';
|
||||
import { continueDefuse, type DefuseState } from '../src/engines/defuse';
|
||||
import type { LabyrinthState } from '../src/engines/labyrinth';
|
||||
import {
|
||||
agreeAccusation,
|
||||
continueChapter,
|
||||
proposeAccusation,
|
||||
submitAccusation,
|
||||
submitLock,
|
||||
tickStory,
|
||||
togglePin,
|
||||
} from '../src/engines/story';
|
||||
import { TELEPATHY_MAX_LENGTH, answerSync, continueSync, type SyncState } from '../src/engines/sync';
|
||||
import { buildState } from '../src/rooms/state';
|
||||
import type { Room } from '../src/rooms/store';
|
||||
import { clock, games, getGame, playingRoom } from './helpers';
|
||||
|
||||
const NEW_GAMES = [
|
||||
'dead-air',
|
||||
'masquerade-montague',
|
||||
'kalahari-expedition',
|
||||
'alchemists-vault',
|
||||
'deep-lab-nereid',
|
||||
'kestrel-heist',
|
||||
'tick-tock-overtime',
|
||||
'in-sync-deep-cuts',
|
||||
'drowned-catacombs',
|
||||
'mirror-maze',
|
||||
];
|
||||
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'seed', 'sequence', 'facing', 'word'];
|
||||
|
||||
const stateOf = (room: Room, role: Role, now = 2_000_000) => {
|
||||
const player = room.players.find((p) => p.seat === role)!;
|
||||
return buildState(room, player, games.get(room.gameId)!, () => true, now);
|
||||
};
|
||||
const json = (room: Room, role: Role, now?: number) => JSON.stringify(stateOf(room, role, now));
|
||||
|
||||
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (!Array.isArray(value)) out.add(k);
|
||||
keysOf(v, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Moves each new game a little way in, so there is real state to leak. */
|
||||
function started(game: Game) {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
if (game.engine === 'defuse') {
|
||||
(room.engine as DefuseState).level = game.levels.length - 1; // every module at once
|
||||
continueDefuse(game, t.ctx());
|
||||
}
|
||||
if (game.engine === 'sync') {
|
||||
continueSync(game, t.ctx());
|
||||
const q = (room.engine as SyncState).questions[0];
|
||||
answerSync(game, t.ctx(), 'A', q.id, 'Lighthouse');
|
||||
}
|
||||
return { room, t };
|
||||
}
|
||||
|
||||
describe.each(NEW_GAMES.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
|
||||
it('never carry answers, fixtures or solver state for either role', () => {
|
||||
expect(game).toBeDefined();
|
||||
const { room, t } = started(game);
|
||||
for (const role of ['A', 'B'] as const) {
|
||||
const keys = keysOf(JSON.parse(json(room, role, t.now)));
|
||||
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (game.engine === 'defuse') {
|
||||
it('keeps the device from the Expert and the manual from the Technician', () => {
|
||||
const { room, t } = started(game);
|
||||
const tech = json(room, 'A', t.now);
|
||||
const expert = json(room, 'B', t.now);
|
||||
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
|
||||
for (const key of ['device', 'indicators', 'serial', 'modules']) expect(keysOf(JSON.parse(expert))).not.toContain(key);
|
||||
// Fixture answers don't ride along in any form.
|
||||
for (const f of game.fixtures.passwords) expect(expert + tech).not.toContain(JSON.stringify(f.columns));
|
||||
});
|
||||
}
|
||||
|
||||
if (game.engine === 'sync') {
|
||||
it('keeps a typed word and a ranking private until both are in', () => {
|
||||
const { room } = started(game);
|
||||
expect(json(room, 'B')).not.toContain('Lighthouse');
|
||||
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerAnswered: true, reveal: null });
|
||||
});
|
||||
}
|
||||
|
||||
if (game.engine === 'labyrinth') {
|
||||
it("never sends the Explorer the map or where they are, or the Navigator the Explorer's position", () => {
|
||||
const { room } = started(game);
|
||||
const s = room.engine as LabyrinthState;
|
||||
const explorer = JSON.parse(json(room, 'B')).views.B;
|
||||
for (const key of ['grid', 'glyphs', 'lanterns', 'oneWay', 'x', 'y', 'facing']) expect(keysOf(explorer)).not.toContain(key);
|
||||
// With no compass, not even a compass letter.
|
||||
if (game.explorer.compass === 'relative') expect(JSON.stringify(explorer)).not.toMatch(/"[nesw]"/);
|
||||
const navigator = JSON.parse(json(room, 'A')).views.A;
|
||||
const { glyphs: _g, lanterns: _l, oneWay: _o, grid, ...rest } = navigator;
|
||||
expect(keysOf(rest)).not.toContain('x');
|
||||
if (game.navigatorView.mapTransform === 'mirror-x') {
|
||||
// Only the mirrored map is ever served.
|
||||
expect(grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
|
||||
expect(grid).not.toEqual(game.grid);
|
||||
} else {
|
||||
expect(grid).toEqual(game.grid);
|
||||
}
|
||||
expect(s.x).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('story: Evidence Locker pins', () => {
|
||||
const game = getGame('kalahari-expedition', 'story');
|
||||
|
||||
it('stars your own clues; your partner only sees how many', () => {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
const [mine] = game.chapters[0].clues.A;
|
||||
const [theirs] = game.chapters[0].clues.B;
|
||||
|
||||
expect(togglePin(game, t.ctx(), 'A', theirs.id).ok).toBe(false);
|
||||
expect(togglePin(game, t.ctx(), 'A', game.chapters[1].clues.A[0].id).ok).toBe(false); // not dealt yet
|
||||
expect(togglePin(game, t.ctx(), 'A', mine.id).ok).toBe(true);
|
||||
|
||||
expect(stateOf(room, 'A').views.A).toMatchObject({ pins: [mine.id], partnerPins: 0 });
|
||||
const b = stateOf(room, 'B').views.B;
|
||||
expect(b).toMatchObject({ pins: [], partnerPins: 1 });
|
||||
expect(JSON.stringify(b)).not.toContain(mine.id);
|
||||
|
||||
togglePin(game, t.ctx(), 'A', mine.id);
|
||||
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerPins: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('story: one-shot, four-question accusations', () => {
|
||||
const game = getGame('masquerade-montague', 'story');
|
||||
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||
const finale = game.finale;
|
||||
|
||||
function atFinale() {
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
continueChapter(game, t.ctx());
|
||||
}
|
||||
return { room, t };
|
||||
}
|
||||
|
||||
it('has four questions and a single attempt', () => {
|
||||
expect(finale.questions).toHaveLength(4);
|
||||
expect(finale.maxAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('a wrong accusation goes straight to the fail ending and the reveal', () => {
|
||||
const { room, t } = atFinale();
|
||||
const wrong = Object.fromEntries(finale.questions.map((q) => [q.id, q.options.find((o) => o.id !== q.answer)!.id]));
|
||||
proposeAccusation(game, t.ctx(), 'A', wrong);
|
||||
agreeAccusation(game, t.ctx(), 'B');
|
||||
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: false });
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toMatchObject({ success: false, ending: finale.failEnding, reveal: finale.reveal });
|
||||
});
|
||||
|
||||
it('the right one wins', () => {
|
||||
const { room, t } = atFinale();
|
||||
proposeAccusation(game, t.ctx(), 'B', Object.fromEntries(finale.questions.map((q) => [q.id, q.answer])));
|
||||
agreeAccusation(game, t.ctx(), 'A');
|
||||
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
|
||||
expect(room.result?.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('results', () => {
|
||||
it('a time limit running out shows "Out of time", not a rank', () => {
|
||||
const game = getGame('kestrel-heist', 'story');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000));
|
||||
expect(stateOf(room, 'A').result).toMatchObject({ success: false, outOfTime: true, rank: 'Out of time', nightmareSurvivors: false });
|
||||
});
|
||||
|
||||
it('a difficulty-5 game with zero hints earns Nightmare Survivors', () => {
|
||||
const game = getGame('kestrel-heist', 'story');
|
||||
expect(game.difficulty).toBe(5);
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
for (const ch of game.chapters) {
|
||||
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
continueChapter(game, t.ctx());
|
||||
}
|
||||
if (game.finale.type !== 'escape') throw new Error('expected escape');
|
||||
submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0]);
|
||||
expect(stateOf(room, 'A').result).toMatchObject({ success: true, outOfTime: false, nightmareSurvivors: true });
|
||||
|
||||
// Not for an easier game.
|
||||
const easy = getGame('alchemists-vault', 'story');
|
||||
const other = playingRoom(easy);
|
||||
const t2 = clock(other);
|
||||
for (const ch of easy.chapters) {
|
||||
submitLock(easy, t2.ctx(), ch.lock.id, ch.lock.answers[0]);
|
||||
continueChapter(easy, t2.ctx());
|
||||
}
|
||||
if (easy.finale.type !== 'escape') throw new Error('expected escape');
|
||||
submitLock(easy, t2.ctx(), easy.finale.lock.id, easy.finale.lock.answers[0]);
|
||||
expect(stateOf(other, 'A').result).toMatchObject({ success: true, nightmareSurvivors: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync: telepathy input', () => {
|
||||
it(`rejects empty answers and anything over ${TELEPATHY_MAX_LENGTH} characters`, () => {
|
||||
const game = getGame('in-sync-deep-cuts', 'sync');
|
||||
const room = playingRoom(game);
|
||||
const t = clock(room);
|
||||
continueSync(game, t.ctx());
|
||||
const q = (room.engine as SyncState).questions[0];
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, '').ok).toBe(false);
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH + 1)).ok).toBe(false);
|
||||
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content validation additions', () => {
|
||||
const clone = <T>(game: T): T => structuredClone(game);
|
||||
|
||||
it('rejects a rank question without exactly 4 items', () => {
|
||||
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
|
||||
game.rank[0].items = game.rank[0].items.slice(0, 3);
|
||||
expect(validateGame(game).join('\n')).toMatch(/exactly 4 items/);
|
||||
});
|
||||
|
||||
it("rejects Overtime tables that don't match the solver's", () => {
|
||||
const game = clone(getGame('tick-tock-overtime', 'defuse'));
|
||||
game.modules.passwords!.words[0] = 'ZEBRA';
|
||||
game.modules.morse!.words.HUG = '3.506';
|
||||
const errors = validateGame(game).join('\n');
|
||||
expect(errors).toMatch(/password word list/);
|
||||
expect(errors).toMatch(/morse word "HUG" should be on 3.505/);
|
||||
});
|
||||
|
||||
it('rejects a password fixture column without 6 letters', () => {
|
||||
const game = clone(getGame('tick-tock-overtime', 'defuse'));
|
||||
game.fixtures.passwords[0].columns[0] = 'XAPUD';
|
||||
expect(validateGame(game).join('\n')).toMatch(/exactly 6 letters/);
|
||||
});
|
||||
|
||||
it('rejects a glyph on a trap', () => {
|
||||
const game = clone(getGame('mirror-maze', 'labyrinth'));
|
||||
const [trap] = game.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
|
||||
game.glyphs[0] = { ...game.glyphs[0], ...trap };
|
||||
expect(validateGame(game).join('\n')).toMatch(/is on a trap/);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,6 @@ describe('story rules', () => {
|
||||
}
|
||||
}
|
||||
expect(room.phase).toBe('finished');
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
|
||||
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: false });
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user