From 998635f542f591d0151ca132abbde10de581cbb3 Mon Sep 17 00:00:00 2001 From: spencer Date: Fri, 24 Apr 2026 01:04:17 -0400 Subject: [PATCH] feat: implement processTurn function to handle turn processing and world state updates refactor: remove legacy types.ts file and update frontend to use new contracts feat: add applyActions function to manage action application and world state mutation chore: remove empty .gitkeep file from sqlite data directory refactor: update frontend App component to align with new API contracts and improve UX docs: revise project.md to reflect updated architecture and system requirements docs: update thoughts.md with current status, architecture decisions, and remaining checks --- Implementation_plan.md | 485 +++++++++++++++ charactergarden.zip | Bin 0 -> 30161 bytes charactergarden/app/src/app.ts | 360 +++-------- charactergarden/app/src/contracts/action.ts | 7 + charactergarden/app/src/contracts/entity.ts | 6 + charactergarden/app/src/contracts/turn.ts | 10 + .../app/src/contracts/validation.ts | 6 + charactergarden/app/src/contracts/world.ts | 8 + charactergarden/app/src/db.ts | 367 +++++------ charactergarden/app/src/index.ts | 2 + charactergarden/app/src/latentEntities.ts | 209 ------- charactergarden/app/src/llmAdapter.ts | 210 ------- .../app/src/parser/parseTextToActions.ts | 42 ++ charactergarden/app/src/truthEngine.ts | 395 ++++-------- charactergarden/app/src/turns/processTurn.ts | 48 ++ charactergarden/app/src/types.ts | 112 ---- charactergarden/app/src/world/applyActions.ts | 73 +++ charactergarden/data/sqlite/.gitkeep | 1 - charactergarden/frontend/src/App.tsx | 154 +++-- project.md | 578 +++++++++--------- thoughts.md | 139 ++--- 21 files changed, 1472 insertions(+), 1740 deletions(-) create mode 100644 Implementation_plan.md create mode 100644 charactergarden.zip create mode 100644 charactergarden/app/src/contracts/action.ts create mode 100644 charactergarden/app/src/contracts/entity.ts create mode 100644 charactergarden/app/src/contracts/turn.ts create mode 100644 charactergarden/app/src/contracts/validation.ts create mode 100644 charactergarden/app/src/contracts/world.ts delete mode 100644 charactergarden/app/src/latentEntities.ts delete mode 100644 charactergarden/app/src/llmAdapter.ts create mode 100644 charactergarden/app/src/parser/parseTextToActions.ts create mode 100644 charactergarden/app/src/turns/processTurn.ts delete mode 100644 charactergarden/app/src/types.ts create mode 100644 charactergarden/app/src/world/applyActions.ts delete mode 100644 charactergarden/data/sqlite/.gitkeep diff --git a/Implementation_plan.md b/Implementation_plan.md new file mode 100644 index 0000000..e28b5a6 --- /dev/null +++ b/Implementation_plan.md @@ -0,0 +1,485 @@ +# CharacterGarden — Iterative Implementation Plan + +## Copilot Operating Rules + +Work in small, reviewable steps. + +After every completed step: + +1. Update `thoughts.md` +2. Record files changed +3. Record assumptions made +4. Record next step +5. Do not skip ahead unless the current step is complete + +Do not redesign the project without updating this plan. + +--- + +# Phase 1 — Contracts First + +## Step 1.1 — Create contracts folder + +Create: + +```txt +app/src/contracts/ +``` + +Add: + +```txt +app/src/contracts/action.ts +app/src/contracts/turn.ts +app/src/contracts/validation.ts +app/src/contracts/world.ts +app/src/contracts/entity.ts +``` + +Goal: all shared types live here. + +--- + +## Step 1.2 — Define Action contract + +In `action.ts`: + +```ts +export type Action = { + actorId: string; + type: string; + targetId?: string; + locationId?: string; + metadata?: Record; +}; +``` + +No other action shape should be used. + +--- + +## Step 1.3 — Define ValidationResult contract + +In `validation.ts`: + +```ts +export type ValidationResult = { + actionIndex: number; + success: boolean; + reason?: string; + message?: string; +}; +``` + +--- + +## Step 1.4 — Define Turn contract + +In `turn.ts`: + +```ts +import type { Action } from "./action"; +import type { ValidationResult } from "./validation"; + +export type Turn = { + id: string; + rawText: string; + actions: Action[]; + validation: ValidationResult[]; + createdAt: number; +}; +``` + +--- + +## Step 1.5 — Define Entity contract + +In `entity.ts`: + +```ts +export type Entity = { + id: string; + name: string; + type: string; + attributes: Record; +}; +``` + +--- + +## Step 1.6 — Define WorldState contract + +In `world.ts`: + +```ts +import type { Entity } from "./entity"; + +export type WorldState = { + id: string; + entities: Record; + metadata: Record; + createdAt: number; +}; +``` + +--- + +# Phase 2 — Enforce Layer Boundaries + +## Step 2.1 — Refactor truth engine imports + +Update `truthEngine.ts` so it imports: + +```ts +import type { Action } from "./contracts/action"; +import type { ValidationResult } from "./contracts/validation"; +import type { WorldState } from "./contracts/world"; +``` + +Truth engine must only receive structured actions. + +--- + +## Step 2.2 — Remove text parsing from truth engine + +Search `truthEngine.ts` for: + +* string parsing +* natural language interpretation +* prompt logic +* LLM calls + +Move any such logic out. + +Truth engine should expose: + +```ts +export function validateActions( + actions: Action[], + worldState: WorldState +): ValidationResult[] { + // deterministic validation only +} +``` + +--- + +## Step 2.3 — Create parser layer + +Create: + +```txt +app/src/parser/ +app/src/parser/parseTextToActions.ts +``` + +Function: + +```ts +import type { Action } from "../contracts/action"; + +export function parseTextToActions(text: string): Action[] { + // temporary simple parser + return []; +} +``` + +For now, returning `[]` is acceptable. + +--- + +## Step 2.4 — Create world state engine + +Create: + +```txt +app/src/world/ +app/src/world/applyActions.ts +``` + +Function: + +```ts +import type { Action } from "../contracts/action"; +import type { ValidationResult } from "../contracts/validation"; +import type { WorldState } from "../contracts/world"; + +export function applyActions( + actions: Action[], + results: ValidationResult[], + worldState: WorldState +): WorldState { + // apply only successful actions + return worldState; +} +``` + +--- + +# Phase 3 — Build First Deterministic Test Domain + +Use a simple door/key room before anything complex. + +## Step 3.1 — Seed initial world + +Create initial world state: + +* actor: `player` +* room: `room_start` +* door: `door_1` +* key: `key_1` + +Door starts locked. + +Key starts in room. + +Player starts in room. + +--- + +## Step 3.2 — Support action types + +Truth engine should recognize: + +```txt +inspect +take +open +move +``` + +Unknown action types fail with: + +```txt +reason: "unknown_action" +``` + +--- + +## Step 3.3 — Validate take action + +Rules: + +* Actor must exist +* Target must exist +* Target must be in same location +* Target must be takeable + +Failure reasons: + +* `actor_not_found` +* `target_not_found` +* `not_in_same_location` +* `not_takeable` + +--- + +## Step 3.4 — Validate open action + +Rules: + +* Actor must exist +* Target must exist +* Target must be openable +* If locked, actor must have matching key + +Failure reasons: + +* `actor_not_found` +* `target_not_found` +* `not_openable` +* `locked_requires_key` + +--- + +## Step 3.5 — Apply successful take + +If `take` succeeds: + +* move item into actor inventory + +--- + +## Step 3.6 — Apply successful open + +If `open` succeeds: + +* set door attribute `open: true` + +--- + +# Phase 4 — Wire Full Turn Processing + +## Step 4.1 — Create turn processor + +Create: + +```txt +app/src/turns/processTurn.ts +``` + +Function: + +```ts +export async function processTurn(rawText: string): Promise { + // parse + // validate + // apply + // persist + // return turn +} +``` + +--- + +## Step 4.2 — Enforce pipeline order + +The turn processor must call: + +```txt +parseTextToActions +validateActions +applyActions +persistTurn +``` + +In that order. + +No layer may skip ahead. + +--- + +## Step 4.3 — Add debug response + +API should return: + +```ts +{ + rawText, + actions, + validation, + worldState +} +``` + +This is for MVP debugging. + +--- + +# Phase 5 — Persistence + +## Step 5.1 — Add database tables + +Minimum SQLite tables: + +```sql +turns +actions +validation_results +entities +world_states +``` + +--- + +## Step 5.2 — Persist every turn + +Each call to `processTurn` must save: + +* raw text +* parsed actions +* validation results +* resulting world state snapshot + +--- + +## Step 5.3 — Add reset endpoint + +Add an endpoint to reset world state to seed state. + +This is needed for testing. + +--- + +# Phase 6 — LLM Adapter Reintroduction + +Only after deterministic flow works. + +## Step 6.1 — LLM parser adapter + +Add optional LLM parser: + +```ts +parseTextToActionsWithLLM(text: string, worldState: WorldState): Promise +``` + +It must output only valid `Action[]`. + +--- + +## Step 6.2 — LLM narrative adapter + +Add: + +```ts +generateNarrative(turn: Turn, worldState: WorldState): Promise +``` + +The narrative adapter may describe results but must not alter them. + +--- + +# Phase 7 — Frontend Debug UI + +## Step 7.1 — Show raw text input + +User can submit a turn. + +--- + +## Step 7.2 — Show parsed actions + +Display action JSON. + +--- + +## Step 7.3 — Show validation results + +Display success/failure reasons. + +--- + +## Step 7.4 — Show world state + +Display current world state JSON. + +--- + +# MVP Completion Criteria + +MVP is complete when this works: + +1. User enters: `take key` +2. Parser returns a `take` action +3. Truth engine validates it +4. World state moves key to inventory +5. User enters: `open door` +6. Truth engine verifies key ownership +7. Door becomes open +8. All steps are visible in debug UI +9. All turns are persisted + +--- + +# Do Not Do Yet + +Do not implement: + +* autonomous agents +* complex memory retrieval +* embeddings +* relationship simulation +* long-term summaries +* branching timelines + +Until the deterministic MVP is working. diff --git a/charactergarden.zip b/charactergarden.zip new file mode 100644 index 0000000000000000000000000000000000000000..3be168cac42269dbc95cf5cb920479d120876162 GIT binary patch literal 30161 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 = { + room_start: { + id: "room_start", + name: "Start Room", type: "room", - name: "Garden", attributes: { - description: "A small overgrown garden with a weathered bench and a shed door nearby.", + description: "A plain room with a locked door.", }, }, - { - id: "shed", + room_exit: { + id: "room_exit", + name: "Exit Room", type: "room", - name: "Shed", attributes: { - description: "A cramped tool shed that smells of old wood and oil.", + description: "A simple room beyond the door.", }, }, - { + player: { id: "player", - type: "character", name: "Player", - attributes: { - location: "garden", - clothed: true, - pocket_count: 4, - has_bag: false, - }, - }, - { - id: "groundskeeper", type: "character", - name: "Groundskeeper", attributes: { - location: "garden", + location: "room_start", + has_key_1: false, }, }, - { - id: "gate", - type: "object", - name: "Garden Gate", + door_1: { + id: "door_1", + name: "Old Door", + type: "door", attributes: { - location: "garden", + location: "room_start", + openable: true, + locked: true, + requiredKey: "key_1", open: false, - locked: false, }, }, - { - id: "bench", - type: "object", - name: "Bench", + key_1: { + id: "key_1", + name: "Brass Key", + type: "item", attributes: { - location: "garden", + location: "room_start", + takeable: true, }, }, - ]; -} + }; -function worldStateFromEntities(entities: Entity[]): WorldState { return { - entities: new Map(entities.map((entity) => [entity.id, entity])), + id: randomUUID(), + entities, + metadata: { + domain: "door_key_mvp", + version: 1, + }, + createdAt: now, }; } -function entitiesFromWorldState(worldState: WorldState): Entity[] { - return Array.from(worldState.entities.values()).sort((left, right) => - left.id.localeCompare(right.id) - ); -} - -function sameRoom(worldState: WorldState, leftId: string, rightId: string): boolean { - const left = worldState.entities.get(leftId); - const right = worldState.entities.get(rightId); - return left?.attributes["location"] === right?.attributes["location"]; -} - -function describeTarget(worldState: WorldState, targetId: string | undefined): string { - if (!targetId) { - return "nothing in particular"; - } - - const entity = worldState.entities.get(targetId); - return entity?.name ?? targetId; -} - -function narrateAction(action: Action, worldState: WorldState): string { - switch (action.verb) { - case "move": { - const targetName = describeTarget(worldState, action.target); - if (action.target === OFFSCENE_ROOM_ID) { - return `You step out of the active scene and into ${targetName.toLowerCase()}.`; - } - return `You move to ${targetName}.`; - } - case "open": - return `You open ${describeTarget(worldState, action.target)}.`; - case "close": - return `You close ${describeTarget(worldState, action.target)}.`; - case "take": - return `You take ${describeTarget(worldState, action.target)}.`; - case "drop": - return `You drop ${describeTarget(worldState, action.target)}.`; - case "use": - return `You use ${describeTarget(worldState, action.target)}.`; - case "inspect": - return `You inspect ${describeTarget(worldState, action.target)}.`; - case "speak": - return `You speak to ${describeTarget(worldState, action.target)}.`; - default: - return "You act."; - } -} - -function narrateResult( - worldState: WorldState, - accepted: Action[], - rejected: { action: Action; reason: string }[], - latentReason?: string, - parserFeedback?: string -): string { - const lines: string[] = []; - - if (parserFeedback) { - lines.push(parserFeedback); - } - - if (latentReason) { - lines.push(latentReason); - } - - for (const action of accepted) { - lines.push(narrateAction(action, worldState)); - } - - for (const rejection of rejected) { - lines.push(`Action failed: ${rejection.reason}.`); - } - - if (lines.length === 0) { - lines.push("Nothing changes."); - } - - return lines.join(" "); -} - -function persistWorldState(db: CharacterGardenDatabase, worldState: WorldState): void { - for (const entity of worldState.entities.values()) { - db.upsertEntity(entity); - } -} - -function hydrateInitialState(db: CharacterGardenDatabase): WorldState { +function ensureSeedState(db: CharacterGardenDatabase): WorldState { db.init(); - const existing = db.listEntities(); - if (existing.length > 0) { - return worldStateFromEntities(existing); + + const latest = db.getLatestWorldState(); + if (latest) { + return latest; } - const seeded = createSeedEntities(); - for (const entity of seeded) { - db.upsertEntity(entity); - } - - return worldStateFromEntities(seeded); + const seed = createSeedWorldState(); + db.upsertEntities(Object.values(seed.entities)); + db.insertWorldState(null, seed); + return seed; } export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { const db = createDatabase({ dbPath }); - let worldState = hydrateInitialState(db); - - function getSnapshot(): AppStateSnapshot { - return { - entities: entitiesFromWorldState(worldState), - events: db.listEvents(), - turns: db.listTurns(), - beliefs: db.listBeliefs(), - summaries: db.listSummaries(), - }; - } - - function processTurn(input: string): TurnResult { - const turnNumber = db.listTurns().length + 1; - const { actions, parser, parser_feedback: parserFeedback } = extractActionsFromProse(input); - - let activeWorldState = worldState; - let latentResolution: TurnResult["latent_resolution"]; - const latentNoun = typeof actions[0]?.params?.["latent_item"] === "string" - ? String(actions[0].params?.["latent_item"]) - : null; - - if (latentNoun) { - const resolution = resolveLatentEntity( - { actor_id: actions[0].actor, noun: latentNoun, turn: turnNumber }, - activeWorldState - ); - - latentResolution = { - accepted: resolution.accepted, - reason: resolution.reason, - entity_id: resolution.entity?.id, - }; - - if (resolution.accepted && resolution.entity) { - activeWorldState = { - entities: new Map(activeWorldState.entities).set( - resolution.entity.id, - resolution.entity - ), - }; - db.upsertEntity(resolution.entity); - for (const belief of resolution.beliefs) { - db.insertBelief(belief); - } - } - } - - const normalizedActions = actions.map((action) => { - if (latentNoun && latentResolution?.accepted && latentResolution.entity_id) { - return { - actor: action.actor, - verb: "take" as const, - target: latentResolution.entity_id, - }; - } - - return action; - }); - - const validation = validate(normalizedActions, activeWorldState); - const nextWorldState = applyChanges(activeWorldState, validation.state_changes); - const narration = narrateResult( - nextWorldState, - validation.accepted, - validation.rejected, - latentResolution?.reason, - parserFeedback - ); - - const turnRecord: Turn = { - id: randomUUID(), - turn: turnNumber, - input, - output: narration, - timestamp: Date.now(), - }; - - db.insertTurn(turnRecord); - - for (const action of validation.accepted) { - const event: GameEvent = { - id: randomUUID(), - turn: turnNumber, - action, - result: "success", - timestamp: Date.now(), - }; - db.insertEvent(event); - } - - for (const rejection of validation.rejected) { - const event: GameEvent = { - id: randomUUID(), - turn: turnNumber, - action: rejection.action, - result: "fail", - timestamp: Date.now(), - }; - db.insertEvent(event); - } - - worldState = nextWorldState; - persistWorldState(db, worldState); - - return { - narration, - parser, - parser_feedback: parserFeedback, - actions: normalizedActions, - accepted: validation.accepted, - rejected: validation.rejected, - latent_resolution: latentResolution, - snapshot: getSnapshot(), - }; - } + let worldState = ensureSeedState(db); return { db, - getSnapshot, - processTurn, + + getSnapshot() { + return { + worldState, + turns: db.listTurns(), + }; + }, + + processTurn(rawText: string) { + const result = processTurn(rawText, worldState, db); + worldState = result.worldState; + return result; + }, + + reset() { + db.wipe(); + worldState = ensureSeedState(db); + return { + worldState, + turns: db.listTurns(), + }; + }, }; -} \ No newline at end of file +} diff --git a/charactergarden/app/src/contracts/action.ts b/charactergarden/app/src/contracts/action.ts new file mode 100644 index 0000000..184dcdd --- /dev/null +++ b/charactergarden/app/src/contracts/action.ts @@ -0,0 +1,7 @@ +export type Action = { + actorId: string; + type: string; + targetId?: string; + locationId?: string; + metadata?: Record; +}; diff --git a/charactergarden/app/src/contracts/entity.ts b/charactergarden/app/src/contracts/entity.ts new file mode 100644 index 0000000..a74854e --- /dev/null +++ b/charactergarden/app/src/contracts/entity.ts @@ -0,0 +1,6 @@ +export type Entity = { + id: string; + name: string; + type: string; + attributes: Record; +}; diff --git a/charactergarden/app/src/contracts/turn.ts b/charactergarden/app/src/contracts/turn.ts new file mode 100644 index 0000000..4f6fdc7 --- /dev/null +++ b/charactergarden/app/src/contracts/turn.ts @@ -0,0 +1,10 @@ +import type { Action } from "./action"; +import type { ValidationResult } from "./validation"; + +export type Turn = { + id: string; + rawText: string; + actions: Action[]; + validation: ValidationResult[]; + createdAt: number; +}; diff --git a/charactergarden/app/src/contracts/validation.ts b/charactergarden/app/src/contracts/validation.ts new file mode 100644 index 0000000..26d4adf --- /dev/null +++ b/charactergarden/app/src/contracts/validation.ts @@ -0,0 +1,6 @@ +export type ValidationResult = { + actionIndex: number; + success: boolean; + reason?: string; + message?: string; +}; diff --git a/charactergarden/app/src/contracts/world.ts b/charactergarden/app/src/contracts/world.ts new file mode 100644 index 0000000..aad00c9 --- /dev/null +++ b/charactergarden/app/src/contracts/world.ts @@ -0,0 +1,8 @@ +import type { Entity } from "./entity"; + +export type WorldState = { + id: string; + entities: Record; + metadata: Record; + createdAt: number; +}; diff --git a/charactergarden/app/src/db.ts b/charactergarden/app/src/db.ts index 6273634..15240b2 100644 --- a/charactergarden/app/src/db.ts +++ b/charactergarden/app/src/db.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; -import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types"; +import type { Action } from "./contracts/action"; +import type { Entity } from "./contracts/entity"; +import type { Turn } from "./contracts/turn"; +import type { ValidationResult } from "./contracts/validation"; +import type { WorldState } from "./contracts/world"; export interface DatabaseConfig { dbPath: string; @@ -12,58 +16,19 @@ export interface CharacterGardenDatabase { sqlite: Database.Database; init(): void; close(): void; - upsertEntity(entity: Entity): void; + upsertEntities(entities: Entity[]): void; listEntities(): Entity[]; - insertEvent(event: GameEvent): void; - listEvents(): GameEvent[]; insertTurn(turn: Turn): void; listTurns(): Turn[]; - insertBelief(belief: Belief): void; - listBeliefs(entityId?: string): Belief[]; - insertSummary(summary: Summary): void; - listSummaries(): Summary[]; + insertActions(turnId: string, actions: Action[]): void; + insertValidationResults(turnId: string, results: ValidationResult[]): void; + insertWorldState(turnId: string | null, worldState: WorldState): void; + getLatestWorldState(): WorldState | null; + wipe(): void; } -type EntityRow = { - id: string; - type: string; - name: string; - attributes_json: string; -}; - -type EventRow = { - id: string; - turn: number; - action_json: string; - result: "success" | "fail"; - timestamp: number; -}; - -type TurnRow = { - id: string; - turn: number; - input: string; - output: string; - timestamp: number; -}; - -type BeliefRow = { - entity_id: string; - claim: string; - confidence: number; -}; - -type SummaryRow = { - id: string; - turn_start: number; - turn_end: number; - text: string; - timestamp: number; -}; - function ensureParentDirectory(dbPath: string): void { - const directory = path.dirname(dbPath); - fs.mkdirSync(directory, { recursive: true }); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); } function parseJson(value: string): T { @@ -72,51 +37,55 @@ function parseJson(value: string): T { export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase { ensureParentDirectory(config.dbPath); - const sqlite = new Database(config.dbPath); const initStatements = [ + ` + CREATE TABLE IF NOT EXISTS turns ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `, + ` + CREATE TABLE IF NOT EXISTS actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + turn_id TEXT NOT NULL, + action_index INTEGER NOT NULL, + actor_id TEXT NOT NULL, + type TEXT NOT NULL, + target_id TEXT, + location_id TEXT, + metadata_json TEXT, + FOREIGN KEY(turn_id) REFERENCES turns(id) + ) + `, + ` + CREATE TABLE IF NOT EXISTS validation_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + turn_id TEXT NOT NULL, + action_index INTEGER NOT NULL, + success INTEGER NOT NULL, + reason TEXT, + message TEXT, + FOREIGN KEY(turn_id) REFERENCES turns(id) + ) + `, ` CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, - type TEXT NOT NULL, name TEXT NOT NULL, + type TEXT NOT NULL, attributes_json TEXT NOT NULL ) `, ` - CREATE TABLE IF NOT EXISTS events ( + CREATE TABLE IF NOT EXISTS world_states ( id TEXT PRIMARY KEY, - turn INTEGER NOT NULL, - action_json TEXT NOT NULL, - result TEXT NOT NULL CHECK(result IN ('success', 'fail')), - timestamp INTEGER NOT NULL - ) - `, - ` - CREATE TABLE IF NOT EXISTS turns ( - id TEXT PRIMARY KEY, - turn INTEGER NOT NULL UNIQUE, - input TEXT NOT NULL, - output TEXT NOT NULL, - timestamp INTEGER NOT NULL - ) - `, - ` - CREATE TABLE IF NOT EXISTS beliefs ( - entity_id TEXT NOT NULL, - claim TEXT NOT NULL, - confidence REAL NOT NULL, - PRIMARY KEY (entity_id, claim) - ) - `, - ` - CREATE TABLE IF NOT EXISTS summaries ( - id TEXT PRIMARY KEY, - turn_start INTEGER NOT NULL, - turn_end INTEGER NOT NULL, - text TEXT NOT NULL, - timestamp INTEGER NOT NULL + turn_id TEXT, + state_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(turn_id) REFERENCES turns(id) ) `, ]; @@ -125,163 +94,207 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase sqlite.exec(statement); } + const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities"); const upsertEntityStatement = sqlite.prepare(` - INSERT INTO entities (id, type, name, attributes_json) - VALUES (@id, @type, @name, @attributes_json) + INSERT INTO entities (id, name, type, attributes_json) + VALUES (@id, @name, @type, @attributes_json) ON CONFLICT(id) DO UPDATE SET - type = excluded.type, name = excluded.name, + type = excluded.type, attributes_json = excluded.attributes_json `); const listEntitiesStatement = sqlite.prepare(` - SELECT id, type, name, attributes_json + SELECT id, name, type, attributes_json FROM entities ORDER BY id ASC `); - const insertEventStatement = sqlite.prepare(` - INSERT INTO events (id, turn, action_json, result, timestamp) - VALUES (@id, @turn, @action_json, @result, @timestamp) - `); - - const listEventsStatement = sqlite.prepare(` - SELECT id, turn, action_json, result, timestamp - FROM events - ORDER BY turn ASC, timestamp ASC, id ASC - `); - const insertTurnStatement = sqlite.prepare(` - INSERT INTO turns (id, turn, input, output, timestamp) - VALUES (@id, @turn, @input, @output, @timestamp) + INSERT INTO turns (id, raw_text, created_at) + VALUES (@id, @raw_text, @created_at) `); const listTurnsStatement = sqlite.prepare(` - SELECT id, turn, input, output, timestamp + SELECT id, raw_text, created_at FROM turns - ORDER BY turn ASC + ORDER BY created_at ASC `); - const insertBeliefStatement = sqlite.prepare(` - INSERT INTO beliefs (entity_id, claim, confidence) - VALUES (@entity_id, @claim, @confidence) - ON CONFLICT(entity_id, claim) DO UPDATE SET - confidence = excluded.confidence + const insertActionStatement = sqlite.prepare(` + INSERT INTO actions ( + turn_id, + action_index, + actor_id, + type, + target_id, + location_id, + metadata_json + ) VALUES ( + @turn_id, + @action_index, + @actor_id, + @type, + @target_id, + @location_id, + @metadata_json + ) `); - const listBeliefsStatement = sqlite.prepare(` - SELECT entity_id, claim, confidence - FROM beliefs - ORDER BY entity_id ASC, claim ASC + const insertValidationStatement = sqlite.prepare(` + INSERT INTO validation_results ( + turn_id, + action_index, + success, + reason, + message + ) VALUES ( + @turn_id, + @action_index, + @success, + @reason, + @message + ) `); - const listBeliefsByEntityStatement = sqlite.prepare(` - SELECT entity_id, claim, confidence - FROM beliefs - WHERE entity_id = ? - ORDER BY claim ASC + const insertWorldStateStatement = sqlite.prepare(` + INSERT INTO world_states (id, turn_id, state_json, created_at) + VALUES (@id, @turn_id, @state_json, @created_at) `); - const insertSummaryStatement = sqlite.prepare(` - INSERT INTO summaries (id, turn_start, turn_end, text, timestamp) - VALUES (@id, @turn_start, @turn_end, @text, @timestamp) - `); - - const listSummariesStatement = sqlite.prepare(` - SELECT id, turn_start, turn_end, text, timestamp - FROM summaries - ORDER BY turn_start ASC, turn_end ASC + const latestWorldStateStatement = sqlite.prepare(` + SELECT state_json + FROM world_states + ORDER BY created_at DESC + LIMIT 1 `); return { sqlite, init() { - // Schema is applied on database construction so prepared statements are valid. + // Tables are initialized on construction. }, close() { sqlite.close(); }, - upsertEntity(entity) { - upsertEntityStatement.run({ - id: entity.id, - type: entity.type, - name: entity.name, - attributes_json: JSON.stringify(entity.attributes), + wipe() { + sqlite.exec(` + DELETE FROM validation_results; + DELETE FROM actions; + DELETE FROM world_states; + DELETE FROM turns; + DELETE FROM entities; + `); + }, + + upsertEntities(entities) { + const tx = sqlite.transaction((entityList: Entity[]) => { + clearEntitiesStatement.run(); + for (const entity of entityList) { + upsertEntityStatement.run({ + id: entity.id, + name: entity.name, + type: entity.type, + attributes_json: JSON.stringify(entity.attributes), + }); + } }); + + tx(entities); }, listEntities() { - const rows = listEntitiesStatement.all() as EntityRow[]; + const rows = listEntitiesStatement.all() as Array<{ + id: string; + name: string; + type: string; + attributes_json: string; + }>; + return rows.map((row) => ({ id: row.id, - type: row.type, name: row.name, + type: row.type, attributes: parseJson>(row.attributes_json), })); }, - insertEvent(event) { - insertEventStatement.run({ - id: event.id, - turn: event.turn, - action_json: JSON.stringify(event.action), - result: event.result, - timestamp: event.timestamp, - }); - }, - - listEvents() { - const rows = listEventsStatement.all() as EventRow[]; - return rows.map((row) => ({ - id: row.id, - turn: row.turn, - action: parseJson(row.action_json), - result: row.result, - timestamp: row.timestamp, - })); - }, - insertTurn(turn) { - insertTurnStatement.run(turn); + insertTurnStatement.run({ + id: turn.id, + raw_text: turn.rawText, + created_at: turn.createdAt, + }); }, listTurns() { - return listTurnsStatement.all() as TurnRow[]; + const rows = listTurnsStatement.all() as Array<{ + id: string; + raw_text: string; + created_at: number; + }>; + + return rows.map((row) => ({ + id: row.id, + rawText: row.raw_text, + actions: [], + validation: [], + createdAt: row.created_at, + })); }, - insertBelief(belief) { - insertBeliefStatement.run(belief); + insertActions(turnId, actions) { + const tx = sqlite.transaction((actionList: Action[]) => { + actionList.forEach((action, index) => { + insertActionStatement.run({ + turn_id: turnId, + action_index: index, + actor_id: action.actorId, + type: action.type, + target_id: action.targetId ?? null, + location_id: action.locationId ?? null, + metadata_json: action.metadata ? JSON.stringify(action.metadata) : null, + }); + }); + }); + + tx(actions); }, - listBeliefs(entityId) { - if (entityId) { - return listBeliefsByEntityStatement.all(entityId) as BeliefRow[]; - } + insertValidationResults(turnId, results) { + const tx = sqlite.transaction((validationList: ValidationResult[]) => { + for (const result of validationList) { + insertValidationStatement.run({ + turn_id: turnId, + action_index: result.actionIndex, + success: result.success ? 1 : 0, + reason: result.reason ?? null, + message: result.message ?? null, + }); + } + }); - return listBeliefsStatement.all() as BeliefRow[]; + tx(results); }, - insertSummary(summary) { - insertSummaryStatement.run({ - id: summary.id, - turn_start: summary.turn_range[0], - turn_end: summary.turn_range[1], - text: summary.text, - timestamp: summary.timestamp, + insertWorldState(turnId, worldState) { + insertWorldStateStatement.run({ + id: worldState.id, + turn_id: turnId, + state_json: JSON.stringify(worldState), + created_at: worldState.createdAt, }); }, - listSummaries() { - const rows = listSummariesStatement.all() as SummaryRow[]; - return rows.map((row) => ({ - id: row.id, - turn_range: [row.turn_start, row.turn_end], - text: row.text, - timestamp: row.timestamp, - })); + getLatestWorldState() { + const row = latestWorldStateStatement.get() as { state_json: string } | undefined; + if (!row) { + return null; + } + return parseJson(row.state_json); }, }; -} \ No newline at end of file +} diff --git a/charactergarden/app/src/index.ts b/charactergarden/app/src/index.ts index 7fdc2c0..c2b738f 100644 --- a/charactergarden/app/src/index.ts +++ b/charactergarden/app/src/index.ts @@ -13,6 +13,8 @@ server.get("/health", async () => ({ ok: true })); server.get("/api/state", async () => game.getSnapshot()); +server.post("/api/reset", async () => game.reset()); + server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => { const input = request.body?.input?.trim(); if (!input) { diff --git a/charactergarden/app/src/latentEntities.ts b/charactergarden/app/src/latentEntities.ts deleted file mode 100644 index 9279871..0000000 --- a/charactergarden/app/src/latentEntities.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - Affordance, - Belief, - Entity, - Fact, - LatentEntityRequest, - LatentEntityResolution, -} from "./types"; -import { WorldState } from "./truthEngine"; - -const PERSONAL_ITEM_NOUNS = new Set([ - "phone", - "wallet", - "keys", - "notebook", - "pen", - "coin", - "id card", - "card", -]); - -function asBoolean(value: unknown): boolean | null { - if (typeof value === "boolean") { - return value; - } - - return null; -} - -function asNumber(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - - return null; -} - -function slugify(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "item"; -} - -function deriveAffordances(actor: Entity): Affordance[] { - const clothed = asBoolean(actor.attributes["clothed"]); - const pocketCount = asNumber(actor.attributes["pocket_count"]); - const hasBag = asBoolean(actor.attributes["has_bag"]); - const searchedEmpty = asBoolean(actor.attributes["searched_empty"]); - - const canConcealSmallItems = - searchedEmpty !== true && - ((clothed === true && (pocketCount ?? 0) > 0) || hasBag === true); - - const reason = searchedEmpty === true - ? "actor was previously established as carrying nothing" - : hasBag === true - ? "actor is carrying a bag or container" - : clothed === true && (pocketCount ?? 0) > 0 - ? "actor is clothed and has pockets" - : "actor has no established carrying context for concealed items"; - - return [ - { - entity_id: actor.id, - key: "can_conceal_small_items", - enabled: canConcealSmallItems, - reason, - }, - ]; -} - -function buildBelief(actor: Entity, noun: string): Belief { - return { - entity_id: actor.id, - claim: `${actor.name} may be carrying a ${noun}`, - confidence: PERSONAL_ITEM_NOUNS.has(noun) ? 0.8 : 0.5, - }; -} - -function createEntityId(actorId: string, noun: string, worldState: WorldState): string { - const base = `${actorId}-${slugify(noun)}`; - if (!worldState.entities.has(base)) { - return base; - } - - let suffix = 2; - while (worldState.entities.has(`${base}-${suffix}`)) { - suffix += 1; - } - - return `${base}-${suffix}`; -} - -function createLatentEntity( - actor: Entity, - noun: string, - turn: number | undefined, - worldState: WorldState -): Entity { - const entityId = createEntityId(actor.id, noun, worldState); - - return { - id: entityId, - type: "item", - name: noun, - attributes: { - location: `inventory:${actor.id}`, - takeable: true, - useable: true, - provenance: { - introduced_turn: turn, - introduced_by: actor.id, - introduced_reason: "plausible_personal_item", - latent_from_belief: `${actor.name} may be carrying a ${noun}`, - }, - }, - }; -} - -export function resolveLatentEntity( - request: LatentEntityRequest, - worldState: WorldState -): LatentEntityResolution { - const actor = worldState.entities.get(request.actor_id); - if (!actor) { - return { - accepted: false, - reason: `actor entity '${request.actor_id}' does not exist`, - facts: [], - beliefs: [], - affordances: [], - }; - } - - const noun = request.noun.trim().toLowerCase(); - if (!noun) { - return { - accepted: false, - reason: "latent entity request requires a noun", - facts: [], - beliefs: [], - affordances: [], - }; - } - - const affordances = deriveAffordances(actor); - const concealment = affordances.find( - (affordance) => affordance.key === "can_conceal_small_items" - ); - const belief = buildBelief(actor, noun); - - if (asBoolean(actor.attributes["naked"]) === true) { - return { - accepted: false, - reason: `${actor.id} is established as naked and cannot plausibly conceal a ${noun}`, - facts: [], - beliefs: [belief], - affordances, - }; - } - - if (concealment?.enabled !== true) { - return { - accepted: false, - reason: concealment?.reason ?? `no carrying context supports introducing a ${noun}`, - facts: [], - beliefs: [belief], - affordances, - }; - } - - if (!PERSONAL_ITEM_NOUNS.has(noun)) { - return { - accepted: false, - reason: `${noun} is not in the MVP plausible personal-item set`, - facts: [], - beliefs: [belief], - affordances, - }; - } - - const entity = createLatentEntity(actor, noun, request.turn, worldState); - - const facts: Fact[] = [ - { - entity_id: actor.id, - key: `may_have_${slugify(noun)}`, - value: true, - source: "inference", - }, - { - entity_id: entity.id, - key: "location", - value: `inventory:${actor.id}`, - source: "inference", - }, - ]; - - return { - accepted: true, - reason: `${noun} promoted from plausible latent belief to fact`, - entity, - facts, - beliefs: [belief], - affordances, - }; -} \ No newline at end of file diff --git a/charactergarden/app/src/llmAdapter.ts b/charactergarden/app/src/llmAdapter.ts deleted file mode 100644 index f917acc..0000000 --- a/charactergarden/app/src/llmAdapter.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Action, ALLOWED_VERBS, Entity } from "./types"; - -export interface ExtractedActions { - actions: Action[]; - parser: "fallback"; - parser_feedback?: string; -} - -export interface ActionExtractionPrompt { - system: string; - user: string; -} - -function toEntityLine(entity: Entity): string { - const location = typeof entity.attributes["location"] === "string" - ? ` location=${entity.attributes["location"]}` - : ""; - - return `- ${entity.id} [${entity.type}] "${entity.name}"${location}`; -} - -export function buildActionExtractionPrompt(input: string, entities: Entity[], actorId = "player"): ActionExtractionPrompt { - const entityDigest = entities - .slice() - .sort((left, right) => left.id.localeCompare(right.id)) - .map(toEntityLine) - .join("\n"); - - const system = [ - "You convert player prose into canonical game actions.", - "Only produce actions that are valid in the current world snapshot.", - `Allowed verbs: ${ALLOWED_VERBS.join(", ")}`, - "Use exact entity ids from the world snapshot for actor and target.", - "If intent is unclear or target is missing, return no actions and a parser_feedback string suggesting rephrasing.", - "Return strict JSON only: {\"actions\": Action[], \"parser_feedback\"?: string}", - ].join("\n"); - - const user = [ - `Actor id: ${actorId}`, - "World snapshot entities:", - entityDigest || "- (none)", - `Player input: ${input}`, - ].join("\n"); - - return { system, user }; -} - -const REPHRASE_EXAMPLES = "Try rephrasing like: 'look around', 'go to the shed', 'open the gate', or 'pull out my phone'."; - -const ROOM_ALIASES: Record = { - garden: "garden", - shed: "shed", - offscene: "offscene", - outside: "offscene", - away: "offscene", -}; - -const TARGET_ALIASES: Record = { - gate: "gate", - bench: "bench", - groundskeeper: "groundskeeper", - keeper: "groundskeeper", - shed: "shed", - garden: "garden", - offscene: "offscene", -}; - -function normalized(input: string): string { - return input.trim().toLowerCase(); -} - -function extractQuotedOrTrailingNoun(input: string): string | null { - const quoted = input.match(/"([^"]+)"|'([^']+)'/); - if (quoted) { - return (quoted[1] ?? quoted[2]).trim().toLowerCase(); - } - - const pulled = input.match(/(?:pull|pulls|pulled|take|takes|took)\s+(?:out\s+)?(?:a|an|the|my|their|his|her)?\s*([a-z0-9 ]+)$/i); - if (pulled?.[1]) { - return pulled[1].trim().toLowerCase(); - } - - return null; -} - -function resolveTarget(input: string): string | undefined { - const direct = Object.entries(TARGET_ALIASES).find(([alias]) => - input.includes(alias) - ); - - return direct?.[1]; -} - -export function extractActionsFromProse(input: string, actorId = "player"): ExtractedActions { - const text = normalized(input); - - if (!text) { - return { - actions: [], - parser: "fallback", - parser_feedback: `I couldn't parse an empty turn. ${REPHRASE_EXAMPLES}`, - }; - } - - const room = Object.entries(ROOM_ALIASES).find(([alias]) => text.includes(alias))?.[1]; - if (/(go|move|walk|head|travel)/.test(text) && room) { - return { - actions: [{ actor: actorId, verb: "move", target: room }], - parser: "fallback", - }; - } - - if (/(go|move|walk|head|travel)/.test(text) && !room) { - return { - actions: [], - parser: "fallback", - parser_feedback: `I understood movement, but not the destination. Try 'go to the shed' or 'go to the garden'.`, - }; - } - - if (/(open)/.test(text)) { - return { - actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }], - parser: "fallback", - }; - } - - if (/(close|shut)/.test(text)) { - return { - actions: [{ actor: actorId, verb: "close", target: resolveTarget(text) ?? "gate" }], - parser: "fallback", - }; - } - - if (/(take|pick up|grab)/.test(text)) { - const target = resolveTarget(text); - if (!target) { - return { - actions: [], - parser: "fallback", - parser_feedback: `I understood 'take' but not what item you meant. Try 'take the bench' or 'pull out my phone'.`, - }; - } - - return { - actions: [{ actor: actorId, verb: "take", target }], - parser: "fallback", - }; - } - - if (/(drop|put down|set down)/.test(text)) { - const target = resolveTarget(text); - if (!target) { - return { - actions: [], - parser: "fallback", - parser_feedback: `I understood 'drop' but not which item. Try 'drop phone' or 'drop keys'.`, - }; - } - - return { - actions: [{ actor: actorId, verb: "drop", target }], - parser: "fallback", - }; - } - - if (/(talk|speak|ask|say)/.test(text)) { - return { - actions: [{ actor: actorId, verb: "speak", target: resolveTarget(text) ?? "groundskeeper", params: { utterance: input } }], - parser: "fallback", - }; - } - - if (/(use|press|activate)/.test(text)) { - const target = resolveTarget(text); - if (!target) { - return { - actions: [], - parser: "fallback", - parser_feedback: `I understood 'use' but not the target. Try 'use gate' or 'use phone'.`, - }; - } - - return { - actions: [{ actor: actorId, verb: "use", target }], - parser: "fallback", - }; - } - - if (/(look|inspect|examine)/.test(text)) { - return { - actions: [{ actor: actorId, verb: "inspect", target: resolveTarget(text) ?? actorId }], - parser: "fallback", - }; - } - - const latentNoun = extractQuotedOrTrailingNoun(text); - if (latentNoun && /(pull|pulls|pulled|take|takes|took).*(out)/.test(text)) { - return { - actions: [{ actor: actorId, verb: "inspect", params: { latent_item: latentNoun } }], - parser: "fallback", - }; - } - - return { - actions: [], - parser: "fallback", - parser_feedback: `I couldn't map that request to a game action. ${REPHRASE_EXAMPLES}`, - }; -} \ No newline at end of file diff --git a/charactergarden/app/src/parser/parseTextToActions.ts b/charactergarden/app/src/parser/parseTextToActions.ts new file mode 100644 index 0000000..330b4d8 --- /dev/null +++ b/charactergarden/app/src/parser/parseTextToActions.ts @@ -0,0 +1,42 @@ +import type { Action } from "../contracts/action"; + +function normalized(input: string): string { + return input.trim().toLowerCase(); +} + +export function parseTextToActions(text: string, actorId = "player"): Action[] { + const input = normalized(text); + if (!input) { + return []; + } + + if (/(look|inspect|examine)/.test(input)) { + return [{ actorId, type: "inspect", targetId: actorId }]; + } + + 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" }]; + } + if (input.includes("start")) { + return [{ actorId, type: "move", targetId: "room_start" }]; + } + return []; + } + + if (/(open)/.test(input)) { + if (input.includes("door")) { + return [{ actorId, type: "open", targetId: "door_1" }]; + } + return []; + } + + if (/(take|pick up|grab)/.test(input)) { + if (input.includes("key")) { + return [{ actorId, type: "take", targetId: "key_1" }]; + } + return []; + } + + return []; +} diff --git a/charactergarden/app/src/truthEngine.ts b/charactergarden/app/src/truthEngine.ts index 80466e8..26b980d 100644 --- a/charactergarden/app/src/truthEngine.ts +++ b/charactergarden/app/src/truthEngine.ts @@ -1,294 +1,125 @@ -/** - * Truth Engine — section 5.2 - * - * Pure validation logic. No LLM. No I/O. No side effects. - * Receives a world state snapshot and a list of actions. - * Returns what is accepted, what is rejected, and what would change. - * - * Rules (section 3): - * 1. Only the Truth Engine may produce StateChanges. - * 2. LLM output is never directly trusted. - * 3. Every state change must be traceable to an accepted action. - * 4. Invalid actions must return explicit failure reasons. - */ +import type { Action } from "./contracts/action"; +import type { Entity } from "./contracts/entity"; +import type { ValidationResult } from "./contracts/validation"; +import type { WorldState } from "./contracts/world"; -import { Action, Entity, StateChange, ValidationResult, ALLOWED_VERBS } from "./types"; - -export const OFFSCENE_ROOM_ID = "offscene"; - -// ── World state snapshot passed into validate() ────────────── -export interface WorldState { - entities: Map; -} - -// ── Per-verb rule handlers ──────────────────────────────────── -type RuleResult = - | { ok: true; changes: StateChange[] } - | { ok: false; reason: string }; - -type VerbHandler = ( - action: Action, - actor: Entity, - world: WorldState -) => RuleResult; - -// ── Helpers ─────────────────────────────────────────────────── -function requireTarget( - action: Action, - world: WorldState -): { ok: true; target: Entity } | { ok: false; reason: string } { - if (!action.target) { - return { ok: false, reason: `'${action.verb}' requires a target` }; +function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined { + if (!entityId) { + return undefined; } - const target = world.entities.get(action.target); - if (!target) { - return { - ok: false, - reason: `target entity '${action.target}' does not exist`, - }; - } - return { ok: true, target }; + return worldState.entities[entityId]; } -function attributeChange( - entity: Entity, - field: string, - newValue: unknown -): StateChange { - return { - entity_id: entity.id, - field, - old_value: entity.attributes[field] ?? null, - new_value: newValue, - }; +function hasKey(actor: Entity, requiredKeyId: string): boolean { + return actor.attributes[`has_${requiredKeyId}`] === true; } -// ── Verb handlers ───────────────────────────────────────────── -const verbHandlers: Record = { - move(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - // Target must be a room/location. The built-in offscene room is valid. - if (t.target.type !== "room") { - return { - ok: false, - reason: `cannot move to '${t.target.id}': not a room`, - }; - } - - return { - ok: true, - changes: [attributeChange(actor, "location", t.target.id)], - }; - }, - - open(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - if (t.target.attributes["locked"] === true) { - return { - ok: false, - reason: `'${t.target.id}' is locked and cannot be opened`, - }; - } - if (t.target.attributes["open"] === true) { - return { ok: false, reason: `'${t.target.id}' is already open` }; - } - - return { - ok: true, - changes: [attributeChange(t.target, "open", true)], - }; - }, - - close(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - if (t.target.attributes["open"] === false) { - return { ok: false, reason: `'${t.target.id}' is already closed` }; - } - - return { - ok: true, - changes: [attributeChange(t.target, "open", false)], - }; - }, - - take(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - if (t.target.attributes["takeable"] === false) { - return { - ok: false, - reason: `'${t.target.id}' cannot be taken`, - }; - } - - // Item must be in the same location as actor (unless already in inventory) - const actorLocation = actor.attributes["location"]; - const itemLocation = t.target.attributes["location"]; - const expectedInventory = `inventory:${actor.id}`; - - // If already in inventory, it's a no-op (already holding it) - if (itemLocation === expectedInventory) { - return { - ok: true, - changes: [], - }; - } - - if (actorLocation !== itemLocation) { - return { - ok: false, - reason: `'${t.target.id}' is not in the same location as '${actor.id}'`, - }; - } - - return { - ok: true, - changes: [attributeChange(t.target, "location", expectedInventory)], - }; - }, - - drop(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - const expectedLocation = `inventory:${actor.id}`; - if (t.target.attributes["location"] !== expectedLocation) { - return { - ok: false, - reason: `'${t.target.id}' is not in '${actor.id}' inventory`, - }; - } - - return { - ok: true, - changes: [ - attributeChange(t.target, "location", actor.attributes["location"]), - ], - }; - }, - - use(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - if (t.target.attributes["useable"] === false) { - return { ok: false, reason: `'${t.target.id}' cannot be used` }; - } - - // Generic "use" records a state change marking last user; concrete effects - // are handled by higher-level game logic layered on top. - return { - ok: true, - changes: [attributeChange(t.target, "last_used_by", actor.id)], - }; - }, - - inspect(_action, _actor, _world) { - // inspect is always valid — it has no side effects - return { ok: true, changes: [] }; - }, - - speak(action, actor, world) { - const t = requireTarget(action, world); - if (!t.ok) return t; - - if (t.target.type !== "character") { - return { - ok: false, - reason: `cannot speak to '${t.target.id}': not a character`, - }; - } - - return { ok: true, changes: [] }; - }, -}; - -// ── Main export ─────────────────────────────────────────────── - -/** - * Validate a list of actions against the current world state. - * Pure function — does NOT mutate worldState. - */ -export function validate( - actions: Action[], - worldState: WorldState -): ValidationResult { - const accepted: Action[] = []; - const rejected: { action: Action; reason: string }[] = []; - const state_changes: StateChange[] = []; - - for (const action of actions) { - // 1. Verb must be in the allowed set - if (!(ALLOWED_VERBS as readonly string[]).includes(action.verb)) { - rejected.push({ action, reason: `unknown verb '${action.verb}'` }); - continue; - } - - // 2. Actor must exist - const actor = worldState.entities.get(action.actor); +export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] { + return actions.map((action, actionIndex): ValidationResult => { + const actor = getEntity(worldState, action.actorId); if (!actor) { - rejected.push({ - action, - reason: `actor entity '${action.actor}' does not exist`, - }); - continue; + return { + actionIndex, + success: false, + reason: "actor_not_found", + message: `Actor '${action.actorId}' does not exist.`, + }; } - // 3. Run verb-specific handler - const handler = verbHandlers[action.verb]; - const result = handler(action, actor, worldState); + switch (action.type) { + case "inspect": + return { actionIndex, success: true }; - if (!result.ok) { - rejected.push({ action, reason: result.reason }); - } else { - accepted.push(action); - state_changes.push(...result.changes); + 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.`, + }; } - } - - return { accepted, rejected, state_changes }; -} - -/** - * Apply a validated set of StateChanges to a WorldState snapshot. - * Returns a new Map — does NOT mutate the original. - */ -export function applyChanges( - worldState: WorldState, - changes: StateChange[] -): WorldState { - const next = new Map( - Array.from(worldState.entities.entries()).map(([id, entity]) => [ - id, - { ...entity, attributes: { ...entity.attributes } }, - ]) - ); - - for (const change of changes) { - const entity = next.get(change.entity_id); - if (entity) { - entity.attributes[change.field] = change.new_value; - } - } - - return { entities: next }; -} - -export function createOffsceneRoom(): Entity { - return { - id: OFFSCENE_ROOM_ID, - type: "room", - name: "Offscene", - attributes: { - offscene: true, - visible: false, - }, - }; + }); } diff --git a/charactergarden/app/src/turns/processTurn.ts b/charactergarden/app/src/turns/processTurn.ts new file mode 100644 index 0000000..65a07d7 --- /dev/null +++ b/charactergarden/app/src/turns/processTurn.ts @@ -0,0 +1,48 @@ +import { randomUUID } from "node:crypto"; + +import type { CharacterGardenDatabase } from "../db"; +import type { Action } from "../contracts/action"; +import type { Turn } from "../contracts/turn"; +import type { ValidationResult } from "../contracts/validation"; +import type { WorldState } from "../contracts/world"; +import { parseTextToActions } from "../parser/parseTextToActions"; +import { validateActions } from "../truthEngine"; +import { applyActions } from "../world/applyActions"; + +export type ProcessTurnResponse = { + rawText: string; + actions: Action[]; + validation: ValidationResult[]; + worldState: WorldState; +}; + +export function processTurn( + rawText: string, + worldState: WorldState, + db: CharacterGardenDatabase +): ProcessTurnResponse { + const actions = parseTextToActions(rawText); + const validation = validateActions(actions, worldState); + const nextWorldState = applyActions(actions, validation, worldState); + + const turn: Turn = { + id: randomUUID(), + rawText, + actions, + validation, + createdAt: Date.now(), + }; + + db.insertTurn(turn); + db.insertActions(turn.id, actions); + db.insertValidationResults(turn.id, validation); + db.upsertEntities(Object.values(nextWorldState.entities)); + db.insertWorldState(turn.id, nextWorldState); + + return { + rawText, + actions, + validation, + worldState: nextWorldState, + }; +} diff --git a/charactergarden/app/src/types.ts b/charactergarden/app/src/types.ts deleted file mode 100644 index 549e758..0000000 --- a/charactergarden/app/src/types.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Core contracts — DO NOT modify without updating project.md - -// ── Section 2.1 ───────────────────────────────────────────── -export interface Entity { - id: string; - type: string; - name: string; - attributes: Record; -} - -// ── Section 2.2 ───────────────────────────────────────────── -export const ALLOWED_VERBS = [ - "move", - "open", - "close", - "take", - "drop", - "use", - "inspect", - "speak", -] as const; - -export type Verb = (typeof ALLOWED_VERBS)[number]; - -export interface Action { - actor: string; // entity id - verb: Verb; - target?: string; // entity id - params?: Record; -} - -// ── Section 2.3 ───────────────────────────────────────────── -export interface ValidationResult { - accepted: Action[]; - rejected: { action: Action; reason: string }[]; - state_changes: StateChange[]; -} - -// ── Section 2.4 ───────────────────────────────────────────── -export interface StateChange { - entity_id: string; - field: string; - old_value: unknown; - new_value: unknown; -} - -// ── Section 2.5 ───────────────────────────────────────────── -export interface GameEvent { - id: string; - turn: number; - action: Action; - result: "success" | "fail"; - timestamp: number; -} - -// ── Section 4 — Memory types ───────────────────────────────── -export interface Turn { - id: string; - turn: number; - input: string; - output: string; - timestamp: number; -} - -export interface Belief { - entity_id: string; - claim: string; - confidence: number; -} - -export interface Fact { - entity_id: string; - key: string; - value: unknown; - source: "seed" | "action" | "inference"; -} - -export interface Affordance { - entity_id: string; - key: string; - enabled: boolean; - reason: string; -} - -export interface EntityProvenance { - introduced_turn?: number; - introduced_by?: string; - introduced_reason?: string; - latent_from_belief?: string; -} - -export interface LatentEntityRequest { - actor_id: string; - noun: string; - turn?: number; -} - -export interface LatentEntityResolution { - accepted: boolean; - reason: string; - entity?: Entity; - facts: Fact[]; - beliefs: Belief[]; - affordances: Affordance[]; -} - -export interface Summary { - id: string; - turn_range: [number, number]; - text: string; - timestamp: number; -} diff --git a/charactergarden/app/src/world/applyActions.ts b/charactergarden/app/src/world/applyActions.ts new file mode 100644 index 0000000..0545313 --- /dev/null +++ b/charactergarden/app/src/world/applyActions.ts @@ -0,0 +1,73 @@ +import { randomUUID } from "node:crypto"; + +import type { Action } from "../contracts/action"; +import type { ValidationResult } from "../contracts/validation"; +import type { Entity } from "../contracts/entity"; +import type { WorldState } from "../contracts/world"; + +function cloneWorldState(worldState: WorldState): WorldState { + const entities: Record = {}; + for (const [id, entity] of Object.entries(worldState.entities)) { + entities[id] = { + ...entity, + attributes: { ...entity.attributes }, + }; + } + + return { + ...worldState, + entities, + metadata: { ...worldState.metadata }, + }; +} + +export function applyActions( + actions: Action[], + results: ValidationResult[], + worldState: WorldState +): WorldState { + const nextState = cloneWorldState(worldState); + + for (const result of results) { + if (!result.success) { + continue; + } + + const action = actions[result.actionIndex]; + if (!action) { + continue; + } + + const actor = nextState.entities[action.actorId]; + const target = action.targetId ? nextState.entities[action.targetId] : undefined; + + switch (action.type) { + case "move": + if (actor && action.targetId) { + actor.attributes.location = action.targetId; + } + break; + case "take": + if (actor && target) { + target.attributes.location = `inventory:${actor.id}`; + if (target.id === "key_1") { + actor.attributes.has_key_1 = true; + } + } + break; + case "open": + if (target) { + target.attributes.open = true; + } + break; + case "inspect": + default: + break; + } + } + + nextState.id = randomUUID(); + nextState.createdAt = Date.now(); + + return nextState; +} diff --git a/charactergarden/data/sqlite/.gitkeep b/charactergarden/data/sqlite/.gitkeep deleted file mode 100644 index 77f0050..0000000 --- a/charactergarden/data/sqlite/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# sqlite data directory — tracked by git, contents ignored diff --git a/charactergarden/frontend/src/App.tsx b/charactergarden/frontend/src/App.tsx index ec8fbad..20ee6c0 100644 --- a/charactergarden/frontend/src/App.tsx +++ b/charactergarden/frontend/src/App.tsx @@ -7,60 +7,52 @@ type Entity = { attributes: Record; }; -type GameEvent = { - id: string; - turn: number; - result: "success" | "fail"; - action: Record; - timestamp: number; +type Action = { + actorId: string; + type: string; + targetId?: string; + locationId?: string; +}; + +type ValidationResult = { + actionIndex: number; + success: boolean; + reason?: string; + message?: string; }; type Turn = { id: string; - turn: number; - input: string; - output: string; - timestamp: number; + rawText: string; + actions: Action[]; + validation: ValidationResult[]; + createdAt: number; }; -type Belief = { - entity_id: string; - claim: string; - confidence: number; -}; - -type Summary = { +type WorldState = { id: string; - turn_range: [number, number]; - text: string; - timestamp: number; + entities: Record; + metadata: Record; + createdAt: number; }; -type Snapshot = { - entities: Entity[]; - events: GameEvent[]; +type AppSnapshot = { + worldState: WorldState; turns: Turn[]; - beliefs: Belief[]; - summaries: Summary[]; }; -type TurnResult = { - narration: string; - parser: string; - parser_feedback?: string; - actions: Array>; - accepted: Array>; - rejected: Array<{ action: Record; reason: string }>; - latent_resolution?: { accepted: boolean; reason: string; entity_id?: string }; - snapshot: Snapshot; +type ProcessTurnResponse = { + rawText: string; + actions: Action[]; + validation: ValidationResult[]; + worldState: WorldState; }; const starterPrompts = [ "look around", - "open the gate", - "talk to the groundskeeper", - "go to the shed", - "pull out my phone", + "take key", + "open door", + "move to exit", ]; async function fetchJson(input: RequestInfo, init?: RequestInit): Promise { @@ -72,15 +64,15 @@ async function fetchJson(input: RequestInfo, init?: RequestInit): Promise } export default function App() { - const [snapshot, setSnapshot] = useState(null); - const [latest, setLatest] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [latest, setLatest] = useState(null); const [input, setInput] = useState("look around"); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); useEffect(() => { - void fetchJson("/api/state") + void fetchJson("/api/state") .then((data) => { setSnapshot(data); setLoading(false); @@ -97,13 +89,14 @@ export default function App() { setError(null); try { - const result = await fetchJson("/api/turn", { + const result = await fetchJson("/api/turn", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input }), }); setLatest(result); - setSnapshot(result.snapshot); + const nextSnapshot = await fetchJson("/api/state"); + setSnapshot(nextSnapshot); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "Unknown error"); } finally { @@ -111,13 +104,30 @@ export default function App() { } } + async function onReset() { + setError(null); + try { + const result = await fetchJson("/api/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + setSnapshot(result); + setLatest(null); + } catch (resetError) { + setError(resetError instanceof Error ? resetError.message : "Unknown error"); + } + } + + const entities = snapshot ? Object.values(snapshot.worldState.entities) : []; + return (

CharacterGarden

Bootable narrative sandbox

- Submit a turn, inspect the current entities and events, and verify how the truth engine is mutating state. + Submit a turn, inspect world state, and verify how the truth engine is mutating state.

@@ -133,6 +143,9 @@ export default function App() { +
{starterPrompts.map((prompt) => (