-- Persist the AI companions created with each universe and use that party when -- a campaign starts. This replaces the temporary hard-coded Echo companion. alter table public.worlds add column if not exists starter_companions jsonb not null default '[]'::jsonb; alter table public.worlds drop constraint if exists worlds_starter_companions_array; alter table public.worlds add constraint worlds_starter_companions_array check ( jsonb_typeof(starter_companions) = 'array' and jsonb_array_length(starter_companions) <= 3 ); -- Ready drafts created before companions were part of WorldStarter inherit -- three universe-specific people that the coauthor already generated. update public.coauthor_sessions session set generated_world = jsonb_set( session.generated_world, '{companions}', coalesce(( select jsonb_agg( jsonb_build_object( 'name', npc.value->>'name', 'concept', left(npc.value->>'summary', 600), 'abilities', case npc.position when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb end, 'hp', 10 + npc.position, 'maxHp', 10 + npc.position, 'defense', 11 + npc.position, 'proficiency', 2, 'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'), 'persona', jsonb_build_object( 'voice', left(npc.value->>'summary', 240), 'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300), 'flaw', 'Their personal agenda can complicate the party''s plans.', 'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300) ) ) order by npc.position ) from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb)) with ordinality as npc(value, position) where npc.position <= 3 ), '[]'::jsonb), true ), updated_at = now() where session.generated_world is not null and jsonb_typeof(session.generated_world->'npcs') = 'array' and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0; -- Confirmed universes do not retain the original generation JSON. Build their -- starter companions from the AI-created NPC entities already stored for them. update public.worlds world set starter_companions = coalesce(( select jsonb_agg( jsonb_build_object( 'name', npc.name, 'concept', left(npc.summary, 600), 'abilities', case npc.position when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb end, 'hp', 10 + npc.position, 'maxHp', 10 + npc.position, 'defense', 11 + npc.position, 'proficiency', 2, 'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'), 'persona', jsonb_build_object( 'voice', left(npc.summary, 240), 'motivation', left('Pursue the goal behind: ' || npc.summary, 300), 'flaw', 'Their personal agenda can complicate the party''s plans.', 'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300) ) ) order by npc.position ) from ( select entity.name, entity.summary, (row_number() over (order by entity.created_at, entity.id))::integer as position from public.world_entities entity where entity.world_id = world.id and entity.kind = 'npc' order by entity.created_at, entity.id limit 3 ) npc ), '[]'::jsonb) where jsonb_array_length(world.starter_companions) = 0; create or replace function public.stage_two_confirm_world( 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_world jsonb; v_world_id uuid; v_entity jsonb; 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' and v_session.confirmed_world_id is not null then return v_session.confirmed_world_id; end if; if v_session.status <> 'ready' or v_session.generated_world is null then raise exception 'coauthor session is not ready to confirm'; end if; v_world := v_session.generated_world; if coalesce(v_world->>'title', '') = '' or coalesce(v_world->>'openingScene', '') = '' or jsonb_typeof(v_world->'companions') <> 'array' or jsonb_array_length(v_world->'companions') <> 3 then raise exception 'generated world is incomplete'; end if; insert into public.worlds( owner_id, title, genre, tone, premise, content_boundaries, hidden_threat, hook, opening_scene, starter_companions, status ) values ( p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise', coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat', v_world->>'hook', v_world->>'openingScene', v_world->'companions', 'confirmed' ) returning id into v_world_id; v_entity := v_world->'startingLocation'; insert into public.world_entities(world_id, kind, name, summary, tags, secrets) values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary', array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))), coalesce(v_entity->'secrets', '[]'::jsonb)); for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop insert into public.world_entities(world_id, kind, name, summary, tags, secrets) values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary', array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))), coalesce(v_entity->'secrets', '[]'::jsonb)); end loop; for v_entity in select * from jsonb_array_elements(v_world->'factions') loop insert into public.world_entities(world_id, kind, name, summary, tags, secrets) values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary', array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))), coalesce(v_entity->'secrets', '[]'::jsonb)); end loop; insert into public.world_entities(world_id, kind, name, summary, tags, secrets) values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb); update public.coauthor_sessions set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now() where id = p_session_id; return v_world_id; end; $$; create or replace function public.stage_two_create_campaign( p_world_id uuid, p_owner_id uuid, p_title text ) returns uuid language plpgsql security definer set search_path = '' as $$ declare v_world public.worlds%rowtype; v_campaign_id uuid; v_owner_name text; v_companion jsonb; begin if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then raise exception 'campaign title must be between 3 and 100 characters'; end if; select * into v_world from public.worlds where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share; if not found then raise exception 'confirmed world not found'; end if; if jsonb_typeof(v_world.starter_companions) <> 'array' or jsonb_array_length(v_world.starter_companions) = 0 then raise exception 'confirmed world has no AI companions'; end if; select nullif(btrim(display_name), '') into v_owner_name from public.profiles where id = p_owner_id; v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80); insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status) values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active') returning id into v_campaign_id; insert into public.campaign_members(campaign_id, user_id, role) values (v_campaign_id, p_owner_id, 'owner'); insert into public.characters( campaign_id, user_id, name, concept, controller, abilities, hp, max_hp, defense, proficiency, inventory, statuses, persona ) values ( v_campaign_id, p_owner_id, v_owner_name, left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600), 'human'::public.character_controller, '{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb, 12, 12, 12, 2, '["Field kit","Personal keepsake"]'::jsonb, '[]'::jsonb, jsonb_build_object( 'voice', 'Defined by the player.', 'motivation', 'Discover what the opening scene is hiding.', 'flaw', 'Still learning what this world demands.', 'bond', 'Protect the party through the first danger.' ) ); for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop insert into public.characters( campaign_id, user_id, name, concept, controller, abilities, hp, max_hp, defense, proficiency, inventory, statuses, persona ) values ( v_campaign_id, null, v_companion->>'name', v_companion->>'concept', 'ai'::public.character_controller, v_companion->'abilities', (v_companion->>'hp')::integer, (v_companion->>'maxHp')::integer, (v_companion->>'defense')::integer, (v_companion->>'proficiency')::integer, v_companion->'inventory', '[]'::jsonb, v_companion->'persona' ); end loop; insert into public.rounds(campaign_id, number) values (v_campaign_id, 1); return v_campaign_id; end; $$; -- Remove only the exact temporary Echo inserted by schema v10, preserving any -- independently authored character that happens to share the name. delete from public.characters character where character.user_id is null and character.controller = 'ai'::public.character_controller and character.name = 'Echo' and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb and character.persona->>'voice' = 'Observant, concise, and quietly curious.' and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.' and character.concept like 'A persistent AI companion shaped by the %'; -- Existing active campaigns receive the companion roster belonging to their -- universe. Name matching keeps this migration idempotent around manual heroes. insert into public.characters( campaign_id, user_id, name, concept, controller, abilities, hp, max_hp, defense, proficiency, inventory, statuses, persona ) select campaign.id, null, companion.value->>'name', companion.value->>'concept', 'ai'::public.character_controller, companion.value->'abilities', (companion.value->>'hp')::integer, (companion.value->>'maxHp')::integer, (companion.value->>'defense')::integer, (companion.value->>'proficiency')::integer, companion.value->'inventory', '[]'::jsonb, companion.value->'persona' from public.campaigns campaign join public.worlds world on world.id = campaign.world_id cross join lateral jsonb_array_elements(world.starter_companions) companion(value) where campaign.status = 'active' and not exists ( select 1 from public.characters character where character.campaign_id = campaign.id and character.controller = 'ai'::public.character_controller and lower(character.name) = lower(companion.value->>'name') ); create or replace function public.dng_schema_version() returns integer language sql stable security definer set search_path = '' as $$ select 11; $$; revoke all on function public.dng_schema_version() from public, anon, authenticated; grant execute on function public.dng_schema_version() to service_role; notify pgrst, 'reload schema';