refactor!(gui): Refactor simulation.ts

This commit is contained in:
2026-07-16 08:43:07 +05:30
parent c55c7e222a
commit 906ef2cdf3
10 changed files with 1219 additions and 1072 deletions

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
import type { SimSession } from "./types";
/**
* Runs the HandoffEngine for every agent entity that has accumulated enough
* buffer entries to warrant a handoff (compression to long-term memory).
*/
export async function runHandoffResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const handoffEngine = new HandoffEngine(
session.handoffProvider,
session.embeddingProvider,
session.bufferRepo,
session.ledgerRepo,
);
const entities = Array.from(worldState.entities.values());
for (const entity of entities) {
if (!entity.isAgent) continue;
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
const maxContext =
session.handoffProvider.maxContext !== undefined
? session.handoffProvider.maxContext
: 32768;
const trigger = checkHandoffTrigger(
entity,
bufferEntries,
worldState.clock.get(),
maxContext,
);
if (trigger !== "none") {
await handoffEngine.runHandoff(
entity,
bufferEntries,
worldState.clock.get(),
);
}
}
}
/**
* For every agent that shares a location with another entity they haven't
* previously encountered, generates a first-person alias description and
* persists it on the viewing entity.
*/
export async function runAliasResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entities = Array.from(worldState.entities.values());
for (const viewer of entities) {
if (!viewer.isAgent) continue;
if (!viewer.locationId) continue;
for (const target of entities) {
if (viewer.id === target.id) continue;
if (
target.locationId === viewer.locationId &&
!viewer.aliases.has(target.id)
) {
const alias = await session.aliasGenerator.generate(viewer, target);
viewer.aliases.set(target.id, alias);
session.coreRepo.saveEntity(viewer, worldState.id);
}
}
}
}

View File

@@ -0,0 +1,16 @@
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load .env from monorepo root or apps/gui/
const cwd = process.cwd();
const envCandidates = [
path.resolve(cwd, ".env"),
path.resolve(cwd, "../../.env"),
];
for (const c of envCandidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
dotenv.config({ path: c });
break;
}
}

View File

@@ -0,0 +1,17 @@
/**
* Barrel entry point for the simulation module.
*
* Consumers import from "@/lib/simulation" exactly as before — no import
* paths need to change anywhere in the codebase.
*/
import { SimulationManager } from "./simulation-manager";
export const simulationManager = new SimulationManager();
export type {
SimSnapshot,
EntityInfo,
LogEntry,
IntentInfo,
WaitingContext,
} from "../simulation-types";

View File

@@ -0,0 +1,175 @@
import {
GeminiProvider,
MockLLMProvider,
ProviderManager,
OpenRouterProvider,
GeminiEmbeddingProvider,
MockEmbeddingProvider,
} from "@omnia/llm";
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "@omnia/llm";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface ResolvedProviders {
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
}
export interface ProviderResolverOptions {
/**
* Pre-resolved generative instance to fall back to when ProviderManager has
* no active generative provider (e.g. when the caller already validated a
* specific provider during session creation).
*/
fallbackInstance?: ModelProviderInstance | null;
/**
* When true, throws an Error if no provider can be resolved for a task.
* When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
*/
required?: boolean;
}
// ---------------------------------------------------------------------------
// Private builders
// ---------------------------------------------------------------------------
function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider {
if (inst.providerName === "google-genai") {
return new GeminiProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
} else if (inst.providerName === "openrouter") {
return new OpenRouterProvider(
inst.apiKey,
inst.modelName,
inst.name,
inst.maxContext,
);
}
return new MockLLMProvider([]);
}
function buildEmbeddingProvider(
inst: ModelProviderInstance,
): IEmbeddingProvider {
if (inst.providerName === "google-genai") {
return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName);
}
return new MockEmbeddingProvider(inst.modelName);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolves all six LLM + embedding providers needed for a simulation session.
*
* Resolution order for each generative task:
* 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
* 2. ProviderManager active generative instance
* 3. `fallbackInstance` (if supplied)
* 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
* 5. Throws (if `required`) or returns MockLLMProvider
*/
export function resolveProviders(
mappings: Record<string, string>,
options: ProviderResolverOptions = {},
): ResolvedProviders {
const { fallbackInstance = null, required = false } = options;
const list = ProviderManager.list();
const activeGenerative =
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
const resolveGenerative = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "generative") {
inst = activeGenerative;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
);
}
return new MockLLMProvider([]);
}
return buildLLMProvider(inst);
};
const resolveEmbedding = (): IEmbeddingProvider => {
const mappedId = mappings["embeddings"];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default Embed (Env)",
"google-genai",
envKey,
"gemini-embedding-001",
"embedding",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
);
}
return new MockEmbeddingProvider(undefined);
}
return buildEmbeddingProvider(inst);
};
return {
actorProvider: resolveGenerative("actor-prose"),
validatorProvider: resolveGenerative("llm-validator"),
decoderProvider: resolveGenerative("intent-decoder"),
timedeltaProvider: resolveGenerative("timedelta"),
handoffProvider: resolveGenerative("handoff"),
embeddingProvider: resolveEmbedding(),
};
}

