DUNGEONS-20: enforce core SRD d20 rules #16

Merged
Perfect_Python500 merged 1 commits from agent/codex-chatgpt/36e1fd11 into main 2026-08-23 14:02:43 +00:00
13 changed files with 230 additions and 27 deletions
Showing only changes of commit 4e0c38c925 - Show all commits

View File

@@ -29,7 +29,7 @@ function updateInventory(companion: CharacterDraft, event: Event) {
<section class="wide private-truth"><label>PRIVATE GM TRUTH</label><textarea v-model="draft.hiddenThreat" rows="4" /><small>This hidden threat or goal is visible only to the owner and the Groundkeeper.</small></section>
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
<section class="wide"><label>AI COMPANIONS</label><p class="section-note">These universe-specific heroes join the campaign automatically.</p><div class="entities companion-grid"><article v-for="(companion, index) in draft.companions" :key="index" class="companion-card"><small>AI HERO</small><input v-model="companion.name" maxlength="80" aria-label="Companion name"><textarea v-model="companion.concept" maxlength="600" rows="4" aria-label="Companion concept" /><div class="companion-stats"><label>HP<input v-model.number="companion.hp" type="number" min="1" :max="companion.maxHp"></label><label>MAX<input v-model.number="companion.maxHp" type="number" min="1" max="100"></label><label>DEF<input v-model.number="companion.defense" type="number" min="1" max="40"></label><label>PROF<input v-model.number="companion.proficiency" type="number" min="1" max="10"></label><label v-for="ability in (['str','dex','con','int','wis','cha'] as const)" :key="ability">{{ ability }}<input v-model.number="companion.abilities[ability]" type="number" min="1" max="30"></label></div><label class="inventory-label">INVENTORY<input :value="companion.inventory.join(', ')" maxlength="800" @input="updateInventory(companion, $event)"></label><details><summary>VOICE & PERSONALITY</summary><input v-model="companion.persona.voice" maxlength="240" placeholder="Voice"><input v-model="companion.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="companion.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="companion.persona.bond" maxlength="300" placeholder="Bond"></details></article></div></section>
<section class="wide"><label>AI COMPANIONS</label><p class="section-note">These universe-specific heroes join the campaign automatically.</p><div class="entities companion-grid"><article v-for="(companion, index) in draft.companions" :key="index" class="companion-card"><small>AI HERO</small><input v-model="companion.name" maxlength="80" aria-label="Companion name"><textarea v-model="companion.concept" maxlength="600" rows="4" aria-label="Companion concept" /><div class="companion-stats"><label>HP<input v-model.number="companion.hp" type="number" min="1" :max="companion.maxHp"></label><label>MAX<input v-model.number="companion.maxHp" type="number" min="1" max="100"></label><label>AC<input v-model.number="companion.defense" type="number" min="1" max="40"></label><label>PROF<input v-model.number="companion.proficiency" type="number" min="1" max="10"></label><label v-for="ability in (['str','dex','con','int','wis','cha'] as const)" :key="ability">{{ ability }}<input v-model.number="companion.abilities[ability]" type="number" min="1" max="30"></label></div><label class="inventory-label">INVENTORY<input :value="companion.inventory.join(', ')" maxlength="800" @input="updateInventory(companion, $event)"></label><details><summary>VOICE & PERSONALITY</summary><input v-model="companion.persona.voice" maxlength="240" placeholder="Voice"><input v-model="companion.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="companion.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="companion.persona.bond" maxlength="300" placeholder="Bond"></details></article></div></section>
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')"> ADD BOUNDARY</button></section>
</div>

View File

@@ -41,7 +41,7 @@ export function useDemo() {
const history = useState<Array<{ type: 'narration' | 'action' | 'roll'; author: string; body: string; meta?: string }>>('history', () => [
{ type: 'narration', author: 'GROUNDKEEPER', body: defaultWorld.openingScene, meta: 'ROUND 03 · NOW' },
{ type: 'action', author: 'MARA VALE', body: 'I ask the Orison to isolate the transmission and compare the voiceprint to mine.', meta: 'READY' },
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 3 = 17', meta: 'SUCCESS · DC 14' },
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 1 = 15', meta: 'SUCCESS · DC 14' },
])
const currentAction = useState('current-action', () => '')
const ready = useState('ready', () => false)

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>

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, Defense 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+ 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.`,
messages: [{
role: 'user',
content: JSON.stringify({

View File

@@ -100,7 +100,7 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
export async function planRound(input: RoundContext): Promise<RoundPlan> {
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
{ role: 'system', content: 'You plan one asynchronous 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. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, 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, 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: 'user', content: JSON.stringify(input) },
], value => RoundPlanSchema.parse(value))
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))

View File

@@ -1,7 +1,7 @@
import './env'
import { Worker } from 'bullmq'
import IORedis from 'ioredis'
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
@@ -153,11 +153,7 @@ if (!supabaseUrl || !serviceKey) {
})),
})
const plan = validateAndOrderRoundPlan(await planRound(context), context)
const rolls = plan.checks.map(check => {
const actor = characters.find(character => character.id === check.actorId)
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
return resolveCheck(check, actor)
})
const rolls = resolveChecks(plan.checks, characters)
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
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)))

View File

@@ -41,6 +41,21 @@ describe('round orchestration', () => {
}, context)).toThrow('Unknown check actor outsider')
})
it('requires complete SRD check inputs before rolling', () => {
const context = buildRoundContext({
scene: 'Scene', recentRounds: [], intents: [],
characters: [character('hero', 'human'), character('foe', 'ai')], memories: [],
})
const basePlan = { aiActions: [], proposedEvents: [], relevantMemoryQueries: [] }
expect(() => validateAndOrderRoundPlan({
...basePlan, checks: [{ actorId: 'hero', kind: 'skill', mode: 'normal', reason: 'Search' }],
}, context)).toThrow('Skill checks require a skill')
expect(() => validateAndOrderRoundPlan({
...basePlan, checks: [{ actorId: 'hero', kind: 'attack', mode: 'normal', reason: 'Strike' }],
}, context)).toThrow('Attack rolls require a target')
})
it('schedules durable story summaries every third completed round', () => {
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6])
})

View File

@@ -64,6 +64,13 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
for (const check of plan.checks) {
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`)
if (check.targetId && !characterIds.has(check.targetId)) throw new Error(`Unknown check target ${check.targetId}`)
if (check.kind === 'skill' && !check.skill) throw new Error('Skill checks require a skill')
if (['ability', 'savingThrow'].includes(check.kind) && !check.ability) throw new Error(`${check.kind} checks require an ability`)
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
throw new Error(`${check.kind} checks require a Difficulty Class`)
}
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`)
}
for (const event of plan.proposedEvents) {
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 }
@@ -18,7 +18,7 @@ describe('game engine', () => {
it('keeps dice server-owned and auditable', () => {
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
expect(roll.rolls).toEqual([12])
expect(roll.total).toBe(17)
expect(roll.total).toBe(15)
expect(roll.success).toBe(true)
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
})
@@ -30,10 +30,60 @@ describe('game engine', () => {
hero,
{ integer: () => values.shift()! },
)
expect(roll.formula).toBe('2d20kl1+5')
expect(roll.formula).toBe('2d20kl1+3')
expect(roll.kept).toEqual([4])
})
it('applies proficiency only to proficient skills and saving throws', () => {
const perception = resolveCheck({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
difficulty: 12, mode: 'normal', reason: 'Spot an ambush',
}, hero, fixed)
const constitutionSave = resolveCheck({
actorId: 'hero', kind: 'savingThrow', ability: 'con', proficient: false,
difficulty: 12, mode: 'normal', reason: 'Endure poison',
}, hero, fixed)
expect(perception.modifier).toBe(1)
expect(constitutionSave.modifier).toBe(1)
})
it('uses server-owned Armor Class and natural attack outcomes', () => {
const target: Character = { ...hero, id: 'target', defense: 30 }
const naturalTwenty = resolveCheck({
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
difficulty: 1, mode: 'normal', reason: 'Sword strike',
}, hero, { integer: () => 20 }, target)
const naturalOne = resolveCheck({
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
difficulty: 1, mode: 'normal', reason: 'Sword strike',
}, hero, { integer: () => 1 }, { ...target, defense: 1 })
expect(naturalTwenty.difficulty).toBe(30)
expect(naturalTwenty.success).toBe(true)
expect(naturalOne.difficulty).toBe(1)
expect(naturalOne.success).toBe(false)
})
it('skips damage on a miss and doubles only critical damage dice', () => {
const target: Character = { ...hero, id: 'target', defense: 14 }
const checks = [
{ actorId: 'hero', targetId: 'target', kind: 'attack' as const, ability: 'str' as const, mode: 'normal' as const, reason: 'Sword strike' },
{ actorId: 'hero', targetId: 'target', kind: 'damage' as const, dice: '1d6+2', mode: 'normal' as const, reason: 'Sword damage' },
]
const missed = resolveChecks(checks, [hero, target], { integer: () => 1 })
expect(missed).toHaveLength(1)
expect(missed[0]?.checkKind).toBe('attack')
const values = [20, 4, 5]
const critical = resolveChecks(checks, [hero, target], { integer: () => values.shift()! })
expect(critical).toHaveLength(2)
expect(critical[1]?.formula).toBe('2d6+2')
expect(critical[1]?.rolls).toEqual([4, 5])
expect(critical[1]?.total).toBe(11)
})
it('replaces model-proposed damage with the authoritative roll total', () => {
const damage = resolveCheck(
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },

View File

@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto'
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent } from '@dng/shared'
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
export interface RandomSource {
integer(min: number, max: number): number
@@ -51,13 +51,47 @@ export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: Dice
})
}
const skillAbilities: Record<SkillKey, AbilityKey> = {
acrobatics: 'dex',
animalHandling: 'wis',
arcana: 'int',
athletics: 'str',
deception: 'cha',
history: 'int',
insight: 'wis',
intimidation: 'cha',
investigation: 'int',
medicine: 'wis',
nature: 'int',
perception: 'wis',
performance: 'cha',
persuasion: 'cha',
religion: 'int',
sleightOfHand: 'dex',
stealth: 'dex',
survival: 'wis',
}
function abilityForCheck(request: CheckRequest): AbilityKey {
if (request.kind === 'skill' && request.skill) return skillAbilities[request.skill]
if (request.ability) return request.ability
if (request.kind === 'initiative') return 'dex'
return 'str'
}
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
function usesProficiency(request: CheckRequest): boolean {
// The alpha treats ordinary weapon attacks as proficient unless the planner
// explicitly marks an improvised or unfamiliar attack otherwise.
if (request.kind === 'attack') return request.proficient !== false
return request.proficient === true
}
export function resolveCheck(
request: CheckRequest,
actor: Character,
random: RandomSource = secureRandom,
target?: Character,
): DiceRoll {
if (request.kind === 'damage' || request.kind === 'healing') {
const rolled = rollDice(request.dice ?? '1d6', random)
return {
@@ -82,9 +116,15 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
const diceCount = request.mode === 'normal' ? 1 : 2
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 modifier = abilityModifier(actor.abilities[ability]) + actor.proficiency
const modifier = abilityModifier(actor.abilities[ability]) + (usesProficiency(request) ? actor.proficiency : 0)
const total = keptValue + modifier
const difficulty = request.difficulty ?? null
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
throw new Error('Attack rolls require the active target character')
}
const difficulty = request.kind === 'attack' ? target!.defense : request.difficulty ?? null
const automaticAttackOutcome = request.kind === 'attack'
? keptValue === 20 ? true : keptValue === 1 ? false : null
: null
return {
id: randomUUID(),
checkKind: request.kind,
@@ -96,13 +136,58 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
modifier,
total,
difficulty,
success: difficulty === null ? null : total >= difficulty,
success: difficulty === null ? null : automaticAttackOutcome ?? total >= difficulty,
actorId: actor.id,
targetId: request.targetId ?? null,
createdAt: new Date().toISOString(),
}
}
function criticalDamageFormula(formula: string): string {
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
const modifier = match[3] ? `${match[3]}${match[4]}` : ''
return `${Number(match[1]) * 2}d${match[2]}${modifier}`
}
/**
* Resolves an ordered round plan while enforcing attack-to-damage rules.
* A missed attack never rolls damage; a natural 20 doubles only the damage
* dice and not the flat modifier.
*/
export function resolveChecks(
requests: CheckRequest[],
characters: Character[],
random: RandomSource = secureRandom,
): DiceRoll[] {
const rolls: DiceRoll[] = []
for (const originalRequest of requests) {
const actor = characters.find(character => character.id === originalRequest.actorId)
if (!actor) throw new Error(`Unknown check actor ${originalRequest.actorId}`)
const target = originalRequest.targetId
? characters.find(character => character.id === originalRequest.targetId)
: undefined
let request = originalRequest
if (request.kind === 'damage') {
const attack = [...rolls].reverse().find(roll =>
roll.checkKind === 'attack'
&& roll.actorId === request.actorId
&& roll.targetId === request.targetId,
)
if (attack?.success === false) continue
if (attack?.kept[0] === 20) {
request = { ...request, dice: criticalDamageFormula(request.dice ?? '1d6') }
}
}
rolls.push(resolveCheck(request, actor, random, target))
}
return rolls
}
export interface AppliedState {
characters: Character[]
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { PlayerIntentSchema } from './index'
import { CheckRequestSchema, PlayerIntentSchema } from './index'
describe('shared database contracts', () => {
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
@@ -16,4 +16,16 @@ describe('shared database contracts', () => {
expect(intent.ready).toBe(true)
})
it('accepts typed SRD d20 tests', () => {
expect(CheckRequestSchema.parse({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
difficulty: 15, mode: 'advantage', reason: 'Search the trapped hall',
})).toMatchObject({ kind: 'skill', skill: 'perception', proficient: true })
expect(CheckRequestSchema.parse({
actorId: 'hero', kind: 'savingThrow', ability: 'wis', proficient: false,
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
})
})

View File

@@ -10,6 +10,14 @@ export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
export const AbilityKeySchema = z.enum(abilityKeys)
export type AbilityKey = z.infer<typeof AbilityKeySchema>
export const skillKeys = [
'acrobatics', 'animalHandling', 'arcana', 'athletics', 'deception', 'history',
'insight', 'intimidation', 'investigation', 'medicine', 'nature', 'perception',
'performance', 'persuasion', 'religion', 'sleightOfHand', 'stealth', 'survival',
] as const
export const SkillKeySchema = z.enum(skillKeys)
export type SkillKey = z.infer<typeof SkillKeySchema>
export const AbilityScoresSchema = z.object({
str: z.number().int().min(1).max(30),
dex: z.number().int().min(1).max(30),
@@ -121,8 +129,10 @@ export type RoundContext = z.infer<typeof RoundContextSchema>
export const CheckRequestSchema = z.object({
actorId: z.string().min(1),
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
kind: z.enum(['ability', 'skill', 'savingThrow', 'attack', 'initiative', 'damage', 'healing']),
ability: AbilityKeySchema.optional(),
skill: SkillKeySchema.optional(),
proficient: z.boolean().optional(),
difficulty: z.number().int().min(1).max(40).optional(),
targetId: z.string().optional(),
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),