// 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; 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; 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>; /** 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>; result: GameResult | null; } export type JoinAck = | { ok: true; code: string; playerToken: string } | { ok: false; code: string; message: string }; export const PING_EMOJIS = ['👋', '🔍', '💡', '❤️'] as const;