Files
two-keys/server/test/content.test.ts
T
joshuaandClaude Opus 5.5 52745935ee V1.3.0
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>
2026-10-15 10:21:24 +02:00

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();
});
});