Files
two-keys/server/src/engines/story.ts
T
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:12 +02:00

156 lines
5.2 KiB
TypeScript

import { 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;
};
}
export function initStory(): StoryState {
return {
chapter: 0,
awaitingContinue: false,
solved: [],
hints: {},
wrong: {},
lastSubmit: {},
finale: { proposal: null, agreed: false, attempts: 0 },
};
}
const state = (ctx: Ctx) => ctx.room.engine as StoryState;
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(HINT_PENALTY_SECONDS);
ctx.toast('all', `Hint ${tier + 1} revealed (+2:00)`);
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 };
}