641 lines
21 KiB
TypeScript
641 lines
21 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
|
|
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;
|
|
interpreter?: {
|
|
interpreterVersion: string;
|
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
|
minConfidence: number;
|
|
status: "resolved" | "needs_clarification" | "rejected";
|
|
selectedConfidence?: number;
|
|
candidates?: Array<{
|
|
action: Action;
|
|
confidence: number;
|
|
rationale?: string;
|
|
}>;
|
|
diagnostics: string[];
|
|
clarification?: {
|
|
reasonCode: string;
|
|
question: string;
|
|
field?: string;
|
|
options?: Array<{
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
|
|
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;
|
|
interpreter: {
|
|
interpreterVersion: string;
|
|
resolutionSource: "deterministic" | "llm" | "hybrid";
|
|
minConfidence: number;
|
|
status: "resolved" | "needs_clarification" | "rejected";
|
|
selectedConfidence?: number;
|
|
candidates: Array<{
|
|
action: Action;
|
|
confidence: number;
|
|
rationale?: string;
|
|
}>;
|
|
diagnostics: string[];
|
|
clarification?: {
|
|
reasonCode: string;
|
|
question: string;
|
|
field?: string;
|
|
options?: Array<{
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
|
|
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;
|
|
version: number;
|
|
name: string;
|
|
description?: string;
|
|
rules: ActionRuleSet[];
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
type RulebookListItem = {
|
|
id: string;
|
|
name: string;
|
|
version: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
const starterPrompts = [
|
|
"look around",
|
|
"take key",
|
|
"take lantern",
|
|
"give key to groundskeeper",
|
|
"introduce jeff",
|
|
"describe groundskeeper as patient",
|
|
"open door",
|
|
"move to exit",
|
|
];
|
|
|
|
function formatConfidence(value: number | undefined): string {
|
|
if (typeof value !== "number") return "n/a";
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
function formatTurnTime(epochMs: number): string {
|
|
try {
|
|
return new Date(epochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
} catch {
|
|
return String(epochMs);
|
|
}
|
|
}
|
|
|
|
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(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
|
|
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);
|
|
props.onSaved?.(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>
|
|
<p className="rulebook-desc">
|
|
Version {rulebook.version} | Updated {formatTurnTime(rulebook.updatedAt)}
|
|
</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>
|
|
<p className="rule-hint">
|
|
For conditional creation, use <code>{`{"op":"actionMetadataEq","key":"createIfMissing","value":true}`}</code> together with actor checks.
|
|
</p>
|
|
<p className="rule-hint">
|
|
For transfer ownership checks, use <code>{`{"op":"itemInInventory","itemMetadataKey":"itemId","holderRole":"actor"}`}</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");
|
|
const [activeRulebook, setActiveRulebook] = useState<SceneRulebook | null>(null);
|
|
const [rulebooks, setRulebooks] = useState<RulebookListItem[]>([]);
|
|
|
|
async function refreshRulebookState() {
|
|
const [active, list] = await Promise.all([
|
|
fetchJson<SceneRulebook>("/api/rulebook"),
|
|
fetchJson<RulebookListItem[]>("/api/rulebooks"),
|
|
]);
|
|
setActiveRulebook(active);
|
|
setRulebooks(list);
|
|
}
|
|
|
|
useEffect(() => {
|
|
void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
|
|
.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 Promise.all([
|
|
fetchJson<AppSnapshot>("/api/state"),
|
|
refreshRulebookState(),
|
|
]);
|
|
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);
|
|
await refreshRulebookState();
|
|
} catch (resetError) {
|
|
setError(resetError instanceof Error ? resetError.message : "Unknown error");
|
|
}
|
|
}
|
|
|
|
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
|
|
const turns = useMemo(() => snapshot?.turns.slice().reverse() ?? [], [snapshot]);
|
|
|
|
function applyClarificationOption(optionValue: string) {
|
|
setInput((prev) => {
|
|
const trimmed = prev.trim();
|
|
if (!trimmed) return optionValue;
|
|
return `${trimmed} ${optionValue}`;
|
|
});
|
|
}
|
|
|
|
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>
|
|
<p>
|
|
<strong>Interpreter:</strong> {latest.interpreter.status}
|
|
{` via ${latest.interpreter.resolutionSource}`}
|
|
{` | model threshold ${formatConfidence(latest.interpreter.minConfidence)}`}
|
|
{` | selected ${formatConfidence(latest.interpreter.selectedConfidence)}`}
|
|
</p>
|
|
<p>
|
|
<strong>Interpreter version:</strong> {latest.interpreter.interpreterVersion}
|
|
</p>
|
|
{latest.interpreter.clarification ? (
|
|
<p>
|
|
<strong>Clarification ({latest.interpreter.clarification.reasonCode}):</strong>{" "}
|
|
{latest.interpreter.clarification.question}
|
|
</p>
|
|
) : null}
|
|
{latest.interpreter.clarification?.options?.length ? (
|
|
<div className="chips">
|
|
{latest.interpreter.clarification.options.map((o) => (
|
|
<button
|
|
key={o.id}
|
|
type="button"
|
|
className="chip"
|
|
onClick={() => applyClarificationOption(o.value)}
|
|
>
|
|
clarify: {o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<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>
|
|
{latest.interpreter.status === "resolved"
|
|
? "No actions parsed."
|
|
: "Turn requires clarification before any action can be validated."}
|
|
</li>
|
|
) : null}
|
|
</ul>
|
|
{latest.interpreter.diagnostics.length > 0 ? (
|
|
<p>
|
|
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
|
|
</p>
|
|
) : null}
|
|
{latest.actions.length > 0 ? (
|
|
<details>
|
|
<summary>Selected actions ({latest.actions.length})</summary>
|
|
<pre>{JSON.stringify(latest.actions, null, 2)}</pre>
|
|
</details>
|
|
) : null}
|
|
{latest.interpreter.candidates.length > 0 ? (
|
|
<details>
|
|
<summary>Interpreter candidates ({latest.interpreter.candidates.length})</summary>
|
|
<ul className="timeline-list compact">
|
|
{latest.interpreter.candidates.map((candidate, index) => (
|
|
<li key={`${candidate.action.type}-${index}`}>
|
|
{candidate.action.type} at {formatConfidence(candidate.confidence)}
|
|
{candidate.rationale ? ` - ${candidate.rationale}` : ""}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
) : null}
|
|
</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}
|
|
{snapshot ? (
|
|
<div className="meta-grid">
|
|
<p className="meta-kv"><strong>World ID:</strong> {snapshot.worldState.id}</p>
|
|
<p className="meta-kv"><strong>Created:</strong> {formatTurnTime(snapshot.worldState.createdAt)}</p>
|
|
<p className="meta-kv"><strong>Domain:</strong> {String(snapshot.worldState.metadata?.domain ?? "unknown")}</p>
|
|
<p className="meta-kv"><strong>World schema:</strong> {String(snapshot.worldState.metadata?.version ?? "unknown")}</p>
|
|
</div>
|
|
) : 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">
|
|
{turns.map((turn) => (
|
|
<li key={turn.id}>
|
|
<strong>{turn.rawText}</strong>
|
|
<span className="turn-time"> at {formatTurnTime(turn.createdAt)}</span>
|
|
{turn.interpreter ? (
|
|
<span>
|
|
{" "}[interp:{turn.interpreter.status} via {turn.interpreter.resolutionSource};
|
|
conf {formatConfidence(turn.interpreter.selectedConfidence)}]
|
|
</span>
|
|
) : null}
|
|
{turn.interpreter?.clarification ? (
|
|
<p className="parser-hint">
|
|
Clarification ({turn.interpreter.clarification.reasonCode}): {turn.interpreter.clarification.question}
|
|
</p>
|
|
) : null}
|
|
{turn.interpreter?.diagnostics?.length ? (
|
|
<p className="turn-diagnostics">
|
|
Diagnostics: {turn.interpreter.diagnostics.join(" | ")}
|
|
</p>
|
|
) : null}
|
|
{turn.validation.map((v) => (
|
|
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
|
|
))}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</article>
|
|
|
|
<article className="panel">
|
|
<h2>System</h2>
|
|
{activeRulebook ? (
|
|
<div className="meta-grid">
|
|
<p className="meta-kv"><strong>Active rulebook:</strong> {activeRulebook.name}</p>
|
|
<p className="meta-kv"><strong>Rulebook ID:</strong> {activeRulebook.id}</p>
|
|
<p className="meta-kv"><strong>Rulebook version:</strong> {activeRulebook.version}</p>
|
|
<p className="meta-kv"><strong>Updated:</strong> {formatTurnTime(activeRulebook.updatedAt)}</p>
|
|
<p className="meta-kv"><strong>Saved rulebooks:</strong> {rulebooks.length}</p>
|
|
</div>
|
|
) : (
|
|
<p>Loading rulebook info...</p>
|
|
)}
|
|
{rulebooks.length > 0 ? (
|
|
<ul className="timeline-list compact">
|
|
{rulebooks.map((rb) => (
|
|
<li key={rb.id}>
|
|
<strong>{rb.name}</strong> ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</article>
|
|
</>
|
|
) : (
|
|
<article className="panel panel--full">
|
|
<h2>Rulebook editor</h2>
|
|
<RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
|
|
</article>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
} |