Files
two-keys/server/test/story.test.ts
T
joshuaandClaude Opus 5.5 6669be11e5 V1.2.0
Bring games 9-18 in line with GAMES-NEW.md:
- Library sorts easiest first, filters by category and difficulty; difficulty 5
  shows a Nightmare chip, and a no-hint finish earns Nightmare Survivors.
- Time limits end on "Out of time" (no rank); Deep Lab Nereid's countdown is a
  rising water gauge behind the header.
- Evidence Locker search, chapter filter and starred clues (partner sees a count).
- One-shot accusations ask "One shot. Are you both sure?" first.
- Symbol font fallback, tight monospace blocks for grids/keypads/SQL.
- Rank rounds reorder by drag or up/down buttons; reveal highlights #1 and #4.
- Telepathy capped at 30 characters; maze:move accepts forward/left/right/back;
  gate buttons read "Gate (won't open from this side)".
- Overtime solver tables checked against Appendix A; keypad generator re-rolls.
- More content checks (rank items, password fixtures, glyphs/gates on traps) and
  sanitize tests for all ten new games.
- GAMES.md carries the full spoiler guide for 9-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:44:28 +02:00

128 lines
6.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Role } from '@two-keys/shared';
import type { StoryGame } from '../src/content/schemas';
import {
agreeAccusation,
continueChapter,
proposeAccusation,
requestHint,
submitAccusation,
submitLock,
type StoryState,
} from '../src/engines/story';
import { computeResult } from '../src/rooms/state';
import { clock, getGame, playingRoom, storyGames } from './helpers';
const correctAccusation = (game: StoryGame) => {
if (game.finale.type !== 'accusation') throw new Error('not an accusation finale');
return Object.fromEntries(game.finale.questions.map((q) => [q.id, q.answer]));
};
describe.each(storyGames.map((g) => [g.id, g] as const))('story: %s', (_id, game) => {
it('accepts every answer listed in the YAML, for every lock', () => {
const locks = [...game.chapters.map((c) => c.lock), ...(game.finale.type === 'escape' ? [game.finale.lock] : [])];
for (const lock of locks) {
for (const answer of lock.answers) {
// Fresh room fast-forwarded to this lock.
const room = playingRoom(game);
const s = room.engine as StoryState;
const index = game.chapters.findIndex((c) => c.lock.id === lock.id);
s.chapter = index === -1 ? game.chapters.length : index;
const t = clock(room);
expect(submitLock(game, t.ctx(), lock.id, answer), `${lock.id}: ${answer}`).toEqual({ ok: true, correct: true });
// Sloppy formatting is fine too.
const room2 = playingRoom(game);
(room2.engine as StoryState).chapter = s.chapter;
const sloppy = ` ${answer.toLowerCase().split('').join(' ')}! `;
expect(submitLock(game, clock(room2).ctx(), lock.id, sloppy).ok && true).toBe(true);
expect((room2.engine as StoryState).solved).toContain(lock.id);
}
}
});
it('plays start to finish and earns one key per chapter', () => {
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
expect(submitLock(game, t.ctx(), ch.lock.id, 'definitely wrong')).toEqual({ ok: true, correct: false });
expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toEqual({ ok: true, correct: true });
expect(continueChapter(game, t.ctx()).ok).toBe(true);
}
expect(room.keys).toEqual(game.chapters.map((c) => c.lock.keyName));
if (game.finale.type === 'escape') {
expect(submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0])).toMatchObject({ correct: true });
} else {
expect(proposeAccusation(game, t.ctx(), 'A', correctAccusation(game)).ok).toBe(true);
expect(submitAccusation(game, t.ctx()).ok).toBe(false); // not agreed yet
expect(agreeAccusation(game, t.ctx(), 'A').ok).toBe(false); // can't agree with yourself
expect(agreeAccusation(game, t.ctx(), 'B').ok).toBe(true);
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
}
expect(room.phase).toBe('finished');
expect(room.result).toMatchObject({ success: true, ending: game.finale.successEnding });
expect(room.wrongGuesses).toBe(game.chapters.length);
expect(computeResult(room, game)).toMatchObject({ success: true, keysEarned: game.chapters.length });
});
});
describe('story rules', () => {
const game = getGame('vineyard-hollow', 'story');
const lock = game.chapters[0].lock;
it('rate-limits to one submission per 2s per lock', () => {
const room = playingRoom(game);
const t = clock(room);
expect(submitLock(game, t.ctx(), lock.id, '0000')).toMatchObject({ ok: true, correct: false });
expect(submitLock(game, t.ctx(500), lock.id, lock.answers[0])).toMatchObject({ ok: false, code: 'rate_limited' });
expect(submitLock(game, t.ctx(2000), lock.id, lock.answers[0])).toMatchObject({ ok: true, correct: true });
});
it('only accepts the lock that is currently open', () => {
const room = playingRoom(game);
const t = clock(room);
const next = game.chapters[1].lock;
expect(submitLock(game, t.ctx(), next.id, next.answers[0])).toMatchObject({ ok: false, code: 'lock_closed' });
submitLock(game, t.ctx(), lock.id, lock.answers[0]);
// Solved but not continued: neither lock is open.
expect(submitLock(game, t.ctx(), next.id, next.answers[0])).toMatchObject({ ok: false });
expect(continueChapter(game, t.ctx()).ok).toBe(true);
expect(continueChapter(game, t.ctx()).ok).toBe(false);
});
it('reveals hints in order with a 2:00 penalty each', () => {
const room = playingRoom(game);
const t = clock(room);
for (let tier = 1; tier <= 3; tier++) {
expect(requestHint(game, t.ctx(), lock.id).ok).toBe(true);
expect((room.engine as StoryState).hints[lock.id]).toBe(tier);
}
expect(requestHint(game, t.ctx(), lock.id).ok).toBe(false);
expect(room.hintsUsed).toBe(3);
expect(room.penaltySeconds).toBe(360);
});
it('fails the case after maxAttempts wrong accusations without saying which part was wrong', () => {
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
const room = playingRoom(game);
(room.engine as StoryState).chapter = game.chapters.length;
const t = clock(room);
const wrong = { ...correctAccusation(game), who: 'clara' };
for (let attempt = 1; attempt <= game.finale.maxAttempts; attempt++) {
const by: Role = attempt % 2 ? 'A' : 'B';
expect(proposeAccusation(game, t.ctx(), by, { who: 'clara' }).ok).toBe(false); // incomplete
proposeAccusation(game, t.ctx(), by, wrong);
agreeAccusation(game, t.ctx(), by === 'A' ? 'B' : 'A');
const ctx = t.ctx();
expect(submitAccusation(game, ctx)).toMatchObject({ ok: true, correct: false });
if (attempt < game.finale.maxAttempts) {
expect(ctx.toasts.map((x) => x.message).join()).toMatch(/Not quite\. 1 attempt left/);
expect(room.phase).toBe('playing');
}
}
expect(room.phase).toBe('finished');
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: false });
});
});