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>
This commit is contained in:
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Generated
+5
-5
@@ -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"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/server",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/shared",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.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;
|
||||
|
||||
Reference in New Issue
Block a user