diff --git a/charactergarden/app/.dockerignore b/charactergarden/app/.dockerignore new file mode 100644 index 0000000..1e38074 --- /dev/null +++ b/charactergarden/app/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +npm-debug.log \ No newline at end of file diff --git a/charactergarden/app/Dockerfile b/charactergarden/app/Dockerfile new file mode 100644 index 0000000..0aac9b1 --- /dev/null +++ b/charactergarden/app/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-bookworm + +WORKDIR /app + +COPY package.json ./ +COPY tsconfig.json ./ + +RUN npm install + +COPY src ./src + +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/charactergarden/app/package.json b/charactergarden/app/package.json index 244e820..ff0b829 100644 --- a/charactergarden/app/package.json +++ b/charactergarden/app/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "ts-node-dev --respawn src/index.ts", + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", "start": "node dist/index.js" }, diff --git a/charactergarden/app/src/app.ts b/charactergarden/app/src/app.ts new file mode 100644 index 0000000..7d0557d --- /dev/null +++ b/charactergarden/app/src/app.ts @@ -0,0 +1,316 @@ +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"; + +export interface AppStateSnapshot { + entities: Entity[]; + events: GameEvent[]; + turns: Turn[]; + beliefs: Belief[]; + summaries: Summary[]; +} + +export interface TurnResult { + narration: string; + parser: "fallback"; + 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; +} + +function createSeedEntities(): Entity[] { + return [ + createOffsceneRoom(), + { + id: "garden", + type: "room", + name: "Garden", + attributes: { + description: "A small overgrown garden with a weathered bench and a shed door nearby.", + }, + }, + { + id: "shed", + type: "room", + name: "Shed", + attributes: { + description: "A cramped tool shed that smells of old wood and oil.", + }, + }, + { + 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", + }, + }, + { + id: "gate", + type: "object", + name: "Garden Gate", + attributes: { + location: "garden", + open: false, + locked: false, + }, + }, + { + id: "bench", + type: "object", + name: "Bench", + attributes: { + location: "garden", + }, + }, + ]; +} + +function worldStateFromEntities(entities: Entity[]): WorldState { + return { + entities: new Map(entities.map((entity) => [entity.id, entity])), + }; +} + +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 +): string { + const lines: string[] = []; + + 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 { + db.init(); + const existing = db.listEntities(); + if (existing.length > 0) { + return worldStateFromEntities(existing); + } + + const seeded = createSeedEntities(); + for (const entity of seeded) { + db.upsertEntity(entity); + } + + return worldStateFromEntities(seeded); +} + +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 } = 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); + + 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, + actions: normalizedActions, + accepted: validation.accepted, + rejected: validation.rejected, + latent_resolution: latentResolution, + snapshot: getSnapshot(), + }; + } + + return { + db, + getSnapshot, + processTurn, + }; +} \ No newline at end of file diff --git a/charactergarden/app/src/db.ts b/charactergarden/app/src/db.ts new file mode 100644 index 0000000..6273634 --- /dev/null +++ b/charactergarden/app/src/db.ts @@ -0,0 +1,287 @@ +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; + +import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types"; + +export interface DatabaseConfig { + dbPath: string; +} + +export interface CharacterGardenDatabase { + sqlite: Database.Database; + init(): void; + close(): void; + upsertEntity(entity: 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[]; +} + +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 }); +} + +function parseJson(value: string): T { + return JSON.parse(value) as T; +} + +export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase { + ensureParentDirectory(config.dbPath); + + const sqlite = new Database(config.dbPath); + + const initStatements = [ + ` + CREATE TABLE IF NOT EXISTS entities ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + attributes_json TEXT NOT NULL + ) + `, + ` + CREATE TABLE IF NOT EXISTS events ( + 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 + ) + `, + ]; + + for (const statement of initStatements) { + sqlite.exec(statement); + } + + const upsertEntityStatement = sqlite.prepare(` + INSERT INTO entities (id, type, name, attributes_json) + VALUES (@id, @type, @name, @attributes_json) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + name = excluded.name, + attributes_json = excluded.attributes_json + `); + + const listEntitiesStatement = sqlite.prepare(` + SELECT id, type, name, 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) + `); + + const listTurnsStatement = sqlite.prepare(` + SELECT id, turn, input, output, timestamp + FROM turns + ORDER BY turn 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 listBeliefsStatement = sqlite.prepare(` + SELECT entity_id, claim, confidence + FROM beliefs + ORDER BY entity_id ASC, claim ASC + `); + + const listBeliefsByEntityStatement = sqlite.prepare(` + SELECT entity_id, claim, confidence + FROM beliefs + WHERE entity_id = ? + ORDER BY claim ASC + `); + + 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 + `); + + return { + sqlite, + + init() { + // Schema is applied on database construction so prepared statements are valid. + }, + + close() { + sqlite.close(); + }, + + upsertEntity(entity) { + upsertEntityStatement.run({ + id: entity.id, + type: entity.type, + name: entity.name, + attributes_json: JSON.stringify(entity.attributes), + }); + }, + + listEntities() { + const rows = listEntitiesStatement.all() as EntityRow[]; + return rows.map((row) => ({ + id: row.id, + type: row.type, + name: row.name, + 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); + }, + + listTurns() { + return listTurnsStatement.all() as TurnRow[]; + }, + + insertBelief(belief) { + insertBeliefStatement.run(belief); + }, + + listBeliefs(entityId) { + if (entityId) { + return listBeliefsByEntityStatement.all(entityId) as BeliefRow[]; + } + + return listBeliefsStatement.all() as BeliefRow[]; + }, + + 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, + }); + }, + + 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, + })); + }, + }; +} \ No newline at end of file diff --git a/charactergarden/app/src/index.ts b/charactergarden/app/src/index.ts new file mode 100644 index 0000000..7fdc2c0 --- /dev/null +++ b/charactergarden/app/src/index.ts @@ -0,0 +1,35 @@ +import Fastify from "fastify"; + +import { createCharacterGardenApp } from "./app"; + +const port = Number(process.env.APP_PORT ?? 3000); +const host = process.env.APP_HOST ?? "0.0.0.0"; +const dbPath = process.env.DB_PATH ?? "/data/sqlite/app.db"; + +const game = createCharacterGardenApp(dbPath); +const server = Fastify({ logger: true }); + +server.get("/health", async () => ({ ok: true })); + +server.get("/api/state", async () => game.getSnapshot()); + +server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => { + const input = request.body?.input?.trim(); + if (!input) { + reply.code(400); + return { error: "input is required" }; + } + + return game.processTurn(input); +}); + +async function start(): Promise { + try { + await server.listen({ host, port }); + } catch (error) { + server.log.error(error); + process.exit(1); + } +} + +void start(); \ No newline at end of file diff --git a/charactergarden/app/src/latentEntities.ts b/charactergarden/app/src/latentEntities.ts new file mode 100644 index 0000000..9279871 --- /dev/null +++ b/charactergarden/app/src/latentEntities.ts @@ -0,0 +1,209 @@ +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 new file mode 100644 index 0000000..3f57692 --- /dev/null +++ b/charactergarden/app/src/llmAdapter.ts @@ -0,0 +1,131 @@ +import { Action } from "./types"; + +export interface ExtractedActions { + actions: Action[]; + parser: "fallback"; +} + +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: [{ actor: actorId, verb: "inspect", target: actorId }], + parser: "fallback", + }; + } + + 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 (/(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)) { + return { + actions: [{ actor: actorId, verb: "take", target: resolveTarget(text) ?? undefined }], + parser: "fallback", + }; + } + + if (/(drop|put down|set down)/.test(text)) { + return { + actions: [{ actor: actorId, verb: "drop", target: resolveTarget(text) ?? undefined }], + 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)) { + return { + actions: [{ actor: actorId, verb: "use", target: resolveTarget(text) ?? undefined }], + 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: [{ actor: actorId, verb: "inspect", target: actorId, params: { raw_input: input } }], + parser: "fallback", + }; +} \ No newline at end of file diff --git a/charactergarden/app/src/truthEngine.ts b/charactergarden/app/src/truthEngine.ts new file mode 100644 index 0000000..c10f68e --- /dev/null +++ b/charactergarden/app/src/truthEngine.ts @@ -0,0 +1,283 @@ +/** + * 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 { 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` }; + } + const target = world.entities.get(action.target); + if (!target) { + return { + ok: false, + reason: `target entity '${action.target}' does not exist`, + }; + } + return { ok: true, target }; +} + +function attributeChange( + entity: Entity, + field: string, + newValue: unknown +): StateChange { + return { + entity_id: entity.id, + field, + old_value: entity.attributes[field] ?? null, + new_value: newValue, + }; +} + +// ── 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 + const actorLocation = actor.attributes["location"]; + const itemLocation = t.target.attributes["location"]; + 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", `inventory:${actor.id}`)], + }; + }, + + 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); + if (!actor) { + rejected.push({ + action, + reason: `actor entity '${action.actor}' does not exist`, + }); + continue; + } + + // 3. Run verb-specific handler + const handler = verbHandlers[action.verb]; + const result = handler(action, actor, worldState); + + if (!result.ok) { + rejected.push({ action, reason: result.reason }); + } else { + accepted.push(action); + state_changes.push(...result.changes); + } + } + + 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/types.ts b/charactergarden/app/src/types.ts index 365f7f6..549e758 100644 --- a/charactergarden/app/src/types.ts +++ b/charactergarden/app/src/types.ts @@ -68,6 +68,42 @@ export interface Belief { 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]; diff --git a/charactergarden/docker-compose.yml b/charactergarden/docker-compose.yml index 90c7df9..211d3f3 100644 --- a/charactergarden/docker-compose.yml +++ b/charactergarden/docker-compose.yml @@ -1,23 +1,29 @@ -version: "3.9" - services: app: build: ./app + working_dir: /app ports: - "${APP_PORT:-3000}:3000" environment: + - APP_PORT=3000 - NODE_ENV=${NODE_ENV:-development} - DB_PATH=/data/sqlite/app.db - OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434} volumes: - ./data:/data - depends_on: - - ollama + - ./app/src:/app/src frontend: build: ./frontend + working_dir: /frontend ports: - "${FRONTEND_PORT:-5173}:5173" + environment: + - CHOKIDAR_USEPOLLING=true + volumes: + - ./frontend/src:/frontend/src + - ./frontend/index.html:/frontend/index.html + - ./frontend/vite.config.ts:/frontend/vite.config.ts depends_on: - app diff --git a/charactergarden/frontend/.dockerignore b/charactergarden/frontend/.dockerignore new file mode 100644 index 0000000..1e38074 --- /dev/null +++ b/charactergarden/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +npm-debug.log \ No newline at end of file diff --git a/charactergarden/frontend/Dockerfile b/charactergarden/frontend/Dockerfile new file mode 100644 index 0000000..33147ab --- /dev/null +++ b/charactergarden/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-bookworm + +WORKDIR /frontend + +COPY package.json ./ +COPY tsconfig.json ./ +COPY vite.config.ts ./ +COPY index.html ./ + +RUN npm install + +COPY src ./src + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/charactergarden/frontend/index.html b/charactergarden/frontend/index.html new file mode 100644 index 0000000..93f342c --- /dev/null +++ b/charactergarden/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + CharacterGarden + + +
+ + + \ No newline at end of file diff --git a/charactergarden/frontend/src/App.tsx b/charactergarden/frontend/src/App.tsx new file mode 100644 index 0000000..d7330f2 --- /dev/null +++ b/charactergarden/frontend/src/App.tsx @@ -0,0 +1,221 @@ +import { FormEvent, useEffect, useState } from "react"; + +type Entity = { + id: string; + type: string; + name: string; + attributes: Record; +}; + +type GameEvent = { + id: string; + turn: number; + result: "success" | "fail"; + action: Record; + timestamp: number; +}; + +type Turn = { + id: string; + turn: number; + input: string; + output: string; + timestamp: number; +}; + +type Belief = { + entity_id: string; + claim: string; + confidence: number; +}; + +type Summary = { + id: string; + turn_range: [number, number]; + text: string; + timestamp: number; +}; + +type Snapshot = { + entities: Entity[]; + events: GameEvent[]; + turns: Turn[]; + beliefs: Belief[]; + summaries: Summary[]; +}; + +type TurnResult = { + narration: string; + parser: string; + actions: Array>; + accepted: Array>; + rejected: Array<{ action: Record; reason: string }>; + latent_resolution?: { accepted: boolean; reason: string; entity_id?: string }; + snapshot: Snapshot; +}; + +const starterPrompts = [ + "look around", + "open the gate", + "talk to the groundskeeper", + "go to the shed", + "pull out my phone", +]; + +async function fetchJson(input: RequestInfo, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + return response.json() as Promise; +} + +export default function App() { + 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") + .then((data) => { + setSnapshot(data); + setLoading(false); + }) + .catch((fetchError: Error) => { + setError(fetchError.message); + setLoading(false); + }); + }, []); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + + try { + const result = await fetchJson("/api/turn", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input }), + }); + setLatest(result); + setSnapshot(result.snapshot); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Unknown error"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

CharacterGarden

+

Bootable narrative sandbox

+

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

+ +
+ +