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