-- Narrative layer, first slice: a mystery (campaign) wrapping ordered level -- template versions, an authored NPC cast with named poses, scripted cutscenes, -- and a per-user playthrough that owns game state. Dialogue content is fixed and -- owned by the mystery; progress is a reference recorded in seen_dialogue. CREATE TABLE osint.mysteries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE osint.mystery_chapters ( mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE, chapter_index INTEGER NOT NULL CHECK (chapter_index >= 1), level_template_version_id UUID NOT NULL REFERENCES osint.level_template_versions(id), PRIMARY KEY (mystery_id, chapter_index) ); CREATE TABLE osint.npcs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE, npc_key TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', default_pose_key TEXT, UNIQUE (mystery_id, npc_key) ); -- A pose is a named portrait variant backed by an immutable shared asset. A -- missing pose is never an error; the client falls back to the NPC default pose -- and then to no artwork. CREATE TABLE osint.npc_poses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), npc_id UUID NOT NULL REFERENCES osint.npcs(id) ON DELETE CASCADE, pose_key TEXT NOT NULL, asset_id UUID REFERENCES osint.assets(id), UNIQUE (npc_id, pose_key) ); CREATE TABLE osint.cutscenes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE, chapter_index INTEGER, slot TEXT NOT NULL CHECK (slot IN ('mystery_intro','level_intro','level_debrief','mystery_resolution')), title TEXT NOT NULL DEFAULT '', -- Chapter-scoped slots carry a chapter; mystery-scoped slots do not. CHECK ( (slot IN ('mystery_intro','mystery_resolution') AND chapter_index IS NULL) OR (slot IN ('level_intro','level_debrief') AND chapter_index IS NOT NULL) ) ); CREATE INDEX cutscenes_mystery_idx ON osint.cutscenes (mystery_id); -- One utterance. `kind` reserves the LLM path; `body_text` is required for -- scripted steps and null for generated ones. `advances_to` reserves branching; -- null means "next by sort_order". CREATE TABLE osint.dialogue_steps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), cutscene_id UUID NOT NULL REFERENCES osint.cutscenes(id) ON DELETE CASCADE, step_key TEXT NOT NULL, sort_order INTEGER NOT NULL, npc_id UUID NOT NULL REFERENCES osint.npcs(id), pose_key TEXT, kind TEXT NOT NULL DEFAULT 'scripted' CHECK (kind IN ('scripted','generated')), body_text TEXT, advances_to TEXT, UNIQUE (cutscene_id, sort_order), UNIQUE (cutscene_id, step_key), CHECK ((kind = 'scripted' AND body_text IS NOT NULL) OR kind = 'generated') ); -- The single object that owns a player's game state, bound to the JWT identity. CREATE TABLE osint.playthroughs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id TEXT NOT NULL, mystery_id UUID NOT NULL REFERENCES osint.mysteries(id) ON DELETE CASCADE, current_chapter_index INTEGER NOT NULL DEFAULT 1 CHECK (current_chapter_index >= 1), current_level_id UUID REFERENCES osint.levels(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','finished')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX playthroughs_user_idx ON osint.playthroughs (user_id, updated_at DESC); -- Progress as a reference to fixed authored dialogue, never a copy of its text. CREATE TABLE osint.seen_dialogue ( playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE, cutscene_id UUID NOT NULL REFERENCES osint.cutscenes(id) ON DELETE CASCADE, seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (playthrough_id, cutscene_id) ); COMMENT ON TABLE osint.playthroughs IS 'Per-user instance of a mystery; owns current chapter and live level. Bound to the JWT sub (or the development test user).'; COMMENT ON TABLE osint.dialogue_steps IS 'Fixed authored utterances owned by the immutable mystery. seen_dialogue references these ids; they are never cloned per playthrough.';