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) }) })