feat: implement processTurn function to handle turn processing and world state updates

refactor: remove legacy types.ts file and update frontend to use new contracts

feat: add applyActions function to manage action application and world state mutation

chore: remove empty .gitkeep file from sqlite data directory

refactor: update frontend App component to align with new API contracts and improve UX

docs: revise project.md to reflect updated architecture and system requirements

docs: update thoughts.md with current status, architecture decisions, and remaining checks
This commit is contained in:
2026-04-24 01:04:17 -04:00
parent 2f6af46c79
commit 998635f542
21 changed files with 1472 additions and 1740 deletions

View File

@@ -1,329 +1,127 @@
import { randomUUID } from "node:crypto";
import { createDatabase, CharacterGardenDatabase } from "./db";
import { resolveLatentEntity } from "./latentEntities";
import { extractActionsFromProse } from "./llmAdapter";
import { applyChanges, createOffsceneRoom, OFFSCENE_ROOM_ID, validate, WorldState } from "./truthEngine";
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
import type { Entity } from "./contracts/entity";
import type { Turn } from "./contracts/turn";
import type { WorldState } from "./contracts/world";
import { processTurn, ProcessTurnResponse } from "./turns/processTurn";
export interface AppStateSnapshot {
entities: Entity[];
events: GameEvent[];
export interface AppSnapshot {
worldState: WorldState;
turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
}
export interface TurnResult {
narration: string;
parser: "fallback";
parser_feedback?: string;
actions: Action[];
accepted: Action[];
rejected: { action: Action; reason: string }[];
latent_resolution?: {
accepted: boolean;
reason: string;
entity_id?: string;
};
snapshot: AppStateSnapshot;
}
export interface CharacterGardenApp {
db: CharacterGardenDatabase;
getSnapshot(): AppStateSnapshot;
processTurn(input: string): TurnResult;
getSnapshot(): AppSnapshot;
processTurn(rawText: string): ProcessTurnResponse;
reset(): AppSnapshot;
}
function createSeedEntities(): Entity[] {
return [
createOffsceneRoom(),
{
id: "garden",
function createSeedWorldState(): WorldState {
const now = Date.now();
const entities: Record<string, Entity> = {
room_start: {
id: "room_start",
name: "Start Room",
type: "room",
name: "Garden",
attributes: {
description: "A small overgrown garden with a weathered bench and a shed door nearby.",
description: "A plain room with a locked door.",
},
},
{
id: "shed",
room_exit: {
id: "room_exit",
name: "Exit Room",
type: "room",
name: "Shed",
attributes: {
description: "A cramped tool shed that smells of old wood and oil.",
description: "A simple room beyond the door.",
},
},
{
player: {
id: "player",
type: "character",
name: "Player",
attributes: {
location: "garden",
clothed: true,
pocket_count: 4,
has_bag: false,
},
},
{
id: "groundskeeper",
type: "character",
name: "Groundskeeper",
attributes: {
location: "garden",
location: "room_start",
has_key_1: false,
},
},
{
id: "gate",
type: "object",
name: "Garden Gate",
door_1: {
id: "door_1",
name: "Old Door",
type: "door",
attributes: {
location: "garden",
location: "room_start",
openable: true,
locked: true,
requiredKey: "key_1",
open: false,
locked: false,
},
},
{
id: "bench",
type: "object",
name: "Bench",
key_1: {
id: "key_1",
name: "Brass Key",
type: "item",
attributes: {
location: "garden",
location: "room_start",
takeable: true,
},
},
];
}
};
function worldStateFromEntities(entities: Entity[]): WorldState {
return {
entities: new Map(entities.map((entity) => [entity.id, entity])),
id: randomUUID(),
entities,
metadata: {
domain: "door_key_mvp",
version: 1,
},
createdAt: now,
};
}
function entitiesFromWorldState(worldState: WorldState): Entity[] {
return Array.from(worldState.entities.values()).sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function sameRoom(worldState: WorldState, leftId: string, rightId: string): boolean {
const left = worldState.entities.get(leftId);
const right = worldState.entities.get(rightId);
return left?.attributes["location"] === right?.attributes["location"];
}
function describeTarget(worldState: WorldState, targetId: string | undefined): string {
if (!targetId) {
return "nothing in particular";
}
const entity = worldState.entities.get(targetId);
return entity?.name ?? targetId;
}
function narrateAction(action: Action, worldState: WorldState): string {
switch (action.verb) {
case "move": {
const targetName = describeTarget(worldState, action.target);
if (action.target === OFFSCENE_ROOM_ID) {
return `You step out of the active scene and into ${targetName.toLowerCase()}.`;
}
return `You move to ${targetName}.`;
}
case "open":
return `You open ${describeTarget(worldState, action.target)}.`;
case "close":
return `You close ${describeTarget(worldState, action.target)}.`;
case "take":
return `You take ${describeTarget(worldState, action.target)}.`;
case "drop":
return `You drop ${describeTarget(worldState, action.target)}.`;
case "use":
return `You use ${describeTarget(worldState, action.target)}.`;
case "inspect":
return `You inspect ${describeTarget(worldState, action.target)}.`;
case "speak":
return `You speak to ${describeTarget(worldState, action.target)}.`;
default:
return "You act.";
}
}
function narrateResult(
worldState: WorldState,
accepted: Action[],
rejected: { action: Action; reason: string }[],
latentReason?: string,
parserFeedback?: string
): string {
const lines: string[] = [];
if (parserFeedback) {
lines.push(parserFeedback);
}
if (latentReason) {
lines.push(latentReason);
}
for (const action of accepted) {
lines.push(narrateAction(action, worldState));
}
for (const rejection of rejected) {
lines.push(`Action failed: ${rejection.reason}.`);
}
if (lines.length === 0) {
lines.push("Nothing changes.");
}
return lines.join(" ");
}
function persistWorldState(db: CharacterGardenDatabase, worldState: WorldState): void {
for (const entity of worldState.entities.values()) {
db.upsertEntity(entity);
}
}
function hydrateInitialState(db: CharacterGardenDatabase): WorldState {
function ensureSeedState(db: CharacterGardenDatabase): WorldState {
db.init();
const existing = db.listEntities();
if (existing.length > 0) {
return worldStateFromEntities(existing);
const latest = db.getLatestWorldState();
if (latest) {
return latest;
}
const seeded = createSeedEntities();
for (const entity of seeded) {
db.upsertEntity(entity);
}
return worldStateFromEntities(seeded);
const seed = createSeedWorldState();
db.upsertEntities(Object.values(seed.entities));
db.insertWorldState(null, seed);
return seed;
}
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
const db = createDatabase({ dbPath });
let worldState = hydrateInitialState(db);
function getSnapshot(): AppStateSnapshot {
return {
entities: entitiesFromWorldState(worldState),
events: db.listEvents(),
turns: db.listTurns(),
beliefs: db.listBeliefs(),
summaries: db.listSummaries(),
};
}
function processTurn(input: string): TurnResult {
const turnNumber = db.listTurns().length + 1;
const { actions, parser, parser_feedback: parserFeedback } = extractActionsFromProse(input);
let activeWorldState = worldState;
let latentResolution: TurnResult["latent_resolution"];
const latentNoun = typeof actions[0]?.params?.["latent_item"] === "string"
? String(actions[0].params?.["latent_item"])
: null;
if (latentNoun) {
const resolution = resolveLatentEntity(
{ actor_id: actions[0].actor, noun: latentNoun, turn: turnNumber },
activeWorldState
);
latentResolution = {
accepted: resolution.accepted,
reason: resolution.reason,
entity_id: resolution.entity?.id,
};
if (resolution.accepted && resolution.entity) {
activeWorldState = {
entities: new Map(activeWorldState.entities).set(
resolution.entity.id,
resolution.entity
),
};
db.upsertEntity(resolution.entity);
for (const belief of resolution.beliefs) {
db.insertBelief(belief);
}
}
}
const normalizedActions = actions.map((action) => {
if (latentNoun && latentResolution?.accepted && latentResolution.entity_id) {
return {
actor: action.actor,
verb: "take" as const,
target: latentResolution.entity_id,
};
}
return action;
});
const validation = validate(normalizedActions, activeWorldState);
const nextWorldState = applyChanges(activeWorldState, validation.state_changes);
const narration = narrateResult(
nextWorldState,
validation.accepted,
validation.rejected,
latentResolution?.reason,
parserFeedback
);
const turnRecord: Turn = {
id: randomUUID(),
turn: turnNumber,
input,
output: narration,
timestamp: Date.now(),
};
db.insertTurn(turnRecord);
for (const action of validation.accepted) {
const event: GameEvent = {
id: randomUUID(),
turn: turnNumber,
action,
result: "success",
timestamp: Date.now(),
};
db.insertEvent(event);
}
for (const rejection of validation.rejected) {
const event: GameEvent = {
id: randomUUID(),
turn: turnNumber,
action: rejection.action,
result: "fail",
timestamp: Date.now(),
};
db.insertEvent(event);
}
worldState = nextWorldState;
persistWorldState(db, worldState);
return {
narration,
parser,
parser_feedback: parserFeedback,
actions: normalizedActions,
accepted: validation.accepted,
rejected: validation.rejected,
latent_resolution: latentResolution,
snapshot: getSnapshot(),
};
}
let worldState = ensureSeedState(db);
return {
db,
getSnapshot,
processTurn,
getSnapshot() {
return {
worldState,
turns: db.listTurns(),
};
},
processTurn(rawText: string) {
const result = processTurn(rawText, worldState, db);
worldState = result.worldState;
return result;
},
reset() {
db.wipe();
worldState = ensureSeedState(db);
return {
worldState,
turns: db.listTurns(),
};
},
};
}
}

View File

@@ -0,0 +1,7 @@
export type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
metadata?: Record<string, unknown>;
};

View File

@@ -0,0 +1,6 @@
export type Entity = {
id: string;
name: string;
type: string;
attributes: Record<string, unknown>;
};

View File

@@ -0,0 +1,10 @@
import type { Action } from "./action";
import type { ValidationResult } from "./validation";
export type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};

View File

@@ -0,0 +1,6 @@
export type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};

View File

@@ -0,0 +1,8 @@
import type { Entity } from "./entity";
export type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};

View File

@@ -2,7 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity";
import type { Turn } from "./contracts/turn";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
export interface DatabaseConfig {
dbPath: string;
@@ -12,58 +16,19 @@ export interface CharacterGardenDatabase {
sqlite: Database.Database;
init(): void;
close(): void;
upsertEntity(entity: Entity): void;
upsertEntities(entities: Entity[]): void;
listEntities(): Entity[];
insertEvent(event: GameEvent): void;
listEvents(): GameEvent[];
insertTurn(turn: Turn): void;
listTurns(): Turn[];
insertBelief(belief: Belief): void;
listBeliefs(entityId?: string): Belief[];
insertSummary(summary: Summary): void;
listSummaries(): Summary[];
insertActions(turnId: string, actions: Action[]): void;
insertValidationResults(turnId: string, results: ValidationResult[]): void;
insertWorldState(turnId: string | null, worldState: WorldState): void;
getLatestWorldState(): WorldState | null;
wipe(): void;
}
type EntityRow = {
id: string;
type: string;
name: string;
attributes_json: string;
};
type EventRow = {
id: string;
turn: number;
action_json: string;
result: "success" | "fail";
timestamp: number;
};
type TurnRow = {
id: string;
turn: number;
input: string;
output: string;
timestamp: number;
};
type BeliefRow = {
entity_id: string;
claim: string;
confidence: number;
};
type SummaryRow = {
id: string;
turn_start: number;
turn_end: number;
text: string;
timestamp: number;
};
function ensureParentDirectory(dbPath: string): void {
const directory = path.dirname(dbPath);
fs.mkdirSync(directory, { recursive: true });
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
function parseJson<T>(value: string): T {
@@ -72,51 +37,55 @@ function parseJson<T>(value: string): T {
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
ensureParentDirectory(config.dbPath);
const sqlite = new Database(config.dbPath);
const initStatements = [
`
CREATE TABLE IF NOT EXISTS turns (
id TEXT PRIMARY KEY,
raw_text TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
turn_id TEXT NOT NULL,
action_index INTEGER NOT NULL,
actor_id TEXT NOT NULL,
type TEXT NOT NULL,
target_id TEXT,
location_id TEXT,
metadata_json TEXT,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
`
CREATE TABLE IF NOT EXISTS validation_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
turn_id TEXT NOT NULL,
action_index INTEGER NOT NULL,
success INTEGER NOT NULL,
reason TEXT,
message TEXT,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
`
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
attributes_json TEXT NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS events (
CREATE TABLE IF NOT EXISTS world_states (
id TEXT PRIMARY KEY,
turn INTEGER NOT NULL,
action_json TEXT NOT NULL,
result TEXT NOT NULL CHECK(result IN ('success', 'fail')),
timestamp INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS turns (
id TEXT PRIMARY KEY,
turn INTEGER NOT NULL UNIQUE,
input TEXT NOT NULL,
output TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS beliefs (
entity_id TEXT NOT NULL,
claim TEXT NOT NULL,
confidence REAL NOT NULL,
PRIMARY KEY (entity_id, claim)
)
`,
`
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY,
turn_start INTEGER NOT NULL,
turn_end INTEGER NOT NULL,
text TEXT NOT NULL,
timestamp INTEGER NOT NULL
turn_id TEXT,
state_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(turn_id) REFERENCES turns(id)
)
`,
];
@@ -125,163 +94,207 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
sqlite.exec(statement);
}
const clearEntitiesStatement = sqlite.prepare("DELETE FROM entities");
const upsertEntityStatement = sqlite.prepare(`
INSERT INTO entities (id, type, name, attributes_json)
VALUES (@id, @type, @name, @attributes_json)
INSERT INTO entities (id, name, type, attributes_json)
VALUES (@id, @name, @type, @attributes_json)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
name = excluded.name,
type = excluded.type,
attributes_json = excluded.attributes_json
`);
const listEntitiesStatement = sqlite.prepare(`
SELECT id, type, name, attributes_json
SELECT id, name, type, attributes_json
FROM entities
ORDER BY id ASC
`);
const insertEventStatement = sqlite.prepare(`
INSERT INTO events (id, turn, action_json, result, timestamp)
VALUES (@id, @turn, @action_json, @result, @timestamp)
`);
const listEventsStatement = sqlite.prepare(`
SELECT id, turn, action_json, result, timestamp
FROM events
ORDER BY turn ASC, timestamp ASC, id ASC
`);
const insertTurnStatement = sqlite.prepare(`
INSERT INTO turns (id, turn, input, output, timestamp)
VALUES (@id, @turn, @input, @output, @timestamp)
INSERT INTO turns (id, raw_text, created_at)
VALUES (@id, @raw_text, @created_at)
`);
const listTurnsStatement = sqlite.prepare(`
SELECT id, turn, input, output, timestamp
SELECT id, raw_text, created_at
FROM turns
ORDER BY turn ASC
ORDER BY created_at ASC
`);
const insertBeliefStatement = sqlite.prepare(`
INSERT INTO beliefs (entity_id, claim, confidence)
VALUES (@entity_id, @claim, @confidence)
ON CONFLICT(entity_id, claim) DO UPDATE SET
confidence = excluded.confidence
const insertActionStatement = sqlite.prepare(`
INSERT INTO actions (
turn_id,
action_index,
actor_id,
type,
target_id,
location_id,
metadata_json
) VALUES (
@turn_id,
@action_index,
@actor_id,
@type,
@target_id,
@location_id,
@metadata_json
)
`);
const listBeliefsStatement = sqlite.prepare(`
SELECT entity_id, claim, confidence
FROM beliefs
ORDER BY entity_id ASC, claim ASC
const insertValidationStatement = sqlite.prepare(`
INSERT INTO validation_results (
turn_id,
action_index,
success,
reason,
message
) VALUES (
@turn_id,
@action_index,
@success,
@reason,
@message
)
`);
const listBeliefsByEntityStatement = sqlite.prepare(`
SELECT entity_id, claim, confidence
FROM beliefs
WHERE entity_id = ?
ORDER BY claim ASC
const insertWorldStateStatement = sqlite.prepare(`
INSERT INTO world_states (id, turn_id, state_json, created_at)
VALUES (@id, @turn_id, @state_json, @created_at)
`);
const insertSummaryStatement = sqlite.prepare(`
INSERT INTO summaries (id, turn_start, turn_end, text, timestamp)
VALUES (@id, @turn_start, @turn_end, @text, @timestamp)
`);
const listSummariesStatement = sqlite.prepare(`
SELECT id, turn_start, turn_end, text, timestamp
FROM summaries
ORDER BY turn_start ASC, turn_end ASC
const latestWorldStateStatement = sqlite.prepare(`
SELECT state_json
FROM world_states
ORDER BY created_at DESC
LIMIT 1
`);
return {
sqlite,
init() {
// Schema is applied on database construction so prepared statements are valid.
// Tables are initialized on construction.
},
close() {
sqlite.close();
},
upsertEntity(entity) {
upsertEntityStatement.run({
id: entity.id,
type: entity.type,
name: entity.name,
attributes_json: JSON.stringify(entity.attributes),
wipe() {
sqlite.exec(`
DELETE FROM validation_results;
DELETE FROM actions;
DELETE FROM world_states;
DELETE FROM turns;
DELETE FROM entities;
`);
},
upsertEntities(entities) {
const tx = sqlite.transaction((entityList: Entity[]) => {
clearEntitiesStatement.run();
for (const entity of entityList) {
upsertEntityStatement.run({
id: entity.id,
name: entity.name,
type: entity.type,
attributes_json: JSON.stringify(entity.attributes),
});
}
});
tx(entities);
},
listEntities() {
const rows = listEntitiesStatement.all() as EntityRow[];
const rows = listEntitiesStatement.all() as Array<{
id: string;
name: string;
type: string;
attributes_json: string;
}>;
return rows.map((row) => ({
id: row.id,
type: row.type,
name: row.name,
type: row.type,
attributes: parseJson<Record<string, unknown>>(row.attributes_json),
}));
},
insertEvent(event) {
insertEventStatement.run({
id: event.id,
turn: event.turn,
action_json: JSON.stringify(event.action),
result: event.result,
timestamp: event.timestamp,
});
},
listEvents() {
const rows = listEventsStatement.all() as EventRow[];
return rows.map((row) => ({
id: row.id,
turn: row.turn,
action: parseJson<Action>(row.action_json),
result: row.result,
timestamp: row.timestamp,
}));
},
insertTurn(turn) {
insertTurnStatement.run(turn);
insertTurnStatement.run({
id: turn.id,
raw_text: turn.rawText,
created_at: turn.createdAt,
});
},
listTurns() {
return listTurnsStatement.all() as TurnRow[];
const rows = listTurnsStatement.all() as Array<{
id: string;
raw_text: string;
created_at: number;
}>;
return rows.map((row) => ({
id: row.id,
rawText: row.raw_text,
actions: [],
validation: [],
createdAt: row.created_at,
}));
},
insertBelief(belief) {
insertBeliefStatement.run(belief);
insertActions(turnId, actions) {
const tx = sqlite.transaction((actionList: Action[]) => {
actionList.forEach((action, index) => {
insertActionStatement.run({
turn_id: turnId,
action_index: index,
actor_id: action.actorId,
type: action.type,
target_id: action.targetId ?? null,
location_id: action.locationId ?? null,
metadata_json: action.metadata ? JSON.stringify(action.metadata) : null,
});
});
});
tx(actions);
},
listBeliefs(entityId) {
if (entityId) {
return listBeliefsByEntityStatement.all(entityId) as BeliefRow[];
}
insertValidationResults(turnId, results) {
const tx = sqlite.transaction((validationList: ValidationResult[]) => {
for (const result of validationList) {
insertValidationStatement.run({
turn_id: turnId,
action_index: result.actionIndex,
success: result.success ? 1 : 0,
reason: result.reason ?? null,
message: result.message ?? null,
});
}
});
return listBeliefsStatement.all() as BeliefRow[];
tx(results);
},
insertSummary(summary) {
insertSummaryStatement.run({
id: summary.id,
turn_start: summary.turn_range[0],
turn_end: summary.turn_range[1],
text: summary.text,
timestamp: summary.timestamp,
insertWorldState(turnId, worldState) {
insertWorldStateStatement.run({
id: worldState.id,
turn_id: turnId,
state_json: JSON.stringify(worldState),
created_at: worldState.createdAt,
});
},
listSummaries() {
const rows = listSummariesStatement.all() as SummaryRow[];
return rows.map((row) => ({
id: row.id,
turn_range: [row.turn_start, row.turn_end],
text: row.text,
timestamp: row.timestamp,
}));
getLatestWorldState() {
const row = latestWorldStateStatement.get() as { state_json: string } | undefined;
if (!row) {
return null;
}
return parseJson<WorldState>(row.state_json);
},
};
}
}

View File

@@ -13,6 +13,8 @@ server.get("/health", async () => ({ ok: true }));
server.get("/api/state", async () => game.getSnapshot());
server.post("/api/reset", async () => game.reset());
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
const input = request.body?.input?.trim();
if (!input) {

View File

@@ -1,209 +0,0 @@
import {
Affordance,
Belief,
Entity,
Fact,
LatentEntityRequest,
LatentEntityResolution,
} from "./types";
import { WorldState } from "./truthEngine";
const PERSONAL_ITEM_NOUNS = new Set([
"phone",
"wallet",
"keys",
"notebook",
"pen",
"coin",
"id card",
"card",
]);
function asBoolean(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
return null;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return null;
}
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "item";
}
function deriveAffordances(actor: Entity): Affordance[] {
const clothed = asBoolean(actor.attributes["clothed"]);
const pocketCount = asNumber(actor.attributes["pocket_count"]);
const hasBag = asBoolean(actor.attributes["has_bag"]);
const searchedEmpty = asBoolean(actor.attributes["searched_empty"]);
const canConcealSmallItems =
searchedEmpty !== true &&
((clothed === true && (pocketCount ?? 0) > 0) || hasBag === true);
const reason = searchedEmpty === true
? "actor was previously established as carrying nothing"
: hasBag === true
? "actor is carrying a bag or container"
: clothed === true && (pocketCount ?? 0) > 0
? "actor is clothed and has pockets"
: "actor has no established carrying context for concealed items";
return [
{
entity_id: actor.id,
key: "can_conceal_small_items",
enabled: canConcealSmallItems,
reason,
},
];
}
function buildBelief(actor: Entity, noun: string): Belief {
return {
entity_id: actor.id,
claim: `${actor.name} may be carrying a ${noun}`,
confidence: PERSONAL_ITEM_NOUNS.has(noun) ? 0.8 : 0.5,
};
}
function createEntityId(actorId: string, noun: string, worldState: WorldState): string {
const base = `${actorId}-${slugify(noun)}`;
if (!worldState.entities.has(base)) {
return base;
}
let suffix = 2;
while (worldState.entities.has(`${base}-${suffix}`)) {
suffix += 1;
}
return `${base}-${suffix}`;
}
function createLatentEntity(
actor: Entity,
noun: string,
turn: number | undefined,
worldState: WorldState
): Entity {
const entityId = createEntityId(actor.id, noun, worldState);
return {
id: entityId,
type: "item",
name: noun,
attributes: {
location: `inventory:${actor.id}`,
takeable: true,
useable: true,
provenance: {
introduced_turn: turn,
introduced_by: actor.id,
introduced_reason: "plausible_personal_item",
latent_from_belief: `${actor.name} may be carrying a ${noun}`,
},
},
};
}
export function resolveLatentEntity(
request: LatentEntityRequest,
worldState: WorldState
): LatentEntityResolution {
const actor = worldState.entities.get(request.actor_id);
if (!actor) {
return {
accepted: false,
reason: `actor entity '${request.actor_id}' does not exist`,
facts: [],
beliefs: [],
affordances: [],
};
}
const noun = request.noun.trim().toLowerCase();
if (!noun) {
return {
accepted: false,
reason: "latent entity request requires a noun",
facts: [],
beliefs: [],
affordances: [],
};
}
const affordances = deriveAffordances(actor);
const concealment = affordances.find(
(affordance) => affordance.key === "can_conceal_small_items"
);
const belief = buildBelief(actor, noun);
if (asBoolean(actor.attributes["naked"]) === true) {
return {
accepted: false,
reason: `${actor.id} is established as naked and cannot plausibly conceal a ${noun}`,
facts: [],
beliefs: [belief],
affordances,
};
}
if (concealment?.enabled !== true) {
return {
accepted: false,
reason: concealment?.reason ?? `no carrying context supports introducing a ${noun}`,
facts: [],
beliefs: [belief],
affordances,
};
}
if (!PERSONAL_ITEM_NOUNS.has(noun)) {
return {
accepted: false,
reason: `${noun} is not in the MVP plausible personal-item set`,
facts: [],
beliefs: [belief],
affordances,
};
}
const entity = createLatentEntity(actor, noun, request.turn, worldState);
const facts: Fact[] = [
{
entity_id: actor.id,
key: `may_have_${slugify(noun)}`,
value: true,
source: "inference",
},
{
entity_id: entity.id,
key: "location",
value: `inventory:${actor.id}`,
source: "inference",
},
];
return {
accepted: true,
reason: `${noun} promoted from plausible latent belief to fact`,
entity,
facts,
beliefs: [belief],
affordances,
};
}

View File

@@ -1,210 +0,0 @@
import { Action, ALLOWED_VERBS, Entity } from "./types";
export interface ExtractedActions {
actions: Action[];
parser: "fallback";
parser_feedback?: string;
}
export interface ActionExtractionPrompt {
system: string;
user: string;
}
function toEntityLine(entity: Entity): string {
const location = typeof entity.attributes["location"] === "string"
? ` location=${entity.attributes["location"]}`
: "";
return `- ${entity.id} [${entity.type}] "${entity.name}"${location}`;
}
export function buildActionExtractionPrompt(input: string, entities: Entity[], actorId = "player"): ActionExtractionPrompt {
const entityDigest = entities
.slice()
.sort((left, right) => left.id.localeCompare(right.id))
.map(toEntityLine)
.join("\n");
const system = [
"You convert player prose into canonical game actions.",
"Only produce actions that are valid in the current world snapshot.",
`Allowed verbs: ${ALLOWED_VERBS.join(", ")}`,
"Use exact entity ids from the world snapshot for actor and target.",
"If intent is unclear or target is missing, return no actions and a parser_feedback string suggesting rephrasing.",
"Return strict JSON only: {\"actions\": Action[], \"parser_feedback\"?: string}",
].join("\n");
const user = [
`Actor id: ${actorId}`,
"World snapshot entities:",
entityDigest || "- (none)",
`Player input: ${input}`,
].join("\n");
return { system, user };
}
const REPHRASE_EXAMPLES = "Try rephrasing like: 'look around', 'go to the shed', 'open the gate', or 'pull out my phone'.";
const ROOM_ALIASES: Record<string, string> = {
garden: "garden",
shed: "shed",
offscene: "offscene",
outside: "offscene",
away: "offscene",
};
const TARGET_ALIASES: Record<string, string> = {
gate: "gate",
bench: "bench",
groundskeeper: "groundskeeper",
keeper: "groundskeeper",
shed: "shed",
garden: "garden",
offscene: "offscene",
};
function normalized(input: string): string {
return input.trim().toLowerCase();
}
function extractQuotedOrTrailingNoun(input: string): string | null {
const quoted = input.match(/"([^"]+)"|'([^']+)'/);
if (quoted) {
return (quoted[1] ?? quoted[2]).trim().toLowerCase();
}
const pulled = input.match(/(?:pull|pulls|pulled|take|takes|took)\s+(?:out\s+)?(?:a|an|the|my|their|his|her)?\s*([a-z0-9 ]+)$/i);
if (pulled?.[1]) {
return pulled[1].trim().toLowerCase();
}
return null;
}
function resolveTarget(input: string): string | undefined {
const direct = Object.entries(TARGET_ALIASES).find(([alias]) =>
input.includes(alias)
);
return direct?.[1];
}
export function extractActionsFromProse(input: string, actorId = "player"): ExtractedActions {
const text = normalized(input);
if (!text) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I couldn't parse an empty turn. ${REPHRASE_EXAMPLES}`,
};
}
const room = Object.entries(ROOM_ALIASES).find(([alias]) => text.includes(alias))?.[1];
if (/(go|move|walk|head|travel)/.test(text) && room) {
return {
actions: [{ actor: actorId, verb: "move", target: room }],
parser: "fallback",
};
}
if (/(go|move|walk|head|travel)/.test(text) && !room) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood movement, but not the destination. Try 'go to the shed' or 'go to the garden'.`,
};
}
if (/(open)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }],
parser: "fallback",
};
}
if (/(close|shut)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "close", target: resolveTarget(text) ?? "gate" }],
parser: "fallback",
};
}
if (/(take|pick up|grab)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood 'take' but not what item you meant. Try 'take the bench' or 'pull out my phone'.`,
};
}
return {
actions: [{ actor: actorId, verb: "take", target }],
parser: "fallback",
};
}
if (/(drop|put down|set down)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood 'drop' but not which item. Try 'drop phone' or 'drop keys'.`,
};
}
return {
actions: [{ actor: actorId, verb: "drop", target }],
parser: "fallback",
};
}
if (/(talk|speak|ask|say)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "speak", target: resolveTarget(text) ?? "groundskeeper", params: { utterance: input } }],
parser: "fallback",
};
}
if (/(use|press|activate)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return {
actions: [],
parser: "fallback",
parser_feedback: `I understood 'use' but not the target. Try 'use gate' or 'use phone'.`,
};
}
return {
actions: [{ actor: actorId, verb: "use", target }],
parser: "fallback",
};
}
if (/(look|inspect|examine)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "inspect", target: resolveTarget(text) ?? actorId }],
parser: "fallback",
};
}
const latentNoun = extractQuotedOrTrailingNoun(text);
if (latentNoun && /(pull|pulls|pulled|take|takes|took).*(out)/.test(text)) {
return {
actions: [{ actor: actorId, verb: "inspect", params: { latent_item: latentNoun } }],
parser: "fallback",
};
}
return {
actions: [],
parser: "fallback",
parser_feedback: `I couldn't map that request to a game action. ${REPHRASE_EXAMPLES}`,
};
}

View File

@@ -0,0 +1,42 @@
import type { Action } from "../contracts/action";
function normalized(input: string): string {
return input.trim().toLowerCase();
}
export function parseTextToActions(text: string, actorId = "player"): Action[] {
const input = normalized(text);
if (!input) {
return [];
}
if (/(look|inspect|examine)/.test(input)) {
return [{ actorId, type: "inspect", targetId: actorId }];
}
if (/(go|move|walk|head|travel)/.test(input)) {
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
return [{ actorId, type: "move", targetId: "room_exit" }];
}
if (input.includes("start")) {
return [{ actorId, type: "move", targetId: "room_start" }];
}
return [];
}
if (/(open)/.test(input)) {
if (input.includes("door")) {
return [{ actorId, type: "open", targetId: "door_1" }];
}
return [];
}
if (/(take|pick up|grab)/.test(input)) {
if (input.includes("key")) {
return [{ actorId, type: "take", targetId: "key_1" }];
}
return [];
}
return [];
}

View File

@@ -1,294 +1,125 @@
/**
* Truth Engine — section 5.2
*
* Pure validation logic. No LLM. No I/O. No side effects.
* Receives a world state snapshot and a list of actions.
* Returns what is accepted, what is rejected, and what would change.
*
* Rules (section 3):
* 1. Only the Truth Engine may produce StateChanges.
* 2. LLM output is never directly trusted.
* 3. Every state change must be traceable to an accepted action.
* 4. Invalid actions must return explicit failure reasons.
*/
import type { Action } from "./contracts/action";
import type { Entity } from "./contracts/entity";
import type { ValidationResult } from "./contracts/validation";
import type { WorldState } from "./contracts/world";
import { Action, Entity, StateChange, ValidationResult, ALLOWED_VERBS } from "./types";
export const OFFSCENE_ROOM_ID = "offscene";
// ── World state snapshot passed into validate() ──────────────
export interface WorldState {
entities: Map<string, Entity>;
}
// ── Per-verb rule handlers ────────────────────────────────────
type RuleResult =
| { ok: true; changes: StateChange[] }
| { ok: false; reason: string };
type VerbHandler = (
action: Action,
actor: Entity,
world: WorldState
) => RuleResult;
// ── Helpers ───────────────────────────────────────────────────
function requireTarget(
action: Action,
world: WorldState
): { ok: true; target: Entity } | { ok: false; reason: string } {
if (!action.target) {
return { ok: false, reason: `'${action.verb}' requires a target` };
function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined {
if (!entityId) {
return undefined;
}
const target = world.entities.get(action.target);
if (!target) {
return {
ok: false,
reason: `target entity '${action.target}' does not exist`,
};
}
return { ok: true, target };
return worldState.entities[entityId];
}
function attributeChange(
entity: Entity,
field: string,
newValue: unknown
): StateChange {
return {
entity_id: entity.id,
field,
old_value: entity.attributes[field] ?? null,
new_value: newValue,
};
function hasKey(actor: Entity, requiredKeyId: string): boolean {
return actor.attributes[`has_${requiredKeyId}`] === true;
}
// ── Verb handlers ─────────────────────────────────────────────
const verbHandlers: Record<string, VerbHandler> = {
move(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
// Target must be a room/location. The built-in offscene room is valid.
if (t.target.type !== "room") {
return {
ok: false,
reason: `cannot move to '${t.target.id}': not a room`,
};
}
return {
ok: true,
changes: [attributeChange(actor, "location", t.target.id)],
};
},
open(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["locked"] === true) {
return {
ok: false,
reason: `'${t.target.id}' is locked and cannot be opened`,
};
}
if (t.target.attributes["open"] === true) {
return { ok: false, reason: `'${t.target.id}' is already open` };
}
return {
ok: true,
changes: [attributeChange(t.target, "open", true)],
};
},
close(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["open"] === false) {
return { ok: false, reason: `'${t.target.id}' is already closed` };
}
return {
ok: true,
changes: [attributeChange(t.target, "open", false)],
};
},
take(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["takeable"] === false) {
return {
ok: false,
reason: `'${t.target.id}' cannot be taken`,
};
}
// Item must be in the same location as actor (unless already in inventory)
const actorLocation = actor.attributes["location"];
const itemLocation = t.target.attributes["location"];
const expectedInventory = `inventory:${actor.id}`;
// If already in inventory, it's a no-op (already holding it)
if (itemLocation === expectedInventory) {
return {
ok: true,
changes: [],
};
}
if (actorLocation !== itemLocation) {
return {
ok: false,
reason: `'${t.target.id}' is not in the same location as '${actor.id}'`,
};
}
return {
ok: true,
changes: [attributeChange(t.target, "location", expectedInventory)],
};
},
drop(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
const expectedLocation = `inventory:${actor.id}`;
if (t.target.attributes["location"] !== expectedLocation) {
return {
ok: false,
reason: `'${t.target.id}' is not in '${actor.id}' inventory`,
};
}
return {
ok: true,
changes: [
attributeChange(t.target, "location", actor.attributes["location"]),
],
};
},
use(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.attributes["useable"] === false) {
return { ok: false, reason: `'${t.target.id}' cannot be used` };
}
// Generic "use" records a state change marking last user; concrete effects
// are handled by higher-level game logic layered on top.
return {
ok: true,
changes: [attributeChange(t.target, "last_used_by", actor.id)],
};
},
inspect(_action, _actor, _world) {
// inspect is always valid — it has no side effects
return { ok: true, changes: [] };
},
speak(action, actor, world) {
const t = requireTarget(action, world);
if (!t.ok) return t;
if (t.target.type !== "character") {
return {
ok: false,
reason: `cannot speak to '${t.target.id}': not a character`,
};
}
return { ok: true, changes: [] };
},
};
// ── Main export ───────────────────────────────────────────────
/**
* Validate a list of actions against the current world state.
* Pure function — does NOT mutate worldState.
*/
export function validate(
actions: Action[],
worldState: WorldState
): ValidationResult {
const accepted: Action[] = [];
const rejected: { action: Action; reason: string }[] = [];
const state_changes: StateChange[] = [];
for (const action of actions) {
// 1. Verb must be in the allowed set
if (!(ALLOWED_VERBS as readonly string[]).includes(action.verb)) {
rejected.push({ action, reason: `unknown verb '${action.verb}'` });
continue;
}
// 2. Actor must exist
const actor = worldState.entities.get(action.actor);
export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] {
return actions.map((action, actionIndex): ValidationResult => {
const actor = getEntity(worldState, action.actorId);
if (!actor) {
rejected.push({
action,
reason: `actor entity '${action.actor}' does not exist`,
});
continue;
return {
actionIndex,
success: false,
reason: "actor_not_found",
message: `Actor '${action.actorId}' does not exist.`,
};
}
// 3. Run verb-specific handler
const handler = verbHandlers[action.verb];
const result = handler(action, actor, worldState);
switch (action.type) {
case "inspect":
return { actionIndex, success: true };
if (!result.ok) {
rejected.push({ action, reason: result.reason });
} else {
accepted.push(action);
state_changes.push(...result.changes);
case "take": {
const target = getEntity(worldState, action.targetId);
if (!target) {
return {
actionIndex,
success: false,
reason: "target_not_found",
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
};
}
const actorLocation = String(actor.attributes.location ?? "");
const targetLocation = String(target.attributes.location ?? "");
if (actorLocation !== targetLocation) {
return {
actionIndex,
success: false,
reason: "not_in_same_location",
message: `Target '${target.id}' is not in the same location as '${actor.id}'.`,
};
}
if (target.attributes.takeable !== true) {
return {
actionIndex,
success: false,
reason: "not_takeable",
message: `Target '${target.id}' cannot be taken.`,
};
}
return { actionIndex, success: true };
}
case "open": {
const target = getEntity(worldState, action.targetId);
if (!target) {
return {
actionIndex,
success: false,
reason: "target_not_found",
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
};
}
if (target.attributes.openable !== true) {
return {
actionIndex,
success: false,
reason: "not_openable",
message: `Target '${target.id}' is not openable.`,
};
}
if (target.attributes.locked === true) {
const requiredKey = String(target.attributes.requiredKey ?? "key_1");
if (!hasKey(actor, requiredKey)) {
return {
actionIndex,
success: false,
reason: "locked_requires_key",
message: `Target '${target.id}' is locked and requires '${requiredKey}'.`,
};
}
}
return { actionIndex, success: true };
}
case "move": {
const target = getEntity(worldState, action.targetId);
if (!target || target.type !== "room") {
return {
actionIndex,
success: false,
reason: "target_not_found",
message: `Move target '${action.targetId ?? "(missing)"}' is not a valid room.`,
};
}
return { actionIndex, success: true };
}
default:
return {
actionIndex,
success: false,
reason: "unknown_action",
message: `Action type '${action.type}' is not supported.`,
};
}
}
return { accepted, rejected, state_changes };
}
/**
* Apply a validated set of StateChanges to a WorldState snapshot.
* Returns a new Map — does NOT mutate the original.
*/
export function applyChanges(
worldState: WorldState,
changes: StateChange[]
): WorldState {
const next = new Map(
Array.from(worldState.entities.entries()).map(([id, entity]) => [
id,
{ ...entity, attributes: { ...entity.attributes } },
])
);
for (const change of changes) {
const entity = next.get(change.entity_id);
if (entity) {
entity.attributes[change.field] = change.new_value;
}
}
return { entities: next };
}
export function createOffsceneRoom(): Entity {
return {
id: OFFSCENE_ROOM_ID,
type: "room",
name: "Offscene",
attributes: {
offscene: true,
visible: false,
},
};
});
}

View File

@@ -0,0 +1,48 @@
import { randomUUID } from "node:crypto";
import type { CharacterGardenDatabase } from "../db";
import type { Action } from "../contracts/action";
import type { Turn } from "../contracts/turn";
import type { ValidationResult } from "../contracts/validation";
import type { WorldState } from "../contracts/world";
import { parseTextToActions } from "../parser/parseTextToActions";
import { validateActions } from "../truthEngine";
import { applyActions } from "../world/applyActions";
export type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
};
export function processTurn(
rawText: string,
worldState: WorldState,
db: CharacterGardenDatabase
): ProcessTurnResponse {
const actions = parseTextToActions(rawText);
const validation = validateActions(actions, worldState);
const nextWorldState = applyActions(actions, validation, worldState);
const turn: Turn = {
id: randomUUID(),
rawText,
actions,
validation,
createdAt: Date.now(),
};
db.insertTurn(turn);
db.insertActions(turn.id, actions);
db.insertValidationResults(turn.id, validation);
db.upsertEntities(Object.values(nextWorldState.entities));
db.insertWorldState(turn.id, nextWorldState);
return {
rawText,
actions,
validation,
worldState: nextWorldState,
};
}

View File

@@ -1,112 +0,0 @@
// Core contracts — DO NOT modify without updating project.md
// ── Section 2.1 ─────────────────────────────────────────────
export interface Entity {
id: string;
type: string;
name: string;
attributes: Record<string, unknown>;
}
// ── Section 2.2 ─────────────────────────────────────────────
export const ALLOWED_VERBS = [
"move",
"open",
"close",
"take",
"drop",
"use",
"inspect",
"speak",
] as const;
export type Verb = (typeof ALLOWED_VERBS)[number];
export interface Action {
actor: string; // entity id
verb: Verb;
target?: string; // entity id
params?: Record<string, unknown>;
}
// ── Section 2.3 ─────────────────────────────────────────────
export interface ValidationResult {
accepted: Action[];
rejected: { action: Action; reason: string }[];
state_changes: StateChange[];
}
// ── Section 2.4 ─────────────────────────────────────────────
export interface StateChange {
entity_id: string;
field: string;
old_value: unknown;
new_value: unknown;
}
// ── Section 2.5 ─────────────────────────────────────────────
export interface GameEvent {
id: string;
turn: number;
action: Action;
result: "success" | "fail";
timestamp: number;
}
// ── Section 4 — Memory types ─────────────────────────────────
export interface Turn {
id: string;
turn: number;
input: string;
output: string;
timestamp: number;
}
export interface Belief {
entity_id: string;
claim: string;
confidence: number;
}
export interface Fact {
entity_id: string;
key: string;
value: unknown;
source: "seed" | "action" | "inference";
}
export interface Affordance {
entity_id: string;
key: string;
enabled: boolean;
reason: string;
}
export interface EntityProvenance {
introduced_turn?: number;
introduced_by?: string;
introduced_reason?: string;
latent_from_belief?: string;
}
export interface LatentEntityRequest {
actor_id: string;
noun: string;
turn?: number;
}
export interface LatentEntityResolution {
accepted: boolean;
reason: string;
entity?: Entity;
facts: Fact[];
beliefs: Belief[];
affordances: Affordance[];
}
export interface Summary {
id: string;
turn_range: [number, number];
text: string;
timestamp: number;
}

View File

@@ -0,0 +1,73 @@
import { randomUUID } from "node:crypto";
import type { Action } from "../contracts/action";
import type { ValidationResult } from "../contracts/validation";
import type { Entity } from "../contracts/entity";
import type { WorldState } from "../contracts/world";
function cloneWorldState(worldState: WorldState): WorldState {
const entities: Record<string, Entity> = {};
for (const [id, entity] of Object.entries(worldState.entities)) {
entities[id] = {
...entity,
attributes: { ...entity.attributes },
};
}
return {
...worldState,
entities,
metadata: { ...worldState.metadata },
};
}
export function applyActions(
actions: Action[],
results: ValidationResult[],
worldState: WorldState
): WorldState {
const nextState = cloneWorldState(worldState);
for (const result of results) {
if (!result.success) {
continue;
}
const action = actions[result.actionIndex];
if (!action) {
continue;
}
const actor = nextState.entities[action.actorId];
const target = action.targetId ? nextState.entities[action.targetId] : undefined;
switch (action.type) {
case "move":
if (actor && action.targetId) {
actor.attributes.location = action.targetId;
}
break;
case "take":
if (actor && target) {
target.attributes.location = `inventory:${actor.id}`;
if (target.id === "key_1") {
actor.attributes.has_key_1 = true;
}
}
break;
case "open":
if (target) {
target.attributes.open = true;
}
break;
case "inspect":
default:
break;
}
}
nextState.id = randomUUID();
nextState.createdAt = Date.now();
return nextState;
}