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