Compare commits

..

12 Commits

Author SHA1 Message Date
28229d8d69 fix: update app port to 3024 in docker-compose.yml and vite.config.ts
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 15:58:41 -04:00
81e2a7828f fix: correct allowedHosts configuration format in vite.config.ts 2026-04-27 01:06:54 -04:00
b4a2968399 fix: update allowedHosts configuration in vite.config.ts to 'beepc' 2026-04-27 00:57:52 -04:00
3112b6e9fe fix: add allowedHosts configuration in vite.config.ts
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:54:55 -04:00
d38c799b27 fix: update app and frontend port mappings to 3023 in .env.example and docker-compose.yml
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:51:01 -04:00
7a022bc085 fix: update app port in .env and .env.example to 3024 and 3023 respectively 2026-04-27 00:47:47 -04:00
665646bc18 fix: correct app port mapping in docker-compose.yml to use 3000 2026-04-27 00:46:10 -04:00
56c9cce4c7 fix: correct app port mapping in docker-compose.yml to use 3000 2026-04-27 00:44:57 -04:00
76dee7e73f fix: update app port mapping in docker-compose.yml from 3000 to 3023
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:36:21 -04:00
ca49565117 fix: correct app port mapping in docker-compose.yml to match environment variable
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:31:25 -04:00
5189446c73 fix: update app port mapping in docker-compose.yml from 3000 to 3023 2026-04-27 00:15:17 -04:00
0da62785d5 feat: refactor turn processing and world state management; remove obsolete files and enhance database interactions
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 00:09:11 -04:00
18 changed files with 403 additions and 237 deletions

View File

@@ -30,6 +30,15 @@ Status: COMPLETE
- Interpreter contract and first interpreter module created - Interpreter contract and first interpreter module created
- Docker container builds are passing for app and frontend - Docker container builds are passing for app and frontend
## Phase 0 Structural Cleanup (April 2026) — COMPLETE
- Deleted turns/processTurn.ts (was a pointless shim over runTurnManager)
- Deleted truthEngine.ts (was a thin wrapper over rulebookEngine.validateWithRulebook)
- Extracted world/seedWorld.ts — seed world logic out of app.ts; app factory is now lean
- Fixed db.listTurns() to return real actions and validation results (previously always empty arrays)
- Fixed worldState.rulebookId persistence — active rulebook now survives restarts
- Generalized has_<item_id> in applyActions — no longer hardcoded to key_1 only
--- ---
# Phase 1 — Intent Interpreter Boundary Hardening # Phase 1 — Intent Interpreter Boundary Hardening

View File

@@ -1,5 +1,5 @@
NODE_ENV=development NODE_ENV=development
APP_PORT=3000 APP_PORT=3024
FRONTEND_PORT=5173 FRONTEND_PORT=5173
DB_PATH=/data/sqlite/app.db DB_PATH=/data/sqlite/app.db

View File

@@ -1,11 +1,26 @@
# Copy this file to .env and adjust values as needed. # Copy this file to .env and adjust values as needed.
# Never commit .env — it is gitignored. # Never commit .env — it is gitignored.
NODE_ENV=development # Host port the app is exposed on. Must match the container-internal port (right side of ports mapping).
APP_PORT=3000 APP_PORT=3023
FRONTEND_PORT=5173
DB_PATH=/data/sqlite/app.db
# Optional — only required when running with: docker compose --profile llm up # Host port the frontend dev server is exposed on.
OLLAMA_URL=http://ollama:11434 FRONTEND_PORT=5173
OLLAMA_MODEL=llama3
NODE_ENV=development
# ---------------------------------------------------------------------------
# LLM resolver — only active when running: docker compose --profile llm up
# ---------------------------------------------------------------------------
# URL of the Ollama-compatible backend (default points to the compose ollama service).
LLM_RESOLVER_URL=http://ollama:11434
# Model to pull and use. Must be available on the Ollama instance.
LLM_RESOLVER_MODEL=llama3.2:3b
# Request timeout in milliseconds. Increase for larger/slower models.
LLM_RESOLVER_TIMEOUT_MS=15000
# Resolver mode: hybrid (llm with deterministic fallback) | llm | deterministic
INTENT_RESOLVER_MODE=hybrid

