feat(gameplay): enforce core SRD d20 rules
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user