Compare commits
12 Commits
c32fa977a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 28229d8d69 | |||
| 81e2a7828f | |||
| b4a2968399 | |||
| 3112b6e9fe | |||
| d38c799b27 | |||
| 7a022bc085 | |||
| 665646bc18 | |||
| 56c9cce4c7 | |||
| 76dee7e73f | |||
| ca49565117 | |||
| 5189446c73 | |||
| 0da62785d5 |
@@ -30,6 +30,15 @@ Status: COMPLETE
|
||||
- Interpreter contract and first interpreter module created
|
||||
- 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
NODE_ENV=development
|
||||
APP_PORT=3000
|
||||
APP_PORT=3024
|
||||
FRONTEND_PORT=5173
|
||||
DB_PATH=/data/sqlite/app.db
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
# Copy this file to .env and adjust values as needed.
|
||||
# Never commit .env — it is gitignored.
|
||||
|
||||
NODE_ENV=development
|
||||
APP_PORT=3000
|
||||
FRONTEND_PORT=5173
|
||||
DB_PATH=/data/sqlite/app.db
|
||||
# Host port the app is exposed on. Must match the container-internal port (right side of ports mapping).
|
||||
APP_PORT=3023
|
||||
|
||||
# Optional — only required when running with: docker compose --profile llm up
|
||||
OLLAMA_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
# Host port the frontend dev server is exposed on.
|
||||
FRONTEND_PORT=5173
|
||||
|
||||
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
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createDatabase, CharacterGardenDatabase } from "./db";
|
||||
import type { Entity } from "./contracts/entity";
|
||||
import type { SceneRulebook } from "./contracts/rulebook";
|
||||
import type { Turn } from "./contracts/turn";
|
||||
import type { WorldState } from "./contracts/world";
|
||||
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 {
|
||||
worldState: WorldState;
|
||||
@@ -16,170 +14,13 @@ export interface AppSnapshot {
|
||||
export interface CharacterGardenApp {
|
||||
db: CharacterGardenDatabase;
|
||||
getSnapshot(): AppSnapshot;
|
||||
processTurn(rawText: string): Promise<ProcessTurnResponse>;
|
||||
processTurn(rawText: string): Promise<TurnManagerResponse>;
|
||||
getRulebook(): SceneRulebook;
|
||||
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
|
||||
listRulebooks(): SceneRulebook[];
|
||||
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(
|
||||
db: CharacterGardenDatabase,
|
||||
worldState: WorldState
|
||||
@@ -221,7 +62,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||
|
||||
async processTurn(rawText: string) {
|
||||
const rulebook = loadActiveRulebook();
|
||||
const result = await processTurn(rawText, worldState, db, rulebook);
|
||||
const result = await runTurnManager(rawText, worldState, db, rulebook);
|
||||
worldState = result.worldState;
|
||||
return result;
|
||||
},
|
||||
@@ -238,6 +79,9 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||
};
|
||||
db.upsertRulebook(updated);
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 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
|
||||
* 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. */
|
||||
|
||||
@@ -155,6 +155,18 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
||||
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(`
|
||||
SELECT turn_id, interpreter_json
|
||||
FROM interpreter_events
|
||||
@@ -322,11 +334,52 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
||||
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) => ({
|
||||
id: row.id,
|
||||
rawText: row.raw_text,
|
||||
actions: [],
|
||||
validation: [],
|
||||
actions: actionsByTurnId.get(row.id) ?? [],
|
||||
validation: validationByTurnId.get(row.id) ?? [],
|
||||
createdAt: row.created_at,
|
||||
interpreter: interpreterByTurnId.get(row.id),
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,7 @@ export const DEFAULT_RULEBOOK_ID = "rulebook_default";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Action } from "../../contracts/action";
|
||||
import type { InterpreterOutput } from "../../contracts/intent";
|
||||
import type { WorldState } from "../../contracts/world";
|
||||
import type { ResolveIntentInput } from "../resolveIntent";
|
||||
|
||||
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 } {
|
||||
const system = [
|
||||
"You are an intent-to-actions resolver for a text adventure engine.",
|
||||
"Return ONLY JSON with this shape:",
|
||||
'{"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"}',
|
||||
"If unresolved, selectedActions must be an empty array and clarification must be present.",
|
||||
"Use canonical action types only. Do not invent fields.",
|
||||
].join(" ");
|
||||
"You will receive the current world state and a player command.",
|
||||
"Return ONLY a JSON object with this exact shape (no markdown, no prose):",
|
||||
JSON.stringify({
|
||||
status: "resolved|needs_clarification|rejected",
|
||||
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 = [
|
||||
`actorId: ${input.actorId}`,
|
||||
`input: ${JSON.stringify(input.rawText)}`,
|
||||
`minimum_confidence: ${input.minConfidence}`,
|
||||
].join("\n");
|
||||
`world_context:\n${worldContext}`,
|
||||
`player_input: ${JSON.stringify(input.rawText)}`,
|
||||
].join("\n\n");
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InterpreterOutput } from "../contracts/intent";
|
||||
import type { WorldState } from "../contracts/world";
|
||||
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
|
||||
import { resolveLlmIntent } from "./adapters/llmResolver";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ const DEFAULT_MIN_CONFIDENCE = 0.65;
|
||||
type InterpretTurnOptions = {
|
||||
mode?: ResolverMode;
|
||||
minConfidence?: number;
|
||||
worldState?: WorldState;
|
||||
};
|
||||
|
||||
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
|
||||
@@ -25,6 +27,7 @@ function buildInput(rawText: string, actorId: string, options?: InterpretTurnOpt
|
||||
rawText,
|
||||
actorId,
|
||||
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
|
||||
worldState: options?.worldState,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InterpreterOutput } from "../contracts/intent";
|
||||
import type { WorldState } from "../contracts/world";
|
||||
|
||||
export type ResolverMode = "deterministic" | "llm" | "hybrid";
|
||||
|
||||
@@ -6,6 +7,8 @@ export type ResolveIntentInput = {
|
||||
rawText: string;
|
||||
actorId: string;
|
||||
minConfidence: number;
|
||||
/** Optional world state — passed to LLM resolvers to provide entity context. */
|
||||
worldState?: WorldState;
|
||||
};
|
||||
|
||||
export type IntentResolver = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import type { ValidationResult } from "../contracts/validation";
|
||||
import type { WorldState } from "../contracts/world";
|
||||
import { interpretTurn } from "../interpreter/interpretTurn";
|
||||
import { validateInterpreterOutput } from "../interpreter/validateInterpreterOutput";
|
||||
import { validateActions } from "../truthEngine";
|
||||
import { createDefaultRulebook } from "../defaultRulebook";
|
||||
import { validateWithRulebook } from "../rulebookEngine";
|
||||
import { applyActions } from "../world/applyActions";
|
||||
|
||||
export type TurnManagerResponse = {
|
||||
@@ -39,7 +40,7 @@ export async function runTurnManager(
|
||||
db: CharacterGardenDatabase,
|
||||
rulebook?: SceneRulebook
|
||||
): Promise<TurnManagerResponse> {
|
||||
const interpreted = await interpretTurn(rawText, "player");
|
||||
const interpreted = await interpretTurn(rawText, "player", { worldState });
|
||||
const boundaryCheck = validateInterpreterOutput(interpreted);
|
||||
const interpreter: InterpreterOutput = boundaryCheck.isValid
|
||||
? interpreted
|
||||
@@ -84,7 +85,8 @@ export async function runTurnManager(
|
||||
}
|
||||
|
||||
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 turn: Turn = {
|
||||
|
||||
@@ -109,9 +109,9 @@ export function applyActions(
|
||||
case "take":
|
||||
if (actor && target) {
|
||||
target.attributes.location = `inventory:${actor.id}`;
|
||||
if (target.id === "key_1") {
|
||||
actor.attributes.has_key_1 = true;
|
||||
}
|
||||
// Set has_<item_id> on the actor so attributeRef rulebook checks work
|
||||
// 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) {
|
||||
nextState.entities[action.targetId] = {
|
||||
id: action.targetId,
|
||||
|
||||
162
charactergarden/app/src/world/seedWorld.ts
Normal file
162
charactergarden/app/src/world/seedWorld.ts
Normal 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;
|
||||
}
|
||||
@@ -3,12 +3,16 @@ services:
|
||||
build: ./app
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
- "${APP_PORT:-3024}:3024"
|
||||
environment:
|
||||
- APP_PORT=3000
|
||||
- APP_PORT=3024
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
- DB_PATH=/var/lib/charactergarden/app.db
|
||||
- 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:
|
||||
- sqlite_data:/var/lib/charactergarden
|
||||
- ./app/src:/app/src
|
||||
@@ -35,6 +39,23 @@ services:
|
||||
- ollama_data:/root/.ollama
|
||||
profiles:
|
||||
- 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:
|
||||
ollama_data:
|
||||
|
||||
@@ -4,15 +4,16 @@ import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ["beepc","cg.sketchferret.com"],
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://app:3000",
|
||||
target: "http://app:3024",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://app:3000",
|
||||
target: "http://app:3024",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
19
thoughts.md
19
thoughts.md
@@ -18,6 +18,18 @@
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -58,6 +70,13 @@
|
||||
- Existing DBs may hold older rulebooks missing new action rule sets.
|
||||
- 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)
|
||||
|
||||
### Iteration 1: LLM adapter hardening
|
||||
|
||||
Reference in New Issue
Block a user