Files
Dungeons-Ground/supabase/migrations/0012_versioned_srd_rules.sql
pavel444-byte 6c39188027
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s
feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 08:45:50 +05:00

353 lines
15 KiB
PL/PgSQL

-- Introduce a versioned SRD 5.2.1 rules profile without rebuilding campaigns.
-- Existing narrative, rounds, HP, inventory and statuses remain untouched.
alter table public.campaigns
add column if not exists ruleset_version smallint;
insert into public.audit_entries(
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
)
select
campaign.id,
campaign.owner_id,
'migrate_ruleset',
'campaign',
campaign.id,
jsonb_build_object('rulesetVersion', coalesce(campaign.ruleset_version, 1)),
jsonb_build_object(
'rulesetVersion', 2,
'ruleset', 'SRD 5.2.1 alpha',
'preserved', jsonb_build_array('rounds', 'history', 'hp', 'inventory', 'statuses')
)
from public.campaigns campaign
where coalesce(campaign.ruleset_version, 1) < 2;
update public.campaigns
set ruleset_version = 2
where ruleset_version is null or ruleset_version < 2;
alter table public.campaigns
alter column ruleset_version set default 2,
alter column ruleset_version set not null;
alter table public.campaigns
drop constraint if exists campaigns_ruleset_version_supported;
alter table public.campaigns
add constraint campaigns_ruleset_version_supported check (ruleset_version = 2);
alter table public.characters
add column if not exists rules_state jsonb;
-- Compatibility is deliberate: migrated heroes retain the old planner hint
-- only until their profile is explicitly edited. Newly-authored profiles use
-- server-owned skill/save lists and set this flag to false.
update public.characters character
set rules_state = jsonb_build_object(
'version', 2,
'level', case
when character.proficiency <= 2 then 1
when character.proficiency = 3 then 5
when character.proficiency = 4 then 9
when character.proficiency = 5 then 13
else 17
end,
'skillProficiencies', '[]'::jsonb,
'skillExpertise', '[]'::jsonb,
'savingThrowProficiencies', '[]'::jsonb,
'temporaryHp', 0,
'deathSaves', jsonb_build_object('successes', 0, 'failures', 0),
'exhaustion', 0,
'damageResistances', '[]'::jsonb,
'damageVulnerabilities', '[]'::jsonb,
'damageImmunities', '[]'::jsonb,
'resources', jsonb_build_object(
'hitDice', jsonb_build_object('current', 1, 'max', 1, 'recovery', 'longRest')
),
'legacyProficiencyFallback', true
)
where character.rules_state is null;
alter table public.characters
alter column rules_state set default '{
"version":2,
"level":1,
"skillProficiencies":[],
"skillExpertise":[],
"savingThrowProficiencies":[],
"temporaryHp":0,
"deathSaves":{"successes":0,"failures":0},
"exhaustion":0,
"damageResistances":[],
"damageVulnerabilities":[],
"damageImmunities":[],
"resources":{"hitDice":{"current":1,"max":1,"recovery":"longRest"}},
"legacyProficiencyFallback":true
}'::jsonb,
alter column rules_state set not null;
alter table public.characters
drop constraint if exists characters_rules_state_v2;
alter table public.characters
add constraint characters_rules_state_v2 check (
jsonb_typeof(rules_state) = 'object'
and rules_state->>'version' = '2'
and (rules_state->>'level')::integer between 1 and 20
and jsonb_typeof(rules_state->'skillProficiencies') = 'array'
and jsonb_typeof(rules_state->'skillExpertise') = 'array'
and jsonb_typeof(rules_state->'savingThrowProficiencies') = 'array'
and (rules_state->>'temporaryHp')::integer between 0 and 999
and jsonb_typeof(rules_state->'deathSaves') = 'object'
and (rules_state->'deathSaves'->>'successes')::integer between 0 and 3
and (rules_state->'deathSaves'->>'failures')::integer between 0 and 3
and (rules_state->>'exhaustion')::integer between 0 and 6
and jsonb_typeof(rules_state->'damageResistances') = 'array'
and jsonb_typeof(rules_state->'damageVulnerabilities') = 'array'
and jsonb_typeof(rules_state->'damageImmunities') = 'array'
and jsonb_typeof(rules_state->'resources') = 'object'
and jsonb_typeof(rules_state->'legacyProficiencyFallback') = 'boolean'
);
-- Older world drafts gain deterministic proficiency data so a newly-started
-- campaign is fully on rules v2 even when its universe predates this migration.
update public.worlds world
set starter_companions = coalesce((
select jsonb_agg(
companion.value || case companion.position
when 1 then '{"skillProficiencies":["acrobatics","perception","stealth","survival"],"skillExpertise":[],"savingThrowProficiencies":["dex","wis"]}'::jsonb
when 2 then '{"skillProficiencies":["athletics","intimidation","perception","survival"],"skillExpertise":[],"savingThrowProficiencies":["str","con"]}'::jsonb
else '{"skillProficiencies":["arcana","history","investigation","perception"],"skillExpertise":["investigation"],"savingThrowProficiencies":["int","wis"]}'::jsonb
end
order by companion.position
)
from jsonb_array_elements(world.starter_companions) with ordinality as companion(value, position)
), '[]'::jsonb)
where jsonb_typeof(world.starter_companions) = 'array';
update public.coauthor_sessions session
set generated_world = jsonb_set(
session.generated_world,
'{companions}',
coalesce((
select jsonb_agg(
companion.value || case companion.position
when 1 then '{"skillProficiencies":["acrobatics","perception","stealth","survival"],"skillExpertise":[],"savingThrowProficiencies":["dex","wis"]}'::jsonb
when 2 then '{"skillProficiencies":["athletics","intimidation","perception","survival"],"skillExpertise":[],"savingThrowProficiencies":["str","con"]}'::jsonb
else '{"skillProficiencies":["arcana","history","investigation","perception"],"skillExpertise":["investigation"],"savingThrowProficiencies":["int","wis"]}'::jsonb
end
order by companion.position
)
from jsonb_array_elements(session.generated_world->'companions') with ordinality as companion(value, position)
), '[]'::jsonb),
true
)
where session.generated_world is not null
and jsonb_typeof(session.generated_world->'companions') = 'array';
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, ruleset_version)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active', 2)
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, rules_state
) 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.'
),
'{
"version":2,"level":1,
"skillProficiencies":["investigation","perception","persuasion","survival"],
"skillExpertise":[],"savingThrowProficiencies":["dex","wis"],
"temporaryHp":0,"deathSaves":{"successes":0,"failures":0},"exhaustion":0,
"damageResistances":[],"damageVulnerabilities":[],"damageImmunities":[],
"resources":{"hitDice":{"current":1,"max":1,"recovery":"longRest"}},
"legacyProficiencyFallback":false
}'::jsonb
);
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, rules_state
) 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',
jsonb_build_object(
'version', 2, 'level', 1,
'skillProficiencies', coalesce(v_companion->'skillProficiencies', '[]'::jsonb),
'skillExpertise', coalesce(v_companion->'skillExpertise', '[]'::jsonb),
'savingThrowProficiencies', coalesce(v_companion->'savingThrowProficiencies', '[]'::jsonb),
'temporaryHp', 0, 'deathSaves', jsonb_build_object('successes', 0, 'failures', 0),
'exhaustion', 0, 'damageResistances', '[]'::jsonb,
'damageVulnerabilities', '[]'::jsonb, 'damageImmunities', '[]'::jsonb,
'resources', jsonb_build_object('hitDice', jsonb_build_object('current', 1, 'max', 1, 'recovery', 'longRest')),
'legacyProficiencyFallback', false
)
);
end loop;
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
create or replace function public.stage_five_create_character(
p_campaign_id uuid,
p_actor_id uuid,
p_controller text,
p_name text,
p_concept text,
p_abilities jsonb,
p_hp integer,
p_max_hp integer,
p_defense integer,
p_proficiency integer,
p_inventory jsonb,
p_statuses jsonb,
p_persona jsonb,
p_rules_state jsonb
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_character_id uuid;
begin
if coalesce(jsonb_typeof(p_rules_state), '') <> 'object' or p_rules_state->>'version' <> '2' then
raise exception 'a version 2 rules profile is required';
end if;
v_character_id := public.stage_four_create_character(
p_campaign_id, p_actor_id, p_controller, p_name, p_concept, p_abilities,
p_hp, p_max_hp, p_defense, p_proficiency, p_inventory, p_statuses, p_persona
);
update public.characters set rules_state = p_rules_state where id = v_character_id;
return v_character_id;
end;
$$;
create or replace function public.apply_srd_character_rules(
p_round_id uuid,
p_character_states jsonb
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_campaign_id uuid;
v_state jsonb;
v_character_id uuid;
v_seen uuid[] := '{}';
begin
select campaign_id into v_campaign_id from public.rounds where id = p_round_id;
if not found then raise exception 'round not found'; end if;
if coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
raise exception 'character states must be an array';
end if;
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) then raise exception 'duplicate character state for %', v_character_id; end if;
v_seen := array_append(v_seen, v_character_id);
if coalesce(jsonb_typeof(v_state->'rulesState'), '') <> 'object'
or v_state->'rulesState'->>'version' <> '2' then
raise exception 'character % has an invalid rules profile', v_character_id;
end if;
update public.characters
set rules_state = v_state->'rulesState'
where id = v_character_id and campaign_id = v_campaign_id;
if not found then raise exception 'character % is outside the round campaign', v_character_id; end if;
end loop;
end;
$$;
create or replace function public.commit_srd_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 $$
begin
if exists (select 1 from public.rounds where id = p_round_id and status = 'resolved') then return; 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
);
perform public.apply_srd_character_rules(p_round_id, p_character_states);
end;
$$;
create or replace function public.commit_claimed_srd_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
perform public.commit_claimed_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.apply_srd_character_rules(p_round_id, p_character_states);
end;
$$;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 12;
$$;
revoke all on function public.stage_five_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.apply_srd_character_rules(uuid, jsonb) from public, anon, authenticated;
revoke all on function public.commit_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
revoke all on function public.commit_claimed_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.stage_five_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb, jsonb) to service_role;
grant execute on function public.commit_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
grant execute on function public.commit_claimed_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';