Compare commits
2 Commits
14a07bca7a
...
c2d12ffcc9
| Author | SHA1 | Date | |
|---|---|---|---|
| c2d12ffcc9 | |||
| 1df2ae8164 |
3
charactergarden/app/.dockerignore
Normal file
3
charactergarden/app/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
12
charactergarden/app/Dockerfile
Normal file
12
charactergarden/app/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY src ./src
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn src/index.ts",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
|
||||
316
charactergarden/app/src/app.ts
Normal file
316
charactergarden/app/src/app.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createDatabase, CharacterGardenDatabase } from "./db";
|
||||
import { resolveLatentEntity } from "./latentEntities";
|
||||
import { extractActionsFromProse } from "./llmAdapter";
|
||||
import { applyChanges, createOffsceneRoom, OFFSCENE_ROOM_ID, validate, WorldState } from "./truthEngine";
|
||||
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
|
||||
|
||||
export interface AppStateSnapshot {
|
||||
entities: Entity[];
|
||||
events: GameEvent[];
|
||||
turns: Turn[];
|
||||
beliefs: Belief[];
|
||||
summaries: Summary[];
|
||||
}
|
||||
|
||||
export interface TurnResult {
|
||||
narration: string;
|
||||
parser: "fallback";
|
||||
actions: Action[];
|
||||
accepted: Action[];
|
||||
rejected: { action: Action; reason: string }[];
|
||||
latent_resolution?: {
|
||||
accepted: boolean;
|
||||
reason: string;
|
||||
entity_id?: string;
|
||||
};
|
||||
snapshot: AppStateSnapshot;
|
||||
}
|
||||
|
||||
export interface CharacterGardenApp {
|
||||
db: CharacterGardenDatabase;
|
||||
getSnapshot(): AppStateSnapshot;
|
||||
processTurn(input: string): TurnResult;
|
||||
}
|
||||
|
||||
function createSeedEntities(): Entity[] {
|
||||
return [
|
||||
createOffsceneRoom(),
|
||||
{
|
||||
id: "garden",
|
||||
type: "room",
|
||||
name: "Garden",
|
||||
attributes: {
|
||||
description: "A small overgrown garden with a weathered bench and a shed door nearby.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shed",
|
||||
type: "room",
|
||||
name: "Shed",
|
||||
attributes: {
|
||||
description: "A cramped tool shed that smells of old wood and oil.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "player",
|
||||
type: "character",
|
||||
name: "Player",
|
||||
attributes: {
|
||||
location: "garden",
|
||||
clothed: true,
|
||||
pocket_count: 4,
|
||||
has_bag: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "groundskeeper",
|
||||
type: "character",
|
||||
name: "Groundskeeper",
|
||||
attributes: {
|
||||
location: "garden",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gate",
|
||||
type: "object",
|
||||
name: "Garden Gate",
|
||||
attributes: {
|
||||
location: "garden",
|
||||
open: false,
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bench",
|
||||
type: "object",
|
||||
name: "Bench",
|
||||
attributes: {
|
||||
location: "garden",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function worldStateFromEntities(entities: Entity[]): WorldState {
|
||||
return {
|
||||
entities: new Map(entities.map((entity) => [entity.id, entity])),
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFromWorldState(worldState: WorldState): Entity[] {
|
||||
return Array.from(worldState.entities.values()).sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function sameRoom(worldState: WorldState, leftId: string, rightId: string): boolean {
|
||||
const left = worldState.entities.get(leftId);
|
||||
const right = worldState.entities.get(rightId);
|
||||
return left?.attributes["location"] === right?.attributes["location"];
|
||||
}
|
||||
|
||||
function describeTarget(worldState: WorldState, targetId: string | undefined): string {
|
||||
if (!targetId) {
|
||||
return "nothing in particular";
|
||||
}
|
||||
|
||||
const entity = worldState.entities.get(targetId);
|
||||
return entity?.name ?? targetId;
|
||||
}
|
||||
|
||||
function narrateAction(action: Action, worldState: WorldState): string {
|
||||
switch (action.verb) {
|
||||
case "move": {
|
||||
const targetName = describeTarget(worldState, action.target);
|
||||
if (action.target === OFFSCENE_ROOM_ID) {
|
||||
return `You step out of the active scene and into ${targetName.toLowerCase()}.`;
|
||||
}
|
||||
return `You move to ${targetName}.`;
|
||||
}
|
||||
case "open":
|
||||
return `You open ${describeTarget(worldState, action.target)}.`;
|
||||
case "close":
|
||||
return `You close ${describeTarget(worldState, action.target)}.`;
|
||||
case "take":
|
||||
return `You take ${describeTarget(worldState, action.target)}.`;
|
||||
case "drop":
|
||||
return `You drop ${describeTarget(worldState, action.target)}.`;
|
||||
case "use":
|
||||
return `You use ${describeTarget(worldState, action.target)}.`;
|
||||
case "inspect":
|
||||
return `You inspect ${describeTarget(worldState, action.target)}.`;
|
||||
case "speak":
|
||||
return `You speak to ${describeTarget(worldState, action.target)}.`;
|
||||
default:
|
||||
return "You act.";
|
||||
}
|
||||
}
|
||||
|
||||
function narrateResult(
|
||||
worldState: WorldState,
|
||||
accepted: Action[],
|
||||
rejected: { action: Action; reason: string }[],
|
||||
latentReason?: string
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (latentReason) {
|
||||
lines.push(latentReason);
|
||||
}
|
||||
|
||||
for (const action of accepted) {
|
||||
lines.push(narrateAction(action, worldState));
|
||||
}
|
||||
|
||||
for (const rejection of rejected) {
|
||||
lines.push(`Action failed: ${rejection.reason}.`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push("Nothing changes.");
|
||||
}
|
||||
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
function persistWorldState(db: CharacterGardenDatabase, worldState: WorldState): void {
|
||||
for (const entity of worldState.entities.values()) {
|
||||
db.upsertEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateInitialState(db: CharacterGardenDatabase): WorldState {
|
||||
db.init();
|
||||
const existing = db.listEntities();
|
||||
if (existing.length > 0) {
|
||||
return worldStateFromEntities(existing);
|
||||
}
|
||||
|
||||
const seeded = createSeedEntities();
|
||||
for (const entity of seeded) {
|
||||
db.upsertEntity(entity);
|
||||
}
|
||||
|
||||
return worldStateFromEntities(seeded);
|
||||
}
|
||||
|
||||
export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
|
||||
const db = createDatabase({ dbPath });
|
||||
let worldState = hydrateInitialState(db);
|
||||
|
||||
function getSnapshot(): AppStateSnapshot {
|
||||
return {
|
||||
entities: entitiesFromWorldState(worldState),
|
||||
events: db.listEvents(),
|
||||
turns: db.listTurns(),
|
||||
beliefs: db.listBeliefs(),
|
||||
summaries: db.listSummaries(),
|
||||
};
|
||||
}
|
||||
|
||||
function processTurn(input: string): TurnResult {
|
||||
const turnNumber = db.listTurns().length + 1;
|
||||
const { actions, parser } = extractActionsFromProse(input);
|
||||
|
||||
let activeWorldState = worldState;
|
||||
let latentResolution: TurnResult["latent_resolution"];
|
||||
const latentNoun = typeof actions[0]?.params?.["latent_item"] === "string"
|
||||
? String(actions[0].params?.["latent_item"])
|
||||
: null;
|
||||
|
||||
if (latentNoun) {
|
||||
const resolution = resolveLatentEntity(
|
||||
{ actor_id: actions[0].actor, noun: latentNoun, turn: turnNumber },
|
||||
activeWorldState
|
||||
);
|
||||
|
||||
latentResolution = {
|
||||
accepted: resolution.accepted,
|
||||
reason: resolution.reason,
|
||||
entity_id: resolution.entity?.id,
|
||||
};
|
||||
|
||||
if (resolution.accepted && resolution.entity) {
|
||||
activeWorldState = {
|
||||
entities: new Map(activeWorldState.entities).set(
|
||||
resolution.entity.id,
|
||||
resolution.entity
|
||||
),
|
||||
};
|
||||
db.upsertEntity(resolution.entity);
|
||||
for (const belief of resolution.beliefs) {
|
||||
db.insertBelief(belief);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedActions = actions.map((action) => {
|
||||
if (latentNoun && latentResolution?.accepted && latentResolution.entity_id) {
|
||||
return {
|
||||
actor: action.actor,
|
||||
verb: "take" as const,
|
||||
target: latentResolution.entity_id,
|
||||
};
|
||||
}
|
||||
|
||||
return action;
|
||||
});
|
||||
|
||||
const validation = validate(normalizedActions, activeWorldState);
|
||||
const nextWorldState = applyChanges(activeWorldState, validation.state_changes);
|
||||
const narration = narrateResult(nextWorldState, validation.accepted, validation.rejected, latentResolution?.reason);
|
||||
|
||||
const turnRecord: Turn = {
|
||||
id: randomUUID(),
|
||||
turn: turnNumber,
|
||||
input,
|
||||
output: narration,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
db.insertTurn(turnRecord);
|
||||
|
||||
for (const action of validation.accepted) {
|
||||
const event: GameEvent = {
|
||||
id: randomUUID(),
|
||||
turn: turnNumber,
|
||||
action,
|
||||
result: "success",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
db.insertEvent(event);
|
||||
}
|
||||
|
||||
for (const rejection of validation.rejected) {
|
||||
const event: GameEvent = {
|
||||
id: randomUUID(),
|
||||
turn: turnNumber,
|
||||
action: rejection.action,
|
||||
result: "fail",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
db.insertEvent(event);
|
||||
}
|
||||
|
||||
worldState = nextWorldState;
|
||||
persistWorldState(db, worldState);
|
||||
|
||||
return {
|
||||
narration,
|
||||
parser,
|
||||
actions: normalizedActions,
|
||||
accepted: validation.accepted,
|
||||
rejected: validation.rejected,
|
||||
latent_resolution: latentResolution,
|
||||
snapshot: getSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
getSnapshot,
|
||||
processTurn,
|
||||
};
|
||||
}
|
||||
287
charactergarden/app/src/db.ts
Normal file
287
charactergarden/app/src/db.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import { Action, Belief, Entity, GameEvent, Summary, Turn } from "./types";
|
||||
|
||||
export interface DatabaseConfig {
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
export interface CharacterGardenDatabase {
|
||||
sqlite: Database.Database;
|
||||
init(): void;
|
||||
close(): void;
|
||||
upsertEntity(entity: Entity): void;
|
||||
listEntities(): Entity[];
|
||||
insertEvent(event: GameEvent): void;
|
||||
listEvents(): GameEvent[];
|
||||
insertTurn(turn: Turn): void;
|
||||
listTurns(): Turn[];
|
||||
insertBelief(belief: Belief): void;
|
||||
listBeliefs(entityId?: string): Belief[];
|
||||
insertSummary(summary: Summary): void;
|
||||
listSummaries(): Summary[];
|
||||
}
|
||||
|
||||
type EntityRow = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
attributes_json: string;
|
||||
};
|
||||
|
||||
type EventRow = {
|
||||
id: string;
|
||||
turn: number;
|
||||
action_json: string;
|
||||
result: "success" | "fail";
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type TurnRow = {
|
||||
id: string;
|
||||
turn: number;
|
||||
input: string;
|
||||
output: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type BeliefRow = {
|
||||
entity_id: string;
|
||||
claim: string;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
type SummaryRow = {
|
||||
id: string;
|
||||
turn_start: number;
|
||||
turn_end: number;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function ensureParentDirectory(dbPath: string): void {
|
||||
const directory = path.dirname(dbPath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string): T {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
export function createDatabase(config: DatabaseConfig): CharacterGardenDatabase {
|
||||
ensureParentDirectory(config.dbPath);
|
||||
|
||||
const sqlite = new Database(config.dbPath);
|
||||
|
||||
const initStatements = [
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
attributes_json TEXT NOT NULL
|
||||
)
|
||||
`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
turn INTEGER NOT NULL,
|
||||
action_json TEXT NOT NULL,
|
||||
result TEXT NOT NULL CHECK(result IN ('success', 'fail')),
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS turns (
|
||||
id TEXT PRIMARY KEY,
|
||||
turn INTEGER NOT NULL UNIQUE,
|
||||
input TEXT NOT NULL,
|
||||
output TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS beliefs (
|
||||
entity_id TEXT NOT NULL,
|
||||
claim TEXT NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
PRIMARY KEY (entity_id, claim)
|
||||
)
|
||||
`,
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS summaries (
|
||||
id TEXT PRIMARY KEY,
|
||||
turn_start INTEGER NOT NULL,
|
||||
turn_end INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
`,
|
||||
];
|
||||
|
||||
for (const statement of initStatements) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
|
||||
const upsertEntityStatement = sqlite.prepare(`
|
||||
INSERT INTO entities (id, type, name, attributes_json)
|
||||
VALUES (@id, @type, @name, @attributes_json)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
name = excluded.name,
|
||||
attributes_json = excluded.attributes_json
|
||||
`);
|
||||
|
||||
const listEntitiesStatement = sqlite.prepare(`
|
||||
SELECT id, type, name, attributes_json
|
||||
FROM entities
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
|
||||
const insertEventStatement = sqlite.prepare(`
|
||||
INSERT INTO events (id, turn, action_json, result, timestamp)
|
||||
VALUES (@id, @turn, @action_json, @result, @timestamp)
|
||||
`);
|
||||
|
||||
const listEventsStatement = sqlite.prepare(`
|
||||
SELECT id, turn, action_json, result, timestamp
|
||||
FROM events
|
||||
ORDER BY turn ASC, timestamp ASC, id ASC
|
||||
`);
|
||||
|
||||
const insertTurnStatement = sqlite.prepare(`
|
||||
INSERT INTO turns (id, turn, input, output, timestamp)
|
||||
VALUES (@id, @turn, @input, @output, @timestamp)
|
||||
`);
|
||||
|
||||
const listTurnsStatement = sqlite.prepare(`
|
||||
SELECT id, turn, input, output, timestamp
|
||||
FROM turns
|
||||
ORDER BY turn ASC
|
||||
`);
|
||||
|
||||
const insertBeliefStatement = sqlite.prepare(`
|
||||
INSERT INTO beliefs (entity_id, claim, confidence)
|
||||
VALUES (@entity_id, @claim, @confidence)
|
||||
ON CONFLICT(entity_id, claim) DO UPDATE SET
|
||||
confidence = excluded.confidence
|
||||
`);
|
||||
|
||||
const listBeliefsStatement = sqlite.prepare(`
|
||||
SELECT entity_id, claim, confidence
|
||||
FROM beliefs
|
||||
ORDER BY entity_id ASC, claim ASC
|
||||
`);
|
||||
|
||||
const listBeliefsByEntityStatement = sqlite.prepare(`
|
||||
SELECT entity_id, claim, confidence
|
||||
FROM beliefs
|
||||
WHERE entity_id = ?
|
||||
ORDER BY claim ASC
|
||||
`);
|
||||
|
||||
const insertSummaryStatement = sqlite.prepare(`
|
||||
INSERT INTO summaries (id, turn_start, turn_end, text, timestamp)
|
||||
VALUES (@id, @turn_start, @turn_end, @text, @timestamp)
|
||||
`);
|
||||
|
||||
const listSummariesStatement = sqlite.prepare(`
|
||||
SELECT id, turn_start, turn_end, text, timestamp
|
||||
FROM summaries
|
||||
ORDER BY turn_start ASC, turn_end ASC
|
||||
`);
|
||||
|
||||
return {
|
||||
sqlite,
|
||||
|
||||
init() {
|
||||
// Schema is applied on database construction so prepared statements are valid.
|
||||
},
|
||||
|
||||
close() {
|
||||
sqlite.close();
|
||||
},
|
||||
|
||||
upsertEntity(entity) {
|
||||
upsertEntityStatement.run({
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
name: entity.name,
|
||||
attributes_json: JSON.stringify(entity.attributes),
|
||||
});
|
||||
},
|
||||
|
||||
listEntities() {
|
||||
const rows = listEntitiesStatement.all() as EntityRow[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
attributes: parseJson<Record<string, unknown>>(row.attributes_json),
|
||||
}));
|
||||
},
|
||||
|
||||
insertEvent(event) {
|
||||
insertEventStatement.run({
|
||||
id: event.id,
|
||||
turn: event.turn,
|
||||
action_json: JSON.stringify(event.action),
|
||||
result: event.result,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
},
|
||||
|
||||
listEvents() {
|
||||
const rows = listEventsStatement.all() as EventRow[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
turn: row.turn,
|
||||
action: parseJson<Action>(row.action_json),
|
||||
result: row.result,
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
},
|
||||
|
||||
insertTurn(turn) {
|
||||
insertTurnStatement.run(turn);
|
||||
},
|
||||
|
||||
listTurns() {
|
||||
return listTurnsStatement.all() as TurnRow[];
|
||||
},
|
||||
|
||||
insertBelief(belief) {
|
||||
insertBeliefStatement.run(belief);
|
||||
},
|
||||
|
||||
listBeliefs(entityId) {
|
||||
if (entityId) {
|
||||
return listBeliefsByEntityStatement.all(entityId) as BeliefRow[];
|
||||
}
|
||||
|
||||
return listBeliefsStatement.all() as BeliefRow[];
|
||||
},
|
||||
|
||||
insertSummary(summary) {
|
||||
insertSummaryStatement.run({
|
||||
id: summary.id,
|
||||
turn_start: summary.turn_range[0],
|
||||
turn_end: summary.turn_range[1],
|
||||
text: summary.text,
|
||||
timestamp: summary.timestamp,
|
||||
});
|
||||
},
|
||||
|
||||
listSummaries() {
|
||||
const rows = listSummariesStatement.all() as SummaryRow[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
turn_range: [row.turn_start, row.turn_end],
|
||||
text: row.text,
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
35
charactergarden/app/src/index.ts
Normal file
35
charactergarden/app/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Fastify from "fastify";
|
||||
|
||||
import { createCharacterGardenApp } from "./app";
|
||||
|
||||
const port = Number(process.env.APP_PORT ?? 3000);
|
||||
const host = process.env.APP_HOST ?? "0.0.0.0";
|
||||
const dbPath = process.env.DB_PATH ?? "/data/sqlite/app.db";
|
||||
|
||||
const game = createCharacterGardenApp(dbPath);
|
||||
const server = Fastify({ logger: true });
|
||||
|
||||
server.get("/health", async () => ({ ok: true }));
|
||||
|
||||
server.get("/api/state", async () => game.getSnapshot());
|
||||
|
||||
server.post<{ Body: { input?: string } }>("/api/turn", async (request, reply) => {
|
||||
const input = request.body?.input?.trim();
|
||||
if (!input) {
|
||||
reply.code(400);
|
||||
return { error: "input is required" };
|
||||
}
|
||||
|
||||
return game.processTurn(input);
|
||||
});
|
||||
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
await server.listen({ host, port });
|
||||
} catch (error) {
|
||||
server.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void start();
|
||||
209
charactergarden/app/src/latentEntities.ts
Normal file
209
charactergarden/app/src/latentEntities.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
Affordance,
|
||||
Belief,
|
||||
Entity,
|
||||
Fact,
|
||||
LatentEntityRequest,
|
||||
LatentEntityResolution,
|
||||
} from "./types";
|
||||
import { WorldState } from "./truthEngine";
|
||||
|
||||
const PERSONAL_ITEM_NOUNS = new Set([
|
||||
"phone",
|
||||
"wallet",
|
||||
"keys",
|
||||
"notebook",
|
||||
"pen",
|
||||
"coin",
|
||||
"id card",
|
||||
"card",
|
||||
]);
|
||||
|
||||
function asBoolean(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "item";
|
||||
}
|
||||
|
||||
function deriveAffordances(actor: Entity): Affordance[] {
|
||||
const clothed = asBoolean(actor.attributes["clothed"]);
|
||||
const pocketCount = asNumber(actor.attributes["pocket_count"]);
|
||||
const hasBag = asBoolean(actor.attributes["has_bag"]);
|
||||
const searchedEmpty = asBoolean(actor.attributes["searched_empty"]);
|
||||
|
||||
const canConcealSmallItems =
|
||||
searchedEmpty !== true &&
|
||||
((clothed === true && (pocketCount ?? 0) > 0) || hasBag === true);
|
||||
|
||||
const reason = searchedEmpty === true
|
||||
? "actor was previously established as carrying nothing"
|
||||
: hasBag === true
|
||||
? "actor is carrying a bag or container"
|
||||
: clothed === true && (pocketCount ?? 0) > 0
|
||||
? "actor is clothed and has pockets"
|
||||
: "actor has no established carrying context for concealed items";
|
||||
|
||||
return [
|
||||
{
|
||||
entity_id: actor.id,
|
||||
key: "can_conceal_small_items",
|
||||
enabled: canConcealSmallItems,
|
||||
reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildBelief(actor: Entity, noun: string): Belief {
|
||||
return {
|
||||
entity_id: actor.id,
|
||||
claim: `${actor.name} may be carrying a ${noun}`,
|
||||
confidence: PERSONAL_ITEM_NOUNS.has(noun) ? 0.8 : 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function createEntityId(actorId: string, noun: string, worldState: WorldState): string {
|
||||
const base = `${actorId}-${slugify(noun)}`;
|
||||
if (!worldState.entities.has(base)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (worldState.entities.has(`${base}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
function createLatentEntity(
|
||||
actor: Entity,
|
||||
noun: string,
|
||||
turn: number | undefined,
|
||||
worldState: WorldState
|
||||
): Entity {
|
||||
const entityId = createEntityId(actor.id, noun, worldState);
|
||||
|
||||
return {
|
||||
id: entityId,
|
||||
type: "item",
|
||||
name: noun,
|
||||
attributes: {
|
||||
location: `inventory:${actor.id}`,
|
||||
takeable: true,
|
||||
useable: true,
|
||||
provenance: {
|
||||
introduced_turn: turn,
|
||||
introduced_by: actor.id,
|
||||
introduced_reason: "plausible_personal_item",
|
||||
latent_from_belief: `${actor.name} may be carrying a ${noun}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLatentEntity(
|
||||
request: LatentEntityRequest,
|
||||
worldState: WorldState
|
||||
): LatentEntityResolution {
|
||||
const actor = worldState.entities.get(request.actor_id);
|
||||
if (!actor) {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: `actor entity '${request.actor_id}' does not exist`,
|
||||
facts: [],
|
||||
beliefs: [],
|
||||
affordances: [],
|
||||
};
|
||||
}
|
||||
|
||||
const noun = request.noun.trim().toLowerCase();
|
||||
if (!noun) {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: "latent entity request requires a noun",
|
||||
facts: [],
|
||||
beliefs: [],
|
||||
affordances: [],
|
||||
};
|
||||
}
|
||||
|
||||
const affordances = deriveAffordances(actor);
|
||||
const concealment = affordances.find(
|
||||
(affordance) => affordance.key === "can_conceal_small_items"
|
||||
);
|
||||
const belief = buildBelief(actor, noun);
|
||||
|
||||
if (asBoolean(actor.attributes["naked"]) === true) {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: `${actor.id} is established as naked and cannot plausibly conceal a ${noun}`,
|
||||
facts: [],
|
||||
beliefs: [belief],
|
||||
affordances,
|
||||
};
|
||||
}
|
||||
|
||||
if (concealment?.enabled !== true) {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: concealment?.reason ?? `no carrying context supports introducing a ${noun}`,
|
||||
facts: [],
|
||||
beliefs: [belief],
|
||||
affordances,
|
||||
};
|
||||
}
|
||||
|
||||
if (!PERSONAL_ITEM_NOUNS.has(noun)) {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: `${noun} is not in the MVP plausible personal-item set`,
|
||||
facts: [],
|
||||
beliefs: [belief],
|
||||
affordances,
|
||||
};
|
||||
}
|
||||
|
||||
const entity = createLatentEntity(actor, noun, request.turn, worldState);
|
||||
|
||||
const facts: Fact[] = [
|
||||
{
|
||||
entity_id: actor.id,
|
||||
key: `may_have_${slugify(noun)}`,
|
||||
value: true,
|
||||
source: "inference",
|
||||
},
|
||||
{
|
||||
entity_id: entity.id,
|
||||
key: "location",
|
||||
value: `inventory:${actor.id}`,
|
||||
source: "inference",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
reason: `${noun} promoted from plausible latent belief to fact`,
|
||||
entity,
|
||||
facts,
|
||||
beliefs: [belief],
|
||||
affordances,
|
||||
};
|
||||
}
|
||||
131
charactergarden/app/src/llmAdapter.ts
Normal file
131
charactergarden/app/src/llmAdapter.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Action } from "./types";
|
||||
|
||||
export interface ExtractedActions {
|
||||
actions: Action[];
|
||||
parser: "fallback";
|
||||
}
|
||||
|
||||
const ROOM_ALIASES: Record<string, string> = {
|
||||
garden: "garden",
|
||||
shed: "shed",
|
||||
offscene: "offscene",
|
||||
outside: "offscene",
|
||||
away: "offscene",
|
||||
};
|
||||
|
||||
const TARGET_ALIASES: Record<string, string> = {
|
||||
gate: "gate",
|
||||
bench: "bench",
|
||||
groundskeeper: "groundskeeper",
|
||||
keeper: "groundskeeper",
|
||||
shed: "shed",
|
||||
garden: "garden",
|
||||
offscene: "offscene",
|
||||
};
|
||||
|
||||
function normalized(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractQuotedOrTrailingNoun(input: string): string | null {
|
||||
const quoted = input.match(/"([^"]+)"|'([^']+)'/);
|
||||
if (quoted) {
|
||||
return (quoted[1] ?? quoted[2]).trim().toLowerCase();
|
||||
}
|
||||
|
||||
const pulled = input.match(/(?:pull|pulls|pulled|take|takes|took)\s+(?:out\s+)?(?:a|an|the|my|their|his|her)?\s*([a-z0-9 ]+)$/i);
|
||||
if (pulled?.[1]) {
|
||||
return pulled[1].trim().toLowerCase();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTarget(input: string): string | undefined {
|
||||
const direct = Object.entries(TARGET_ALIASES).find(([alias]) =>
|
||||
input.includes(alias)
|
||||
);
|
||||
|
||||
return direct?.[1];
|
||||
}
|
||||
|
||||
export function extractActionsFromProse(input: string, actorId = "player"): ExtractedActions {
|
||||
const text = normalized(input);
|
||||
|
||||
if (!text) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "inspect", target: actorId }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
const room = Object.entries(ROOM_ALIASES).find(([alias]) => text.includes(alias))?.[1];
|
||||
if (/(go|move|walk|head|travel)/.test(text) && room) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "move", target: room }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(open)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(close|shut)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "close", target: resolveTarget(text) ?? "gate" }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(take|pick up|grab)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "take", target: resolveTarget(text) ?? undefined }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(drop|put down|set down)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "drop", target: resolveTarget(text) ?? undefined }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(talk|speak|ask|say)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "speak", target: resolveTarget(text) ?? "groundskeeper", params: { utterance: input } }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(use|press|activate)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "use", target: resolveTarget(text) ?? undefined }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(look|inspect|examine)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "inspect", target: resolveTarget(text) ?? actorId }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
const latentNoun = extractQuotedOrTrailingNoun(text);
|
||||
if (latentNoun && /(pull|pulls|pulled|take|takes|took).*(out)/.test(text)) {
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "inspect", params: { latent_item: latentNoun } }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actions: [{ actor: actorId, verb: "inspect", target: actorId, params: { raw_input: input } }],
|
||||
parser: "fallback",
|
||||
};
|
||||
}
|
||||
294
charactergarden/app/src/truthEngine.ts
Normal file
294
charactergarden/app/src/truthEngine.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Truth Engine — section 5.2
|
||||
*
|
||||
* Pure validation logic. No LLM. No I/O. No side effects.
|
||||
* Receives a world state snapshot and a list of actions.
|
||||
* Returns what is accepted, what is rejected, and what would change.
|
||||
*
|
||||
* Rules (section 3):
|
||||
* 1. Only the Truth Engine may produce StateChanges.
|
||||
* 2. LLM output is never directly trusted.
|
||||
* 3. Every state change must be traceable to an accepted action.
|
||||
* 4. Invalid actions must return explicit failure reasons.
|
||||
*/
|
||||
|
||||
import { Action, Entity, StateChange, ValidationResult, ALLOWED_VERBS } from "./types";
|
||||
|
||||
export const OFFSCENE_ROOM_ID = "offscene";
|
||||
|
||||
// ── World state snapshot passed into validate() ──────────────
|
||||
export interface WorldState {
|
||||
entities: Map<string, Entity>;
|
||||
}
|
||||
|
||||
// ── Per-verb rule handlers ────────────────────────────────────
|
||||
type RuleResult =
|
||||
| { ok: true; changes: StateChange[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
type VerbHandler = (
|
||||
action: Action,
|
||||
actor: Entity,
|
||||
world: WorldState
|
||||
) => RuleResult;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function requireTarget(
|
||||
action: Action,
|
||||
world: WorldState
|
||||
): { ok: true; target: Entity } | { ok: false; reason: string } {
|
||||
if (!action.target) {
|
||||
return { ok: false, reason: `'${action.verb}' requires a target` };
|
||||
}
|
||||
const target = world.entities.get(action.target);
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `target entity '${action.target}' does not exist`,
|
||||
};
|
||||
}
|
||||
return { ok: true, target };
|
||||
}
|
||||
|
||||
function attributeChange(
|
||||
entity: Entity,
|
||||
field: string,
|
||||
newValue: unknown
|
||||
): StateChange {
|
||||
return {
|
||||
entity_id: entity.id,
|
||||
field,
|
||||
old_value: entity.attributes[field] ?? null,
|
||||
new_value: newValue,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Verb handlers ─────────────────────────────────────────────
|
||||
const verbHandlers: Record<string, VerbHandler> = {
|
||||
move(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
// Target must be a room/location. The built-in offscene room is valid.
|
||||
if (t.target.type !== "room") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `cannot move to '${t.target.id}': not a room`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changes: [attributeChange(actor, "location", t.target.id)],
|
||||
};
|
||||
},
|
||||
|
||||
open(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
if (t.target.attributes["locked"] === true) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `'${t.target.id}' is locked and cannot be opened`,
|
||||
};
|
||||
}
|
||||
if (t.target.attributes["open"] === true) {
|
||||
return { ok: false, reason: `'${t.target.id}' is already open` };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changes: [attributeChange(t.target, "open", true)],
|
||||
};
|
||||
},
|
||||
|
||||
close(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
if (t.target.attributes["open"] === false) {
|
||||
return { ok: false, reason: `'${t.target.id}' is already closed` };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changes: [attributeChange(t.target, "open", false)],
|
||||
};
|
||||
},
|
||||
|
||||
take(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
if (t.target.attributes["takeable"] === false) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `'${t.target.id}' cannot be taken`,
|
||||
};
|
||||
}
|
||||
|
||||
// Item must be in the same location as actor (unless already in inventory)
|
||||
const actorLocation = actor.attributes["location"];
|
||||
const itemLocation = t.target.attributes["location"];
|
||||
const expectedInventory = `inventory:${actor.id}`;
|
||||
|
||||
// If already in inventory, it's a no-op (already holding it)
|
||||
if (itemLocation === expectedInventory) {
|
||||
return {
|
||||
ok: true,
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (actorLocation !== itemLocation) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `'${t.target.id}' is not in the same location as '${actor.id}'`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changes: [attributeChange(t.target, "location", expectedInventory)],
|
||||
};
|
||||
},
|
||||
|
||||
drop(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
const expectedLocation = `inventory:${actor.id}`;
|
||||
if (t.target.attributes["location"] !== expectedLocation) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `'${t.target.id}' is not in '${actor.id}' inventory`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changes: [
|
||||
attributeChange(t.target, "location", actor.attributes["location"]),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
use(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
if (t.target.attributes["useable"] === false) {
|
||||
return { ok: false, reason: `'${t.target.id}' cannot be used` };
|
||||
}
|
||||
|
||||
// Generic "use" records a state change marking last user; concrete effects
|
||||
// are handled by higher-level game logic layered on top.
|
||||
return {
|
||||
ok: true,
|
||||
changes: [attributeChange(t.target, "last_used_by", actor.id)],
|
||||
};
|
||||
},
|
||||
|
||||
inspect(_action, _actor, _world) {
|
||||
// inspect is always valid — it has no side effects
|
||||
return { ok: true, changes: [] };
|
||||
},
|
||||
|
||||
speak(action, actor, world) {
|
||||
const t = requireTarget(action, world);
|
||||
if (!t.ok) return t;
|
||||
|
||||
if (t.target.type !== "character") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `cannot speak to '${t.target.id}': not a character`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, changes: [] };
|
||||
},
|
||||
};
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a list of actions against the current world state.
|
||||
* Pure function — does NOT mutate worldState.
|
||||
*/
|
||||
export function validate(
|
||||
actions: Action[],
|
||||
worldState: WorldState
|
||||
): ValidationResult {
|
||||
const accepted: Action[] = [];
|
||||
const rejected: { action: Action; reason: string }[] = [];
|
||||
const state_changes: StateChange[] = [];
|
||||
|
||||
for (const action of actions) {
|
||||
// 1. Verb must be in the allowed set
|
||||
if (!(ALLOWED_VERBS as readonly string[]).includes(action.verb)) {
|
||||
rejected.push({ action, reason: `unknown verb '${action.verb}'` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Actor must exist
|
||||
const actor = worldState.entities.get(action.actor);
|
||||
if (!actor) {
|
||||
rejected.push({
|
||||
action,
|
||||
reason: `actor entity '${action.actor}' does not exist`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Run verb-specific handler
|
||||
const handler = verbHandlers[action.verb];
|
||||
const result = handler(action, actor, worldState);
|
||||
|
||||
if (!result.ok) {
|
||||
rejected.push({ action, reason: result.reason });
|
||||
} else {
|
||||
accepted.push(action);
|
||||
state_changes.push(...result.changes);
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted, rejected, state_changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a validated set of StateChanges to a WorldState snapshot.
|
||||
* Returns a new Map — does NOT mutate the original.
|
||||
*/
|
||||
export function applyChanges(
|
||||
worldState: WorldState,
|
||||
changes: StateChange[]
|
||||
): WorldState {
|
||||
const next = new Map(
|
||||
Array.from(worldState.entities.entries()).map(([id, entity]) => [
|
||||
id,
|
||||
{ ...entity, attributes: { ...entity.attributes } },
|
||||
])
|
||||
);
|
||||
|
||||
for (const change of changes) {
|
||||
const entity = next.get(change.entity_id);
|
||||
if (entity) {
|
||||
entity.attributes[change.field] = change.new_value;
|
||||
}
|
||||
}
|
||||
|
||||
return { entities: next };
|
||||
}
|
||||
|
||||
export function createOffsceneRoom(): Entity {
|
||||
return {
|
||||
id: OFFSCENE_ROOM_ID,
|
||||
type: "room",
|
||||
name: "Offscene",
|
||||
attributes: {
|
||||
offscene: true,
|
||||
visible: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,42 @@ export interface Belief {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface Fact {
|
||||
entity_id: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
source: "seed" | "action" | "inference";
|
||||
}
|
||||
|
||||
export interface Affordance {
|
||||
entity_id: string;
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface EntityProvenance {
|
||||
introduced_turn?: number;
|
||||
introduced_by?: string;
|
||||
introduced_reason?: string;
|
||||
latent_from_belief?: string;
|
||||
}
|
||||
|
||||
export interface LatentEntityRequest {
|
||||
actor_id: string;
|
||||
noun: string;
|
||||
turn?: number;
|
||||
}
|
||||
|
||||
export interface LatentEntityResolution {
|
||||
accepted: boolean;
|
||||
reason: string;
|
||||
entity?: Entity;
|
||||
facts: Fact[];
|
||||
beliefs: Belief[];
|
||||
affordances: Affordance[];
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
id: string;
|
||||
turn_range: [number, number];
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: ./app
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
environment:
|
||||
- APP_PORT=3000
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
- DB_PATH=/data/sqlite/app.db
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://ollama:11434}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
- ollama
|
||||
- ./app/src:/app/src
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
working_dir: /frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:5173"
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
volumes:
|
||||
- ./frontend/src:/frontend/src
|
||||
- ./frontend/index.html:/frontend/index.html
|
||||
- ./frontend/vite.config.ts:/frontend/vite.config.ts
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
|
||||
3
charactergarden/frontend/.dockerignore
Normal file
3
charactergarden/frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
14
charactergarden/frontend/Dockerfile
Normal file
14
charactergarden/frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
COPY package.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY vite.config.ts ./
|
||||
COPY index.html ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY src ./src
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
12
charactergarden/frontend/index.html
Normal file
12
charactergarden/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CharacterGarden</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
214
charactergarden/frontend/src/App.tsx
Normal file
214
charactergarden/frontend/src/App.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
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, 0)}</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, 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 {turn.turn}:</strong> {turn.input} → {turn.output}
|
||||
</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, 0)}</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> {belief.claim} ({belief.confidence})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Summaries</h3>
|
||||
<ul className="timeline-list compact">
|
||||
{snapshot?.summaries.map((summary) => (
|
||||
<li key={summary.id}>
|
||||
<strong>{summary.turn_range.join("-")}:</strong> {summary.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
charactergarden/frontend/src/main.tsx
Normal file
11
charactergarden/frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
183
charactergarden/frontend/src/styles.css
Normal file
183
charactergarden/frontend/src/styles.css
Normal file
@@ -0,0 +1,183 @@
|
||||
: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: 16px 0 24px;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(244, 239, 228, 0.16);
|
||||
background: rgba(19, 24, 18, 0.68);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 20px;
|
||||
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: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
}
|
||||
|
||||
.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: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(244, 239, 228, 0.12);
|
||||
background: rgba(11, 16, 12, 0.58);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.inspector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.entity-list,
|
||||
.timeline-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entity-list li,
|
||||
.timeline-list li {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
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.75rem;
|
||||
color: #cfd9c2;
|
||||
margin: 4px 0 0 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.compact li {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.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: 14px;
|
||||
}
|
||||
}
|
||||
20
charactergarden/frontend/tsconfig.json
Normal file
20
charactergarden/frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
20
charactergarden/frontend/vite.config.ts
Normal file
20
charactergarden/frontend/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://app:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://app:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
34
thoughts.md
34
thoughts.md
@@ -2,10 +2,19 @@
|
||||
|
||||
## Current Status
|
||||
- Scaffold complete: `charactergarden/` folder structure created per spec section 9
|
||||
- Core contracts defined in `app/src/types.ts`: Entity, Action, Verb, ValidationResult, StateChange, GameEvent, Turn, Belief, Summary
|
||||
- Core contracts defined in `app/src/types.ts`: Entity, Action, Verb, ValidationResult, StateChange, GameEvent, Turn, Belief, Fact, Affordance, Summary
|
||||
- `docker-compose.yml` created; ollama service gated behind `--profile llm` (not required for MVP)
|
||||
- `.env` created with defaults
|
||||
- Nothing is implemented yet — no logic, no database, no server
|
||||
- `.env` / `.env.example` / `.gitignore` in place
|
||||
- Container-first runtime files added: app/frontend Dockerfiles and `.dockerignore`s
|
||||
- **Truth Engine implemented** in `app/src/truthEngine.ts` — pure function, no I/O, no LLM
|
||||
- `validate(actions, worldState)` → ValidationResult
|
||||
- `applyChanges(worldState, changes)` → new WorldState (immutable)
|
||||
- All 8 verbs handled with explicit rejection reasons
|
||||
- `move` now supports a built-in offscene room convention via `createOffsceneRoom()`
|
||||
- `latentEntities.ts` can promote plausible personal items from belief to fact when the actor has carrying context
|
||||
- `db.ts` added with SQLite schema + persistence helpers for `entities`, `events`, `turns`, `beliefs`, and `summaries`
|
||||
- Minimal Fastify server + app pipeline added with seeded world state and fallback parser
|
||||
- Minimal Vite React inspector added for visual boot testing and state inspection
|
||||
|
||||
## Current Architecture Decisions
|
||||
- App: Node.js + Fastify + TypeScript
|
||||
@@ -13,17 +22,24 @@
|
||||
- Database: better-sqlite3 (synchronous, no ORM)
|
||||
- Ollama is optional; system must work without it (per section 14)
|
||||
- `Event` type renamed `GameEvent` in code to avoid collision with the DOM `Event` global
|
||||
- Latent personal items are gated by facts-derived affordances, not accepted directly from beliefs
|
||||
- The offscene room is represented as a normal room entity with id `offscene`
|
||||
- App and frontend should be run and validated through Docker Compose rather than host-installed Node
|
||||
|
||||
## Next Steps
|
||||
1. Implement Truth Engine (`app/src/truthEngine.ts`) — pure validation, no I/O
|
||||
2. Add SQLite schema + db module (`app/src/db.ts`) with the 5 tables from section 7
|
||||
3. Implement App service / turn flow (`app/src/index.ts`) per section 6
|
||||
4. Add Fastify route `POST /turn` that executes the full pipeline
|
||||
5. Stub LLM adapter (`app/src/llmAdapter.ts`) with a fallback parser
|
||||
1. Implement App service / turn flow (`app/src/app.ts`) per section 6
|
||||
2. Validate Docker boot and iterate on any compile/runtime failures
|
||||
3. Expand fallback parser coverage and tighten truth-engine world rules
|
||||
4. Add LLM adapter implementation beyond fallback parsing
|
||||
|
||||
## Open Questions
|
||||
- Should room/location be an Entity attribute or a separate entity type?
|
||||
- What is the initial world state for the MVP (1–2 rooms, ≤3 characters)?
|
||||
- Should latent personal-item plausibility live only on actor attributes, or also look at worn item/container entities?
|
||||
|
||||
## Session Notes
|
||||
- 2026-04-23: Project started. Scaffold and type contracts created. No logic yet.
|
||||
- 2026-04-23: Project started. Scaffold, type contracts, .gitignore, and .env.example created.
|
||||
- 2026-04-23: Truth Engine implemented. Pure validation with per-verb handlers and immutable applyChanges helper.
|
||||
- 2026-04-23: Added facts/affordances + latent entity resolver for improv-style personal items, plus offscene room support.
|
||||
- 2026-04-23: Added SQLite schema module. Host `npm install` is blocked by `better-sqlite3` on Windows Node 25, so runtime validation should happen inside Docker on an LTS Node image instead.
|
||||
- 2026-04-23: Added minimal backend/frontend boot slice so the project can be tested visually through Docker.
|
||||
|
||||
Reference in New Issue
Block a user