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,294 +1,125 @@
|
||||
/**
|
||||
* 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 type { Action } from "./contracts/action";
|
||||
import type { Entity } from "./contracts/entity";
|
||||
import type { ValidationResult } from "./contracts/validation";
|
||||
import type { WorldState } from "./contracts/world";
|
||||
|
||||
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` };
|
||||
function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined {
|
||||
if (!entityId) {
|
||||
return undefined;
|
||||
}
|
||||
const target = world.entities.get(action.target);
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `target entity '${action.target}' does not exist`,
|
||||
};
|
||||
}
|
||||
return { ok: true, target };
|
||||
return worldState.entities[entityId];
|
||||
}
|
||||
|
||||
function attributeChange(
|
||||
entity: Entity,
|
||||
field: string,
|
||||
newValue: unknown
|
||||
): StateChange {
|
||||
return {
|
||||
entity_id: entity.id,
|
||||
field,
|
||||
old_value: entity.attributes[field] ?? null,
|
||||
new_value: newValue,
|
||||
};
|
||||
function hasKey(actor: Entity, requiredKeyId: string): boolean {
|
||||
return actor.attributes[`has_${requiredKeyId}`] === true;
|
||||
}
|
||||
|
||||
// ── 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 (unless already in inventory)
|
||||
const actorLocation = actor.attributes["location"];
|
||||
const itemLocation = t.target.attributes["location"];
|
||||
const expectedInventory = `inventory:${actor.id}`;
|
||||
|
||||
// If already in inventory, it's a no-op (already holding it)
|
||||
if (itemLocation === expectedInventory) {
|
||||
return {
|
||||
ok: true,
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
|
||||
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", expectedInventory)],
|
||||
};
|
||||
},
|
||||
|
||||
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);
|
||||
export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] {
|
||||
return actions.map((action, actionIndex): ValidationResult => {
|
||||
const actor = getEntity(worldState, action.actorId);
|
||||
if (!actor) {
|
||||
rejected.push({
|
||||
action,
|
||||
reason: `actor entity '${action.actor}' does not exist`,
|
||||
});
|
||||
continue;
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "actor_not_found",
|
||||
message: `Actor '${action.actorId}' does not exist.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Run verb-specific handler
|
||||
const handler = verbHandlers[action.verb];
|
||||
const result = handler(action, actor, worldState);
|
||||
switch (action.type) {
|
||||
case "inspect":
|
||||
return { actionIndex, success: true };
|
||||
|
||||
if (!result.ok) {
|
||||
rejected.push({ action, reason: result.reason });
|
||||
} else {
|
||||
accepted.push(action);
|
||||
state_changes.push(...result.changes);
|
||||
case "take": {
|
||||
const target = getEntity(worldState, action.targetId);
|
||||
if (!target) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "target_not_found",
|
||||
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
|
||||
};
|
||||
}
|
||||
|
||||
const actorLocation = String(actor.attributes.location ?? "");
|
||||
const targetLocation = String(target.attributes.location ?? "");
|
||||
if (actorLocation !== targetLocation) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "not_in_same_location",
|
||||
message: `Target '${target.id}' is not in the same location as '${actor.id}'.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (target.attributes.takeable !== true) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "not_takeable",
|
||||
message: `Target '${target.id}' cannot be taken.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { actionIndex, success: true };
|
||||
}
|
||||
|
||||
case "open": {
|
||||
const target = getEntity(worldState, action.targetId);
|
||||
if (!target) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "target_not_found",
|
||||
message: `Target '${action.targetId ?? "(missing)"}' does not exist.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (target.attributes.openable !== true) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "not_openable",
|
||||
message: `Target '${target.id}' is not openable.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (target.attributes.locked === true) {
|
||||
const requiredKey = String(target.attributes.requiredKey ?? "key_1");
|
||||
if (!hasKey(actor, requiredKey)) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "locked_requires_key",
|
||||
message: `Target '${target.id}' is locked and requires '${requiredKey}'.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { actionIndex, success: true };
|
||||
}
|
||||
|
||||
case "move": {
|
||||
const target = getEntity(worldState, action.targetId);
|
||||
if (!target || target.type !== "room") {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "target_not_found",
|
||||
message: `Move target '${action.targetId ?? "(missing)"}' is not a valid room.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { actionIndex, success: true };
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "unknown_action",
|
||||
message: `Action type '${action.type}' is not supported.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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