DUNGEONS-21: add versioned SRD rules migration #17
@@ -18,6 +18,8 @@ To verify the complete hosted-Supabase multiplayer path (two temporary email acc
|
|||||||
|
|
||||||
Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix.
|
Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix.
|
||||||
|
|
||||||
|
Existing campaigns are upgraded automatically to ruleset version 2 by `pnpm db:ensure`. The migration preserves every round, narration, current HP, inventory, and status; adds the SRD 5.2.1 character rules profile; and records one `migrate_ruleset` audit entry per campaign. Migrated characters retain compatibility with their previous proficiency hints, while newly created characters use explicit server-owned skill, expertise, and saving-throw proficiencies.
|
||||||
|
|
||||||
The web and worker commands both load this root `.env` file explicitly. Environment variables supplied by Railway or CI take precedence over values in the file.
|
The web and worker commands both load this root `.env` file explicitly. Environment variables supplied by Railway or CI take precedence over values in the file.
|
||||||
|
|
||||||
The UI can be built without credentials. A running Nitro server and every worker process require `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NUXT_PUBLIC_SUPABASE_URL`, and `NUXT_PUBLIC_SUPABASE_ANON_KEY`; startup fails with a clear error when required server credentials are missing. Redis is optional: when `REDIS_URL` is absent, the worker safely claims jobs from the Supabase `ai_jobs` outbox using short database leases and retries failed jobs up to three times.
|
The UI can be built without credentials. A running Nitro server and every worker process require `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NUXT_PUBLIC_SUPABASE_URL`, and `NUXT_PUBLIC_SUPABASE_ANON_KEY`; startup fails with a clear error when required server credentials are missing. Redis is optional: when `REDIS_URL` is absent, the worker safely claims jobs from the Supabase `ai_jobs` outbox using short database leases and retries failed jobs up to three times.
|
||||||
@@ -41,7 +43,7 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
|
|||||||
- Worlds are private by default.
|
- Worlds are private by default.
|
||||||
- Content is limited to 13+; explicit sexual content and sexual content involving minors are rejected.
|
- Content is limited to 13+; explicit sexual content and sexual content involving minors are rejected.
|
||||||
- AI responses are parsed against strict schemas.
|
- AI responses are parsed against strict schemas.
|
||||||
- AI can propose checks and events, but cannot directly set dice outcomes or mechanical values.
|
- AI can propose checks and events, but cannot directly set dice outcomes, proficiency, temporary HP, death saves, resources, or other mechanical values.
|
||||||
- SRD-derived work must retain the attribution in `LEGAL.md`.
|
- SRD-derived work must retain the attribution in `LEGAL.md`.
|
||||||
|
|
||||||
## Production services
|
## Production services
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const defaultWorld: WorldStarter = {
|
|||||||
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
||||||
],
|
],
|
||||||
companions: [
|
companions: [
|
||||||
{ name: 'Rook-7', concept: 'A security intelligence in a weathered rescue frame.', abilities: { str: 16, dex: 11, con: 15, int: 10, wis: 12, cha: 8 }, hp: 15, maxHp: 15, defense: 15, proficiency: 2, inventory: ['Arc baton', 'Emergency beacon'], persona: { voice: 'Direct and protective.', motivation: 'Keep the crew alive long enough to learn the truth.', flaw: 'Treats uncertainty as a threat.', bond: 'The Orison crew gave Rook a second purpose.' } },
|
{ name: 'Rook-7', concept: 'A security intelligence in a weathered rescue frame.', abilities: { str: 16, dex: 11, con: 15, int: 10, wis: 12, cha: 8 }, hp: 15, maxHp: 15, defense: 15, proficiency: 2, inventory: ['Arc baton', 'Emergency beacon'], skillProficiencies: ['athletics', 'intimidation', 'perception', 'survival'], skillExpertise: [], savingThrowProficiencies: ['str', 'con'], persona: { voice: 'Direct and protective.', motivation: 'Keep the crew alive long enough to learn the truth.', flaw: 'Treats uncertainty as a threat.', bond: 'The Orison crew gave Rook a second purpose.' } },
|
||||||
{ name: 'Sable Thread', concept: 'A probabilistic navigator who reads possible futures as tangled routes.', abilities: { str: 8, dex: 14, con: 10, int: 16, wis: 13, cha: 11 }, hp: 10, maxHp: 10, defense: 13, proficiency: 2, inventory: ['Route prism', 'Vacuum line'], persona: { voice: 'Careful, elliptical, and precise.', motivation: 'Find the future in which everyone returns.', flaw: 'Can hesitate when too many paths look viable.', bond: 'Trusts the party to choose what prediction cannot.' } },
|
{ name: 'Sable Thread', concept: 'A probabilistic navigator who reads possible futures as tangled routes.', abilities: { str: 8, dex: 14, con: 10, int: 16, wis: 13, cha: 11 }, hp: 10, maxHp: 10, defense: 13, proficiency: 2, inventory: ['Route prism', 'Vacuum line'], skillProficiencies: ['arcana', 'investigation', 'perception', 'stealth'], skillExpertise: ['investigation'], savingThrowProficiencies: ['dex', 'int'], persona: { voice: 'Careful, elliptical, and precise.', motivation: 'Find the future in which everyone returns.', flaw: 'Can hesitate when too many paths look viable.', bond: 'Trusts the party to choose what prediction cannot.' } },
|
||||||
{ name: 'Morrow Coil', concept: 'A former relay technician rebuilt around experimental signal hardware.', abilities: { str: 11, dex: 12, con: 13, int: 14, wis: 10, cha: 13 }, hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: ['Signal probe', 'Insulated field coat'], persona: { voice: 'Warm humor under technical jargon.', motivation: 'Prove the relay can be understood instead of destroyed.', flaw: 'Cannot leave a broken machine alone.', bond: 'Believes this party is the last honest crew in the sector.' } },
|
{ name: 'Morrow Coil', concept: 'A former relay technician rebuilt around experimental signal hardware.', abilities: { str: 11, dex: 12, con: 13, int: 14, wis: 10, cha: 13 }, hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: ['Signal probe', 'Insulated field coat'], skillProficiencies: ['arcana', 'history', 'investigation', 'persuasion'], skillExpertise: [], savingThrowProficiencies: ['int', 'cha'], persona: { voice: 'Warm humor under technical jargon.', motivation: 'Prove the relay can be understood instead of destroyed.', flaw: 'Cannot leave a broken machine alone.', bond: 'Believes this party is the last honest crew in the sector.' } },
|
||||||
],
|
],
|
||||||
hook: 'Dock with the silent relay, locate the source of the impossible distress call, and decide whether its warning should be believed.',
|
hook: 'Dock with the silent relay, locate the source of the impossible distress call, and decide whether its warning should be believed.',
|
||||||
hiddenThreat: 'A causality fracture is teaching the relay to choose which future becomes real.',
|
hiddenThreat: 'A causality fracture is teaching the relay to choose which future becomes real.',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CharacterDraft } from '@dng/shared'
|
import { skillKeys, type CharacterDraft, type SkillKey } from '@dng/shared'
|
||||||
|
|
||||||
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
||||||
type Controller = 'human' | 'ai' | 'delegated'
|
type Controller = 'human' | 'ai' | 'delegated'
|
||||||
@@ -36,6 +36,8 @@ const createKind = ref<'human' | 'ai'>('human')
|
|||||||
const characterForm = reactive({
|
const characterForm = reactive({
|
||||||
name: '', concept: '', hp: 12, maxHp: 12, defense: 12, proficiency: 2,
|
name: '', concept: '', hp: 12, maxHp: 12, defense: 12, proficiency: 2,
|
||||||
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } as Record<AbilityKey, number>,
|
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } as Record<AbilityKey, number>,
|
||||||
|
skillProficiencies: [] as SkillKey[], skillExpertise: [] as SkillKey[],
|
||||||
|
savingThrowProficiencies: [] as AbilityKey[],
|
||||||
persona: { voice: '', motivation: '', flaw: '', bond: '' },
|
persona: { voice: '', motivation: '', flaw: '', bond: '' },
|
||||||
})
|
})
|
||||||
const inventoryText = ref('')
|
const inventoryText = ref('')
|
||||||
@@ -233,6 +235,9 @@ async function suggestCharacter() {
|
|||||||
characterForm.defense = suggestion.defense
|
characterForm.defense = suggestion.defense
|
||||||
characterForm.proficiency = suggestion.proficiency
|
characterForm.proficiency = suggestion.proficiency
|
||||||
characterForm.abilities = { ...suggestion.abilities }
|
characterForm.abilities = { ...suggestion.abilities }
|
||||||
|
characterForm.skillProficiencies = [...suggestion.skillProficiencies]
|
||||||
|
characterForm.skillExpertise = [...suggestion.skillExpertise]
|
||||||
|
characterForm.savingThrowProficiencies = [...suggestion.savingThrowProficiencies]
|
||||||
characterForm.persona = { ...suggestion.persona }
|
characterForm.persona = { ...suggestion.persona }
|
||||||
inventoryText.value = suggestion.inventory.join('\n')
|
inventoryText.value = suggestion.inventory.join('\n')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -251,13 +256,20 @@ async function createCharacter() {
|
|||||||
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
|
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
|
||||||
defense: characterForm.defense, proficiency: characterForm.proficiency,
|
defense: characterForm.defense, proficiency: characterForm.proficiency,
|
||||||
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
|
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
|
||||||
statuses: [], persona: characterForm.persona,
|
statuses: [],
|
||||||
|
skillProficiencies: characterForm.skillProficiencies,
|
||||||
|
skillExpertise: characterForm.skillExpertise.filter(skill => characterForm.skillProficiencies.includes(skill)),
|
||||||
|
savingThrowProficiencies: characterForm.savingThrowProficiencies,
|
||||||
|
persona: characterForm.persona,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
if (success) {
|
if (success) {
|
||||||
showCharacterForm.value = false
|
showCharacterForm.value = false
|
||||||
characterForm.name = ''
|
characterForm.name = ''
|
||||||
characterForm.concept = ''
|
characterForm.concept = ''
|
||||||
|
characterForm.skillProficiencies = []
|
||||||
|
characterForm.skillExpertise = []
|
||||||
|
characterForm.savingThrowProficiencies = []
|
||||||
inventoryText.value = ''
|
inventoryText.value = ''
|
||||||
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
|
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
|
||||||
}
|
}
|
||||||
@@ -298,6 +310,7 @@ onBeforeUnmount(() => {
|
|||||||
<button type="button" class="ai-draft" :disabled="suggestingCharacter || mutating" @click="suggestCharacter">{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING…' : '✦ DRAFT WITH AI' }}</button>
|
<button type="button" class="ai-draft" :disabled="suggestingCharacter || mutating" @click="suggestCharacter">{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING…' : '✦ DRAFT WITH AI' }}</button>
|
||||||
<div class="ability-editor"><label v-for="key in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="key"><span>{{ key }}</span><input v-model.number="characterForm.abilities[key]" type="number" min="1" max="30"></label></div>
|
<div class="ability-editor"><label v-for="key in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="key"><span>{{ key }}</span><input v-model.number="characterForm.abilities[key]" type="number" min="1" max="30"></label></div>
|
||||||
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>AC <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
|
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>AC <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
|
||||||
|
<details class="proficiency-editor"><summary>SRD PROFICIENCIES</summary><small>SKILLS</small><div><label v-for="skill in skillKeys" :key="skill"><input v-model="characterForm.skillProficiencies" type="checkbox" :value="skill">{{ skill }}</label></div><small>SAVING THROWS</small><div><label v-for="ability in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="ability"><input v-model="characterForm.savingThrowProficiencies" type="checkbox" :value="ability">{{ ability }}</label></div><small>EXPERTISE</small><div><label v-for="skill in characterForm.skillProficiencies" :key="skill"><input v-model="characterForm.skillExpertise" type="checkbox" :value="skill">{{ skill }}</label></div></details>
|
||||||
<textarea v-model="inventoryText" maxlength="800" placeholder="Starting items, one per line" />
|
<textarea v-model="inventoryText" maxlength="800" placeholder="Starting items, one per line" />
|
||||||
<details class="persona-editor"><summary>VOICE & PERSONALITY</summary><input v-model="characterForm.persona.voice" maxlength="240" placeholder="Voice"><input v-model="characterForm.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="characterForm.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="characterForm.persona.bond" maxlength="300" placeholder="Bond"></details>
|
<details class="persona-editor"><summary>VOICE & PERSONALITY</summary><input v-model="characterForm.persona.voice" maxlength="240" placeholder="Voice"><input v-model="characterForm.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="characterForm.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="characterForm.persona.bond" maxlength="300" placeholder="Bond"></details>
|
||||||
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
|
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
|
||||||
@@ -308,7 +321,7 @@ onBeforeUnmount(() => {
|
|||||||
</article>
|
</article>
|
||||||
<section v-if="myCharacter" class="sheet">
|
<section v-if="myCharacter" class="sheet">
|
||||||
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
|
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
|
||||||
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP</span><span><b>{{ myCharacter.defense }}</b> AC</span></div>
|
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP<span v-if="myCharacter.rules_state?.temporaryHp"> + {{ myCharacter.rules_state.temporaryHp }} TEMP</span></span><span><b>{{ myCharacter.defense }}</b> AC</span><span v-if="myCharacter.rules_state"><b>{{ myCharacter.rules_state.level }}</b> LEVEL</span></div>
|
||||||
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }} {{ abilityLabel(Number(score)) }}</small><b>{{ score }}</b></span></div>
|
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }} {{ abilityLabel(Number(score)) }}</small><b>{{ score }}</b></span></div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -343,7 +356,7 @@ onBeforeUnmount(() => {
|
|||||||
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
||||||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||||||
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span>↗</span></button>
|
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span>↗</span></button>
|
||||||
<div class="mechanics-note"><i /> D20 tests, AC, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
<div class="mechanics-note"><i /> SRD 5.2.1 RULES V{{ campaign?.ruleset_version ?? 2 }} · D20 tests, proficiency, conditions, damage and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
@@ -361,6 +374,7 @@ onBeforeUnmount(() => {
|
|||||||
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
||||||
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
||||||
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
||||||
|
.proficiency-editor{border:1px solid var(--line);padding:9px}.proficiency-editor summary,.proficiency-editor>small{color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.proficiency-editor>small{display:block;margin:10px 0 5px;color:var(--acid)}.proficiency-editor>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px}.proficiency-editor label{display:flex;gap:5px;align-items:center;overflow-wrap:anywhere;color:var(--muted);font:500 6px var(--mono);letter-spacing:0}.proficiency-editor input{width:auto;margin:0;padding:0}
|
||||||
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
|
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
|
||||||
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
|
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
|
||||||
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
|
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
`campaign_members?select=id,user_id,role,active,ai_takeover_allowed,joined_at&campaign_id=eq.${campaignId}&order=joined_at.asc`,
|
`campaign_members?select=id,user_id,role,active,ai_takeover_allowed,joined_at&campaign_id=eq.${campaignId}&order=joined_at.asc`,
|
||||||
),
|
),
|
||||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||||
`characters?select=id,campaign_id,user_id,name,concept,controller,abilities,hp,max_hp,defense,proficiency,inventory,statuses,persona,created_at&campaign_id=eq.${campaignId}&order=created_at.asc`,
|
`characters?select=id,campaign_id,user_id,name,concept,controller,abilities,hp,max_hp,defense,proficiency,inventory,statuses,persona,rules_state,created_at&campaign_id=eq.${campaignId}&order=created_at.asc`,
|
||||||
),
|
),
|
||||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&status=eq.open&order=number.desc&limit=1`,
|
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&status=eq.open&order=number.desc&limit=1`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AbilityScoresSchema } from '@dng/shared'
|
import { AbilityKeySchema, AbilityScoresSchema, SkillKeySchema } from '@dng/shared'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
|
||||||
@@ -13,8 +13,15 @@ const BodySchema = z.object({
|
|||||||
proficiency: z.number().int().min(1).max(10).default(2),
|
proficiency: z.number().int().min(1).max(10).default(2),
|
||||||
inventory: z.array(z.string().trim().min(1).max(120)).max(50).default([]),
|
inventory: z.array(z.string().trim().min(1).max(120)).max(50).default([]),
|
||||||
statuses: z.array(z.string().trim().min(1).max(80)).max(12).default([]),
|
statuses: z.array(z.string().trim().min(1).max(80)).max(12).default([]),
|
||||||
|
skillProficiencies: z.array(SkillKeySchema).max(8).default([]),
|
||||||
|
skillExpertise: z.array(SkillKeySchema).max(4).default([]),
|
||||||
|
savingThrowProficiencies: z.array(AbilityKeySchema).max(3).default([]),
|
||||||
persona: z.record(z.string(), z.unknown()).default({}),
|
persona: z.record(z.string(), z.unknown()).default({}),
|
||||||
}).strict().refine(value => value.hp <= value.maxHp, { message: 'hp must not exceed maxHp', path: ['hp'] })
|
}).strict()
|
||||||
|
.refine(value => value.hp <= value.maxHp, { message: 'hp must not exceed maxHp', path: ['hp'] })
|
||||||
|
.refine(value => value.skillExpertise.every(skill => value.skillProficiencies.includes(skill)), {
|
||||||
|
message: 'expertise requires skill proficiency', path: ['skillExpertise'],
|
||||||
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
||||||
}
|
}
|
||||||
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
|
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
|
||||||
const characterId = await stageTwoRpc<string>('stage_four_create_character', {
|
const characterId = await stageTwoRpc<string>('stage_five_create_character', {
|
||||||
p_campaign_id: campaignId,
|
p_campaign_id: campaignId,
|
||||||
p_actor_id: user.id,
|
p_actor_id: user.id,
|
||||||
p_controller: body.controller,
|
p_controller: body.controller,
|
||||||
@@ -40,6 +47,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
p_inventory: body.inventory,
|
p_inventory: body.inventory,
|
||||||
p_statuses: body.statuses,
|
p_statuses: body.statuses,
|
||||||
p_persona: body.persona,
|
p_persona: body.persona,
|
||||||
|
p_rules_state: {
|
||||||
|
version: 2,
|
||||||
|
level: 1,
|
||||||
|
skillProficiencies: body.skillProficiencies,
|
||||||
|
skillExpertise: body.skillExpertise,
|
||||||
|
savingThrowProficiencies: body.savingThrowProficiencies,
|
||||||
|
temporaryHp: 0,
|
||||||
|
deathSaves: { successes: 0, failures: 0 },
|
||||||
|
exhaustion: 0,
|
||||||
|
damageResistances: [],
|
||||||
|
damageVulnerabilities: [],
|
||||||
|
damageImmunities: [],
|
||||||
|
resources: { hitDice: { current: 1, max: 1, recovery: 'longRest' } },
|
||||||
|
legacyProficiencyFallback: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||||
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
|
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
|
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
|
||||||
|
|
||||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||||
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 7–16, max HP 8–20, Armor Class (AC) 10–16, proficiency 2. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
|
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ SRD 5.2.1 TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 7–16, max HP 8–20, Armor Class (AC) 10–16, proficiency 2. Choose exactly four fitting skill proficiencies and two saving throw proficiencies; expertise may contain at most one of the chosen skills. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
|
||||||
messages: [{
|
messages: [{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: JSON.stringify({
|
content: JSON.stringify({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
)
|
)
|
||||||
const filters = [`owner_id.eq.${user.id}`, ...memberships.map(item => `id.eq.${item.campaign_id}`)]
|
const filters = [`owner_id.eq.${user.id}`, ...memberships.map(item => `id.eq.${item.campaign_id}`)]
|
||||||
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||||
`campaigns?select=id,world_id,owner_id,title,current_scene,next_prompt,status,created_at,updated_at&or=(${filters.join(',')})&order=updated_at.desc`,
|
`campaigns?select=id,world_id,owner_id,title,current_scene,next_prompt,status,ruleset_version,created_at,updated_at&or=(${filters.join(',')})&order=updated_at.desc`,
|
||||||
)
|
)
|
||||||
return { campaigns }
|
return { campaigns }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const worldFixture = () => ({
|
|||||||
defense: 12,
|
defense: 12,
|
||||||
proficiency: 2,
|
proficiency: 2,
|
||||||
inventory: ['Field kit'],
|
inventory: ['Field kit'],
|
||||||
|
skillProficiencies: ['arcana', 'investigation', 'perception', 'survival'],
|
||||||
|
skillExpertise: ['investigation'],
|
||||||
|
savingThrowProficiencies: ['int', 'wis'],
|
||||||
persona: { voice: 'Distinctive and clear.', motivation: 'Help the party.', flaw: 'Has a private agenda.', bond: 'Believes in the party.' },
|
persona: { voice: 'Distinctive and clear.', motivation: 'Help the party.', flaw: 'Has a private agenda.', bond: 'Believes in the party.' },
|
||||||
})),
|
})),
|
||||||
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
|
|||||||
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
|
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
|
||||||
assert13Plus(...messages.map(message => message.content))
|
assert13Plus(...messages.map(message => message.content))
|
||||||
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
|
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
|
||||||
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
|
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private SRD 5.2.1 TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, exactly four fitting skill proficiencies, exactly two saving throw proficiencies, no more than one expertise chosen from their proficient skills, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
|
||||||
...messages,
|
...messages,
|
||||||
], value => WorldStarterSchema.parse(value))
|
], value => WorldStarterSchema.parse(value))
|
||||||
// Moderate the entire typed object, including entity summaries, secrets and
|
// Moderate the entire typed object, including entity summaries, secrets and
|
||||||
@@ -100,7 +100,7 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
|
|||||||
|
|
||||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
||||||
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||||
{ role: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Set proficient true for a skill or saving throw only when the character concept supports it; ordinary equipped-weapon attacks default to proficient. Request separate damage or healing dice only after an applicable action. Never invent dice results and never directly mutate mechanical state. The server owns rules, AC, HP, inventory, statuses and randomness.' },
|
{ role: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, deathSavingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP and randomness.' },
|
||||||
{ role: 'user', content: JSON.stringify(input) },
|
{ role: 'user', content: JSON.stringify(input) },
|
||||||
], value => RoundPlanSchema.parse(value))
|
], value => RoundPlanSchema.parse(value))
|
||||||
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
||||||
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
|
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
|
||||||
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
|
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
|
||||||
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses, persona: row.persona,
|
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
|
||||||
|
persona: row.persona, rules: row.rules_state,
|
||||||
}))
|
}))
|
||||||
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
|
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
|
||||||
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
|
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
|
||||||
@@ -157,7 +158,7 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
||||||
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
|
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
|
||||||
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
||||||
const applied = applyMechanicalEvents(characters, safeEvents)
|
const applied = applyMechanicalEvents(characters, safeEvents, rolls)
|
||||||
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
|
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
|
||||||
const summary = shouldSummarize
|
const summary = shouldSummarize
|
||||||
? await summarizeStory({
|
? await summarizeStory({
|
||||||
@@ -166,7 +167,7 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
|
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_srd_round_resolution' : 'rpc/commit_srd_round_resolution', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_round_id: round.id,
|
p_round_id: round.id,
|
||||||
@@ -174,7 +175,13 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
p_next_prompt: resolution.nextPrompt,
|
p_next_prompt: resolution.nextPrompt,
|
||||||
p_rolls: rolls,
|
p_rolls: rolls,
|
||||||
p_events: safeEvents,
|
p_events: safeEvents,
|
||||||
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
|
p_character_states: applied.characters.map(character => ({
|
||||||
|
id: character.id,
|
||||||
|
hp: character.hp,
|
||||||
|
inventory: character.inventory,
|
||||||
|
statuses: character.statuses,
|
||||||
|
rulesState: character.rules,
|
||||||
|
})),
|
||||||
p_memory: sanitizeRoundMemory(resolution.memory),
|
p_memory: sanitizeRoundMemory(resolution.memory),
|
||||||
p_idempotency_key: job.id,
|
p_idempotency_key: job.id,
|
||||||
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
||||||
|
|||||||
@@ -69,15 +69,19 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
|
|||||||
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
|
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
|
||||||
throw new Error(`${check.kind} checks require a Difficulty Class`)
|
throw new Error(`${check.kind} checks require a Difficulty Class`)
|
||||||
}
|
}
|
||||||
|
if (check.kind === 'deathSavingThrow' && check.targetId) throw new Error('Death saving throws cannot target another character')
|
||||||
if (check.kind === 'attack' && !check.targetId) throw new Error('Attack rolls require a target')
|
if (check.kind === 'attack' && !check.targetId) throw new Error('Attack rolls require a target')
|
||||||
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
|
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
|
||||||
}
|
}
|
||||||
for (const event of plan.proposedEvents) {
|
for (const event of plan.proposedEvents) {
|
||||||
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||||
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
||||||
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !event.targetId) {
|
if (['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type) && !event.targetId) {
|
||||||
throw new Error(`${event.type} event requires a target`)
|
throw new Error(`${event.type} event requires a target`)
|
||||||
}
|
}
|
||||||
|
if (event.type === 'damage' && !event.damageType) throw new Error('damage events require a damage type')
|
||||||
|
if (event.type === 'resource' && !event.item) throw new Error('resource events require a resource name')
|
||||||
|
if (event.type === 'rest' && !['short', 'long'].includes(String(event.item))) throw new Error('rest events require short or long')
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiActions = plan.aiActions
|
const aiActions = plan.aiActions
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, proficiencyBonus, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
||||||
import type { Character } from '@dng/shared'
|
import type { Character } from '@dng/shared'
|
||||||
|
|
||||||
const fixed: RandomSource = { integer: () => 12 }
|
const fixed: RandomSource = { integer: () => 12 }
|
||||||
@@ -91,13 +91,13 @@ describe('game engine', () => {
|
|||||||
{ integer: () => 4 },
|
{ integer: () => 4 },
|
||||||
)
|
)
|
||||||
const events = bindMechanicalEventsToRolls([
|
const events = bindMechanicalEventsToRolls([
|
||||||
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, description: 'Strike' },
|
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, damageType: 'slashing', description: 'Strike' },
|
||||||
], [damage])
|
], [damage])
|
||||||
expect(events[0]?.value).toBe(6)
|
expect(events[0]?.value).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps mechanical state', () => {
|
it('clamps mechanical state', () => {
|
||||||
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, description: 'Catastrophic hit' }])
|
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, damageType: 'force', description: 'Catastrophic hit' }])
|
||||||
expect(result.characters[0]?.hp).toBe(0)
|
expect(result.characters[0]?.hp).toBe(0)
|
||||||
expect(result.audit).toHaveLength(1)
|
expect(result.audit).toHaveLength(1)
|
||||||
})
|
})
|
||||||
@@ -107,4 +107,73 @@ describe('game engine', () => {
|
|||||||
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
|
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
|
||||||
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
|
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('derives proficiency and expertise from the server-owned rules profile', () => {
|
||||||
|
const trained: Character = {
|
||||||
|
...hero,
|
||||||
|
rules: {
|
||||||
|
version: 2, level: 9, skillProficiencies: ['perception'], skillExpertise: ['perception'],
|
||||||
|
savingThrowProficiencies: ['con'], temporaryHp: 0, deathSaves: { successes: 0, failures: 0 },
|
||||||
|
exhaustion: 1, damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
|
||||||
|
resources: {}, legacyProficiencyFallback: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const roll = resolveCheck({
|
||||||
|
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: false,
|
||||||
|
difficulty: 10, mode: 'normal', reason: 'Notice the rune',
|
||||||
|
}, trained, fixed)
|
||||||
|
|
||||||
|
expect(proficiencyBonus(9)).toBe(4)
|
||||||
|
// WIS -1 + expertise 8 - exhaustion 2.
|
||||||
|
expect(roll.modifier).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies resistance, temporary HP, zero-HP state and healing recovery', () => {
|
||||||
|
const guarded: Character = {
|
||||||
|
...hero,
|
||||||
|
hp: 4,
|
||||||
|
rules: {
|
||||||
|
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
|
||||||
|
temporaryHp: 2, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
|
||||||
|
damageResistances: ['fire'], damageVulnerabilities: [], damageImmunities: [],
|
||||||
|
resources: {}, legacyProficiencyFallback: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const damaged = applyMechanicalEvents([guarded], [{
|
||||||
|
type: 'damage', actorId: null, targetId: 'hero', value: 12, item: null,
|
||||||
|
damageType: 'fire', description: 'Flames engulf the hero',
|
||||||
|
}]).characters[0]!
|
||||||
|
expect(damaged.rules?.temporaryHp).toBe(0)
|
||||||
|
expect(damaged.hp).toBe(0)
|
||||||
|
expect(damaged.statuses).toContain('unconscious')
|
||||||
|
|
||||||
|
const healed = applyMechanicalEvents([damaged], [{
|
||||||
|
type: 'healing', actorId: null, targetId: 'hero', value: 4, item: null,
|
||||||
|
damageType: null, description: 'An ally restores the hero',
|
||||||
|
}]).characters[0]!
|
||||||
|
expect(healed.hp).toBe(4)
|
||||||
|
expect(healed.statuses).not.toContain('unconscious')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks natural death-save outcomes without model-authored state changes', () => {
|
||||||
|
const dying: Character = {
|
||||||
|
...hero,
|
||||||
|
hp: 0,
|
||||||
|
statuses: ['unconscious'],
|
||||||
|
rules: {
|
||||||
|
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
|
||||||
|
temporaryHp: 0, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
|
||||||
|
damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
|
||||||
|
resources: {}, legacyProficiencyFallback: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const deathRoll = resolveCheck({
|
||||||
|
actorId: 'hero', kind: 'deathSavingThrow', mode: 'normal', reason: 'Cling to life',
|
||||||
|
}, dying, { integer: () => 20 })
|
||||||
|
const recovered = applyMechanicalEvents([dying], [], [deathRoll]).characters[0]!
|
||||||
|
|
||||||
|
expect(recovered.hp).toBe(1)
|
||||||
|
expect(recovered.statuses).not.toContain('unconscious')
|
||||||
|
expect(recovered.rules?.deathSaves).toEqual({ successes: 0, failures: 0 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto'
|
import { randomInt, randomUUID } from 'node:crypto'
|
||||||
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
|
import type { AbilityKey, Character, CharacterRules, CheckRequest, DamageType, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
|
||||||
|
|
||||||
export interface RandomSource {
|
export interface RandomSource {
|
||||||
integer(min: number, max: number): number
|
integer(min: number, max: number): number
|
||||||
@@ -16,6 +16,11 @@ export function abilityModifier(score: number): number {
|
|||||||
return Math.floor((score - 10) / 2)
|
return Math.floor((score - 10) / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function proficiencyBonus(level: number): number {
|
||||||
|
if (!Number.isInteger(level) || level < 1 || level > 20) throw new Error('Character level must be between 1 and 20')
|
||||||
|
return 2 + Math.floor((level - 1) / 4)
|
||||||
|
}
|
||||||
|
|
||||||
export function rollDice(formula: string, random: RandomSource = secureRandom) {
|
export function rollDice(formula: string, random: RandomSource = secureRandom) {
|
||||||
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
||||||
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
||||||
@@ -86,6 +91,54 @@ function usesProficiency(request: CheckRequest): boolean {
|
|||||||
return request.proficient === true
|
return request.proficient === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function proficiencyMultiplier(request: CheckRequest, actor: Character): number {
|
||||||
|
const rules = actor.rules
|
||||||
|
if (!rules) return usesProficiency(request) ? 1 : 0
|
||||||
|
if (request.kind === 'skill' && request.skill) {
|
||||||
|
if (rules.skillExpertise.includes(request.skill)) return 2
|
||||||
|
if (rules.skillProficiencies.includes(request.skill)) return 1
|
||||||
|
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
|
||||||
|
}
|
||||||
|
if (request.kind === 'savingThrow' && request.ability) {
|
||||||
|
if (rules.savingThrowProficiencies.includes(request.ability)) return 1
|
||||||
|
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
|
||||||
|
}
|
||||||
|
if (request.kind === 'attack') return request.proficient === false ? 0 : 1
|
||||||
|
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedStatuses(character: Character): Set<string> {
|
||||||
|
return new Set(character.statuses.map(status => status.trim().toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveRollMode(request: CheckRequest, actor: Character, target?: Character): CheckRequest['mode'] {
|
||||||
|
let advantage = request.mode === 'advantage' ? 1 : 0
|
||||||
|
let disadvantage = request.mode === 'disadvantage' ? 1 : 0
|
||||||
|
const actorStatuses = normalizedStatuses(actor)
|
||||||
|
const targetStatuses = target ? normalizedStatuses(target) : new Set<string>()
|
||||||
|
|
||||||
|
if (['ability', 'skill', 'attack'].includes(request.kind) && actorStatuses.has('poisoned')) disadvantage += 1
|
||||||
|
if (request.kind === 'attack') {
|
||||||
|
if (actorStatuses.has('blinded') || actorStatuses.has('restrained') || actorStatuses.has('prone')) disadvantage += 1
|
||||||
|
if (actorStatuses.has('invisible')) advantage += 1
|
||||||
|
if (targetStatuses.has('blinded') || targetStatuses.has('restrained') || targetStatuses.has('paralyzed')
|
||||||
|
|| targetStatuses.has('petrified') || targetStatuses.has('stunned') || targetStatuses.has('unconscious')) advantage += 1
|
||||||
|
if (targetStatuses.has('invisible')) disadvantage += 1
|
||||||
|
}
|
||||||
|
if (request.kind === 'savingThrow' && request.ability === 'dex' && actorStatuses.has('restrained')) disadvantage += 1
|
||||||
|
|
||||||
|
if (advantage && disadvantage) return 'normal'
|
||||||
|
if (advantage) return 'advantage'
|
||||||
|
if (disadvantage) return 'disadvantage'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function automaticallyFailedSave(request: CheckRequest, actor: Character): boolean {
|
||||||
|
if (request.kind !== 'savingThrow' || !request.ability || !['str', 'dex'].includes(request.ability)) return false
|
||||||
|
const statuses = normalizedStatuses(actor)
|
||||||
|
return ['paralyzed', 'petrified', 'stunned', 'unconscious'].some(status => statuses.has(status))
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCheck(
|
export function resolveCheck(
|
||||||
request: CheckRequest,
|
request: CheckRequest,
|
||||||
actor: Character,
|
actor: Character,
|
||||||
@@ -112,11 +165,27 @@ export function resolveCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.kind === 'deathSavingThrow') {
|
||||||
|
const statuses = normalizedStatuses(actor)
|
||||||
|
if (actor.hp !== 0 || statuses.has('stable') || statuses.has('dead')) {
|
||||||
|
throw new Error('Death saving throws require an unstable character at 0 HP')
|
||||||
|
}
|
||||||
|
const result = random.integer(1, 20)
|
||||||
|
return {
|
||||||
|
id: randomUUID(), checkKind: request.kind, formula: '1d20', rolls: [result], kept: [result],
|
||||||
|
modifier: 0, total: result, difficulty: 10, success: result >= 10,
|
||||||
|
actorId: actor.id, targetId: null, createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ability = abilityForCheck(request)
|
const ability = abilityForCheck(request)
|
||||||
const diceCount = request.mode === 'normal' ? 1 : 2
|
const mode = effectiveRollMode(request, actor, target)
|
||||||
|
const diceCount = mode === 'normal' ? 1 : 2
|
||||||
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
|
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
|
||||||
const keptValue = request.mode === 'advantage' ? Math.max(...rolls) : request.mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
|
const keptValue = mode === 'advantage' ? Math.max(...rolls) : mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
|
||||||
const modifier = abilityModifier(actor.abilities[ability]) + (usesProficiency(request) ? actor.proficiency : 0)
|
const proficiency = actor.rules ? proficiencyBonus(actor.rules.level) : actor.proficiency
|
||||||
|
const exhaustionPenalty = actor.rules ? actor.rules.exhaustion * 2 : 0
|
||||||
|
const modifier = abilityModifier(actor.abilities[ability]) + proficiency * proficiencyMultiplier(request, actor) - exhaustionPenalty
|
||||||
const total = keptValue + modifier
|
const total = keptValue + modifier
|
||||||
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
|
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
|
||||||
throw new Error('Attack rolls require the active target character')
|
throw new Error('Attack rolls require the active target character')
|
||||||
@@ -125,18 +194,19 @@ export function resolveCheck(
|
|||||||
const automaticAttackOutcome = request.kind === 'attack'
|
const automaticAttackOutcome = request.kind === 'attack'
|
||||||
? keptValue === 20 ? true : keptValue === 1 ? false : null
|
? keptValue === 20 ? true : keptValue === 1 ? false : null
|
||||||
: null
|
: null
|
||||||
|
const automaticSaveOutcome = automaticallyFailedSave(request, actor) ? false : null
|
||||||
return {
|
return {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
checkKind: request.kind,
|
checkKind: request.kind,
|
||||||
formula: request.mode === 'normal'
|
formula: mode === 'normal'
|
||||||
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
|
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
|
||||||
: `2d20${request.mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
|
: `2d20${mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
|
||||||
rolls,
|
rolls,
|
||||||
kept: [keptValue],
|
kept: [keptValue],
|
||||||
modifier,
|
modifier,
|
||||||
total,
|
total,
|
||||||
difficulty,
|
difficulty,
|
||||||
success: difficulty === null ? null : automaticAttackOutcome ?? total >= difficulty,
|
success: difficulty === null ? null : automaticAttackOutcome ?? automaticSaveOutcome ?? total >= difficulty,
|
||||||
actorId: actor.id,
|
actorId: actor.id,
|
||||||
targetId: request.targetId ?? null,
|
targetId: request.targetId ?? null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -193,26 +263,145 @@ export interface AppliedState {
|
|||||||
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
|
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[]): AppliedState {
|
function cloneRules(rules: CharacterRules | undefined): CharacterRules | undefined {
|
||||||
const next = characters.map(character => ({ ...character, inventory: [...character.inventory], statuses: [...character.statuses] }))
|
return rules ? structuredClone(rules) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStatus(character: Character, status: string): void {
|
||||||
|
if (!character.statuses.some(current => current.toLowerCase() === status.toLowerCase())) character.statuses.push(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStatus(character: Character, status: string): void {
|
||||||
|
character.statuses = character.statuses.filter(current => current.toLowerCase() !== status.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustedDamage(value: number, damageType: DamageType | null | undefined, rules: CharacterRules | undefined): number {
|
||||||
|
let damage = Math.max(0, value)
|
||||||
|
if (!damageType || !rules) return damage
|
||||||
|
if (rules.damageImmunities.includes(damageType)) return 0
|
||||||
|
if (rules.damageResistances.includes(damageType)) damage = Math.floor(damage / 2)
|
||||||
|
if (rules.damageVulnerabilities.includes(damageType)) damage *= 2
|
||||||
|
return damage
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDeathSavingThrows(characters: Character[], rolls: DiceRoll[]): void {
|
||||||
|
for (const roll of rolls.filter(candidate => candidate.checkKind === 'deathSavingThrow')) {
|
||||||
|
const character = characters.find(candidate => candidate.id === roll.actorId)
|
||||||
|
if (!character?.rules || character.hp !== 0 || normalizedStatuses(character).has('dead')) continue
|
||||||
|
const natural = roll.kept[0]
|
||||||
|
if (natural === 20) {
|
||||||
|
character.hp = 1
|
||||||
|
character.rules.deathSaves = { successes: 0, failures: 0 }
|
||||||
|
removeStatus(character, 'unconscious')
|
||||||
|
removeStatus(character, 'stable')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (roll.success) character.rules.deathSaves.successes = Math.min(3, character.rules.deathSaves.successes + 1)
|
||||||
|
else character.rules.deathSaves.failures = Math.min(3, character.rules.deathSaves.failures + (natural === 1 ? 2 : 1))
|
||||||
|
|
||||||
|
if (character.rules.deathSaves.successes >= 3) {
|
||||||
|
character.rules.deathSaves = { successes: 0, failures: 0 }
|
||||||
|
addStatus(character, 'stable')
|
||||||
|
}
|
||||||
|
if (character.rules.deathSaves.failures >= 3) {
|
||||||
|
removeStatus(character, 'unconscious')
|
||||||
|
removeStatus(character, 'stable')
|
||||||
|
addStatus(character, 'dead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRest(character: Character, rest: string): void {
|
||||||
|
if (!character.rules || normalizedStatuses(character).has('dead')) return
|
||||||
|
const longRest = rest.toLowerCase() === 'long'
|
||||||
|
for (const resource of Object.values(character.rules.resources)) {
|
||||||
|
if (longRest ? resource.recovery !== 'none' : resource.recovery === 'shortRest') resource.current = resource.max
|
||||||
|
}
|
||||||
|
if (!longRest || character.hp < 1) return
|
||||||
|
character.hp = character.maxHp
|
||||||
|
character.rules.temporaryHp = 0
|
||||||
|
character.rules.exhaustion = Math.max(0, character.rules.exhaustion - 1)
|
||||||
|
character.rules.deathSaves = { successes: 0, failures: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[], rolls: DiceRoll[] = []): AppliedState {
|
||||||
|
const next = characters.map(character => ({
|
||||||
|
...character,
|
||||||
|
inventory: [...character.inventory],
|
||||||
|
statuses: [...character.statuses],
|
||||||
|
rules: cloneRules(character.rules),
|
||||||
|
}))
|
||||||
const audit: AppliedState['audit'] = []
|
const audit: AppliedState['audit'] = []
|
||||||
|
|
||||||
|
applyDeathSavingThrows(next, rolls)
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (!['damage', 'healing', 'inventory', 'status'].includes(event.type)) continue
|
if (!['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type)) continue
|
||||||
const target = next.find(character => character.id === event.targetId)
|
const target = next.find(character => character.id === event.targetId)
|
||||||
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
|
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
|
||||||
const before = structuredClone(target)
|
const before = structuredClone(target)
|
||||||
|
|
||||||
if (event.type === 'damage') target.hp = Math.max(0, target.hp - Math.max(0, event.value ?? 0))
|
if (event.type === 'damage') {
|
||||||
if (event.type === 'healing') target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
|
const damage = adjustedDamage(event.value ?? 0, event.damageType, target.rules)
|
||||||
|
const priorHp = target.hp
|
||||||
|
const absorbed = Math.min(target.rules?.temporaryHp ?? 0, damage)
|
||||||
|
if (target.rules) target.rules.temporaryHp -= absorbed
|
||||||
|
const hpDamage = damage - absorbed
|
||||||
|
const remainingDamageAtZero = Math.max(0, hpDamage - priorHp)
|
||||||
|
const criticalAtZero = priorHp === 0 && rolls.some(roll =>
|
||||||
|
roll.checkKind === 'attack'
|
||||||
|
&& roll.actorId === event.actorId
|
||||||
|
&& roll.targetId === event.targetId
|
||||||
|
&& roll.kept[0] === 20,
|
||||||
|
)
|
||||||
|
target.hp = Math.max(0, priorHp - hpDamage)
|
||||||
|
if (target.hp === 0 && !normalizedStatuses(target).has('dead')) {
|
||||||
|
if (remainingDamageAtZero >= target.maxHp) {
|
||||||
|
removeStatus(target, 'unconscious')
|
||||||
|
removeStatus(target, 'stable')
|
||||||
|
addStatus(target, 'dead')
|
||||||
|
} else {
|
||||||
|
addStatus(target, 'unconscious')
|
||||||
|
if (priorHp === 0 && target.rules) {
|
||||||
|
removeStatus(target, 'stable')
|
||||||
|
target.rules.deathSaves.failures = Math.min(3, target.rules.deathSaves.failures + (criticalAtZero ? 2 : 1))
|
||||||
|
if (target.rules.deathSaves.failures >= 3) {
|
||||||
|
removeStatus(target, 'unconscious')
|
||||||
|
addStatus(target, 'dead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === 'healing' && !normalizedStatuses(target).has('dead')) {
|
||||||
|
target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
|
||||||
|
if (target.hp > 0) {
|
||||||
|
removeStatus(target, 'unconscious')
|
||||||
|
removeStatus(target, 'stable')
|
||||||
|
if (target.rules) target.rules.deathSaves = { successes: 0, failures: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.type === 'inventory' && event.item) {
|
if (event.type === 'inventory' && event.item) {
|
||||||
if ((event.value ?? 1) >= 0 && !target.inventory.includes(event.item)) target.inventory.push(event.item)
|
if ((event.value ?? 1) >= 0 && !target.inventory.includes(event.item)) target.inventory.push(event.item)
|
||||||
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
|
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
|
||||||
}
|
}
|
||||||
if (event.type === 'status' && event.item) {
|
if (event.type === 'status' && event.item) {
|
||||||
if ((event.value ?? 1) >= 0 && !target.statuses.includes(event.item)) target.statuses.push(event.item)
|
if (event.item.toLowerCase() === 'exhaustion' && target.rules) {
|
||||||
if ((event.value ?? 1) < 0) target.statuses = target.statuses.filter(status => status !== event.item)
|
target.rules.exhaustion = Math.max(0, Math.min(6, target.rules.exhaustion + (event.value ?? 1)))
|
||||||
|
if (target.rules.exhaustion >= 6) addStatus(target, 'dead')
|
||||||
|
} else {
|
||||||
|
if ((event.value ?? 1) >= 0) addStatus(target, event.item)
|
||||||
|
if ((event.value ?? 1) < 0) removeStatus(target, event.item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (event.type === 'temporaryHp' && target.rules) {
|
||||||
|
target.rules.temporaryHp = Math.max(target.rules.temporaryHp, Math.max(0, event.value ?? 0))
|
||||||
|
}
|
||||||
|
if (event.type === 'resource' && event.item && target.rules?.resources[event.item]) {
|
||||||
|
const resource = target.rules.resources[event.item]!
|
||||||
|
resource.current = Math.max(0, Math.min(resource.max, resource.current + (event.value ?? -1)))
|
||||||
|
}
|
||||||
|
if (event.type === 'rest' && event.item) applyRest(target, event.item)
|
||||||
audit.push({ event, before, after: structuredClone(target) })
|
audit.push({ event, before, after: structuredClone(target) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const draft = {
|
|||||||
defense: 13,
|
defense: 13,
|
||||||
proficiency: 2,
|
proficiency: 2,
|
||||||
inventory: ['Signal lens', 'Field toolkit'],
|
inventory: ['Signal lens', 'Field toolkit'],
|
||||||
|
skillProficiencies: ['arcana', 'history', 'investigation', 'perception'],
|
||||||
|
skillExpertise: ['investigation'],
|
||||||
|
savingThrowProficiencies: ['int', 'wis'],
|
||||||
persona: {
|
persona: {
|
||||||
voice: 'Precise, with dry humor.',
|
voice: 'Precise, with dry humor.',
|
||||||
motivation: 'Prove the relay is speaking from the future.',
|
motivation: 'Prove the relay is speaking from the future.',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { CheckRequestSchema, PlayerIntentSchema } from './index'
|
import { CharacterRulesSchema, CheckRequestSchema, PlayerIntentSchema } from './index'
|
||||||
|
|
||||||
describe('shared database contracts', () => {
|
describe('shared database contracts', () => {
|
||||||
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
||||||
@@ -28,4 +28,21 @@ describe('shared database contracts', () => {
|
|||||||
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
||||||
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
|
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('validates versioned SRD character mechanics', () => {
|
||||||
|
const rules = CharacterRulesSchema.parse({
|
||||||
|
version: 2,
|
||||||
|
level: 5,
|
||||||
|
skillProficiencies: ['perception'],
|
||||||
|
skillExpertise: ['perception'],
|
||||||
|
savingThrowProficiencies: ['wis'],
|
||||||
|
resources: { focus: { current: 2, max: 3, recovery: 'shortRest' } },
|
||||||
|
})
|
||||||
|
expect(rules.temporaryHp).toBe(0)
|
||||||
|
expect(rules.deathSaves).toEqual({ successes: 0, failures: 0 })
|
||||||
|
expect(() => CharacterRulesSchema.parse({
|
||||||
|
...rules,
|
||||||
|
skillExpertise: ['stealth'],
|
||||||
|
})).toThrow('expertise requires skill proficiency')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,62 @@ export const skillKeys = [
|
|||||||
export const SkillKeySchema = z.enum(skillKeys)
|
export const SkillKeySchema = z.enum(skillKeys)
|
||||||
export type SkillKey = z.infer<typeof SkillKeySchema>
|
export type SkillKey = z.infer<typeof SkillKeySchema>
|
||||||
|
|
||||||
|
export const damageTypeKeys = [
|
||||||
|
'acid', 'bludgeoning', 'cold', 'fire', 'force', 'lightning', 'necrotic',
|
||||||
|
'piercing', 'poison', 'psychic', 'radiant', 'slashing', 'thunder',
|
||||||
|
] as const
|
||||||
|
export const DamageTypeSchema = z.enum(damageTypeKeys)
|
||||||
|
export type DamageType = z.infer<typeof DamageTypeSchema>
|
||||||
|
|
||||||
|
export const conditionKeys = [
|
||||||
|
'blinded', 'charmed', 'deafened', 'frightened', 'grappled', 'incapacitated',
|
||||||
|
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||||
|
'stunned', 'unconscious', 'stable', 'dead',
|
||||||
|
] as const
|
||||||
|
export const ConditionKeySchema = z.enum(conditionKeys)
|
||||||
|
export type ConditionKey = z.infer<typeof ConditionKeySchema>
|
||||||
|
|
||||||
|
export const CharacterResourceSchema = z.object({
|
||||||
|
current: z.number().int().min(0),
|
||||||
|
max: z.number().int().min(0),
|
||||||
|
recovery: z.enum(['none', 'shortRest', 'longRest']).default('longRest'),
|
||||||
|
}).strict().refine(value => value.current <= value.max, {
|
||||||
|
message: 'resource current value must not exceed its maximum',
|
||||||
|
path: ['current'],
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versioned SRD mechanics that older campaigns did not persist. The legacy
|
||||||
|
* fallback is explicit so migrated games keep their previous proficiency
|
||||||
|
* behavior without allowing it in newly-authored rules profiles.
|
||||||
|
*/
|
||||||
|
export const CharacterRulesSchema = z.object({
|
||||||
|
version: z.literal(2),
|
||||||
|
level: z.number().int().min(1).max(20).default(1),
|
||||||
|
skillProficiencies: z.array(SkillKeySchema).max(18).default([]),
|
||||||
|
skillExpertise: z.array(SkillKeySchema).max(18).default([]),
|
||||||
|
savingThrowProficiencies: z.array(AbilityKeySchema).max(6).default([]),
|
||||||
|
temporaryHp: z.number().int().min(0).max(999).default(0),
|
||||||
|
deathSaves: z.object({
|
||||||
|
successes: z.number().int().min(0).max(3),
|
||||||
|
failures: z.number().int().min(0).max(3),
|
||||||
|
}).strict().default({ successes: 0, failures: 0 }),
|
||||||
|
exhaustion: z.number().int().min(0).max(6).default(0),
|
||||||
|
damageResistances: z.array(DamageTypeSchema).max(13).default([]),
|
||||||
|
damageVulnerabilities: z.array(DamageTypeSchema).max(13).default([]),
|
||||||
|
damageImmunities: z.array(DamageTypeSchema).max(13).default([]),
|
||||||
|
resources: z.record(z.string().trim().min(1).max(80), CharacterResourceSchema).default({}),
|
||||||
|
legacyProficiencyFallback: z.boolean().default(false),
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
const trained = new Set(value.skillProficiencies)
|
||||||
|
for (const skill of value.skillExpertise) {
|
||||||
|
if (!trained.has(skill)) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'expertise requires skill proficiency', path: ['skillExpertise'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export type CharacterRules = z.infer<typeof CharacterRulesSchema>
|
||||||
|
|
||||||
export const AbilityScoresSchema = z.object({
|
export const AbilityScoresSchema = z.object({
|
||||||
str: z.number().int().min(1).max(30),
|
str: z.number().int().min(1).max(30),
|
||||||
dex: z.number().int().min(1).max(30),
|
dex: z.number().int().min(1).max(30),
|
||||||
@@ -55,6 +111,9 @@ export const CharacterDraftSchema = z.object({
|
|||||||
defense: z.number().int().min(1).max(40),
|
defense: z.number().int().min(1).max(40),
|
||||||
proficiency: z.number().int().min(1).max(10),
|
proficiency: z.number().int().min(1).max(10),
|
||||||
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
|
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
|
||||||
|
skillProficiencies: z.array(SkillKeySchema).max(8).default([]),
|
||||||
|
skillExpertise: z.array(SkillKeySchema).max(4).default([]),
|
||||||
|
savingThrowProficiencies: z.array(AbilityKeySchema).max(3).default([]),
|
||||||
persona: CharacterPersonaSchema,
|
persona: CharacterPersonaSchema,
|
||||||
}).strict().refine(value => value.hp <= value.maxHp, {
|
}).strict().refine(value => value.hp <= value.maxHp, {
|
||||||
message: 'hp must not exceed maxHp',
|
message: 'hp must not exceed maxHp',
|
||||||
@@ -92,6 +151,7 @@ export const CharacterSchema = z.object({
|
|||||||
inventory: z.array(z.string().max(120)).max(50).default([]),
|
inventory: z.array(z.string().max(120)).max(50).default([]),
|
||||||
statuses: z.array(z.string().max(80)).max(12).default([]),
|
statuses: z.array(z.string().max(80)).max(12).default([]),
|
||||||
persona: z.record(z.string(), z.unknown()).optional(),
|
persona: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
rules: CharacterRulesSchema.optional(),
|
||||||
})
|
})
|
||||||
export type Character = z.infer<typeof CharacterSchema>
|
export type Character = z.infer<typeof CharacterSchema>
|
||||||
|
|
||||||
@@ -129,7 +189,7 @@ export type RoundContext = z.infer<typeof RoundContextSchema>
|
|||||||
|
|
||||||
export const CheckRequestSchema = z.object({
|
export const CheckRequestSchema = z.object({
|
||||||
actorId: z.string().min(1),
|
actorId: z.string().min(1),
|
||||||
kind: z.enum(['ability', 'skill', 'savingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
kind: z.enum(['ability', 'skill', 'savingThrow', 'deathSavingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
||||||
ability: AbilityKeySchema.optional(),
|
ability: AbilityKeySchema.optional(),
|
||||||
skill: SkillKeySchema.optional(),
|
skill: SkillKeySchema.optional(),
|
||||||
proficient: z.boolean().optional(),
|
proficient: z.boolean().optional(),
|
||||||
@@ -137,6 +197,7 @@ export const CheckRequestSchema = z.object({
|
|||||||
targetId: z.string().optional(),
|
targetId: z.string().optional(),
|
||||||
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
||||||
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
||||||
|
damageType: DamageTypeSchema.optional(),
|
||||||
reason: z.string().min(1).max(500),
|
reason: z.string().min(1).max(500),
|
||||||
})
|
})
|
||||||
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
||||||
@@ -158,11 +219,12 @@ export const DiceRollSchema = z.object({
|
|||||||
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
||||||
|
|
||||||
export const ProposedEventSchema = z.object({
|
export const ProposedEventSchema = z.object({
|
||||||
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing']),
|
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing', 'temporaryHp', 'resource', 'rest']),
|
||||||
actorId: z.string().nullable().default(null),
|
actorId: z.string().nullable().default(null),
|
||||||
targetId: z.string().nullable().default(null),
|
targetId: z.string().nullable().default(null),
|
||||||
value: z.number().int().nullable().default(null),
|
value: z.number().int().nullable().default(null),
|
||||||
item: z.string().max(120).nullable().default(null),
|
item: z.string().max(120).nullable().default(null),
|
||||||
|
damageType: DamageTypeSchema.nullable().default(null),
|
||||||
description: z.string().min(1).max(1000),
|
description: z.string().min(1).max(1000),
|
||||||
})
|
})
|
||||||
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
||||||
|
|||||||
@@ -2780,6 +2780,363 @@ grant execute on function public.dng_schema_version() to service_role;
|
|||||||
|
|
||||||
notify pgrst, 'reload schema';
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0012_versioned_srd_rules.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 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';
|
||||||
|
|
||||||
|
|
||||||
do $$
|
do $$
|
||||||
begin
|
begin
|
||||||
@@ -2804,7 +3161,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
|
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';
|
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||||
end if;
|
end if;
|
||||||
if public.dng_schema_version() <> 11 then
|
if public.dng_schema_version() <> 12 then
|
||||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
|
|||||||
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