Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
@@ -34,5 +34,5 @@ describe('worker AI providers', () => {
|
||||
const { generateWorld } = await import('./ai')
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
||||
}, 15_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoundPlanSchema, RoundResolutionSchema, WorldStarterSchema, moderate13Plus, type Character, type PlayerIntent, type RoundPlan, type RoundResolution, type WorldStarter } from '@dng/shared'
|
||||
import { RoundPlanSchema, RoundResolutionSchema, StorySummarySchema, WorldStarterSchema, moderate13Plus, type RoundContext, type RoundPlan, type RoundResolution, type StorySummary, type WorldStarter } from '@dng/shared'
|
||||
import type { DiceRoll } from '@dng/shared'
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
@@ -96,18 +96,27 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
|
||||
return world
|
||||
}
|
||||
|
||||
export async function planRound(input: { scene: string; intents: PlayerIntent[]; characters: Character[]; memories: string[] }): Promise<RoundPlan> {
|
||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
||||
return structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You plan one asynchronous TTRPG round. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'system', content: 'You plan one asynchronous 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. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
}
|
||||
|
||||
export async function narrateRound(input: { scene: string; intents: PlayerIntent[]; aiActions: RoundPlan['aiActions']; 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'] }): Promise<RoundResolution> {
|
||||
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round using the supplied, authoritative dice results. Do not change them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
|
||||
{ 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))
|
||||
assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
|
||||
return resolution
|
||||
}
|
||||
|
||||
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }): 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))
|
||||
assert13Plus(result.summary)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Worker } from 'bullmq'
|
||||
import IORedis from 'ioredis'
|
||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
|
||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||
import { generateWorld, narrateRound, planRound } from './ai'
|
||||
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
|
||||
const redisUrl = process.env.REDIS_URL
|
||||
const supabaseUrl = process.env.SUPABASE_URL
|
||||
@@ -36,7 +37,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
) {
|
||||
const rpcName = path.startsWith('rpc/') ? path.slice('rpc/'.length) : null
|
||||
const message = status === 404 && rpcName
|
||||
? `Supabase RPC "${rpcName}" was not found. Apply supabase/migrations/0002_supabase_ai_queue.sql in the Supabase SQL Editor, then restart the worker.`
|
||||
? `Supabase RPC "${rpcName}" was not found. The database schema is incomplete. Run the generated supabase/bootstrap.sql in the Supabase SQL Editor, then restart the worker.`
|
||||
: `Supabase returned ${status} for ${path}: ${detail}`
|
||||
super(message)
|
||||
this.name = 'SupabaseRequestError'
|
||||
@@ -57,14 +58,37 @@ if (!supabaseUrl || !serviceKey) {
|
||||
const detail = await response.text()
|
||||
throw new SupabaseRequestError(response.status, path, detail)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
const responseBody = await response.text()
|
||||
if (!responseBody) return undefined as T
|
||||
return JSON.parse(responseBody) as T
|
||||
}
|
||||
|
||||
async function selectRows<T>(table: string, query: Record<string, string>): Promise<T[]> {
|
||||
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) {
|
||||
try {
|
||||
await databaseRequest('rpc/stage_two_upsert_story_summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
p_job_id: jobId,
|
||||
p_campaign_id: campaignId,
|
||||
p_through_round: throughRound,
|
||||
p_summary: summary,
|
||||
}),
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 250))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
async function processWorld(job: JobPayload) {
|
||||
let messages = job.messages
|
||||
if (!messages) {
|
||||
@@ -94,32 +118,57 @@ if (!supabaseUrl || !serviceKey) {
|
||||
if (!round) throw new Error('Round not found')
|
||||
if (round.status === 'resolved') return { duplicate: true }
|
||||
|
||||
const [rawCharacters, rawIntents, rawMemories] = await Promise.all([
|
||||
const [rawCharacters, rawIntents, rawMemories, 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 }>('memories', { select: 'summary', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc', limit: '12' }),
|
||||
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',
|
||||
}),
|
||||
selectRows<{ through_round: number; summary: string }>('story_summaries', {
|
||||
select: 'through_round,summary', campaign_id: `eq.${round.campaign_id}`, order: 'through_round.desc', limit: '1',
|
||||
}),
|
||||
])
|
||||
|
||||
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
||||
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
|
||||
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
|
||||
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
|
||||
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses, persona: row.persona,
|
||||
}))
|
||||
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
|
||||
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
|
||||
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
}))
|
||||
const scene = String(round.campaigns.current_scene ?? '')
|
||||
const plan = await planRound({ scene, intents, characters, memories: rawMemories.map(row => String(row.summary)) })
|
||||
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,
|
||||
})),
|
||||
})
|
||||
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
||||
const rolls = plan.checks.map(check => {
|
||||
const actor = characters.find(character => character.id === check.actorId)
|
||||
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||
return resolveCheck(check, actor)
|
||||
})
|
||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
||||
const resolution = await narrateRound({ scene, intents, aiActions: plan.aiActions, rolls, permittedEvents })
|
||||
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
|
||||
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
||||
const applied = applyMechanicalEvents(characters, safeEvents)
|
||||
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
|
||||
const summary = shouldSummarize
|
||||
? await summarizeStory({
|
||||
previousSummary: context.storySummary,
|
||||
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
|
||||
})
|
||||
: null
|
||||
|
||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
|
||||
method: 'POST',
|
||||
@@ -130,11 +179,18 @@ if (!supabaseUrl || !serviceKey) {
|
||||
p_rolls: rolls,
|
||||
p_events: safeEvents,
|
||||
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
|
||||
p_memory: resolution.memory,
|
||||
p_memory: sanitizeRoundMemory(resolution.memory),
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -201,6 +257,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
const pollInterval = Math.max(250, Number(process.env.AI_JOB_POLL_INTERVAL_MS ?? 1_000))
|
||||
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
|
||||
let stopped = false
|
||||
let consecutivePollFailures = 0
|
||||
|
||||
const stop = () => { stopped = true }
|
||||
process.once('SIGTERM', stop)
|
||||
@@ -212,6 +269,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
const claimed = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job', {
|
||||
method: 'POST', body: JSON.stringify({ p_worker_id: workerId }),
|
||||
})
|
||||
consecutivePollFailures = 0
|
||||
const job = claimed[0]
|
||||
if (!job) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
@@ -233,8 +291,15 @@ if (!supabaseUrl || !serviceKey) {
|
||||
process.exitCode = 1
|
||||
break
|
||||
}
|
||||
console.error('[worker] polling failed:', error)
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
consecutivePollFailures += 1
|
||||
const retryDelay = Math.min(30_000, pollInterval * 2 ** Math.min(consecutivePollFailures - 1, 5))
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Avoid flooding the terminal and Supabase during a network outage.
|
||||
// Log the first failure and then only when the backoff duration grows.
|
||||
if (consecutivePollFailures === 1 || (consecutivePollFailures & (consecutivePollFailures - 1)) === 0) {
|
||||
console.error(`[worker] polling failed; retrying in ${retryDelay}ms: ${message}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
apps/worker/src/orchestration.test.ts
Normal file
56
apps/worker/src/orchestration.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
|
||||
import { buildActionSequence, 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('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[]', () => {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
110
apps/worker/src/orchestration.ts
Normal file
110
apps/worker/src/orchestration.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
RoundContextSchema,
|
||||
RoundPlanSchema,
|
||||
type Character,
|
||||
type PlayerIntent,
|
||||
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[] }>
|
||||
}
|
||||
|
||||
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,
|
||||
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 aiOrder = new Map(context.aiCharacters.map((character, index) => [character.id, index]))
|
||||
const seenAiActors = new Set<string>()
|
||||
|
||||
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}`)
|
||||
}
|
||||
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}`)
|
||||
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !event.targetId) {
|
||||
throw new Error(`${event.type} event requires a target`)
|
||||
}
|
||||
}
|
||||
|
||||
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']): RoundResolution['memory'] {
|
||||
if (!memory) return null
|
||||
return {
|
||||
...memory,
|
||||
entityIds: [...new Set(memory.entityIds.filter(id => postgresUuidPattern.test(id)))],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user