diff --git a/CLAUDE.md b/CLAUDE.md index 229637b..ff4e4e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -255,7 +255,8 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e - Story: every chapter has ≥1 clue for A **and** ≥1 for B; every lock has ≥1 answer and exactly 3 hints; finale answers exist in options; clue ids are unique; every signal clue has a `signal`; escape finales have A and B clues - Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined; for `overtime`, the keypad columns, password words and Morse frequencies match the solver's tables (`OVERTIME_TABLES`) and every password fixture column has exactly `lettersPerColumn` letters - Labyrinth: grid rows equal length; exactly one S and E; exactly `lanternsRequired` K; every K and E reachable from S **without stepping on a trap** (BFS, respecting one-way gates); no cell where a gate leaves the Explorer stuck for good; glyphs and gates never on walls or traps; every `lanterns` entry sits on a K -- Sync: ≥15 questions per round type in use; every rank question has exactly 4 items +- 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. ## 11. Build phases (do in order, commit after each) diff --git a/client/package.json b/client/package.json index c89bf85..debda79 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/client", - "version": "1.2.1", + "version": "1.2.2", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/HintButton.tsx b/client/src/components/HintButton.tsx index 4b2db0e..7876386 100644 --- a/client/src/components/HintButton.tsx +++ b/client/src/components/HintButton.tsx @@ -10,7 +10,8 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) { const used = lock.hints.length; const left = lock.hintsTotal - used; 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 = () => { setConfirming(false); @@ -30,8 +31,17 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) { { setOpen(false); setConfirming(false); }} title="Hints">

- Hints go from a gentle nudge to nearly the answer. Each one adds {cost} to - your time, and you both see it. + Hints go from a gentle nudge to nearly the answer.{' '} + {lock.timeLimited ? ( + <> + Each one takes {formatClock(lock.hintPenaltySeconds)} off the time you + have left, and you both see it. + + ) : ( + <> + Each one adds {cost} to your time, and you both see it. + + )}

    {lock.hints.map((hint, i) => ( diff --git a/client/src/pages/HowToPlay.tsx b/client/src/pages/HowToPlay.tsx index a882892..924f73f 100644 --- a/client/src/pages/HowToPlay.tsx +++ b/client/src/pages/HowToPlay.tsx @@ -25,7 +25,7 @@ const STEPS = [ ]; 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.'], ['👋 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.'], diff --git a/content/games/blue-lantern.yaml b/content/games/blue-lantern.yaml index 36bcbef..ecf1c42 100644 --- a/content/games/blue-lantern.yaml +++ b/content/games/blue-lantern.yaml @@ -232,9 +232,9 @@ finale: - id: how label: How? 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: poison, label: Poison in his whiskey, gunshot staged later } + - { id: poison, label: "Poison in his whiskey, gunshot staged later" } answer: gunsolo - id: why label: Why? diff --git a/content/games/dead-air.yaml b/content/games/dead-air.yaml index 77d0364..6a72eb1 100644 --- a/content/games/dead-air.yaml +++ b/content/games/dead-air.yaml @@ -311,7 +311,7 @@ finale: label: How? options: - { 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: lozenge, label: A peanut-laced throat lozenge from a fan's flowers } answer: honey diff --git a/content/games/in-sync-deep-cuts.yaml b/content/games/in-sync-deep-cuts.yaml index 51fe310..f7839fe 100644 --- a/content/games/in-sync-deep-cuts.yaml +++ b/content/games/in-sync-deep-cuts.yaml @@ -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: 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: 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: 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: 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] } diff --git a/content/games/kalahari-expedition.yaml b/content/games/kalahari-expedition.yaml index 3e62710..b8c4fbc 100644 --- a/content/games/kalahari-expedition.yaml +++ b/content/games/kalahari-expedition.yaml @@ -384,15 +384,15 @@ finale: options: - { id: snake, label: An accidental puff adder bite } - { 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 } answer: same - id: how label: How did Marlowe die? options: - { 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: poison, label: Poisoned at dinner, found dead at 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: shot, label: Shot with the Captain's rifle } answer: struck - id: why diff --git a/content/games/masquerade-montague.yaml b/content/games/masquerade-montague.yaml index 162180b..f45b4d5 100644 --- a/content/games/masquerade-montague.yaml +++ b/content/games/masquerade-montague.yaml @@ -281,7 +281,7 @@ finale: - id: how label: How? 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: lake, label: Drowned in the conservatory pond } - { id: pistol, label: Shot with a silenced pistol } diff --git a/package-lock.json b/package-lock.json index ebf64a6..b4b02dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "two-keys", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "two-keys", - "version": "1.2.1", + "version": "1.2.2", "workspaces": [ "shared", "server", @@ -23,7 +23,7 @@ }, "client": { "name": "@two-keys/client", - "version": "1.2.1", + "version": "1.2.2", "dependencies": { "@two-keys/shared": "*", "framer-motion": "^11.11.0", @@ -6847,7 +6847,7 @@ }, "server": { "name": "@two-keys/server", - "version": "1.2.1", + "version": "1.2.2", "dependencies": { "@two-keys/shared": "*", "express": "^4.21.0", @@ -7332,7 +7332,7 @@ }, "shared": { "name": "@two-keys/shared", - "version": "1.2.1", + "version": "1.2.2", "devDependencies": { "vitest": "^2.1.0" } diff --git a/package.json b/package.json index 9b35332..49c0efb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "two-keys", "private": true, - "version": "1.2.1", + "version": "1.2.2", "description": "Co-op mystery games for couples", "workspaces": [ "shared", diff --git a/server/package.json b/server/package.json index ec3c8d4..f841233 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/server", - "version": "1.2.1", + "version": "1.2.2", "private": true, "type": "module", "scripts": { diff --git a/server/src/content/sanitize.ts b/server/src/content/sanitize.ts index 2a5c99c..f99a9c1 100644 --- a/server/src/content/sanitize.ts +++ b/server/src/content/sanitize.ts @@ -30,7 +30,7 @@ import { toAbsolute, type LabyrinthState, } 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 type { Room } from '../rooms/store'; 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), hintsTotal: lock.hints.length, hintPenaltySeconds: hintPenalty(game), + timeLimited: timeLimitSeconds(game) !== null, wrongAttempts, nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG, successText: solved ? (lock.successText ?? null) : null, diff --git a/server/src/content/schemas.ts b/server/src/content/schemas.ts index ddb1080..d5cdca3 100644 --- a/server/src/content/schemas.ts +++ b/server/src/content/schemas.ts @@ -30,7 +30,8 @@ const clueSchema = z.object({ .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({ id: z.string().min(1), diff --git a/server/src/content/validate.ts b/server/src/content/validate.ts index 382027e..9c0cd41 100644 --- a/server/src/content/validate.ts +++ b/server/src/content/validate.ts @@ -180,6 +180,15 @@ function validateSync(game: SyncGame): string[] { 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) { 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) { diff --git a/server/test/games-new.test.ts b/server/test/games-new.test.ts index 46e8a28..4095251 100644 --- a/server/test/games-new.test.ts +++ b/server/test/games-new.test.ts @@ -191,6 +191,13 @@ describe('results', () => { 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); @@ -240,6 +247,23 @@ describe('content validation additions', () => { 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'; diff --git a/shared/package.json b/shared/package.json index 4d680b5..19faafa 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@two-keys/shared", - "version": "1.2.1", + "version": "1.2.2", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/shared/src/types.ts b/shared/src/types.ts index 08666e2..66de0e3 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -87,6 +87,8 @@ export interface LockView { hintsTotal: number; /** What the next hint costs. */ 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; nudge: boolean; successText: string | null;