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.
This commit is contained in:
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createDatabase, CharacterGardenDatabase } from "./db";
|
||||
import type { Entity } from "./contracts/entity";
|
||||
import type { SceneRulebook } from "./contracts/rulebook";
|
||||
import type { Turn } from "./contracts/turn";
|
||||
import type { WorldState } from "./contracts/world";
|
||||
import { createDefaultRulebook, DEFAULT_RULEBOOK_ID } from "./defaultRulebook";
|
||||
import { processTurn, ProcessTurnResponse } from "./turns/processTurn";
|
||||
|
||||
export interface AppSnapshot {
|
||||
@@ -15,6 +17,9 @@ export interface CharacterGardenApp {
|
||||
db: CharacterGardenDatabase;
|
||||
getSnapshot(): AppSnapshot;
|
||||
processTurn(rawText: string): ProcessTurnResponse;
|
||||
getRulebook(): SceneRulebook;
|
||||
upsertRulebook(rulebook: SceneRulebook): SceneRulebook;
|
||||
listRulebooks(): SceneRulebook[];
|
||||
reset(): AppSnapshot;
|
||||
}
|
||||
|
||||
@@ -22,12 +27,22 @@ function createSeedWorldState(): WorldState {
|
||||
const now = Date.now();
|
||||
|
||||
const entities: Record<string, Entity> = {
|
||||
room_offstage: {
|
||||
id: "room_offstage",
|
||||
name: "Offstage",
|
||||
type: "room",
|
||||
attributes: {
|
||||
description: "A holding area for characters not currently in the active scene.",
|
||||
is_joinable: false,
|
||||
},
|
||||
},
|
||||
room_start: {
|
||||
id: "room_start",
|
||||
name: "Start Room",
|
||||
type: "room",
|
||||
attributes: {
|
||||
description: "A plain room with a locked door.",
|
||||
is_joinable: true,
|
||||
},
|
||||
},
|
||||
room_exit: {
|
||||
@@ -36,6 +51,7 @@ function createSeedWorldState(): WorldState {
|
||||
type: "room",
|
||||
attributes: {
|
||||
description: "A simple room beyond the door.",
|
||||
is_joinable: true,
|
||||
},
|
||||
},
|
||||
player: {
|
||||
@@ -47,6 +63,16 @@ function createSeedWorldState(): WorldState {
|
||||
has_key_1: false,
|
||||
},
|
||||
},
|
||||
groundskeeper: {
|
||||
id: "groundskeeper",
|
||||
name: "Groundskeeper",
|
||||
type: "character",
|
||||
attributes: {
|
||||
location: "room_offstage",
|
||||
is_social: true,
|
||||
in_scene: false,
|
||||
},
|
||||
},
|
||||
door_1: {
|
||||
id: "door_1",
|
||||
name: "Old Door",
|
||||
@@ -81,12 +107,71 @@ function createSeedWorldState(): WorldState {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSeedWorldState(worldState: WorldState): { worldState: WorldState; changed: boolean } {
|
||||
const seed = createSeedWorldState();
|
||||
const mergedEntities: Record<string, Entity> = {};
|
||||
let changed = false;
|
||||
|
||||
for (const [entityId, seedEntity] of Object.entries(seed.entities)) {
|
||||
const existingEntity = worldState.entities[entityId];
|
||||
if (!existingEntity) {
|
||||
mergedEntities[entityId] = seedEntity;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const mergedAttributes = {
|
||||
...seedEntity.attributes,
|
||||
...existingEntity.attributes,
|
||||
};
|
||||
|
||||
if (JSON.stringify(existingEntity.attributes) !== JSON.stringify(mergedAttributes)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
mergedEntities[entityId] = {
|
||||
...existingEntity,
|
||||
attributes: mergedAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
for (const [entityId, existingEntity] of Object.entries(worldState.entities)) {
|
||||
if (!mergedEntities[entityId]) {
|
||||
mergedEntities[entityId] = existingEntity;
|
||||
}
|
||||
}
|
||||
|
||||
const mergedWorldState: WorldState = {
|
||||
...worldState,
|
||||
entities: mergedEntities,
|
||||
metadata: {
|
||||
...seed.metadata,
|
||||
...worldState.metadata,
|
||||
},
|
||||
};
|
||||
|
||||
if (changed) {
|
||||
mergedWorldState.id = randomUUID();
|
||||
mergedWorldState.createdAt = Date.now();
|
||||
}
|
||||
|
||||
return {
|
||||
worldState: mergedWorldState,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSeedState(db: CharacterGardenDatabase): WorldState {
|
||||
db.init();
|
||||
|
||||
const latest = db.getLatestWorldState();
|
||||
if (latest) {
|
||||
return latest;
|
||||
const merged = mergeSeedWorldState(latest);
|
||||
db.upsertEntities(Object.values(merged.worldState.entities));
|
||||
if (merged.changed) {
|
||||
db.insertWorldState(null, merged.worldState);
|
||||
}
|
||||
return merged.worldState;
|
||||
}
|
||||
|
||||
const seed = createSeedWorldState();
|
||||
@@ -95,9 +180,34 @@ function ensureSeedState(db: CharacterGardenDatabase): WorldState {
|
||||
return seed;
|
||||
}
|
||||
|
||||
function ensureDefaultRulebook(
|
||||
db: CharacterGardenDatabase,
|
||||
worldState: WorldState
|
||||
): SceneRulebook {
|
||||
const existing = db.getRulebook(DEFAULT_RULEBOOK_ID);
|
||||
if (existing) return existing;
|
||||
const defaultRulebook = createDefaultRulebook(worldState.id);
|
||||
db.upsertRulebook(defaultRulebook);
|
||||
return defaultRulebook;
|
||||
}
|
||||
|
||||
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||
const db = createDatabase({ dbPath });
|
||||
let worldState = ensureSeedState(db);
|
||||
// Active rulebook ID — tracks which rulebook the world is using.
|
||||
let activeRulebookId: string =
|
||||
worldState.rulebookId ?? DEFAULT_RULEBOOK_ID;
|
||||
|
||||
// Ensure the default rulebook is present on first boot.
|
||||
ensureDefaultRulebook(db, worldState);
|
||||
|
||||
function loadActiveRulebook(): SceneRulebook {
|
||||
const rulebook = db.getRulebook(activeRulebookId);
|
||||
if (rulebook) return rulebook;
|
||||
// Fall back to default if the active one was deleted.
|
||||
activeRulebookId = DEFAULT_RULEBOOK_ID;
|
||||
return ensureDefaultRulebook(db, worldState);
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
@@ -110,14 +220,32 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||
},
|
||||
|
||||
processTurn(rawText: string) {
|
||||
const result = processTurn(rawText, worldState, db);
|
||||
const rulebook = loadActiveRulebook();
|
||||
const result = processTurn(rawText, worldState, db, rulebook);
|
||||
worldState = result.worldState;
|
||||
return result;
|
||||
},
|
||||
|
||||
getRulebook() {
|
||||
return loadActiveRulebook();
|
||||
},
|
||||
|
||||
upsertRulebook(rulebook: SceneRulebook) {
|
||||
const updated: SceneRulebook = { ...rulebook, updatedAt: Date.now() };
|
||||
db.upsertRulebook(updated);
|
||||
activeRulebookId = updated.id;
|
||||
return updated;
|
||||
},
|
||||
|
||||
listRulebooks() {
|
||||
return db.listRulebooks();
|
||||
},
|
||||
|
||||
reset() {
|
||||
db.wipe();
|
||||
worldState = ensureSeedState(db);
|
||||
activeRulebookId = DEFAULT_RULEBOOK_ID;
|
||||
ensureDefaultRulebook(db, worldState);
|
||||
return {
|
||||
worldState,
|
||||
turns: db.listTurns(),
|
||||
|
||||
94
charactergarden/app/src/contracts/rulebook.ts
Normal file
94
charactergarden/app/src/contracts/rulebook.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* SceneRulebook — data-driven validation rules for the truth engine.
|
||||
*
|
||||
* Rules are stored per-scene in the database and evaluated by rulebookEngine.ts.
|
||||
* The default set is seeded from defaultRulebook.ts and mirrors the original
|
||||
* hardcoded logic in truthEngine.ts.
|
||||
*/
|
||||
|
||||
/** Which entity in the action context a condition refers to. */
|
||||
export type EntityRole = "actor" | "target" | "actorRoom" | "targetRoom";
|
||||
|
||||
/**
|
||||
* A composable, JSON-serialisable condition expression.
|
||||
*
|
||||
* Combinators: and | or | not
|
||||
* Predicates:
|
||||
* entityExists — entity referenced by role is present in world state
|
||||
* entityExistsOrWillBeCreated — entity exists OR will be created earlier in this turn
|
||||
* entityType — entity.type === requiredType
|
||||
* eq / neq — entity field comparison (id, name, type, or attributes[attribute])
|
||||
* attributeExists — entity.attributes[attribute] is not undefined
|
||||
* sameLocation — two entities share the same location attribute value
|
||||
* actorIdIn — action.actorId is included in an allowed list
|
||||
* actorNameIn — actor.name matches one of an allowed list (case-insensitive)
|
||||
* attributeRef — entities[checkRole].attributes[prefix + entities[refRole].attributes[refAttribute]] === true
|
||||
* metaValueNotInRoom — no entity of entityType in actor's room has name === action.metadata[metaKey]
|
||||
*/
|
||||
export type ConditionExpr =
|
||||
| { op: "and"; conditions: ConditionExpr[] }
|
||||
| { op: "or"; conditions: ConditionExpr[] }
|
||||
| { op: "not"; condition: ConditionExpr }
|
||||
| { op: "entityExists"; role: EntityRole }
|
||||
| { op: "entityExistsOrWillBeCreated"; role: EntityRole }
|
||||
| { op: "entityType"; role: EntityRole; requiredType: string }
|
||||
| { op: "eq"; role: EntityRole; attribute: string; value: unknown }
|
||||
| { op: "neq"; role: EntityRole; attribute: string; value: unknown }
|
||||
| { op: "attributeExists"; role: EntityRole; attribute: string }
|
||||
| { op: "sameLocation"; roleA: EntityRole; roleB: EntityRole }
|
||||
| { op: "actorIdIn"; allowedIds: string[] }
|
||||
| { op: "actorNameIn"; allowedNames: string[] }
|
||||
| {
|
||||
op: "attributeRef";
|
||||
/** Entity whose attribute is being tested */
|
||||
checkRole: EntityRole;
|
||||
/** Optional string prepended to the resolved key (e.g. "has_") */
|
||||
prefix?: string;
|
||||
/** Entity that provides the dynamic attribute name */
|
||||
refRole: EntityRole;
|
||||
/** Attribute on refRole whose value supplies the key name */
|
||||
refAttribute: string;
|
||||
}
|
||||
| {
|
||||
op: "metaValueNotInRoom";
|
||||
/** Key in action.metadata whose value to match against entity names */
|
||||
metaKey: string;
|
||||
/** Only match entities of this type */
|
||||
entityType: string;
|
||||
};
|
||||
|
||||
/** A single named check within an action rule set. */
|
||||
export type RuleCheck = {
|
||||
id: string;
|
||||
/** Human-readable label shown in the rulebook editor. */
|
||||
description: string;
|
||||
condition: ConditionExpr;
|
||||
failReason: string;
|
||||
/**
|
||||
* Failure message template.
|
||||
* Supports: {actor.id}, {actor.name}, {target.id}, {target.name}
|
||||
*/
|
||||
failMessage: string;
|
||||
};
|
||||
|
||||
/** All checks that apply to a specific action type. */
|
||||
export type ActionRuleSet = {
|
||||
actionType: string;
|
||||
/**
|
||||
* When false, all checks are skipped and the action always passes.
|
||||
* Useful for quickly disabling enforcement without deleting the rules.
|
||||
*/
|
||||
enabled: boolean;
|
||||
checks: RuleCheck[];
|
||||
};
|
||||
|
||||
/** The full rulebook attached to a scene/world. */
|
||||
export type SceneRulebook = {
|
||||
id: string;
|
||||
worldId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
rules: ActionRuleSet[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -5,4 +5,6 @@ export type WorldState = {
|
||||
entities: Record<string, Entity>;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
/** ID of the SceneRulebook currently active for this world. */
|
||||
rulebookId?: string;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import Database from "better-sqlite3";
|
||||
|
||||
import type { Action } from "./contracts/action";
|
||||
import type { Entity } from "./contracts/entity";
|
||||
import type { SceneRulebook } from "./contracts/rulebook";
|
||||
import type { Turn } from "./contracts/turn";
|
||||
import type { ValidationResult } from "./contracts/validation";
|
||||
import type { WorldState } from "./contracts/world";
|
||||
@@ -24,6 +25,10 @@ export interface CharacterGardenDatabase {
|
||||
insertValidationResults(turnId: string, results: ValidationResult[]): void;
|
||||
insertWorldState(turnId: string | null, worldState: WorldState): void;
|
||||
getLatestWorldState(): WorldState | null;
|
||||
upsertRulebook(rulebook: SceneRulebook): void;
|
||||
getRulebook(id: string): SceneRulebook | null;
|
||||
listRulebooks(): SceneRulebook[];
|
||||
deleteRulebook(id: string): void;
|
||||
wipe(): void;
|
||||
}
|
||||
|
||||
@@ -88,6 +93,17 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
||||
FOREIGN KEY(turn_id) REFERENCES turns(id)
|
||||
)
|
||||
`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS rulebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
world_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
rules_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`,
|
||||
];
|
||||
|
||||
for (const statement of initStatements) {
|
||||
@@ -169,6 +185,32 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const upsertRulebookStatement = sqlite.prepare(`
|
||||
INSERT INTO rulebooks (id, world_id, name, description, rules_json, created_at, updated_at)
|
||||
VALUES (@id, @world_id, @name, @description, @rules_json, @created_at, @updated_at)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
rules_json = excluded.rules_json,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
|
||||
const getRulebookStatement = sqlite.prepare(`
|
||||
SELECT id, world_id, name, description, rules_json, created_at, updated_at
|
||||
FROM rulebooks
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
const listRulebooksStatement = sqlite.prepare(`
|
||||
SELECT id, world_id, name, description, rules_json, created_at, updated_at
|
||||
FROM rulebooks
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
|
||||
const deleteRulebookStatement = sqlite.prepare(`
|
||||
DELETE FROM rulebooks WHERE id = @id
|
||||
`);
|
||||
|
||||
return {
|
||||
sqlite,
|
||||
|
||||
@@ -296,5 +338,66 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
|
||||
}
|
||||
return parseJson<WorldState>(row.state_json);
|
||||
},
|
||||
|
||||
upsertRulebook(rulebook) {
|
||||
upsertRulebookStatement.run({
|
||||
id: rulebook.id,
|
||||
world_id: rulebook.worldId,
|
||||
name: rulebook.name,
|
||||
description: rulebook.description ?? null,
|
||||
rules_json: JSON.stringify(rulebook.rules),
|
||||
created_at: rulebook.createdAt,
|
||||
updated_at: rulebook.updatedAt,
|
||||
});
|
||||
},
|
||||
|
||||
getRulebook(id) {
|
||||
const row = getRulebookStatement.get({ id }) as
|
||||
| {
|
||||
id: string;
|
||||
world_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rules_json: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
worldId: row.world_id,
|
||||
name: row.name,
|
||||
description: row.description ?? undefined,
|
||||
rules: parseJson(row.rules_json),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
},
|
||||
|
||||
listRulebooks() {
|
||||
const rows = listRulebooksStatement.all() as Array<{
|
||||
id: string;
|
||||
world_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rules_json: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
worldId: row.world_id,
|
||||
name: row.name,
|
||||
description: row.description ?? undefined,
|
||||
rules: parseJson(row.rules_json),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
deleteRulebook(id) {
|
||||
deleteRulebookStatement.run({ id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
247
charactergarden/app/src/defaultRulebook.ts
Normal file
247
charactergarden/app/src/defaultRulebook.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { SceneRulebook } from "./contracts/rulebook";
|
||||
|
||||
export const DEFAULT_RULEBOOK_ID = "rulebook_default";
|
||||
|
||||
/**
|
||||
* Builds the default SceneRulebook, encoding all validation logic that was
|
||||
* previously hardcoded in truthEngine.ts as editable, data-driven rules.
|
||||
*/
|
||||
export function createDefaultRulebook(worldId: string): SceneRulebook {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: DEFAULT_RULEBOOK_ID,
|
||||
worldId,
|
||||
name: "Default Rulebook",
|
||||
description: "Built-in scene rules for the CharacterGarden engine. Edit freely — the engine re-evaluates on every turn.",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
rules: [
|
||||
{
|
||||
actionType: "inspect",
|
||||
enabled: true,
|
||||
checks: [],
|
||||
},
|
||||
|
||||
{
|
||||
actionType: "take",
|
||||
enabled: true,
|
||||
checks: [
|
||||
{
|
||||
id: "take_target_exists",
|
||||
description: "Target entity must exist in the world",
|
||||
condition: { op: "entityExists", role: "target" },
|
||||
failReason: "target_not_found",
|
||||
failMessage: "Target '{target.id}' does not exist.",
|
||||
},
|
||||
{
|
||||
id: "take_same_location",
|
||||
description: "Actor and target must be in the same location",
|
||||
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
|
||||
failReason: "not_in_same_location",
|
||||
failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.",
|
||||
},
|
||||
{
|
||||
id: "take_takeable",
|
||||
description: "Target must have takeable attribute set to true",
|
||||
condition: { op: "eq", role: "target", attribute: "takeable", value: true },
|
||||
failReason: "not_takeable",
|
||||
failMessage: "Target '{target.id}' cannot be taken.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
actionType: "open",
|
||||
enabled: true,
|
||||
checks: [
|
||||
{
|
||||
id: "open_target_exists",
|
||||
description: "Target entity must exist in the world",
|
||||
condition: { op: "entityExists", role: "target" },
|
||||
failReason: "target_not_found",
|
||||
failMessage: "Target '{target.id}' does not exist.",
|
||||
},
|
||||
{
|
||||
id: "open_openable",
|
||||
description: "Target must have openable attribute set to true",
|
||||
condition: { op: "eq", role: "target", attribute: "openable", value: true },
|
||||
failReason: "not_openable",
|
||||
failMessage: "Target '{target.id}' is not openable.",
|
||||
},
|
||||
{
|
||||
id: "open_lock_check",
|
||||
description: "If target is locked, actor must possess the required key (has_<requiredKey> attribute)",
|
||||
condition: {
|
||||
op: "or",
|
||||
conditions: [
|
||||
{
|
||||
op: "not",
|
||||
condition: { op: "eq", role: "target", attribute: "locked", value: true },
|
||||
},
|
||||
{
|
||||
op: "attributeRef",
|
||||
checkRole: "actor",
|
||||
prefix: "has_",
|
||||
refRole: "target",
|
||||
refAttribute: "requiredKey",
|
||||
},
|
||||
],
|
||||
},
|
||||
failReason: "locked_requires_key",
|
||||
failMessage: "Target '{target.id}' is locked and requires a key.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
actionType: "move",
|
||||
enabled: true,
|
||||
checks: [
|
||||
{
|
||||
id: "move_target_is_room",
|
||||
description: "Target must be an existing entity of type 'room'",
|
||||
condition: {
|
||||
op: "and",
|
||||
conditions: [
|
||||
{ op: "entityExists", role: "target" },
|
||||
{ op: "entityType", role: "target", requiredType: "room" },
|
||||
],
|
||||
},
|
||||
failReason: "target_not_found",
|
||||
failMessage: "Move target '{target.id}' is not a valid room.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
actionType: "introduce",
|
||||
enabled: true,
|
||||
checks: [
|
||||
{
|
||||
id: "introduce_actor_authorized",
|
||||
description: "Only approved characters can introduce/create characters in-scene",
|
||||
condition: {
|
||||
op: "actorIdIn",
|
||||
allowedIds: ["player"],
|
||||
},
|
||||
failReason: "actor_not_authorized",
|
||||
failMessage: "Actor '{actor.id}' is not allowed to introduce new characters.",
|
||||
},
|
||||
{
|
||||
id: "introduce_actor_in_room",
|
||||
description: "Actor must be located in a valid room entity",
|
||||
condition: {
|
||||
op: "and",
|
||||
conditions: [
|
||||
{ op: "entityExists", role: "actorRoom" },
|
||||
{ op: "entityType", role: "actorRoom", requiredType: "room" },
|
||||
],
|
||||
},
|
||||
failReason: "room_not_found",
|
||||
failMessage: "Actor '{actor.id}' is not currently in a valid room.",
|
||||
},
|
||||
{
|
||||
id: "introduce_room_joinable",
|
||||
description: "Actor's room must allow new arrivals (is_joinable: true)",
|
||||
condition: { op: "eq", role: "actorRoom", attribute: "is_joinable", value: true },
|
||||
failReason: "room_not_joinable",
|
||||
failMessage: "Room is not available for new arrivals.",
|
||||
},
|
||||
{
|
||||
id: "introduce_target_is_character",
|
||||
description: "If target entity exists, it must be of type 'character'",
|
||||
condition: {
|
||||
op: "or",
|
||||
conditions: [
|
||||
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||
{ op: "entityType", role: "target", requiredType: "character" },
|
||||
],
|
||||
},
|
||||
failReason: "target_not_character",
|
||||
failMessage: "Target '{target.id}' is not a character and cannot join the scene.",
|
||||
},
|
||||
{
|
||||
id: "introduce_target_social",
|
||||
description: "If target exists, it must be socially available (is_social: true)",
|
||||
condition: {
|
||||
op: "or",
|
||||
conditions: [
|
||||
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||
{ op: "eq", role: "target", attribute: "is_social", value: true },
|
||||
],
|
||||
},
|
||||
failReason: "target_not_social",
|
||||
failMessage: "Target '{target.id}' is not socially available to join the scene.",
|
||||
},
|
||||
{
|
||||
id: "introduce_not_already_present",
|
||||
description: "If target exists, it must not already be in the same room as the actor",
|
||||
condition: {
|
||||
op: "or",
|
||||
conditions: [
|
||||
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||
{
|
||||
op: "not",
|
||||
condition: { op: "sameLocation", roleA: "actor", roleB: "target" },
|
||||
},
|
||||
],
|
||||
},
|
||||
failReason: "already_in_scene",
|
||||
failMessage: "Target '{target.id}' is already present in the scene.",
|
||||
},
|
||||
{
|
||||
id: "introduce_no_name_duplicate",
|
||||
description: "When introducing a new character by name, no character with that name may already be in the room",
|
||||
condition: {
|
||||
op: "metaValueNotInRoom",
|
||||
metaKey: "characterName",
|
||||
entityType: "character",
|
||||
},
|
||||
failReason: "already_in_scene",
|
||||
failMessage: "A character with this name is already present in the scene.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
actionType: "describe",
|
||||
enabled: true,
|
||||
checks: [
|
||||
{
|
||||
id: "describe_target_exists",
|
||||
description: "Target must exist in the world or be created earlier in this turn",
|
||||
condition: { op: "entityExistsOrWillBeCreated", role: "target" },
|
||||
failReason: "target_not_found",
|
||||
failMessage: "Target '{target.id}' does not exist.",
|
||||
},
|
||||
{
|
||||
id: "describe_target_is_character",
|
||||
description: "If target exists, it must be of type 'character'",
|
||||
condition: {
|
||||
op: "or",
|
||||
conditions: [
|
||||
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||
{ op: "entityType", role: "target", requiredType: "character" },
|
||||
],
|
||||
},
|
||||
failReason: "target_not_character",
|
||||
failMessage: "Target '{target.id}' is not a character and cannot be described.",
|
||||
},
|
||||
{
|
||||
id: "describe_same_location",
|
||||
description: "If target exists, actor and target must be in the same location",
|
||||
condition: {
|
||||
op: "or",
|
||||
conditions: [
|
||||
{ op: "not", condition: { op: "entityExists", role: "target" } },
|
||||
{ op: "sameLocation", roleA: "actor", roleB: "target" },
|
||||
],
|
||||
},
|
||||
failReason: "not_in_same_location",
|
||||
failMessage: "Target '{target.id}' is not in the same location as '{actor.id}'.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Fastify from "fastify";
|
||||
|
||||
import { createCharacterGardenApp } from "./app";
|
||||
import type { SceneRulebook } from "./contracts/rulebook";
|
||||
|
||||
const port = Number(process.env.APP_PORT ?? 3000);
|
||||
const host = process.env.APP_HOST ?? "0.0.0.0";
|
||||
@@ -25,6 +26,26 @@ server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) =>
|
||||
return game.processTurn(input);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rulebook endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** GET /api/rulebook — returns the currently active rulebook. */
|
||||
server.get("/api/rulebook", async () => game.getRulebook());
|
||||
|
||||
/** PUT /api/rulebook — replace (or create) the active rulebook. */
|
||||
server.put<{ Body: SceneRulebook }>("/api/rulebook", async (request, reply) => {
|
||||
const body = request.body;
|
||||
if (!body || typeof body.id !== "string" || !Array.isArray(body.rules)) {
|
||||
reply.code(400);
|
||||
return { error: "Invalid rulebook payload. Must include id (string) and rules (array)." };
|
||||
}
|
||||
return game.upsertRulebook(body);
|
||||
});
|
||||
|
||||
/** GET /api/rulebooks — list all saved rulebooks (name + id summary). */
|
||||
server.get("/api/rulebooks", async () => game.listRulebooks());
|
||||
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
await server.listen({ host, port });
|
||||
|
||||
@@ -4,39 +4,128 @@ function normalized(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function parseTextToActions(text: string, actorId = "player"): Action[] {
|
||||
const input = normalized(text);
|
||||
function toDisplayName(value: string): string {
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function extractIntroducedCharacterName(input: string): string | undefined {
|
||||
const match = input.match(/(?:introduce|bring in|invite|have)\s+(?:the\s+|a\s+|an\s+)?(.+?)(?:\s+join)?$/);
|
||||
const rawName = match?.[1]?.trim();
|
||||
if (!rawName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return rawName.replace(/^(the|a|an)\s+/, "").trim() || undefined;
|
||||
}
|
||||
|
||||
function extractActorAndAction(sentence: string): { actorName?: string; action: string } {
|
||||
const normalized_sent = normalized(sentence);
|
||||
// For now, treat the entire sentence as an action with no explicit actor
|
||||
// In future, we can add patterns like "spencer introduces jeff" -> { actorName: "spencer", action: "introduces jeff" }
|
||||
return { action: normalized_sent };
|
||||
}
|
||||
|
||||
function parseSingleAction(actionText: string, defaultActorId: string): Action | undefined {
|
||||
const input = normalized(actionText);
|
||||
if (!input) {
|
||||
return [];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/(look|inspect|examine)/.test(input)) {
|
||||
return [{ actorId, type: "inspect", targetId: actorId }];
|
||||
return { actorId: defaultActorId, type: "inspect", targetId: defaultActorId };
|
||||
}
|
||||
|
||||
if (/(go|move|walk|head|travel)/.test(input)) {
|
||||
if (input.includes("exit") || input.includes("next room") || input.includes("through door")) {
|
||||
return [{ actorId, type: "move", targetId: "room_exit" }];
|
||||
return { actorId: defaultActorId, type: "move", targetId: "room_exit" };
|
||||
}
|
||||
if (input.includes("start")) {
|
||||
return [{ actorId, type: "move", targetId: "room_start" }];
|
||||
return { actorId: defaultActorId, type: "move", targetId: "room_start" };
|
||||
}
|
||||
return [];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/(open)/.test(input)) {
|
||||
if (input.includes("door")) {
|
||||
return [{ actorId, type: "open", targetId: "door_1" }];
|
||||
return { actorId: defaultActorId, type: "open", targetId: "door_1" };
|
||||
}
|
||||
return [];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/(take|pick up|grab)/.test(input)) {
|
||||
if (input.includes("key")) {
|
||||
return [{ actorId, type: "take", targetId: "key_1" }];
|
||||
return { actorId: defaultActorId, type: "take", targetId: "key_1" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/(introduce|bring in|invite|have .* join)/.test(input)) {
|
||||
if (input.includes("groundskeeper")) {
|
||||
return { actorId: defaultActorId, type: "introduce", targetId: "groundskeeper" };
|
||||
}
|
||||
|
||||
const characterName = extractIntroducedCharacterName(input);
|
||||
if (!characterName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
actorId: defaultActorId,
|
||||
type: "introduce",
|
||||
metadata: {
|
||||
characterName,
|
||||
displayName: toDisplayName(characterName),
|
||||
createIfMissing: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (/(describe|is a|is an|has)/.test(input)) {
|
||||
// Match patterns like "describe the merchant as shrewd" or "the merchant is shrewd"
|
||||
const describeMatch = input.match(/(?:describe|tell about)\s+(?:the\s+)?([a-z\s_]+?)\s+as\s+(.+)$/) ||
|
||||
input.match(/(?:the\s+)?([a-z\s_]+?)\s+(?:is|has)\s+(.+)$/);
|
||||
if (describeMatch) {
|
||||
const [_, targetNameRaw, trait] = describeMatch;
|
||||
const targetName = targetNameRaw.trim().replace(/^the\s+/, "").trim();
|
||||
const targetId = `character_${targetName.replace(/\s+/g, "_")}`;
|
||||
return {
|
||||
actorId: defaultActorId,
|
||||
type: "describe",
|
||||
targetId,
|
||||
metadata: {
|
||||
trait: trait.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTextToActions(text: string, actorId = "player"): Action[] {
|
||||
if (!text || !text.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
// Split by sentence terminators
|
||||
const sentences = text.split(/[.!?]+/).map((s) => s.trim()).filter(Boolean);
|
||||
const actions: Action[] = [];
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const { actorName, action } = extractActorAndAction(sentence);
|
||||
const resolvedActorId = actorName ? `character_${actorName}` : actorId;
|
||||
const parsedAction = parseSingleAction(action, resolvedActorId);
|
||||
if (parsedAction) {
|
||||
actions.push(parsedAction);
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
286
charactergarden/app/src/rulebookEngine.ts
Normal file
286
charactergarden/app/src/rulebookEngine.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { Action } from "./contracts/action";
|
||||
import type { Entity } from "./contracts/entity";
|
||||
import type {
|
||||
ActionRuleSet,
|
||||
ConditionExpr,
|
||||
EntityRole,
|
||||
SceneRulebook,
|
||||
} from "./contracts/rulebook";
|
||||
import type { ValidationResult } from "./contracts/validation";
|
||||
import type { WorldState } from "./contracts/world";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal evaluation context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EvalContext {
|
||||
action: Action;
|
||||
worldState: WorldState;
|
||||
/** Entity IDs that will be created by introduce actions earlier in this turn. */
|
||||
willBeCreated: Set<string>;
|
||||
entities: Record<EntityRole, Entity | undefined>;
|
||||
}
|
||||
|
||||
function resolveEntities(
|
||||
action: Action,
|
||||
worldState: WorldState
|
||||
): Record<EntityRole, Entity | undefined> {
|
||||
const actor = worldState.entities[action.actorId];
|
||||
const target = action.targetId ? worldState.entities[action.targetId] : undefined;
|
||||
const actorRoom = actor
|
||||
? worldState.entities[String(actor.attributes.location ?? "")]
|
||||
: undefined;
|
||||
const targetRoom = target
|
||||
? worldState.entities[String(target.attributes.location ?? "")]
|
||||
: undefined;
|
||||
|
||||
return { actor, target, actorRoom, targetRoom };
|
||||
}
|
||||
|
||||
/** Read a field from an entity — id/name/type are first-class; anything else reads from attributes. */
|
||||
function getEntityField(entity: Entity, attribute: string): unknown {
|
||||
if (attribute === "id") return entity.id;
|
||||
if (attribute === "name") return entity.name;
|
||||
if (attribute === "type") return entity.type;
|
||||
return entity.attributes[attribute];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition evaluator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function evaluate(expr: ConditionExpr, ctx: EvalContext): boolean {
|
||||
switch (expr.op) {
|
||||
case "and":
|
||||
return expr.conditions.every((c) => evaluate(c, ctx));
|
||||
|
||||
case "or":
|
||||
return expr.conditions.some((c) => evaluate(c, ctx));
|
||||
|
||||
case "not":
|
||||
return !evaluate(expr.condition, ctx);
|
||||
|
||||
case "entityExists": {
|
||||
return ctx.entities[expr.role] !== undefined;
|
||||
}
|
||||
|
||||
case "entityExistsOrWillBeCreated": {
|
||||
const entity = ctx.entities[expr.role];
|
||||
if (entity) return true;
|
||||
const roleId =
|
||||
expr.role === "target" ? ctx.action.targetId : undefined;
|
||||
return roleId !== undefined && ctx.willBeCreated.has(roleId);
|
||||
}
|
||||
|
||||
case "entityType": {
|
||||
const entity = ctx.entities[expr.role];
|
||||
if (!entity) return false;
|
||||
return entity.type === expr.requiredType;
|
||||
}
|
||||
|
||||
case "eq": {
|
||||
const entity = ctx.entities[expr.role];
|
||||
if (!entity) return false;
|
||||
return getEntityField(entity, expr.attribute) === expr.value;
|
||||
}
|
||||
|
||||
case "neq": {
|
||||
const entity = ctx.entities[expr.role];
|
||||
if (!entity) return false;
|
||||
return getEntityField(entity, expr.attribute) !== expr.value;
|
||||
}
|
||||
|
||||
case "attributeExists": {
|
||||
const entity = ctx.entities[expr.role];
|
||||
if (!entity) return false;
|
||||
return entity.attributes[expr.attribute] !== undefined;
|
||||
}
|
||||
|
||||
case "sameLocation": {
|
||||
const entityA = ctx.entities[expr.roleA];
|
||||
const entityB = ctx.entities[expr.roleB];
|
||||
if (!entityA || !entityB) return false;
|
||||
const locA = String(entityA.attributes.location ?? "");
|
||||
const locB = String(entityB.attributes.location ?? "");
|
||||
return locA !== "" && locA === locB;
|
||||
}
|
||||
|
||||
case "actorIdIn": {
|
||||
return expr.allowedIds.includes(ctx.action.actorId);
|
||||
}
|
||||
|
||||
case "actorNameIn": {
|
||||
const actor = ctx.entities.actor;
|
||||
if (!actor) return false;
|
||||
const actorName = actor.name.trim().toLowerCase();
|
||||
return expr.allowedNames.some((name) => name.trim().toLowerCase() === actorName);
|
||||
}
|
||||
|
||||
case "attributeRef": {
|
||||
const checkEntity = ctx.entities[expr.checkRole];
|
||||
const refEntity = ctx.entities[expr.refRole];
|
||||
if (!checkEntity || !refEntity) return false;
|
||||
const refValue = String(refEntity.attributes[expr.refAttribute] ?? "");
|
||||
if (!refValue) return false;
|
||||
const dynamicKey = (expr.prefix ?? "") + refValue;
|
||||
return checkEntity.attributes[dynamicKey] === true;
|
||||
}
|
||||
|
||||
case "metaValueNotInRoom": {
|
||||
// Passes when: no target exists yet (new-character path) AND no entity
|
||||
// of the given type in the actor's room already has the same name.
|
||||
if (ctx.entities.target) {
|
||||
// Target already exists — this check is not applicable (handled by
|
||||
// introduce_not_already_present instead).
|
||||
return true;
|
||||
}
|
||||
const metaValue = ctx.action.metadata?.[expr.metaKey];
|
||||
if (typeof metaValue !== "string" || !metaValue.trim()) {
|
||||
return true; // No name supplied → nothing to deduplicate.
|
||||
}
|
||||
const actor = ctx.entities.actor;
|
||||
if (!actor) return true;
|
||||
const actorLocation = String(actor.attributes.location ?? "");
|
||||
const normalizedName = metaValue.trim().toLowerCase();
|
||||
const hasDuplicate = Object.values(ctx.worldState.entities).some(
|
||||
(e) =>
|
||||
e.type === expr.entityType &&
|
||||
String(e.attributes.location ?? "") === actorLocation &&
|
||||
e.name.trim().toLowerCase() === normalizedName
|
||||
);
|
||||
return !hasDuplicate;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Exhaustiveness guard — TypeScript will warn if a new op is added
|
||||
// to ConditionExpr without a case here.
|
||||
const exhaustiveCheck: never = expr;
|
||||
console.warn("rulebookEngine: unhandled condition op", exhaustiveCheck);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message template resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveMessage(
|
||||
template: string,
|
||||
action: Action,
|
||||
worldState: WorldState
|
||||
): string {
|
||||
const actor = worldState.entities[action.actorId];
|
||||
const target = action.targetId ? worldState.entities[action.targetId] : undefined;
|
||||
return template
|
||||
.replace(/\{actor\.id\}/g, action.actorId)
|
||||
.replace(/\{actor\.name\}/g, actor?.name ?? action.actorId)
|
||||
.replace(/\{target\.id\}/g, action.targetId ?? "(missing)")
|
||||
.replace(/\{target\.name\}/g, target?.name ?? action.targetId ?? "(missing)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-pass: collect entity IDs that will be created by introduce actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function collectWillBeCreated(
|
||||
actions: Action[],
|
||||
worldState: WorldState
|
||||
): Set<string> {
|
||||
const willBeCreated = new Set<string>();
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.type !== "introduce") continue;
|
||||
if (worldState.entities[action.targetId ?? ""]) continue; // target already exists
|
||||
|
||||
const characterName =
|
||||
typeof action.metadata?.characterName === "string"
|
||||
? action.metadata.characterName.trim()
|
||||
: typeof action.metadata?.displayName === "string"
|
||||
? action.metadata.displayName.trim()
|
||||
: null;
|
||||
|
||||
if (!characterName) continue;
|
||||
|
||||
// Mirror the ID scheme used in applyActions.ts createCharacterId / slugify.
|
||||
const slug = characterName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "character";
|
||||
willBeCreated.add(`character_${slug}`);
|
||||
}
|
||||
|
||||
return willBeCreated;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a list of actions against a SceneRulebook.
|
||||
*
|
||||
* - If an action's type has no matching ActionRuleSet, it fails as "unknown_action".
|
||||
* - If a matching ActionRuleSet has enabled: false, all checks are skipped and
|
||||
* the action passes (useful for temporarily disabling enforcement).
|
||||
* - Checks run in order; the first failing check short-circuits the rest.
|
||||
*/
|
||||
export function validateWithRulebook(
|
||||
actions: Action[],
|
||||
worldState: WorldState,
|
||||
rulebook: SceneRulebook
|
||||
): ValidationResult[] {
|
||||
const ruleIndex = new Map<string, ActionRuleSet>();
|
||||
for (const ruleSet of rulebook.rules) {
|
||||
ruleIndex.set(ruleSet.actionType, ruleSet);
|
||||
}
|
||||
|
||||
const willBeCreated = collectWillBeCreated(actions, worldState);
|
||||
|
||||
return actions.map((action, actionIndex): ValidationResult => {
|
||||
const actor = worldState.entities[action.actorId];
|
||||
if (!actor) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "actor_not_found",
|
||||
message: `Actor '${action.actorId}' does not exist.`,
|
||||
};
|
||||
}
|
||||
|
||||
const ruleSet = ruleIndex.get(action.type);
|
||||
if (!ruleSet) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "unknown_action",
|
||||
message: `Action type '${action.type}' is not supported.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!ruleSet.enabled) {
|
||||
return { actionIndex, success: true };
|
||||
}
|
||||
|
||||
const ctx: EvalContext = {
|
||||
action,
|
||||
worldState,
|
||||
willBeCreated,
|
||||
entities: resolveEntities(action, worldState),
|
||||
};
|
||||
|
||||
for (const check of ruleSet.checks) {
|
||||
const passes = evaluate(check.condition, ctx);
|
||||
if (!passes) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: check.failReason,
|
||||
message: resolveMessage(check.failMessage, action, worldState),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { actionIndex, success: true };
|
||||
});
|
||||
}
|
||||
@@ -1,125 +1,21 @@
|
||||
import type { Action } from "./contracts/action";
|
||||
import type { Entity } from "./contracts/entity";
|
||||
import type { SceneRulebook } from "./contracts/rulebook";
|
||||
import type { ValidationResult } from "./contracts/validation";
|
||||
import type { WorldState } from "./contracts/world";
|
||||
import { createDefaultRulebook } from "./defaultRulebook";
|
||||
import { validateWithRulebook } from "./rulebookEngine";
|
||||
|
||||
function getEntity(worldState: WorldState, entityId: string | undefined): Entity | undefined {
|
||||
if (!entityId) {
|
||||
return undefined;
|
||||
}
|
||||
return worldState.entities[entityId];
|
||||
}
|
||||
|
||||
function hasKey(actor: Entity, requiredKeyId: string): boolean {
|
||||
return actor.attributes[`has_${requiredKeyId}`] === true;
|
||||
}
|
||||
|
||||
export function validateActions(actions: Action[], worldState: WorldState): ValidationResult[] {
|
||||
return actions.map((action, actionIndex): ValidationResult => {
|
||||
const actor = getEntity(worldState, action.actorId);
|
||||
if (!actor) {
|
||||
return {
|
||||
actionIndex,
|
||||
success: false,
|
||||
reason: "actor_not_found",
|
||||
message: `Actor '${action.actorId}' does not exist.`,
|
||||
};
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case "inspect":
|
||||
return { actionIndex, success: true };
|
||||
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Validate a list of parsed actions against the world state.
|
||||
*
|
||||
* Pass a SceneRulebook to use data-driven scene rules.
|
||||
* Falls back to the built-in default rulebook when none is provided.
|
||||
*/
|
||||
export function validateActions(
|
||||
actions: Action[],
|
||||
worldState: WorldState,
|
||||
rulebook?: SceneRulebook
|
||||
): ValidationResult[] {
|
||||
const activeRulebook = rulebook ?? createDefaultRulebook(worldState.id);
|
||||
return validateWithRulebook(actions, worldState, activeRulebook);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { CharacterGardenDatabase } from "../db";
|
||||
import type { Action } from "../contracts/action";
|
||||
import type { SceneRulebook } from "../contracts/rulebook";
|
||||
import type { Turn } from "../contracts/turn";
|
||||
import type { ValidationResult } from "../contracts/validation";
|
||||
import type { WorldState } from "../contracts/world";
|
||||
@@ -19,10 +20,11 @@ export type ProcessTurnResponse = {
|
||||
export function processTurn(
|
||||
rawText: string,
|
||||
worldState: WorldState,
|
||||
db: CharacterGardenDatabase
|
||||
db: CharacterGardenDatabase,
|
||||
rulebook?: SceneRulebook
|
||||
): ProcessTurnResponse {
|
||||
const actions = parseTextToActions(rawText);
|
||||
const validation = validateActions(actions, worldState);
|
||||
const validation = validateActions(actions, worldState, rulebook);
|
||||
const nextWorldState = applyActions(actions, validation, worldState);
|
||||
|
||||
const turn: Turn = {
|
||||
|
||||
@@ -21,6 +21,46 @@ function cloneWorldState(worldState: WorldState): WorldState {
|
||||
};
|
||||
}
|
||||
|
||||
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[],
|
||||
@@ -60,6 +100,43 @@ export function applyActions(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user