feat: implement core application structure with Fastify server and SQLite persistence

- Add Fastify server in `app/src/index.ts` with health check and API routes for game state and turn processing.
- Create `latentEntities.ts` to handle personal item plausibility and promote beliefs to facts based on actor context.
- Introduce `llmAdapter.ts` for action extraction from prose input.
- Develop `truthEngine.ts` for pure validation logic, handling all verbs with explicit rejection reasons.
- Define new types in `types.ts` for facts, affordances, and latent entity requests/resolutions.
- Update `docker-compose.yml` for improved service structure and volume management.
- Create frontend structure with React, including Dockerfile, Vite configuration, and initial components for state inspection.
- Implement basic styles and HTML structure for the frontend application.
- Document current status and next steps in `thoughts.md`.
This commit is contained in:
2026-04-23 21:08:38 -04:00
parent 14a07bca7a
commit 1df2ae8164
20 changed files with 1830 additions and 14 deletions

View File

@@ -0,0 +1,316 @@
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";
export interface AppStateSnapshot {
entities: Entity[];
events: GameEvent[];
turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
}
export interface TurnResult {
narration: string;
parser: "fallback";
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;
}
function createSeedEntities(): Entity[] {
return [
createOffsceneRoom(),
{
id: "garden",
type: "room",
name: "Garden",
attributes: {
description: "A small overgrown garden with a weathered bench and a shed door nearby.",
},
},
{
id: "shed",
type: "room",
name: "Shed",
attributes: {
description: "A cramped tool shed that smells of old wood and oil.",
},
},
{
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",
},
},
{
id: "gate",
type: "object",
name: "Garden Gate",
attributes: {
location: "garden",
open: false,
locked: false,
},
},
{
id: "bench",
type: "object",
name: "Bench",
attributes: {
location: "garden",
},
},
];
}
function worldStateFromEntities(entities: Entity[]): WorldState {
return {
entities: new Map(entities.map((entity) => [entity.id, entity])),
};
}
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
): string {
const lines: string[] = [];
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 {
db.init();
const existing = db.listEntities();
if (existing.length > 0) {
return worldStateFromEntities(existing);
}
const seeded = createSeedEntities();
for (const entity of seeded) {
db.upsertEntity(entity);
}
return worldStateFromEntities(seeded);
}
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 } = 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);
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,
actions: normalizedActions,
accepted: validation.accepted,
rejected: validation.rejected,
latent_resolution: latentResolution,
snapshot: getSnapshot(),
};
}
return {
db,
getSnapshot,
processTurn,
};
}