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:
2026-04-26 13:33:05 -04:00
parent 998635f542
commit ff9b86c3e9
16 changed files with 2013 additions and 412 deletions

View File

@@ -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(),

View 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;
};

View File

@@ -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;
};

View File

@@ -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 });
},
};
}

View 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}'.",
},
],
},
],
};
}

View File

@@ -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 });

View File

@@ -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;
}

View 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 };
});
}

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -48,6 +48,433 @@ type ProcessTurnResponse = {
worldState: WorldState;
};
type RuleCheck = {
id: string;
description: string;
condition: unknown;
failReason: string;
failMessage: string;
};
type ActionRuleSet = {
actionType: string;
enabled: boolean;
checks: RuleCheck[];
};
type SceneRulebook = {
id: string;
worldId: string;
name: string;
description?: string;
rules: ActionRuleSet[];
createdAt: number;
updatedAt: number;
};
const starterPrompts = [
"look around",
"take key",
"open door",
"move to exit",
];
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
// ---------------------------------------------------------------------------
// Rulebook editor component
// ---------------------------------------------------------------------------
function RulebookEditor() {
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
void fetchJson<SceneRulebook>("/api/rulebook").then((rb) => {
setRulebook(rb);
const initial: Record<string, string> = {};
for (const ruleSet of rb.rules) {
initial[ruleSet.actionType] = JSON.stringify(ruleSet.checks, null, 2);
}
setDrafts(initial);
});
}, []);
function toggleEnabled(actionType: string) {
if (!rulebook) return;
setRulebook({
...rulebook,
rules: rulebook.rules.map((r) =>
r.actionType === actionType ? { ...r, enabled: !r.enabled } : r
),
});
}
function updateDraft(actionType: string, value: string) {
setDrafts((prev) => ({ ...prev, [actionType]: value }));
setParseErrors((prev) => {
const next = { ...prev };
delete next[actionType];
return next;
});
}
async function saveRulebook() {
if (!rulebook) return;
setSaveError(null);
setSaved(false);
// Validate and apply all drafts.
const updatedRules: ActionRuleSet[] = [];
const newErrors: Record<string, string> = {};
for (const ruleSet of rulebook.rules) {
const draft = drafts[ruleSet.actionType] ?? JSON.stringify(ruleSet.checks, null, 2);
try {
const parsedChecks = JSON.parse(draft) as RuleCheck[];
updatedRules.push({ ...ruleSet, checks: parsedChecks });
} catch {
newErrors[ruleSet.actionType] = "Invalid JSON";
updatedRules.push(ruleSet);
}
}
if (Object.keys(newErrors).length > 0) {
setParseErrors(newErrors);
return;
}
setSaving(true);
try {
const updated = await fetchJson<SceneRulebook>("/api/rulebook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
});
setRulebook(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (!rulebook) return <p>Loading rulebook</p>;
return (
<div className="rulebook-editor">
<div className="rulebook-header">
<div>
<p className="rulebook-name">{rulebook.name}</p>
{rulebook.description ? (
<p className="rulebook-desc">{rulebook.description}</p>
) : null}
</div>
<button
type="button"
onClick={() => void saveRulebook()}
disabled={saving}
className="save-btn"
>
{saving ? "Saving…" : saved ? "Saved" : "Save rulebook"}
</button>
</div>
{saveError ? <p className="error-banner">{saveError}</p> : null}
<div className="rule-list">
{rulebook.rules.map((ruleSet) => (
<details key={ruleSet.actionType} className="rule-panel">
<summary className="rule-summary">
<span className="rule-action-type">{ruleSet.actionType}</span>
<span className="rule-check-count">{ruleSet.checks.length} check{ruleSet.checks.length !== 1 ? "s" : ""}</span>
<label
className="rule-toggle"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={ruleSet.enabled}
onChange={() => toggleEnabled(ruleSet.actionType)}
/>
{ruleSet.enabled ? "enforced" : "disabled"}
</label>
</summary>
<div className="rule-body">
<p className="rule-hint">
Edit the checks array below. Each check has: <code>id</code>, <code>description</code>, <code>condition</code>, <code>failReason</code>, <code>failMessage</code>.
</p>
<p className="rule-hint">
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
</p>
{ruleSet.checks.length > 0 ? (
<ul className="check-list">
{ruleSet.checks.map((check) => (
<li key={check.id} title={check.failReason}>
<span className="check-desc">{check.description}</span>
</li>
))}
</ul>
) : (
<p className="rule-hint">No checks action always passes.</p>
)}
<label className="json-label">
Checks JSON
<textarea
className={`json-editor${parseErrors[ruleSet.actionType] ? " json-error" : ""}`}
value={drafts[ruleSet.actionType] ?? ""}
onChange={(e) => updateDraft(ruleSet.actionType, e.target.value)}
rows={10}
spellCheck={false}
/>
</label>
{parseErrors[ruleSet.actionType] ? (
<p className="error-banner">{parseErrors[ruleSet.actionType]}</p>
) : null}
</div>
</details>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main app
// ---------------------------------------------------------------------------
export default function App() {
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
const [input, setInput] = useState("look around");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"world" | "rulebook">("world");
useEffect(() => {
void fetchJson<AppSnapshot>("/api/state")
.then((data) => {
setSnapshot(data);
setLoading(false);
})
.catch((fetchError: Error) => {
setError(fetchError.message);
setLoading(false);
});
}, []);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
setSnapshot(nextSnapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
} finally {
setSubmitting(false);
}
}
async function onReset() {
setError(null);
try {
const result = await fetchJson<AppSnapshot>("/api/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
setSnapshot(result);
setLatest(null);
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
}
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
return (
<main className="page-shell">
<section className="hero-panel">
<p className="eyebrow">CharacterGarden</p>
<h1>Bootable narrative sandbox</h1>
<p className="lede">
Submit a turn, inspect world state, and verify how the truth engine is mutating state.
</p>
<form className="turn-form" onSubmit={onSubmit}>
<label htmlFor="turn-input">Turn input</label>
<textarea
id="turn-input"
value={input}
onChange={(event) => setInput(event.target.value)}
rows={4}
placeholder="Type what the player does..."
/>
<div className="actions-row">
<button type="submit" disabled={submitting}>
{submitting ? "Submitting..." : "Run turn"}
</button>
<button type="button" className="chip" onClick={onReset}>
Reset world
</button>
<div className="chips">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
{prompt}
</button>
))}
</div>
</div>
</form>
{latest ? (
<section className="result-card">
<h2>Latest result</h2>
<p><strong>Input:</strong> {latest.rawText}</p>
<ul className="timeline-list compact">
{latest.validation.map((v) => (
<li key={v.actionIndex}>
Action {v.actionIndex}: {v.success ? "ok" : `failed (${v.reason ?? "unknown"})`}
{v.message ? ` - ${v.message}` : ""}
</li>
))}
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null}
</ul>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null}
</section>
<section className="inspector-grid">
<nav className="tab-bar">
<button
type="button"
className={`tab-btn${tab === "world" ? " active" : ""}`}
onClick={() => setTab("world")}
>
World inspector
</button>
<button
type="button"
className={`tab-btn${tab === "rulebook" ? " active" : ""}`}
onClick={() => setTab("rulebook")}
>
Rulebook
</button>
</nav>
{tab === "world" ? (
<>
<article className="panel">
<h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null}
<ul className="entity-list">
{entities.map((entity) => (
<li key={entity.id}>
<strong>{entity.name}</strong> <span>{entity.type}</span>
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Turn log</h2>
<ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}>
<strong>{turn.rawText}</strong>
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))}
</li>
))}
</ul>
</article>
</>
) : (
<article className="panel panel--full">
<h2>Rulebook editor</h2>
<RulebookEditor />
</article>
)}
</section>
</main>
);
}
type Entity = {
id: string;
type: string;
name: string;
attributes: Record<string, unknown>;
};
type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
};
type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};
type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
type AppSnapshot = {
worldState: WorldState;
turns: Turn[];
};
type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
};
const starterPrompts = [
"look around",
"take key",

View File

@@ -176,6 +176,191 @@ pre {
font-size: 0.9rem;
}
/* ---------------------------------------------------------------------------
Tab bar
--------------------------------------------------------------------------- */
.tab-bar {
grid-column: 1 / -1;
display: flex;
gap: 8px;
padding-bottom: 4px;
}
.tab-btn {
border: 1px solid rgba(244, 239, 228, 0.14);
border-radius: 999px;
padding: 8px 18px;
background: rgba(244, 239, 228, 0.05);
color: rgba(244, 239, 228, 0.6);
cursor: pointer;
}
.tab-btn.active {
background: rgba(244, 239, 228, 0.12);
color: #f4efe4;
border-color: rgba(244, 239, 228, 0.26);
}
.panel--full {
grid-column: 1 / -1;
}
/* ---------------------------------------------------------------------------
Rulebook editor
--------------------------------------------------------------------------- */
.rulebook-editor {
display: grid;
gap: 12px;
}
.rulebook-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.rulebook-name {
font-weight: 600;
font-size: 1rem;
margin-bottom: 4px;
}
.rulebook-desc {
font-size: 0.85rem;
color: rgba(244, 239, 228, 0.6);
margin: 0;
}
.save-btn {
border: 0;
border-radius: 999px;
padding: 10px 20px;
background: linear-gradient(135deg, #d8b16f, #a96c36);
color: #1d1b17;
cursor: pointer;
white-space: nowrap;
}
.save-btn:disabled {
opacity: 0.6;
cursor: default;
}
.rule-list {
display: grid;
gap: 8px;
}
.rule-panel {
border: 1px solid rgba(244, 239, 228, 0.1);
border-radius: 14px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.rule-summary {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
cursor: pointer;
list-style: none;
user-select: none;
}
.rule-summary::-webkit-details-marker {
display: none;
}
.rule-action-type {
font-weight: 600;
font-family: "IBM Plex Mono", monospace;
font-size: 0.9rem;
color: #dcbf8d;
min-width: 90px;
}
.rule-check-count {
font-size: 0.8rem;
color: rgba(244, 239, 228, 0.5);
flex: 1;
}
.rule-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: rgba(244, 239, 228, 0.7);
cursor: pointer;
}
.rule-body {
padding: 0 14px 14px;
display: grid;
gap: 8px;
border-top: 1px solid rgba(244, 239, 228, 0.07);
}
.rule-hint {
font-size: 0.82rem;
color: rgba(244, 239, 228, 0.5);
margin: 8px 0 0;
}
.rule-hint code {
font-family: "IBM Plex Mono", monospace;
color: #dcbf8d;
font-size: 0.8rem;
}
.check-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 4px;
}
.check-list li {
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.82rem;
}
.check-desc {
color: rgba(244, 239, 228, 0.8);
}
.json-label {
display: grid;
gap: 6px;
font-size: 0.82rem;
color: rgba(244, 239, 228, 0.6);
}
.json-editor {
width: 100%;
border: 1px solid rgba(244, 239, 228, 0.14);
background: rgba(0, 0, 0, 0.4);
color: #cfd9c2;
border-radius: 10px;
padding: 12px;
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
resize: vertical;
}
.json-editor.json-error {
border-color: #ffd2b8;
}
@media (max-width: 900px) {
.inspector-grid {
grid-template-columns: 1fr;