diff --git a/charactergarden/app/src/db.ts b/charactergarden/app/src/db.ts
index 24cf1fd..a7bcfdc 100644
--- a/charactergarden/app/src/db.ts
+++ b/charactergarden/app/src/db.ts
@@ -262,6 +262,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
DELETE FROM world_states;
DELETE FROM turns;
DELETE FROM entities;
+ DELETE FROM rulebooks;
`);
},
diff --git a/charactergarden/app/src/tests/sqliteProbe.ts b/charactergarden/app/src/tests/sqliteProbe.ts
new file mode 100644
index 0000000..e42f247
--- /dev/null
+++ b/charactergarden/app/src/tests/sqliteProbe.ts
@@ -0,0 +1,6 @@
+import Database from "better-sqlite3";
+
+const db = new Database(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
+db.prepare("CREATE TABLE IF NOT EXISTS probe(value TEXT)").run();
+db.close();
+console.log("sqlite_probe_ok");
diff --git a/charactergarden/app/src/tests/verifyDefaultSeed.ts b/charactergarden/app/src/tests/verifyDefaultSeed.ts
new file mode 100644
index 0000000..6dcceb8
--- /dev/null
+++ b/charactergarden/app/src/tests/verifyDefaultSeed.ts
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+
+import { createCharacterGardenApp } from "../app";
+
+function run(): void {
+ const app = createCharacterGardenApp(process.env.DB_PATH ?? "/var/lib/charactergarden/app.db");
+ const snapshot = app.getSnapshot();
+ const rulebook = app.getRulebook();
+
+ assert.equal(snapshot.turns.length, 0, "Expected no persisted turns after database reset.");
+ assert.ok(snapshot.worldState.entities.player, "Expected player in default world state.");
+ assert.ok(snapshot.worldState.entities.room_start, "Expected room_start in default world state.");
+ assert.equal(rulebook.id, "rulebook_default", "Expected default rulebook to be active.");
+ assert.equal(rulebook.version, 1, "Expected default rulebook version to be 1.");
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ entityCount: Object.keys(snapshot.worldState.entities).length,
+ turnCount: snapshot.turns.length,
+ rulebookId: rulebook.id,
+ rulebookVersion: rulebook.version,
+ ruleCount: rulebook.rules.length,
+ },
+ null,
+ 2
+ )
+ );
+
+ app.db.close();
+}
+
+run();
diff --git a/charactergarden/data/sqlite/test_write_probe.txt b/charactergarden/data/sqlite/test_write_probe.txt
new file mode 100644
index 0000000..e69de29
diff --git a/charactergarden/docker-compose.yml b/charactergarden/docker-compose.yml
index 211d3f3..b56a01b 100644
--- a/charactergarden/docker-compose.yml
+++ b/charactergarden/docker-compose.yml
@@ -7,10 +7,10 @@ services:
environment:
- APP_PORT=3000
- NODE_ENV=${NODE_ENV:-development}
- - DB_PATH=/data/sqlite/app.db
+ - DB_PATH=/var/lib/charactergarden/app.db
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
volumes:
- - ./data:/data
+ - sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src
frontend:
@@ -38,3 +38,4 @@ services:
volumes:
ollama_data:
+ sqlite_data:
diff --git a/charactergarden/frontend/src/App.tsx b/charactergarden/frontend/src/App.tsx
index eef1cdb..e6b32b2 100644
--- a/charactergarden/frontend/src/App.tsx
+++ b/charactergarden/frontend/src/App.tsx
@@ -33,6 +33,11 @@ type Turn = {
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
+ candidates?: Array<{
+ action: Action;
+ confidence: number;
+ rationale?: string;
+ }>;
diagnostics: string[];
clarification?: {
reasonCode: string;
@@ -70,6 +75,11 @@ type ProcessTurnResponse = {
minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
+ candidates: Array<{
+ action: Action;
+ confidence: number;
+ rationale?: string;
+ }>;
diagnostics: string[];
clarification?: {
reasonCode: string;
@@ -109,6 +119,13 @@ type SceneRulebook = {
updatedAt: number;
};
+type RulebookListItem = {
+ id: string;
+ name: string;
+ version: number;
+ updatedAt: number;
+};
+
const starterPrompts = [
"look around",
"take key",
@@ -145,7 +162,7 @@ async function fetchJson(input: RequestInfo, init?: RequestInit): Promise
// Rulebook editor component
// ---------------------------------------------------------------------------
-function RulebookEditor() {
+function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
const [rulebook, setRulebook] = useState(null);
const [drafts, setDrafts] = useState>({});
const [parseErrors, setParseErrors] = useState>({});
@@ -216,6 +233,7 @@ function RulebookEditor() {
body: JSON.stringify({ ...rulebook, rules: updatedRules }),
});
setRulebook(updated);
+ props.onSaved?.(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
} catch (err) {
@@ -327,10 +345,21 @@ export default function App() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [tab, setTab] = useState<"world" | "rulebook">("world");
+ const [activeRulebook, setActiveRulebook] = useState(null);
+ const [rulebooks, setRulebooks] = useState([]);
+
+ async function refreshRulebookState() {
+ const [active, list] = await Promise.all([
+ fetchJson("/api/rulebook"),
+ fetchJson("/api/rulebooks"),
+ ]);
+ setActiveRulebook(active);
+ setRulebooks(list);
+ }
useEffect(() => {
- void fetchJson("/api/state")
- .then((data) => {
+ void Promise.all([fetchJson("/api/state"), refreshRulebookState()])
+ .then(([data]) => {
setSnapshot(data);
setLoading(false);
})
@@ -352,7 +381,10 @@ export default function App() {
body: JSON.stringify({ input }),
});
setLatest(result);
- const nextSnapshot = await fetchJson("/api/state");
+ const [nextSnapshot] = await Promise.all([
+ fetchJson("/api/state"),
+ refreshRulebookState(),
+ ]);
setSnapshot(nextSnapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
@@ -371,6 +403,7 @@ export default function App() {
});
setSnapshot(result);
setLatest(null);
+ await refreshRulebookState();
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
@@ -475,6 +508,25 @@ export default function App() {
Diagnostics: {latest.interpreter.diagnostics.join(" | ")}
) : null}
+ {latest.actions.length > 0 ? (
+
+ Selected actions ({latest.actions.length})
+ {JSON.stringify(latest.actions, null, 2)}
+
+ ) : null}
+ {latest.interpreter.candidates.length > 0 ? (
+
+ Interpreter candidates ({latest.interpreter.candidates.length})
+
+ {latest.interpreter.candidates.map((candidate, index) => (
+ -
+ {candidate.action.type} at {formatConfidence(candidate.confidence)}
+ {candidate.rationale ? ` - ${candidate.rationale}` : ""}
+
+ ))}
+
+
+ ) : null}
) : null}
@@ -504,6 +556,14 @@ export default function App() {
World state
{loading && !snapshot ? Loading...
: null}
+ {snapshot ? (
+
+
World ID: {snapshot.worldState.id}
+
Created: {formatTurnTime(snapshot.worldState.createdAt)}
+
Domain: {String(snapshot.worldState.metadata?.domain ?? "unknown")}
+
World schema: {String(snapshot.worldState.metadata?.version ?? "unknown")}
+
+ ) : null}
{entities.map((entity) => (
-
@@ -544,11 +604,35 @@ export default function App() {
))}
+
+
+ System
+ {activeRulebook ? (
+
+
Active rulebook: {activeRulebook.name}
+
Rulebook ID: {activeRulebook.id}
+
Rulebook version: {activeRulebook.version}
+
Updated: {formatTurnTime(activeRulebook.updatedAt)}
+
Saved rulebooks: {rulebooks.length}
+
+ ) : (
+ Loading rulebook info...
+ )}
+ {rulebooks.length > 0 ? (
+
+ {rulebooks.map((rb) => (
+ -
+ {rb.name} ({rb.id}) v{rb.version} - updated {formatTurnTime(rb.updatedAt)}
+
+ ))}
+
+ ) : null}
+
>
) : (
Rulebook editor
-
+ setActiveRulebook(rulebook)} />
)}
diff --git a/charactergarden/frontend/src/styles.css b/charactergarden/frontend/src/styles.css
index aa45bf0..8116846 100644
--- a/charactergarden/frontend/src/styles.css
+++ b/charactergarden/frontend/src/styles.css
@@ -161,6 +161,19 @@ pre {
padding: 8px 10px;
}
+.meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px 12px;
+ margin-bottom: 10px;
+}
+
+.meta-kv {
+ margin: 0;
+ font-size: 0.86rem;
+ color: rgba(244, 239, 228, 0.86);
+}
+
.error-banner {
margin-top: 16px;
color: #ffd2b8;
@@ -375,6 +388,10 @@ pre {
.panel {
padding: 14px;
}
+
+ .meta-grid {
+ grid-template-columns: 1fr;
+ }
}
.turn-time {