create extension if not exists pgcrypto; create type public.member_role as enum ('owner', 'player'); create type public.character_controller as enum ('human', 'ai', 'delegated'); create type public.round_status as enum ('open', 'queued', 'resolving', 'resolved', 'failed'); create table public.allowlist ( email text primary key check (email <> '' and email = lower(btrim(email))), invited_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now() ); create table public.profiles ( id uuid primary key references auth.users(id) on delete cascade, display_name text not null default 'Adventurer' check (char_length(display_name) between 1 and 80), created_at timestamptz not null default now() ); create table public.worlds ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references public.profiles(id), title text not null check (char_length(title) between 3 and 100), genre text not null, tone text not null, premise text not null, content_boundaries jsonb not null default '[]' check (jsonb_typeof(content_boundaries) = 'array'), hidden_threat text not null, status text not null default 'draft' check (status in ('draft', 'confirmed')), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table public.world_entities ( id uuid primary key default gen_random_uuid(), world_id uuid not null references public.worlds(id) on delete cascade, kind text not null check (kind in ('location', 'npc', 'faction', 'quest')), name text not null, summary text not null, tags text[] not null default '{}', secrets jsonb not null default '[]' check (jsonb_typeof(secrets) = 'array'), search_document tsvector generated always as (to_tsvector('english', coalesce(name, '') || ' ' || coalesce(summary, '') || ' ' || array_to_string(tags, ' '))) stored, created_at timestamptz not null default now() ); create index world_entities_search_idx on public.world_entities using gin(search_document); create table public.coauthor_sessions ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references public.profiles(id), status text not null default 'collecting' check (status in ('collecting', 'generating', 'ready', 'failed', 'confirmed')), messages jsonb not null default '[]' check (jsonb_typeof(messages) = 'array'), generated_world jsonb check (generated_world is null or jsonb_typeof(generated_world) = 'object'), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table public.campaigns ( id uuid primary key default gen_random_uuid(), world_id uuid not null references public.worlds(id), owner_id uuid not null references public.profiles(id), title text not null, current_scene text not null, next_prompt text not null default 'What do you do?', status text not null default 'lobby' check (status in ('lobby', 'active', 'archived')), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table public.campaign_members ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, user_id uuid not null references public.profiles(id) on delete cascade, role public.member_role not null default 'player', active boolean not null default true, ai_takeover_allowed boolean not null default false, joined_at timestamptz not null default now(), unique (campaign_id, user_id) ); create table public.invites ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, created_by uuid not null references public.profiles(id), token_hash text not null unique, max_uses integer not null default 1 check (max_uses between 1 and 20), uses integer not null default 0 check (uses >= 0 and uses <= max_uses), expires_at timestamptz not null, created_at timestamptz not null default now() ); create table public.characters ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, user_id uuid references public.profiles(id) on delete set null, name text not null, concept text not null, controller public.character_controller not null, abilities jsonb not null check (jsonb_typeof(abilities) = 'object'), hp integer not null check (hp >= 0), max_hp integer not null check (max_hp > 0), defense integer not null check (defense between 1 and 40), proficiency integer not null default 2 check (proficiency between 1 and 10), inventory jsonb not null default '[]' check (jsonb_typeof(inventory) = 'array'), statuses jsonb not null default '[]' check (jsonb_typeof(statuses) = 'array'), persona jsonb not null default '{}' check (jsonb_typeof(persona) = 'object'), created_at timestamptz not null default now() ); alter table public.characters add constraint characters_hp_within_max check (hp <= max_hp); create table public.rounds ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, number integer not null check (number > 0), status public.round_status not null default 'open', forced_by uuid references public.profiles(id), narration text, next_prompt text, queued_at timestamptz, resolved_at timestamptz, error text, created_at timestamptz not null default now(), unique (campaign_id, number) ); create table public.player_intents ( id uuid primary key default gen_random_uuid(), round_id uuid not null references public.rounds(id) on delete cascade, member_id uuid not null references public.campaign_members(id) on delete cascade, character_id uuid not null references public.characters(id), action text not null check (char_length(action) between 1 and 2000), ready boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (round_id, member_id) ); create table public.game_events ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, round_id uuid references public.rounds(id), event_type text not null, payload jsonb not null, created_at timestamptz not null default now() ); create table public.dice_rolls ( id uuid primary key, round_id uuid not null references public.rounds(id) on delete cascade, actor_id uuid not null references public.characters(id), target_id uuid references public.characters(id), check_kind text not null, formula text not null, rolls integer[] not null, kept integer[] not null, modifier integer not null, total integer not null, difficulty integer, success boolean, created_at timestamptz not null ); create table public.relationships ( campaign_id uuid not null references public.campaigns(id) on delete cascade, source_entity_id uuid not null, target_entity_id uuid not null, score integer not null default 0 check (score between -100 and 100), notes text not null default '', primary key (campaign_id, source_entity_id, target_entity_id) ); create table public.memories ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, round_id uuid references public.rounds(id), summary text not null, importance integer not null check (importance between 1 and 5), tags text[] not null default '{}', entity_ids uuid[] not null default '{}', search_document tsvector generated always as (to_tsvector('english', coalesce(summary, '') || ' ' || array_to_string(tags, ' '))) stored, created_at timestamptz not null default now() ); create index memories_search_idx on public.memories using gin(search_document); create table public.story_summaries ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, through_round integer not null, summary text not null, created_at timestamptz not null default now(), unique (campaign_id, through_round) ); create table public.ai_jobs ( id uuid primary key default gen_random_uuid(), job_type text not null, entity_id uuid not null, idempotency_key text not null unique, status text not null default 'queued' check (status in ('queued', 'running', 'complete', 'failed')), attempts integer not null default 0 check (attempts >= 0), claimed_by text, lease_expires_at timestamptz, available_at timestamptz not null default now(), max_attempts integer not null default 3 check (max_attempts between 1 and 10), error text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), check ( (status = 'running' and claimed_by is not null and lease_expires_at is not null) or (status <> 'running' and claimed_by is null and lease_expires_at is null) ) ); create unique index ai_jobs_round_resolution_unique on public.ai_jobs(entity_id) where job_type = 'resolve-round'; create index ai_jobs_poll_idx on public.ai_jobs(available_at, created_at) where status in ('queued', 'running'); create index ai_jobs_expired_lease_idx on public.ai_jobs(lease_expires_at) where status = 'running'; create table public.ai_usage ( id uuid primary key default gen_random_uuid(), user_id uuid references public.profiles(id), campaign_id uuid references public.campaigns(id), job_id uuid references public.ai_jobs(id), model text not null, input_tokens integer not null default 0 check (input_tokens >= 0), output_tokens integer not null default 0 check (output_tokens >= 0), cost_usd numeric(12, 6) not null default 0 check (cost_usd >= 0), latency_ms integer check (latency_ms is null or latency_ms >= 0), created_at timestamptz not null default now() ); create table public.audit_entries ( id uuid primary key default gen_random_uuid(), campaign_id uuid not null references public.campaigns(id) on delete cascade, actor_id uuid references public.profiles(id), action text not null, entity_type text not null, entity_id uuid not null, before_state jsonb, after_state jsonb, created_at timestamptz not null default now() ); create index campaigns_owner_idx on public.campaigns(owner_id); create index campaign_members_user_idx on public.campaign_members(user_id, campaign_id) where active; create index characters_campaign_idx on public.characters(campaign_id); create index rounds_campaign_status_idx on public.rounds(campaign_id, status); create index player_intents_round_ready_idx on public.player_intents(round_id, ready); create index game_events_campaign_round_idx on public.game_events(campaign_id, round_id); create index memories_campaign_importance_idx on public.memories(campaign_id, importance desc); create or replace function public.create_profile_for_allowlisted_user() returns trigger language plpgsql security definer set search_path = '' as $$ begin if new.email is null or not exists ( select 1 from public.allowlist where email = lower(btrim(new.email)) ) then raise exception using errcode = '42501', message = 'This email is not invited to the Dungeons & Ground alpha.'; end if; insert into public.profiles(id) values (new.id); return new; end; $$; revoke all on function public.create_profile_for_allowlisted_user() from public; create trigger create_profile_for_allowlisted_user after insert on auth.users for each row execute function public.create_profile_for_allowlisted_user(); create or replace function public.is_campaign_member(p_campaign_id uuid) returns boolean language sql stable security definer set search_path = '' as $$ select exists( select 1 from public.campaign_members where campaign_id = p_campaign_id and user_id = auth.uid() and active ); $$; create or replace function public.is_campaign_owner(p_campaign_id uuid) returns boolean language sql stable security definer set search_path = '' as $$ select exists( select 1 from public.campaigns where id = p_campaign_id and owner_id = auth.uid() ); $$; revoke all on function public.is_campaign_member(uuid) from public; revoke all on function public.is_campaign_owner(uuid) from public; grant execute on function public.is_campaign_member(uuid) to authenticated, service_role; grant execute on function public.is_campaign_owner(uuid) to authenticated, service_role; create or replace function public.validate_player_intent_membership() returns trigger language plpgsql security definer set search_path = '' as $$ declare v_round_campaign_id uuid; v_member_campaign_id uuid; v_member_user_id uuid; v_member_active boolean; v_character_campaign_id uuid; v_character_user_id uuid; begin select campaign_id into v_round_campaign_id from public.rounds where id = new.round_id; select campaign_id, user_id, active into v_member_campaign_id, v_member_user_id, v_member_active from public.campaign_members where id = new.member_id; select campaign_id, user_id into v_character_campaign_id, v_character_user_id from public.characters where id = new.character_id; if v_round_campaign_id is null or v_member_campaign_id is distinct from v_round_campaign_id or v_character_campaign_id is distinct from v_round_campaign_id or not coalesce(v_member_active, false) or v_character_user_id is distinct from v_member_user_id then raise exception 'intent member and character must belong to the round campaign'; end if; return new; end; $$; revoke all on function public.validate_player_intent_membership() from public; create trigger validate_player_intent_membership before insert or update of round_id, member_id, character_id on public.player_intents for each row execute function public.validate_player_intent_membership(); alter table public.allowlist enable row level security; alter table public.profiles enable row level security; alter table public.worlds enable row level security; alter table public.world_entities enable row level security; alter table public.coauthor_sessions enable row level security; alter table public.campaigns enable row level security; alter table public.campaign_members enable row level security; alter table public.invites enable row level security; alter table public.characters enable row level security; alter table public.rounds enable row level security; alter table public.player_intents enable row level security; alter table public.game_events enable row level security; alter table public.dice_rolls enable row level security; alter table public.relationships enable row level security; alter table public.memories enable row level security; alter table public.story_summaries enable row level security; alter table public.ai_jobs enable row level security; alter table public.ai_usage enable row level security; alter table public.audit_entries enable row level security; create policy profiles_self on public.profiles for select using (id = auth.uid()); create policy profiles_self_update on public.profiles for update using (id = auth.uid()) with check (id = auth.uid()); create policy worlds_owner_all on public.worlds for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); create policy entities_owner_all on public.world_entities for all using (exists(select 1 from worlds where worlds.id = world_id and worlds.owner_id = auth.uid())); create policy coauthor_owner_all on public.coauthor_sessions for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); create policy campaigns_members_read on public.campaigns for select using (is_campaign_member(id) or owner_id = auth.uid()); create policy campaigns_owner_write on public.campaigns for all using (owner_id = auth.uid()) with check ( owner_id = auth.uid() and exists ( select 1 from public.worlds where worlds.id = world_id and worlds.owner_id = auth.uid() and worlds.status = 'confirmed' ) ); create policy members_member_read on public.campaign_members for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy members_owner_insert on public.campaign_members for insert with check ( is_campaign_owner(campaign_id) and ( (user_id = auth.uid() and role = 'owner') or (user_id <> auth.uid() and role = 'player') ) ); create policy members_owner_update on public.campaign_members for update using (is_campaign_owner(campaign_id)) with check ( is_campaign_owner(campaign_id) and ( (user_id = auth.uid() and role = 'owner') or (user_id <> auth.uid() and role = 'player') ) ); create policy members_owner_delete on public.campaign_members for delete using (is_campaign_owner(campaign_id) and user_id <> auth.uid()); create policy characters_member_read on public.characters for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy characters_member_insert on public.characters for insert with check ( (is_campaign_member(campaign_id) and user_id = auth.uid() and controller = 'human') or (is_campaign_owner(campaign_id) and (user_id = auth.uid() or (user_id is null and controller = 'ai'))) ); create policy rounds_member_read on public.rounds for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy intents_member_read on public.player_intents for select using ( exists( select 1 from public.rounds where rounds.id = round_id and (is_campaign_member(rounds.campaign_id) or is_campaign_owner(rounds.campaign_id)) ) ); create policy intents_self_insert on public.player_intents for insert with check ( exists( select 1 from public.rounds join public.campaign_members on campaign_members.campaign_id = rounds.campaign_id join public.characters on characters.campaign_id = rounds.campaign_id where rounds.id = round_id and rounds.status = 'open' and campaign_members.id = member_id and campaign_members.user_id = auth.uid() and campaign_members.active and characters.id = character_id and characters.user_id = auth.uid() and characters.controller = 'human' ) ); create policy intents_self_update on public.player_intents for update using ( exists( select 1 from public.campaign_members where campaign_members.id = member_id and campaign_members.user_id = auth.uid() and campaign_members.active ) ) with check ( exists( select 1 from public.rounds join public.campaign_members on campaign_members.campaign_id = rounds.campaign_id join public.characters on characters.campaign_id = rounds.campaign_id where rounds.id = round_id and rounds.status = 'open' and campaign_members.id = member_id and campaign_members.user_id = auth.uid() and campaign_members.active and characters.id = character_id and characters.user_id = auth.uid() and characters.controller = 'human' ) ); create policy events_member_read on public.game_events for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy rolls_member_read on public.dice_rolls for select using (exists(select 1 from public.rounds where rounds.id = round_id and (is_campaign_member(rounds.campaign_id) or is_campaign_owner(rounds.campaign_id)))); create policy relationships_member_read on public.relationships for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy memories_member_read on public.memories for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy summaries_member_read on public.story_summaries for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id)); create policy usage_self_read on public.ai_usage for select using (user_id = auth.uid()); create policy audit_owner_read on public.audit_entries for select using (is_campaign_owner(campaign_id)); 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; 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 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 not exists ( select 1 from public.campaigns where id = v_round.campaign_id and owner_id = p_forced_by ) then raise exception 'only the campaign owner can force a round'; end if; elsif exists ( select 1 from public.campaign_members member 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; 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; $$; revoke all on function public.enqueue_round_resolution(uuid, uuid) from public, anon, authenticated; grant execute on function public.enqueue_round_resolution(uuid, uuid) to service_role; create or replace function public.claim_ai_job( p_worker_id text ) returns table(id uuid, job_type text, entity_id uuid) language plpgsql security definer set search_path = '' as $$ declare v_job public.ai_jobs%rowtype; begin if p_worker_id is null or btrim(p_worker_id) = '' then raise exception 'worker id is required'; end if; select * into v_job from public.ai_jobs where attempts < max_attempts and available_at <= now() and ( status = 'queued' or (status = 'running' and lease_expires_at <= now()) ) order by created_at for update skip locked limit 1; if not found then return; end if; update public.ai_jobs set status = 'running', attempts = attempts + 1, claimed_by = p_worker_id, lease_expires_at = now() + interval '3 minutes', error = null, updated_at = now() where public.ai_jobs.id = v_job.id; if v_job.job_type = 'resolve-round' then update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id; elsif v_job.job_type = 'generate-world' then update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id; end if; return query select v_job.id, v_job.job_type, v_job.entity_id; end; $$; create or replace function public.claim_ai_job_by_id( p_job_id uuid, p_worker_id text ) returns table(id uuid, job_type text, entity_id uuid) language plpgsql security definer set search_path = '' as $$ declare v_job public.ai_jobs%rowtype; begin if p_job_id is null then raise exception 'job id is required'; end if; if p_worker_id is null or btrim(p_worker_id) = '' then raise exception 'worker id is required'; end if; select * into v_job from public.ai_jobs where public.ai_jobs.id = p_job_id and attempts < max_attempts and available_at <= now() and ( status = 'queued' or (status = 'running' and lease_expires_at <= now()) ) for update; if not found then return; end if; update public.ai_jobs set status = 'running', attempts = attempts + 1, claimed_by = p_worker_id, lease_expires_at = now() + interval '3 minutes', error = null, updated_at = now() where public.ai_jobs.id = v_job.id; if v_job.job_type = 'resolve-round' then update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id; elsif v_job.job_type = 'generate-world' then update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id; end if; return query select v_job.id, v_job.job_type, v_job.entity_id; end; $$; create or replace function public.complete_ai_job( p_job_id uuid, p_worker_id text ) returns void language plpgsql security definer set search_path = '' as $$ begin if p_worker_id is null or btrim(p_worker_id) = '' then raise exception 'worker id is required'; end if; update public.ai_jobs set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now() where id = p_job_id and status = 'running' and claimed_by = p_worker_id and lease_expires_at > now(); if not found then raise exception 'job is not owned by this worker'; end if; end; $$; create or replace function public.retry_ai_job( p_job_id uuid, p_worker_id text, p_error text ) returns void language plpgsql security definer set search_path = '' as $$ declare v_job public.ai_jobs%rowtype; v_retry boolean; begin if p_worker_id is null or btrim(p_worker_id) = '' then raise exception 'worker id is required'; end if; select * into v_job from public.ai_jobs where id = p_job_id for update; if not found or v_job.status <> 'running' or v_job.claimed_by <> p_worker_id then raise exception 'job is not owned by this worker'; end if; if v_job.lease_expires_at <= now() then raise exception 'job lease has expired'; end if; v_retry := v_job.attempts < v_job.max_attempts; update public.ai_jobs set status = case when v_retry then 'queued' else 'failed' end, claimed_by = null, lease_expires_at = null, available_at = case when v_retry then now() + make_interval(secs => 5 * power(2, v_job.attempts - 1)::integer) else available_at end, error = left(p_error, 2000), updated_at = now() where id = p_job_id; if v_job.job_type = 'resolve-round' then update public.rounds set status = case when v_retry then 'queued'::public.round_status else 'failed'::public.round_status end, error = left(p_error, 2000) where id = v_job.entity_id and status <> 'resolved'; elsif v_job.job_type = 'generate-world' then update public.coauthor_sessions set status = case when v_retry then 'generating' else 'failed' end, updated_at = now() where id = v_job.entity_id and status <> 'ready'; end if; end; $$; create or replace function public.renew_ai_job_lease( p_job_id uuid, p_worker_id text ) returns timestamptz language plpgsql security definer set search_path = '' as $$ declare v_lease_expires_at timestamptz; begin if p_worker_id is null or btrim(p_worker_id) = '' then raise exception 'worker id is required'; end if; update public.ai_jobs set lease_expires_at = now() + interval '3 minutes', updated_at = now() where id = p_job_id and status = 'running' and claimed_by = p_worker_id and lease_expires_at > now() returning lease_expires_at into v_lease_expires_at; if not found then raise exception 'job is not owned by this worker or lease expired'; end if; return v_lease_expires_at; end; $$; revoke all on function public.claim_ai_job(text) from public, anon, authenticated; revoke all on function public.claim_ai_job_by_id(uuid, text) from public, anon, authenticated; revoke all on function public.complete_ai_job(uuid, text) from public, anon, authenticated; revoke all on function public.retry_ai_job(uuid, text, text) from public, anon, authenticated; revoke all on function public.renew_ai_job_lease(uuid, text) from public, anon, authenticated; grant execute on function public.claim_ai_job(text) to service_role; grant execute on function public.claim_ai_job_by_id(uuid, text) to service_role; grant execute on function public.complete_ai_job(uuid, text) to service_role; grant execute on function public.retry_ai_job(uuid, text, text) to service_role; grant execute on function public.renew_ai_job_lease(uuid, text) to service_role; create or replace function public.commit_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_idempotency_key text ) returns void language plpgsql security definer set search_path = '' as $$ declare v_round public.rounds%rowtype; v_job public.ai_jobs%rowtype; v_roll jsonb; v_event jsonb; v_state jsonb; v_character_id uuid; v_seen_character_ids uuid[] := '{}'; v_row_count integer; begin if p_idempotency_key is null or btrim(p_idempotency_key) = '' then raise exception 'idempotency key is required'; end if; if p_narration is null or btrim(p_narration) = '' or p_next_prompt is null or btrim(p_next_prompt) = '' then raise exception 'narration and next prompt are required'; end if; if coalesce(jsonb_typeof(p_rolls), '') <> 'array' or coalesce(jsonb_typeof(p_events), '') <> 'array' or coalesce(jsonb_typeof(p_character_states), '') <> 'array' then raise exception 'rolls, events, and character states must be arrays'; end if; 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; if v_round.status not in ('queued', 'resolving') then raise exception 'round is not queued for resolution'; end if; select * into v_job from public.ai_jobs where idempotency_key = p_idempotency_key or id::text = p_idempotency_key for update; if not found then raise exception 'resolution job not found'; end if; if v_job.job_type <> 'resolve-round' or v_job.entity_id <> p_round_id then raise exception 'idempotency key belongs to another job'; end if; if v_job.status = 'complete' then return; end if; if v_job.status <> 'running' then raise exception 'resolution job must be claimed before commit'; end if; update public.ai_jobs set status = 'running', error = null, updated_at = now() where id = v_job.id; update public.rounds set status = 'resolving', error = null where id = p_round_id; for v_roll in select * from jsonb_array_elements(p_rolls) loop if not exists ( select 1 from public.characters where id = (v_roll->>'actorId')::uuid and campaign_id = v_round.campaign_id ) or ( nullif(v_roll->>'targetId', '') is not null and not exists ( select 1 from public.characters where id = nullif(v_roll->>'targetId','')::uuid and campaign_id = v_round.campaign_id ) ) then raise exception 'roll actor or target is outside the round campaign'; end if; insert into public.dice_rolls(id, round_id, actor_id, target_id, check_kind, formula, rolls, kept, modifier, total, difficulty, success, created_at) values ((v_roll->>'id')::uuid, p_round_id, (v_roll->>'actorId')::uuid, nullif(v_roll->>'targetId','')::uuid, v_roll->>'checkKind', v_roll->>'formula', array(select jsonb_array_elements_text(v_roll->'rolls')::integer), array(select jsonb_array_elements_text(v_roll->'kept')::integer), (v_roll->>'modifier')::integer, (v_roll->>'total')::integer, nullif(v_roll->>'difficulty','')::integer, (v_roll->>'success')::boolean, (v_roll->>'createdAt')::timestamptz); end loop; for v_event in select * from jsonb_array_elements(p_events) loop if coalesce(btrim(v_event->>'type'), '') = '' then raise exception 'event type is required'; end if; insert into public.game_events(campaign_id, round_id, event_type, payload) values (v_round.campaign_id, p_round_id, v_event->>'type', v_event); end loop; for v_state in select * from jsonb_array_elements(p_character_states) loop v_character_id := (v_state->>'id')::uuid; if v_character_id = any(v_seen_character_ids) then raise exception 'duplicate character state for %', v_character_id; end if; v_seen_character_ids := array_append(v_seen_character_ids, v_character_id); if coalesce(jsonb_typeof(v_state->'inventory'), '') <> 'array' or coalesce(jsonb_typeof(v_state->'statuses'), '') <> 'array' then raise exception 'inventory and statuses must be arrays'; end if; update public.characters set hp = (v_state->>'hp')::integer, inventory = v_state->'inventory', statuses = v_state->'statuses' where id = v_character_id and campaign_id = v_round.campaign_id; get diagnostics v_row_count = row_count; if v_row_count <> 1 then raise exception 'character % is outside the round campaign', v_character_id; end if; end loop; if p_memory is not null and p_memory <> 'null'::jsonb then if coalesce(jsonb_typeof(p_memory), '') <> 'object' then raise exception 'memory must be an object'; end if; insert into public.memories(campaign_id, round_id, summary, importance, tags, entity_ids) values (v_round.campaign_id, p_round_id, p_memory->>'summary', (p_memory->>'importance')::integer, array(select jsonb_array_elements_text(coalesce(p_memory->'tags', '[]'::jsonb))), array(select jsonb_array_elements_text(coalesce(p_memory->'entityIds', '[]'::jsonb))::uuid)); end if; update public.rounds set status = 'resolved', narration = p_narration, next_prompt = p_next_prompt, resolved_at = now() where id = p_round_id; update public.campaigns set current_scene = p_narration, next_prompt = p_next_prompt, updated_at = now() where id = v_round.campaign_id; update public.ai_jobs set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now() where id = v_job.id; end; $$; revoke all on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated; grant execute on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role; create or replace function public.commit_claimed_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_idempotency_key text, p_worker_id text ) returns void language plpgsql security definer set search_path = '' as $$ begin if p_worker_id is null or btrim(p_worker_id) = '' then raise exception 'worker id is required'; end if; if not exists ( select 1 from public.ai_jobs where idempotency_key = p_idempotency_key and job_type = 'resolve-round' and entity_id = p_round_id and status = 'running' and claimed_by = p_worker_id and lease_expires_at > now() ) then raise exception 'resolution job is not owned by this worker'; end if; perform public.commit_round_resolution(p_round_id, p_narration, p_next_prompt, p_rolls, p_events, p_character_states, p_memory, p_idempotency_key); end; $$; revoke all on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated; grant execute on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;