View File

@@ -1,12 +1,10 @@
import { randomUUID } from "node:crypto";
import { createDatabase, CharacterGardenDatabase } from "./db"; import { createDatabase, CharacterGardenDatabase } from "./db";
import type { Entity } from "./contracts/entity";
import type { SceneRulebook } from "./contracts/rulebook"; import type { SceneRulebook } from "./contracts/rulebook";
import type { Turn } from "./contracts/turn"; import type { Turn } from "./contracts/turn";
import type { WorldState } from "./contracts/world"; import type { WorldState } from "./contracts/world";
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook"; import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
import { processTurn, ProcessTurnResponse } from "./turns/processTurn"; import { runTurnManager, TurnManagerResponse } from "./turns/turnManager";
import { ensureSeedState } from "./world/seedWorld";
export interface AppSnapshot { export interface AppSnapshot {
worldState: WorldState; worldState: WorldState;
@@ -16,170 +14,13 @@ export interface AppSnapshot {
export interface CharacterGardenApp { export interface CharacterGardenApp {
db: CharacterGardenDatabase; db: CharacterGardenDatabase;
getSnapshot(): AppSnapshot; getSnapshot(): AppSnapshot;
processTurn(rawText: string): Promise<ProcessTurnResponse>; processTurn(rawText: string): Promise<TurnManagerResponse>;
getRulebook(): SceneRulebook; getRulebook(): SceneRulebook;
upsertRulebook(rulebook: SceneRulebook): SceneRulebook; upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
listRulebooks(): SceneRulebook[]; listRulebooks(): SceneRulebook[];
reset(): AppSnapshot; reset(): AppSnapshot;
} }
function createSeedWorldState(): WorldState {
const now = Date.now();
const entities: Record<string, Entity> = {
room_offstage: {
id: "room_offstage",
name: "Offstage",
type: "room",
attributes: {
description: "A holding area for characters not currently in the active scene.",
is_joinable: false,
},
},
room_start: {
id: "room_start",
name: "Start Room",
type: "room",
attributes: {
description: "A plain room with a locked door.",
is_joinable: true,
},
},
room_exit: {
id: "room_exit",
name: "Exit Room",
type: "room",
attributes: {
description: "A simple room beyond the door.",
is_joinable: true,
},
},
player: {
id: "player",
name: "Player",
type: "character",
attributes: {
location: "room_start",
has_key_1: false,
},
},
groundskeeper: {
id: "groundskeeper",
name: "Groundskeeper",
type: "character",
attributes: {
location: "room_offstage",
is_social: true,
in_scene: false,
},
},
door_1: {
id: "door_1",
name: "Old Door",
type: "door",
attributes: {
location: "room_start",
openable: true,
locked: true,
requiredKey: "key_1",
open: false,
},
},
key_1: {
id: "key_1",
name: "Brass Key",
type: "item",
attributes: {
location: "room_start",
takeable: true,
},
},
};
return {
id: randomUUID(),
entities,
metadata: {
domain: "door_key_mvp",
version: 1,
},
createdAt: now,
};
}
function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } {
const seed = createSeedWorldState();
const mergedEntities: Record<string, Entity> = {};
let changed = false;
for (const [entityId, seedEntity] of Object.entries(seed.entities)) {
const existingEntity = worldState.entities[entityId];
if (!existingEntity) {
mergedEntities[entityId] = seedEntity;
changed = true;
continue;
}
const mergedAttributes = {
...seedEntity.attributes,
...existingEntity.attributes,
};
if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) {
changed = true;
}
mergedEntities[entityId] = {
...existingEntity,
attributes: mergedAttributes,
};
}
for (const [entityId, existingEntity] of Object.entries(worldState.entities)) {
if (!mergedEntities[entityId]) {
mergedEntities[entityId] = existingEntity;
}
}
const mergedWorldState: WorldState = {
...worldState,
entities: mergedEntities,
metadata: {
...seed.metadata,
...worldState.metadata,
},
};
if (changed) {
mergedWorldState.id = randomUUID();
mergedWorldState.createdAt = Date.now();
}
return {
worldState: mergedWorldState,
changed,
};
}
function ensureSeedState(db: CharacterGardenDatabase): WorldState {
db.init();
const latest = db.getLatestWorldState();
if (latest) {
const merged = mergeSeedWorldState(latest);
db.upsertEntities(Object.values(merged.worldState.entities));
if (merged.changed) {
db.insertWorldState(null, merged.worldState);
}
return merged.worldState;
}
const seed = createSeedWorldState();
db.upsertEntities(Object.values(seed.entities));
db.insertWorldState(null, seed);
return seed;
}
function ensureDefaultRulebook( function ensureDefaultRulebook(
db: CharacterGardenDatabase, db: CharacterGardenDatabase,
worldState: WorldState worldState: WorldState
@@ -221,7 +62,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
async processTurn(rawText: string) { async processTurn(rawText: string) {
const rulebook = loadActiveRulebook(); const rulebook = loadActiveRulebook();
const result = await processTurn(rawText, worldState, db, rulebook); const result = await runTurnManager(rawText, worldState, db, rulebook);
worldState = result.worldState; worldState = result.worldState;
return result; return result;
}, },
@@ -238,6 +79,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
}; };
db.upsertRulebook(updated); db.upsertRulebook(updated);
activeRulebookId = updated.id; activeRulebookId = updated.id;
// Persist the active rulebook ID on the world state so it survives restarts.
worldState = { ...worldState, rulebookId: updated.id };
db.insertWorldState(null, worldState);
return updated; return updated;
}, },

