feat(memory): implement stability stage
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Has been cancelled

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-02 15:04:18 +05:00
parent 4cd0b7b390
commit 031d094867
24 changed files with 1661 additions and 79 deletions

View File

@@ -12,6 +12,20 @@ interface AiProvider {
providerOptions?: Record<string, unknown>
}
export interface AiUsageMeasurement {
operation: string
provider: AiProvider['name']
model: string
inputTokens: number
outputTokens: number
costUsd: number
latencyMs: number
}
interface AiRequestOptions {
onUsage?: (measurement: AiUsageMeasurement) => void | Promise<void>
}
function getProvider(): AiProvider {
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
if (provider === 'deepseek') {
@@ -46,10 +60,11 @@ function assert13Plus(...texts: string[]): void {
if (!result.allowed) throw new Error(`Content policy rejected: ${result.categories.join(', ')}`)
}
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T): Promise<T> {
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T, options: AiRequestOptions = {}): Promise<T> {
const provider = getProvider()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 45_000)
const startedAt = Date.now()
try {
const response = await fetch(provider.endpoint, {
method: 'POST',
@@ -77,8 +92,21 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
signal: controller.signal,
})
if (!response.ok) throw new Error(`AI provider returned ${response.status}`)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: unknown }
const payload = await response.json() as {
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number | null; completion_tokens?: number | null; input_tokens?: number | null; output_tokens?: number | null; cost?: number | null; total_cost?: number | null }
}
const content = payload.choices?.[0]?.message?.content
const usage = payload.usage
await options.onUsage?.({
operation: name,
provider: provider.name,
model: provider.model,
inputTokens: Math.max(0, Math.trunc(usage?.prompt_tokens ?? usage?.input_tokens ?? 0)),
outputTokens: Math.max(0, Math.trunc(usage?.completion_tokens ?? usage?.output_tokens ?? 0)),
costUsd: Math.max(0, Number(usage?.cost ?? usage?.total_cost ?? 0)),
latencyMs: Math.max(0, Date.now() - startedAt),
})
if (!content) throw new Error('AI provider returned an empty response')
return parse(JSON.parse(content))
} finally {
@@ -86,41 +114,41 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
}
}
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>, options: AiRequestOptions = {}): Promise<WorldStarter> {
assert13Plus(...messages.map(message => message.content))
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private SRD 5.2.1 TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, exactly four fitting skill proficiencies, exactly two saving throw proficiencies, no more than one expertise chosen from their proficient skills, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
...messages,
], value => WorldStarterSchema.parse(value))
], value => WorldStarterSchema.parse(value), options)
// Moderate the entire typed object, including entity summaries, secrets and
// boundaries—not just the headline fields shown in the first preview.
assert13Plus(JSON.stringify(world))
return world
}
export async function planRound(input: RoundContext): Promise<RoundPlan> {
export async function planRound(input: RoundContext, options: AiRequestOptions = {}): Promise<RoundPlan> {
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
{ 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, deathSavingThrow, 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. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP 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, deathSavingThrow, 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. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP and randomness. Use only supplied entity IDs for world-state events: relationship changes require distinct actorId and targetId plus a delta from -20 to 20; quest changes target a supplied quest entity and use item hidden, active, completed, or failed; a scene transition is a narrative event with item scene and may target a supplied location. Include only changes caused by this round.' },
{ role: 'user', content: JSON.stringify(input) },
], value => RoundPlanSchema.parse(value))
], value => RoundPlanSchema.parse(value), options)
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
return plan
}
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }, options: AiRequestOptions = {}): Promise<RoundResolution> {
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
{ role: 'system', content: 'Narrate the resolved TTRPG round in the exact supplied actionSequence order: all human actions first, then AI/delegated hero actions. Use the authoritative dice results without changing them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
{ role: 'user', content: JSON.stringify(input) },
], value => RoundResolutionSchema.parse(value))
], value => RoundResolutionSchema.parse(value), options)
assert13Plus(JSON.stringify(resolution))
return resolution
}
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }): Promise<StorySummary> {
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }, options: AiRequestOptions = {}): Promise<StorySummary> {
const result = await structuredRequest('story_summary', StorySummarySchema.toJSONSchema(), [
{ role: 'system', content: 'Update a durable TTRPG campaign summary. Preserve established facts, goals, relationships and unresolved consequences. Use only the supplied previous summary and round narrations. Be concise and do not invent details.' },
{ role: 'user', content: JSON.stringify(input) },
], value => StorySummarySchema.parse(value))
], value => StorySummarySchema.parse(value), options)
assert13Plus(result.summary)
return result
}

View File

