feat: Implement scene rulebook and validation engine
- 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.
This commit is contained in:
Binary file not shown.
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
import { createDatabase, CharacterGardenDatabase } from "./db";
|
import { createDatabase, CharacterGardenDatabase } from "./db";
|
||||||
import type { Entity } from "./contracts/entity";
|
import type { Entity } from "./contracts/entity";
|
||||||
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
import type { Turn } from "./contracts/turn";
|
import type { Turn } from "./contracts/turn";
|
||||||
import type { WorldState } from "./contracts/world";
|
import type { WorldState } from "./contracts/world";
|
||||||
|
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
|
||||||
import { processTurn, ProcessTurnResponse } from "./turns/processTurn";
|
import { processTurn, ProcessTurnResponse } from "./turns/processTurn";
|
||||||
|
|
||||||
export interface AppSnapshot {
|
export interface AppSnapshot {
|
||||||
@@ -15,6 +17,9 @@ export interface CharacterGardenApp {
|
|||||||
db: CharacterGardenDatabase;
|
db: CharacterGardenDatabase;
|
||||||
getSnapshot(): AppSnapshot;
|
getSnapshot(): AppSnapshot;
|
||||||
processTurn(rawText: string): ProcessTurnResponse;
|
processTurn(rawText: string): ProcessTurnResponse;
|
||||||
|
getRulebook(): SceneRulebook;
|
||||||
|
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
|
||||||
|
listRulebooks(): SceneRulebook[];
|
||||||
reset(): AppSnapshot;
|
reset(): AppSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +27,22 @@ function createSeedWorldState(): WorldState {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const entities: Record<string, Entity> = {
|
const entities: Record<string, Entity> = {
|
||||||
|
room_offstage: {
|
||||||
|
id: "room_offstage",
|
||||||
|
name: "Offstage",
|
||||||
|
type: "room",
|
||||||
|
attributes: {
|
||||||
|
description: "A holding area for characters not currently in the active scene.",
|
||||||
|
is_joinable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
room_start: {
|
room_start: {
|
||||||
id: "room_start",
|
id: "room_start",
|
||||||
name: "Start Room",
|
name: "Start Room",
|
||||||
type: "room",
|
type: "room",
|
||||||
attributes: {
|
attributes: {
|
||||||
description: "A plain room with a locked door.",
|
description: "A plain room with a locked door.",
|
||||||
|
is_joinable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
room_exit: {
|
room_exit: {
|
||||||
@@ -36,6 +51,7 @@ function createSeedWorldState(): WorldState {
|
|||||||
type: "room",
|
type: "room",
|
||||||
attributes: {
|
attributes: {
|
||||||
description: "A simple room beyond the door.",
|
description: "A simple room beyond the door.",
|
||||||
|
is_joinable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
@@ -47,6 +63,16 @@ function createSeedWorldState(): WorldState {
|
|||||||
has_key_1: false,
|
has_key_1: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
groundskeeper: {
|
||||||
|
id: "groundskeeper",
|
||||||
|
name: "Groundskeeper",
|
||||||
|
type: "character",
|
||||||
|
attributes: {
|
||||||
|
location: "room_offstage",
|
||||||
|
is_social: true,
|
||||||
|
in_scene: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
door_1: {
|
door_1: {
|
||||||
id: "door_1",
|
id: "door_1",
|
||||||
name: "Old Door",
|
name: "Old Door",
|
||||||
@@ -81,12 +107,71 @@ function createSeedWorldState(): WorldState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } {
|
||||||
|
const seed = createSeedWorldState();
|
||||||
|
const mergedEntities: Record<string, Entity> = {};
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [entityId, seedEntity] of Object.entries(seed.entities)) {
|
||||||
|
const existingEntity = worldState.entities[entityId];
|
||||||
|
if (!existingEntity) {
|
||||||
|
mergedEntities[entityId] = seedEntity;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedAttributes = {
|
||||||
|
...seedEntity.attributes,
|
||||||
|
...existingEntity.attributes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedEntities[entityId] = {
|
||||||
|
...existingEntity,
|
||||||
|
attributes: mergedAttributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [entityId, existingEntity] of Object.entries(worldState.entities)) {
|
||||||
|
if (!mergedEntities[entityId]) {
|
||||||
|
mergedEntities[entityId] = existingEntity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedWorldState: WorldState = {
|
||||||
|
...worldState,
|
||||||
|
entities: mergedEntities,
|
||||||
|
metadata: {
|
||||||
|
...seed.metadata,
|
||||||
|
...worldState.metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
mergedWorldState.id = randomUUID();
|
||||||
|
mergedWorldState.createdAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
worldState: mergedWorldState,
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSeedState(db: CharacterGardenDatabase): WorldState {
|
function ensureSeedState(db: CharacterGardenDatabase): WorldState {
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
const latest = db.getLatestWorldState();
|
const latest = db.getLatestWorldState();
|
||||||
if (latest) {
|
if (latest) {
|
||||||
return latest;
|
const merged = mergeSeedWorldState(latest);
|
||||||
|
db.upsertEntities(Object.values(merged.worldState.entities));
|
||||||
|
if (merged.changed) {
|
||||||
|
db.insertWorldState(null, merged.worldState);
|
||||||
|
}
|
||||||
|
return merged.worldState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = createSeedWorldState();
|
const seed = createSeedWorldState();
|
||||||
@@ -95,9 +180,34 @@ function ensureSeedState(db: CharacterGardenDatabase): WorldState {
|
|||||||
return seed;
|
return seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureDefaultRulebook(
|
||||||
|
db: CharacterGardenDatabase,
|
||||||
|
worldState: WorldState
|
||||||
|
): SceneRulebook {
|
||||||
|
const existing = db.getRulebook(DEFAULT_RULEBOOK_ID);
|
||||||
|
if (existing) return existing;
|
||||||
|
const defaultRulebook = createDefaultRulebook(worldState.id);
|
||||||
|
db.upsertRulebook(defaultRulebook);
|
||||||
|
return defaultRulebook;
|
||||||
|
}
|
||||||
|
|
||||||
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||||
const db = createDatabase({ dbPath });
|
const db = createDatabase({ dbPath });
|
||||||
let worldState = ensureSeedState(db);
|
let worldState = ensureSeedState(db);
|
||||||
|
// Active rulebook ID — tracks which rulebook the world is using.
|
||||||
|
let activeRulebookId: string =
|
||||||
|
worldState.rulebookId ?? DEFAULT_RULEBOOK_ID;
|
||||||
|
|
||||||
|
// Ensure the default rulebook is present on first boot.
|
||||||
|
ensureDefaultRulebook(db, worldState);
|
||||||
|
|
||||||
|
function loadActiveRulebook(): SceneRulebook {
|
||||||
|
const rulebook = db.getRulebook(activeRulebookId);
|
||||||
|
if (rulebook) return rulebook;
|
||||||
|
// Fall back to default if the active one was deleted.
|
||||||
|
activeRulebookId = DEFAULT_RULEBOOK_ID;
|
||||||
|
return ensureDefaultRulebook(db, worldState);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
@@ -110,14 +220,32 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
|||||||
},
|
},
|
||||||
|
|
||||||
processTurn(rawText: string) {
|
processTurn(rawText: string) {
|
||||||
const result = processTurn(rawText, worldState, db);
|
const rulebook = loadActiveRulebook();
|
||||||
|
const result = processTurn(rawText, worldState, db, rulebook);
|
||||||
worldState = result.worldState;
|
worldState = result.worldState;
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRulebook() {
|
||||||
|
return loadActiveRulebook();
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertRulebook(rulebook: SceneRulebook) {
|
||||||
|
const updated: SceneRulebook = { ...rulebook, updatedAt: Date.now() };
|
||||||
|
db.upsertRulebook(updated);
|
||||||
|
activeRulebookId = updated.id;
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
listRulebooks() {
|
||||||
|
return db.listRulebooks();
|
||||||
|
},
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
db.wipe();
|
db.wipe();
|
||||||
worldState = ensureSeedState(db);
|
worldState = ensureSeedState(db);
|
||||||
|
activeRulebookId = DEFAULT_RULEBOOK_ID;
|
||||||
|
ensureDefaultRulebook(db, worldState);
|
||||||
return {
|
return {
|
||||||
worldState,
|
worldState,
|
||||||
turns: db.listTurns(),
|
turns: db.listTurns(),
|
||||||
|
|||||||
94
charactergarden/app/src/contracts/rulebook.ts
Normal file
94
charactergarden/app/src/contracts/rulebook.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* SceneRulebook — data-driven validation rules for the truth engine.
|
||||||
|
*
|
||||||
|
* Rules are stored per-scene in the database and evaluated by rulebookEngine.ts.
|
||||||
|
* The default set is seeded from defaultRulebook.ts and mirrors the original
|
||||||
|
* hardcoded logic in truthEngine.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Which entity in the action context a condition refers to. */
|
||||||
|
export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable, JSON-serialisable condition expression.
|
||||||
|
*
|
||||||
|
* Combinators: and | or | not
|
||||||
|
* Predicates:
|
||||||
|
* entityExists — entity referenced by role is present in world state
|
||||||
|
* entityExistsOrWillBeCreated — entity exists OR will be created earlier in this turn
|
||||||
|
* entityType — entity.type === requiredType
|
||||||
|
* eq / neq — entity field comparison (id, name, type, or attributes[attribute])
|
||||||
|
* attributeExists — entity.attributes[attribute] is not undefined
|
||||||
|
* sameLocation — two entities share the same location attribute value
|
||||||
|
* actorIdIn — action.actorId is included in an allowed list
|
||||||
|
* actorNameIn — actor.name matches one of an allowed list (case-insensitive)
|
||||||
|
* attributeRef — entities[checkRole].attributes[prefix + entities[refRole].attributes[refAttribute]] === true
|
||||||
|
* metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey]
|
||||||
|
*/
|
||||||
|
export type ConditionExpr =
|
||||||
|
| { op: "and"; conditions: ConditionExpr[] }
|
||||||
|
| { op: "or"; conditions: ConditionExpr[] }
|
||||||
|
| { op: "not"; condition: ConditionExpr }
|
||||||
|
| { op: "entityExists"; role: EntityRole }
|
||||||
|
| { op: "entityExistsOrWillBeCreated"; role: EntityRole }
|
||||||
|
| { op: "entityType"; role: EntityRole; requiredType: string }
|
||||||
|
| { op: "eq"; role: EntityRole; attribute: string; value: unknown }
|
||||||
|
| { op: "neq"; role: EntityRole; attribute: string; value: unknown }
|
||||||
|
| { op: "attributeExists"; role: EntityRole; attribute: string }
|
||||||
|
| { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole }
|
||||||
|
| { op: "actorIdIn"; allowedIds: string[] }
|
||||||
|
| { op: "actorNameIn"; allowedNames: string[] }
|
||||||
|
| {
|
||||||
|
op: "attributeRef";
|
||||||
|
/** Entity whose attribute is being tested */
|
||||||
|
checkRole: EntityRole;
|
||||||
|
/** Optional string prepended to the resolved key (e.g. "has_") */
|
||||||
|
prefix?: string;
|
||||||
|
/** Entity that provides the dynamic attribute name */
|
||||||
|
refRole: EntityRole;
|
||||||
|
/** Attribute on refRole whose value supplies the key name */
|
||||||
|
refAttribute: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: "metaValueNotInRoom";
|
||||||
|
/** Key in action.metadata whose value to match against entity names */
|
||||||
|
metaKey: string;
|
||||||
|
/** Only match entities of this type */
|
||||||
|
entityType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A single named check within an action rule set. */
|
||||||
|
export type RuleCheck = {
|
||||||
|
id: string;
|
||||||
|
/** Human-readable label shown in the rulebook editor. */
|
||||||
|
description: string;
|
||||||
|
condition: ConditionExpr;
|
||||||
|
failReason: string;
|
||||||
|
/**
|
||||||
|
* Failure message template.
|
||||||
|
* Supports: {actor.id}, {actor.name}, {target.id}, {target.name}
|
||||||
|
*/
|
||||||
|
failMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All checks that apply to a specific action type. */
|
||||||
|
export type ActionRuleSet = {
|
||||||
|
actionType: string;
|
||||||
|
/**
|
||||||
|
* When false, all checks are skipped and the action always passes.
|
||||||
|
* Useful for quickly disabling enforcement without deleting the rules.
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
checks: RuleCheck[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The full rulebook attached to a scene/world. */
|
||||||
|
export type SceneRulebook = {
|
||||||
|
id: string;
|
||||||
|
worldId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
rules: ActionRuleSet[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
@@ -5,4 +5,6 @@ export type WorldState = {
|
|||||||
entities: Record<string, Entity>;
|
entities: Record<string, Entity>;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
/** ID of the SceneRulebook currently active for this world. */
|
||||||
|
rulebookId?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Database from "better-sqlite3";
|
|||||||
|
|
||||||
import type { Action } from "./contracts/action";
|
import type { Action } from "./contracts/action";
|
||||||
import type { Entity } from "./contracts/entity";
|
import type { Entity } from "./contracts/entity";
|
||||||
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
import type { Turn } from "./contracts/turn";
|
import type { Turn } from "./contracts/turn";
|
||||||
import type { ValidationResult } from "./contracts/validation";
|
import type { ValidationResult } from "./contracts/validation";
|
||||||
import type { WorldState } from "./contracts/world";
|
import type { WorldState } from "./contracts/world";
|
||||||
@@ -24,6 +25,10 @@ export interface CharacterGardenDatabase {
|
|||||||
insertValidationResults(turnId: string, results: ValidationResult[]): void;
|
insertValidationResults(turnId: string, results: ValidationResult[]): void;
|
||||||
insertWorldState(turnId: string | null, worldState: WorldState): void;
|
insertWorldState(turnId: string | null, worldState: WorldState): void;
|
||||||
getLatestWorldState(): WorldState | null;
|
getLatestWorldState(): WorldState | null;
|
||||||
|
upsertRulebook(rulebook: SceneRulebook): void;
|
||||||
|
getRulebook(id: string): SceneRulebook | null;
|
||||||
|
listRulebooks(): SceneRulebook[];
|
||||||
|
deleteRulebook(id: string): void;
|
||||||
wipe(): void;
|
wipe(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +93,17 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
|||||||
FOREIGN KEY(turn_id) REFERENCES turns(id)
|
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) {
|
for (const statement of initStatements) {
|
||||||
@@ -169,6 +185,32 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
|||||||
LIMIT 1
|
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 {
|
return {
|
||||||
sqlite,
|
sqlite,
|
||||||
|
|
||||||
@@ -296,5 +338,66 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
|||||||
}
|
}
|
||||||
return parseJson<WorldState>(row.state_json);
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
247
charactergarden/app/src/defaultRulebook.ts
Normal file
247
charactergarden/app/src/defaultRulebook.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
|
|
||||||
|
export const DEFAULT_RULEBOOK_ID = "rulebook_default";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the default SceneRulebook, encoding all validation logic that was
|
||||||
|
* previously hardcoded in truthEngine.ts as editable, data-driven rules.
|
||||||
|
*/
|
||||||
|
export function createDefaultRulebook(worldId: string): SceneRulebook {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id: DEFAULT_RULEBOOK_ID,
|
||||||
|
worldId,
|
||||||
|
name: "Default Rulebook",
|
||||||
|
description: "Built-in scene rules for the CharacterGarden engine. Edit freely — the engine re-evaluates on every turn.",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
actionType: "inspect",
|
||||||
|
enabled: true,
|
||||||
|
checks: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
actionType: "take",
|
||||||
|
enabled: true,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "take_target_exists",
|
||||||
|
description: "Target entity must exist in the world",
|
||||||
|
condition: { op: "entityExists", role: "target" },
|
||||||
|
failReason: "target_not_found",
|
||||||
|
failMessage: "Target '{target.id}' does not exist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "take_same_location",
|
||||||
|
description: "Actor and target must be in the same location",
|
||||||
|
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
|
||||||
|
failReason: "not_in_same_location",
|
||||||
|
failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "take_takeable",
|
||||||
|
description: "Target must have takeable attribute set to true",
|
||||||
|
condition: { op: "eq", role: "target", attribute: "takeable", value: true },
|
||||||
|
failReason: "not_takeable",
|
||||||
|
failMessage: "Target '{target.id}' cannot be taken.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
actionType: "open",
|
||||||
|
enabled: true,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "open_target_exists",
|
||||||
|
description: "Target entity must exist in the world",
|
||||||
|
condition: { op: "entityExists", role: "target" },
|
||||||
|
failReason: "target_not_found",
|
||||||
|
failMessage: "Target '{target.id}' does not exist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "open_openable",
|
||||||
|
description: "Target must have openable attribute set to true",
|
||||||
|
condition: { op: "eq", role: "target", attribute: "openable", value: true },
|
||||||
|
failReason: "not_openable",
|
||||||
|
failMessage: "Target '{target.id}' is not openable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "open_lock_check",
|
||||||
|
description: "If target is locked, actor must possess the required key (has_<requiredKey> attribute)",
|
||||||
|
condition: {
|
||||||
|
op: "or",
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
op: "not",
|
||||||
|
condition: { op: "eq", role: "target", attribute: "locked", value: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: "attributeRef",
|
||||||
|
checkRole: "actor",
|
||||||
|
prefix: "has_",
|
||||||
|
refRole: "target",
|
||||||
|
refAttribute: "requiredKey",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "locked_requires_key",
|
||||||
|
failMessage: "Target '{target.id}' is locked and requires a key.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
actionType: "move",
|
||||||
|
enabled: true,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "move_target_is_room",
|
||||||
|
description: "Target must be an existing entity of type 'room'",
|
||||||
|
condition: {
|
||||||
|
op: "and",
|
||||||
|
conditions: [
|
||||||
|
{ op: "entityExists", role: "target" },
|
||||||
|
{ op: "entityType", role: "target", requiredType: "room" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "target_not_found",
|
||||||
|
failMessage: "Move target '{target.id}' is not a valid room.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
actionType: "introduce",
|
||||||
|
enabled: true,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "introduce_actor_authorized",
|
||||||
|
description: "Only approved characters can introduce/create characters in-scene",
|
||||||
|
condition: {
|
||||||
|
op: "actorIdIn",
|
||||||
|
allowedIds: ["player"],
|
||||||
|
},
|
||||||
|
failReason: "actor_not_authorized",
|
||||||
|
failMessage: "Actor '{actor.id}' is not allowed to introduce new characters.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "introduce_actor_in_room",
|
||||||
|
description: "Actor must be located in a valid room entity",
|
||||||
|
condition: {
|
||||||
|
op: "and",
|
||||||
|
conditions: [
|
||||||
|
{ op: "entityExists", role: "actorRoom" },
|
||||||
|
{ op: "entityType", role: "actorRoom", requiredType: "room" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "room_not_found",
|
||||||
|
failMessage: "Actor '{actor.id}' is not currently in a valid room.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "introduce_room_joinable",
|
||||||
|
description: "Actor's room must allow new arrivals (is_joinable: true)",
|
||||||
|
condition: { op: "eq", role: "actorRoom", attribute: "is_joinable", value: true },
|
||||||
|
failReason: "room_not_joinable",
|
||||||
|
failMessage: "Room is not available for new arrivals.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "introduce_target_is_character",
|
||||||
|
description: "If target entity exists, it must be of type 'character'",
|
||||||
|
condition: {
|
||||||
|
op: "or",
|
||||||
|
conditions: [
|
||||||
|
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||||
|
{ op: "entityType", role: "target", requiredType: "character" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "target_not_character",
|
||||||
|
failMessage: "Target '{target.id}' is not a character and cannot join the scene.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "introduce_target_social",
|
||||||
|
description: "If target exists, it must be socially available (is_social: true)",
|
||||||
|
condition: {
|
||||||
|
op: "or",
|
||||||
|
conditions: [
|
||||||
|
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||||
|
{ op: "eq", role: "target", attribute: "is_social", value: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "target_not_social",
|
||||||
|
failMessage: "Target '{target.id}' is not socially available to join the scene.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "introduce_not_already_present",
|
||||||
|
description: "If target exists, it must not already be in the same room as the actor",
|
||||||
|
condition: {
|
||||||
|
op: "or",
|
||||||
|
conditions: [
|
||||||
|
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||||
|
{
|
||||||
|
op: "not",
|
||||||
|
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "already_in_scene",
|
||||||
|
failMessage: "Target '{target.id}' is already present in the scene.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "introduce_no_name_duplicate",
|
||||||
|
description: "When introducing a new character by name, no character with that name may already be in the room",
|
||||||
|
condition: {
|
||||||
|
op: "metaValueNotInRoom",
|
||||||
|
metaKey: "characterName",
|
||||||
|
entityType: "character",
|
||||||
|
},
|
||||||
|
failReason: "already_in_scene",
|
||||||
|
failMessage: "A character with this name is already present in the scene.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
actionType: "describe",
|
||||||
|
enabled: true,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "describe_target_exists",
|
||||||
|
description: "Target must exist in the world or be created earlier in this turn",
|
||||||
|
condition: { op: "entityExistsOrWillBeCreated", role: "target" },
|
||||||
|
failReason: "target_not_found",
|
||||||
|
failMessage: "Target '{target.id}' does not exist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "describe_target_is_character",
|
||||||
|
description: "If target exists, it must be of type 'character'",
|
||||||
|
condition: {
|
||||||
|
op: "or",
|
||||||
|
conditions: [
|
||||||
|
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||||
|
{ op: "entityType", role: "target", requiredType: "character" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "target_not_character",
|
||||||
|
failMessage: "Target '{target.id}' is not a character and cannot be described.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "describe_same_location",
|
||||||
|
description: "If target exists, actor and target must be in the same location",
|
||||||
|
condition: {
|
||||||
|
op: "or",
|
||||||
|
conditions: [
|
||||||
|
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||||
|
{ op: "sameLocation", roleA: "actor", roleB: "target" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
failReason: "not_in_same_location",
|
||||||
|
failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
|
||||||
import { createCharacterGardenApp } from "./app";
|
import { createCharacterGardenApp } from "./app";
|
||||||
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
|
|
||||||
const port = Number(process.env.APP_PORT ?? 3000);
|
const port = Number(process.env.APP_PORT ?? 3000);
|
||||||
const host = process.env.APP_HOST ?? "0.0.0.0";
|
const host = process.env.APP_HOST ?? "0.0.0.0";
|
||||||
@@ -25,6 +26,26 @@ server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) =>
|
|||||||
return game.processTurn(input);
|
return game.processTurn(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rulebook endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** GET /api/rulebook — returns the currently active rulebook. */
|
||||||
|
server.get("/api/rulebook", async () => game.getRulebook());
|
||||||
|
|
||||||
|
/** PUT /api/rulebook — replace (or create) the active rulebook. */
|
||||||
|
server.put<{ Body: SceneRulebook }>("/api/rulebook", async (request, reply) => {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || typeof body.id !== "string" || !Array.isArray(body.rules)) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "Invalid rulebook payload. Must include id (string) and rules (array)." };
|
||||||
|
}
|
||||||
|
return game.upsertRulebook(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/rulebooks — list all saved rulebooks (name + id summary). */
|
||||||
|
server.get("/api/rulebooks", async () => game.listRulebooks());
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await server.listen({ host, port });
|
await server.listen({ host, port });
|
||||||
|
|||||||
@@ -4,39 +4,128 @@ function normalized(input: string): string {
|
|||||||
return input.trim().toLowerCase();
|
return input.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTextToActions(text: string, actorId = "player"): Action[] {
|
function toDisplayName(value: string): string {
|
||||||
const input = normalized(text);
|
return value
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractIntroducedCharacterName(input: string): string | undefined {
|
||||||
|
const match = input.match(/(?:introduce|bring in|invite|have)\s+(?:the\s+|a\s+|an\s+)?(.+?)(?:\s+join)?$/);
|
||||||
|
const rawName = match?.[1]?.trim();
|
||||||
|
if (!rawName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractActorAndAction(sentence: string): { actorName?: string; action: string } {
|
||||||
|
const normalized_sent = normalized(sentence);
|
||||||
|
// For now, treat the entire sentence as an action with no explicit actor
|
||||||
|
// In future, we can add patterns like "spencer introduces jeff" -> { actorName: "spencer", action: "introduces jeff" }
|
||||||
|
return { action: normalized_sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSingleAction(actionText: string, defaultActorId: string): Action | undefined {
|
||||||
|
const input = normalized(actionText);
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return [];
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/(look|inspect|examine)/.test(input)) {
|
if (/(look|inspect|examine)/.test(input)) {
|
||||||
return [{ actorId, type: "inspect", targetId: actorId }];
|
return { actorId: defaultActorId, type: "inspect", targetId: defaultActorId };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/(go|move|walk|head|travel)/.test(input)) {
|
if (/(go|move|walk|head|travel)/.test(input)) {
|
||||||
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
|
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
|
||||||
return [{ actorId, type: "move", targetId: "room_exit" }];
|
return { actorId: defaultActorId, type: "move", targetId: "room_exit" };
|
||||||
}
|
}
|
||||||
if (input.includes("start")) {
|
if (input.includes("start")) {
|
||||||
return [{ actorId, type: "move", targetId: "room_start" }];
|
return { actorId: defaultActorId, type: "move", targetId: "room_start" };
|
||||||
}
|
}
|
||||||
return [];
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/(open)/.test(input)) {
|
if (/(open)/.test(input)) {
|
||||||
if (input.includes("door")) {
|
if (input.includes("door")) {
|
||||||
return [{ actorId, type: "open", targetId: "door_1" }];
|
return { actorId: defaultActorId, type: "open", targetId: "door_1" };
|
||||||
}
|
}
|
||||||
return [];
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/(take|pick up|grab)/.test(input)) {
|
if (/(take|pick up|grab)/.test(input)) {
|
||||||
if (input.includes("key")) {
|
if (input.includes("key")) {
|
||||||
return [{ actorId, type: "take", targetId: "key_1" }];
|
return { actorId: defaultActorId, type: "take", targetId: "key_1" };
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/(introduce|bring in|invite|have .* join)/.test(input)) {
|
||||||
|
if (input.includes("groundskeeper")) {
|
||||||
|
return { actorId: defaultActorId, type: "introduce", targetId: "groundskeeper" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterName = extractIntroducedCharacterName(input);
|
||||||
|
if (!characterName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actorId: defaultActorId,
|
||||||
|
type: "introduce",
|
||||||
|
metadata: {
|
||||||
|
characterName,
|
||||||
|
displayName: toDisplayName(characterName),
|
||||||
|
createIfMissing: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/(describe|is a|is an|has)/.test(input)) {
|
||||||
|
// Match patterns like "describe the merchant as shrewd" or "the merchant is shrewd"
|
||||||
|
const describeMatch = input.match(/(?:describe|tell about)\s+(?:the\s+)?([a-z\s_]+?)\s+as\s+(.+)$/) ||
|
||||||
|
input.match(/(?:the\s+)?([a-z\s_]+?)\s+(?:is|has)\s+(.+)$/);
|
||||||
|
if (describeMatch) {
|
||||||
|
const [_, targetNameRaw, trait] = describeMatch;
|
||||||
|
const targetName = targetNameRaw.trim().replace(/^the\s+/, "").trim();
|
||||||
|
const targetId = `character_${targetName.replace(/\s+/g, "_")}`;
|
||||||
|
return {
|
||||||
|
actorId: defaultActorId,
|
||||||
|
type: "describe",
|
||||||
|
targetId,
|
||||||
|
metadata: {
|
||||||
|
trait: trait.trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTextToActions(text: string, actorId = "player"): Action[] {
|
||||||
|
if (!text || !text.trim()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
// Split by sentence terminators
|
||||||
|
const sentences = text.split(/[.!?]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
const actions: Action[] = [];
|
||||||
|
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
const { actorName, action } = extractActorAndAction(sentence);
|
||||||
|
const resolvedActorId = actorName ? `character_${actorName}` : actorId;
|
||||||
|
const parsedAction = parseSingleAction(action, resolvedActorId);
|
||||||
|
if (parsedAction) {
|
||||||
|
actions.push(parsedAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
286
charactergarden/app/src/rulebookEngine.ts
Normal file
286
charactergarden/app/src/rulebookEngine.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import type { Action } from "./contracts/action";
|
||||||
|
import type { Entity } from "./contracts/entity";
|
||||||
|
import type {
|
||||||
|
ActionRuleSet,
|
||||||
|
ConditionExpr,
|
||||||
|
EntityRole,
|
||||||
|
SceneRulebook,
|
||||||
|
} from "./contracts/rulebook";
|
||||||
|
import type { ValidationResult } from "./contracts/validation";
|
||||||
|
import type { WorldState } from "./contracts/world";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal evaluation context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EvalContext {
|
||||||
|
action: Action;
|
||||||
|
worldState: WorldState;
|
||||||
|
/** Entity IDs that will be created by introduce actions earlier in this turn. */
|
||||||
|
willBeCreated: Set<string>;
|
||||||
|
entities: Record<EntityRole, Entity | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntities(
|
||||||
|
action: Action,
|
||||||
|
worldState: WorldState
|
||||||
|
): Record<EntityRole, Entity | undefined> {
|
||||||
|
const actor = worldState.entities[action.actorId];
|
||||||
|
const target = action.targetId ? worldState.entities[action.targetId] : undefined;
|
||||||
|
const actorRoom = actor
|
||||||
|
? worldState.entities[String(actor.attributes.location ?? "")]
|
||||||
|
: undefined;
|
||||||
|
const targetRoom = target
|
||||||
|
? worldState.entities[String(target.attributes.location ?? "")]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return { actor, target, actorRoom, targetRoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a field from an entity — id/name/type are first-class; anything else reads from attributes. */
|
||||||
|
function getEntityField(entity: Entity, attribute: string): unknown {
|
||||||
|
if (attribute === "id") return entity.id;
|
||||||
|
if (attribute === "name") return entity.name;
|
||||||
|
if (attribute === "type") return entity.type;
|
||||||
|
return entity.attributes[attribute];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Condition evaluator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean {
|
||||||
|
switch (expr.op) {
|
||||||
|
case "and":
|
||||||
|
return expr.conditions.every((c) => evaluate(c, ctx));
|
||||||
|
|
||||||
|
case "or":
|
||||||
|
return expr.conditions.some((c) => evaluate(c, ctx));
|
||||||
|
|
||||||
|
case "not":
|
||||||
|
return !evaluate(expr.condition, ctx);
|
||||||
|
|
||||||
|
case "entityExists": {
|
||||||
|
return ctx.entities[expr.role] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "entityExistsOrWillBeCreated": {
|
||||||
|
const entity = ctx.entities[expr.role];
|
||||||
|
if (entity) return true;
|
||||||
|
const roleId =
|
||||||
|
expr.role === "target" ? ctx.action.targetId : undefined;
|
||||||
|
return roleId !== undefined && ctx.willBeCreated.has(roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "entityType": {
|
||||||
|
const entity = ctx.entities[expr.role];
|
||||||
|
if (!entity) return false;
|
||||||
|
return entity.type === expr.requiredType;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "eq": {
|
||||||
|
const entity = ctx.entities[expr.role];
|
||||||
|
if (!entity) return false;
|
||||||
|
return getEntityField(entity, expr.attribute) === expr.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "neq": {
|
||||||
|
const entity = ctx.entities[expr.role];
|
||||||
|
if (!entity) return false;
|
||||||
|
return getEntityField(entity, expr.attribute) !== expr.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "attributeExists": {
|
||||||
|
const entity = ctx.entities[expr.role];
|
||||||
|
if (!entity) return false;
|
||||||
|
return entity.attributes[expr.attribute] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sameLocation": {
|
||||||
|
const entityA = ctx.entities[expr.roleA];
|
||||||
|
const entityB = ctx.entities[expr.roleB];
|
||||||
|
if (!entityA || !entityB) return false;
|
||||||
|
const locA = String(entityA.attributes.location ?? "");
|
||||||
|
const locB = String(entityB.attributes.location ?? "");
|
||||||
|
return locA !== "" && locA === locB;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "actorIdIn": {
|
||||||
|
return expr.allowedIds.includes(ctx.action.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "actorNameIn": {
|
||||||
|
const actor = ctx.entities.actor;
|
||||||
|
if (!actor) return false;
|
||||||
|
const actorName = actor.name.trim().toLowerCase();
|
||||||
|
return expr.allowedNames.some((name) => name.trim().toLowerCase() === actorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "attributeRef": {
|
||||||
|
const checkEntity = ctx.entities[expr.checkRole];
|
||||||
|
const refEntity = ctx.entities[expr.refRole];
|
||||||
|
if (!checkEntity || !refEntity) return false;
|
||||||
|
const refValue = String(refEntity.attributes[expr.refAttribute] ?? "");
|
||||||
|
if (!refValue) return false;
|
||||||
|
const dynamicKey = (expr.prefix ?? "") + refValue;
|
||||||
|
return checkEntity.attributes[dynamicKey] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "metaValueNotInRoom": {
|
||||||
|
// Passes when: no target exists yet (new-character path) AND no entity
|
||||||
|
// of the given type in the actor's room already has the same name.
|
||||||
|
if (ctx.entities.target) {
|
||||||
|
// Target already exists — this check is not applicable (handled by
|
||||||
|
// introduce_not_already_present instead).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const metaValue = ctx.action.metadata?.[expr.metaKey];
|
||||||
|
if (typeof metaValue !== "string" || !metaValue.trim()) {
|
||||||
|
return true; // No name supplied → nothing to deduplicate.
|
||||||
|
}
|
||||||
|
const actor = ctx.entities.actor;
|
||||||
|
if (!actor) return true;
|
||||||
|
const actorLocation = String(actor.attributes.location ?? "");
|
||||||
|
const normalizedName = metaValue.trim().toLowerCase();
|
||||||
|
const hasDuplicate = Object.values(ctx.worldState.entities).some(
|
||||||
|
(e) =>
|
||||||
|
e.type === expr.entityType &&
|
||||||
|
String(e.attributes.location ?? "") === actorLocation &&
|
||||||
|
e.name.trim().toLowerCase() === normalizedName
|
||||||
|
);
|
||||||
|
return !hasDuplicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness guard — TypeScript will warn if a new op is added
|
||||||
|
// to ConditionExpr without a case here.
|
||||||
|
const exhaustiveCheck: never = expr;
|
||||||
|
console.warn("rulebookEngine: unhandled condition op", exhaustiveCheck);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Message template resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveMessage(
|
||||||
|
template: string,
|
||||||
|
action: Action,
|
||||||
|
worldState: WorldState
|
||||||
|
): string {
|
||||||
|
const actor = worldState.entities[action.actorId];
|
||||||
|
const target = action.targetId ? worldState.entities[action.targetId] : undefined;
|
||||||
|
return template
|
||||||
|
.replace(/\{actor\.id\}/g, action.actorId)
|
||||||
|
.replace(/\{actor\.name\}/g, actor?.name ?? action.actorId)
|
||||||
|
.replace(/\{target\.id\}/g, action.targetId ?? "(missing)")
|
||||||
|
.replace(/\{target\.name\}/g, target?.name ?? action.targetId ?? "(missing)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pre-pass: collect entity IDs that will be created by introduce actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function collectWillBeCreated(
|
||||||
|
actions: Action[],
|
||||||
|
worldState: WorldState
|
||||||
|
): Set<string> {
|
||||||
|
const willBeCreated = new Set<string>();
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.type !== "introduce") continue;
|
||||||
|
if (worldState.entities[action.targetId ?? ""]) continue; // target already exists
|
||||||
|
|
||||||
|
const characterName =
|
||||||
|
typeof action.metadata?.characterName === "string"
|
||||||
|
? action.metadata.characterName.trim()
|
||||||
|
: typeof action.metadata?.displayName === "string"
|
||||||
|
? action.metadata.displayName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!characterName) continue;
|
||||||
|
|
||||||
|
// Mirror the ID scheme used in applyActions.ts createCharacterId / slugify.
|
||||||
|
const slug = characterName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "") || "character";
|
||||||
|
willBeCreated.add(`character_${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return willBeCreated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a list of actions against a SceneRulebook.
|
||||||
|
*
|
||||||
|
* - If an action's type has no matching ActionRuleSet, it fails as "unknown_action".
|
||||||
|
* - If a matching ActionRuleSet has enabled: false, all checks are skipped and
|
||||||
|
* the action passes (useful for temporarily disabling enforcement).
|
||||||
|
* - Checks run in order; the first failing check short-circuits the rest.
|
||||||
|
*/
|
||||||
|
export function validateWithRulebook(
|
||||||
|
actions: Action[],
|
||||||
|
worldState: WorldState,
|
||||||
|
rulebook: SceneRulebook
|
||||||
|
): ValidationResult[] {
|
||||||
|
const ruleIndex = new Map<string, ActionRuleSet>();
|
||||||
|
for (const ruleSet of rulebook.rules) {
|
||||||
|
ruleIndex.set(ruleSet.actionType, ruleSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
const willBeCreated = collectWillBeCreated(actions, worldState);
|
||||||
|
|
||||||
|
return actions.map((action, actionIndex): ValidationResult => {
|
||||||
|
const actor = worldState.entities[action.actorId];
|
||||||
|
if (!actor) {
|
||||||
|
return {
|
||||||
|
actionIndex,
|
||||||
|
success: false,
|
||||||
|
reason: "actor_not_found",
|
||||||
|
message: `Actor '${action.actorId}' does not exist.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleSet = ruleIndex.get(action.type);
|
||||||
|
if (!ruleSet) {
|
||||||
|
return {
|
||||||
|
actionIndex,
|
||||||
|
success: false,
|
||||||
|
reason: "unknown_action",
|
||||||
|
message: `Action type '${action.type}' is not supported.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ruleSet.enabled) {
|
||||||
|
return { actionIndex, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: EvalContext = {
|
||||||
|
action,
|
||||||
|
worldState,
|
||||||
|
willBeCreated,
|
||||||
|
entities: resolveEntities(action, worldState),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const check of ruleSet.checks) {
|
||||||
|
const passes = evaluate(check.condition, ctx);
|
||||||
|
if (!passes) {
|
||||||
|
return {
|
||||||
|
actionIndex,
|
||||||
|
success: false,
|
||||||
|
reason: check.failReason,
|
||||||
|
message: resolveMessage(check.failMessage, action, worldState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { actionIndex, success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,125 +1,21 @@
|
|||||||
import type { Action } from "./contracts/action";
|
import type { Action } from "./contracts/action";
|
||||||
import type { Entity } from "./contracts/entity";
|
import type { SceneRulebook } from "./contracts/rulebook";
|
||||||
import type { ValidationResult } from "./contracts/validation";
|
import type { ValidationResult } from "./contracts/validation";
|
||||||
import type { WorldState } from "./contracts/world";
|
import type { WorldState } from "./contracts/world";
|
||||||
|
import { createDefaultRulebook } from "./defaultRulebook";
|
||||||
|
import { validateWithRulebook } from "./rulebookEngine";
|
||||||
|
|
||||||
function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined {
|
/**
|
||||||
if (!entityId) {
|
* Validate a list of parsed actions against the world state.
|
||||||
return undefined;
|
*
|
||||||
}
|
* Pass a SceneRulebook to use data-driven scene rules.
|
||||||
return worldState.entities[entityId];
|
* Falls back to the built-in default rulebook when none is provided.
|
||||||
}
|
*/
|
||||||
|
export function validateActions(
|
||||||
function hasKey(actor: Entity, requiredKeyId: string): boolean {
|
actions: Action[],
|
||||||
return actor.attributes[`has_${requiredKeyId}`] === true;
|
worldState: WorldState,
|
||||||
}
|
rulebook?: SceneRulebook
|
||||||
|
): ValidationResult[] {
|
||||||
export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] {
|
const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
|
||||||
return actions.map((action, actionIndex): ValidationResult => {
|
return validateWithRulebook(actions, worldState, activeRulebook);
|
||||||
const actor = getEntity(worldState, action.actorId);
|
|
||||||
if (!actor) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "actor_not_found",
|
|
||||||
message: `Actor '${action.actorId}' does not exist.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action.type) {
|
|
||||||
case "inspect":
|
|
||||||
return { actionIndex, success: true };
|
|
||||||
|
|
||||||
case "take": {
|
|
||||||
const target = getEntity(worldState, action.targetId);
|
|
||||||
if (!target) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "target_not_found",
|
|
||||||
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorLocation = String(actor.attributes.location ?? "");
|
|
||||||
const targetLocation = String(target.attributes.location ?? "");
|
|
||||||
if (actorLocation !== targetLocation) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "not_in_same_location",
|
|
||||||
message: `Target '${target.id}' is not in the same location as '${actor.id}'.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.attributes.takeable !== true) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "not_takeable",
|
|
||||||
message: `Target '${target.id}' cannot be taken.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { actionIndex, success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
case "open": {
|
|
||||||
const target = getEntity(worldState, action.targetId);
|
|
||||||
if (!target) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "target_not_found",
|
|
||||||
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.attributes.openable !== true) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "not_openable",
|
|
||||||
message: `Target '${target.id}' is not openable.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.attributes.locked === true) {
|
|
||||||
const requiredKey = String(target.attributes.requiredKey ?? "key_1");
|
|
||||||
if (!hasKey(actor, requiredKey)) {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "locked_requires_key",
|
|
||||||
message: `Target '${target.id}' is locked and requires '${requiredKey}'.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { actionIndex, success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
case "move": {
|
|
||||||
const target = getEntity(worldState, action.targetId);
|
|
||||||
if (!target || target.type !== "room") {
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "target_not_found",
|
|
||||||
message: `Move target '${action.targetId ?? "(missing)"}' is not a valid room.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { actionIndex, success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
actionIndex,
|
|
||||||
success: false,
|
|
||||||
reason: "unknown_action",
|
|
||||||
message: `Action type '${action.type}' is not supported.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
import type { CharacterGardenDatabase } from "../db";
|
import type { CharacterGardenDatabase } from "../db";
|
||||||
import type { Action } from "../contracts/action";
|
import type { Action } from "../contracts/action";
|
||||||
|
import type { SceneRulebook } from "../contracts/rulebook";
|
||||||
import type { Turn } from "../contracts/turn";
|
import type { Turn } from "../contracts/turn";
|
||||||
import type { ValidationResult } from "../contracts/validation";
|
import type { ValidationResult } from "../contracts/validation";
|
||||||
import type { WorldState } from "../contracts/world";
|
import type { WorldState } from "../contracts/world";
|
||||||
@@ -19,10 +20,11 @@ export type ProcessTurnResponse = {
|
|||||||
export function processTurn(
|
export function processTurn(
|
||||||
rawText: string,
|
rawText: string,
|
||||||
worldState: WorldState,
|
worldState: WorldState,
|
||||||
db: CharacterGardenDatabase
|
db: CharacterGardenDatabase,
|
||||||
|
rulebook?: SceneRulebook
|
||||||
): ProcessTurnResponse {
|
): ProcessTurnResponse {
|
||||||
const actions = parseTextToActions(rawText);
|
const actions = parseTextToActions(rawText);
|
||||||
const validation = validateActions(actions, worldState);
|
const validation = validateActions(actions, worldState, rulebook);
|
||||||
const nextWorldState = applyActions(actions, validation, worldState);
|
const nextWorldState = applyActions(actions, validation, worldState);
|
||||||
|
|
||||||
const turn: Turn = {
|
const turn: Turn = {
|
||||||
|
|||||||
@@ -21,6 +21,46 @@ function cloneWorldState(worldState: WorldState): WorldState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "") || "character";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCharacterId(worldState: WorldState, baseName: string): string {
|
||||||
|
const baseId = `character_${slugify(baseName)}`;
|
||||||
|
if (!worldState.entities[baseId]) {
|
||||||
|
return baseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = 2;
|
||||||
|
while (worldState.entities[`${baseId}_${suffix}`]) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseId}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionCharacterName(action: Action): string | undefined {
|
||||||
|
const displayName = action.metadata?.displayName;
|
||||||
|
if (typeof displayName === "string" && displayName.trim()) {
|
||||||
|
return displayName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterName = action.metadata?.characterName;
|
||||||
|
if (typeof characterName === "string" && characterName.trim()) {
|
||||||
|
return characterName
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyActions(
|
export function applyActions(
|
||||||
actions: Action[],
|
actions: Action[],
|
||||||
results: ValidationResult[],
|
results: ValidationResult[],
|
||||||
@@ -60,6 +100,43 @@ export function applyActions(
|
|||||||
target.attributes.open = true;
|
target.attributes.open = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "introduce":
|
||||||
|
if (actor && target) {
|
||||||
|
target.attributes.location = actor.attributes.location;
|
||||||
|
target.attributes.in_scene = true;
|
||||||
|
target.attributes.last_introduced_by = actor.id;
|
||||||
|
} else if (actor) {
|
||||||
|
const characterName = getActionCharacterName(action);
|
||||||
|
if (!characterName) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterId = createCharacterId(nextState, characterName);
|
||||||
|
nextState.entities[characterId] = {
|
||||||
|
id: characterId,
|
||||||
|
name: characterName,
|
||||||
|
type: "character",
|
||||||
|
attributes: {
|
||||||
|
location: actor.attributes.location,
|
||||||
|
is_social: true,
|
||||||
|
in_scene: true,
|
||||||
|
created_by_action: "introduce",
|
||||||
|
last_introduced_by: actor.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "describe":
|
||||||
|
if (target) {
|
||||||
|
const trait = action.metadata?.trait;
|
||||||
|
if (typeof trait === "string" && trait.trim()) {
|
||||||
|
const traits = Array.isArray(target.attributes.traits)
|
||||||
|
? target.attributes.traits
|
||||||
|
: [];
|
||||||
|
target.attributes.traits = [...traits, trait.trim()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "inspect":
|
case "inspect":
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -48,6 +48,433 @@ type ProcessTurnResponse = {
|
|||||||
worldState: WorldState;
|
worldState: WorldState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuleCheck = {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
condition: unknown;
|
||||||
|
failReason: string;
|
||||||
|
failMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActionRuleSet = {
|
||||||
|
actionType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
checks: RuleCheck[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SceneRulebook = {
|
||||||
|
id: string;
|
||||||
|
worldId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
rules: ActionRuleSet[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const starterPrompts = [
|
||||||
|
"look around",
|
||||||
|
"take key",
|
||||||
|
"open door",
|
||||||
|
"move to exit",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(input, init);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rulebook editor component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RulebookEditor() {
|
||||||
|
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchJson<SceneRulebook>("/api/rulebook").then((rb) => {
|
||||||
|
setRulebook(rb);
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const ruleSet of rb.rules) {
|
||||||
|
initial[ruleSet.actionType] = JSON.stringify(ruleSet.checks, null, 2);
|
||||||
|
}
|
||||||
|
setDrafts(initial);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleEnabled(actionType: string) {
|
||||||
|
if (!rulebook) return;
|
||||||
|
setRulebook({
|
||||||
|
...rulebook,
|
||||||
|
rules: rulebook.rules.map((r) =>
|
||||||
|
r.actionType === actionType ? { ...r, enabled: !r.enabled } : r
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraft(actionType: string, value: string) {
|
||||||
|
setDrafts((prev) => ({ ...prev, [actionType]: value }));
|
||||||
|
setParseErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[actionType];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRulebook() {
|
||||||
|
if (!rulebook) return;
|
||||||
|
setSaveError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
// Validate and apply all drafts.
|
||||||
|
const updatedRules: ActionRuleSet[] = [];
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const ruleSet of rulebook.rules) {
|
||||||
|
const draft = drafts[ruleSet.actionType] ?? JSON.stringify(ruleSet.checks, null, 2);
|
||||||
|
try {
|
||||||
|
const parsedChecks = JSON.parse(draft) as RuleCheck[];
|
||||||
|
updatedRules.push({ ...ruleSet, checks: parsedChecks });
|
||||||
|
} catch {
|
||||||
|
newErrors[ruleSet.actionType] = "Invalid JSON";
|
||||||
|
updatedRules.push(ruleSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setParseErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await fetchJson<SceneRulebook>("/api/rulebook", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
|
||||||
|
});
|
||||||
|
setRulebook(updated);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2500);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rulebook) return <p>Loading rulebook…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rulebook-editor">
|
||||||
|
<div className="rulebook-header">
|
||||||
|
<div>
|
||||||
|
<p className="rulebook-name">{rulebook.name}</p>
|
||||||
|
{rulebook.description ? (
|
||||||
|
<p className="rulebook-desc">{rulebook.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveRulebook()}
|
||||||
|
disabled={saving}
|
||||||
|
className="save-btn"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : saved ? "Saved" : "Save rulebook"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError ? <p className="error-banner">{saveError}</p> : null}
|
||||||
|
|
||||||
|
<div className="rule-list">
|
||||||
|
{rulebook.rules.map((ruleSet) => (
|
||||||
|
<details key={ruleSet.actionType} className="rule-panel">
|
||||||
|
<summary className="rule-summary">
|
||||||
|
<span className="rule-action-type">{ruleSet.actionType}</span>
|
||||||
|
<span className="rule-check-count">{ruleSet.checks.length} check{ruleSet.checks.length !== 1 ? "s" : ""}</span>
|
||||||
|
<label
|
||||||
|
className="rule-toggle"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ruleSet.enabled}
|
||||||
|
onChange={() => toggleEnabled(ruleSet.actionType)}
|
||||||
|
/>
|
||||||
|
{ruleSet.enabled ? "enforced" : "disabled"}
|
||||||
|
</label>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="rule-body">
|
||||||
|
<p className="rule-hint">
|
||||||
|
Edit the checks array below. Each check has: <code>id</code>, <code>description</code>, <code>condition</code>, <code>failReason</code>, <code>failMessage</code>.
|
||||||
|
</p>
|
||||||
|
<p className="rule-hint">
|
||||||
|
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
|
||||||
|
</p>
|
||||||
|
{ruleSet.checks.length > 0 ? (
|
||||||
|
<ul className="check-list">
|
||||||
|
{ruleSet.checks.map((check) => (
|
||||||
|
<li key={check.id} title={check.failReason}>
|
||||||
|
<span className="check-desc">{check.description}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rule-hint">No checks — action always passes.</p>
|
||||||
|
)}
|
||||||
|
<label className="json-label">
|
||||||
|
Checks JSON
|
||||||
|
<textarea
|
||||||
|
className={`json-editor${parseErrors[ruleSet.actionType] ? " json-error" : ""}`}
|
||||||
|
value={drafts[ruleSet.actionType] ?? ""}
|
||||||
|
onChange={(e) => updateDraft(ruleSet.actionType, e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{parseErrors[ruleSet.actionType] ? (
|
||||||
|
<p className="error-banner">{parseErrors[ruleSet.actionType]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main app
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
|
||||||
|
const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
|
||||||
|
const [input, setInput] = useState("look around");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<"world" | "rulebook">("world");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchJson<AppSnapshot>("/api/state")
|
||||||
|
.then((data) => {
|
||||||
|
setSnapshot(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((fetchError: Error) => {
|
||||||
|
setError(fetchError.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ input }),
|
||||||
|
});
|
||||||
|
setLatest(result);
|
||||||
|
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
|
||||||
|
setSnapshot(nextSnapshot);
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReset() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await fetchJson<AppSnapshot>("/api/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
setSnapshot(result);
|
||||||
|
setLatest(null);
|
||||||
|
} catch (resetError) {
|
||||||
|
setError(resetError instanceof Error ? resetError.message : "Unknown error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<section className="hero-panel">
|
||||||
|
<p className="eyebrow">CharacterGarden</p>
|
||||||
|
<h1>Bootable narrative sandbox</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Submit a turn, inspect world state, and verify how the truth engine is mutating state.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="turn-form" onSubmit={onSubmit}>
|
||||||
|
<label htmlFor="turn-input">Turn input</label>
|
||||||
|
<textarea
|
||||||
|
id="turn-input"
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Type what the player does..."
|
||||||
|
/>
|
||||||
|
<div className="actions-row">
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Submitting..." : "Run turn"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="chip" onClick={onReset}>
|
||||||
|
Reset world
|
||||||
|
</button>
|
||||||
|
<div className="chips">
|
||||||
|
{starterPrompts.map((prompt) => (
|
||||||
|
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{latest ? (
|
||||||
|
<section className="result-card">
|
||||||
|
<h2>Latest result</h2>
|
||||||
|
<p><strong>Input:</strong> {latest.rawText}</p>
|
||||||
|
<ul className="timeline-list compact">
|
||||||
|
{latest.validation.map((v) => (
|
||||||
|
<li key={v.actionIndex}>
|
||||||
|
Action {v.actionIndex}: {v.success ? "ok" : `failed (${v.reason ?? "unknown"})`}
|
||||||
|
{v.message ? ` - ${v.message}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="error-banner">{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="inspector-grid">
|
||||||
|
<nav className="tab-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tab-btn${tab === "world" ? " active" : ""}`}
|
||||||
|
onClick={() => setTab("world")}
|
||||||
|
>
|
||||||
|
World inspector
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tab-btn${tab === "rulebook" ? " active" : ""}`}
|
||||||
|
onClick={() => setTab("rulebook")}
|
||||||
|
>
|
||||||
|
Rulebook
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === "world" ? (
|
||||||
|
<>
|
||||||
|
<article className="panel">
|
||||||
|
<h2>World state</h2>
|
||||||
|
{loading && !snapshot ? <p>Loading...</p> : null}
|
||||||
|
<ul className="entity-list">
|
||||||
|
{entities.map((entity) => (
|
||||||
|
<li key={entity.id}>
|
||||||
|
<strong>{entity.name}</strong> <span>{entity.type}</span>
|
||||||
|
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<h2>Turn log</h2>
|
||||||
|
<ul className="timeline-list">
|
||||||
|
{snapshot?.turns.slice().reverse().map((turn) => (
|
||||||
|
<li key={turn.id}>
|
||||||
|
<strong>{turn.rawText}</strong>
|
||||||
|
{turn.validation.map((v) => (
|
||||||
|
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<article className="panel panel--full">
|
||||||
|
<h2>Rulebook editor</h2>
|
||||||
|
<RulebookEditor />
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entity = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
actorId: string;
|
||||||
|
type: string;
|
||||||
|
targetId?: string;
|
||||||
|
locationId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidationResult = {
|
||||||
|
actionIndex: number;
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Turn = {
|
||||||
|
id: string;
|
||||||
|
rawText: string;
|
||||||
|
actions: Action[];
|
||||||
|
validation: ValidationResult[];
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorldState = {
|
||||||
|
id: string;
|
||||||
|
entities: Record<string, Entity>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppSnapshot = {
|
||||||
|
worldState: WorldState;
|
||||||
|
turns: Turn[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProcessTurnResponse = {
|
||||||
|
rawText: string;
|
||||||
|
actions: Action[];
|
||||||
|
validation: ValidationResult[];
|
||||||
|
worldState: WorldState;
|
||||||
|
};
|
||||||
|
|
||||||
const starterPrompts = [
|
const starterPrompts = [
|
||||||
"look around",
|
"look around",
|
||||||
"take key",
|
"take key",
|
||||||
|
|||||||
@@ -176,6 +176,191 @@ pre {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Tab bar
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
border: 1px solid rgba(244, 239, 228, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: rgba(244, 239, 228, 0.05);
|
||||||
|
color: rgba(244, 239, 228, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: rgba(244, 239, 228, 0.12);
|
||||||
|
color: #f4efe4;
|
||||||
|
border-color: rgba(244, 239, 228, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Rulebook editor
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.rulebook-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulebook-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulebook-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulebook-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(244, 239, 228, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(135deg, #d8b16f, #a96c36);
|
||||||
|
color: #1d1b17;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-panel {
|
||||||
|
border: 1px solid rgba(244, 239, 228, 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-action-type {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #dcbf8d;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-check-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(244, 239, 228, 0.5);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(244, 239, 228, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-body {
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid rgba(244, 239, 228, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(244, 239, 228, 0.5);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-hint code {
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
color: #dcbf8d;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list li {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-desc {
|
||||||
|
color: rgba(244, 239, 228, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(244, 239, 228, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(244, 239, 228, 0.14);
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: #cfd9c2;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor.json-error {
|
||||||
|
border-color: #ffd2b8;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.inspector-grid {
|
.inspector-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
559
project.md
559
project.md
@@ -1,355 +1,364 @@
|
|||||||
# CharacterGarden — System Bible (MVP Architecture)
|
# CharacterGarden MVP — Strict Architecture
|
||||||
|
|
||||||
## Purpose
|
## 🧠 Core Principle
|
||||||
|
|
||||||
CharacterGarden is a deterministic roleplay and simulation framework.
|
> The system is a **deterministic simulation engine**.
|
||||||
|
> The LLM is **not a source of truth**. It is an **input/output translator only**.
|
||||||
The system separates:
|
|
||||||
|
|
||||||
* Natural language (LLM / user input)
|
|
||||||
* Structured intent (parsed actions)
|
|
||||||
* Truth validation (rules engine)
|
|
||||||
* World state (persistent simulation)
|
|
||||||
|
|
||||||
The system MUST remain deterministic at the core.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Principle
|
# 🧩 System Architecture
|
||||||
|
|
||||||
The LLM is NOT the source of truth.
|
User / LLM Input (Prose)
|
||||||
|
↓
|
||||||
The LLM is used ONLY for:
|
[Parser Layer]
|
||||||
|
↓
|
||||||
* Parsing natural language → structured actions
|
[Normalization Layer]
|
||||||
* Generating narrative output
|
↓
|
||||||
|
[Truth Engine (Validation)]
|
||||||
ALL validation, state changes, and rules are handled by deterministic code.
|
↓
|
||||||
|
[State Mutation Engine]
|
||||||
|
↓
|
||||||
|
[Persistence Layer]
|
||||||
|
↓
|
||||||
|
[LLM Output Generation]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## System Pipeline
|
# 🔒 Non-Negotiable Rules
|
||||||
|
|
||||||
All input MUST pass through the following pipeline:
|
1. Truth Engine MUST NEVER parse natural language
|
||||||
|
2. Only structured Actions may mutate world state
|
||||||
1. PROSE INPUT
|
3. All mutations must be validated before execution
|
||||||
|
4. World state is modified ONLY through engine functions
|
||||||
* Source: user or AI
|
5. LLM output is never trusted without validation
|
||||||
* Format: free text
|
6. Every turn must be fully traceable
|
||||||
|
|
||||||
2. PARSER LAYER
|
|
||||||
|
|
||||||
* Converts text → structured actions
|
|
||||||
* May use LLM or rule-based parsing
|
|
||||||
* Output MUST follow strict schema (see Action Contract)
|
|
||||||
|
|
||||||
3. ACTION CONTRACT (STRICT)
|
|
||||||
|
|
||||||
* Only valid structured objects allowed past this point
|
|
||||||
* No free text allowed beyond this stage
|
|
||||||
|
|
||||||
4. TRUTH ENGINE
|
|
||||||
|
|
||||||
* Deterministic validation of actions
|
|
||||||
* No LLM usage allowed
|
|
||||||
* Returns validation results
|
|
||||||
|
|
||||||
5. WORLD STATE UPDATE
|
|
||||||
|
|
||||||
* Applies valid actions
|
|
||||||
* Rejects or partially applies invalid ones
|
|
||||||
|
|
||||||
6. RESPONSE GENERATION
|
|
||||||
|
|
||||||
* LLM converts results into narrative output
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hard Rule
|
# 📦 Core Data Contracts
|
||||||
|
|
||||||
The Truth Engine MUST NEVER:
|
## Action (STRICT UNION TYPE)
|
||||||
|
|
||||||
* Parse natural language
|
|
||||||
* Infer missing intent
|
|
||||||
* Use probabilistic logic
|
|
||||||
* Call an LLM
|
|
||||||
|
|
||||||
If it does, the architecture is broken.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Action Contract (REQUIRED)
|
|
||||||
|
|
||||||
All actions MUST conform to this schema:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type Action = {
|
export type Action =
|
||||||
actorId: string;
|
| { type: "move"; actorId: string; targetId: string }
|
||||||
type: string;
|
| { type: "take"; actorId: string; targetId: string }
|
||||||
targetId?: string;
|
| { type: "open"; actorId: string; targetId: string }
|
||||||
locationId?: string;
|
| { type: "close"; actorId: string; targetId: string }
|
||||||
metadata?: Record<string, any>;
|
| { type: "use"; actorId: string; targetId: string; toolId?: string }
|
||||||
|
| { type: "speak"; actorId: string; content: string }
|
||||||
|
| { type: "introduce"; actorId: string; targetId?: string; metadata?: Record<string, unknown> };
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ DO NOT use generic `type: string` actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NormalizedAction
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type NormalizedAction = Action;
|
||||||
|
```
|
||||||
|
|
||||||
|
If invalid → reject BEFORE validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ValidationResult
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ValidationResult = {
|
||||||
|
success: boolean;
|
||||||
|
reasonCode:
|
||||||
|
| "OK"
|
||||||
|
| "NOT_FOUND"
|
||||||
|
| "NOT_PRESENT"
|
||||||
|
| "LOCKED"
|
||||||
|
| "INVALID_TARGET"
|
||||||
|
| "MISSING_REQUIREMENT"
|
||||||
|
| "OUT_OF_TURN"
|
||||||
|
| "UNKNOWN";
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
No additional fields allowed unless explicitly added here.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Turn Structure
|
## Turn (Traceable Execution Unit)
|
||||||
|
|
||||||
Each turn MUST be stored and processed as:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type Turn = {
|
export type Turn = {
|
||||||
id: string;
|
id: string;
|
||||||
rawText: string;
|
rawText: string;
|
||||||
actions: Action[];
|
|
||||||
validation: ValidationResult[];
|
parsedActions: unknown[];
|
||||||
createdAt: number;
|
normalizedActions: NormalizedAction[];
|
||||||
|
|
||||||
|
validationResults: ValidationResult[];
|
||||||
|
|
||||||
|
appliedActions: NormalizedAction[];
|
||||||
|
|
||||||
|
timestamp: number;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Validation Result Contract
|
# 🌍 World State (STRICT SCHEMA)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type ValidationResult = {
|
export type Entity = {
|
||||||
actionIndex: number;
|
|
||||||
success: boolean;
|
|
||||||
reason?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
* "not_your_turn"
|
|
||||||
* "object_not_found"
|
|
||||||
* "door_locked"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Required System Layers
|
|
||||||
|
|
||||||
### 1. Parser Layer
|
|
||||||
|
|
||||||
Function:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
parseTextToActions(text: string): Action[]
|
|
||||||
```
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
* Convert text → valid Action[]
|
|
||||||
* Resolve references ("he", "the door")
|
|
||||||
* May fail or return empty list
|
|
||||||
|
|
||||||
Allowed:
|
|
||||||
|
|
||||||
* LLM usage
|
|
||||||
* Heuristics
|
|
||||||
|
|
||||||
Not allowed:
|
|
||||||
|
|
||||||
* World state mutation
|
|
||||||
* Validation logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Truth Engine
|
|
||||||
|
|
||||||
Function:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
validateActions(actions: Action[], worldState: WorldState): ValidationResult[]
|
|
||||||
```
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
* Validate each action deterministically
|
|
||||||
* No mutation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. World State Engine
|
|
||||||
|
|
||||||
Function:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
applyActions(actions: Action[], results: ValidationResult[], worldState: WorldState): WorldState
|
|
||||||
```
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
* Apply ONLY valid actions
|
|
||||||
* Maintain consistency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## World State Requirements
|
|
||||||
|
|
||||||
World state MUST:
|
|
||||||
|
|
||||||
* Be serializable
|
|
||||||
* Be versionable
|
|
||||||
* Support rollback
|
|
||||||
* Support branching
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Requirements
|
|
||||||
|
|
||||||
Use SQLite.
|
|
||||||
|
|
||||||
Minimum tables:
|
|
||||||
|
|
||||||
* turns
|
|
||||||
* actions
|
|
||||||
* validation_results
|
|
||||||
* entities
|
|
||||||
* world_states
|
|
||||||
|
|
||||||
Each turn MUST be persisted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Entity System
|
|
||||||
|
|
||||||
Entities MUST have stable IDs.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type Entity = {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
locationId?: string;
|
||||||
attributes: Record<string, any>;
|
attributes?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Location = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
connectedTo: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorldState = {
|
||||||
|
entities: Record<string, Entity>;
|
||||||
|
locations: Record<string, Location>;
|
||||||
|
inventory: Record<string, string[]>; // actorId → itemIds
|
||||||
|
flags: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Parser MUST resolve references to entity IDs.
|
---
|
||||||
|
|
||||||
|
# 🧠 System Layers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Failure Handling
|
## 1. Parser Layer (`parser/`)
|
||||||
|
|
||||||
Failure is FIRST-CLASS.
|
**Responsibility:**
|
||||||
|
- Convert prose → rough actions
|
||||||
|
- May use LLM
|
||||||
|
|
||||||
Example:
|
**Output:**
|
||||||
|
```ts
|
||||||
|
unknown[]
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Parser output is NOT trusted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Normalization Layer (`parser/normalizeActions.ts`)
|
||||||
|
|
||||||
|
**Responsibility:**
|
||||||
|
- Enforce schema
|
||||||
|
- Resolve references (`he` → `john`)
|
||||||
|
- Fill missing fields
|
||||||
|
- Reject invalid structures
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```ts
|
||||||
|
unknown[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```ts
|
||||||
|
NormalizedAction[]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Truth Engine (`engine/validate.ts`)
|
||||||
|
|
||||||
|
**Responsibility:**
|
||||||
|
- Determine if action is valid
|
||||||
|
- MUST be deterministic
|
||||||
|
- MUST NOT mutate state
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```ts
|
||||||
|
NormalizedAction + WorldState
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```ts
|
||||||
|
ValidationResult
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mutation Engine (`engine/apply.ts`)
|
||||||
|
|
||||||
|
**Responsibility:**
|
||||||
|
- Apply ONLY successful actions
|
||||||
|
- Mutate world state
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```ts
|
||||||
|
NormalizedAction + WorldState
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```ts
|
||||||
|
WorldState
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Persistence Layer (`storage/`)
|
||||||
|
|
||||||
|
**Responsibility:**
|
||||||
|
- Store:
|
||||||
|
- world state
|
||||||
|
- turns
|
||||||
|
- history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔄 Turn Execution Pipeline
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
function processTurn(rawText: string): Turn {
|
||||||
success: false,
|
const parsed = parse(rawText);
|
||||||
reason: "door_locked",
|
|
||||||
message: "The door cannot be opened."
|
const normalized = normalize(parsed);
|
||||||
|
|
||||||
|
const validationResults = normalized.map(action =>
|
||||||
|
validate(action, worldState)
|
||||||
|
);
|
||||||
|
|
||||||
|
const successfulActions = normalized.filter((_, i) =>
|
||||||
|
validationResults[i].success
|
||||||
|
);
|
||||||
|
|
||||||
|
const newState = apply(successfulActions, worldState);
|
||||||
|
|
||||||
|
const turn: Turn = {
|
||||||
|
id: generateId(),
|
||||||
|
rawText,
|
||||||
|
parsedActions: parsed,
|
||||||
|
normalizedActions: normalized,
|
||||||
|
validationResults,
|
||||||
|
appliedActions: successfulActions,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
persist(turn, newState);
|
||||||
|
|
||||||
|
return turn;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The system MUST:
|
---
|
||||||
|
|
||||||
* Return failures clearly
|
# 🧠 Reference Resolution Rules
|
||||||
* Allow LLM to narrate failures
|
|
||||||
* NOT silently fix invalid actions
|
Handled in normalization layer.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Input | Output |
|
||||||
|
|-------------|--------------|
|
||||||
|
| "he" | actorId |
|
||||||
|
| "the door" | door_1 |
|
||||||
|
| "my key" | key_owned_by_actor |
|
||||||
|
|
||||||
|
Must be:
|
||||||
|
- deterministic
|
||||||
|
- context-aware
|
||||||
|
- testable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## LLM Adapter Rules
|
# 🧪 Debug & Traceability (REQUIRED)
|
||||||
|
|
||||||
The LLM Adapter MUST:
|
Every turn MUST store:
|
||||||
|
|
||||||
* Never mutate world state
|
- raw input
|
||||||
* Never validate actions
|
- parsed output
|
||||||
* Only transform data
|
- normalized actions
|
||||||
|
- validation results
|
||||||
|
- applied actions
|
||||||
|
|
||||||
|
This enables:
|
||||||
|
- replay
|
||||||
|
- debugging
|
||||||
|
- AI correction
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Phases
|
# ⚙️ Initial Action Rules (MVP)
|
||||||
|
|
||||||
### Phase 1 — Contract Enforcement (CURRENT)
|
## move
|
||||||
|
- actor must exist
|
||||||
|
- target must be connected location
|
||||||
|
|
||||||
* Define Action, Turn, ValidationResult
|
## take
|
||||||
* Refactor all code to use contracts
|
- item must be in same location
|
||||||
* Remove any free-text logic from truth engine
|
- item not already owned
|
||||||
|
|
||||||
|
## open
|
||||||
|
- target must exist
|
||||||
|
- if locked → fail unless key present
|
||||||
|
|
||||||
|
## introduce
|
||||||
|
- creates entity OR brings into scene
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2 — Minimal Truth Engine
|
# 🚫 Anti-Patterns (DO NOT DO)
|
||||||
|
|
||||||
Implement test domain:
|
❌ Let LLM mutate state
|
||||||
|
❌ Store unvalidated actions
|
||||||
* Tic-tac-toe OR simple door system
|
❌ Use dynamic/untyped actions
|
||||||
|
❌ Skip normalization
|
||||||
Goal:
|
❌ Combine validation + mutation
|
||||||
|
❌ Allow hidden side effects
|
||||||
* Fully deterministic validation
|
|
||||||
* No LLM required for correctness
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3 — Parser Improvement
|
# 🚀 Future Extensions (Planned)
|
||||||
|
|
||||||
* Add LLM parsing
|
- Memory system (vector + summaries)
|
||||||
* Add reference resolution
|
- Belief vs truth separation
|
||||||
* Improve action extraction
|
- Multi-agent turns
|
||||||
|
- Time-based simulation
|
||||||
|
- Rule plugins per scenario
|
||||||
|
- UI action inspector
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4 — Memory System
|
# 🧠 Mental Model
|
||||||
|
|
||||||
* Persist entities
|
This system is:
|
||||||
* Track history
|
|
||||||
* Add retrieval support
|
> A deterministic simulation engine with LLM-based I/O
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
|
||||||
|
> A chatbot with memory
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Non-Goals (For Now)
|
# ✅ Definition of Done (MVP)
|
||||||
|
|
||||||
* No complex AI reasoning inside truth engine
|
- [ ] Actions fully typed
|
||||||
* No autonomous agents
|
- [ ] Normalization layer implemented
|
||||||
* No multi-agent planning
|
- [ ] Validation fully deterministic
|
||||||
|
- [ ] State mutation isolated
|
||||||
|
- [ ] Turn trace persisted
|
||||||
|
- [ ] Simple scenario (door + key) works
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## thoughts.md Requirement
|
# 💬 Guidance for Copilot
|
||||||
|
|
||||||
Copilot MUST maintain a file:
|
When generating code:
|
||||||
|
|
||||||
```
|
- Prefer explicit types over generic objects
|
||||||
/thoughts.md
|
- Avoid dynamic structures
|
||||||
```
|
- Keep functions pure where possible
|
||||||
|
- Do not introduce hidden state
|
||||||
After each major change, it MUST append:
|
- Follow pipeline strictly
|
||||||
|
|
||||||
* What was implemented
|
|
||||||
* Why it was implemented
|
|
||||||
* What assumptions were made
|
|
||||||
* What remains unclear
|
|
||||||
|
|
||||||
This is REQUIRED to maintain continuity across sessions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (MVP)
|
|
||||||
|
|
||||||
The system is complete when:
|
|
||||||
|
|
||||||
* A user can input text
|
|
||||||
* It is parsed into structured actions
|
|
||||||
* Actions are validated deterministically
|
|
||||||
* World state updates correctly
|
|
||||||
* A narrative response is generated
|
|
||||||
|
|
||||||
WITHOUT requiring the LLM for correctness.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final Rule
|
|
||||||
|
|
||||||
If any part of the system depends on the LLM to maintain logical correctness,
|
|
||||||
the architecture has failed.
|
|
||||||
|
|
||||||
The LLM is an interface layer, not a reasoning authority.
|
|
||||||
39
thoughts.md
39
thoughts.md
@@ -14,6 +14,12 @@
|
|||||||
- `open door` before key -> `locked_requires_key`
|
- `open door` before key -> `locked_requires_key`
|
||||||
- `take key` -> success
|
- `take key` -> success
|
||||||
- `open door` after key -> success
|
- `open door` after key -> success
|
||||||
|
- Scene-entry support added:
|
||||||
|
- `introduce` is now a first-class action
|
||||||
|
- rooms can declare `is_joinable`
|
||||||
|
- characters can declare `is_social`
|
||||||
|
- successful introduction moves a character from offstage into the actor's current room
|
||||||
|
- `introduce` can also create a new social character when the named target does not already exist
|
||||||
|
|
||||||
## Architecture Now
|
## Architecture Now
|
||||||
- Core contracts in `app/src/contracts/`:
|
- Core contracts in `app/src/contracts/`:
|
||||||
@@ -31,7 +37,36 @@
|
|||||||
- Persistence:
|
- Persistence:
|
||||||
- `app/src/db.ts` with tables `turns`, `actions`, `validation_results`, `entities`, `world_states`
|
- `app/src/db.ts` with tables `turns`, `actions`, `validation_results`, `entities`, `world_states`
|
||||||
- App seed domain:
|
- App seed domain:
|
||||||
- `app/src/app.ts` door/key world (`room_start`, `room_exit`, `player`, `door_1`, `key_1`)
|
- `app/src/app.ts` door/key world (`room_start`, `room_exit`, `room_offstage`, `player`, `groundskeeper`, `door_1`, `key_1`)
|
||||||
|
|
||||||
|
## Scene Entry Rules
|
||||||
|
- `introduce` validates against deterministic affordances:
|
||||||
|
- target must exist
|
||||||
|
- target must be a character
|
||||||
|
- target must have `is_social: true`
|
||||||
|
- actor must be in a valid room
|
||||||
|
- room must have `is_joinable: true`
|
||||||
|
- target must not already be in that room
|
||||||
|
- If no existing target entity is resolved but a character name is present, `introduce` may create a new character directly into the current room.
|
||||||
|
|
||||||
|
## Character Description (NEW)
|
||||||
|
- `describe` action adds traits to characters:
|
||||||
|
- Syntax: `"describe the merchant as shrewd and quick"`
|
||||||
|
- Traits are stored in `character.attributes.traits[]`
|
||||||
|
- Multi-sentence support: `"introduce a merchant. describe the merchant as shrewd and quick."`
|
||||||
|
- Validation rules:
|
||||||
|
- target character must exist (or will be created by introduce in same turn)
|
||||||
|
- actor and target must be in same room (for existing targets)
|
||||||
|
- supports forward-reference to entities created in the same turn
|
||||||
|
- Multi-action parsing:
|
||||||
|
- Sentences split on `/[.!?]+/`
|
||||||
|
- Each sentence becomes a separate action
|
||||||
|
- Validation accounts for entities created by introduce actions in the same turn
|
||||||
|
- On success:
|
||||||
|
- target location becomes the actor's location
|
||||||
|
- `in_scene` is set to `true`
|
||||||
|
- `last_introduced_by` is recorded
|
||||||
|
- newly created characters default to `type: character`, `is_social: true`, `in_scene: true`
|
||||||
|
|
||||||
## New Endpoint
|
## New Endpoint
|
||||||
- Added `POST /api/reset` in `app/src/index.ts`
|
- Added `POST /api/reset` in `app/src/index.ts`
|
||||||
@@ -50,5 +85,5 @@
|
|||||||
|
|
||||||
## Remaining Checks
|
## Remaining Checks
|
||||||
1. Frontend build pass completed in container (`docker compose exec frontend npm run build`).
|
1. Frontend build pass completed in container (`docker compose exec frontend npm run build`).
|
||||||
2. Validate inspector UX manually against the door/key flow.
|
2. Validate inspector UX manually against the door/key plus introduce flow.
|
||||||
3. Expand parser coverage only within current clean MVP domain.
|
3. Expand parser coverage only within current clean MVP domain.
|
||||||
|
|||||||
Reference in New Issue
Block a user