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>
156 lines
5.2 KiB
TypeScript
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 };
|
|
}
|