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,283 @@
/**
* 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 { 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` };
}
const target = world.entities.get(action.target);
if (!target) {
return {
ok: false,
reason: `target entity '${action.target}' does not exist`,
};
}
return { ok: true, target };
}
function attributeChange(
entity: Entity,
field: string,
newValue: unknown
): StateChange {
return {
entity_id: entity.id,
field,
old_value: entity.attributes[field] ?? null,
new_value: newValue,
};
}
// ── 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
const actorLocation = actor.attributes["location"];
const itemLocation = t.target.attributes["location"];
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", `inventory:${actor.id}`)],
};
},
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);
if (!actor) {
rejected.push({
action,
reason: `actor entity '${action.actor}' does not exist`,
});
continue;
}
// 3. Run verb-specific handler
const handler = verbHandlers[action.verb];
const result = handler(action, actor, worldState);
if (!result.ok) {
rejected.push({ action, reason: result.reason });
} else {
accepted.push(action);
state_changes.push(...result.changes);
}
}
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,
},
};
}