Game pack 3: 22 new games (19-40), 40 in total. - New engines: dial (Wavelength / Mind Meld), twin (Twin Rooms / Spot the Difference, incl. mirror mode) and blueprint (Describe & Draw, incl. peeks, overlap scoring and a rotated Builder canvas) - Labyrinth: items and doors (Hedge Maze) - Defuse: manualFrom, roles swapping every level behind a Ready card, and duplicate wire modules (Tick Tock: Switch) - Generic time limits for the new engines; sync/defuse successEnding - Library: engine-label chips and filters; Noto Color Emoji font - Validation: Appendix A checks ported, with a per-game summary - Quote comma/colon-split labels in five new YAMLs (wording unchanged) - Tests for all new engines and sanitized payloads of all 22 games - Docs: CLAUDE.md, GAMES.md (pack 3 spoiler guide), How to Play Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { loadAllFiles } from '../src/content/loader';
|
|
import { gameSchema } from '../src/content/schemas';
|
|
import { validateGame } from '../src/content/validate';
|
|
import { CODE_ALPHABET, makeCode } from '../src/rooms/codes';
|
|
import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
|
|
import { games, getGame, labyrinth, sync } from './helpers';
|
|
|
|
describe('content', () => {
|
|
it('loads all 40 games with no validation errors', () => {
|
|
const files = loadAllFiles();
|
|
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
|
|
expect(files).toHaveLength(40);
|
|
const engines = [...games.values()].map((g) => g.engine);
|
|
const ids = ['story', 'defuse', 'sync', 'labyrinth', 'dial', 'twin', 'blueprint'];
|
|
expect(Object.fromEntries(ids.map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
|
|
story: 21,
|
|
defuse: 3,
|
|
sync: 3,
|
|
labyrinth: 4,
|
|
dial: 3,
|
|
twin: 3,
|
|
blueprint: 3,
|
|
});
|
|
});
|
|
|
|
it('rejects a story chapter with no clue for one player', () => {
|
|
const game = structuredClone(getGame('clockmakers-attic', 'story'));
|
|
game.chapters[0].clues.B = [];
|
|
expect(validateGame(game).join('\n')).toMatch(/at least one clue for B/);
|
|
});
|
|
|
|
it('rejects a finale answer that is not an option, and a lock without 3 hints', () => {
|
|
const game = structuredClone(getGame('vineyard-hollow', 'story'));
|
|
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
|
|
game.finale.questions[0].answer = 'nobody';
|
|
expect(validateGame(game).join('\n')).toMatch(/not one of the options/);
|
|
|
|
const raw = structuredClone(getGame('vineyard-hollow', 'story')) as any;
|
|
raw.chapters[0].lock.hints.pop();
|
|
expect(gameSchema.safeParse(raw).success).toBe(false);
|
|
});
|
|
|
|
it('rejects a labyrinth whose lantern is only reachable through a trap', () => {
|
|
const game = structuredClone(labyrinth());
|
|
// Wall off (1,6)... the only trap-free way to the Listening lantern is via (3,6)/(3,7).
|
|
game.grid[6] = '#######.#.#';
|
|
expect(validateGame(game).join('\n')).toMatch(/not reachable/);
|
|
});
|
|
|
|
it('rejects a labyrinth with ragged rows or the wrong number of lanterns', () => {
|
|
const ragged = structuredClone(labyrinth());
|
|
ragged.grid[1] = ragged.grid[1] + '#';
|
|
expect(validateGame(ragged).join('\n')).toMatch(/same length/);
|
|
|
|
const fewer = structuredClone(labyrinth());
|
|
fewer.grid[7] = '#...#.T.#.#';
|
|
expect(validateGame(fewer).join('\n')).toMatch(/exactly 3 K/);
|
|
});
|
|
|
|
it('rejects a sync bank with fewer than 15 questions', () => {
|
|
const game = structuredClone(sync());
|
|
game.match = game.match.slice(0, 14);
|
|
expect(validateGame(game).join('\n')).toMatch(/at least 15/);
|
|
});
|
|
});
|
|
|
|
describe('rooms', () => {
|
|
it('makes 5-char codes with no confusable characters', () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
const code = makeCode();
|
|
expect(code).toMatch(/^[A-Z2-9]{5}$/);
|
|
expect(code).not.toMatch(/[0O1I]/);
|
|
}
|
|
expect(CODE_ALPHABET).not.toMatch(/[0O1I]/);
|
|
});
|
|
|
|
it('expires rooms after 24h idle', () => {
|
|
const store = new RoomStore(null);
|
|
const { room } = store.create('in-sync', 'duo', 0);
|
|
expect(store.sweep(ROOM_TTL_MS - 1)).toBe(0);
|
|
expect(store.sweep(ROOM_TTL_MS + 1)).toBe(1);
|
|
expect(store.get(room.code)).toBeUndefined();
|
|
});
|
|
});
|