feat(gameplay): enforce core SRD d20 rules
All checks were successful
CI / validate (push) Successful in 21m53s
CI / validate (pull_request) Successful in 15m15s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-23 18:50:55 +05:00
parent ace1e47a6d
commit 4e0c38c925
13 changed files with 230 additions and 27 deletions

View File

@@ -74,7 +74,7 @@ const timeline = computed(() => {
})),
...payload.value.diceRolls.map(roll => ({
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER ROLL',
meta: roll.success === null || roll.success === undefined ? roll.check_kind : roll.success ? 'SUCCESS' : 'FAILED',
meta: rollMeta(roll),
body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
})),
...payload.value.events.map(event => ({
@@ -89,6 +89,29 @@ function signed(value: number) {
return value >= 0 ? `+ ${value}` : ` ${Math.abs(value)}`
}
function abilityModifier(score: number) {
return Math.floor((Number(score) - 10) / 2)
}
function abilityLabel(score: number) {
const modifier = abilityModifier(score)
return modifier >= 0 ? `+${modifier}` : String(modifier)
}
function rollMeta(roll: Record<string, any>) {
if (roll.success === null || roll.success === undefined) return String(roll.check_kind ?? 'ROLL').toUpperCase()
const kept = Number(roll.kept?.[0])
const outcome = roll.check_kind === 'attack' && kept === 20
? 'CRITICAL HIT'
: roll.check_kind === 'attack' && kept === 1
? 'CRITICAL MISS'
: roll.success ? 'SUCCESS' : 'FAILED'
const target = roll.difficulty === null || roll.difficulty === undefined
? ''
: ` · ${roll.check_kind === 'attack' ? 'AC' : 'DC'} ${roll.difficulty}`
return `${outcome}${target}`
}
function initials(name: string) {
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase()
}
@@ -274,7 +297,7 @@ onBeforeUnmount(() => {
<textarea v-model="characterForm.concept" required maxlength="600" placeholder="Concept, role, personality" />
<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>DEF <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>
<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>
@@ -285,8 +308,8 @@ 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> DEF</span></div>
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP</span><span><b>{{ myCharacter.defense }}</b> AC</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>
@@ -320,7 +343,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 /> Dice, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
<div class="mechanics-note"><i /> D20 tests, AC, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
</aside>
</div>
</AppShell>

View File

@@ -5,6 +5,11 @@ const forceOpen = ref(false)
const errorMessage = ref('')
const submittedActionIndex = ref<number | null>(null)
function abilityLabel(score: number) {
const modifier = Math.floor((Number(score) - 10) / 2)
return modifier >= 0 ? `+${modifier}` : String(modifier)
}
async function submitAction() {
if (!currentAction.value.trim() || submitting.value || ready.value) return
submitting.value = true
@@ -67,8 +72,8 @@ async function continueRound() {
</article>
<div class="character-sheet">
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> DEF</span></div>
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> AC</span></div>
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }} {{ abilityLabel(score) }}</small><b>{{ score }}</b></span></div>
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
</div>
</aside>