View File

@@ -3,7 +3,7 @@
* *
* Rules are stored per-scene in the database and evaluated by rulebookEngine.ts. * Rules are stored per-scene in the database and evaluated by rulebookEngine.ts.
* The default set is seeded from defaultRulebook.ts and mirrors the original * The default set is seeded from defaultRulebook.ts and mirrors the original
* hardcoded logic in truthEngine.ts. * hardcoded logic previously in the truth engine as editable, data-driven rules.
*/ */
/** Which entity in the action context a condition refers to. */ /** Which entity in the action context a condition refers to. */

View File

@@ -155,6 +155,18 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
ORDER BY created_at ASC ORDER BY created_at ASC
`); `);
const listAllActionsStatement = sqlite.prepare(`
SELECT turn_id, action_index, actor_id, type, target_id, location_id, metadata_json
FROM actions
ORDER BY turn_id, action_index ASC
`);
const listAllValidationStatement = sqlite.prepare(`
SELECT turn_id, action_index, success, reason, message
FROM validation_results
ORDER BY turn_id, action_index ASC
`);
const listInterpreterEventsStatement = sqlite.prepare(` const listInterpreterEventsStatement = sqlite.prepare(`
SELECT turn_id, interpreter_json SELECT turn_id, interpreter_json
FROM interpreter_events FROM interpreter_events
@@ -322,11 +334,52 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
interpreterByTurnId.set(row.turn_id, parseJson<InterpreterOutput>(row.interpreter_json)); interpreterByTurnId.set(row.turn_id, parseJson<InterpreterOutput>(row.interpreter_json));
} }
const actionRows = listAllActionsStatement.all() as Array<{
turn_id: string;
action_index: number;
actor_id: string;
type: string;
target_id: string | null;
location_id: string | null;
metadata_json: string | null;
}>;
const actionsByTurnId = new Map<string, Action[]>();
for (const row of actionRows) {
const list = actionsByTurnId.get(row.turn_id) ?? [];
list.push({
actorId: row.actor_id,
type: row.type,
...(row.target_id != null ? { targetId: row.target_id } : {}),
...(row.location_id != null ? { locationId: row.location_id } : {}),
...(row.metadata_json != null ? { metadata: parseJson<Record<string, unknown>>(row.metadata_json) } : {}),
});
actionsByTurnId.set(row.turn_id, list);
}
const validationRows = listAllValidationStatement.all() as Array<{
turn_id: string;
action_index: number;
success: number;
reason: string | null;
message: string | null;
}>;
const validationByTurnId = new Map<string, ValidationResult[]>();
for (const row of validationRows) {
const list = validationByTurnId.get(row.turn_id) ?? [];
list.push({
actionIndex: row.action_index,
success: row.success === 1,
...(row.reason != null ? { reason: row.reason } : {}),
...(row.message != null ? { message: row.message } : {}),
});
validationByTurnId.set(row.turn_id, list);
}
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
rawText: row.raw_text, rawText: row.raw_text,
actions: [], actions: actionsByTurnId.get(row.id) ?? [],
validation: [], validation: validationByTurnId.get(row.id) ?? [],
createdAt: row.created_at, createdAt: row.created_at,
interpreter: interpreterByTurnId.get(row.id), interpreter: interpreterByTurnId.get(row.id),
})); }));

