Files
two-keys/shared/src/types.ts
T
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
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>
2026-10-12 22:36:12 +02:00

296 lines
7.1 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;
}
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;
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;
}
// ---------- 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 type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView;
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; 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 };
// ---------- Sync engine ----------
export interface SyncView {
engine: 'sync';
status: 'intro' | 'question' | 'reveal' | 'summary';
round: {
index: number;
total: number;
title: string;
type: 'match' | 'predict';
blurb: string;
subject: Role | null;
};
isReplay: boolean;
questionNumber: number;
questionsPerRound: number;
pointsForKey: number;
points: number;
question: { id: string; text: string; options: string[] } | null;
iAmSubject: boolean;
myAnswer: number | null;
partnerAnswered: boolean;
/** Both answers, only once both are in. */
reveal: { A: number; B: number; 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';
export interface ExplorerView {
engine: 'labyrinth';
seat: 'explorer';
glyphLabel: string | null;
open: Record<Dir, boolean>;
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 }[];
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;
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;
};
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;