Files
two-keys/server/test/content.test.ts
T
joshuaandClaude Opus 5.5 bbe44e84c4 V1.1.0
Add ten new games (18 in total) and the engine features they need.

Story: Dead Air, The Masquerade at Montague House, The Kalahari Expedition,
The Alchemist's Vault, Submerged: Deep Lab Nereid, The Kestrel Bank Job.
Games can now set their own hint penalty and a hard time limit, shown as a
countdown; running out ends the game with the fail ending.

Defuse: Tick Tock: Overtime. Manuals are now rulesets, so each game has its
own solver. Adds indicator lights, per-level strike limits, and the
Passwords, Morse Light and Say It Back modules.

Sync: In Sync: Deep Cuts. Adds telepathy (typed word) and ranking rounds,
and a per-round bar for the key.

Labyrinth: The Drowned Catacombs and The Mirror Maze. Adds one-way gates, a
relative (no compass) Explorer, a mirrored Navigator map, and mazes with a
lantern count other than three. Validation now rejects a maze where a gate
can leave the Explorer stuck.

Difficulty runs from 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:34:11 +02:00

82 lines
3.2 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 18 games with no validation errors', () => {
const files = loadAllFiles();
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
expect(files).toHaveLength(18);
const engines = [...games.values()].map((g) => g.engine);
expect(Object.fromEntries(['story', 'defuse', 'sync', 'labyrinth'].map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
story: 11,
defuse: 2,
sync: 2,
labyrinth: 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();
});
});