feat: add database reset functionality and implement rulebook tests

This commit is contained in:
2026-04-26 16:27:26 -04:00
parent fca69d3cb5
commit c32fa977a8
7 changed files with 150 additions and 7 deletions

View File

@@ -262,6 +262,7 @@ export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase
DELETE FROM world_states; DELETE FROM world_states;
DELETE FROM turns; DELETE FROM turns;
DELETE FROM entities; DELETE FROM entities;
DELETE FROM rulebooks;
`); `);
}, },

View File

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

View File

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

View File

@@ -7,10 +7,10 @@ services:
environment: environment:
- APP_PORT=3000 - APP_PORT=3000
- NODE_ENV=${NODE_ENV:-development} - 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} - OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
volumes: volumes:
- ./data:/data - sqlite_data:/var/lib/charactergarden
- ./app/src:/app/src - ./app/src:/app/src
frontend: frontend:
@@ -38,3 +38,4 @@ services:
volumes: volumes:
ollama_data: ollama_data:
sqlite_data:

View File

@@ -33,6 +33,11 @@ type Turn = {
minConfidence: number; minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected"; status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number; selectedConfidence?: number;
candidates?: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[]; diagnostics: string[];
clarification?: { clarification?: {
reasonCode: string; reasonCode: string;
@@ -70,6 +75,11 @@ type ProcessTurnResponse = {
minConfidence: number; minConfidence: number;
status: "resolved" | "needs_clarification" | "rejected"; status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number; selectedConfidence?: number;
candidates: Array<{
action: Action;
confidence: number;
rationale?: string;
}>;
diagnostics: string[]; diagnostics: string[];
clarification?: { clarification?: {
reasonCode: string; reasonCode: string;
@@ -109,6 +119,13 @@ type SceneRulebook = {
updatedAt: number; updatedAt: number;
}; };
type RulebookListItem = {
id: string;
name: string;
version: number;
updatedAt: number;
};
const starterPrompts = [ const starterPrompts = [
"look around", "look around",
"take key", "take key",
@@ -145,7 +162,7 @@ async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T>
// Rulebook editor component // Rulebook editor component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function RulebookEditor() { function RulebookEditor(props: { onSaved?: (rulebook: SceneRulebook) => void }) {
const [rulebook, setRulebook] = useState<SceneRulebook | null>(null); const [rulebook, setRulebook] = useState<SceneRulebook | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({}); const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
@@ -216,6 +233,7 @@ function RulebookEditor() {
body: JSON.stringify({ ...rulebook, rules: updatedRules }), body: JSON.stringify({ ...rulebook, rules: updatedRules }),
}); });
setRulebook(updated); setRulebook(updated);
props.onSaved?.(updated);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2500); setTimeout(() => setSaved(false), 2500);
} catch (err) { } catch (err) {
@@ -327,10 +345,21 @@ export default function App() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"world" | "rulebook">("world"); 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(() => { useEffect(() => {
void fetchJson<AppSnapshot>("/api/state") void Promise.all([fetchJson<AppSnapshot>("/api/state"), refreshRulebookState()])
.then((data) => { .then(([data]) => {
setSnapshot(data); setSnapshot(data);
setLoading(false); setLoading(false);
}) })
@@ -352,7 +381,10 @@ export default function App() {
body: JSON.stringify({ input }), body: JSON.stringify({ input }),
}); });
setLatest(result); setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state"); const [nextSnapshot] = await Promise.all([
fetchJson<AppSnapshot>("/api/state"),
refreshRulebookState(),
]);
setSnapshot(nextSnapshot); setSnapshot(nextSnapshot);
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error"); setError(submitError instanceof Error ? submitError.message : "Unknown error");
@@ -371,6 +403,7 @@ export default function App() {
}); });
setSnapshot(result); setSnapshot(result);
setLatest(null); setLatest(null);
await refreshRulebookState();
} catch (resetError) { } catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error"); setError(resetError instanceof Error ? resetError.message : "Unknown error");
} }
@@ -475,6 +508,25 @@ export default function App() {
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")} <strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p> </p>
) : null} ) : 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> </section>
) : null} ) : null}
@@ -504,6 +556,14 @@ export default function App() {
<article className="panel"> <article className="panel">
<h2>World state</h2> <h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null} {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"> <ul className="entity-list">
{entities.map((entity) => ( {entities.map((entity) => (
<li key={entity.id}> <li key={entity.id}>
@@ -544,11 +604,35 @@ export default function App() {
))} ))}
</ul> </ul>
</article> </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"> <article className="panel panel--full">
<h2>Rulebook editor</h2> <h2>Rulebook editor</h2>
<RulebookEditor /> <RulebookEditor onSaved={(rulebook) => setActiveRulebook(rulebook)} />
</article> </article>
)} )}
</section> </section>

View File

@@ -161,6 +161,19 @@ pre {
padding: 8px 10px; 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 { .error-banner {
margin-top: 16px; margin-top: 16px;
color: #ffd2b8; color: #ffd2b8;
@@ -375,6 +388,10 @@ pre {
.panel { .panel {
padding: 14px; padding: 14px;
} }
.meta-grid {
grid-template-columns: 1fr;
}
} }
.turn-time { .turn-time {