Files
CharacterGardenStack/charactergarden/app/src/world/applyActions.ts
spencer ff9b86c3e9 feat: Implement scene rulebook and validation engine
- Added a new SceneRulebook system to manage data-driven validation rules for actions.
- Introduced rule checks for actions like "take", "open", "move", "introduce", and "describe".
- Created a rulebook engine to evaluate conditions and enforce rules during action validation.
- Enhanced action handling with support for scene entry and character descriptions.
- Updated the architecture documentation to reflect the new rule-based validation approach.
- Added new endpoints and improved the persistence layer for rulebooks.
2026-04-26 13:33:05 -04:00

151 lines
4.0 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Action } from "../contracts/action";
import type { ValidationResult } from "../contracts/validation";
import type { Entity } from "../contracts/entity";
import type { WorldState } from "../contracts/world";
function cloneWorldState(worldState: WorldState): WorldState {
const entities: Record<string, Entity> = {};
for (const [id, entity] of Object.entries(worldState.entities)) {
entities[id] = {
...entity,
attributes: { ...entity.attributes },
};
}
return {
...worldState,
entities,
metadata: { ...worldState.metadata },
};
}
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "character";
}
function createCharacterId(worldState: WorldState, baseName: string): string {
const baseId = `character_${slugify(baseName)}`;
if (!worldState.entities[baseId]) {
return baseId;
}
let suffix = 2;
while (worldState.entities[`${baseId}_${suffix}`]) {
suffix += 1;
}
return `${baseId}_${suffix}`;
}
function getActionCharacterName(action: Action): string | undefined {
const displayName = action.metadata?.displayName;
if (typeof displayName === "string" && displayName.trim()) {
return displayName.trim();
}
const characterName = action.metadata?.characterName;
if (typeof characterName === "string" && characterName.trim()) {
return characterName
.trim()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
return undefined;
}
export function applyActions(
actions: Action[],
results: ValidationResult[],
worldState: WorldState
): WorldState {
const nextState = cloneWorldState(worldState);
for (const result of results) {
if (!result.success) {
continue;
}
const action = actions[result.actionIndex];
if (!action) {
continue;
}
const actor = nextState.entities[action.actorId];
const target = action.targetId ? nextState.entities[action.targetId] : undefined;
switch (action.type) {
case "move":
if (actor && action.targetId) {
actor.attributes.location = action.targetId;
}
break;
case "take":
if (actor && target) {
target.attributes.location = `inventory:${actor.id}`;
if (target.id === "key_1") {
actor.attributes.has_key_1 = true;
}
}
break;
case "open":
if (target) {
target.attributes.open = true;
}
break;
case "introduce":
if (actor && target) {
target.attributes.location = actor.attributes.location;
target.attributes.in_scene = true;
target.attributes.last_introduced_by = actor.id;
} else if (actor) {
const characterName = getActionCharacterName(action);
if (!characterName) {
break;
}
const characterId = createCharacterId(nextState, characterName);
nextState.entities[characterId] = {
id: characterId,
name: characterName,
type: "character",
attributes: {
location: actor.attributes.location,
is_social: true,
in_scene: true,
created_by_action: "introduce",
last_introduced_by: actor.id,
},
};
}
break;
case "describe":
if (target) {
const trait = action.metadata?.trait;
if (typeof trait === "string" && trait.trim()) {
const traits = Array.isArray(target.attributes.traits)
? target.attributes.traits
: [];
target.attributes.traits = [...traits, trait.trim()];
}
}
break;
case "inspect":
default:
break;
}
}
nextState.id = randomUUID();
nextState.createdAt = Date.now();
return nextState;
}