-- 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';