Compare commits
10 Commits
agent/code
...
89fd1fd984
| Author | SHA1 | Date | |
|---|---|---|---|
| 89fd1fd984 | |||
| 4e0c38c925 | |||
| ace1e47a6d | |||
| 0ade57b5a1 | |||
| 56b4586d1a | |||
| f6c0fbcfeb | |||
| 0b53ad0701 | |||
| 719cc738db | |||
| e430b4e773 | |||
| 56bddafbe8 |
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 7–16, max HP 8–20, Defense 10–16, proficiency 2. Give 2–5 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 7–16, max HP 8–20, Armor Class (AC) 10–16, proficiency 2. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user