feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
352
supabase/migrations/0012_versioned_srd_rules.sql
Normal file
352
supabase/migrations/0012_versioned_srd_rules.sql
Normal file
@@ -0,0 +1,352 @@
|
||||
-- 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';
|
||||
Reference in New Issue
Block a user