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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user