Compare commits

...

14 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
c32fa977a8 feat: add database reset functionality and implement rulebook tests 2026-04-26 16:27:26 -04:00
fca69d3cb5 feat: enhance turn display with time and diagnostics styling 2026-04-26 14:15:46 -04:00
23 changed files with 647 additions and 255 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
@@ -262,6 +274,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
DELETE FROM world_states; DELETE FROM world_states;
DELETE FROM turns; DELETE FROM turns;
DELETE FROM entities; DELETE FROM entities;
DELETE FROM rulebooks;
`); `);
}, },
@@ -321,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

@@ -0,0 +1,6 @@
import Database from "better-sqlite3";
const db = new Database(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
db.prepare("CREATE TABLE IF NOT EXISTS probe(value TEXT)").run();
db.close();
console.log("sqlite_probe_ok");

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { createCharacterGardenApp } from "../app";
function run(): void {
const app = createCharacterGardenApp(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
const snapshot = app.getSnapshot();
const rulebook = app.getRulebook();
assert.equal(snapshot.turns.length, 0, "Expected no persisted turns after database reset.");
assert.ok(snapshot.worldState.entities.player, "Expected player in default world state.");
assert.ok(snapshot.worldState.entities.room_start, "Expected room_start in default world state.");
assert.equal(rulebook.id, "rulebook_default", "Expected default rulebook to be active.");
assert.equal(rulebook.version, 1, "Expected default rulebook version to be 1.");
console.log(
JSON.stringify(
{
ok: true,
entityCount: Object.keys(snapshot.worldState.entities).length,
turnCount: snapshot.turns.length,
rulebookId: rulebook.id,
rulebookVersion: rulebook.version,
ruleCount: rulebook.rules.length,
},
null,
2
)
);
app.db.close();
}
run();

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,14 +3,18 @@ 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=/data/sqlite/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:
- ./data:/data - sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src - ./app/src:/app/src
frontend: frontend:
@@ -35,6 +39,24 @@ 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:
sqlite_data:

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
type Entity = { type Entity = {
id: string; id: string;
@@ -28,7 +28,27 @@ type Turn = {
validation: ValidationResult[]; validation: ValidationResult[];
createdAt: number; createdAt: number;
interpreter?: { interpreter?: {
interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected"; status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
candidates?: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
field?: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
}; };
}; };
@@ -51,12 +71,20 @@ type ProcessTurnResponse = {
worldState: WorldState; worldState: WorldState;
interpreter: { interpreter: {
interpreterVersion: string; interpreterVersion: string;
resolutionSource: "deterministic" | "llm" | "hybrid";
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected"; status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number; selectedConfidence?: number;
candidates: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[]; diagnostics: string[];
clarification?: { clarification?: {
reasonCode: string; reasonCode: string;
question: string; question: string;
field?: string;
options?: Array<{ options?: Array<{
id: string; id: string;
label: string; label: string;
@@ -91,14 +119,37 @@ type SceneRulebook = {
updatedAt: number; updatedAt: number;
}; };
type RulebookListItem = {
id: string;
name: string;
version: number;
updatedAt: number;
};
const starterPrompts = [ const starterPrompts = [
"look around", "look around",
"take key", "take key",
"take lantern",
"give key to groundskeeper", "give key to groundskeeper",
"introduce jeff",
"describe groundskeeper as patient",
"open door", "open door",
"move to exit", "move to exit",
]; ];
function formatConfidence(value: number | undefined): string {
if (typeof value !== "number") return "n/a";
return `${Math.round(value * 100)}%`;
}
function formatTurnTime(epochMs: number): string {
try {
return new Date(epochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return String(epochMs);
}
}
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> { async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init); const response = await fetch(input, init);
if (!response.ok) { if (!response.ok) {
@@ -111,7 +162,7 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
// Rulebook editor component // Rulebook editor component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function RulebookEditor() { function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null); const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({}); const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
@@ -182,6 +233,7 @@ function RulebookEditor() {
body: JSON.stringify({ ...rulebook, rules: updatedRules }), body: JSON.stringify({ ...rulebook, rules: updatedRules }),
}); });
setRulebook(updated); setRulebook(updated);
props.onSaved?.(updated);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2500); setTimeout(() => setSaved(false), 2500);
} catch (err) { } catch (err) {
@@ -198,6 +250,9 @@ function RulebookEditor() {
<div className="rulebook-header"> <div className="rulebook-header">
<div> <div>
<p className="rulebook-name">{rulebook.name}</p> <p className="rulebook-name">{rulebook.name}</p>
<p className="rulebook-desc">
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
</p>
{rulebook.description ? ( {rulebook.description ? (
<p className="rulebook-desc">{rulebook.description}</p> <p className="rulebook-desc">{rulebook.description}</p>
) : null} ) : null}
@@ -290,10 +345,21 @@ export default function App() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"world" | "rulebook">("world"); const [tab, setTab] = useState<"world" | "rulebook">("world");
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
async function refreshRulebookState() {
const [active, list] = await Promise.all([
fetchJson<SceneRulebook>("/api/rulebook"),
fetchJson<RulebookListItem[]>("/api/rulebooks"),
]);
setActiveRulebook(active);
setRulebooks(list);
}
useEffect(() => { useEffect(() => {
void fetchJson<AppSnapshot>("/api/state") void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
.then((data) => { .then(([data]) => {
setSnapshot(data); setSnapshot(data);
setLoading(false); setLoading(false);
}) })
@@ -315,7 +381,10 @@ export default function App() {
body: JSON.stringify({ input }), body: JSON.stringify({ input }),
}); });
setLatest(result); setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state"); const [nextSnapshot] = await Promise.all([
fetchJson<AppSnapshot>("/api/state"),
refreshRulebookState(),
]);
setSnapshot(nextSnapshot); setSnapshot(nextSnapshot);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error"); setError(submitError instanceof Error ? submitError.message : "Unknown error");
@@ -334,12 +403,22 @@ export default function App() {
}); });
setSnapshot(result); setSnapshot(result);
setLatest(null); setLatest(null);
await refreshRulebookState();
} catch (resetError) { } catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error"); setError(resetError instanceof Error ? resetError.message : "Unknown error");
} }
} }
const entities = snapshot ? Object.values(snapshot.worldState.entities) : []; const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
const turns = useMemo(() => snapshot?.turns.slice().reverse() ?? [], [snapshot]);
function applyClarificationOption(optionValue: string) {
setInput((prev) => {
const trimmed = prev.trim();
if (!trimmed) return optionValue;
return `${trimmed} ${optionValue}`;
});
}
return ( return (
<main className="page-shell"> <main className="page-shell">
@@ -382,20 +461,32 @@ export default function App() {
<p><strong>Input:</strong> {latest.rawText}</p> <p><strong>Input:</strong> {latest.rawText}</p>
<p> <p>
<strong>Interpreter:</strong> {latest.interpreter.status} <strong>Interpreter:</strong> {latest.interpreter.status}
{typeof latest.interpreter.selectedConfidence === "number" {` via ${latest.interpreter.resolutionSource}`}
? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)` {` | model threshold ${formatConfidence(latest.interpreter.minConfidence)}`}
: ""} {` | selected ${formatConfidence(latest.interpreter.selectedConfidence)}`}
</p>
<p>
<strong>Interpreter version:</strong> {latest.interpreter.interpreterVersion}
</p> </p>
{latest.interpreter.clarification ? ( {latest.interpreter.clarification ? (
<p> <p>
<strong>Clarification:</strong> {latest.interpreter.clarification.question} <strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
{latest.interpreter.clarification.question}
</p> </p>
) : null} ) : null}
{latest.interpreter.clarification?.options?.length ? ( {latest.interpreter.clarification?.options?.length ? (
<p> <div className="chips">
<strong>Options:</strong>{" "} {latest.interpreter.clarification.options.map((o) => (
{latest.interpreter.clarification.options.map((o) => o.label).join(", ")} <button
</p> key={o.id}
type="button"
className="chip"
onClick={() => applyClarificationOption(o.value)}
>
clarify: {o.label}
</button>
))}
</div>
) : null} ) : null}
<ul className="timeline-list compact"> <ul className="timeline-list compact">
{latest.validation.map((v) => ( {latest.validation.map((v) => (
@@ -417,6 +508,25 @@ export default function App() {
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")} <strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p> </p>
) : null} ) : null}
{latest.actions.length > 0 ? (
<details>
<summary>Selected actions ({latest.actions.length})</summary>
<pre>{JSON.stringify(latest.actions, null, 2)}</pre>
</details>
) : null}
{latest.interpreter.candidates.length > 0 ? (
<details>
<summary>Interpreter candidates ({latest.interpreter.candidates.length})</summary>
<ul className="timeline-list compact">
{latest.interpreter.candidates.map((candidate, index) => (
<li key={`${candidate.action.type}-${index}`}>
{candidate.action.type} at {formatConfidence(candidate.confidence)}
{candidate.rationale ? ` - ${candidate.rationale}` : ""}
</li>
))}
</ul>
</details>
) : null}
</section> </section>
) : null} ) : null}
@@ -446,6 +556,14 @@ export default function App() {
<article className="panel"> <article className="panel">
<h2>World state</h2> <h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null} {loading && !snapshot ? <p>Loading...</p> : null}
{snapshot ? (
<div className="meta-grid">
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
<p className="meta-kv"><strong>Domain:</strong> {String(snapshot.worldState.metadata?.domain ?? "unknown")}</p>
<p className="meta-kv"><strong>World schema:</strong> {String(snapshot.worldState.metadata?.version ?? "unknown")}</p>
</div>
) : null}
<ul className="entity-list"> <ul className="entity-list">
{entities.map((entity) => ( {entities.map((entity) => (
<li key={entity.id}> <li key={entity.id}>
@@ -459,11 +577,25 @@ export default function App() {
<article className="panel"> <article className="panel">
<h2>Turn log</h2> <h2>Turn log</h2>
<ul className="timeline-list"> <ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => ( {turns.map((turn) => (
<li key={turn.id}> <li key={turn.id}>
<strong>{turn.rawText}</strong> <strong>{turn.rawText}</strong>
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
{turn.interpreter ? ( {turn.interpreter ? (
<span> [interp:{turn.interpreter.status}]</span> <span>
{" "}[interp:{turn.interpreter.status} via {turn.interpreter.resolutionSource};
conf {formatConfidence(turn.interpreter.selectedConfidence)}]
</span>
) : null}
{turn.interpreter?.clarification ? (
<p className="parser-hint">
Clarification ({turn.interpreter.clarification.reasonCode}): {turn.interpreter.clarification.question}
</p>
) : null}
{turn.interpreter?.diagnostics?.length ? (
<p className="turn-diagnostics">
Diagnostics: {turn.interpreter.diagnostics.join(" | ")}
</p>
) : null} ) : null}
{turn.validation.map((v) => ( {turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span> <span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
@@ -472,11 +604,35 @@ export default function App() {
))} ))}
</ul> </ul>
</article> </article>
<article className="panel">
<h2>System</h2>
{activeRulebook ? (
<div className="meta-grid">
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
</div>
) : (
<p>Loading rulebook info...</p>
)}
{rulebooks.length > 0 ? (
<ul className="timeline-list compact">
{rulebooks.map((rb) => (
<li key={rb.id}>
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
</li>
))}
</ul>
) : null}
</article>
</> </>
) : ( ) : (
<article className="panel panel--full"> <article className="panel panel--full">
<h2>Rulebook editor</h2> <h2>Rulebook editor</h2>
<RulebookEditor /> <RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
</article> </article>
)} )}
</section> </section>

View File

@@ -161,6 +161,19 @@ pre {
padding: 8px 10px; padding: 8px 10px;
} }
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 12px;
margin-bottom: 10px;
}
.meta-kv {
margin: 0;
font-size: 0.86rem;
color: rgba(244, 239, 228, 0.86);
}
.error-banner { .error-banner {
margin-top: 16px; margin-top: 16px;
color: #ffd2b8; color: #ffd2b8;
@@ -375,4 +388,19 @@ pre {
.panel { .panel {
padding: 14px; padding: 14px;
} }
.meta-grid {
grid-template-columns: 1fr;
}
}
.turn-time {
opacity: 0.7;
font-size: 0.82rem;
}
.turn-diagnostics {
margin: 6px 0 0;
color: rgba(244, 239, 228, 0.74);
font-size: 0.82rem;
} }

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