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(),
};
},
};
}
}