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

@@ -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}