mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat: Implement ScenarioLoader pipeline
This commit is contained in:
15
content/scenario-core/package.json
Normal file
15
content/scenario-core/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
2
content/scenario-core/src/index.ts
Normal file
2
content/scenario-core/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./schema.js";
|
||||
export * from "./loader.js";
|
||||
125
content/scenario-core/src/loader.ts
Normal file
125
content/scenario-core/src/loader.ts
Normal file
@@ -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<string> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
65
content/scenario-core/src/schema.ts
Normal file
65
content/scenario-core/src/schema.ts
Normal file
@@ -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<typeof ScenarioSchema>;
|
||||
136
content/scenario-core/tests/scenario.test.ts
Normal file
136
content/scenario-core/tests/scenario.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
13
content/scenario-core/tsconfig.json
Normal file
13
content/scenario-core/tsconfig.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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<T extends AttributableObject>(
|
||||
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<T extends AttributableObject>(
|
||||
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(
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -3,6 +3,7 @@ packages:
|
||||
- "cli"
|
||||
- "web/*"
|
||||
- "content/scenario-builder"
|
||||
- "content/scenario-core"
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
sharp: true
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "./packages/spatial" },
|
||||
{ "path": "./packages/llm" },
|
||||
{ "path": "./packages/actor" },
|
||||
{ "path": "./content/scenario-core" },
|
||||
{ "path": "./cli" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user