MAJOR: Added a GUI app for simulations

This commit is contained in:
2026-07-09 18:14:26 +05:30
parent 0c59756c08
commit 4ef52f926e
22 changed files with 2996 additions and 13 deletions

6
apps/gui/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

30
apps/gui/next.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@omnia/core",
"@omnia/llm",
"@omnia/intent",
"@omnia/architect",
"@omnia/actor",
"@omnia/memory",
"@omnia/spatial",
"@omnia/scenario",
],
serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: ["192.168.0.18", "localhost", "127.0.0.1"],
experimental: {
serverActions: {
allowedOrigins: [
"192.168.0.18:3000",
"192.168.0.18:3001",
"192.168.0.18:3002",
"localhost:3000",
"localhost:3001",
"localhost:3002",
],
},
},
};
export default nextConfig;

32
apps/gui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@omnia/gui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"@omnia/intent": "workspace:*",
"@omnia/architect": "workspace:*",
"@omnia/actor": "workspace:*",
"@omnia/memory": "workspace:*",
"@omnia/spatial": "workspace:*",
"@omnia/scenario": "workspace:*",
"dotenv": "^17.4.2",
"next": "^16.2.10",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^26.1.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,221 @@
"use client";
import { useEffect, useState } from "react";
import { getConfigStatus } from "@/app/play/actions";
interface ConfigStatus {
apiKeySet: boolean;
apiKeyPreview: string;
model: string;
availableScenarios: { path: string; name: string }[];
}
export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
if (!cancelled) {
setConfig(result);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, []);
return (
<div className="config-page">
<h1>Configuration</h1>
{loading && <p>Loading configuration...</p>}
{error && <div className="error-banner">{error}</div>}
{config && !loading && (
<>
<section className="config-section">
<h2>LLM Provider</h2>
<div className="config-row">
<span className="config-label">Provider</span>
<span className="config-value">Google Gemini</span>
</div>
<div className="config-row">
<span className="config-label">Model</span>
<span className="config-value">
<code>{config.model}</code>
</span>
</div>
<div className="config-row">
<span className="config-label">API Key</span>
<span
className={
config.apiKeySet
? "config-value status-ok"
: "config-value status-error"
}
>
{config.apiKeySet
? `✓ Set (${config.apiKeyPreview})`
: "✗ NOT SET"}
</span>
</div>
{!config.apiKeySet && (
<div className="config-hint">
Add <code>GOOGLE_API_KEY=your_key</code> to the{" "}
<code>.env</code> file in the project root, then restart the
server.
</div>
)}
</section>
<section className="config-section">
<h2>Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="config-hint">
No scenarios found in{" "}
<code>content/demo/scenarios/</code>.
</p>
) : (
<table className="scenario-table">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{config.availableScenarios.map((s) => (
<tr key={s.path}>
<td>{s.name}</td>
<td>
<code>{s.path}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="config-section">
<h2>Engine Packages</h2>
<p className="config-hint">
All <code>@omnia/*</code> workspace packages are consumed via{" "}
<code>transpilePackages</code> in <code>next.config.ts</code>.
The native <code>better-sqlite3</code> module is externalized via{" "}
<code>serverExternalPackages</code>.
</p>
</section>
</>
)}
<style>{`
.config-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.config-page h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.config-section {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.config-section h2 {
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
.config-row {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
border-bottom: 1px solid #f3f4f6;
}
.config-label {
color: #555;
font-size: 0.875rem;
}
.config-value {
font-size: 0.875rem;
}
.status-ok {
color: #16a34a;
}
.status-error {
color: #dc2626;
font-weight: 500;
}
.config-hint {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
font-size: 0.8125rem;
color: #92400e;
}
.config-hint code {
background: rgba(0,0,0,0.06);
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-size: 0.75rem;
}
.error-banner {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fca5a5;
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.scenario-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.scenario-table th {
text-align: left;
padding: 0.5rem;
border-bottom: 2px solid #e5e7eb;
color: #555;
font-weight: 500;
}
.scenario-table td {
padding: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.scenario-table code {
font-size: 0.8125rem;
color: #2563eb;
}
code {
font-family: monospace;
font-size: 0.875rem;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,18 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
color: #111;
background: #fafafa;
}
body {
min-height: 100dvh;
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { NavBar } from "@/components/nav/NavBar";
import "./globals.css";
export const metadata = {
title: "Omnia GUI",
description: "Omnia Narrative Simulation Engine — Web Interface",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<NavBar />
{children}
</body>
</html>
);
}

63
apps/gui/src/app/page.tsx Normal file
View File

@@ -0,0 +1,63 @@
import Link from "next/link";
export default function Home() {
return (
<main className="home">
<h1>Omnia GUI</h1>
<p className="subtitle">
Configuration and gameplay interface for the Omnia simulation engine.
</p>
<div className="home-links">
<Link href="/play" className="home-card">
<h2>Play</h2>
<p>Start a simulation and interact with NPCs</p>
</Link>
<Link href="/config" className="home-card">
<h2>Config</h2>
<p>Check environment, API keys, and available scenarios</p>
</Link>
</div>
<style>{`
.home {
max-width: 800px;
margin: 0 auto;
padding: 3rem 1rem;
}
.home h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: #555;
margin-bottom: 2rem;
}
.home-links {
display: flex;
gap: 1rem;
}
.home-card {
flex: 1;
display: block;
padding: 1.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.home-card:hover {
border-color: #2563eb;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
}
.home-card h2 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.home-card p {
font-size: 0.875rem;
color: #555;
}
`}</style>
</main>
);
}

View File

@@ -0,0 +1,231 @@
"use server";
import path from "path";
import fs from "fs";
import { simulationManager } from "@/lib/simulation";
import type { SimSnapshot } from "@/lib/simulation";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
const candidates = [
path.resolve(cwd, relative),
path.resolve(cwd, "content/demo/scenarios", relative),
path.resolve(cwd, "../../", relative),
path.resolve(cwd, "../../content/demo/scenarios", relative),
];
for (const c of candidates) {
try {
if (fs.statSync(c).isFile()) return c;
} catch {
/* not found */
}
}
return path.resolve(cwd, relative);
}
type ActionResult =
| { ok: true; snapshot: SimSnapshot }
| { ok: false; error: string };
export async function startSimulation(input: {
scenario?: string;
playEntity?: string;
}): Promise<ActionResult> {
try {
const scenarioFile =
input.scenario || "content/demo/scenarios/talking-room.json";
const resolved = resolveScenarioPath(scenarioFile);
if (!fs.existsSync(resolved)) {
return { ok: false, error: `Scenario file not found: ${scenarioFile}` };
}
const snapshot = await simulationManager.create(
resolved,
input.playEntity || undefined,
);
if (snapshot.status === "error") {
return { ok: false, error: snapshot.error || "Unknown error" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function stepSimulation(input: {
simId: string;
}): Promise<ActionResult> {
try {
if (!input.simId) {
return { ok: false, error: "Missing simId" };
}
const snapshot = await simulationManager.step(input.simId);
if (!snapshot) {
return { ok: false, error: "Simulation session not found" };
}
if (snapshot.status === "error") {
return { ok: false, error: snapshot.error || "Unknown error" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function submitPlayerAction(input: {
simId: string;
prose: string;
}): Promise<ActionResult> {
try {
if (!input.simId || !input.prose.trim()) {
return { ok: false, error: "Missing simId or prose" };
}
const snapshot = await simulationManager.submitPlayerAction(
input.simId,
input.prose.trim(),
);
if (!snapshot) {
return { ok: false, error: "Simulation session not found" };
}
if (snapshot.status === "error") {
return { ok: false, error: snapshot.error || "Unknown error" };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function getConfigStatus(): Promise<{
apiKeySet: boolean;
apiKeyPreview: string;
model: string;
availableScenarios: { path: string; name: string }[];
}> {
const apiKey = process.env.GOOGLE_API_KEY;
const scenarios: { path: string; name: string }[] = [];
const cwd = process.cwd();
const candidates = [
path.resolve(cwd, "content/demo/scenarios"),
path.resolve(cwd, "../../content/demo/scenarios"),
];
let scenariosDir = "";
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isDirectory()) {
scenariosDir = c;
break;
}
}
if (scenariosDir) {
for (const file of fs.readdirSync(scenariosDir)) {
if (file.endsWith(".json")) {
try {
const fullPath = path.join(scenariosDir, file);
const content = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
scenarios.push({
path: `content/demo/scenarios/${file}`,
name: content.name || file,
});
} catch {
/* skip invalid */
}
}
}
}
return {
apiKeySet: !!apiKey,
apiKeyPreview: apiKey ? apiKey.substring(0, 10) + "..." : "NOT SET",
model: "gemini-2.5-flash",
availableScenarios: scenarios,
};
}
export async function listSavedSimulations(): Promise<
| { ok: true; sessions: SimSnapshot[] }
| { ok: false; error: string }
> {
try {
const sessions = simulationManager.listSavedSessions();
return { ok: true, sessions };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function resumeSimulation(simId: string): Promise<ActionResult> {
try {
const snapshot = await simulationManager.load(simId);
if (!snapshot) {
return { ok: false, error: `Failed to load simulation: ${simId}` };
}
return { ok: true, snapshot };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function getScenarioEntities(scenarioPath: string): Promise<
| { ok: true; entities: { id: string; name: string }[] }
| { ok: false; error: string }
> {
try {
const resolved = resolveScenarioPath(scenarioPath);
if (!fs.existsSync(resolved)) {
return { ok: false, error: `Scenario file not found: ${scenarioPath}` };
}
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
const entities = (content.entities || []).map((e: { id: string; name?: string }) => ({
id: e.id,
name: e.name || e.id,
}));
return { ok: true, entities };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function deleteSimulation(simId: string): Promise<
{ ok: true } | { ok: false; error: string }
> {
try {
simulationManager.deleteSession(simId);
return { ok: true };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -0,0 +1,5 @@
import { PlayView } from "@/components/play/PlayView";
export default function PlayPage() {
return <PlayView />;
}

View File

@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Home" },
{ href: "/play", label: "Play" },
{ href: "/config", label: "Config" },
];
export function NavBar() {
const pathname = usePathname();
return (
<nav className="navbar">
<Link href="/" className="nav-brand">
Omnia
</Link>
<div className="nav-links">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={pathname === link.href ? "nav-link active" : "nav-link"}
>
{link.label}
</Link>
))}
</div>
<style>{`
.navbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
.nav-brand {
font-weight: 700;
font-size: 1rem;
color: #111;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
color: #555;
text-decoration: none;
}
.nav-link:hover {
background: #f3f4f6;
color: #111;
}
.nav-link.active {
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
`}</style>
</nav>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
export interface IntentInfo {
type: string;
description: string;
targetIds: string[];
isValid?: boolean;
reason?: string;
minutesToAdvance?: number;
}
export interface LogEntry {
turn: number;
entityId: string;
entityName: string;
narrativeProse: string;
intents: IntentInfo[];
timestamp: string;
rawPrompt?: {
systemPrompt: string;
userContext: string;
};
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
decoderPrompt?: {
systemPrompt: string;
userContext: string;
};
decoderUsage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
}
export interface EntityInfo {
id: string;
name: string;
isPlayer: boolean;
}
export interface WaitingContext {
entityId: string;
name: string;
systemPrompt: string;
userContext: string;
}
export interface SimSnapshot {
id: string;
status: "running" | "waiting_player" | "done" | "error";
turn: number;
maxTurns: number;
scenarioName: string;
scenarioDescription: string;
entities: EntityInfo[];
log: LogEntry[];
entityIndex: number;
waitingEntity?: WaitingContext;
error?: string;
}

View File

@@ -0,0 +1,751 @@
import dotenv from "dotenv";
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { SQLiteRepository } from "@omnia/core";
// 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;
}
}
import { BufferRepository } from "@omnia/memory";
import { Architect, AliasDeltaGenerator } from "@omnia/architect";
import {
ActorAgent,
ActorPromptBuilder,
IActorProseGenerator,
buildBufferEntryForIntent,
} from "@omnia/actor";
import { GeminiProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type {
IntentInfo,
LogEntry,
EntityInfo,
WaitingContext,
SimSnapshot,
} from "./simulation-types.js";
export type { SimSnapshot, EntityInfo, LogEntry, IntentInfo, WaitingContext };
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;
}
}
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[];
}
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;
}
}
interface SimSession {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
llmProvider: GeminiProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
}
class SimulationManager {
private sessions = new Map<string, SimSession>();
async create(
scenarioPath: string,
playEntityName?: string,
): Promise<SimSnapshot> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error: "GOOGLE_API_KEY is not set. Add it to your .env file.",
};
}
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
const id = `sim-${Date.now()}`;
const dbDir = path.resolve(process.cwd(), "data");
fs.mkdirSync(dbDir, { recursive: true });
const dbPath = path.join(dbDir, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(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.",
};
}
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,
}));
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 llmProvider = new GeminiProvider(apiKey);
const architect = new Architect(llmProvider, coreRepo);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
worldInstanceId,
scenarioName: scenarioJson.name,
scenarioDescription: scenarioJson.description,
turn: 1,
maxTurns: 20,
entities: entityInfos,
playerEntityId,
entityIndex: 0,
llmProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
};
this.sessions.set(id, session);
return this.snapshot(session);
}
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";
this.save(session);
return this.snapshot(session);
}
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await this.runAliasResolution(session);
session.aliasDoneForTurn = true;
this.save(session);
return this.snapshot(session);
}
if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
this.save(session);
return this.snapshot(session);
}
const info = session.entities[session.entityIndex];
if (info.isPlayer) {
await this.preparePlayerTurn(session, info);
this.save(session);
return this.snapshot(session);
}
await this.processNpcTurn(session, info);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
this.save(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 {
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(
session.llmProvider,
session.bufferRepo,
20,
new FixedProseGenerator(prose),
);
const startCallIdx = session.llmProvider.lastCalls.length;
const result = await playerActor.act(worldState, entity);
const endCallIdx = session.llmProvider.lastCalls.length;
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 (endCallIdx > startCallIdx) {
const call = session.llmProvider.lastCalls[startCallIdx];
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
};
entry.decoderUsage = call.usage;
}
for (const intent of result.intents.intents) {
const outcome = await session.architect.processIntent(
worldState,
intent,
);
const ts = worldState.clock.get().toISOString();
entry.intents.push({
type: intent.type,
description: intent.description,
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);
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== ctx.entityId &&
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,
});
}
}
}
}
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
this.save(session);
return this.snapshot(session);
}
private async 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, 20);
const { systemPrompt, userContext } = promptBuilder.build(
worldState,
entity,
);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt,
userContext,
};
session.status = "waiting_player";
}
private async 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(session.llmProvider, session.bufferRepo, 20);
const startCallIdx = session.llmProvider.lastCalls.length;
const result = await actor.act(worldState, entity);
const endCallIdx = session.llmProvider.lastCalls.length;
const entry: LogEntry = {
turn: session.turn,
entityId: info.id,
entityName: info.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
};
if (endCallIdx - startCallIdx >= 2) {
const actorCall = session.llmProvider.lastCalls[startCallIdx];
const decoderCall = session.llmProvider.lastCalls[startCallIdx + 1];
entry.rawPrompt = {
systemPrompt: actorCall.systemPrompt,
userContext: actorCall.userContext,
};
entry.usage = actorCall.usage;
entry.decoderPrompt = {
systemPrompt: decoderCall.systemPrompt,
userContext: decoderCall.userContext,
};
entry.decoderUsage = decoderCall.usage;
} else if (endCallIdx - startCallIdx === 1) {
const call = session.llmProvider.lastCalls[startCallIdx];
entry.rawPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
};
entry.usage = call.usage;
}
for (const intent of result.intents.intents) {
const outcome = await session.architect.processIntent(
worldState,
intent,
);
const ts = worldState.clock.get().toISOString();
entry.intents.push({
type: intent.type,
description: intent.description,
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);
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== info.id &&
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 });
}
}
}
}
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
private async 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.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);
}
}
}
}
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);
}
const dbDir = path.resolve(process.cwd(), "data");
const dbPath = path.join(dbDir, `${id}.db`);
if (fs.existsSync(dbPath)) {
try {
fs.unlinkSync(dbPath);
} catch (err) {
console.error(`Failed to delete session file ${dbPath}:`, err);
}
}
}
async load(id: string): Promise<SimSnapshot | null> {
const active = this.sessions.get(id);
if (active) {
return this.snapshot(active);
}
const dbDir = path.resolve(process.cwd(), "data");
const dbPath = path.join(dbDir, `${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 apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
db.close();
throw new Error("GOOGLE_API_KEY is not set.");
}
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const llmProvider = new GeminiProvider(apiKey);
const architect = new Architect(llmProvider, coreRepo);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
turn: state.turn,
maxTurns: state.maxTurns,
entities: state.entities || [],
playerEntityId: state.playerEntityId,
entityIndex: state.entityIndex,
llmProvider,
architect,
aliasGenerator,
log: state.log || [],
status: state.status,
error: state.error,
waitingEntity: state.waitingEntity,
aliasDoneForTurn: state.aliasDoneForTurn || false,
};
this.sessions.set(id, session);
return this.snapshot(session);
} catch (err) {
console.error(`Failed to load session ${id}:`, err);
return null;
}
}
listSavedSessions(): SimSnapshot[] {
const dbDir = path.resolve(process.cwd(), "data");
if (!fs.existsSync(dbDir)) return [];
const snapshots: SimSnapshot[] = [];
const files = fs.readdirSync(dbDir).filter(f => f.startsWith("sim-") && f.endsWith(".db"));
for (const file of files) {
const id = file.replace(".db", "");
const dbPath = path.join(dbDir, file);
const active = this.sessions.get(id);
if (active) {
snapshots.push(this.snapshot(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 */
}
}
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;
});
}
private save(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,
};
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));
}
getSnapshot(id: string): SimSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
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,
};
}
}
export const simulationManager = new SimulationManager();

41
apps/gui/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}