2 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 4ff95e6018 V1.2.2
- Quote options and labels containing commas so they no longer split
  (In Sync: Deep Cuts d12/d15; accusation options in Blue Lantern,
  Dead Air, Kalahari Expedition and Masquerade)
- Validation: strict choice options; flag sync options that look split
- Hint sheet: timed games say hints take time off the countdown
- How to Play: hint cost covers timed games

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:20:16 +02:00
joshuaandClaude Opus 5.5 db2653c1ab V1.2.1
Point the Overtime fixtures comment at the real solver: GAMES-NEW.md Appendix A
and its TypeScript port in server/src/engines/defuse.ts, not the missing
tools/reference/overtime_solver.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:48:20 +02:00
19 changed files with 75 additions and 27 deletions
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/client",
"version": "1.2.0",
"version": "1.2.2",
"private": true,
"type": "module",
"scripts": {
+13 -3
View File
@@ -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 }) {
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
<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
your time, and you both see it.
Hints go from a gentle nudge to nearly the answer.{' '}
{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>
<ol className="space-y-2">
{lock.hints.map((hint, i) => (
+1 -1
View File
@@ -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.'],
+2 -2
View File
@@ -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?
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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] }
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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 }
+2 -2
View File
@@ -252,8 +252,8 @@ manual: |
| 2 | Yellow | Green | Blue | Red |
# ---------- Solver fixtures (MUST pass as unit tests) ----------
# Generated from the reference solver in tools/reference/overtime_solver.py, which implements
# the manual above exactly. Port that solver to TypeScript in server/src/engines/defuse.ts.
# Generated from the reference solver (GAMES-NEW.md, Appendix A), which implements the manual
# above exactly. Its TypeScript port is the `overtime` ruleset in server/src/engines/defuse.ts.
fixtures:
wires:
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "two-keys",
"version": "1.2.0",
"version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "two-keys",
"version": "1.2.0",
"version": "1.2.2",
"workspaces": [
"shared",
"server",
@@ -23,7 +23,7 @@
},
"client": {
"name": "@two-keys/client",
"version": "1.2.0",
"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.0",
"version": "1.2.2",
"dependencies": {
"@two-keys/shared": "*",
"express": "^4.21.0",
@@ -7332,7 +7332,7 @@
},
"shared": {
"name": "@two-keys/shared",
"version": "1.2.0",
"version": "1.2.2",
"devDependencies": {
"vitest": "^2.1.0"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "two-keys",
"private": true,
"version": "1.2.0",
"version": "1.2.2",
"description": "Co-op mystery games for couples",
"workspaces": [
"shared",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/server",
"version": "1.2.0",
"version": "1.2.2",
"private": true,
"type": "module",
"scripts": {
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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),
+9
View File
@@ -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) {
+24
View File
@@ -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';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/shared",
"version": "1.2.0",
"version": "1.2.2",
"private": true,
"type": "module",
"main": "./src/index.ts",
+2
View File
@@ -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;