feat: implement core application structure with Fastify server and SQLite persistence

- Add Fastify server in `app/src/index.ts` with health check and API routes for game state and turn processing.
- Create `latentEntities.ts` to handle personal item plausibility and promote beliefs to facts based on actor context.
- Introduce `llmAdapter.ts` for action extraction from prose input.
- Develop `truthEngine.ts` for pure validation logic, handling all verbs with explicit rejection reasons.
- Define new types in `types.ts` for facts, affordances, and latent entity requests/resolutions.
- Update `docker-compose.yml` for improved service structure and volume management.
- Create frontend structure with React, including Dockerfile, Vite configuration, and initial components for state inspection.
- Implement basic styles and HTML structure for the frontend application.
- Document current status and next steps in `thoughts.md`.
This commit is contained in:
2026-04-23 21:08:38 -04:00
parent 14a07bca7a
commit 1df2ae8164
20 changed files with 1830 additions and 14 deletions

View File

@@ -0,0 +1,221 @@
import { FormEvent, useEffect, useState } from "react";
type Entity = {
id: string;
type: string;
name: string;
attributes: Record<string, unknown>;
};
type GameEvent = {
id: string;
turn: number;
result: "success" | "fail";
action: Record<string, unknown>;
timestamp: number;
};
type Turn = {
id: string;
turn: number;
input: string;
output: string;
timestamp: number;
};
type Belief = {
entity_id: string;
claim: string;
confidence: number;
};
type Summary = {
id: string;
turn_range: [number, number];
text: string;
timestamp: number;
};
type Snapshot = {
entities: Entity[];
events: GameEvent[];
turns: Turn[];
beliefs: Belief[];
summaries: Summary[];
};
type TurnResult = {
narration: string;
parser: string;
actions: Array<Record<string, unknown>>;
accepted: Array<Record<string, unknown>>;
rejected: Array<{ action: Record<string, unknown>; reason: string }>;
latent_resolution?: { accepted: boolean; reason: string; entity_id?: string };
snapshot: Snapshot;
};
const starterPrompts = [
"look around",
"open the gate",
"talk to the groundskeeper",
"go to the shed",
"pull out my phone",
];
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>;
}
export default function App() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [latest, setLatest] = useState<TurnResult | 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);
useEffect(() => {
void fetchJson<Snapshot>("/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<TurnResult>("/api/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
setLatest(result);
setSnapshot(result.snapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
} finally {
setSubmitting(false);
}
}
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 the current entities and events, 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>
<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>{latest.narration}</p>
<pre>{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 2)}</pre>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null}
</section>
<section className="inspector-grid">
<article className="panel">
<h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null}
<ul className="entity-list">
{snapshot?.entities.map((entity) => (
<li key={entity.id}>
<strong>{entity.name}</strong>
<span>{entity.type}</span>
<pre>{JSON.stringify(entity.attributes, null, 2)}</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 {turn.turn}</strong>
<p>{turn.input}</p>
<p>{turn.output}</p>
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Events</h2>
<ul className="timeline-list compact">
{snapshot?.events.slice().reverse().map((event) => (
<li key={event.id}>
<strong>{event.result}</strong>
<pre>{JSON.stringify(event.action, null, 2)}</pre>
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Beliefs & summaries</h2>
<h3>Beliefs</h3>
<ul className="timeline-list compact">
{snapshot?.beliefs.map((belief) => (
<li key={`${belief.entity_id}-${belief.claim}`}>
<strong>{belief.entity_id}</strong>
<p>{belief.claim}</p>
<p>confidence: {belief.confidence}</p>
</li>
))}
</ul>
<h3>Summaries</h3>
<ul className="timeline-list compact">
{snapshot?.summaries.map((summary) => (
<li key={summary.id}>
<strong>{summary.turn_range.join("-")}</strong>
<p>{summary.text}</p>
</li>
))}
</ul>
</article>
</section>
</main>
);
}

View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,181 @@
:root {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: #f4efe4;
background:
radial-gradient(circle at top left, rgba(199, 166, 106, 0.16), transparent 36%),
linear-gradient(160deg, #112419 0%, #1c3024 52%, #31261b 100%);
line-height: 1.5;
font-weight: 400;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
textarea {
font: inherit;
}
.page-shell {
width: min(1400px, calc(100vw - 32px));
margin: 0 auto;
padding: 32px 0 48px;
}
.hero-panel {
padding: 32px;
border: 1px solid rgba(244, 239, 228, 0.16);
background: rgba(19, 24, 18, 0.68);
backdrop-filter: blur(12px);
border-radius: 28px;
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.24);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.22em;
font-size: 0.78rem;
color: #dcbf8d;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
font-family: "Alegreya", Georgia, serif;
font-size: clamp(2.5rem, 4vw, 4.5rem);
margin-bottom: 12px;
}
.lede {
max-width: 58rem;
color: rgba(244, 239, 228, 0.78);
}
.turn-form {
display: grid;
gap: 12px;
margin-top: 24px;
}
.turn-form textarea {
width: 100%;
border: 1px solid rgba(244, 239, 228, 0.18);
background: rgba(12, 16, 12, 0.72);
color: inherit;
border-radius: 18px;
padding: 16px;
resize: vertical;
}
.actions-row {
display: grid;
gap: 12px;
}
.actions-row > button,
.chip {
border: 0;
border-radius: 999px;
padding: 12px 18px;
background: linear-gradient(135deg, #d8b16f, #a96c36);
color: #1d1b17;
cursor: pointer;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
background: rgba(244, 239, 228, 0.08);
color: #f4efe4;
border: 1px solid rgba(244, 239, 228, 0.12);
}
.result-card,
.panel {
border-radius: 24px;
padding: 24px;
border: 1px solid rgba(244, 239, 228, 0.12);
background: rgba(11, 16, 12, 0.58);
}
.result-card {
margin-top: 24px;
}
.inspector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-top: 24px;
}
.entity-list,
.timeline-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 14px;
}
.entity-list li,
.timeline-list li {
padding: 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.entity-list span {
display: inline-block;
margin-left: 8px;
opacity: 0.7;
}
pre {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
color: #cfd9c2;
}
.compact li {
padding: 12px;
}
.error-banner {
margin-top: 16px;
color: #ffd2b8;
}
@media (max-width: 900px) {
.inspector-grid {
grid-template-columns: 1fr;
}
.page-shell {
width: min(100vw - 20px, 1400px);
padding-top: 20px;
}
.hero-panel,
.panel {
padding: 20px;
}
}