- 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>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
// 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/);
|
||
});
|
||
});
|