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:
2026-04-26 13:33:05 -04:00
parent 998635f542
commit ff9b86c3e9
16 changed files with 2013 additions and 412 deletions

Binary file not shown.

View File

@@ -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(),

View 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;
};

View File

@@ -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;
}; };

View File

@@ -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 });
},
}; };
} }

View 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}'.",
},
],
},
],
};
}

View File

@@ -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 });

View File

@@ -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;
} }

View 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 };
});
}

View File

@@ -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.`,
};
}
});
} }

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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.

View File

@@ -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.