105 lines
5.8 KiB
TypeScript
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)
|
|
})
|
|
})
|