View File

@@ -0,0 +1,139 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { SimSession, SavedState } from "./types";
import type { SimSnapshot } from "../simulation-types";
export const DATA_DIR = path.resolve(process.cwd(), "data");
// ---------------------------------------------------------------------------
// Low-level read/write helpers
// ---------------------------------------------------------------------------
export function loadSessionState(
db: Database.Database,
id: string,
): SavedState | null {
try {
db.prepare(
`CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
).run();
const row = db
.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
.get(id) as { state_json: string } | undefined;
return row ? (JSON.parse(row.state_json) as SavedState) : null;
} catch {
return null;
}
}
export function saveSession(session: SimSession): void {
const state: SavedState = {
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
turn: session.turn,
maxTurns: session.maxTurns,
entities: session.entities,
playerEntityId: session.playerEntityId,
entityIndex: session.entityIndex,
status: session.status,
error: session.error,
waitingEntity: session.waitingEntity,
aliasDoneForTurn: session.aliasDoneForTurn,
log: session.log,
providerMappings: session.providerMappings,
};
session.db
.prepare(
`CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
)
.run();
session.db
.prepare(
`INSERT INTO gui_meta (id, state_json)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
)
.run(session.worldInstanceId, JSON.stringify(state));
}
// ---------------------------------------------------------------------------
// Session file management
// ---------------------------------------------------------------------------
export function deleteSessionFile(id: string): void {
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (fs.existsSync(dbPath)) {
try {
fs.unlinkSync(dbPath);
} catch (err) {
console.error(`Failed to delete session file ${dbPath}:`, err);
}
}
}
/**
* Lists all saved simulation snapshots by scanning the data directory.
* Active in-memory sessions are snapshotted via the provided callback;
* inactive ones are read directly from their `.db` files.
*/
export function listSavedSessions(
activeSessions: Map<string, SimSession>,
snapshotFn: (session: SimSession) => SimSnapshot,
): SimSnapshot[] {
if (!fs.existsSync(DATA_DIR)) return [];
const snapshots: SimSnapshot[] = [];
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
for (const file of files) {
const id = file.replace(".db", "");
const dbPath = path.join(DATA_DIR, file);
const active = activeSessions.get(id);
if (active) {
snapshots.push(snapshotFn(active));
continue;
}
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
db.close();
if (state) {
snapshots.push({
id,
status: state.status,
turn: state.turn,
maxTurns: state.maxTurns,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
entities: state.entities || [],
log: state.log || [],
entityIndex: state.entityIndex,
waitingEntity: state.waitingEntity,
error: state.error,
});
}
} catch {
/* skip corrupt / in-use db files */
}
}
return snapshots.sort((a, b) => {
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
return tsB - tsA;
});
}

View File

@@ -0,0 +1,455 @@
import "./env"; // Must be first — loads .env before any code reads process.env
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { SQLiteRepository } from "@omnia/core";
import { BufferRepository, LedgerRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import {
ProviderManager,
GeminiEmbeddingProvider,
MockEmbeddingProvider,
} from "@omnia/llm";
import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type { SimSnapshot } from "../simulation-types";
import type { SimSession, EntityInfo } from "./types";
import { resolveProviders } from "./provider-resolver";
import {
DATA_DIR,
loadSessionState,
saveSession,
listSavedSessions,
deleteSessionFile,
} from "./session-store";
import {
preparePlayerTurn,
processNpcTurn,
executePlayerAction,
} from "./turn-executor";
import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
export class SimulationManager {
private sessions = new Map<string, SimSession>();
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
): Promise<SimSnapshot> {
// Resolve or validate the active generative provider upfront so we can
// return a clean error snapshot before touching the filesystem.
let activeInstance: ModelProviderInstance | null = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
: ProviderManager.getActive("generative");
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!activeInstance) {
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error:
"No active LLM Provider Instance found. Please configure a key in Settings first.",
};
}
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
const id = `sim-${Date.now()}`;
fs.mkdirSync(DATA_DIR, { recursive: true });
const dbPath = path.join(DATA_DIR, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const worldInstanceId = id;
await loader.initializeWorld(scenarioJson, worldInstanceId);
const worldState = coreRepo.loadWorldState(worldInstanceId);
if (!worldState) {
db.close();
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error: "Failed to load world state after initialization.",
};
}
// Build entity list
const rawEntities = Array.from(worldState.entities.values());
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
id: e.id,
name: (e.attributes.get("name")?.getValue() as string) || e.id,
isPlayer: false,
isAgent: e.isAgent,
}));
// Resolve player entity (exact match → name match → fuzzy)
let playerEntityId: string | undefined;
if (playEntityName) {
let matched = worldState.getEntity(playEntityName);
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
string | undefined;
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
matched = ent;
break;
}
}
}
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
string | undefined;
if (
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
) {
matched = ent;
break;
}
}
}
if (matched) {
playerEntityId = matched.id;
const info = entityInfos.find((e) => e.id === matched!.id);
if (info) info.isPlayer = true;
}
}
const mappings = ProviderManager.getMappings();
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { fallbackInstance: activeInstance });
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId,
scenarioName: scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
entities: entityInfos,
playerEntityId,
entityIndex: 0,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
}
async load(id: string): Promise<SimSnapshot | null> {
const active = this.sessions.get(id);
if (active) return this.snapshot(active);
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (!fs.existsSync(dbPath)) return null;
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
if (!state) {
db.close();
return null;
}
const mappings = state.providerMappings || {};
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { required: true });
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
turn: state.turn,
maxTurns: state.maxTurns,
entities: state.entities || [],
playerEntityId: state.playerEntityId,
entityIndex: state.entityIndex,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: state.log || [],
status: state.status,
error: state.error,
waitingEntity: state.waitingEntity,
aliasDoneForTurn: state.aliasDoneForTurn || false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
} catch (err) {
console.error(`Failed to load session ${id}:`, err);
return null;
}
}
close(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
}
deleteSession(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
deleteSessionFile(id);
}
listSavedSessions(): SimSnapshot[] {
return listSavedSessions(this.sessions, (s) => this.snapshot(s));
}
getSnapshot(id: string): SimSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
// ---------------------------------------------------------------------------
// Simulation stepping
// ---------------------------------------------------------------------------
async step(id: string): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "running") return this.snapshot(session);
try {
if (session.turn > session.maxTurns) {
session.status = "done";
saveSession(session);
return this.snapshot(session);
}
// Start of turn: alias + handoff resolution before any entity acts
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await runAliasResolution(session);
await runHandoffResolution(session);
session.aliasDoneForTurn = true;
saveSession(session);
return this.snapshot(session);
}
// End of turn: advance to next turn
if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
saveSession(session);
return this.snapshot(session);
}
const info = session.entities[session.entityIndex];
if (!info.isAgent) {
session.entityIndex++;
saveSession(session);
return this.snapshot(session);
}
if (info.isPlayer) {
await preparePlayerTurn(session, info);
saveSession(session);
return this.snapshot(session);
}
await processNpcTurn(session, info);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
async submitPlayerAction(
id: string,
prose: string,
): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "waiting_player") return this.snapshot(session);
if (!session.waitingEntity) return this.snapshot(session);
const ctx = session.waitingEntity;
session.waitingEntity = undefined;
session.status = "running";
try {
await executePlayerAction(session, ctx, prose);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
if (!fs.existsSync(DATA_DIR)) return;
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
const list = ProviderManager.list();
let inst = newProviderInstanceId
? (list.find((p) => p.id === newProviderInstanceId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || "";
const providerName = inst ? inst.providerName : "google-genai";
const modelName = inst ? inst.modelName : undefined;
const embeddingProvider: IEmbeddingProvider =
providerName === "google-genai"
? new GeminiEmbeddingProvider(key, modelName)
: new MockEmbeddingProvider(modelName);
for (const file of files) {
const dbPath = path.join(DATA_DIR, file);
const fileId = file.replace(".db", "");
const activeSession = this.sessions.get(fileId);
const db = activeSession ? activeSession.db : new Database(dbPath);
try {
const rows = db
.prepare(`SELECT id, content FROM ledger_entries`)
.all() as { id: string; content: string }[];
for (const row of rows) {
const vector = await embeddingProvider.embed(row.content);
const buffer = Buffer.from(new Float32Array(vector).buffer);
db.prepare(
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
).run(buffer, row.id);
}
} catch (err) {
console.error(`Failed to regenerate embeddings for ${file}:`, err);
} finally {
if (!activeSession) db.close();
}
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private snapshot(session: SimSession): SimSnapshot {
return {
id: session.worldInstanceId,
status: session.status,
turn: session.turn,
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities: session.entities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
};
}
}

View File

@@ -0,0 +1,277 @@
import {
ActorAgent,
ActorPromptBuilder,
buildBufferEntryForIntent,
} from "@omnia/actor";
import type { IActorProseGenerator } from "@omnia/actor";
import type { SimSession } from "./types";
import type {
EntityInfo,
IntentInfo,
LogEntry,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Prose generator that returns a fixed player-supplied string verbatim. */
class FixedProseGenerator implements IActorProseGenerator {
constructor(private prose: string) {}
async generate(
entityId: string,
systemPrompt: string,
userContext: string,
): Promise<string> {
void entityId;
void systemPrompt;
void userContext;
return this.prose;
}
}
/**
* Processes every intent produced by an actor turn:
* - Validates via Architect
* - Appends to actor's own buffer
* - Fan-outs to co-located observers for dialogue/action intents
*
* Extracted to eliminate verbatim duplication between NPC and player paths.
*/
async function processIntents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
intents: any[],
actorEntityId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
worldState: any,
session: SimSession,
): Promise<IntentInfo[]> {
const intentInfos: IntentInfo[] = [];
for (const intent of intents) {
const outcome = await session.architect.processIntent(worldState, intent);
const ts = worldState.clock.get().toISOString();
intentInfos.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
const actorEntry = buildBufferEntryForIntent(intent, ts, entity.locationId);
if (intent.type === "action") {
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
}
session.bufferRepo.save(actorEntry);
// Fan-out observable events to co-located entities
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== actorEntityId &&
other.locationId === entity.locationId
) {
const observerEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
}
}
}
}
return intentInfos;
}
// ---------------------------------------------------------------------------
// Exported turn functions
// ---------------------------------------------------------------------------
/**
* Builds the prompt for the player entity and sets the session to
* `waiting_player` so the next client call can supply the prose.
*/
export async function preparePlayerTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const promptBuilder = new ActorPromptBuilder(
session.bufferRepo,
session.ledgerRepo,
20,
);
const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt,
userContext,
};
session.status = "waiting_player";
}
/**
* Runs an autonomous NPC turn: generates prose via ActorAgent, validates
* and persists all intents, and appends a LogEntry to the session.
*/
export async function processNpcTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
);
const result = await actor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: info.id,
entityName: info.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
};
if (
session.actorProvider.lastCalls &&
session.actorProvider.lastCalls.length > 0
) {
const actorCall =
session.actorProvider.lastCalls[
session.actorProvider.lastCalls.length - 1
];
entry.rawPrompt = {
systemPrompt: actorCall.systemPrompt,
userContext: actorCall.userContext,
};
entry.usage = actorCall.usage;
}
if (
session.decoderProvider.lastCalls &&
session.decoderProvider.lastCalls.length > 0
) {
const decoderCall =
session.decoderProvider.lastCalls[
session.decoderProvider.lastCalls.length - 1
];
entry.decoderPrompt = {
systemPrompt: decoderCall.systemPrompt,
userContext: decoderCall.userContext,
};
entry.decoderUsage = decoderCall.usage;
}
entry.intents = await processIntents(
result.intents.intents,
info.id,
entity,
worldState,
session,
);
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
/**
* Executes the player's turn using the prose they supplied.
* Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
* returns the player's text directly.
*/
export async function executePlayerAction(
session: SimSession,
ctx: WaitingContext,
prose: string,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(ctx.entityId);
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
const playerActor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
new FixedProseGenerator(prose),
);
const result = await playerActor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: ctx.entityId,
entityName: ctx.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: ctx.systemPrompt,
userContext: ctx.userContext,
},
};
if (
session.decoderProvider.lastCalls &&
session.decoderProvider.lastCalls.length > 0
) {
const call =
session.decoderProvider.lastCalls[
session.decoderProvider.lastCalls.length - 1
];
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
};
entry.decoderUsage = call.usage;
}
entry.intents = await processIntents(
result.intents.intents,
ctx.entityId,
entity,
worldState,
session,
);
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}

View File

@@ -0,0 +1,68 @@
import type Database from "better-sqlite3";
import type { SQLiteRepository } from "@omnia/core";
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
export type {
EntityInfo,
IntentInfo,
LogEntry,
SimSnapshot,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Persisted state (written to sqlite gui_meta table as JSON)
// ---------------------------------------------------------------------------
export interface SavedState {
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
log: LogEntry[];
providerMappings: Record<string, string>;
}
// ---------------------------------------------------------------------------
// In-memory session (held in SimulationManager.sessions Map)
// ---------------------------------------------------------------------------
export interface SimSession {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
providerMappings: Record<string, string>;
}