- Added a new SceneRulebook system to manage data-driven validation rules for actions. - Introduced rule checks for actions like "take", "open", "move", "introduce", and "describe". - Created a rulebook engine to evaluate conditions and enforce rules during action validation. - Enhanced action handling with support for scene entry and character descriptions. - Updated the architecture documentation to reflect the new rule-based validation approach. - Added new endpoints and improved the persistence layer for rulebooks.
404 lines
10 KiB
TypeScript
404 lines
10 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import Database from "better-sqlite3";
|
|
|
|
import type { Action } from "./contracts/action";
|
|
import type { Entity } from "./contracts/entity";
|
|
import type { SceneRulebook } from "./contracts/rulebook";
|
|
import type { Turn } from "./contracts/turn";
|
|
import type { ValidationResult } from "./contracts/validation";
|
|
import type { WorldState } from "./contracts/world";
|
|
|
|
export interface DatabaseConfig {
|
|
dbPath: string;
|
|
}
|
|
|
|
export interface CharacterGardenDatabase {
|
|
sqlite: Database.Database;
|
|
init(): void;
|
|
close(): void;
|
|
upsertEntities(entities: Entity[]): void;
|
|
listEntities(): Entity[];
|
|
insertTurn(turn: Turn): void;
|
|
listTurns(): Turn[];
|
|
insertActions(turnId: string, actions: Action[]): void;
|
|
insertValidationResults(turnId: string, results: ValidationResult[]): void;
|
|
insertWorldState(turnId: string | null, worldState: WorldState): void;
|
|
getLatestWorldState(): WorldState | null;
|
|
upsertRulebook(rulebook: SceneRulebook): void;
|
|
getRulebook(id: string): SceneRulebook | null;
|
|
listRulebooks(): SceneRulebook[];
|
|
deleteRulebook(id: string): void;
|
|
wipe(): void;
|
|
}
|
|
|
|
function ensureParentDirectory(dbPath: string): void {
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
}
|
|
|
|
function parseJson<T>(value: string): T {
|
|
return JSON.parse(value) as T;
|
|
}
|
|
|
|
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
|
|
ensureParentDirectory(config.dbPath);
|
|
const sqlite = new Database(config.dbPath);
|
|
|
|
const initStatements = [
|
|
`
|
|
CREATE TABLE IF NOT EXISTS turns (
|
|
id TEXT PRIMARY KEY,
|
|
raw_text TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
)
|
|
`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS actions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
turn_id TEXT NOT NULL,
|
|
action_index INTEGER NOT NULL,
|
|
actor_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
target_id TEXT,
|
|
location_id TEXT,
|
|
metadata_json TEXT,
|
|
FOREIGN KEY(turn_id) REFERENCES turns(id)
|
|
)
|
|
`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS validation_results (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
turn_id TEXT NOT NULL,
|
|
action_index INTEGER NOT NULL,
|
|
success INTEGER NOT NULL,
|
|
reason TEXT,
|
|
message TEXT,
|
|
FOREIGN KEY(turn_id) REFERENCES turns(id)
|
|
)
|
|
`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS entities (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
attributes_json TEXT NOT NULL
|
|
)
|
|
`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS world_states (
|
|
id TEXT PRIMARY KEY,
|
|
turn_id TEXT,
|
|
state_json TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(turn_id) REFERENCES turns(id)
|
|
)
|
|
`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS rulebooks (
|
|
id TEXT PRIMARY KEY,
|
|
world_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
rules_json TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
)
|
|
`,
|
|
];
|
|
|
|
for (const statement of initStatements) {
|
|
sqlite.exec(statement);
|
|
}
|
|
|
|
const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities");
|
|
const upsertEntityStatement = sqlite.prepare(`
|
|
INSERT INTO entities (id, name, type, attributes_json)
|
|
VALUES (@id, @name, @type, @attributes_json)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
type = excluded.type,
|
|
attributes_json = excluded.attributes_json
|
|
`);
|
|
|
|
const listEntitiesStatement = sqlite.prepare(`
|
|
SELECT id, name, type, attributes_json
|
|
FROM entities
|
|
ORDER BY id ASC
|
|
`);
|
|
|
|
const insertTurnStatement = sqlite.prepare(`
|
|
INSERT INTO turns (id, raw_text, created_at)
|
|
VALUES (@id, @raw_text, @created_at)
|
|
`);
|
|
|
|
const listTurnsStatement = sqlite.prepare(`
|
|
SELECT id, raw_text, created_at
|
|
FROM turns
|
|
ORDER BY created_at ASC
|
|
`);
|
|
|
|
const insertActionStatement = sqlite.prepare(`
|
|
INSERT INTO actions (
|
|
turn_id,
|
|
action_index,
|
|
actor_id,
|
|
type,
|
|
target_id,
|
|
location_id,
|
|
metadata_json
|
|
) VALUES (
|
|
@turn_id,
|
|
@action_index,
|
|
@actor_id,
|
|
@type,
|
|
@target_id,
|
|
@location_id,
|
|
@metadata_json
|
|
)
|
|
`);
|
|
|
|
const insertValidationStatement = sqlite.prepare(`
|
|
INSERT INTO validation_results (
|
|
turn_id,
|
|
action_index,
|
|
success,
|
|
reason,
|
|
message
|
|
) VALUES (
|
|
@turn_id,
|
|
@action_index,
|
|
@success,
|
|
@reason,
|
|
@message
|
|
)
|
|
`);
|
|
|
|
const insertWorldStateStatement = sqlite.prepare(`
|
|
INSERT INTO world_states (id, turn_id, state_json, created_at)
|
|
VALUES (@id, @turn_id, @state_json, @created_at)
|
|
`);
|
|
|
|
const latestWorldStateStatement = sqlite.prepare(`
|
|
SELECT state_json
|
|
FROM world_states
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`);
|
|
|
|
const upsertRulebookStatement = sqlite.prepare(`
|
|
INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at)
|
|
VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
description = excluded.description,
|
|
rules_json = excluded.rules_json,
|
|
updated_at = excluded.updated_at
|
|
`);
|
|
|
|
const getRulebookStatement = sqlite.prepare(`
|
|
SELECT id, world_id, name, description, rules_json, created_at, updated_at
|
|
FROM rulebooks
|
|
WHERE id = @id
|
|
`);
|
|
|
|
const listRulebooksStatement = sqlite.prepare(`
|
|
SELECT id, world_id, name, description, rules_json, created_at, updated_at
|
|
FROM rulebooks
|
|
ORDER BY created_at ASC
|
|
`);
|
|
|
|
const deleteRulebookStatement = sqlite.prepare(`
|
|
DELETE FROM rulebooks WHERE id = @id
|
|
`);
|
|
|
|
return {
|
|
sqlite,
|
|
|
|
init() {
|
|
// Tables are initialized on construction.
|
|
},
|
|
|
|
close() {
|
|
sqlite.close();
|
|
},
|
|
|
|
wipe() {
|
|
sqlite.exec(`
|
|
DELETE FROM validation_results;
|
|
DELETE FROM actions;
|
|
DELETE FROM world_states;
|
|
DELETE FROM turns;
|
|
DELETE FROM entities;
|
|
`);
|
|
},
|
|
|
|
upsertEntities(entities) {
|
|
const tx = sqlite.transaction((entityList: Entity[]) => {
|
|
clearEntitiesStatement.run();
|
|
for (const entity of entityList) {
|
|
upsertEntityStatement.run({
|
|
id: entity.id,
|
|
name: entity.name,
|
|
type: entity.type,
|
|
attributes_json: JSON.stringify(entity.attributes),
|
|
});
|
|
}
|
|
});
|
|
|
|
tx(entities);
|
|
},
|
|
|
|
listEntities() {
|
|
const rows = listEntitiesStatement.all() as Array<{
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
attributes_json: string;
|
|
}>;
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
type: row.type,
|
|
attributes: parseJson<Record<string, unknown>>(row.attributes_json),
|
|
}));
|
|
},
|
|
|
|
insertTurn(turn) {
|
|
insertTurnStatement.run({
|
|
id: turn.id,
|
|
raw_text: turn.rawText,
|
|
created_at: turn.createdAt,
|
|
});
|
|
},
|
|
|
|
listTurns() {
|
|
const rows = listTurnsStatement.all() as Array<{
|
|
id: string;
|
|
raw_text: string;
|
|
created_at: number;
|
|
}>;
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
rawText: row.raw_text,
|
|
actions: [],
|
|
validation: [],
|
|
createdAt: row.created_at,
|
|
}));
|
|
},
|
|
|
|
insertActions(turnId, actions) {
|
|
const tx = sqlite.transaction((actionList: Action[]) => {
|
|
actionList.forEach((action, index) => {
|
|
insertActionStatement.run({
|
|
turn_id: turnId,
|
|
action_index: index,
|
|
actor_id: action.actorId,
|
|
type: action.type,
|
|
target_id: action.targetId ?? null,
|
|
location_id: action.locationId ?? null,
|
|
metadata_json: action.metadata ? JSON.stringify(action.metadata) : null,
|
|
});
|
|
});
|
|
});
|
|
|
|
tx(actions);
|
|
},
|
|
|
|
insertValidationResults(turnId, results) {
|
|
const tx = sqlite.transaction((validationList: ValidationResult[]) => {
|
|
for (const result of validationList) {
|
|
insertValidationStatement.run({
|
|
turn_id: turnId,
|
|
action_index: result.actionIndex,
|
|
success: result.success ? 1 : 0,
|
|
reason: result.reason ?? null,
|
|
message: result.message ?? null,
|
|
});
|
|
}
|
|
});
|
|
|
|
tx(results);
|
|
},
|
|
|
|
insertWorldState(turnId, worldState) {
|
|
insertWorldStateStatement.run({
|
|
id: worldState.id,
|
|
turn_id: turnId,
|
|
state_json: JSON.stringify(worldState),
|
|
created_at: worldState.createdAt,
|
|
});
|
|
},
|
|
|
|
getLatestWorldState() {
|
|
const row = latestWorldStateStatement.get() as { state_json: string } | undefined;
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
return parseJson<WorldState>(row.state_json);
|
|
},
|
|
|
|
upsertRulebook(rulebook) {
|
|
upsertRulebookStatement.run({
|
|
id: rulebook.id,
|
|
world_id: rulebook.worldId,
|
|
name: rulebook.name,
|
|
description: rulebook.description ?? null,
|
|
rules_json: JSON.stringify(rulebook.rules),
|
|
created_at: rulebook.createdAt,
|
|
updated_at: rulebook.updatedAt,
|
|
});
|
|
},
|
|
|
|
getRulebook(id) {
|
|
const row = getRulebookStatement.get({ id }) as
|
|
| {
|
|
id: string;
|
|
world_id: string;
|
|
name: string;
|
|
description: string | null;
|
|
rules_json: string;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
| undefined;
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
worldId: row.world_id,
|
|
name: row.name,
|
|
description: row.description ?? undefined,
|
|
rules: parseJson(row.rules_json),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
},
|
|
|
|
listRulebooks() {
|
|
const rows = listRulebooksStatement.all() as Array<{
|
|
id: string;
|
|
world_id: string;
|
|
name: string;
|
|
description: string | null;
|
|
rules_json: string;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}>;
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
worldId: row.world_id,
|
|
name: row.name,
|
|
description: row.description ?? undefined,
|
|
rules: parseJson(row.rules_json),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}));
|
|
},
|
|
|
|
deleteRulebook(id) {
|
|
deleteRulebookStatement.run({ id });
|
|
},
|
|
};
|
|
}
|