Files
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

289 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/);
});
});