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:
316
charactergarden/app/src/app.ts
Normal file
316
charactergarden/app/src/app.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user