fix(gameplay): use AI-generated world companions
Some checks failed
CI / validate (push) Successful in 1h3m54s
CI / validate (pull_request) Failing after 14m14s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-21 20:38:12 +05:00
parent f7ca158fe4
commit 9dd856ff8c
12 changed files with 632 additions and 124 deletions

View File

@@ -1,5 +1,5 @@
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter and the party receives one persistent AI companion.
-- human-controlled starter, and only actionable members hold the round open.
create or replace function public.stage_two_create_campaign(
p_world_id uuid,
@@ -52,30 +52,6 @@ begin
)
);
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,
'Echo',
left('A persistent AI companion shaped by the ' || v_world.genre || ' world of ' || v_world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
);
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
@@ -172,38 +148,6 @@ where campaign.status = 'active'
and character.user_id = campaign.owner_id
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || world.genre || ' world of ' || world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
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
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 10;

View File

@@ -0,0 +1,287 @@
-- 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';