Files
two-keys/server/src/engines/story.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

200 lines
7.2 KiB
TypeScript

import { formatClock, matchesAnswer, type Role } from '@two-keys/shared';
import type { Lock, StoryGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export const HINT_PENALTY_SECONDS = 120;
export const SUBMIT_COOLDOWN_MS = 2000;
export const NUDGE_AFTER_WRONG = 5;
export interface StoryState {
/** Index of the open chapter; equals chapters.length once the finale is open. */
chapter: number;
awaitingContinue: boolean;
solved: string[];
hints: Record<string, number>;
wrong: Record<string, number>;
lastSubmit: Record<string, number>;
finale: {
proposal: { answers: Record<string, string>; by: Role } | null;
agreed: boolean;
attempts: number;
};
/** Starred clue ids per role. The partner only ever learns how many. */
pins?: Partial<Record<Role, string[]>>;
}
export function initStory(): StoryState {
return {
chapter: 0,
awaitingContinue: false,
solved: [],
hints: {},
wrong: {},
lastSubmit: {},
finale: { proposal: null, agreed: false, attempts: 0 },
pins: {},
};
}
const state = (ctx: Ctx) => ctx.room.engine as StoryState;
/** What one hint costs in this game. */
export const hintPenalty = (game: StoryGame): number => game.rules.hintPenaltySeconds ?? HINT_PENALTY_SECONDS;
/** The hard limit on elapsed + penalty time, for the games that have one. */
export const timeLimitSeconds = (game: StoryGame): number | null =>
game.rules.timeLimitMinutes ? Math.round(game.rules.timeLimitMinutes * 60) : null;
/** Called on a timer; returns true if the room changed (the time limit ran out). */
export function tickStory(game: StoryGame, ctx: Ctx): boolean {
const limit = timeLimitSeconds(game);
const { room } = ctx;
if (limit === null || room.phase !== 'playing' || room.startedAt === null) return false;
if ((ctx.now - room.startedAt) / 1000 + room.penaltySeconds < limit) return false;
const { finale } = game;
ctx.finish(
false,
finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?",
finale.type === 'accusation' ? finale.reveal : null,
true,
);
return true;
}
const atFinale = (game: StoryGame, s: StoryState) => s.chapter >= game.chapters.length;
/** The one lock that can currently be worked on, if any. */
export function openLock(game: StoryGame, s: StoryState): { lock: Lock; finale: boolean } | null {
if (!atFinale(game, s)) {
return s.awaitingContinue ? null : { lock: game.chapters[s.chapter].lock, finale: false };
}
if (game.finale.type === 'escape' && !s.solved.includes(game.finale.lock.id)) {
return { lock: game.finale.lock, finale: true };
}
return null;
}
export function submitLock(game: StoryGame, ctx: Ctx, lockId: string, value: string): ActionResult {
const s = state(ctx);
const open = openLock(game, s);
if (!open || open.lock.id !== lockId) return fail('lock_closed', "That lock isn't open right now.");
const last = s.lastSubmit[lockId] ?? 0;
if (ctx.now - last < SUBMIT_COOLDOWN_MS) {
return fail('rate_limited', 'Easy! One guess every two seconds.');
}
s.lastSubmit[lockId] = ctx.now;
if (!matchesAnswer(value, open.lock.answers)) {
s.wrong[lockId] = (s.wrong[lockId] ?? 0) + 1;
ctx.room.wrongGuesses++;
return { ok: true, correct: false };
}
s.solved.push(lockId);
if (open.finale) {
ctx.finish(true, game.finale.successEnding);
} else {
ctx.addKey(open.lock.keyName ?? 'Key');
s.awaitingContinue = true;
}
return { ok: true, correct: true };
}
export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionResult {
const s = state(ctx);
const open = openLock(game, s);
if (!open || open.lock.id !== lockId) return fail('lock_closed', "That lock isn't open right now.");
const tier = s.hints[lockId] ?? 0;
if (tier >= open.lock.hints.length) return fail('no_more_hints', "That's every hint for this lock.");
s.hints[lockId] = tier + 1;
ctx.room.hintsUsed++;
ctx.addPenalty(hintPenalty(game));
ctx.toast('all', `Hint ${tier + 1} revealed (+${formatClock(hintPenalty(game))})`);
return OK;
}
/** Every clue id this role can see so far. */
export function visibleClueIds(game: StoryGame, s: StoryState, role: Role): string[] {
const chapters = game.chapters.slice(0, Math.min(s.chapter, game.chapters.length - 1) + 1);
const ids = chapters.flatMap((ch) => [...ch.clues[role], ...ch.clues.shared].map((c) => c.id));
if (atFinale(game, s) && game.finale.type === 'escape') ids.push(...game.finale.clues[role].map((c) => c.id));
return ids;
}
/** Stars or un-stars one of your own clues. */
export function togglePin(game: StoryGame, ctx: Ctx, role: Role, clueId: string): ActionResult {
const s = state(ctx);
if (!visibleClueIds(game, s, role).includes(clueId)) return fail('no_clue', "You don't have that clue.");
const pins = (s.pins ??= {});
const mine = pins[role] ?? [];
pins[role] = mine.includes(clueId) ? mine.filter((id) => id !== clueId) : [...mine, clueId];
return OK;
}
export function continueChapter(game: StoryGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (!s.awaitingContinue) return fail('not_ready', 'Solve the lock first.');
s.awaitingContinue = false;
s.chapter++;
return OK;
}
function accusation(game: StoryGame, s: StoryState) {
return atFinale(game, s) && game.finale.type === 'accusation' ? game.finale : null;
}
export function proposeAccusation(
game: StoryGame,
ctx: Ctx,
role: Role,
answers: Record<string, string>,
): ActionResult {
const s = state(ctx);
const finale = accusation(game, s);
if (!finale) return fail('no_finale', "The finale isn't open yet.");
const clean: Record<string, string> = {};
for (const q of finale.questions) {
const pick = answers[q.id];
if (!q.options.some((o) => o.id === pick)) return fail('incomplete', 'Answer every question first.');
clean[q.id] = pick;
}
s.finale.proposal = { answers: clean, by: role };
s.finale.agreed = false;
return OK;
}
export function agreeAccusation(game: StoryGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (!accusation(game, s) || !s.finale.proposal) return fail('no_proposal', 'Nothing to agree to yet.');
if (s.finale.proposal.by === role) return fail('own_proposal', 'Your partner has to agree, not you.');
s.finale.agreed = true;
return OK;
}
export function submitAccusation(game: StoryGame, ctx: Ctx): ActionResult {
const s = state(ctx);
const finale = accusation(game, s);
if (!finale || !s.finale.proposal) return fail('no_proposal', 'Propose an accusation first.');
if (!s.finale.agreed) return fail('not_agreed', 'You both have to agree before submitting.');
const { answers } = s.finale.proposal;
const correct = finale.questions.every((q) => answers[q.id] === q.answer);
if (correct) {
ctx.finish(true, finale.successEnding);
return { ok: true, correct: true };
}
s.finale.attempts++;
s.finale.proposal = null;
s.finale.agreed = false;
ctx.room.wrongGuesses++;
const left = finale.maxAttempts - s.finale.attempts;
if (left <= 0) {
ctx.finish(false, finale.failEnding, finale.reveal);
} else {
// Deliberately doesn't say which part was wrong.
ctx.toast('all', `Not quite. ${left} attempt${left === 1 ? '' : 's'} left.`);
}
return { ok: true, correct: false };
}