Files
Dungeons-Ground/apps/worker/src/orchestration.test.ts
pavel444-byte 031d094867
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Has been cancelled
feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 15:04:18 +05:00

105 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
const character = (id: string, controller: Character['controller']): Character => ({
id, controller, name: id, concept: 'A campaign hero', userId: controller === 'human' ? 'user' : null,
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 },
hp: 10, maxHp: 10, defense: 10, proficiency: 2, inventory: [], statuses: [], persona: {},
})
const intent = (id: string, characterId: string, createdAt: string): PlayerIntent => ({
id, roundId: 'round', memberId: id, characterId, action: `Action ${id}`, ready: true, createdAt, updatedAt: createdAt,
})
describe('round orchestration', () => {
it('keeps a minimal context and orders humans before AI/delegated heroes', () => {
const context = buildRoundContext({
scene: 'Current scene', storySummary: 'Story so far',
recentRounds: [1, 2, 3].map(number => ({ number, narration: `Round ${number}` })),
intents: [intent('late', 'human', '2026-01-01T00:00:02.000Z'), intent('early', 'human', '2026-01-01T00:00:01.000Z')],
characters: [character('human', 'human'), character('companion', 'ai'), character('stand-in', 'delegated')],
memories: Array.from({ length: 10 }, (_, index) => ({ summary: `Memory ${index}`, importance: (index % 5) + 1 })),
})
const plan: RoundPlan = { checks: [], proposedEvents: [], relevantMemoryQueries: [], aiActions: [
{ characterId: 'stand-in', action: 'Covers the retreat' },
{ characterId: 'human', action: 'Illegally overrides the player' },
{ characterId: 'companion', action: 'Scouts ahead' },
] }
const validated = validateAndOrderRoundPlan(plan, context)
expect(context.recentRounds.map(round => round.number)).toEqual([2, 3])
expect(context.memories).toHaveLength(8)
expect(validated.aiActions.map(action => action.characterId)).toEqual(['companion', 'stand-in'])
expect(buildActionSequence(context, validated).map(action => action.phase)).toEqual(['human', 'human', 'ai', 'ai'])
})
it('rejects mechanical references outside the active campaign', () => {
const context = buildRoundContext({ scene: 'Scene', recentRounds: [], intents: [], characters: [character('hero', 'human')], memories: [] })
expect(() => validateAndOrderRoundPlan({
checks: [{ actorId: 'outsider', kind: 'ability', mode: 'normal', reason: 'Invalid' }],
aiActions: [], proposedEvents: [], relevantMemoryQueries: [],
}, 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])
})
it('drops model-authored memory labels that PostgreSQL cannot cast to uuid[]', () => {
const knownId = '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'
expect(sanitizeRoundMemory({
summary: 'The party learned who controls the gate.',
importance: 4,
tags: ['gate'],
entityIds: ['the-gatekeeper', knownId, knownId, '8ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
}, new Set([knownId]))?.entityIds).toEqual([knownId])
})
it('builds a bounded retrieval query from only current-round context', () => {
const text = buildRetrievalText({
scene: 'The sealed moon gate is humming.',
intents: [intent('ready', 'hero', '2026-01-01T00:00:00.000Z'), { ...intent('draft', 'hero', '2026-01-01T00:00:01.000Z'), ready: false }],
characters: [character('hero', 'human')],
})
expect(text).toContain('sealed moon gate')
expect(text).toContain('Action ready')
expect(text).not.toContain('Action draft')
})
it('accepts typed relationship, quest, and scene projections only for known entities', () => {
const questId = '11111111-1111-4111-8111-111111111111'
const locationId = '22222222-2222-4222-8222-222222222222'
const context = buildRoundContext({
scene: 'Scene', recentRounds: [], intents: [], characters: [character('hero', 'human')], memories: [],
entities: [
{ id: questId, kind: 'quest', name: 'Open the gate', summary: 'Find the key.', tags: ['gate'] },
{ id: locationId, kind: 'location', name: 'Moon Gate', summary: 'A sealed arch.', tags: ['gate'] },
],
})
const validated = validateAndOrderRoundPlan({
checks: [], aiActions: [], relevantMemoryQueries: [],
proposedEvents: [
{ type: 'relationship', actorId: 'hero', targetId: questId, value: 3, item: null, damageType: null, description: 'The hero becomes invested.' },
{ type: 'quest', actorId: 'hero', targetId: questId, value: null, item: 'active', damageType: null, description: 'The gate must be opened.' },
{ type: 'narrative', actorId: null, targetId: locationId, value: null, item: 'scene', damageType: null, description: 'The party reaches the Moon Gate.' },
],
}, context)
expect(validated.proposedEvents).toHaveLength(3)
})
})