) {
+ 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(() => {
{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING…' : '✦ DRAFT WITH AI' }}
{{ key }}
- HP MAX DEF
+ HP MAX AC
VOICE & PERSONALITY
{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}
@@ -285,8 +308,8 @@ onBeforeUnmount(() => {
YOUR CHARACTER {{ myCharacter.name }} {{ myCharacter.concept }}
- {{ myCharacter.hp }} /{{ myCharacter.max_hp }} HP{{ myCharacter.defense }} DEF
- {{ key }} {{ score }}
+ {{ myCharacter.hp }} /{{ myCharacter.max_hp }} HP{{ myCharacter.defense }} AC
+ {{ key }} {{ abilityLabel(Number(score)) }} {{ score }}
@@ -320,7 +343,7 @@ onBeforeUnmount(() => {
AI TURN ORDER No AI heroes in this party.
{{ String(index+1).padStart(2,'0') }} {{ character.name }}{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}
CONTINUE WITHOUT WAITING ↗
RETRY FAILED ROUND ↗
- Dice, HP and mechanical state are resolved by the server and recorded in the campaign log.
+ D20 tests, AC, HP and mechanical state are resolved by the server and recorded in the campaign log.
diff --git a/apps/web/pages/campaign/demo.vue b/apps/web/pages/campaign/demo.vue
index eb31c7d..6056242 100644
--- a/apps/web/pages/campaign/demo.vue
+++ b/apps/web/pages/campaign/demo.vue
@@ -5,6 +5,11 @@ const forceOpen = ref(false)
const errorMessage = ref('')
const submittedActionIndex = ref(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() {
ACTIVE CHARACTER {{ characters[0]?.name }} {{ characters[0]?.concept }}
-
{{ characters[0]?.hp }} /{{ characters[0]?.maxHp }} HP{{ characters[0]?.defense }} DEF
-
{{ key }} {{ score }}
+
{{ characters[0]?.hp }} /{{ characters[0]?.maxHp }} HP{{ characters[0]?.defense }} AC
+
{{ key }} {{ abilityLabel(score) }} {{ score }}
INVENTORY
diff --git a/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts b/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts
index 5069f13..0265716 100644
--- a/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts
+++ b/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts
@@ -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({
diff --git a/apps/worker/src/ai.ts b/apps/worker/src/ai.ts
index b991aa2..49be8cc 100644
--- a/apps/worker/src/ai.ts
+++ b/apps/worker/src/ai.ts
@@ -100,7 +100,7 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
export async function planRound(input: RoundContext): Promise {
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))
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 1c900c2..cfde295 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -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)))
diff --git a/apps/worker/src/orchestration.test.ts b/apps/worker/src/orchestration.test.ts
index 5d92e99..4991e7a 100644
--- a/apps/worker/src/orchestration.test.ts
+++ b/apps/worker/src/orchestration.test.ts
@@ -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])
})
diff --git a/apps/worker/src/orchestration.ts b/apps/worker/src/orchestration.ts
index d699f53..43c1bcf 100644
--- a/apps/worker/src/orchestration.ts
+++ b/apps/worker/src/orchestration.ts
@@ -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}`)
diff --git a/packages/game-engine/src/index.test.ts b/packages/game-engine/src/index.test.ts
index 02b4d22..c18e300 100644
--- a/packages/game-engine/src/index.test.ts
+++ b/packages/game-engine/src/index.test.ts
@@ -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' },
diff --git a/packages/game-engine/src/index.ts b/packages/game-engine/src/index.ts
index 401ef79..2a24901 100644
--- a/packages/game-engine/src/index.ts
+++ b/packages/game-engine/src/index.ts
@@ -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 = {
+ 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 }>
diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts
index 4c062ac..8c2943c 100644
--- a/packages/shared/src/index.test.ts
+++ b/packages/shared/src/index.test.ts
@@ -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' })
+ })
})
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index bcaafea..3499d60 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -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
+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
+
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
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'),