diff --git a/charactergarden.zip b/charactergarden.zip deleted file mode 100644 index 3be168c..0000000 Binary files a/charactergarden.zip and /dev/null differ diff --git a/charactergarden/app/src/app.ts b/charactergarden/app/src/app.ts index 100f48f..236c6cc 100644 --- a/charactergarden/app/src/app.ts +++ b/charactergarden/app/src/app.ts @@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto"; import { createDatabase, CharacterGardenDatabase } from "./db"; import type { Entity } from "./contracts/entity"; +import type { SceneRulebook } from "./contracts/rulebook"; import type { Turn } from "./contracts/turn"; import type { WorldState } from "./contracts/world"; +import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook"; import { processTurn, ProcessTurnResponse } from "./turns/processTurn"; export interface AppSnapshot { @@ -15,6 +17,9 @@ export interface CharacterGardenApp { db: CharacterGardenDatabase; getSnapshot(): AppSnapshot; processTurn(rawText: string): ProcessTurnResponse; + getRulebook(): SceneRulebook; + upsertRulebook(rulebook: SceneRulebook): SceneRulebook; + listRulebooks(): SceneRulebook[]; reset(): AppSnapshot; } @@ -22,12 +27,22 @@ function createSeedWorldState(): WorldState { const now = Date.now(); const entities: Record = { + 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.

+ )} +