fix(gameplay): start campaigns in playable state
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 32m54s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-21 19:41:50 +05:00
parent 19693c78c4
commit f7ca158fe4
9 changed files with 590 additions and 44 deletions

View File

@@ -2324,6 +2324,226 @@ grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
-- ============================================================================
-- 0010_playable_starter_parties.sql
-- ============================================================================
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter and the party receives one persistent AI companion.
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;
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;
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.'
)
);
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;
$$;
-- Members who have not created a character cannot submit an intent and must not
-- hold the round open. Only active members with a human-controlled character
-- participate in the readiness barrier.
create or replace function public.stage_two_submit_intent(
p_round_id uuid,
p_user_id uuid,
p_character_id uuid,
p_action text,
p_ready boolean default false
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_member public.campaign_members%rowtype;
v_intent_id uuid;
v_job_id uuid;
begin
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
raise exception 'action must be between 1 and 2000 characters';
end if;
select * into v_round from public.rounds where id = p_round_id for update;
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
select * into v_member from public.campaign_members
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
if not found then raise exception 'active campaign membership not found'; end if;
if not exists (
select 1 from public.characters
where id = p_character_id and campaign_id = v_round.campaign_id
and user_id = p_user_id and controller = 'human'
) then raise exception 'controlled character not found'; end if;
insert into public.player_intents(round_id, member_id, character_id, action, ready)
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
on conflict (round_id, member_id) do update
set character_id = excluded.character_id, action = excluded.action,
ready = excluded.ready, updated_at = now()
returning id into v_intent_id;
if coalesce(p_ready, false) and not 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
v_job_id := public.enqueue_round_resolution(p_round_id, null);
end if;
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
end;
$$;
-- Repair campaigns created before starter parties were automatic.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
campaign.owner_id,
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
left('An adaptable protagonist ready to confront the opening mystery of ' || 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.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
left join public.profiles profile on profile.id = campaign.owner_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
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;
$$;
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';
do $$
begin
@@ -2348,7 +2568,7 @@ begin
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
end if;
if public.dng_schema_version() <> 9 then
if public.dng_schema_version() <> 10 then
raise exception 'D&G bootstrap verification failed: unexpected schema version';
end if;
end;

View File

@@ -0,0 +1,215 @@
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter and the party receives one persistent AI companion.
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;
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;
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.'
)
);
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;
$$;
-- Members who have not created a character cannot submit an intent and must not
-- hold the round open. Only active members with a human-controlled character
-- participate in the readiness barrier.
create or replace function public.stage_two_submit_intent(
p_round_id uuid,
p_user_id uuid,
p_character_id uuid,
p_action text,
p_ready boolean default false
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_member public.campaign_members%rowtype;
v_intent_id uuid;
v_job_id uuid;
begin
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
raise exception 'action must be between 1 and 2000 characters';
end if;
select * into v_round from public.rounds where id = p_round_id for update;
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
select * into v_member from public.campaign_members
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
if not found then raise exception 'active campaign membership not found'; end if;
if not exists (
select 1 from public.characters
where id = p_character_id and campaign_id = v_round.campaign_id
and user_id = p_user_id and controller = 'human'
) then raise exception 'controlled character not found'; end if;
insert into public.player_intents(round_id, member_id, character_id, action, ready)
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
on conflict (round_id, member_id) do update
set character_id = excluded.character_id, action = excluded.action,
ready = excluded.ready, updated_at = now()
returning id into v_intent_id;
if coalesce(p_ready, false) and not 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
v_job_id := public.enqueue_round_resolution(p_round_id, null);
end if;
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
end;
$$;
-- Repair campaigns created before starter parties were automatic.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
campaign.owner_id,
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
left('An adaptable protagonist ready to confront the opening mystery of ' || 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.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
left join public.profiles profile on profile.id = campaign.owner_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
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;
$$;
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';