View File

@@ -4,7 +4,7 @@ export const DEFAULT_RULEBOOK_ID = "rulebook_default";
/** /**
* Builds the default SceneRulebook, encoding all validation logic that was * Builds the default SceneRulebook, encoding all validation logic that was
* previously hardcoded in truthEngine.ts as editable, data-driven rules. * previously hardcoded in the truth engine as editable, data-driven rules.
*/ */
export function createDefaultRulebook(worldId: string): SceneRulebook { export function createDefaultRulebook(worldId: string): SceneRulebook {
const now = Date.now(); const now = Date.now();

View File

@@ -1,5 +1,6 @@
import type { Action } from "../../contracts/action"; import type { Action } from "../../contracts/action";
import type { InterpreterOutput } from "../../contracts/intent"; import type { InterpreterOutput } from "../../contracts/intent";
import type { WorldState } from "../../contracts/world";
import type { ResolveIntentInput } from "../resolveIntent"; import type { ResolveIntentInput } from "../resolveIntent";
export const LLM_INTERPRETER_VERSION = "llm-v1-ollama"; export const LLM_INTERPRETER_VERSION = "llm-v1-ollama";
@@ -140,20 +141,98 @@ function toReasonCode(value: string | undefined):
} }
} }
/**
* Build a compact world-context block so the model can resolve entity references
* (e.g. "the key" → targetId: "key_1") without inventing IDs.
*
* Includes:
* - The actor's current room
* - Every entity visible in that room (items, doors, characters)
* - Items in the actor's inventory
* - All room entities (needed to populate locationId for move actions)
*/
function buildWorldContext(worldState: WorldState, actorId: string): string {
const actor = worldState.entities[actorId];
const actorLocation = actor ? String(actor.attributes.location ?? "") : "";
const lines: string[] = [];
if (actorLocation) {
const room = worldState.entities[actorLocation];
lines.push(`actor_location_id: ${actorLocation}${room ? ` (${room.name})` : ""}`);
}
for (const entity of Object.values(worldState.entities)) {
if (entity.id === actorId) continue;
const loc = String(entity.attributes.location ?? "");
const inRoom = loc === actorLocation;
const inActorInventory = loc === `inventory:${actorId}`;
const isRoom = entity.type === "room";
if (inRoom || inActorInventory || isRoom) {
const context = inActorInventory
? "actor_inventory"
: isRoom
? "room"
: "in_actor_room";
// Emit only fields the model needs to build actions.
const extras: string[] = [];
if (entity.attributes.locked === true) extras.push("locked");
if (entity.attributes.open === true) extras.push("open");
if (entity.attributes.takeable === true) extras.push("takeable");
if (entity.attributes.openable === true) extras.push("openable");
const extrasStr = extras.length ? ` [${extras.join(",")}]` : "";
lines.push(
`entity id=${entity.id} name=${JSON.stringify(entity.name)} type=${entity.type} context=${context}${extrasStr}`
);
}
}
return lines.length ? lines.join("\n") : "(no world context available)";
}
function buildPrompt(input: ResolveIntentInput): { system: string; user: string } { function buildPrompt(input: ResolveIntentInput): { system: string; user: string } {
const system = [ const system = [
"You are an intent-to-actions resolver for a text adventure engine.", "You are an intent-to-actions resolver for a text adventure engine.",
"Return ONLY JSON with this shape:", "You will receive the current world state and a player command.",
'{"status":"resolved|needs_clarification|rejected","selectedActions":[{"type":"inspect|move|take|open|introduce|describe|transfer","targetId":"optional","locationId":"optional","metadata":{"optional":"object"}}],"selectedConfidence":0.0,"clarification":{"reasonCode":"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT","question":"string","field":"verb|target|item|recipient|location"},"rationale":"brief"}', "Return ONLY a JSON object with this exact shape (no markdown, no prose):",
"If unresolved, selectedActions must be an empty array and clarification must be present.", JSON.stringify({
"Use canonical action types only. Do not invent fields.", status: "resolved|needs_clarification|rejected",
].join(" "); selectedActions: [
{
type: "inspect|move|open|take|introduce|describe|transfer",
targetId: "entity id from world context, or omit",
locationId: "room id for move, or omit",
metadata: { note: "omit if unused" },
},
],
selectedConfidence: 0.0,
clarification: {
reasonCode:
"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT",
question: "question to ask player",
field: "verb|target|item|recipient|location",
},
rationale: "one sentence",
}),
"Rules:",
"- Use entity ids from the world context for targetId and locationId. Never invent ids.",
"- If status is resolved, selectedActions must be non-empty and clarification must be omitted.",
"- If status is not resolved, selectedActions must be empty and clarification must be present.",
"- Use only the canonical action types listed above.",
].join("\n");
const worldContext = input.worldState
? buildWorldContext(input.worldState, input.actorId)
: "(no world context provided)";
const user = [ const user = [
`actorId: ${input.actorId}`, `actorId: ${input.actorId}`,
`input: ${JSON.stringify(input.rawText)}`,
`minimum_confidence: ${input.minConfidence}`, `minimum_confidence: ${input.minConfidence}`,
].join("\n"); `world_context:\n${worldContext}`,
`player_input: ${JSON.stringify(input.rawText)}`,
].join("\n\n");
return { system, user }; return { system, user };
} }

