- 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.
151 lines
4.0 KiB
TypeScript
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;
|
|
}
|