Files
two-keys/shared/src/types.ts
T
joshuaandClaude Opus 5.5 4ff95e6018 V1.2.2
- Quote options and labels containing commas so they no longer split
  (In Sync: Deep Cuts d12/d15; accusation options in Blue Lantern,
  Dead Air, Kalahari Expedition and Masquerade)
- Validation: strict choice options; flag sync options that look split
- Hint sheet: timed games say hints take time off the countdown
- How to Play: hint cost covers timed games

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:20:16 +02:00

371 lines
9.6 KiB
TypeScript

// Types shared by server and client. Everything here describes what may travel over the
// wire: sanitized, per-role views. Raw game content (with answers) is typed on the server.
export type Role = 'A' | 'B';
export const ROLES: readonly Role[] = ['A', 'B'];
export const otherRole = (role: Role): Role => (role === 'A' ? 'B' : 'A');
/** A couch-mode player holds both roles on one device. */
export type Seat = Role | 'both';
export type PlayMode = 'duo' | 'couch';
export type Phase = 'lobby' | 'playing' | 'finished';
export type EngineId = 'story' | 'defuse' | 'sync' | 'labyrinth';
export type Category = 'murder-mystery' | 'escape-room' | 'co-op';
export interface Theme {
primary: string;
accent: string;
background: string;
headingFont: string;
}
export interface RoleInfo {
name: string;
blurb: string;
}
export interface GameSummary {
id: string;
engine: EngineId;
category: Category;
title: string;
tagline: string;
estimatedMinutes: number;
difficulty: number;
theme: Theme;
roles: Record<Role, RoleInfo>;
totalKeys: number;
/** Hard time limit, for the games that have one. */
timeLimitMinutes: number | null;
}
export interface GameMeta extends GameSummary {
intro: string;
}
// ---------- Story engine ----------
export const CLUE_TYPES = [
'note',
'letter',
'document',
'report',
'log',
'table',
'photo',
'map',
'transcript',
'signal',
'diagram',
] as const;
export type ClueType = (typeof CLUE_TYPES)[number];
export interface ClueView {
id: string;
title: string;
type: ClueType;
body: string;
/** Only present on clues of type `signal`. Never print it as text. */
signal?: string;
audience: 'private' | 'shared';
}
export interface LockOption {
id: string;
label: string;
}
export interface LockView {
id: string;
prompt: string;
inputType: 'code' | 'text' | 'choice';
format: string | null;
options: LockOption[] | null;
solved: boolean;
/** Revealed hint tiers only. */
hints: string[];
hintsTotal: number;
/** What the next hint costs. */
hintPenaltySeconds: number;
/** The game has a countdown, so a hint takes time off what's left instead of adding to a stopwatch. */
timeLimited: boolean;
wrongAttempts: number;
nudge: boolean;
successText: string | null;
keyName: string | null;
}
export interface ChapterView {
id: string;
title: string;
narrative: string;
clues: ClueView[];
lock: LockView;
}
export interface Suspect {
id: string;
name: string;
role: string;
description: string;
}
export interface AccusationView {
type: 'accusation';
prompt: string;
questions: { id: string; label: string; options: LockOption[] }[];
proposal: { answers: Record<string, string>; by: Role } | null;
agreed: boolean;
attemptsLeft: number;
maxAttempts: number;
}
export interface EscapeFinaleView {
type: 'escape';
prompt: string;
clues: ClueView[];
lock: LockView;
}
export interface StoryView {
engine: 'story';
suspects: Suspect[];
/** Chapters unlocked so far (earlier ones feed the Evidence Locker). */
chapters: ChapterView[];
currentChapter: number;
totalChapters: number;
awaitingContinue: boolean;
finale: AccusationView | EscapeFinaleView | null;
/** Clue ids you starred. */
pins: string[];
/** How many clues your partner starred (never which). */
partnerPins: number;
}
// ---------- Defuse engine ----------
export type DefuseStatus = 'ready' | 'active' | 'exploded' | 'cleared';
export interface DefuseClock {
/** Game-seconds left at the moment the state was built. */
remaining: number;
/** Game-seconds per real second (speeds up with strikes). */
speed: number;
running: boolean;
}
export interface WiresModuleView {
id: 'wires';
solved: boolean;
wires: string[];
}
export interface ButtonModuleView {
id: 'button';
solved: boolean;
label: string;
color: string;
holding: boolean;
strip: string | null;
}
export interface KeypadModuleView {
id: 'keypad';
solved: boolean;
symbols: { id: string; label: string; pressed: boolean }[];
}
export interface PasswordsModuleView {
id: 'passwords';
solved: boolean;
/** The letters each column cycles through, top to bottom. */
columns: string[];
}
export interface MorseModuleView {
id: 'morse';
solved: boolean;
/** Same format as a signal clue. Never print it as text. */
signal: string;
/** Dial range in kHz (3500 = 3.500 MHz). */
minKhz: number;
maxKhz: number;
}
export interface SimonModuleView {
id: 'simon';
solved: boolean;
colors: string[];
/** The flashes of the current stage. */
flashes: string[];
stage: number;
stages: number;
/** Presses accepted so far in this stage. */
entered: number;
}
export type DefuseModuleView =
| WiresModuleView
| ButtonModuleView
| KeypadModuleView
| PasswordsModuleView
| MorseModuleView
| SimonModuleView;
export const DEFUSE_MODULES = ['wires', 'button', 'keypad', 'passwords', 'morse', 'simon'] as const;
export type DefuseModuleId = (typeof DEFUSE_MODULES)[number];
interface DefuseCommon {
engine: 'defuse';
level: { index: number; total: number; title: string; timeSeconds: number };
status: DefuseStatus;
failReason: 'strikes' | 'time' | null;
strikes: number;
maxStrikes: number;
clock: DefuseClock | null;
}
export interface TechnicianView extends DefuseCommon {
seat: 'technician';
device: {
serial: string;
batteries: number;
indicators: { label: string; lit: boolean }[];
modules: DefuseModuleView[];
} | null;
}
export interface ExpertView extends DefuseCommon {
seat: 'expert';
manual: string;
}
export type DefuseView = TechnicianView | ExpertView;
export type DefuseAction =
| { type: 'cut'; index: number }
| { type: 'tap' }
| { type: 'hold' }
| { type: 'release' }
| { type: 'press'; symbol?: string; color?: string }
| { type: 'submit'; word: string }
| { type: 'transmit'; frequency: string };
// ---------- Sync engine ----------
export const SYNC_ROUND_TYPES = ['match', 'predict', 'telepathy', 'rank'] as const;
export type SyncRoundType = (typeof SYNC_ROUND_TYPES)[number];
/** An option index (match/predict), a typed word (telepathy), or item indexes, best first (rank). */
export type SyncAnswer = number | string | number[];
export interface SyncView {
engine: 'sync';
status: 'intro' | 'question' | 'reveal' | 'summary';
round: {
index: number;
total: number;
title: string;
type: SyncRoundType;
blurb: string;
/** Who the current question is about (predict and rank rounds). */
subject: Role | null;
};
isReplay: boolean;
questionNumber: number;
questionsPerRound: number;
pointsForKey: number;
points: number;
/** `options` holds the items to order in a rank round, and is empty for telepathy. */
question: { id: string; text: string; options: string[] } | null;
iAmSubject: boolean;
myAnswer: SyncAnswer | null;
partnerAnswered: boolean;
/** Both answers, only once both are in. */
reveal: { A: SyncAnswer; B: SyncAnswer; match: boolean } | null;
/** Match / miss for each question revealed so far this round. */
marks: boolean[];
summary: { earned: boolean; canReplay: boolean; keyName: string } | null;
}
// ---------- Labyrinth engine ----------
export type Dir = 'n' | 'e' | 's' | 'w';
/** Forward / left / right / back, for mazes where the Explorer has no compass. */
export type RelDir = 'f' | 'l' | 'r' | 'b';
export type MoveDir = Dir | RelDir;
export interface ExplorerView {
engine: 'labyrinth';
seat: 'explorer';
glyphLabel: string | null;
compass: 'absolute' | 'relative';
/** Keyed by n/e/s/w, or by f/l/r/b when the compass is relative. */
open: Partial<Record<MoveDir, boolean>>;
/** Directions blocked by a one-way gate rather than a wall. */
gates: MoveDir[];
keysCarried: number;
totalKeys: number;
atExit: boolean;
message: string;
moves: number;
falls: number;
}
export interface NavigatorView {
engine: 'labyrinth';
seat: 'navigator';
grid: string[];
glyphs: { x: number; y: number; label: string }[];
lanterns: { x: number; y: number; name: string; collected: boolean }[];
/** One-way gates: passable from (x, y) in `dir` only. */
oneWay: { x: number; y: number; dir: Dir }[];
banner: string | null;
keysCarried: number;
totalKeys: number;
moves: number | null;
falls: number;
}
export type LabyrinthView = ExplorerView | NavigatorView;
export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView;
// ---------- Room ----------
export interface GameResult {
success: boolean;
elapsedSeconds: number;
penaltySeconds: number;
finalSeconds: number;
hintsUsed: number;
wrongGuesses: number;
keysEarned: number;
totalKeys: number;
rank: string;
outOfTime: boolean;
nightmareSurvivors: boolean;
ending: string | null;
reveal: string | null;
}
export interface RoomState {
code: string;
mode: PlayMode;
phase: Phase;
game: GameMeta;
you: { seat: Seat | null; ready: boolean };
partner: { online: boolean; seat: Role | null; ready: boolean; viewing: string | null } | null;
notepad: string;
keys: string[];
timer: {
startedAt: number | null;
finishedAt: number | null;
penaltySeconds: number;
serverNow: number;
/** Hard limit on elapsed + penalty time, if the game has one. */
limitSeconds: number | null;
limitLabel: string | null;
};
stats: { hintsUsed: number; wrongGuesses: number };
/** One view per role this socket may see: own role, or both in couch mode. */
views: Partial<Record<Role, EngineView>>;
result: GameResult | null;
}
export type JoinAck =
| { ok: true; code: string; playerToken: string }
| { ok: false; code: string; message: string };
export const PING_EMOJIS = ['👋', '🔍', '💡', '❤️'] as const;