feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
543
supabase/migrations/0013_memory_and_stability.sql
Normal file
543
supabase/migrations/0013_memory_and_stability.sql
Normal file
@@ -0,0 +1,543 @@
|
||||
-- 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';
|
||||
Reference in New Issue
Block a user