Compare commits

...

3 Commits

Author SHA1 Message Date
89fd1fd984 Merge pull request 'DUNGEONS-20: enforce core SRD d20 rules' (#16) from agent/codex-chatgpt/36e1fd11 into main
All checks were successful
CI / validate (push) Successful in 16m28s
Reviewed-on: #16
2026-08-23 14:02:38 +00:00
4e0c38c925 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>
2026-08-23 18:50:55 +05:00
ace1e47a6d Merge pull request 'DUNGEONS-18: add first-visit homepage intro' (#15) from agent/codex-chatgpt/b56d3ed9 into main
All checks were successful
CI / validate (push) Successful in 18m26s
Reviewed-on: #15
2026-08-22 15:20:21 +00:00
13 changed files with 230 additions and 27 deletions

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 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>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>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>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> <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> </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', () => [ 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: '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: '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 currentAction = useState('current-action', () => '')
const ready = useState('ready', () => false) const ready = useState('ready', () => false)

View File

@@ -74,7 +74,7 @@ const timeline = computed(() => {
})), })),
...payload.value.diceRolls.map(roll => ({ ...payload.value.diceRolls.map(roll => ({
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER 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}`, body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
})), })),
...payload.value.events.map(event => ({ ...payload.value.events.map(event => ({
@@ -89,6 +89,29 @@ function signed(value: number) {
return value >= 0 ? `+ ${value}` : ` ${Math.abs(value)}` 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) { function initials(name: string) {
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() 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" /> <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> <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>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" /> <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>
@@ -285,8 +308,8 @@ 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> DEF</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 }}</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>
@@ -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> <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 /> 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> </aside>
</div> </div>
</AppShell> </AppShell>

View File

@@ -5,6 +5,11 @@ const forceOpen = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const submittedActionIndex = ref<number | null>(null) 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() { async function submitAction() {
if (!currentAction.value.trim() || submitting.value || ready.value) return if (!currentAction.value.trim() || submitting.value || ready.value) return
submitting.value = true submitting.value = true
@@ -67,8 +72,8 @@ async function continueRound() {
</article> </article>
<div class="character-sheet"> <div class="character-sheet">
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p> <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="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 }}</small><b>{{ score }}</b></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> <details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
</div> </div>
</aside> </aside>

View File

@@ -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 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: [{ messages: [{
role: 'user', role: 'user',
content: JSON.stringify({ 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> { 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 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) }, { 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))

View File

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

View File

@@ -41,6 +41,21 @@ describe('round orchestration', () => {
}, context)).toThrow('Unknown check actor outsider') }, 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', () => { it('schedules durable story summaries every third completed round', () => {
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6]) 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) { for (const check of plan.checks) {
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`) 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.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) { 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}`)

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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' import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 } const fixed: RandomSource = { integer: () => 12 }
@@ -18,7 +18,7 @@ describe('game engine', () => {
it('keeps dice server-owned and auditable', () => { it('keeps dice server-owned and auditable', () => {
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed) const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
expect(roll.rolls).toEqual([12]) expect(roll.rolls).toEqual([12])
expect(roll.total).toBe(17) expect(roll.total).toBe(15)
expect(roll.success).toBe(true) expect(roll.success).toBe(true)
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i) 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, hero,
{ integer: () => values.shift()! }, { integer: () => values.shift()! },
) )
expect(roll.formula).toBe('2d20kl1+5') expect(roll.formula).toBe('2d20kl1+3')
expect(roll.kept).toEqual([4]) 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', () => { it('replaces model-proposed damage with the authoritative roll total', () => {
const damage = resolveCheck( const damage = resolveCheck(
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' }, { 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 { 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 { export interface RandomSource {
integer(min: number, max: number): number 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 { function abilityForCheck(request: CheckRequest): AbilityKey {
if (request.kind === 'skill' && request.skill) return skillAbilities[request.skill]
if (request.ability) return request.ability if (request.ability) return request.ability
if (request.kind === 'initiative') return 'dex' if (request.kind === 'initiative') return 'dex'
return 'str' 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') { if (request.kind === 'damage' || request.kind === 'healing') {
const rolled = rollDice(request.dice ?? '1d6', random) const rolled = rollDice(request.dice ?? '1d6', random)
return { return {
@@ -82,9 +116,15 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
const diceCount = request.mode === 'normal' ? 1 : 2 const diceCount = request.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 = 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 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 { return {
id: randomUUID(), id: randomUUID(),
checkKind: request.kind, checkKind: request.kind,
@@ -96,13 +136,58 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
modifier, modifier,
total, total,
difficulty, difficulty,
success: difficulty === null ? null : total >= difficulty, success: difficulty === null ? null : automaticAttackOutcome ?? total >= difficulty,
actorId: actor.id, actorId: actor.id,
targetId: request.targetId ?? null, targetId: request.targetId ?? null,
createdAt: new Date().toISOString(), 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 { export interface AppliedState {
characters: Character[] characters: Character[]
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }> audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { PlayerIntentSchema } from './index' import { 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', () => {
@@ -16,4 +16,16 @@ describe('shared database contracts', () => {
expect(intent.ready).toBe(true) 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 const AbilityKeySchema = z.enum(abilityKeys)
export type AbilityKey = z.infer<typeof AbilityKeySchema> 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({ 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),
@@ -121,8 +129,10 @@ 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', 'attack', 'initiative', 'damage', 'healing']), kind: z.enum(['ability', 'skill', 'savingThrow', 'attack', 'initiative', 'damage', 'healing']),
ability: AbilityKeySchema.optional(), ability: AbilityKeySchema.optional(),
skill: SkillKeySchema.optional(),
proficient: z.boolean().optional(),
difficulty: z.number().int().min(1).max(40).optional(), difficulty: z.number().int().min(1).max(40).optional(),
targetId: z.string().optional(), targetId: z.string().optional(),
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'), mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),