- 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>
371 lines
9.6 KiB
TypeScript
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;
|