import { RoundContextSchema, RoundPlanSchema, type Character, type ContextEntity, type PlayerIntent, type QuestState, type RelationshipState, type RoundContext, type RoundPlan, type RoundResolution, } from '@dng/shared' const postgresUuidPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i export interface RoundContextInput { scene: string storySummary?: string | null recentRounds: Array<{ number: number; narration: string; nextPrompt?: string | null }> intents: PlayerIntent[] characters: Character[] memories: Array<{ summary: string; importance?: number; tags?: string[]; entityIds?: string[] }> entities?: ContextEntity[] relationships?: RelationshipState[] activeGoals?: QuestState[] } export function buildRetrievalText(input: Pick): string { return [ input.scene, ...input.intents.filter(intent => intent.ready).map(intent => intent.action), ...input.characters.flatMap(character => [character.name, character.concept]), ].join(' ').replace(/\s+/g, ' ').trim().slice(0, 4_000) } export function buildRoundContext(input: RoundContextInput): RoundContext { const characterIds = new Set(input.characters.map(character => character.id)) const actingCharacterIds = new Set(input.intents.filter(intent => intent.ready).map(intent => intent.characterId)) const humanIntents = input.intents .filter(intent => intent.ready && characterIds.has(intent.characterId)) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) const aiCharacters = input.characters.filter(character => character.controller === 'ai' || (character.controller === 'delegated' && !actingCharacterIds.has(character.id)), ) const relevanceText = [ input.scene, ...humanIntents.map(intent => intent.action), ...input.characters.flatMap(character => [character.id, character.name]), ].join(' ').toLowerCase() const rankedMemories = input.memories .map(memory => { const terms = [...(memory.tags ?? []), ...(memory.entityIds ?? [])] const relevance = terms.reduce((score, term) => score + (term && relevanceText.includes(term.toLowerCase()) ? 10 : 0), 0) return { memory, score: relevance + (memory.importance ?? 0) } }) .sort((left, right) => right.score - left.score) .slice(0, 8) .map(entry => entry.memory) return RoundContextSchema.parse({ scene: input.scene, storySummary: input.storySummary ?? null, recentRounds: [...input.recentRounds].sort((a, b) => a.number - b.number).slice(-2), humanIntents, aiCharacters, activeCharacters: input.characters, entities: input.entities ?? [], relationships: input.relationships ?? [], activeGoals: input.activeGoals ?? [], memories: rankedMemories, }) } export function validateAndOrderRoundPlan(planInput: unknown, context: RoundContext): RoundPlan { const plan = RoundPlanSchema.parse(planInput) const characterIds = new Set(context.activeCharacters.map(character => character.id)) const entityIds = new Set(context.entities.map(entity => entity.id)) const worldStateIds = new Set([...characterIds, ...entityIds]) const entityKinds = new Map(context.entities.map(entity => [entity.id, entity.kind])) const aiOrder = new Map(context.aiCharacters.map((character, index) => [character.id, index])) const seenAiActors = new Set() 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 === 'deathSavingThrow' && check.targetId) throw new Error('Death saving throws cannot target another character') 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) { const mechanical = ['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type) if (event.actorId && !(mechanical ? characterIds : worldStateIds).has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`) if (event.targetId && !(mechanical ? characterIds : worldStateIds).has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`) if (['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type) && !event.targetId) { throw new Error(`${event.type} event requires a target`) } if (event.type === 'damage' && !event.damageType) throw new Error('damage events require a damage type') if (event.type === 'resource' && !event.item) throw new Error('resource events require a resource name') if (event.type === 'rest' && !['short', 'long'].includes(String(event.item))) throw new Error('rest events require short or long') if (event.type === 'relationship') { if (!event.actorId || !event.targetId || event.actorId === event.targetId) throw new Error('relationship events require two distinct known entities') if (event.value === null || event.value < -20 || event.value > 20) throw new Error('relationship changes must be between -20 and 20') } if (event.type === 'quest') { if (!event.targetId || entityKinds.get(event.targetId) !== 'quest') throw new Error('quest events require a known quest entity') if (!['hidden', 'active', 'completed', 'failed'].includes(String(event.item))) throw new Error('quest events require a valid quest status') } if (event.type === 'narrative' && event.item === 'scene' && event.targetId && entityKinds.get(event.targetId) !== 'location') { throw new Error('scene events may only target a known location') } } const aiActions = plan.aiActions .filter(action => aiOrder.has(action.characterId)) .filter(action => { if (seenAiActors.has(action.characterId)) return false seenAiActors.add(action.characterId) return true }) .sort((left, right) => aiOrder.get(left.characterId)! - aiOrder.get(right.characterId)!) return { ...plan, aiActions } } export function buildActionSequence(context: RoundContext, plan: RoundPlan) { return [ ...context.humanIntents.map(intent => ({ phase: 'human' as const, characterId: intent.characterId, action: intent.action })), ...plan.aiActions.map(action => ({ phase: 'ai' as const, characterId: action.characterId, action: action.action })), ] } export function shouldUpdateStorySummary(roundNumber: number): boolean { return Number.isInteger(roundNumber) && roundNumber > 0 && roundNumber % 3 === 0 } /** * PostgreSQL stores memory entity references as uuid[]. Model-authored labels * are useful prose but cannot be cast to that column and would roll back an * otherwise valid round. Preserve only storage-compatible references. */ export function sanitizeRoundMemory(memory: RoundResolution['memory'], allowedEntityIds?: ReadonlySet): RoundResolution['memory'] { if (!memory) return null return { ...memory, entityIds: [...new Set(memory.entityIds.filter(id => postgresUuidPattern.test(id) && (!allowedEntityIds || allowedEntityIds.has(id)), ))], } }