@@ -3,8 +3,8 @@ import { Worker } from 'bullmq'
import IORedis from 'ioredis'
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'
import { generateWorld, narrateRound, planRound, summarizeStory, type AiUsageMeasurement } from './ai'
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
const redisUrl = process.env.REDIS_URL
const supabaseUrl = process.env.SUPABASE_URL
@@ -28,6 +28,12 @@ if (!supabaseUrl || !serviceKey) {
job_type: JobType
entity_id: string
}
interface RetrievedContext {
memories?: Array<{ summary: string; importance: number; tags: string[]; entityIds: string[] }>
entities?: Array<{ id: string; kind: 'location' | 'npc' | 'faction' | 'quest'; name: string; summary: string; tags: string[] }>
relationships?: Array<{ sourceEntityId: string; targetEntityId: string; score: number; notes: string }>
activeGoals?: Array<{ questEntityId: string; name: string; status: 'hidden' | 'active' | 'completed' | 'failed'; summary: string; tags: string[] }>
}
class SupabaseRequestError extends Error {
constructor(
@@ -67,38 +73,42 @@ if (!supabaseUrl || !serviceKey) {
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
}
async function persistStorySummary(jobId: string, campaignId: string, throughRound: number, summary: string): Promise<void> {
let lastError: unknown
for (let attempt = 1; attempt <= 3; attempt += 1) {
function usageRecorder(job: JobPayload, userId: string | null, campaignId: string | null) {
return async (measurement: AiUsageMeasurement): Promise<void> => {
try {
await databaseRequest('rpc/stage_two_upsert_story_summary', {
await databaseRequest('ai_usage?on_conflict=usage_key', {
method: 'POST',
headers: { Prefer: 'resolution=merge-duplicates,return=minimal' },
body: JSON.stringify({
p_job_id: jobId,
p_campaign_id: campaignId,
p_through_round: throughRound,
p_summary: summary,
usage_key: `${job.id}:${measurement.operation}:${makeId('usage')}`,
user_id: userId,
campaign_id: campaignId,
job_id: job.id,
provider: measurement.provider,
request_kind: measurement.operation,
model: measurement.model,
input_tokens: measurement.inputTokens,
output_tokens: measurement.outputTokens,
cost_usd: measurement.costUsd,
latency_ms: measurement.latencyMs,
}),
})
return
} catch (error) {
lastError = error
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 250))
// Usage telemetry must never trigger a second paid model call. Quota
// reservations are persisted before the job is queued, so a temporary
// telemetry failure cannot bypass rate limits.
console.error(`[worker] could not record ${measurement.operation} usage for job ${job.id}:`, error)
}
}
throw lastError
}
async function processWorld(job: JobPayload) {
let messages = job.messages
if (!messages) {
const [session] = await selectRows<{ messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
select: 'messages', id: `eq.${job.entityId}`, limit: '1',
})
if (!session) throw new Error('Coauthor session not found')
messages = session.messages
}
const world = await generateWorld(messages)
const [session] = await selectRows<{ owner_id: string; messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
select: 'owner_id,messages', id: `eq.${job.entityId}`, limit: '1',
})
if (!session) throw new Error('Coauthor session not found')
const messages = job.messages ?? session.messages
const world = await generateWorld(messages, { onUsage: usageRecorder(job, session.owner_id, null) })
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
@@ -118,12 +128,9 @@ if (!supabaseUrl || !serviceKey) {
if (!round) throw new Error('Round not found')
if (round.status === 'resolved') return { duplicate: true }
const [rawCharacters, rawIntents, rawMemories, rawRecentRounds, rawStorySummaries] = await Promise.all([
const [rawCharacters, rawIntents, rawRecentRounds, rawStorySummaries] = await Promise.all([
selectRows<Record<string, any>>('characters', { select: '*', campaign_id: `eq.${round.campaign_id}` }),
selectRows<Record<string, any>>('player_intents', { select: '*', round_id: `eq.${round.id}` }),
selectRows<{ summary: string; importance: number; tags: string[]; entity_ids: string[] }>('memories', {
select: 'summary,importance,tags,entity_ids', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc,created_at.desc', limit: '24',
}),
selectRows<{ number: number; narration: string; next_prompt: string | null }>('rounds', {
select: 'number,narration,next_prompt', campaign_id: `eq.${round.campaign_id}`, status: 'eq.resolved', order: 'number.desc', limit: '2',
}),
@@ -143,20 +150,30 @@ if (!supabaseUrl || !serviceKey) {
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
}))
const scene = String(round.campaigns.current_scene ?? '')
const retrieval = await databaseRequest<RetrievedContext>('rpc/stage_six_retrieve_context', {
method: 'POST',
body: JSON.stringify({
p_campaign_id: round.campaign_id,
p_search_text: buildRetrievalText({ scene, intents, characters }),
p_limit: 8,
}),
})
const context = buildRoundContext({
scene,
storySummary: rawStorySummaries[0]?.summary ?? null,
recentRounds: rawRecentRounds.map(previous => ({ number: previous.number, narration: previous.narration, nextPrompt: previous.next_prompt })),
intents,
characters,
memories: rawMemories.map(memory => ({
summary: memory.summary, importance: memory.importance, tags: memory.tags, entityIds: memory.entity_ids,
})),
memories: retrieval.memories ?? [],
entities: retrieval.entities ?? [],
relationships: retrieval.relationships ?? [],
activeGoals: retrieval.activeGoals ?? [],
})
const plan = validateAndOrderRoundPlan(await planRound(context), context)
const recordUsage = usageRecorder(job, String(round.campaigns.owner_id), String(round.campaign_id))
const plan = validateAndOrderRoundPlan(await planRound(context, { onUsage: recordUsage }), context)
const rolls = resolveChecks(plan.checks, characters)
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents }, { onUsage: recordUsage })
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
const applied = applyMechanicalEvents(characters, safeEvents, rolls)
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
@@ -164,10 +181,10 @@ if (!supabaseUrl || !serviceKey) {
? await summarizeStory({
previousSummary: context.storySummary,
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
})
}, { onUsage: recordUsage })
: null
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_srd_round_resolution' : 'rpc/commit_srd_round_resolution', {
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_stage_six_round_resolution' : 'rpc/commit_stage_six_round_resolution', {
method: 'POST',
body: JSON.stringify({
p_round_id: round.id,
@@ -182,18 +199,15 @@ if (!supabaseUrl || !serviceKey) {
statuses: character.statuses,
rulesState: character.rules,
})),
p_memory: sanitizeRoundMemory(resolution.memory),
p_memory: sanitizeRoundMemory(resolution.memory, new Set([
...context.activeCharacters.map(character => character.id),
...context.entities.map(entity => entity.id),
])),
p_story_summary: summary?.summary ?? null,
p_idempotency_key: job.id,
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
}),
})
if (summary) {
try {
await persistStorySummary(job.id, round.campaign_id, Number(round.number), summary.summary)
} catch (error) {
console.error(`[worker] round ${round.id} committed but story summary persistence failed:`, error)
}
}
return { narration: resolution.narration, rolls: rolls.length }
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { Character, PlayerIntent } from '@dng/shared'
import { buildRoundContext } from './orchestration'
const hero: Character = {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
name: 'Mara Venn',
concept: 'A careful investigator who remembers promises.',
controller: 'human',
userId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
abilities: { str: 10, dex: 12, con: 10, int: 14, wis: 13, cha: 9 },
hp: 10,
maxHp: 10,
defense: 12,
proficiency: 2,
inventory: [],
statuses: [],
}
const scenarios = Array.from({ length: 20 }, (_, index) => {
const number = index + 1
const tag = `clue-${number.toString().padStart(2, '0')}`
return {
name: `memory regression ${number.toString().padStart(2, '0')}`,
tag,
fact: `In the opening round, Mara promised the keeper to preserve ${tag}.`,
action: `Mara invokes ${tag} and asks the keeper to honor their opening-round agreement.`,
}
})
describe('20-scenario AI memory context regression suite', () => {
it.each(scenarios)('$name retrieves the expected early fact after twenty rounds', ({ tag, fact, action }) => {
const intent: PlayerIntent = {
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
roundId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
memberId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
characterId: hero.id,
action,
ready: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
const context = buildRoundContext({
scene: `Round 21 returns to the keeper's chamber near ${tag}.`,
storySummary: 'The party survived twenty rounds without replaying the full transcript.',
recentRounds: Array.from({ length: 20 }, (_, round) => ({ number: round + 1, narration: `Resolved round ${round + 1}.` })),
intents: [intent],
characters: [hero],
memories: [
{ summary: fact, importance: 5, tags: [tag], entityIds: [] },
...Array.from({ length: 24 }, (_, decoy) => ({
summary: `Unrelated later observation ${decoy + 1}.`,
importance: (decoy % 4) + 1,
tags: [`decoy-${decoy + 1}`],
entityIds: [],
})),
],
})
expect(context.recentRounds.map(round => round.number)).toEqual([19, 20])
expect(context.memories.map(memory => memory.summary)).toContain(fact)
expect(context.memories).toHaveLength(8)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
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,
@@ -61,11 +61,44 @@ describe('round orchestration', () => {
})
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', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
})?.entityIds).toEqual(['9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'])
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)
})
})

