From d96fc045426fe0677fce450503a3be914d1888d0 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 9 Jul 2026 01:59:27 +0530 Subject: [PATCH] feat: Implement ScenarioLoader pipeline --- content/scenario-core/package.json | 15 ++ content/scenario-core/src/index.ts | 2 + content/scenario-core/src/loader.ts | 125 +++++++++++++++++ content/scenario-core/src/schema.ts | 65 +++++++++ content/scenario-core/tests/scenario.test.ts | 136 +++++++++++++++++++ content/scenario-core/tsconfig.json | 13 ++ packages/core/src/repository.ts | 79 ++++++++++- pnpm-lock.yaml | 15 ++ pnpm-workspace.yaml | 1 + tsconfig.json | 1 + vitest.config.ts | 1 + 11 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 content/scenario-core/package.json create mode 100644 content/scenario-core/src/index.ts create mode 100644 content/scenario-core/src/loader.ts create mode 100644 content/scenario-core/src/schema.ts create mode 100644 content/scenario-core/tests/scenario.test.ts create mode 100644 content/scenario-core/tsconfig.json diff --git a/content/scenario-core/package.json b/content/scenario-core/package.json new file mode 100644 index 0000000..c6a72ea --- /dev/null +++ b/content/scenario-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@omnia/scenario-core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "dependencies": { + "@omnia/core": "workspace:*", + "@omnia/spatial": "workspace:*", + "@omnia/memory": "workspace:*", + "zod": "^4.4.3" + } +} diff --git a/content/scenario-core/src/index.ts b/content/scenario-core/src/index.ts new file mode 100644 index 0000000..0bcd8f7 --- /dev/null +++ b/content/scenario-core/src/index.ts @@ -0,0 +1,2 @@ +export * from "./schema.js"; +export * from "./loader.js"; diff --git a/content/scenario-core/src/loader.ts b/content/scenario-core/src/loader.ts new file mode 100644 index 0000000..c56750b --- /dev/null +++ b/content/scenario-core/src/loader.ts @@ -0,0 +1,125 @@ +import { WorldState, Entity, SQLiteRepository, AttributeVisibility } from "@omnia/core"; +import { Location } from "@omnia/spatial"; +import { BufferRepository } from "@omnia/memory"; +import { ScenarioSchema, Scenario } from "./schema.js"; + +export class ScenarioLoader { + constructor( + private coreRepo: SQLiteRepository, + private bufferRepo: BufferRepository, + ) {} + + /** + * Instantiates a live world from a static JSON scenario template. + * Creates a new world instance in the database using a generated unique World ID. + * + * @param scenarioJson The raw JSON scenario template contents. + * @param targetWorldId The unique ID for the running instance to create (e.g. UUID). + * Allows launching multiple active runs from one scenario. + */ + async initializeWorld(scenarioJson: unknown, targetWorldId: string): Promise { + // 1. Validate scenario template schema + const scenario: Scenario = ScenarioSchema.parse(scenarioJson); + + // 2. Instantiate running WorldState using the target instance ID + const world = new WorldState(targetWorldId, new Date(scenario.startTime)); + + // Seed world-level attributes + world.addAttribute("name", scenario.name, AttributeVisibility.PUBLIC); + world.addAttribute("description", scenario.description, AttributeVisibility.PUBLIC); + + if (scenario.world?.attributes) { + for (const attr of scenario.world.attributes) { + const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE; + world.addAttribute( + attr.name, + attr.value, + vis, + attr.allowedEntities ? new Set(attr.allowedEntities) : null, + ); + } + } + + // 3. Save World State core row + this.coreRepo.saveWorldState(world); + + // 4. Instantiate and Persist Locations + if (scenario.locations) { + for (const locData of scenario.locations) { + const location = new Location(locData.id, locData.parentId ?? null); + + if (locData.attributes) { + for (const attr of locData.attributes) { + const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE; + location.addAttribute( + attr.name, + attr.value, + vis, + attr.allowedEntities ? new Set(attr.allowedEntities) : null, + ); + } + } + + if (locData.connections) { + location.connections = locData.connections.map((c) => ({ + targetId: c.targetId, + portalName: c.portalName, + portalStateDescriptor: c.portalStateDescriptor, + visionProp: c.visionProp, + soundProp: c.soundProp, + bidirectional: c.bidirectional, + })); + } + + // Save location record linked to the world instance + this.coreRepo.saveLocation(location, world.id); + } + } + + // 5. Instantiate and Persist Entities (with Aliases & Memory Buffers) + if (scenario.entities) { + for (const entData of scenario.entities) { + const entity = new Entity(entData.id, entData.locationId ?? null); + + // Load attributes + if (entData.attributes) { + for (const attr of entData.attributes) { + const vis = attr.visibility === "PUBLIC" ? AttributeVisibility.PUBLIC : AttributeVisibility.PRIVATE; + entity.addAttribute( + attr.name, + attr.value, + vis, + attr.allowedEntities ? new Set(attr.allowedEntities) : null, + ); + } + } + + // Load aliases + if (entData.aliases) { + for (const [targetId, alias] of Object.entries(entData.aliases)) { + entity.aliases.set(targetId, alias); + } + } + + // Save entity record linked to the world instance + this.coreRepo.saveEntity(entity, world.id); + + // Seed initial memory buffer history + if (entData.initialMemories) { + for (const mem of entData.initialMemories) { + this.bufferRepo.save({ + id: mem.id, + ownerId: entData.id, + timestamp: mem.timestamp, + locationId: mem.locationId, + intent: mem.intent, + outcome: mem.outcome, + }); + } + } + } + } + + return world.id; + } +} diff --git a/content/scenario-core/src/schema.ts b/content/scenario-core/src/schema.ts new file mode 100644 index 0000000..c2ed31e --- /dev/null +++ b/content/scenario-core/src/schema.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +export const AttributeVisibilitySchema = z.enum(["PUBLIC", "PRIVATE"]); + +export const ScenarioAttributeSchema = z.object({ + name: z.string(), + value: z.string(), + visibility: AttributeVisibilitySchema, + allowedEntities: z.array(z.string()).optional(), +}); + +export const ScenarioPortalConnectionSchema = z.object({ + targetId: z.string(), + portalName: z.string().optional(), + portalStateDescriptor: z.string().optional(), + visionProp: z.number().min(0).max(10), + soundProp: z.number().min(0).max(10), + bidirectional: z.boolean(), +}); + +export const ScenarioLocationSchema = z.object({ + id: z.string(), + parentId: z.string().nullable().optional(), + attributes: z.array(ScenarioAttributeSchema).optional(), + connections: z.array(ScenarioPortalConnectionSchema).optional(), +}); + +export const ScenarioMemoryEntrySchema = z.object({ + id: z.string(), + timestamp: z.string(), // ISO string + locationId: z.string().nullable(), + intent: z.object({ + type: z.enum(["dialogue", "action", "monologue"]), + originalText: z.string(), + description: z.string(), + actorId: z.string(), + targetIds: z.array(z.string()), + }), + outcome: z.object({ + isValid: z.boolean(), + reason: z.string(), + }).optional(), +}); + +export const ScenarioEntitySchema = z.object({ + id: z.string(), + locationId: z.string().nullable().optional(), + attributes: z.array(ScenarioAttributeSchema).optional(), + aliases: z.record(z.string(), z.string()).optional(), // targetId -> subjective descriptor + initialMemories: z.array(ScenarioMemoryEntrySchema).optional(), +}); + +export const ScenarioSchema = z.object({ + id: z.string(), // Template identifier + name: z.string(), + description: z.string(), + startTime: z.string(), // ISO string + world: z.object({ + attributes: z.array(ScenarioAttributeSchema).optional(), + }).optional(), + locations: z.array(ScenarioLocationSchema).optional(), + entities: z.array(ScenarioEntitySchema).optional(), +}); + +export type Scenario = z.infer; diff --git a/content/scenario-core/tests/scenario.test.ts b/content/scenario-core/tests/scenario.test.ts new file mode 100644 index 0000000..058930d --- /dev/null +++ b/content/scenario-core/tests/scenario.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect } from "vitest"; +import Database from "better-sqlite3"; +import { SQLiteRepository } from "@omnia/core"; +import { Location } from "@omnia/spatial"; +import { BufferRepository } from "@omnia/memory"; +import { ScenarioLoader, ScenarioSchema } from "../src/index.js"; + +describe("Scenario Validation & Schema Tests (Tier 1)", () => { + const validScenario = { + id: "sc-haunted-house", + name: "Haunted House Mystery", + description: "A spooky old manor.", + startTime: "2026-07-09T08:00:00.000Z", + world: { + attributes: [ + { name: "weather", value: "stormy", visibility: "PUBLIC" }, + ], + }, + locations: [ + { + id: "lobby", + parentId: null, + attributes: [ + { name: "light", value: "dim", visibility: "PUBLIC" }, + ], + connections: [ + { + targetId: "kitchen", + portalName: "swinging door", + visionProp: 2, + soundProp: 6, + bidirectional: true, + }, + ], + }, + { + id: "kitchen", + parentId: "lobby", + }, + ], + entities: [ + { + id: "investigator", + locationId: "lobby", + attributes: [ + { name: "sanity", value: "100", visibility: "PRIVATE", allowedEntities: ["investigator"] }, + ], + aliases: { + ghost: "shadowy specter", + }, + initialMemories: [ + { + id: "mem-seed-1", + timestamp: "2026-07-09T07:55:00.000Z", + locationId: "lobby", + intent: { + type: "action", + originalText: "I entered the foyer.", + description: "entered the house", + actorId: "investigator", + targetIds: [], + }, + }, + ], + }, + ], + }; + + test("successfully validates a valid scenario JSON template", () => { + const result = ScenarioSchema.safeParse(validScenario); + expect(result.success).toBe(true); + }); + + test("fails validation on invalid scenario structure", () => { + const invalidScenario = { + id: "sc-bad", + name: "Missing critical fields", + // description and startTime are missing + }; + const result = ScenarioSchema.safeParse(invalidScenario); + expect(result.success).toBe(false); + }); + + test("loads scenario into SQLite database and reconstitutes all objects correctly", async () => { + const db = new Database(":memory:"); + const coreRepo = new SQLiteRepository(db); + const bufferRepo = new BufferRepository(db); + const loader = new ScenarioLoader(coreRepo, bufferRepo); + + const targetWorldId = "active-world-run-1"; + const worldId = await loader.initializeWorld(validScenario, targetWorldId); + expect(worldId).toBe(targetWorldId); + + // 1. Verify WorldState loaded + const world = coreRepo.loadWorldState(targetWorldId); + expect(world).not.toBeNull(); + expect(world!.id).toBe(targetWorldId); + expect(world!.clock.get().toISOString()).toBe("2026-07-09T08:00:00.000Z"); + expect(world!.attributes.get("name")?.getValue()).toBe("Haunted House Mystery"); + expect(world!.attributes.get("weather")?.getValue()).toBe("stormy"); + + // 2. Verify Locations loaded with connections & hierarchy + const locations = coreRepo.listLocations(targetWorldId, (id, parentId) => new Location(id, parentId)); + expect(locations).toHaveLength(2); + + const lobby = locations.find((l) => l.id === "lobby"); + expect(lobby).toBeDefined(); + expect(lobby!.parentId).toBeNull(); + expect(lobby!.attributes.get("light")?.getValue()).toBe("dim"); + expect(lobby!.connections).toHaveLength(1); + expect(lobby!.connections[0].targetId).toBe("kitchen"); + expect(lobby!.connections[0].portalName).toBe("swinging door"); + expect(lobby!.connections[0].visionProp).toBe(2); + + const kitchen = locations.find((l) => l.id === "kitchen"); + expect(kitchen).toBeDefined(); + expect(kitchen!.parentId).toBe("lobby"); + + // 3. Verify Entities loaded with subjective alias map + const loadedInvestigator = world!.getEntity("investigator"); + expect(loadedInvestigator).toBeDefined(); + expect(loadedInvestigator!.locationId).toBe("lobby"); + expect(loadedInvestigator!.attributes.get("sanity")?.getValue()).toBe("100"); + expect(loadedInvestigator!.aliases.get("ghost")).toBe("shadowy specter"); + + // 4. Verify pre-seeded memories loaded in BufferRepository + const memories = bufferRepo.listForOwner("investigator"); + expect(memories).toHaveLength(1); + expect(memories[0].id).toBe("mem-seed-1"); + expect(memories[0].timestamp).toBe("2026-07-09T07:55:00.000Z"); + expect(memories[0].locationId).toBe("lobby"); + expect(memories[0].intent.description).toBe("entered the house"); + + db.close(); + }); +}); diff --git a/content/scenario-core/tsconfig.json b/content/scenario-core/tsconfig.json new file mode 100644 index 0000000..17045aa --- /dev/null +++ b/content/scenario-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/spatial" }, + { "path": "../../packages/memory" } + ] +} diff --git a/packages/core/src/repository.ts b/packages/core/src/repository.ts index a77a367..e8f2847 100644 --- a/packages/core/src/repository.ts +++ b/packages/core/src/repository.ts @@ -64,6 +64,13 @@ export class SQLiteRepository { } catch { // Column already exists, ignore error } + + // Safely add connections_json column if it does not exist in an existing database + try { + this.db.exec("ALTER TABLE objects ADD COLUMN connections_json TEXT;"); + } catch { + // Column already exists, ignore error + } } save(obj: AttributableObject, type: string, worldId?: string): void { @@ -75,26 +82,38 @@ export class SQLiteRepository { let locationId: string | null = null; let aliasesJson: string | null = null; + let connectionsJson: string | null = null; + if (obj instanceof Entity) { locationId = obj.locationId; aliasesJson = JSON.stringify(Array.from(obj.aliases.entries())); } + // Check if it's a location (using duck typing to avoid circular import of Location) + if (type === "location") { + const loc = obj as { parentId?: string | null; connections?: unknown[] }; + locationId = loc.parentId ?? null; + if (loc.connections) { + connectionsJson = JSON.stringify(loc.connections); + } + } + // 1. Insert or ignore the object in the objects table this.db .prepare( ` - INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json, connections_json) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, world_id = excluded.world_id, clock_iso = excluded.clock_iso, location_id = excluded.location_id, - aliases_json = excluded.aliases_json + aliases_json = excluded.aliases_json, + connections_json = excluded.connections_json `, ) - .run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson); + .run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson, connectionsJson); // Get current attributes from db to delete the ones that are no longer present const existingAttrs = this.db @@ -166,6 +185,10 @@ export class SQLiteRepository { this.save(entity, "entity", worldId); } + saveLocation(location: AttributableObject, worldId?: string): void { + this.save(location, "location", worldId); + } + saveWorldState(worldState: WorldState): void { const saveWorldTx = this.db.transaction(() => { this.save(worldState, "world"); @@ -200,6 +223,54 @@ export class SQLiteRepository { return entity; } + loadLocation( + id: string, + factory: (id: string, parentId: string | null) => T, + ): T | null { + const objRow = this.db + .prepare( + ` + SELECT type, location_id, connections_json FROM objects WHERE id = ? + `, + ) + .get(id) as { type: string; location_id: string | null; connections_json: string | null } | undefined; + + if (!objRow || objRow.type !== "location") { + return null; + } + + const location = factory(id, objRow.location_id); + if (objRow.connections_json) { + (location as { connections?: unknown[] }).connections = JSON.parse(objRow.connections_json); + } + this.reconstituteAttributes(location); + return location; + } + + listLocations( + worldId: string, + factory: (id: string, parentId: string | null) => T, + ): T[] { + const rows = this.db + .prepare( + ` + SELECT id, location_id, connections_json FROM objects WHERE type = 'location' AND world_id = ? + `, + ) + .all(worldId) as { id: string; location_id: string | null; connections_json: string | null }[]; + + const locations: T[] = []; + for (const row of rows) { + const loc = factory(row.id, row.location_id); + if (row.connections_json) { + (loc as { connections?: unknown[] }).connections = JSON.parse(row.connections_json); + } + this.reconstituteAttributes(loc); + locations.push(loc); + } + return locations; + } + loadWorldState(id: string): WorldState | null { const objRow = this.db .prepare( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e88c9c4..9714d93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,6 +297,21 @@ importers: specifier: ^5 version: 5.9.3 + content/scenario-core: + dependencies: + '@omnia/core': + specifier: workspace:* + version: link:../../packages/core + '@omnia/memory': + specifier: workspace:* + version: link:../../packages/memory + '@omnia/spatial': + specifier: workspace:* + version: link:../../packages/spatial + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/actor: dependencies: '@omnia/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec9e54c..9d7f5f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - "cli" - "web/*" - "content/scenario-builder" + - "content/scenario-core" allowBuilds: better-sqlite3: true sharp: true diff --git a/tsconfig.json b/tsconfig.json index b6662da..18625c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ { "path": "./packages/spatial" }, { "path": "./packages/llm" }, { "path": "./packages/actor" }, + { "path": "./content/scenario-core" }, { "path": "./cli" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index ef42062..e3ef702 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "@omnia/memory": path.resolve(__dirname, "./packages/memory/src"), "@omnia/spatial": path.resolve(__dirname, "./packages/spatial/src"), "@omnia/actor": path.resolve(__dirname, "./packages/actor/src"), + "@omnia/scenario-core": path.resolve(__dirname, "./content/scenario-core/src"), "@omnia/cli": path.resolve(__dirname, "./cli/src"), }, },