feat: Added repository abstraction for worldState and Entity (closes #4)

This commit is contained in:
2026-07-05 23:42:04 +05:30
parent 7d34f4067e
commit e87d98a47d
10 changed files with 3747 additions and 42 deletions

View File

@@ -5,7 +5,7 @@ import tseslint from "typescript-eslint";
export default [
{
ignores: ["**/dist/**", "**/node_modules/**"],
ignores: ["**/dist/**", "**/node_modules/**", "content/scenario-builder/**"],
},
js.configs.recommended,

View File

@@ -28,6 +28,7 @@
"type": "module",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.11.1",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",

View File

@@ -5,5 +5,8 @@
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"better-sqlite3": "^12.11.1"
}
}

View File

@@ -58,6 +58,10 @@ export class Attribute {
this.allowedEntities.delete(objectId);
}
}
getAllowedEntities(): Set<string> {
return new Set(this.allowedEntities);
}
}
export interface IAttribute {
@@ -73,9 +77,13 @@ export interface IAttribute {
}
export abstract class AttributableObject implements IAttribute {
readonly id: string = crypto.randomUUID();
readonly id: string;
readonly attributes: Map<string, Attribute> = new Map<string, Attribute>();
constructor(id?: string) {
this.id = id ?? crypto.randomUUID();
}
addAttribute(
name: string,
value: string,

View File

@@ -1,7 +1,7 @@
import { AttributableObject } from "./attribute.js";
export class Entity extends AttributableObject {
constructor() {
super();
constructor(id?: string) {
super(id);
}
}

View File

@@ -1,3 +1,4 @@
export * from "./attribute.js";
export * from "./entity.js";
export * from "./world.js";
export * from "./repository.js";

View File

@@ -0,0 +1,244 @@
import Database from "better-sqlite3";
import { AttributableObject, AttributeVisibility } from "./attribute.js";
import { Entity } from "./entity.js";
import { WorldState } from "./world.js";
export class SQLiteRepository {
private db: Database.Database;
constructor(db: Database.Database) {
this.db = db;
this.initializeSchema();
}
private initializeSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS objects (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
world_id TEXT,
FOREIGN KEY (world_id) REFERENCES objects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS attributes (
object_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
visibility TEXT NOT NULL,
PRIMARY KEY (object_id, name),
FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS attribute_acl (
object_id TEXT NOT NULL,
attribute_name TEXT NOT NULL,
entity_id TEXT NOT NULL,
PRIMARY KEY (object_id, attribute_name, entity_id),
FOREIGN KEY (object_id, attribute_name) REFERENCES attributes(object_id, name) ON DELETE CASCADE
);
`);
}
save(obj: AttributableObject, type: string, worldId?: string): void {
const saveTx = this.db.transaction(() => {
// 1. Insert or ignore the object in the objects table
this.db
.prepare(
`
INSERT INTO objects (id, type, world_id)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET type = excluded.type, world_id = excluded.world_id
`,
)
.run(obj.id, type, worldId || null);
// Get current attributes from db to delete the ones that are no longer present
const existingAttrs = this.db
.prepare(
`
SELECT name FROM attributes WHERE object_id = ?
`,
)
.all(obj.id) as { name: string }[];
const existingNames = new Set(existingAttrs.map((a) => a.name));
const currentNames = new Set(obj.attributes.keys());
// Delete attributes that are no longer on the object
for (const name of existingNames) {
if (!currentNames.has(name)) {
this.db
.prepare(
`
DELETE FROM attributes WHERE object_id = ? AND name = ?
`,
)
.run(obj.id, name);
}
}
// Save / update current attributes
for (const [name, attr] of obj.attributes) {
this.db
.prepare(
`
INSERT INTO attributes (object_id, name, value, visibility)
VALUES (?, ?, ?, ?)
ON CONFLICT(object_id, name) DO UPDATE SET
value = excluded.value,
visibility = excluded.visibility
`,
)
.run(obj.id, name, attr.getValue(), attr.getVisibility());
// Manage ACL
// Clear existing ACL entries for this attribute
this.db
.prepare(
`
DELETE FROM attribute_acl WHERE object_id = ? AND attribute_name = ?
`,
)
.run(obj.id, name);
// Insert new ACL entries
if (attr.getVisibility() === AttributeVisibility.PRIVATE) {
const allowed = attr.getAllowedEntities();
const insertAcl = this.db.prepare(`
INSERT INTO attribute_acl (object_id, attribute_name, entity_id)
VALUES (?, ?, ?)
`);
for (const entityId of allowed) {
insertAcl.run(obj.id, name, entityId);
}
}
}
});
saveTx();
}
saveEntity(entity: Entity, worldId?: string): void {
this.save(entity, "entity", worldId);
}
saveWorldState(worldState: WorldState): void {
const saveWorldTx = this.db.transaction(() => {
this.save(worldState, "world");
for (const entity of worldState.entities.values()) {
this.saveEntity(entity, worldState.id);
}
});
saveWorldTx();
}
loadEntity(id: string): Entity | null {
const objRow = this.db
.prepare(
`
SELECT type FROM objects WHERE id = ?
`,
)
.get(id) as { type: string } | undefined;
if (!objRow || objRow.type !== "entity") {
return null;
}
const entity = new Entity(id);
this.reconstituteAttributes(entity);
return entity;
}
loadWorldState(id: string): WorldState | null {
const objRow = this.db
.prepare(
`
SELECT type FROM objects WHERE id = ?
`,
)
.get(id) as { type: string } | undefined;
if (!objRow || objRow.type !== "world") {
return null;
}
const worldState = new WorldState(id);
this.reconstituteAttributes(worldState);
// Reconstitute all entities belonging to this world
const entityRows = this.db
.prepare(
`
SELECT id FROM objects WHERE type = 'entity' AND world_id = ?
`,
)
.all(id) as { id: string }[];
for (const row of entityRows) {
const entity = new Entity(row.id);
this.reconstituteAttributes(entity);
worldState.addEntity(entity);
}
return worldState;
}
listEntities(): Entity[] {
const rows = this.db
.prepare(
`
SELECT id FROM objects WHERE type = 'entity'
`,
)
.all() as { id: string }[];
const entities: Entity[] = [];
for (const row of rows) {
const entity = new Entity(row.id);
this.reconstituteAttributes(entity);
entities.push(entity);
}
return entities;
}
delete(id: string): void {
this.db.prepare(`DELETE FROM objects WHERE id = ?`).run(id);
}
private reconstituteAttributes(obj: AttributableObject): void {
// Clear the auto-generated empty maps/attributes if any
obj.attributes.clear();
const attrs = this.db
.prepare(
`
SELECT name, value, visibility FROM attributes WHERE object_id = ?
`,
)
.all(obj.id) as {
name: string;
value: string;
visibility: AttributeVisibility;
}[];
for (const attrRow of attrs) {
const allowedRows = this.db
.prepare(
`
SELECT entity_id FROM attribute_acl WHERE object_id = ? AND attribute_name = ?
`,
)
.all(obj.id, attrRow.name) as { entity_id: string }[];
const allowedEntities = new Set(allowedRows.map((r) => r.entity_id));
obj.addAttribute(
attrRow.name,
attrRow.value,
attrRow.visibility,
allowedEntities,
);
}
}
}

View File

@@ -1,7 +1,27 @@
import { AttributableObject } from "./attribute.js";
import { Entity } from "./entity.js";
export class World extends AttributableObject {
constructor() {
super();
export class WorldState extends AttributableObject {
/**
* WorldState is the live, evolving instance you get from loading a Scenario and playing it forward.
* Universe's current state (distinct from how it started)
*/
readonly entities: Map<string, Entity> = new Map();
constructor(id?: string) {
super(id);
}
addEntity(entity: Entity): void {
if (this.entities.has(entity.id)) {
throw new Error(
`Entity with ID ${entity.id} already exists in the world`,
);
}
this.entities.set(entity.id, entity);
}
getEntity(id: string): Entity | undefined {
return this.entities.get(id);
}
}

3495
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,8 @@ packages:
- "packages/*"
- "cli"
- "web/*"
- "content/scenario-builder"
allowBuilds:
better-sqlite3: true
sharp: true
unrs-resolver: true