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>
200 lines
7.2 KiB
TypeScript
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 };
|
|
}
|