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>
82 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|