From ff9b86c3e94a08e71fb71fe632b5d7b1f0e679b5 Mon Sep 17 00:00:00 2001 From: spencer Date: Sun, 26 Apr 2026 13:33:05 -0400 Subject: [PATCH] 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. --- charactergarden.zip | Bin 30161 -> 0 bytes charactergarden/app/src/app.ts | 132 ++++- charactergarden/app/src/contracts/rulebook.ts | 94 +++ charactergarden/app/src/contracts/world.ts | 2 + charactergarden/app/src/db.ts | 103 ++++ charactergarden/app/src/defaultRulebook.ts | 247 ++++++++ charactergarden/app/src/index.ts | 21 + .../app/src/parser/parseTextToActions.ts | 111 +++- charactergarden/app/src/rulebookEngine.ts | 286 +++++++++ charactergarden/app/src/truthEngine.ts | 136 +---- charactergarden/app/src/turns/processTurn.ts | 6 +- charactergarden/app/src/world/applyActions.ts | 77 +++ charactergarden/frontend/src/App.tsx | 427 +++++++++++++ charactergarden/frontend/src/styles.css | 185 ++++++ project.md | 559 +++++++++--------- thoughts.md | 39 +- 16 files changed, 2013 insertions(+), 412 deletions(-) delete mode 100644 charactergarden.zip create mode 100644 charactergarden/app/src/contracts/rulebook.ts create mode 100644 charactergarden/app/src/defaultRulebook.ts create mode 100644 charactergarden/app/src/rulebookEngine.ts diff --git a/charactergarden.zip b/charactergarden.zip deleted file mode 100644 index 3be168cac42269dbc95cf5cb920479d120876162..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30161 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 = { + 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: { id: "room_start", name: "Start Room", type: "room", attributes: { description: "A plain room with a locked door.", + is_joinable: true, }, }, room_exit: { @@ -36,6 +51,7 @@ function createSeedWorldState(): WorldState { type: "room", attributes: { description: "A simple room beyond the door.", + is_joinable: true, }, }, player: { @@ -47,6 +63,16 @@ function createSeedWorldState(): WorldState { has_key_1: false, }, }, + groundskeeper: { + id: "groundskeeper", + name: "Groundskeeper", + type: "character", + attributes: { + location: "room_offstage", + is_social: true, + in_scene: false, + }, + }, door_1: { id: "door_1", name: "Old Door", @@ -81,12 +107,71 @@ function createSeedWorldState(): WorldState { }; } +function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } { + const seed = createSeedWorldState(); + const mergedEntities: Record = {}; + 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 { db.init(); const latest = db.getLatestWorldState(); 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(); @@ -95,9 +180,34 @@ function ensureSeedState(db: CharacterGardenDatabase): WorldState { 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 { const db = createDatabase({ dbPath }); 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 { db, @@ -110,14 +220,32 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { }, processTurn(rawText: string) { - const result = processTurn(rawText, worldState, db); + const rulebook = loadActiveRulebook(); + const result = processTurn(rawText, worldState, db, rulebook); worldState = result.worldState; 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() { db.wipe(); worldState = ensureSeedState(db); + activeRulebookId = DEFAULT_RULEBOOK_ID; + ensureDefaultRulebook(db, worldState); return { worldState, turns: db.listTurns(), diff --git a/charactergarden/app/src/contracts/rulebook.ts b/charactergarden/app/src/contracts/rulebook.ts new file mode 100644 index 0000000..9b5f499 --- /dev/null +++ b/charactergarden/app/src/contracts/rulebook.ts @@ -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; +}; diff --git a/charactergarden/app/src/contracts/world.ts b/charactergarden/app/src/contracts/world.ts index aad00c9..bcfb98a 100644 --- a/charactergarden/app/src/contracts/world.ts +++ b/charactergarden/app/src/contracts/world.ts @@ -5,4 +5,6 @@ export type WorldState = { entities: Record; metadata: Record; createdAt: number; + /** ID of the SceneRulebook currently active for this world. */ + rulebookId?: string; }; diff --git a/charactergarden/app/src/db.ts b/charactergarden/app/src/db.ts index 15240b2..2ac3dda 100644 --- a/charactergarden/app/src/db.ts +++ b/charactergarden/app/src/db.ts @@ -4,6 +4,7 @@ import Database from "better-sqlite3"; import type { Action } from "./contracts/action"; import type { Entity } from "./contracts/entity"; +import type { SceneRulebook } from "./contracts/rulebook"; import type { Turn } from "./contracts/turn"; import type { ValidationResult } from "./contracts/validation"; import type { WorldState } from "./contracts/world"; @@ -24,6 +25,10 @@ export interface CharacterGardenDatabase { insertValidationResults(turnId: string, results: ValidationResult[]): void; insertWorldState(turnId: string | null, worldState: WorldState): void; getLatestWorldState(): WorldState | null; + upsertRulebook(rulebook: SceneRulebook): void; + getRulebook(id: string): SceneRulebook | null; + listRulebooks(): SceneRulebook[]; + deleteRulebook(id: string): void; wipe(): void; } @@ -88,6 +93,17 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase 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) { @@ -169,6 +185,32 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase LIMIT 1 `); + const upsertRulebookStatement = sqlite.prepare(` + INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at) + VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + rules_json = excluded.rules_json, + updated_at = excluded.updated_at + `); + + const getRulebookStatement = sqlite.prepare(` + SELECT id, world_id, name, description, rules_json, created_at, updated_at + FROM rulebooks + WHERE id = @id + `); + + const listRulebooksStatement = sqlite.prepare(` + SELECT id, world_id, name, description, rules_json, created_at, updated_at + FROM rulebooks + ORDER BY created_at ASC + `); + + const deleteRulebookStatement = sqlite.prepare(` + DELETE FROM rulebooks WHERE id = @id + `); + return { sqlite, @@ -296,5 +338,66 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase } return parseJson(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 }); + }, }; } diff --git a/charactergarden/app/src/defaultRulebook.ts b/charactergarden/app/src/defaultRulebook.ts new file mode 100644 index 0000000..d36ecfa --- /dev/null +++ b/charactergarden/app/src/defaultRulebook.ts @@ -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_ 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}'.", + }, + ], + }, + ], + }; +} diff --git a/charactergarden/app/src/index.ts b/charactergarden/app/src/index.ts index c2b738f..c53512c 100644 --- a/charactergarden/app/src/index.ts +++ b/charactergarden/app/src/index.ts @@ -1,6 +1,7 @@ import Fastify from "fastify"; import { createCharacterGardenApp } from "./app"; +import type { SceneRulebook } from "./contracts/rulebook"; const port = Number(process.env.APP_PORT ?? 3000); 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); }); +// --------------------------------------------------------------------------- +// 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 { try { await server.listen({ host, port }); diff --git a/charactergarden/app/src/parser/parseTextToActions.ts b/charactergarden/app/src/parser/parseTextToActions.ts index 330b4d8..7d7b60d 100644 --- a/charactergarden/app/src/parser/parseTextToActions.ts +++ b/charactergarden/app/src/parser/parseTextToActions.ts @@ -4,39 +4,128 @@ function normalized(input: string): string { return input.trim().toLowerCase(); } -export function parseTextToActions(text: string, actorId = "player"): Action[] { - const input = normalized(text); +function toDisplayName(value: string): string { + 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) { - return []; + return undefined; } 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 (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")) { - return [{ actorId, type: "move", targetId: "room_start" }]; + return { actorId: defaultActorId, type: "move", targetId: "room_start" }; } - return []; + return undefined; } if (/(open)/.test(input)) { 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 (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 []; + // 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; } + + diff --git a/charactergarden/app/src/rulebookEngine.ts b/charactergarden/app/src/rulebookEngine.ts new file mode 100644 index 0000000..4c7609f --- /dev/null +++ b/charactergarden/app/src/rulebookEngine.ts @@ -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; + entities: Record; +} + +function resolveEntities( + action: Action, + worldState: WorldState +): Record { + 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 { + const willBeCreated = new Set(); + + 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(); + 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 }; + }); +} diff --git a/charactergarden/app/src/truthEngine.ts b/charactergarden/app/src/truthEngine.ts index 26b980d..c2526a7 100644 --- a/charactergarden/app/src/truthEngine.ts +++ b/charactergarden/app/src/truthEngine.ts @@ -1,125 +1,21 @@ 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 { WorldState } from "./contracts/world"; +import { createDefaultRulebook } from "./defaultRulebook"; +import { validateWithRulebook } from "./rulebookEngine"; -function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined { - if (!entityId) { - return undefined; - } - return worldState.entities[entityId]; -} - -function hasKey(actor: Entity, requiredKeyId: string): boolean { - return actor.attributes[`has_${requiredKeyId}`] === true; -} - -export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] { - return actions.map((action, actionIndex): ValidationResult => { - 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.`, - }; - } - }); +/** + * Validate a list of parsed actions against the world state. + * + * Pass a SceneRulebook to use data-driven scene rules. + * Falls back to the built-in default rulebook when none is provided. + */ +export function validateActions( + actions: Action[], + worldState: WorldState, + rulebook?: SceneRulebook +): ValidationResult[] { + const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id); + return validateWithRulebook(actions, worldState, activeRulebook); } diff --git a/charactergarden/app/src/turns/processTurn.ts b/charactergarden/app/src/turns/processTurn.ts index 65a07d7..e908e9e 100644 --- a/charactergarden/app/src/turns/processTurn.ts +++ b/charactergarden/app/src/turns/processTurn.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { CharacterGardenDatabase } from "../db"; import type { Action } from "../contracts/action"; +import type { SceneRulebook } from "../contracts/rulebook"; import type { Turn } from "../contracts/turn"; import type { ValidationResult } from "../contracts/validation"; import type { WorldState } from "../contracts/world"; @@ -19,10 +20,11 @@ export type ProcessTurnResponse = { export function processTurn( rawText: string, worldState: WorldState, - db: CharacterGardenDatabase + db: CharacterGardenDatabase, + rulebook?: SceneRulebook ): ProcessTurnResponse { const actions = parseTextToActions(rawText); - const validation = validateActions(actions, worldState); + const validation = validateActions(actions, worldState, rulebook); const nextWorldState = applyActions(actions, validation, worldState); const turn: Turn = { diff --git a/charactergarden/app/src/world/applyActions.ts b/charactergarden/app/src/world/applyActions.ts index 0545313..2b3b071 100644 --- a/charactergarden/app/src/world/applyActions.ts +++ b/charactergarden/app/src/world/applyActions.ts @@ -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( actions: Action[], results: ValidationResult[], @@ -60,6 +100,43 @@ export function applyActions( target.attributes.open = true; } 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": default: break; diff --git a/charactergarden/frontend/src/App.tsx b/charactergarden/frontend/src/App.tsx index 20ee6c0..f572c49 100644 --- a/charactergarden/frontend/src/App.tsx +++ b/charactergarden/frontend/src/App.tsx @@ -48,6 +48,433 @@ type ProcessTurnResponse = { 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(input: RequestInfo, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + return response.json() as Promise; +} + +// --------------------------------------------------------------------------- +// Rulebook editor component +// --------------------------------------------------------------------------- + +function RulebookEditor() { + const [rulebook, setRulebook] = useState(null); + const [drafts, setDrafts] = useState>({}); + const [parseErrors, setParseErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + void fetchJson("/api/rulebook").then((rb) => { + setRulebook(rb); + const initial: Record = {}; + 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 = {}; + + 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("/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

Loading rulebook…

; + + return ( +
+
+
+

{rulebook.name}

+ {rulebook.description ? ( +

{rulebook.description}

+ ) : null} +
+ +
+ + {saveError ?

{saveError}

: null} + +
+ {rulebook.rules.map((ruleSet) => ( +
+ + {ruleSet.actionType} + {ruleSet.checks.length} check{ruleSet.checks.length !== 1 ? "s" : ""} + + + +
+

+ Edit the checks array below. Each check has: id, description, condition, failReason, failMessage. +

+

+ For character permissions, use {`{"op":"actorIdIn","allowedIds":["player"]}`} or {`{"op":"actorNameIn","allowedNames":["Player"]}`}. +

+ {ruleSet.checks.length > 0 ? ( +
    + {ruleSet.checks.map((check) => ( +
  • + {check.description} +
  • + ))} +
+ ) : ( +

No checks — action always passes.

+ )} +