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 0000000..3be168c Binary files /dev/null and b/charactergarden.zip differ diff --git a/charactergarden/app/src/app.ts b/charactergarden/app/src/app.ts index 874ac2c..100f48f 100644 --- a/charactergarden/app/src/app.ts +++ b/charactergarden/app/src/app.ts @@ -1,329 +1,127 @@ import { randomUUID } from "node:crypto"; import { createDatabase, CharacterGardenDatabase } from "./db"; -import { resolveLatentEntity } from "./latentEntities"; -import { extractActionsFromProse } from "./llmAdapter"; -import { applyChanges, createOffsceneRoom, OFFSCENE_ROOM_ID, validate, WorldState } from "./truthEngine"; -import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types"; +import type { Entity } from "./contracts/entity"; +import type { Turn } from "./contracts/turn"; +import type { WorldState } from "./contracts/world"; +import { processTurn, ProcessTurnResponse } from "./turns/processTurn"; -export interface AppStateSnapshot { - entities: Entity[]; - events: GameEvent[]; +export interface AppSnapshot { + worldState: WorldState; turns: Turn[]; - beliefs: Belief[]; - summaries: Summary[]; -} - -export interface TurnResult { - narration: string; - parser: "fallback"; - parser_feedback?: string; - actions: Action[]; - accepted: Action[]; - rejected: { action: Action; reason: string }[]; - latent_resolution?: { - accepted: boolean; - reason: string; - entity_id?: string; - }; - snapshot: AppStateSnapshot; } export interface CharacterGardenApp { db: CharacterGardenDatabase; - getSnapshot(): AppStateSnapshot; - processTurn(input: string): TurnResult; + getSnapshot(): AppSnapshot; + processTurn(rawText: string): ProcessTurnResponse; + reset(): AppSnapshot; } -function createSeedEntities(): Entity[] { - return [ - createOffsceneRoom(), - { - id: "garden", +function createSeedWorldState(): WorldState { + const now = Date.now(); + + const entities: Record = { + 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) => (