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; wrong: Record; lastSubmit: Record; finale: { proposal: { answers: Record; 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, ): 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 = {}; 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 }; }