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>
128 lines
6.0 KiB
TypeScript
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 });
|
|
});
|
|
});
|