View File

@@ -1,4 +1,5 @@
import type { InterpreterOutput } from "../contracts/intent"; import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
import { resolveDeterministicIntent } from "./adapters/deterministicResolver"; import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
import { resolveLlmIntent } from "./adapters/llmResolver"; import { resolveLlmIntent } from "./adapters/llmResolver";
import { import {
@@ -11,6 +12,7 @@ const DEFAULT_MIN_CONFIDENCE = 0.65;
type InterpretTurnOptions = { type InterpretTurnOptions = {
mode?: ResolverMode; mode?: ResolverMode;
minConfidence?: number; minConfidence?: number;
worldState?: WorldState;
}; };
function getResolverMode(options?: InterpretTurnOptions): ResolverMode { function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
@@ -25,6 +27,7 @@ function buildInput(rawText: string, actorId: string, options?: InterpretTurnOpt
rawText, rawText,
actorId, actorId,
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE, minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
worldState: options?.worldState,
}; };
} }

View File

@@ -1,4 +1,5 @@
import type { InterpreterOutput } from "../contracts/intent"; import type { InterpreterOutput } from "../contracts/intent";
import type { WorldState } from "../contracts/world";
export type ResolverMode = "deterministic" | "llm" | "hybrid"; export type ResolverMode = "deterministic" | "llm" | "hybrid";
@@ -6,6 +7,8 @@ export type ResolveIntentInput = {
rawText: string; rawText: string;
actorId: string; actorId: string;
minConfidence: number; minConfidence: number;
/** Optional world state — passed to LLM resolvers to provide entity context. */
worldState?: WorldState;
}; };
export type IntentResolver = { export type IntentResolver = {

View File

@@ -1,21 +0,0 @@
import type { Action } from "./contracts/action";
import type { SceneRulebook } from "./contracts/rulebook";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
import { createDefaultRulebook } from "./defaultRulebook";
import { validateWithRulebook } from "./rulebookEngine";
/**
* Validate a list of parsed actions against the world state.
*
* Pass a SceneRulebook to use data-driven scene rules.
* Falls back to the built-in default rulebook when none is provided.
*/
export function validateActions(
actions: Action[],
worldState: WorldState,
rulebook?: SceneRulebook
): ValidationResult[] {
const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
return validateWithRulebook(actions, worldState, activeRulebook);
}

View File

@@ -1,24 +0,0 @@
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
import type { InterpreterOutput } from "../contracts/intent";
import type { SceneRulebook } from "../contracts/rulebook";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { runTurnManager } from "./turnManager";
export type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: InterpreterOutput;
};
export async function processTurn(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase,
rulebook?: SceneRulebook
): Promise<ProcessTurnResponse> {
return runTurnManager(rawText, worldState, db, rulebook);
}

View File

@@ -9,7 +9,8 @@ import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world"; import type { WorldState } from "../contracts/world";
import { interpretTurn } from "../interpreter/interpretTurn"; import { interpretTurn } from "../interpreter/interpretTurn";
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput"; import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
import { validateActions } from "../truthEngine"; import { createDefaultRulebook } from "../defaultRulebook";
import { validateWithRulebook } from "../rulebookEngine";
import { applyActions } from "../world/applyActions"; import { applyActions } from "../world/applyActions";
export type TurnManagerResponse = { export type TurnManagerResponse = {
@@ -39,7 +40,7 @@ export async function runTurnManager(
db: CharacterGardenDatabase, db: CharacterGardenDatabase,
rulebook?: SceneRulebook rulebook?: SceneRulebook
): Promise<TurnManagerResponse> { ): Promise<TurnManagerResponse> {
const interpreted = await interpretTurn(rawText, "player"); const interpreted = await interpretTurn(rawText, "player", { worldState });
const boundaryCheck = validateInterpreterOutput(interpreted); const boundaryCheck = validateInterpreterOutput(interpreted);
const interpreter: InterpreterOutput = boundaryCheck.isValid const interpreter: InterpreterOutput = boundaryCheck.isValid
? interpreted ? interpreted
@@ -84,7 +85,8 @@ export async function runTurnManager(
} }
const actions = interpreter.selectedActions; const actions = interpreter.selectedActions;
const validation = validateActions(actions, worldState, rulebook); const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
const validation = validateWithRulebook(actions, worldState, activeRulebook);
const nextWorldState = applyActions(actions, validation, worldState); const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = { const turn: Turn = {

View File

@@ -109,9 +109,9 @@ export function applyActions(
case "take": case "take":
if (actor && target) { if (actor && target) {
target.attributes.location = `inventory:${actor.id}`; target.attributes.location = `inventory:${actor.id}`;
if (target.id === "key_1") { // Set has_<item_id> on the actor so attributeRef rulebook checks work
actor.attributes.has_key_1 = true; // generically (e.g. has_key_1 for opening a locked door).
} actor.attributes[`has_${target.id}`] = true;
} else if (actor && action.targetId && action.metadata?.createIfMissing === true) { } else if (actor && action.targetId && action.metadata?.createIfMissing === true) {
nextState.entities[action.targetId] = { nextState.entities[action.targetId] = {
id: action.targetId, id: action.targetId,

View File

@@ -0,0 +1,162 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Entity } from "../contracts/entity";
import type { WorldState } from "../contracts/world";
export function createSeedWorldState(): WorldState {
const now = Date.now();
const entities: Record<string, Entity> = {
room_offstage: {
id: "room_offstage",
name: "Offstage",
type: "room",
attributes: {
description: "A holding area for characters not currently in the active scene.",
is_joinable: false,
},
},
room_start: {
id: "room_start",
name: "Start Room",
type: "room",
attributes: {
description: "A plain room with a locked door.",
is_joinable: true,
},
},
room_exit: {
id: "room_exit",
name: "Exit Room",
type: "room",
attributes: {
description: "A simple room beyond the door.",
is_joinable: true,
},
},
player: {
id: "player",
name: "Player",
type: "character",
attributes: {
location: "room_start",
has_key_1: false,
},
},
groundskeeper: {
id: "groundskeeper",
name: "Groundskeeper",
type: "character",
attributes: {
location: "room_offstage",
is_social: true,
in_scene: false,
},
},
door_1: {
id: "door_1",
name: "Old Door",
type: "door",
attributes: {
location: "room_start",
openable: true,
locked: true,
requiredKey: "key_1",
open: false,
},
},
key_1: {
id: "key_1",
name: "Brass Key",
type: "item",
attributes: {
location: "room_start",
takeable: true,
},
},
};
return {
id: randomUUID(),
entities,
metadata: {
domain: "door_key_mvp",
version: 1,
},
createdAt: now,
};
}
export function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } {
const seed = createSeedWorldState();
const mergedEntities: Record<string, Entity> = {};
let changed = false;
for (const [entityId, seedEntity] of Object.entries(seed.entities)) {
const existingEntity = worldState.entities[entityId];
if (!existingEntity) {
mergedEntities[entityId] = seedEntity;
changed = true;
continue;
}
const mergedAttributes = {
...seedEntity.attributes,
...existingEntity.attributes,
};
if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) {
changed = true;
}
mergedEntities[entityId] = {
...existingEntity,
attributes: mergedAttributes,
};
}
for (const [entityId, existingEntity] of Object.entries(worldState.entities)) {
if (!mergedEntities[entityId]) {
mergedEntities[entityId] = existingEntity;
}
}
const mergedWorldState: WorldState = {
...worldState,
entities: mergedEntities,
metadata: {
...seed.metadata,
...worldState.metadata,
},
};
if (changed) {
mergedWorldState.id = randomUUID();
mergedWorldState.createdAt = Date.now();
}
return {
worldState: mergedWorldState,
changed,
};
}
export function ensureSeedState(db: CharacterGardenDatabase): WorldState {
db.init();
const latest = db.getLatestWorldState();
if (latest) {
const merged = mergeSeedWorldState(latest);
db.upsertEntities(Object.values(merged.worldState.entities));
if (merged.changed) {
db.insertWorldState(null, merged.worldState);
}
return merged.worldState;
}
const seed = createSeedWorldState();
db.upsertEntities(Object.values(seed.entities));
db.insertWorldState(null, seed);
return seed;
}

View File

@@ -3,12 +3,16 @@ services:
build: ./app build: ./app
working_dir: /app working_dir: /app
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3024}:3024"
environment: environment:
- APP_PORT=3000 - APP_PORT=3024
- NODE_ENV=${NODE_ENV:-development} - NODE_ENV=${NODE_ENV:-development}
- DB_PATH=/var/lib/charactergarden/app.db - DB_PATH=/var/lib/charactergarden/app.db
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434} - OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
- LLM_RESOLVER_URL=${LLM_RESOLVER_URL:-http://ollama:11434}
- LLM_RESOLVER_MODEL=${LLM_RESOLVER_MODEL:-llama3.2:3b}
- LLM_RESOLVER_TIMEOUT_MS=${LLM_RESOLVER_TIMEOUT_MS:-15000}
- INTENT_RESOLVER_MODE=${INTENT_RESOLVER_MODE:-hybrid}
volumes: volumes:
- sqlite_data:/var/lib/charactergarden - sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src - ./app/src:/app/src
@@ -35,6 +39,23 @@ services:
- ollama_data:/root/.ollama - ollama_data:/root/.ollama
profiles: profiles:
- llm - llm
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
ollama-init:
image: ollama/ollama:latest
depends_on:
ollama:
condition: service_healthy
environment:
- OLLAMA_HOST=http://ollama:11434
entrypoint: ["ollama", "pull", "${LLM_RESOLVER_MODEL:-llama3.2:3b}"]
profiles:
- llm
volumes: volumes:
ollama_data: ollama_data:

View File

@@ -4,15 +4,16 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
allowedHosts: ["beepc","cg.sketchferret.com"],
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://app:3000", target: "http://app:3024",
changeOrigin: true, changeOrigin: true,
}, },
"/health": { "/health": {
target: "http://app:3000", target: "http://app:3024",
changeOrigin: true, changeOrigin: true,
}, },
}, },

View File

@@ -18,6 +18,18 @@
- Interpreter envelopes are persisted per turn and surfaced to the UI timeline. - Interpreter envelopes are persisted per turn and surfaced to the UI timeline.
- LLM resolver now calls an HTTP model backend (Ollama-compatible) with hybrid deterministic fallback. - LLM resolver now calls an HTTP model backend (Ollama-compatible) with hybrid deterministic fallback.
- Rulebooks now include a version field with backward-compatible DB migration. - Rulebooks now include a version field with backward-compatible DB migration.
- Turn log now returns populated actions and validation results per turn (previously always empty).
- Active rulebook ID is now persisted on worldState and survives restarts.
- `take` sets `has_<item_id>` generically on the actor (was hardcoded to key_1 only).
### Structural refactoring completed (April 2026)
- **Deleted `turns/processTurn.ts`** — was a 3-line shim over runTurnManager. app.ts now calls runTurnManager directly.
- **Deleted `truthEngine.ts`** — was a thin wrapper over rulebookEngine.validateWithRulebook. turnManager.ts now calls validateWithRulebook directly.
- **Extracted `world/seedWorld.ts`** — createSeedWorldState, mergeSeedWorldState, ensureSeedState moved out of app.ts. App factory is now clean.
- **Fixed `db.listTurns()`** — now reads back actions and validation_results from their tables. Frontend turn log now has real data.
- **Fixed `worldState.rulebookId` persistence** — upsertRulebook now updates worldState.rulebookId and persists a world snapshot so the active rulebook survives restarts.
- **Generalized `has_<item_id>` in applyActions** — `take` now sets `has_<item_id>` on the actor for all taken items, not just `key_1`. The attributeRef rulebook check continues to work generically.
### Confirmed via containerized validation ### Confirmed via containerized validation
@@ -58,6 +70,13 @@
- Existing DBs may hold older rulebooks missing new action rule sets. - Existing DBs may hold older rulebooks missing new action rule sets.
- Need explicit upgrade path/versioning. - Need explicit upgrade path/versioning.
4. Parser is world-specific
- parseTextToActions.ts hardcodes entity IDs (room_exit, door_1, key_1, groundskeeper).
- The parser is used only inside the deterministic resolver adapter; keeping it isolated limits blast radius, but a future world-context-aware resolver would eliminate this entirely.
5. Entity table vs world_state blob redundancy
- `entities` table and `world_states.state_json` both store entity data. The entities table is the live read target; world_states is the history log. No query capability on history. Acceptable for MVP.
## Path Forward (Next 3 Iterations) ## Path Forward (Next 3 Iterations)
### Iteration 1: LLM adapter hardening ### Iteration 1: LLM adapter hardening