-- Weeks 7–8: durable world projections, PostgreSQL context retrieval, quotas, -- usage telemetry, atomic summaries, and owner-audited corrections. alter table public.ai_usage add column if not exists usage_key text; alter table public.ai_usage add column if not exists provider text not null default 'openrouter'; alter table public.ai_usage add column if not exists request_kind text not null default 'unknown'; update public.ai_usage set usage_key = id::text where usage_key is null; alter table public.ai_usage alter column usage_key set not null; create unique index if not exists ai_usage_usage_key_unique on public.ai_usage(usage_key); create index if not exists ai_usage_user_created_idx on public.ai_usage(user_id, created_at desc); create index if not exists ai_usage_campaign_created_idx on public.ai_usage(campaign_id, created_at desc); create table if not exists public.ai_quota_events ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.profiles(id) on delete cascade, campaign_id uuid references public.campaigns(id) on delete cascade, request_kind text not null check (char_length(request_kind) between 1 and 80), created_at timestamptz not null default now() ); create index if not exists ai_quota_events_user_created_idx on public.ai_quota_events(user_id, created_at desc); create index if not exists ai_quota_events_campaign_created_idx on public.ai_quota_events(campaign_id, created_at desc); alter table public.ai_quota_events enable row level security; drop policy if exists quota_events_self_read on public.ai_quota_events; create policy quota_events_self_read on public.ai_quota_events for select using (user_id = auth.uid()); create table if not exists public.scene_states ( campaign_id uuid primary key references public.campaigns(id) on delete cascade, location_id uuid references public.world_entities(id) on delete set null, summary text not null check (char_length(summary) between 1 and 6000), active_entity_ids uuid[] not null default '{}', tags text[] not null default '{}', through_round integer not null default 0 check (through_round >= 0), updated_at timestamptz not null default now() ); create table if not exists public.quest_states ( campaign_id uuid not null references public.campaigns(id) on delete cascade, quest_entity_id uuid not null references public.world_entities(id) on delete cascade, status text not null default 'hidden' check (status in ('hidden', 'active', 'completed', 'failed')), summary text not null check (char_length(summary) between 1 and 1200), tags text[] not null default '{}', through_round integer not null default 0 check (through_round >= 0), updated_at timestamptz not null default now(), primary key (campaign_id, quest_entity_id) ); create index if not exists quest_states_campaign_status_idx on public.quest_states(campaign_id, status, updated_at desc); create or replace function public.stage_six_ensure_opening_quest() returns trigger language plpgsql security definer set search_path = '' as $$ begin if nullif(btrim(new.hook), '') is not null and not exists ( select 1 from public.world_entities where world_id = new.id and kind = 'quest' ) then insert into public.world_entities(world_id, kind, name, summary, tags, secrets) values (new.id, 'quest', 'Opening objective', left(btrim(new.hook), 1200), array['opening', 'active'], '[]'::jsonb); end if; return new; end; $$; drop trigger if exists stage_six_world_opening_quest on public.worlds; create trigger stage_six_world_opening_quest after insert or update of hook on public.worlds for each row execute function public.stage_six_ensure_opening_quest(); insert into public.world_entities(world_id, kind, name, summary, tags, secrets) select world.id, 'quest', 'Opening objective', left(btrim(world.hook), 1200), array['opening', 'active'], '[]'::jsonb from public.worlds world where nullif(btrim(world.hook), '') is not null and not exists (select 1 from public.world_entities entity where entity.world_id = world.id and entity.kind = 'quest'); alter table public.scene_states enable row level security; alter table public.quest_states enable row level security; drop policy if exists scene_states_member_read on public.scene_states; drop policy if exists quest_states_member_read on public.quest_states; create policy scene_states_member_read on public.scene_states for select using ( public.is_campaign_member(campaign_id) or public.is_campaign_owner(campaign_id) ); create policy quest_states_member_read on public.quest_states for select using ( public.is_campaign_member(campaign_id) or public.is_campaign_owner(campaign_id) ); insert into public.scene_states(campaign_id, summary) select id, current_scene from public.campaigns on conflict (campaign_id) do nothing; insert into public.quest_states(campaign_id, quest_entity_id, status, summary, tags) select campaign.id, entity.id, case when 'opening' = any(entity.tags) then 'active' else 'hidden' end, entity.summary, entity.tags from public.campaigns campaign join public.world_entities entity on entity.world_id = campaign.world_id and entity.kind = 'quest' on conflict (campaign_id, quest_entity_id) do nothing; create or replace function public.stage_six_initialize_campaign_state() returns trigger language plpgsql security definer set search_path = '' as $$ begin insert into public.scene_states(campaign_id, summary) values (new.id, new.current_scene) on conflict (campaign_id) do nothing; insert into public.quest_states(campaign_id, quest_entity_id, status, summary, tags) select new.id, entity.id, case when 'opening' = any(entity.tags) then 'active' else 'hidden' end, entity.summary, entity.tags from public.world_entities entity where entity.world_id = new.world_id and entity.kind = 'quest' on conflict (campaign_id, quest_entity_id) do nothing; return new; end; $$; drop trigger if exists stage_six_campaign_state on public.campaigns; create trigger stage_six_campaign_state after insert on public.campaigns for each row execute function public.stage_six_initialize_campaign_state(); create or replace function public.stage_six_consume_ai_quota( p_user_id uuid, p_campaign_id uuid, p_request_kind text ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_user_minute integer; v_user_day integer; v_campaign_day integer := 0; v_tokens_day bigint; begin if p_user_id is null or not exists (select 1 from public.profiles where id = p_user_id) then raise exception 'AI quota requires a valid user'; end if; if p_campaign_id is not null and not exists ( select 1 from public.campaigns where id = p_campaign_id and (owner_id = p_user_id or exists ( select 1 from public.campaign_members where campaign_id = p_campaign_id and user_id = p_user_id and active )) ) then raise exception 'AI quota campaign access denied'; end if; if p_request_kind is null or char_length(btrim(p_request_kind)) not between 1 and 80 then raise exception 'AI request kind is required'; end if; perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('ai-quota:' || p_user_id::text, 0)); select count(*) into v_user_minute from public.ai_quota_events where user_id = p_user_id and created_at >= now() - interval '1 minute'; select count(*) into v_user_day from public.ai_quota_events where user_id = p_user_id and created_at >= date_trunc('day', now()); select coalesce(sum(input_tokens + output_tokens), 0) into v_tokens_day from public.ai_usage where user_id = p_user_id and created_at >= date_trunc('day', now()); if p_campaign_id is not null then select count(*) into v_campaign_day from public.ai_quota_events where campaign_id = p_campaign_id and created_at >= date_trunc('day', now()); end if; if v_user_minute >= 10 then raise exception 'AI rate limit reached; try again in a minute'; end if; if v_user_day >= 60 then raise exception 'daily user AI quota reached'; end if; if p_campaign_id is not null and v_campaign_day >= 40 then raise exception 'daily campaign AI quota reached'; end if; if v_tokens_day >= 250000 then raise exception 'daily AI token quota reached'; end if; insert into public.ai_quota_events(user_id, campaign_id, request_kind) values (p_user_id, p_campaign_id, btrim(p_request_kind)); return jsonb_build_object( 'userRemaining', 59 - v_user_day, 'campaignRemaining', case when p_campaign_id is null then null else 39 - v_campaign_day end, 'tokenRemaining', greatest(0, 250000 - v_tokens_day) ); end; $$; create or replace function public.enqueue_round_resolution( p_round_id uuid, p_forced_by uuid default null ) returns uuid language plpgsql security definer set search_path = '' as $$ declare v_round public.rounds%rowtype; v_job_id uuid; v_owner_id uuid; begin select * into v_round from public.rounds where id = p_round_id for update; if not found then raise exception 'round not found'; end if; select owner_id into v_owner_id from public.campaigns where id = v_round.campaign_id; select id into v_job_id from public.ai_jobs where job_type = 'resolve-round' and entity_id = p_round_id; if v_round.status in ('queued', 'resolving', 'resolved') then if v_job_id is null and v_round.status <> 'resolved' then raise exception 'round status and job outbox are inconsistent'; end if; return v_job_id; end if; if v_round.status <> 'open' then raise exception 'round cannot be queued from status %', v_round.status; end if; if p_forced_by is not null then if p_forced_by <> v_owner_id then raise exception 'only the campaign owner can force a round'; end if; elsif exists ( select 1 from public.campaign_members member join public.characters character on character.campaign_id = member.campaign_id and character.user_id = member.user_id and character.controller = 'human'::public.character_controller where member.campaign_id = v_round.campaign_id and member.active and not exists ( select 1 from public.player_intents intent where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready ) ) then raise exception 'not all active players are ready'; end if; perform public.stage_six_consume_ai_quota(v_owner_id, v_round.campaign_id, 'resolve-round'); v_job_id := gen_random_uuid(); insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status) values (v_job_id, 'resolve-round', p_round_id, v_job_id::text, 'queued'); update public.rounds set status = 'queued', queued_at = now(), forced_by = p_forced_by, error = null where id = p_round_id; return v_job_id; end; $$; create or replace function public.stage_two_enqueue_world_generation( p_session_id uuid, p_owner_id uuid ) returns uuid language plpgsql security definer set search_path = '' as $$ declare v_session public.coauthor_sessions%rowtype; v_job public.ai_jobs%rowtype; v_existing boolean := false; begin select * into v_session from public.coauthor_sessions where id = p_session_id and owner_id = p_owner_id for update; if not found then raise exception 'coauthor session not found'; end if; if v_session.status = 'confirmed' then raise exception 'coauthor session is already confirmed'; end if; if jsonb_array_length(v_session.messages) < 1 then raise exception 'at least one message is required'; end if; select * into v_job from public.ai_jobs where job_type = 'generate-world' and entity_id = p_session_id for update; v_existing := found; if v_existing and v_job.status in ('queued', 'running') then return v_job.id; end if; perform public.stage_six_consume_ai_quota(p_owner_id, null, 'generate-world'); if v_existing then update public.ai_jobs set status = 'queued', attempts = 0, claimed_by = null, lease_expires_at = null, available_at = now(), error = null, updated_at = now() where id = v_job.id; else v_job.id := gen_random_uuid(); insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status) values (v_job.id, 'generate-world', p_session_id, v_job.id::text, 'queued'); end if; update public.coauthor_sessions set status = 'generating', generated_world = null, updated_at = now() where id = p_session_id; return v_job.id; end; $$; create or replace function public.stage_six_retrieve_context( p_campaign_id uuid, p_search_text text, p_limit integer default 8 ) returns jsonb language plpgsql stable security definer set search_path = '' as $$ declare v_world_id uuid; v_query tsquery; v_limit integer := greatest(1, least(coalesce(p_limit, 8), 12)); v_entities jsonb; v_entity_ids uuid[]; begin select world_id into v_world_id from public.campaigns where id = p_campaign_id; if not found then raise exception 'campaign not found'; end if; v_query := plainto_tsquery('english', left(coalesce(p_search_text, ''), 4000)); select coalesce(jsonb_agg(item.payload order by item.relevance desc, item.name), '[]'::jsonb), coalesce(array_agg(item.id order by item.relevance desc, item.name), '{}') into v_entities, v_entity_ids from ( select entity.id, entity.name, jsonb_build_object('id', entity.id, 'kind', entity.kind, 'name', entity.name, 'summary', entity.summary, 'tags', entity.tags) as payload, case when numnode(v_query) > 0 then ts_rank_cd(entity.search_document, v_query) else 0 end + case when exists (select 1 from unnest(entity.tags) tag where position(lower(tag) in lower(coalesce(p_search_text, ''))) > 0) then 2 else 0 end + case when quest.status = 'active' then 3 else 0 end + case when scene.location_id = entity.id or entity.id = any(scene.active_entity_ids) then 4 else 0 end as relevance from public.world_entities entity left join public.quest_states quest on quest.campaign_id = p_campaign_id and quest.quest_entity_id = entity.id left join public.scene_states scene on scene.campaign_id = p_campaign_id where entity.world_id = v_world_id order by relevance desc, entity.created_at limit v_limit ) item; return jsonb_build_object( 'entities', v_entities, 'memories', coalesce(( select jsonb_agg(memory_item.payload order by memory_item.relevance desc, memory_item.created_at desc) from ( select memory.created_at, jsonb_build_object('summary', memory.summary, 'importance', memory.importance, 'tags', memory.tags, 'entityIds', memory.entity_ids) as payload, memory.importance + case when numnode(v_query) > 0 then ts_rank_cd(memory.search_document, v_query) * 10 else 0 end + case when memory.entity_ids && v_entity_ids then 5 else 0 end + case when exists (select 1 from unnest(memory.tags) tag where position(lower(tag) in lower(coalesce(p_search_text, ''))) > 0) then 4 else 0 end as relevance from public.memories memory where memory.campaign_id = p_campaign_id order by relevance desc, memory.created_at desc limit v_limit ) memory_item ), '[]'::jsonb), 'relationships', coalesce(( select jsonb_agg(jsonb_build_object( 'sourceEntityId', item.source_entity_id, 'targetEntityId', item.target_entity_id, 'score', item.score, 'notes', item.notes ) order by abs(item.score) desc) from ( select relationship.* from public.relationships relationship where relationship.campaign_id = p_campaign_id and ( relationship.source_entity_id = any(v_entity_ids) or relationship.target_entity_id = any(v_entity_ids) or exists ( select 1 from public.characters character where character.campaign_id = p_campaign_id and character.id in (relationship.source_entity_id, relationship.target_entity_id) ) ) order by abs(relationship.score) desc limit 20 ) item ), '[]'::jsonb), 'activeGoals', coalesce(( select jsonb_agg(jsonb_build_object( 'questEntityId', item.quest_entity_id, 'name', item.name, 'status', item.status, 'summary', item.summary, 'tags', item.tags ) order by item.updated_at desc) from ( select quest.*, entity.name from public.quest_states quest join public.world_entities entity on entity.id = quest.quest_entity_id where quest.campaign_id = p_campaign_id and quest.status = 'active' order by quest.updated_at desc limit 8 ) item ), '[]'::jsonb) ); end; $$; create or replace function public.stage_six_apply_round_projections( p_round_id uuid, p_events jsonb ) returns void language plpgsql security definer set search_path = '' as $$ declare v_round public.rounds%rowtype; v_event jsonb; v_actor uuid; v_target uuid; v_before jsonb; v_after jsonb; v_scene_projected boolean := false; begin select * into v_round from public.rounds where id = p_round_id; if not found then raise exception 'round not found'; end if; for v_event in select * from jsonb_array_elements(coalesce(p_events, '[]'::jsonb)) loop v_actor := nullif(v_event->>'actorId', '')::uuid; v_target := nullif(v_event->>'targetId', '')::uuid; if v_event->>'type' = 'relationship' then if v_actor is null or v_target is null or v_actor = v_target or (v_event->>'value')::integer not between -20 and 20 then raise exception 'invalid relationship projection'; end if; select to_jsonb(relationship) into v_before from public.relationships relationship where campaign_id = v_round.campaign_id and source_entity_id = v_actor and target_entity_id = v_target; insert into public.relationships(campaign_id, source_entity_id, target_entity_id, score, notes) values (v_round.campaign_id, v_actor, v_target, (v_event->>'value')::integer, v_event->>'description') on conflict (campaign_id, source_entity_id, target_entity_id) do update set score = greatest(-100, least(100, public.relationships.score + excluded.score)), notes = excluded.notes; select to_jsonb(relationship) into v_after from public.relationships relationship where campaign_id = v_round.campaign_id and source_entity_id = v_actor and target_entity_id = v_target; insert into public.audit_entries(campaign_id, action, entity_type, entity_id, before_state, after_state) values (v_round.campaign_id, 'project_relationship', 'relationship', v_target, v_before, v_after); elsif v_event->>'type' = 'quest' then if v_target is null or v_event->>'item' not in ('hidden', 'active', 'completed', 'failed') then raise exception 'invalid quest projection'; end if; select to_jsonb(quest) into v_before from public.quest_states quest where campaign_id = v_round.campaign_id and quest_entity_id = v_target; update public.quest_states set status = v_event->>'item', summary = v_event->>'description', through_round = v_round.number, updated_at = now() where campaign_id = v_round.campaign_id and quest_entity_id = v_target; if not found then raise exception 'quest is outside the round campaign'; end if; select to_jsonb(quest) into v_after from public.quest_states quest where campaign_id = v_round.campaign_id and quest_entity_id = v_target; insert into public.audit_entries(campaign_id, action, entity_type, entity_id, before_state, after_state) values (v_round.campaign_id, 'project_quest', 'quest', v_target, v_before, v_after); elsif v_event->>'type' = 'narrative' and v_event->>'item' = 'scene' then v_scene_projected := true; if v_target is not null and not exists ( select 1 from public.world_entities entity join public.campaigns campaign on campaign.world_id = entity.world_id where campaign.id = v_round.campaign_id and entity.id = v_target and entity.kind = 'location' ) then raise exception 'scene location is outside the campaign world'; end if; insert into public.scene_states(campaign_id, location_id, summary, active_entity_ids, through_round, updated_at) values ( v_round.campaign_id, v_target, v_event->>'description', array_remove(array[v_actor, v_target], null), v_round.number, now() ) on conflict (campaign_id) do update set location_id = excluded.location_id, summary = excluded.summary, active_entity_ids = excluded.active_entity_ids, through_round = excluded.through_round, updated_at = excluded.updated_at; update public.campaigns set current_scene = v_event->>'description', updated_at = now() where id = v_round.campaign_id; end if; end loop; if not v_scene_projected and nullif(btrim(v_round.narration), '') is not null then insert into public.scene_states(campaign_id, summary, through_round, updated_at) values (v_round.campaign_id, v_round.narration, v_round.number, now()) on conflict (campaign_id) do update set summary = excluded.summary, through_round = excluded.through_round, updated_at = excluded.updated_at; end if; end; $$; create or replace function public.commit_stage_six_round_resolution( p_round_id uuid, p_narration text, p_next_prompt text, p_rolls jsonb, p_events jsonb, p_character_states jsonb, p_memory jsonb, p_story_summary text, p_idempotency_key text ) returns void language plpgsql security definer set search_path = '' as $$ declare v_round public.rounds%rowtype; begin select * into v_round from public.rounds where id = p_round_id for update; if not found then raise exception 'round not found'; end if; if v_round.status = 'resolved' then return; end if; perform public.commit_srd_round_resolution( p_round_id, p_narration, p_next_prompt, p_rolls, p_events, p_character_states, p_memory, p_idempotency_key ); perform public.stage_six_apply_round_projections(p_round_id, p_events); if p_story_summary is not null then if v_round.number % 3 <> 0 or char_length(btrim(p_story_summary)) not between 20 and 6000 then raise exception 'invalid scheduled story summary'; end if; insert into public.story_summaries(campaign_id, through_round, summary) values (v_round.campaign_id, v_round.number, btrim(p_story_summary)) on conflict (campaign_id, through_round) do update set summary = excluded.summary; end if; end; $$; create or replace function public.commit_claimed_stage_six_round_resolution( p_round_id uuid, p_narration text, p_next_prompt text, p_rolls jsonb, p_events jsonb, p_character_states jsonb, p_memory jsonb, p_story_summary text, p_idempotency_key text, p_worker_id text ) returns void language plpgsql security definer set search_path = '' as $$ declare v_round public.rounds%rowtype; begin select * into v_round from public.rounds where id = p_round_id for update; if not found then raise exception 'round not found'; end if; if v_round.status = 'resolved' then return; end if; perform public.commit_claimed_srd_round_resolution( p_round_id, p_narration, p_next_prompt, p_rolls, p_events, p_character_states, p_memory, p_idempotency_key, p_worker_id ); perform public.stage_six_apply_round_projections(p_round_id, p_events); if p_story_summary is not null then if v_round.number % 3 <> 0 or char_length(btrim(p_story_summary)) not between 20 and 6000 then raise exception 'invalid scheduled story summary'; end if; insert into public.story_summaries(campaign_id, through_round, summary) values (v_round.campaign_id, v_round.number, btrim(p_story_summary)) on conflict (campaign_id, through_round) do update set summary = excluded.summary; end if; end; $$; create or replace function public.stage_six_adjust_character_state( p_campaign_id uuid, p_owner_id uuid, p_character_id uuid, p_patch jsonb, p_reason text ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_character public.characters%rowtype; v_before jsonb; v_after jsonb; v_hp integer; begin if not exists (select 1 from public.campaigns where id = p_campaign_id and owner_id = p_owner_id) then raise exception 'only the campaign owner can adjust state'; end if; if p_reason is null or char_length(btrim(p_reason)) not between 3 and 500 then raise exception 'an audit reason is required'; end if; if coalesce(jsonb_typeof(p_patch), '') <> 'object' or p_patch - array['hp', 'inventory', 'statuses']::text[] <> '{}'::jsonb then raise exception 'only hp, inventory, and statuses may be adjusted'; end if; select * into v_character from public.characters where id = p_character_id and campaign_id = p_campaign_id for update; if not found then raise exception 'character not found'; end if; v_before := jsonb_build_object('hp', v_character.hp, 'inventory', v_character.inventory, 'statuses', v_character.statuses); v_hp := coalesce((p_patch->>'hp')::integer, v_character.hp); if v_hp < 0 or v_hp > v_character.max_hp then raise exception 'hp must be between zero and max hp'; end if; if p_patch ? 'inventory' and jsonb_typeof(p_patch->'inventory') <> 'array' then raise exception 'inventory must be an array'; end if; if p_patch ? 'statuses' and jsonb_typeof(p_patch->'statuses') <> 'array' then raise exception 'statuses must be an array'; end if; update public.characters set hp = v_hp, inventory = coalesce(p_patch->'inventory', inventory), statuses = coalesce(p_patch->'statuses', statuses) where id = p_character_id returning jsonb_build_object('hp', hp, 'inventory', inventory, 'statuses', statuses) into v_after; insert into public.audit_entries(campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state) values (p_campaign_id, p_owner_id, 'manual_character_adjustment: ' || btrim(p_reason), 'character', p_character_id, v_before, v_after); return v_after; end; $$; create or replace function public.dng_schema_version() returns integer language sql stable security definer set search_path = '' as $$ select 13; $$; revoke all on function public.stage_six_consume_ai_quota(uuid, uuid, text) from public, anon, authenticated; revoke all on function public.stage_six_retrieve_context(uuid, text, integer) from public, anon, authenticated; revoke all on function public.stage_six_apply_round_projections(uuid, jsonb) from public, anon, authenticated; revoke all on function public.commit_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated; revoke all on function public.commit_claimed_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text) from public, anon, authenticated; revoke all on function public.stage_six_adjust_character_state(uuid, uuid, uuid, jsonb, text) from public, anon, authenticated; revoke all on function public.dng_schema_version() from public, anon, authenticated; grant execute on function public.stage_six_consume_ai_quota(uuid, uuid, text) to service_role; grant execute on function public.stage_six_retrieve_context(uuid, text, integer) to service_role; grant execute on function public.commit_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role; grant execute on function public.commit_claimed_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text) to service_role; grant execute on function public.stage_six_adjust_character_state(uuid, uuid, uuid, jsonb, text) to service_role; grant execute on function public.dng_schema_version() to service_role; notify pgrst, 'reload schema';