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

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