View File

@@ -2,7 +2,10 @@ import {
RoundContextSchema,
RoundPlanSchema,
type Character,
type ContextEntity,
type PlayerIntent,
type QuestState,
type RelationshipState,
type RoundContext,
type RoundPlan,
type RoundResolution,
@@ -17,6 +20,17 @@ export interface RoundContextInput {
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<RoundContextInput, 'scene' | 'intents' | 'characters'>): 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 {
@@ -51,6 +65,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
humanIntents,
aiCharacters,
activeCharacters: input.characters,
entities: input.entities ?? [],
relationships: input.relationships ?? [],
activeGoals: input.activeGoals ?? [],
memories: rankedMemories,
})
}
@@ -58,6 +75,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
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<string>()
@@ -74,14 +94,26 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
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}`)
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
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
@@ -112,10 +144,12 @@ export function shouldUpdateStorySummary(roundNumber: number): boolean {
* 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']): RoundResolution['memory'] {
export function sanitizeRoundMemory(memory: RoundResolution['memory'], allowedEntityIds?: ReadonlySet<string>): RoundResolution['memory'] {
if (!memory) return null
return {
...memory,
entityIds: [...new Set(memory.entityIds.filter(id => postgresUuidPattern.test(id)))],
entityIds: [...new Set(memory.entityIds.filter(id =>
postgresUuidPattern.test(id) && (!allowedEntityIds || allowedEntityIds.has(id)),
))],
}
}