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:
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user