feat(gameplay): add versioned SRD rules migration
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-26 08:45:50 +05:00
parent 89fd1fd984
commit 6c39188027
18 changed files with 1143 additions and 42 deletions

View File

@@ -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: [] },
],
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: '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: '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: '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'], 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'], 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.',
hiddenThreat: 'A causality fracture is teaching the relay to choose which future becomes real.',

View File

@@ -1,5 +1,5 @@
<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 Controller = 'human' | 'ai' | 'delegated'
@@ -36,6 +36,8 @@ const createKind = ref<'human' | 'ai'>('human')
const characterForm = reactive({
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>,
skillProficiencies: [] as SkillKey[], skillExpertise: [] as SkillKey[],
savingThrowProficiencies: [] as AbilityKey[],
persona: { voice: '', motivation: '', flaw: '', bond: '' },
})
const inventoryText = ref('')
@@ -233,6 +235,9 @@ async function suggestCharacter() {
characterForm.defense = suggestion.defense
characterForm.proficiency = suggestion.proficiency
characterForm.abilities = { ...suggestion.abilities }
characterForm.skillProficiencies = [...suggestion.skillProficiencies]
characterForm.skillExpertise = [...suggestion.skillExpertise]
characterForm.savingThrowProficiencies = [...suggestion.savingThrowProficiencies]
characterForm.persona = { ...suggestion.persona }
inventoryText.value = suggestion.inventory.join('\n')
} catch (error) {
@@ -251,13 +256,20 @@ async function createCharacter() {
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
defense: characterForm.defense, proficiency: characterForm.proficiency,
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) {
showCharacterForm.value = false
characterForm.name = ''
characterForm.concept = ''
characterForm.skillProficiencies = []
characterForm.skillExpertise = []
characterForm.savingThrowProficiencies = []
inventoryText.value = ''
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>
<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>
<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" />
<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>
@@ -308,7 +321,7 @@ onBeforeUnmount(() => {
</article>
<section v-if="myCharacter" class="sheet">
<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>
</section>
</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>
<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>
<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>
</div>
</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.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}
.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}
.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}

View File

@@ -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`,
),
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>>>(
`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`,

View File

@@ -1,4 +1,4 @@
import { AbilityScoresSchema } from '@dng/shared'
import { AbilityKeySchema, AbilityScoresSchema, SkillKeySchema } from '@dng/shared'
import { z } from 'zod'
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),
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([]),
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({}),
}).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) => {
try {
@@ -26,7 +33,7 @@ export default defineEventHandler(async (event) => {
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'))
const characterId = await stageTwoRpc<string>('stage_four_create_character', {
const characterId = await stageTwoRpc<string>('stage_five_create_character', {
p_campaign_id: campaignId,
p_actor_id: user.id,
p_controller: body.controller,
@@ -40,6 +47,21 @@ export default defineEventHandler(async (event) => {
p_inventory: body.inventory,
p_statuses: body.statuses,
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>>>(
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,

View File

@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
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 716, max HP 820, Armor Class (AC) 1016, proficiency 2. Give 25 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 716, max HP 820, Armor Class (AC) 1016, proficiency 2. Choose exactly four fitting skill proficiencies and two saving throw proficiencies; expertise may contain at most one of the chosen skills. Give 25 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
messages: [{
role: 'user',
content: JSON.stringify({

View File

@@ -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 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 }
} catch (error) {