Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff95e6018 | ||
|
|
db2653c1ab | ||
|
|
6669be11e5 |
@@ -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)
|
- `photo`: polaroid frame; body text is the photo description in italics (no image assets needed)
|
||||||
- `map` / `diagram`: monospace block, preserve whitespace, grid tables
|
- `map` / `diagram`: monospace block, preserve whitespace, grid tables
|
||||||
- `signal`: animated blinking light driven by `signal` field (see §6.3)
|
- `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
|
### Other engines
|
||||||
See each YAML for its schema. Summaries in §6.
|
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.
|
- 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.
|
- 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?").
|
- **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).
|
- **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`.
|
- **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`.
|
- For `escape` finales, it's just the last lock; on success show `successEnding`.
|
||||||
|
|
||||||
### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml`
|
### 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.
|
- `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.
|
- 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.)
|
- 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.
|
- `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, the partner predicts. Point only if **both** the top and the bottom match.
|
- `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`.
|
- A round can set its own `pointsForKey`.
|
||||||
|
|
||||||
### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml`
|
### 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:
|
Client → server:
|
||||||
`room:create {gameId}`, `room:join {code, playerToken?}`, `room:pickRole {role}`, `room:ready`,
|
`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`,
|
`finale:propose {answers}`, `finale:agree`, `finale:submit`,
|
||||||
`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId | text | order}`,
|
`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:
|
Server → client:
|
||||||
`room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`,
|
`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
|
## 8. Scoring & results
|
||||||
|
|
||||||
- `finalTime = elapsed + penaltySeconds`
|
- `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.
|
- Results page: time, hints used, keys earned, wrong guesses, plus a "Play another" carousel. Store best results per game in localStorage.
|
||||||
|
|
||||||
## 9. Design direction
|
## 9. Design direction
|
||||||
|
|
||||||
- Vibe: **date-night cozy meets noir**. Dark mode default, warm candlelit accents, per-game theme from YAML `theme`.
|
- 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.
|
- 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.
|
- **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).
|
- 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").
|
- 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,11 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
|
|||||||
|
|
||||||
`npm run validate-content` must check:
|
`npm run validate-content` must check:
|
||||||
- Every YAML parses and passes its engine's zod schema
|
- 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
|
- 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
|
- 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
|
- 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
|
- Sync: ≥15 questions per round type in use; every rank question has exactly 4 items; no option that looks split by an unquoted comma (quote any option or label containing a comma)
|
||||||
|
- Choice options (`{ id, label }`) are strict, so a stray key from an unquoted comma fails validation
|
||||||
Run it in CI and as a pre-start step.
|
Run it in CI and as a pre-start step.
|
||||||
|
|
||||||
## 11. Build phases (do in order, commit after each)
|
## 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 · 🔑🔑🔑)
|
## 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 |
|
| Ch | Lock | A has | B has | Answer |
|
||||||
|----|------|--------|
|
|----|------|-------|-------|--------|
|
||||||
| 1 | Ghost Signal | `GHOST SIGNAL` |
|
| 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 | The Last Cup | `23:06` |
|
| 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 | The Studio Door | Kyle |
|
| 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 | The Midnight Announcement | `THE SALE IS FORGED` |
|
| 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 · 🔑🔑🔑🔑)
|
## 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 |
|
| Ch | Lock | A has | B has | Answer |
|
||||||
|----|------|--------|
|
|----|------|-------|-------|--------|
|
||||||
| 1 | Who Wore What | Odette |
|
| 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 | The Raven in the Conservatory | Ivy |
|
| 2 | Who the footman saw | Footman: Raven + black cloak | Cloakroom: Raven ↔ Swan swapped at 23:00, cloaks kept | Ivy |
|
||||||
| 3 | The Missing Dagger | `11` |
|
| 3 | Dagger pot | Gardener: primes not watered | Disturbed pots 4, 9, 11, 15; spotless gloves | `11` |
|
||||||
| 4 | Julian's Confession | `MARSH WAS INNOCENT` |
|
| 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 |
|
## 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.**
|
||||||
| 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` |
|
|
||||||
|
|
||||||
**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 · 🔑🔑🔑)
|
## 12. The Alchemist's Vault (escape room · 25 min · 🔑🔑🔑)
|
||||||
Hint penalty +3:00.
|
Roles: **Apprentice** (A) / **Scribe** (B).
|
||||||
|
|
||||||
| Lock | Answer |
|
| Lock | A has | B has | Answer |
|
||||||
|------|--------|
|
|------|-------|-------|--------|
|
||||||
| The Five Flasks | `GENIUS` |
|
| Cabinet | Flasks: numbers + flame colours | Element table + rainbow order | `GENIUS` (Ge N I U S) |
|
||||||
| The Philosopher's Stone | `25` |
|
| Stone dial | Two balanced scales | Silver = 5, gold = 3 × silver | `25` |
|
||||||
| The Dotted Plaque | `80` |
|
| Dotted plaque | Braille ⠍⠑⠗⠉⠥⠗⠽ | Braille card; Mercury = Hg | `80` |
|
||||||
| Door (finale) | `234` |
|
| Vault (finale) | ☉ ☽ ♂ ♄ | Symbol → metal chart (+ element table from ch1) | `234` (79 + 47 + 26 + 82) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 13. Submerged: Deep Lab Nereid (escape room · 35 min · 🔑🔑🔑🔑)
|
## 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 |
|
| Lock | A has | B has | Answer |
|
||||||
|------|-------|-------|--------|
|
|------|-------|-------|--------|
|
||||||
| Pressure valves | Warning lights: ON ON ON off ON | Which lights each valve flips | `145` |
|
| Hatch valves | Lights L1 L2 L3 L5 on | Valve → light wiring | `145` (unique solution) |
|
||||||
| Breathing gas | 210 L at exactly 21% oxygen | Tank X 36%, Tank Y 15% | `60` |
|
| Breathing gas | 210 L at 21% | Tank X 36%, Y 15% | `60` |
|
||||||
| Find the pod | 300 m east, then 200 m south | Chart: lab at E5, 100 m squares | `H7` |
|
| Pod location | Sonar legs 300 m E, 200 m S | Chart, lab at E5, 100 m squares | `H7` |
|
||||||
| Launch word (finale) | Keypresses 555·4·3·66·55·9·222 | The rewired keypad | `SURFACE` |
|
| 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 |
|
| 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` |
|
| Staff door | Worn keys 0, 2, 6 (0 most worn) | Codes are DDMM family dates; social posts | `2006` (Mia, 20 June) |
|
||||||
| Laser hall | Column bit values 16/8/4/2/1 | Row masks 21, 12, 25, 17, 8 | `4` |
|
| Laser hall | Mask bit values per column | Row masks 21, 12, 25, 17, 8 | Column `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` |
|
| Guards | Guards 1 & 2 timings | Guard 3 timing; 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` |
|
| Vault dial | Sons' ages in Roman numerals | Atbash note "MY SONS AGES, ELDEST FIRST" | `19-12-07` |
|
||||||
| Box number | SQL query + CLIENTS table | BOXES table | `317` |
|
| Box | SQL query + CLIENTS | BOXES table | `317` |
|
||||||
| Getaway (finale) | `MUTLEETSTHODIREAEP`, "rails = floors" | 3 floors; how a rail fence works | `OLD PIER` |
|
| Getaway (finale) | Rail-fence text | Building has 3 floors (= 3 rails) | `OLD PIER` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 15. Tick Tock: Overtime (co-op defuse · 20 min · 🔑🔑🔑🔑)
|
## 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. New: 3–6 wires, indicators (LUV, HUG, BAE, XOX, DTE), passwords, morse, simon. Level 4 runs all six modules with only 2 strikes.
|
||||||
4 levels = 4 keys; the last level has all six modules and only 2 strikes. Fixtures in the YAML are the unit tests.
|
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 · 🔑🔑🔑)
|
## 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).
|
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.
|
||||||
Round 4 ranking, subject alternating A/B (top **and** bottom must match, 3/5). One replay per round.
|
|
||||||
|
|
||||||
## 17. The Drowned Catacombs (co-op navigation · 20 min · 🔑🔑🔑)
|
## 17. The Drowned Catacombs (co-op navigation · 20 min · 🔑🔑🔑)
|
||||||
3 lanterns, 5 traps (1:00 each), and three one-way flood gates:
|
13×13, 5 traps, 3 lanterns, **3 one-way flood gates**, sparse carvings.
|
||||||
(5,9) east only · (5,11) north only · (7,8) north only.
|
|
||||||
|
|
||||||
## 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
|
############# S (1,1) E (11,11)
|
||||||
(forward/left/right/back from the way they face, starting east). Two glyphs (crescent, star) appear twice.
|
#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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>Two Keys · Co-op mysteries for couples</title>
|
<title>Two Keys · Co-op mysteries for couples</title>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@two-keys/client",
|
"name": "@two-keys/client",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 (
|
return (
|
||||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:
|
|||||||
</div>
|
</div>
|
||||||
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
|
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
<Audience clue={clue} light={light} />
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Audience clue={clue} light={light} />
|
||||||
|
{onPin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPin}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
aria-label={pinned ? `Unstar ${clue.title}` : `Star ${clue.title}`}
|
||||||
|
className={`-m-1 flex h-9 w-9 items-center justify-center rounded-full font-sans text-lg not-italic transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||||
|
pinned ? 'text-amber-400' : light ? 'text-stone-500 hover:text-stone-800' : 'text-stone-500 hover:text-stone-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pinned ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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) {
|
switch (clue.type) {
|
||||||
case 'note':
|
case 'note':
|
||||||
return (
|
return (
|
||||||
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
|
<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>
|
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
case 'letter':
|
case 'letter':
|
||||||
return (
|
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">
|
<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>
|
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
|||||||
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<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>
|
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
|||||||
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Header clue={clue} light />
|
<Header {...pin} clue={clue} light />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
|||||||
case 'diagram':
|
case 'diagram':
|
||||||
return (
|
return (
|
||||||
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
|
<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>
|
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
case 'transcript':
|
case 'transcript':
|
||||||
return (
|
return (
|
||||||
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
|
<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>
|
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
case 'signal':
|
case 'signal':
|
||||||
return (
|
return (
|
||||||
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
|
<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} />}
|
{clue.signal && <SignalLamp pattern={clue.signal} />}
|
||||||
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
|
|||||||
case 'table':
|
case 'table':
|
||||||
return (
|
return (
|
||||||
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
|
<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>
|
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
|
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
|
||||||
import { getBest, saveSession } from '../lib/session';
|
import { getBest, saveSession } from '../lib/session';
|
||||||
import { request } from '../lib/socket';
|
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 { pushToast } from '../lib/toasts';
|
||||||
import { RoleBadge, Sheet } from './ui';
|
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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
|
<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 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)}
|
{difficultyKeys(game.difficulty)}
|
||||||
<span className="opacity-25 grayscale">{difficultyKeys(MAX_DIFFICULTY - game.difficulty)}</span>
|
<span className="opacity-25 grayscale">{difficultyKeys(MAX_DIFFICULTY - game.difficulty)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{game.difficulty >= MAX_DIFFICULTY && (
|
||||||
|
<span className="chip border-red-400/40 text-red-200">☠️ Nightmare</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
|
<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>
|
<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">
|
<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>{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>
|
<span className="font-semibold text-accent transition group-hover:translate-x-0.5">Play →</span>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
|
|||||||
const used = lock.hints.length;
|
const used = lock.hints.length;
|
||||||
const left = lock.hintsTotal - used;
|
const left = lock.hintsTotal - used;
|
||||||
const nextIsLast = used === lock.hintsTotal - 1;
|
const nextIsLast = used === lock.hintsTotal - 1;
|
||||||
const cost = `+${formatClock(lock.hintPenaltySeconds)}`;
|
// A countdown loses time; a stopwatch gains it.
|
||||||
|
const cost = `${lock.timeLimited ? '−' : '+'}${formatClock(lock.hintPenaltySeconds)}`;
|
||||||
|
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
@@ -30,8 +31,17 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
|
|||||||
|
|
||||||
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
|
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
|
||||||
<p className="mb-4 text-sm text-stone-400">
|
<p className="mb-4 text-sm text-stone-400">
|
||||||
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">{cost}</strong> to
|
Hints go from a gentle nudge to nearly the answer.{' '}
|
||||||
your time, and you both see it.
|
{lock.timeLimited ? (
|
||||||
|
<>
|
||||||
|
Each one takes <strong className="text-stone-200">{formatClock(lock.hintPenaltySeconds)}</strong> off the time you
|
||||||
|
have left, and you both see it.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Each one adds <strong className="text-stone-200">{cost}</strong> to your time, and you both see it.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{lock.hints.map((hint, i) => (
|
{lock.hints.map((hint, i) => (
|
||||||
|
|||||||
@@ -46,3 +46,24 @@ export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receiv
|
|||||||
</div>
|
</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>}
|
{view.message && <p className="mt-3 text-lg italic text-stone-200">{view.message}</p>}
|
||||||
<p className="mt-3 text-sm text-stone-400">
|
<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>
|
</p>
|
||||||
{gated.length > 0 && (
|
{gated.length > 0 && (
|
||||||
<p className="mt-1 text-sm text-stone-400">
|
<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">
|
<div className="mx-auto grid w-56 grid-cols-3 grid-rows-3 gap-2" role="group" aria-label="Move">
|
||||||
{controls.map((d) => {
|
{controls.map((d) => {
|
||||||
const gate = view.gates.includes(d.dir);
|
const gate = view.gates.includes(d.dir);
|
||||||
const blocked = gate ? " (gate, won't open from this side)" : ' (wall)';
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={d.dir}
|
key={d.dir}
|
||||||
className={`btn aspect-square flex-col gap-0 text-2xl ${d.area}`}
|
className={`btn aspect-square flex-col gap-0 text-2xl ${d.area}`}
|
||||||
disabled={!view.open[d.dir]}
|
disabled={!view.open[d.dir]}
|
||||||
onClick={() => send('maze:move', { dir: 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}
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
|
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
|
||||||
import { otherRole } from '@two-keys/shared';
|
import { otherRole } from '@two-keys/shared';
|
||||||
@@ -14,11 +14,16 @@ interface Props {
|
|||||||
send: Send;
|
send: Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Clues({ clues }: { clues: ClueView[] }) {
|
interface Pins {
|
||||||
|
pins: string[];
|
||||||
|
onPin: (clueId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Clues({ clues, pins, onPin }: { clues: ClueView[] } & Pins) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{clues.map((clue) => (
|
{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>
|
</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]);
|
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
|
||||||
const partnerName = state.game.roles[otherRole(role)].name;
|
const partnerName = state.game.roles[otherRole(role)].name;
|
||||||
const mine = finale.proposal?.by === role;
|
const mine = finale.proposal?.by === role;
|
||||||
|
const lastShot = finale.attemptsLeft === 1;
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
useEffect(() => setConfirming(false), [proposalKey, finale.agreed]);
|
||||||
|
|
||||||
let action;
|
let action;
|
||||||
if (matchesProposal && finale.agreed) {
|
if (matchesProposal && finale.agreed && confirming) {
|
||||||
action = (
|
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 ⚖️
|
You both agree. Make the accusation ⚖️
|
||||||
</button>
|
</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">
|
<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">
|
<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`}
|
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
|
||||||
</p>
|
</p>
|
||||||
{action}
|
{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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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 && (
|
{view.suspects.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
|
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
|
||||||
@@ -163,22 +248,25 @@ function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: Chapter
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{chapters.length === 0 && view.suspects.length === 0 && (
|
{filtering && found === 0 && <p className="text-stone-400">Nothing matches.</p>}
|
||||||
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
|
{sections
|
||||||
)}
|
.filter((s) => !filtering || s.clues.length > 0)
|
||||||
{chapters.map((ch, i) => (
|
.map((s) => (
|
||||||
<details key={ch.id} className="group" open={i === chapters.length - 1}>
|
// Filtering opens every match; otherwise the latest solved chapter starts open.
|
||||||
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
<details key={`${s.key}:${filtering}`} className="group" open={filtering || s.index === currentChapter - 1}>
|
||||||
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
||||||
Chapter {i + 1}: {ch.title}
|
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
||||||
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
|
{s.title}
|
||||||
</summary>
|
{s.chapter?.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {s.chapter.lock.keyName}</span>}
|
||||||
<div className="mt-4 space-y-4 px-1">
|
</summary>
|
||||||
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
|
<div className="mt-4 space-y-4 px-1">
|
||||||
<Clues clues={ch.clues} />
|
{!filtering && s.chapter?.lock.successText && (
|
||||||
</div>
|
<Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{s.chapter.lock.successText}</Markdown>
|
||||||
</details>
|
)}
|
||||||
))}
|
<Clues clues={s.clues} pins={pins} onPin={onPin} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,8 +276,10 @@ export function StoryGame({ view, state, role, send }: Props) {
|
|||||||
const atFinale = view.finale !== null;
|
const atFinale = view.finale !== null;
|
||||||
const chapter = atFinale ? null : view.chapters[view.currentChapter];
|
const chapter = atFinale ? null : view.chapters[view.currentChapter];
|
||||||
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
|
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
|
||||||
// Everything already solved: all chapters once the finale opens, earlier ones before that.
|
const pins = view.pins ?? [];
|
||||||
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
|
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.
|
// Tell the partner what we're looking at.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,7 +298,7 @@ export function StoryGame({ view, state, role, send }: Props) {
|
|||||||
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
|
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
@@ -226,7 +316,7 @@ export function StoryGame({ view, state, role, send }: Props) {
|
|||||||
{chapter && (
|
{chapter && (
|
||||||
<>
|
<>
|
||||||
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
|
<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 ? (
|
{chapter.lock.solved && view.awaitingContinue ? (
|
||||||
<Unlocked
|
<Unlocked
|
||||||
lock={chapter.lock}
|
lock={chapter.lock}
|
||||||
@@ -249,13 +339,20 @@ export function StoryGame({ view, state, role, send }: Props) {
|
|||||||
{view.finale?.type === 'escape' && (
|
{view.finale?.type === 'escape' && (
|
||||||
<>
|
<>
|
||||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
<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} />
|
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
|
<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>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+107
-45
@@ -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 { motion } from 'framer-motion';
|
||||||
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
|
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
|
||||||
import { otherRole } 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"
|
className="field text-lg"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
maxLength={40}
|
maxLength={30}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
spellCheck={false}
|
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 }) {
|
function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) {
|
||||||
const [order, setOrder] = useState<number[]>([]);
|
const [order, setOrder] = useState<number[]>(() => items.map((_, i) => i));
|
||||||
const toggle = (i: number) => setOrder((o) => (o.includes(i) ? o.filter((x) => x !== i) : [...o, 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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-2" role="group" aria-label="Tap in order, most to least">
|
<p className="text-center text-xs text-stone-400">Most at the top, least at the bottom.</p>
|
||||||
{items.map((item, i) => {
|
<ol className="grid gap-2 touch-none select-none" onPointerMove={onPointerMove} onPointerUp={() => setDragging(null)} onPointerCancel={() => setDragging(null)}>
|
||||||
const place = order.indexOf(i);
|
{order.map((item, place) => (
|
||||||
return (
|
<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
|
<button
|
||||||
key={i}
|
type="button"
|
||||||
onClick={() => toggle(i)}
|
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded-lg text-stone-400 active:cursor-grabbing"
|
||||||
aria-pressed={place >= 0}
|
aria-hidden
|
||||||
aria-label={place >= 0 ? `${item}, ranked number ${place + 1}. Tap to remove.` : `${item}, not ranked yet`}
|
tabIndex={-1}
|
||||||
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 ${
|
onPointerDown={(e) => {
|
||||||
place >= 0 ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
e.preventDefault();
|
||||||
}`}
|
(e.currentTarget.closest('ol') as HTMLElement | null)?.setPointerCapture(e.pointerId);
|
||||||
|
setDragging(item);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
⠿
|
||||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
|
|
||||||
place >= 0 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-500'
|
|
||||||
}`}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{place >= 0 ? place + 1 : '·'}
|
|
||||||
</span>
|
|
||||||
{item}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
<span
|
||||||
})}
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
|
||||||
</div>
|
place === 0 || place === order.length - 1 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-400'
|
||||||
<div className="flex gap-2">
|
}`}
|
||||||
<button className="btn" disabled={order.length === 0} onClick={() => setOrder([])}>
|
aria-hidden
|
||||||
Start over
|
>
|
||||||
</button>
|
{place + 1}
|
||||||
<button className="btn-primary flex-1" disabled={order.length !== items.length} onClick={() => onSubmit(order)}>
|
</span>
|
||||||
{order.length === items.length ? 'Lock in this order' : `Tap ${items.length - order.length} more`}
|
<span className="min-w-0 flex-1">{items[item]}</span>
|
||||||
</button>
|
<div className="flex shrink-0 gap-1">
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn !min-h-[40px] !px-2.5 !py-1"
|
||||||
|
disabled={place === 0}
|
||||||
|
onClick={() => move(place, place - 1)}
|
||||||
|
aria-label={`Move ${items[item]} up, now number ${place + 1}`}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn !min-h-[40px] !px-2.5 !py-1"
|
||||||
|
disabled={place === order.length - 1}
|
||||||
|
onClick={() => move(place, place + 1)}
|
||||||
|
aria-label={`Move ${items[item]} down, now number ${place + 1}`}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{announce}
|
||||||
|
</p>
|
||||||
|
<button className="btn-primary w-full" onClick={() => onSubmit(order)}>
|
||||||
|
Lock in this order
|
||||||
|
</button>
|
||||||
</div>
|
</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 === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.';
|
||||||
else if (type === 'rank') {
|
else if (type === 'rank') {
|
||||||
instruction = view.iAmSubject
|
instruction = view.iAmSubject
|
||||||
? "This one's about you. Tap them in order, most to least. Be honest!"
|
? "This one's about you. Put 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.`;
|
: `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.`;
|
} else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`;
|
||||||
|
|
||||||
/** An answer as the reveal cards show it. */
|
/** 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 === 'string') return answer;
|
||||||
if (typeof answer === 'number') return q.options[answer];
|
if (typeof answer === 'number') return q.options[answer];
|
||||||
return (
|
return (
|
||||||
<ol className="space-y-0.5 text-left text-base">
|
<ol className="w-full space-y-1 text-left text-sm sm:text-base">
|
||||||
{answer.map((item, place) => (
|
{answer.map((item, place) => {
|
||||||
<li key={item} className={place === 0 || place === answer.length - 1 ? '' : 'font-normal text-stone-400'}>
|
// #1 and the last place are the ones that score.
|
||||||
{place + 1}. {q.options[item]}
|
const scores = place === 0 || place === answer.length - 1;
|
||||||
</li>
|
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>
|
</ol>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+21
-3
@@ -101,18 +101,24 @@ body {
|
|||||||
.md th:empty {
|
.md th:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
/* Stencil grids and box-drawing keypads need tight lines so the boxes join up. */
|
||||||
.md pre {
|
.md pre {
|
||||||
overflow-x: auto;
|
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;
|
font-size: 0.9em;
|
||||||
line-height: 1.5;
|
line-height: 1.15;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
.md pre code.language-sql {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.md pre code {
|
.md pre code {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
.md code {
|
.md code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
}
|
}
|
||||||
.md .scroll-x {
|
.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);
|
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 {
|
@keyframes shake {
|
||||||
20%,
|
20%,
|
||||||
60% {
|
60% {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface BestResult {
|
|||||||
rank: string;
|
rank: string;
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
keysEarned: number;
|
keysEarned: number;
|
||||||
|
/** Ever finished this (difficulty 5) game with zero hints. Kept once earned. */
|
||||||
|
nightmareSurvivors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBest(gameId: string): BestResult | null {
|
export function getBest(gameId: string): BestResult | null {
|
||||||
@@ -53,6 +55,10 @@ export function saveBest(gameId: string, result: BestResult): boolean {
|
|||||||
!best ||
|
!best ||
|
||||||
result.keysEarned > best.keysEarned ||
|
result.keysEarned > best.keysEarned ||
|
||||||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
|
(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;
|
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 ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
|
||||||
|
|
||||||
export const MAX_DIFFICULTY = 5;
|
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));
|
export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const STEPS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TIPS = [
|
const TIPS = [
|
||||||
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes on your final time, and you both see it.'],
|
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes: added to your final time, or taken off the countdown in timed games. You both see it.'],
|
||||||
['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'],
|
['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'],
|
||||||
['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."],
|
['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."],
|
||||||
['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'],
|
['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'],
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import type { Category } from '@two-keys/shared';
|
import type { Category } from '@two-keys/shared';
|
||||||
import { GameCard } from '../components/GameCard';
|
import { GameCard } from '../components/GameCard';
|
||||||
import { SiteShell } from '../components/SiteShell';
|
import { SiteShell } from '../components/SiteShell';
|
||||||
import { useGames } from '../lib/hooks';
|
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 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() {
|
export function Library() {
|
||||||
const games = useGames();
|
const games = useGames();
|
||||||
const [filter, setFilter] = useState<Category | 'all'>('all');
|
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 (
|
return (
|
||||||
<SiteShell>
|
<SiteShell>
|
||||||
@@ -21,16 +42,20 @@ export function Library() {
|
|||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
|
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
|
||||||
{FILTERS.map((f) => (
|
{FILTERS.map((f) => (
|
||||||
<button
|
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
|
||||||
key={f}
|
|
||||||
aria-pressed={filter === f}
|
|
||||||
onClick={() => setFilter(f)}
|
|
||||||
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
|
||||||
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
|
{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>
|
</div>
|
||||||
|
|
||||||
@@ -43,6 +68,7 @@ export function Library() {
|
|||||||
{shown.map((game) => (
|
{shown.map((game) => (
|
||||||
<GameCard key={game.id} game={game} />
|
<GameCard key={game.id} game={game} />
|
||||||
))}
|
))}
|
||||||
|
{shown.length === 0 && <p className="text-stone-400">No games match those filters.</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { KeyRing } from '../components/KeyRing';
|
|||||||
import { Notepad } from '../components/Notepad';
|
import { Notepad } from '../components/Notepad';
|
||||||
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
|
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
|
||||||
import { PassDeviceScreen } from '../components/PassDeviceScreen';
|
import { PassDeviceScreen } from '../components/PassDeviceScreen';
|
||||||
import { Timer } from '../components/Timer';
|
import { Timer, WaterGauge, isWaterLevel } from '../components/Timer';
|
||||||
import { RoleBadge } from '../components/ui';
|
import { RoleBadge } from '../components/ui';
|
||||||
import { DefuseGame } from '../engines/DefuseGame';
|
import { DefuseGame } from '../engines/DefuseGame';
|
||||||
import { LabyrinthGame } from '../engines/LabyrinthGame';
|
import { LabyrinthGame } from '../engines/LabyrinthGame';
|
||||||
@@ -45,8 +45,9 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
|
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
|
||||||
<header
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
|
<Link 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,
|
rank: result.rank,
|
||||||
hintsUsed: result.hintsUsed,
|
hintsUsed: result.hintsUsed,
|
||||||
keysEarned: result.keysEarned,
|
keysEarned: result.keysEarned,
|
||||||
|
nightmareSurvivors: result.nightmareSurvivors,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [game.id, result]);
|
}, [game.id, result]);
|
||||||
@@ -55,9 +56,15 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
|
|||||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
||||||
>
|
>
|
||||||
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
{result.outOfTime ? '⌛' : result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||||
</motion.div>
|
</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>}
|
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ export default {
|
|||||||
roleA: '#f5b942',
|
roleA: '#f5b942',
|
||||||
roleB: '#2dd4bf',
|
roleB: '#2dd4bf',
|
||||||
},
|
},
|
||||||
|
// Noto Sans Symbols 2 covers the Braille and alchemy symbols (⠍ ☉ ♄ ☿) the clues use.
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
|
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
|
||||||
serif: ['Playfair Display', 'Georgia', 'serif'],
|
serif: ['Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
|
||||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
sans: ['Inter', 'system-ui', 'Noto Sans Symbols 2', 'sans-serif'],
|
||||||
hand: ['Caveat', 'cursive'],
|
hand: ['Caveat', 'Noto Sans Symbols 2', 'cursive'],
|
||||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
mono: ['JetBrains Mono', 'ui-monospace', 'Noto Sans Symbols 2', 'monospace'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ finale:
|
|||||||
- id: how
|
- id: how
|
||||||
label: How?
|
label: How?
|
||||||
options:
|
options:
|
||||||
- { id: gunsolo, label: Shot with Vincent's .38 under the drum solo, gun hidden in the piano }
|
- { id: gunsolo, label: "Shot with Vincent's .38 under the drum solo, gun hidden in the piano" }
|
||||||
- { id: alley, label: Shot from the alley through the office window }
|
- { id: alley, label: Shot from the alley through the office window }
|
||||||
- { id: poison, label: Poison in his whiskey, gunshot staged later }
|
- { id: poison, label: "Poison in his whiskey, gunshot staged later" }
|
||||||
answer: gunsolo
|
answer: gunsolo
|
||||||
- id: why
|
- id: why
|
||||||
label: Why?
|
label: Why?
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ finale:
|
|||||||
label: How?
|
label: How?
|
||||||
options:
|
options:
|
||||||
- { id: tea, label: Peanut in the tea Zanele served him at 23:24 }
|
- { id: tea, label: Peanut in the tea Zanele served him at 23:24 }
|
||||||
- { id: honey, label: Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card }
|
- { id: honey, label: "Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card" }
|
||||||
- { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day }
|
- { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day }
|
||||||
- { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers }
|
- { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers }
|
||||||
answer: honey
|
answer: honey
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ predict:
|
|||||||
- { id: d09, q: "{subject}'s happiest childhood memory involves...", options: [A holiday, A family tradition, A friend, A pet] }
|
- { id: d09, q: "{subject}'s happiest childhood memory involves...", options: [A holiday, A family tradition, A friend, A pet] }
|
||||||
- { id: d10, q: "What would {subject} want written about them one day?", options: [They were generous, They were brave, They made people laugh, They loved well] }
|
- { id: d10, q: "What would {subject} want written about them one day?", options: [They were generous, They were brave, They made people laugh, They loved well] }
|
||||||
- { id: d11, q: "What drains {subject} the fastest?", options: [Conflict, Crowds, Boredom, Being rushed] }
|
- { id: d11, q: "What drains {subject} the fastest?", options: [Conflict, Crowds, Boredom, Being rushed] }
|
||||||
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, A full, noisy home, Building something together, Peace and quiet somewhere beautiful] }
|
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, "A full, noisy home", Building something together, Peace and quiet somewhere beautiful] }
|
||||||
- { id: d13, q: "What does {subject} think is our biggest strength as a couple?", options: [We laugh a lot, We fight fair, We have the same values, We're a good team] }
|
- { id: d13, q: "What does {subject} think is our biggest strength as a couple?", options: [We laugh a lot, We fight fair, We have the same values, We're a good team] }
|
||||||
- { id: d14, q: "When {subject} is upset with me, they usually...", options: [Tell me straight away, Go quiet first, Joke about it, Wait until later to bring it up] }
|
- { id: d14, q: "When {subject} is upset with me, they usually...", options: [Tell me straight away, Go quiet first, Joke about it, Wait until later to bring it up] }
|
||||||
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, A craft (woodwork, pottery, etc.)] }
|
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, "A craft (woodwork, pottery, etc.)"] }
|
||||||
- { id: d16, q: "What does {subject} spend the most time thinking about?", options: [The future, The past, Other people, Ideas and projects] }
|
- { id: d16, q: "What does {subject} spend the most time thinking about?", options: [The future, The past, Other people, Ideas and projects] }
|
||||||
- { id: d17, q: "If {subject} wrote a book, it'd be...", options: [A thriller, A memoir, A how-to, A fantasy epic] }
|
- { id: d17, q: "If {subject} wrote a book, it'd be...", options: [A thriller, A memoir, A how-to, A fantasy epic] }
|
||||||
- { id: d18, q: "{subject}'s ideal way to celebrate a big win?", options: [Fancy dinner, Big party, Quiet night in, Buy something special] }
|
- { id: d18, q: "{subject}'s ideal way to celebrate a big win?", options: [Fancy dinner, Big party, Quiet night in, Buy something special] }
|
||||||
|
|||||||
@@ -384,15 +384,15 @@ finale:
|
|||||||
options:
|
options:
|
||||||
- { id: snake, label: An accidental puff adder bite }
|
- { id: snake, label: An accidental puff adder bite }
|
||||||
- { id: same, label: Drugged and killed by the same killer to silence him }
|
- { id: same, label: Drugged and killed by the same killer to silence him }
|
||||||
- { id: other, label: Killed by a second, different killer }
|
- { id: other, label: "Killed by a second, different killer" }
|
||||||
- { id: illness, label: Heart failure from the heat }
|
- { id: illness, label: Heart failure from the heat }
|
||||||
answer: same
|
answer: same
|
||||||
- id: how
|
- id: how
|
||||||
label: How did Marlowe die?
|
label: How did Marlowe die?
|
||||||
options:
|
options:
|
||||||
- { id: fall, label: He fell into the pit in the dark }
|
- { id: fall, label: He fell into the pit in the dark }
|
||||||
- { id: struck, label: Lured to the dig at 02:00, struck on the head, then put in the pit }
|
- { id: struck, label: "Lured to the dig at 02:00, struck on the head, then put in the pit" }
|
||||||
- { id: poison, label: Poisoned at dinner, found dead at the pit }
|
- { id: poison, label: "Poisoned at dinner, found dead at the pit" }
|
||||||
- { id: shot, label: Shot with the Captain's rifle }
|
- { id: shot, label: Shot with the Captain's rifle }
|
||||||
answer: struck
|
answer: struck
|
||||||
- id: why
|
- id: why
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ finale:
|
|||||||
- id: how
|
- id: how
|
||||||
label: How?
|
label: How?
|
||||||
options:
|
options:
|
||||||
- { id: dagger, label: Stabbed with the armoury dagger, then buried it in a dry cactus pot }
|
- { id: dagger, label: "Stabbed with the armoury dagger, then buried it in a dry cactus pot" }
|
||||||
- { id: poison, label: Poison in his champagne }
|
- { id: poison, label: Poison in his champagne }
|
||||||
- { id: lake, label: Drowned in the conservatory pond }
|
- { id: lake, label: Drowned in the conservatory pond }
|
||||||
- { id: pistol, label: Shot with a silenced pistol }
|
- { id: pistol, label: Shot with a silenced pistol }
|
||||||
|
|||||||
@@ -252,8 +252,8 @@ manual: |
|
|||||||
| 2 | Yellow | Green | Blue | Red |
|
| 2 | Yellow | Green | Blue | Red |
|
||||||
|
|
||||||
# ---------- Solver fixtures (MUST pass as unit tests) ----------
|
# ---------- Solver fixtures (MUST pass as unit tests) ----------
|
||||||
# Generated from the reference solver in tools/reference/overtime_solver.py, which implements
|
# Generated from the reference solver (GAMES-NEW.md, Appendix A), which implements the manual
|
||||||
# the manual above exactly. Port that solver to TypeScript in server/src/engines/defuse.ts.
|
# above exactly. Its TypeScript port is the `overtime` ruleset in server/src/engines/defuse.ts.
|
||||||
fixtures:
|
fixtures:
|
||||||
wires:
|
wires:
|
||||||
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
|
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "two-keys",
|
"name": "two-keys",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "two-keys",
|
"name": "two-keys",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"shared",
|
"shared",
|
||||||
"server",
|
"server",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"name": "@two-keys/client",
|
"name": "@two-keys/client",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@two-keys/shared": "*",
|
"@two-keys/shared": "*",
|
||||||
"framer-motion": "^11.11.0",
|
"framer-motion": "^11.11.0",
|
||||||
@@ -6847,7 +6847,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"name": "@two-keys/server",
|
"name": "@two-keys/server",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@two-keys/shared": "*",
|
"@two-keys/shared": "*",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
@@ -7332,7 +7332,7 @@
|
|||||||
},
|
},
|
||||||
"shared": {
|
"shared": {
|
||||||
"name": "@two-keys/shared",
|
"name": "@two-keys/shared",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "two-keys",
|
"name": "two-keys",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"description": "Co-op mystery games for couples",
|
"description": "Co-op mystery games for couples",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"shared",
|
"shared",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@two-keys/server",
|
"name": "@two-keys/server",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
toAbsolute,
|
toAbsolute,
|
||||||
type LabyrinthState,
|
type LabyrinthState,
|
||||||
} from '../engines/labyrinth';
|
} from '../engines/labyrinth';
|
||||||
import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story';
|
import { NUDGE_AFTER_WRONG, hintPenalty, timeLimitSeconds, type StoryState } from '../engines/story';
|
||||||
import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync';
|
import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync';
|
||||||
import type { Room } from '../rooms/store';
|
import type { Room } from '../rooms/store';
|
||||||
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
|
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
|
||||||
@@ -76,6 +76,7 @@ function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView {
|
|||||||
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
|
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
|
||||||
hintsTotal: lock.hints.length,
|
hintsTotal: lock.hints.length,
|
||||||
hintPenaltySeconds: hintPenalty(game),
|
hintPenaltySeconds: hintPenalty(game),
|
||||||
|
timeLimited: timeLimitSeconds(game) !== null,
|
||||||
wrongAttempts,
|
wrongAttempts,
|
||||||
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
|
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
|
||||||
successText: solved ? (lock.successText ?? null) : null,
|
successText: solved ? (lock.successText ?? null) : null,
|
||||||
@@ -133,6 +134,9 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
|
|||||||
totalChapters: total,
|
totalChapters: total,
|
||||||
awaitingContinue: s.awaitingContinue,
|
awaitingContinue: s.awaitingContinue,
|
||||||
finale,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const clueSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) });
|
// Strict: an unquoted comma in `{ id, label: a, b }` adds a stray `b` key instead of failing.
|
||||||
|
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) }).strict();
|
||||||
|
|
||||||
const lockSchema = z.object({
|
const lockSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared';
|
import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared';
|
||||||
import {
|
import {
|
||||||
|
OVERTIME_TABLES,
|
||||||
buttonSolution,
|
buttonSolution,
|
||||||
solveKeypad,
|
solveKeypad,
|
||||||
solveMorse,
|
solveMorse,
|
||||||
@@ -100,6 +101,10 @@ function validateDefuse(game: DefuseGame): string[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fixtures.passwords.forEach((f, i) => {
|
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 ?? []);
|
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'}`);
|
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 (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 (modules.simon && modules.simon.stages.min < 1) errors.push('simon needs at least one stage');
|
||||||
|
|
||||||
if (!/\d/.test(game.device.serial.alphabet)) errors.push('serial alphabet has no digits');
|
if (!/\d/.test(game.device.serial.alphabet)) errors.push('serial alphabet has no digits');
|
||||||
@@ -159,8 +177,23 @@ 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 (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`);
|
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}`);
|
||||||
|
}
|
||||||
|
// An unquoted comma in a YAML [flow, list] splits one option into several, e.g. "A craft (woodwork" + "pottery".
|
||||||
|
const lists = [...game.match, ...game.predict].map((q) => [q.id, q.options] as const);
|
||||||
|
for (const [id, options] of [...lists, ...game.rank.map((q) => [q.id, q.items] as const)]) {
|
||||||
|
for (const o of options) {
|
||||||
|
if (o.split('(').length !== o.split(')').length || /^[a-z]/.test(o)) {
|
||||||
|
errors.push(`question ${id}: option "${o}" looks split by an unquoted comma; quote it`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const r of game.rounds) {
|
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 === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`);
|
||||||
|
if (r.type === 'rank' && r.subjectPattern && r.subjectPattern.length !== game.rules.questionsPerRound) {
|
||||||
|
errors.push(`round ${r.id}: subjectPattern should name a subject for each of the ${game.rules.questionsPerRound} questions`);
|
||||||
|
}
|
||||||
if (pointsForKey(game, r) > game.rules.questionsPerRound) errors.push(`round ${r.id}: pointsForKey is more than questionsPerRound`);
|
if (pointsForKey(game, r) > game.rules.questionsPerRound) errors.push(`round ${r.id}: pointsForKey is more than questionsPerRound`);
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
@@ -184,14 +217,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`);
|
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) {
|
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) {
|
for (const g of game.oneWay) {
|
||||||
const through = DELTAS[g.dir];
|
const through = DELTAS[g.dir];
|
||||||
if (cellAt(grid, g.x, g.y) === '#' || cellAt(grid, g.x + through.x, g.y + through.y) === '#') {
|
const ends = [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`);
|
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) {
|
if (starts.length === 1) {
|
||||||
|
|||||||
@@ -171,6 +171,31 @@ export function solveKeypad(symbols: string[], columns: string[][]): string[] |
|
|||||||
return matches[0].filter((s) => symbols.includes(s));
|
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 ----------
|
// ---------- Generator ----------
|
||||||
|
|
||||||
export function mulberry32(seed: number): () => number {
|
export function mulberry32(seed: number): () => number {
|
||||||
@@ -251,8 +276,13 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
|
|||||||
solved: false,
|
solved: false,
|
||||||
};
|
};
|
||||||
} else if (id === 'keypad') {
|
} else if (id === 'keypad') {
|
||||||
const column = pick(mods.keypad.columns);
|
// Re-roll until exactly one column holds all four symbols.
|
||||||
device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false };
|
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) {
|
} else if (id === 'passwords' && mods.passwords) {
|
||||||
const { words, lettersPerColumn } = mods.passwords;
|
const { words, lettersPerColumn } = mods.passwords;
|
||||||
// Re-roll until exactly one listed word can be spelled.
|
// Re-roll until exactly one listed word can be spelled.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface StoryState {
|
|||||||
agreed: boolean;
|
agreed: boolean;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
};
|
};
|
||||||
|
/** Starred clue ids per role. The partner only ever learns how many. */
|
||||||
|
pins?: Partial<Record<Role, string[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initStory(): StoryState {
|
export function initStory(): StoryState {
|
||||||
@@ -30,6 +32,7 @@ export function initStory(): StoryState {
|
|||||||
wrong: {},
|
wrong: {},
|
||||||
lastSubmit: {},
|
lastSubmit: {},
|
||||||
finale: { proposal: null, agreed: false, attempts: 0 },
|
finale: { proposal: null, agreed: false, attempts: 0 },
|
||||||
|
pins: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ export function tickStory(game: StoryGame, ctx: Ctx): boolean {
|
|||||||
false,
|
false,
|
||||||
finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?",
|
finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?",
|
||||||
finale.type === 'accusation' ? finale.reveal : null,
|
finale.type === 'accusation' ? finale.reveal : null,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,24 @@ export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionRe
|
|||||||
return OK;
|
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 {
|
export function continueChapter(game: StoryGame, ctx: Ctx): ActionResult {
|
||||||
const s = state(ctx);
|
const s = state(ctx);
|
||||||
if (!s.awaitingContinue) return fail('not_ready', 'Solve the lock first.');
|
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 type { SyncGame, SyncQuestion } from '../content/schemas';
|
||||||
import { OK, fail, type ActionResult, type Ctx } from './types';
|
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];
|
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 {
|
function cleanAnswer(type: Round['type'], question: SyncQuestion, answer: unknown): SyncAnswer | null {
|
||||||
if (type === 'telepathy') {
|
if (type === 'telepathy') {
|
||||||
if (typeof answer !== 'string') return null;
|
if (typeof answer !== 'string') return null;
|
||||||
const text = answer.trim().slice(0, TELEPATHY_MAX_LENGTH);
|
const text = answer.trim();
|
||||||
return normalizeAnswer(text) === '' ? null : text;
|
return text.length > TELEPATHY_MAX_LENGTH || normalizeAnswer(text) === '' ? null : text;
|
||||||
}
|
}
|
||||||
if (type === 'rank') {
|
if (type === 'rank') {
|
||||||
const count = question.items?.length ?? 0;
|
const count = question.items?.length ?? 0;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface Ctx {
|
|||||||
toast(to: Role | 'all', message: string): void;
|
toast(to: Role | 'all', message: string): void;
|
||||||
addKey(name: string): void;
|
addKey(name: string): void;
|
||||||
addPenalty(seconds: number): 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 {
|
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 }),
|
toast: (to, message) => void toasts.push({ to, message }),
|
||||||
addKey: (name) => void room.keys.push(name),
|
addKey: (name) => void room.keys.push(name),
|
||||||
addPenalty: (seconds) => void (room.penaltySeconds += seconds),
|
addPenalty: (seconds) => void (room.penaltySeconds += seconds),
|
||||||
finish(success, ending = null, reveal = null) {
|
finish(success, ending = null, reveal = null, outOfTime = false) {
|
||||||
room.phase = 'finished';
|
room.phase = 'finished';
|
||||||
room.finishedAt = now;
|
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 elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
|
||||||
const finalSeconds = elapsedSeconds + room.penaltySeconds;
|
const finalSeconds = elapsedSeconds + room.penaltySeconds;
|
||||||
const keys = totalKeys(game);
|
const keys = totalKeys(game);
|
||||||
|
const outOfTime = room.result.outOfTime ?? false;
|
||||||
return {
|
return {
|
||||||
success: room.result.success,
|
success: room.result.success,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
@@ -28,7 +29,11 @@ export function computeResult(room: Room, game: Game): GameResult | null {
|
|||||||
estimatedMinutes: game.estimatedMinutes,
|
estimatedMinutes: game.estimatedMinutes,
|
||||||
keysEarned: room.keys.length,
|
keysEarned: room.keys.length,
|
||||||
totalKeys: keys,
|
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,
|
ending: room.result.ending,
|
||||||
reveal: room.result.reveal,
|
reveal: room.result.reveal,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface Room {
|
|||||||
wrongGuesses: number;
|
wrongGuesses: number;
|
||||||
/** Engine-specific state, owned by the game's engine. */
|
/** Engine-specific state, owned by the game's engine. */
|
||||||
engine: unknown;
|
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;
|
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Server, Socket } from 'socket.io';
|
import type { Server, Socket } from 'socket.io';
|
||||||
import { z } from 'zod';
|
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 type { Game } from '../content/schemas';
|
||||||
import { defuseAction, tickDefuse } from '../engines/defuse';
|
import { defuseAction, tickDefuse } from '../engines/defuse';
|
||||||
import { continueGame, initEngine } from '../engines/index';
|
import { continueGame, initEngine } from '../engines/index';
|
||||||
@@ -13,6 +22,7 @@ import {
|
|||||||
submitAccusation,
|
submitAccusation,
|
||||||
submitLock,
|
submitLock,
|
||||||
tickStory,
|
tickStory,
|
||||||
|
togglePin,
|
||||||
} from '../engines/story';
|
} from '../engines/story';
|
||||||
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
|
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
|
||||||
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
|
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
|
||||||
@@ -31,6 +41,7 @@ const schemas = {
|
|||||||
pickRole: z.object({ role }),
|
pickRole: z.object({ role }),
|
||||||
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
|
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
|
||||||
hint: z.object({ lockId: z.string(), ...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 }),
|
propose: z.object({ answers: z.record(z.string()), ...asRole }),
|
||||||
defuse: z.object({
|
defuse: z.object({
|
||||||
moduleId: z.enum(DEFUSE_MODULES),
|
moduleId: z.enum(DEFUSE_MODULES),
|
||||||
@@ -53,7 +64,12 @@ const schemas = {
|
|||||||
order: z.array(z.number().int()).max(12).optional(),
|
order: z.array(z.number().int()).max(12).optional(),
|
||||||
...asRole,
|
...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() }),
|
notepad: z.object({ text: z.string() }),
|
||||||
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
|
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
|
||||||
view: z.object({ label: z.string().max(80) }),
|
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.'),
|
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('chapter:continue', empty, 'playing', ({ game, ctx }) => continueGame(game, ctx));
|
||||||
|
|
||||||
on('finale:propose', schemas.propose, 'playing', ({ game, data, role, 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
|
requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over
|
||||||
expect(tickStory(game, t.ctx())).toBe(true);
|
expect(tickStory(game, t.ctx())).toBe(true);
|
||||||
expect(room.phase).toBe('finished');
|
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);
|
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 room = playingRoom(game);
|
||||||
const t = clock(room);
|
const t = clock(room);
|
||||||
expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true);
|
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 untimed = getGame('vineyard-hollow', 'story');
|
||||||
const calm = playingRoom(untimed);
|
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((room.engine as StoryState).chapter).toBe(game.chapters.length);
|
||||||
expect(tickStory(game, t.ctx())).toBe(false);
|
expect(tickStory(game, t.ctx())).toBe(false);
|
||||||
submitLock(game, t.ctx(), game.finale.lock.id, 'surface');
|
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,288 @@
|
|||||||
|
// 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('tells the hint sheet when hints come off a countdown', () => {
|
||||||
|
const timed = getGame('kestrel-heist', 'story');
|
||||||
|
const untimed = getGame('alchemists-vault', 'story');
|
||||||
|
expect(stateOf(playingRoom(timed), 'A').views.A).toMatchObject({ chapters: [{ lock: { timeLimited: true } }] });
|
||||||
|
expect(stateOf(playingRoom(untimed), 'A').views.A).toMatchObject({ chapters: [{ lock: { timeLimited: 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('keeps options that contain commas whole', () => {
|
||||||
|
const deep = getGame('in-sync-deep-cuts', 'sync');
|
||||||
|
expect(deep.predict.find((q) => q.id === 'd12')!.options).toContain('A full, noisy home');
|
||||||
|
expect(deep.predict.find((q) => q.id === 'd15')!.options).toEqual(['An instrument', 'A language', 'A sport', 'A craft (woodwork, pottery, etc.)']);
|
||||||
|
const masq = getGame('masquerade-montague', 'story');
|
||||||
|
if (masq.finale.type !== 'accusation') throw new Error('expected accusation');
|
||||||
|
expect(masq.finale.questions.flatMap((q) => q.options.map((o) => o.label))).toContain(
|
||||||
|
'Stabbed with the armoury dagger, then buried it in a dry cactus pot',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a sync option split by an unquoted comma', () => {
|
||||||
|
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
|
||||||
|
game.predict[0].options = ['A craft (woodwork', 'pottery', 'etc.)', 'A sport'];
|
||||||
|
expect(validateGame(game).join('\n')).toMatch(/looks split by an unquoted comma/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.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",
|
"name": "@two-keys/shared",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"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 type Rank = (typeof RANKS)[number];
|
||||||
|
|
||||||
export interface RankInput {
|
export interface RankInput {
|
||||||
@@ -8,11 +8,14 @@ export interface RankInput {
|
|||||||
estimatedMinutes: number;
|
estimatedMinutes: number;
|
||||||
keysEarned: number;
|
keysEarned: number;
|
||||||
totalKeys: number;
|
totalKeys: number;
|
||||||
|
/** A time limit ran out: no rank badge, just "Out of time". */
|
||||||
|
outOfTime?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rankFor(r: RankInput): Rank {
|
export function rankFor(r: RankInput): Rank {
|
||||||
const estimate = r.estimatedMinutes * 60;
|
const estimate = r.estimatedMinutes * 60;
|
||||||
const allKeys = r.keysEarned >= r.totalKeys;
|
const allKeys = r.keysEarned >= r.totalKeys;
|
||||||
|
if (r.outOfTime) return 'Out of time';
|
||||||
if (!r.success) return 'Love Is Patient 😅';
|
if (!r.success) return 'Love Is Patient 😅';
|
||||||
if (allKeys && r.hintsUsed === 0 && r.finalSeconds <= estimate) return 'Soulmate Sleuths';
|
if (allKeys && r.hintsUsed === 0 && r.finalSeconds <= estimate) return 'Soulmate Sleuths';
|
||||||
if (allKeys && r.hintsUsed <= 3 && r.finalSeconds <= estimate * 1.5) return 'Dream Team';
|
if (allKeys && r.hintsUsed <= 3 && r.finalSeconds <= estimate * 1.5) return 'Dream Team';
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface LockView {
|
|||||||
hintsTotal: number;
|
hintsTotal: number;
|
||||||
/** What the next hint costs. */
|
/** What the next hint costs. */
|
||||||
hintPenaltySeconds: number;
|
hintPenaltySeconds: number;
|
||||||
|
/** The game has a countdown, so a hint takes time off what's left instead of adding to a stopwatch. */
|
||||||
|
timeLimited: boolean;
|
||||||
wrongAttempts: number;
|
wrongAttempts: number;
|
||||||
nudge: boolean;
|
nudge: boolean;
|
||||||
successText: string | null;
|
successText: string | null;
|
||||||
@@ -134,6 +136,10 @@ export interface StoryView {
|
|||||||
totalChapters: number;
|
totalChapters: number;
|
||||||
awaitingContinue: boolean;
|
awaitingContinue: boolean;
|
||||||
finale: AccusationView | EscapeFinaleView | null;
|
finale: AccusationView | EscapeFinaleView | null;
|
||||||
|
/** Clue ids you starred. */
|
||||||
|
pins: string[];
|
||||||
|
/** How many clues your partner starred (never which). */
|
||||||
|
partnerPins: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Defuse engine ----------
|
// ---------- Defuse engine ----------
|
||||||
@@ -327,6 +333,8 @@ export interface GameResult {
|
|||||||
keysEarned: number;
|
keysEarned: number;
|
||||||
totalKeys: number;
|
totalKeys: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
outOfTime: boolean;
|
||||||
|
nightmareSurvivors: boolean;
|
||||||
ending: string | null;
|
ending: string | null;
|
||||||
reveal: string | null;
|
reveal: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user