Compare commits
16 Commits
master
...
272995fb6c
| Author | SHA1 | Date | |
|---|---|---|---|
| 272995fb6c | |||
| 7f1d9fa39a | |||
| 0e9d1b2a43 | |||
| e39d1a446e | |||
| 4ab0e564ef | |||
| 6d5510773a | |||
| 4aa65c9d4c | |||
| e80a4071f6 | |||
| 843ab11a5c | |||
| 710745e548 | |||
| 38c659cbfe | |||
| 87c5748295 | |||
| 800811dac2 | |||
| d340e06dad | |||
| c5eccbb2a5 | |||
| d4ada9bedb |
@@ -1,46 +0,0 @@
|
|||||||
[2026-07-09 10:54:03.926] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:54:03.929] Processing: hook_event=UserPromptSubmit, tool=
|
|
||||||
[2026-07-09 10:56:09.126] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:09.128] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:18.474] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:18.476] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:21.852] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:21.855] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:25.336] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:25.338] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:28.741] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:28.744] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:32.070] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:32.073] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:35.419] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:35.422] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:39.157] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:39.159] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:42.516] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:42.518] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:48.542] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:48.545] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:51.814] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:51.816] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:55.095] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:55.098] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:56:59.075] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:56:59.077] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:02.336] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:02.338] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:05.616] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:05.618] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:08.886] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:08.888] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:12.207] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:12.209] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:16.216] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:16.218] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:19.540] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:19.542] Processing: hook_event=PostToolUse, tool=Write
|
|
||||||
[2026-07-09 10:57:31.383] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:31.386] Processing: hook_event=PostToolUse, tool=Edit
|
|
||||||
[2026-07-09 10:57:51.755] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
|
||||||
[2026-07-09 10:57:51.758] Processing: hook_event=Stop, tool=
|
|
||||||
[2026-07-09 10:57:51.761] _git_status_porcelain error: [Errno 138] emscripten does not support processes.
|
|
||||||
[2026-07-09 10:57:51.761] Stop hook: empty review set
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"shown_warnings": [], "touched_paths": []}
|
|
||||||
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.claude
|
||||||
|
client
|
||||||
|
content
|
||||||
|
docs/*
|
||||||
|
!docs/schemas
|
||||||
|
**/__pycache__
|
||||||
|
**/*.py[cod]
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
**/.pytest_cache
|
||||||
|
api/tests
|
||||||
|
api/.env
|
||||||
|
api/.env.*
|
||||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Godot 4.7 (client/)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Editor + import cache (4.x replaced .import/ with .godot/)
|
||||||
|
.godot/
|
||||||
|
# Exported builds
|
||||||
|
/client/build/
|
||||||
|
export.cfg
|
||||||
|
# export_presets.cfg holds keystore paths / signing config — keep secrets out of history
|
||||||
|
export_presets.cfg
|
||||||
|
# Mono/C# (only if we drop to C# per charter §16)
|
||||||
|
.mono/
|
||||||
|
data_*/
|
||||||
|
*.mono/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Python / FastAPI (api/)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
# Virtual envs
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
# Tooling caches
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Secrets — the API key lives here in dev, NEVER in the client (charter §4)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Editors / OS
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Logs (charter §4/§10 logs go to a store, not the repo)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Claude Code harness session/state files
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
.claude/
|
||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -530,3 +530,15 @@ Test that before building anything else. Every other system exists to make that
|
|||||||
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
|
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
|
||||||
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
|
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
|
||||||
- **Prefer deleting scope.** The POC's value is answering one question fast.
|
- **Prefer deleting scope.** The POC's value is answering one question fast.
|
||||||
|
|
||||||
|
### Git workflow
|
||||||
|
|
||||||
|
The standard flow for this project. Claude follows it; the human owns `master`.
|
||||||
|
|
||||||
|
- **Branches.** `master` and `dev` are long-lived. `master` is release; `dev` is integration. All work branches off `dev`.
|
||||||
|
- **Never commit non-doc changes directly to `master` or `dev`.** Anything that is not purely docs gets its own branch (`feature/…`, `fix/…`, `chore/…`, etc.), branched off `dev`.
|
||||||
|
- **Doc-only edits may commit directly to `dev`** — no branch required.
|
||||||
|
- **Never commit to `master`.** Ever. The human merges `dev` → `master` as they see fit. Claude never touches that merge.
|
||||||
|
- **Merging a working branch → `dev`** happens only after the work is done, smoke-tested, and **the human confirms**. Merge locally with `--no-ff`.
|
||||||
|
- **After merging to `dev`, delete the working branch locally.**
|
||||||
|
- **Pushing.** Origin is set. Claude pushes **only `dev`**, and only after a merge. Never push working branches or `master` — the human always pushes `master`.
|
||||||
|
|||||||
11
api/.env.example
Normal file
11
api/.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Copy to .env for local dev. NEVER commit the real .env. The client never sees
|
||||||
|
# any of these — keys live only in the proxy (charter §4).
|
||||||
|
|
||||||
|
# Where the proxy sends model calls in dev (Ollama on the homelab).
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# Prod model provider (charter §4). Leave blank in dev.
|
||||||
|
REPLICATE_API_TOKEN=
|
||||||
|
|
||||||
|
# Port the proxy binds. compose / fly.io override this.
|
||||||
|
PORT=8000
|
||||||
26
api/Dockerfile
Normal file
26
api/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# /api — FastAPI proxy (charter §4). Build context is the repo root so the
|
||||||
|
# canon log schemas (/docs/schemas) are bundled into the image.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install deps first so the layer caches when only app code changes.
|
||||||
|
COPY api/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# App code + the schema contract (single source of truth in docs/schemas).
|
||||||
|
COPY api/app ./app
|
||||||
|
COPY docs/schemas ./schemas
|
||||||
|
|
||||||
|
# Run as non-root.
|
||||||
|
RUN useradd --create-home --uid 1000 appuser
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# fly.io / compose set PORT; default to 8000 (charter localhost:8000).
|
||||||
|
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
||||||
51
api/app/canon_log.py
Normal file
51
api/app/canon_log.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Load the JSON Schema contracts and validate canon logs / origin seeds.
|
||||||
|
|
||||||
|
Schemas are the single source of truth in /docs/schemas. This module is the
|
||||||
|
api's half of the contract (charter §11): every canon log the client posts is
|
||||||
|
validated here before it could ever reach a prompt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_dir() -> Path:
|
||||||
|
"""Locate the schema directory.
|
||||||
|
|
||||||
|
Honours CANON_SCHEMA_DIR, else walks up from this file looking for
|
||||||
|
docs/schemas (local checkout) or schemas (bundled into the Docker image).
|
||||||
|
"""
|
||||||
|
env = os.environ.get("CANON_SCHEMA_DIR")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
for parent in here.parents:
|
||||||
|
for candidate in (parent / "docs" / "schemas", parent / "schemas"):
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("schema directory not found")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _validator(schema_name: str) -> Draft202012Validator:
|
||||||
|
with open(_schema_dir() / schema_name) as f:
|
||||||
|
return Draft202012Validator(json.load(f))
|
||||||
|
|
||||||
|
|
||||||
|
def validation_errors(doc: dict, schema_name: str) -> list[str]:
|
||||||
|
validator = _validator(schema_name)
|
||||||
|
# Stringify path segments before sorting: a path can mix str property names
|
||||||
|
# and int array indices, which are not orderable against each other.
|
||||||
|
return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: [str(p) for p in e.path])]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_origin(doc: dict) -> list[str]:
|
||||||
|
return validation_errors(doc, "origin.schema.json")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_canon_log(doc: dict) -> list[str]:
|
||||||
|
return validation_errors(doc, "canon-log.schema.json")
|
||||||
50
api/app/content.py
Normal file
50
api/app/content.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Load authored world content and cross-check origin references.
|
||||||
|
|
||||||
|
Language-neutral content integrity: every id an origin references (start
|
||||||
|
location, start quest, granted items, seeded npc dispositions) must resolve in
|
||||||
|
world content. New-game construction (client-side, Plan B) relies on this
|
||||||
|
holding; catching it here fails a broken origin at authoring time, not three
|
||||||
|
scenes into play.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ids(dir_path: Path) -> set[str]:
|
||||||
|
ids: set[str] = set()
|
||||||
|
for f in dir_path.glob("*.json"):
|
||||||
|
with open(f) as fh:
|
||||||
|
ids.add(json.load(fh)["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def load_world(content_root: Path) -> dict[str, set[str]]:
|
||||||
|
world = content_root / "world"
|
||||||
|
return {
|
||||||
|
"locations": _load_ids(world / "locations"),
|
||||||
|
"npcs": _load_ids(world / "npcs"),
|
||||||
|
"quests": _load_ids(world / "quests"),
|
||||||
|
"items": _load_ids(world / "items"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_origin(path: Path) -> dict:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
|
||||||
|
missing: list[str] = []
|
||||||
|
if origin["start_location_id"] not in world["locations"]:
|
||||||
|
missing.append(f"location:{origin['start_location_id']}")
|
||||||
|
quest = origin.get("start_quest_id")
|
||||||
|
if quest is not None and quest not in world["quests"]:
|
||||||
|
missing.append(f"quest:{quest}")
|
||||||
|
for grant in origin["inventory_grants"]:
|
||||||
|
if grant["item_id"] not in world["items"]:
|
||||||
|
missing.append(f"item:{grant['item_id']}")
|
||||||
|
for npc_id in origin["disposition_overrides"]:
|
||||||
|
if npc_id not in world["npcs"]:
|
||||||
|
missing.append(f"npc:{npc_id}")
|
||||||
|
return missing
|
||||||
75
api/app/main.py
Normal file
75
api/app/main.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
|
||||||
|
|
||||||
|
Skeleton: a health check plus the five role endpoints. Each role endpoint now
|
||||||
|
validates the posted canon log against the contract (charter §11) before doing
|
||||||
|
anything else — an invalid log is rejected with 422 and never reaches a prompt.
|
||||||
|
Prompt routing, model selection, and logging land later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .canon_log import validate_canon_log
|
||||||
|
|
||||||
|
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def unify_validation_errors(request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
"""Reshape pydantic body-validation failures into the same envelope as a
|
||||||
|
canon-log schema failure, so the client parses ONE 422 shape (charter §11):
|
||||||
|
{"detail": {"canon_log_errors": [...]}}.
|
||||||
|
"""
|
||||||
|
errors = [f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in exc.errors()]
|
||||||
|
return JSONResponse(status_code=422, content={"detail": {"canon_log_errors": errors}})
|
||||||
|
|
||||||
|
|
||||||
|
class TurnRequest(BaseModel):
|
||||||
|
canon_log: dict
|
||||||
|
|
||||||
|
|
||||||
|
def valid_turn(req: TurnRequest) -> TurnRequest:
|
||||||
|
"""Shared dependency: reject any request whose canon log breaks the contract."""
|
||||||
|
errors = validate_canon_log(req.canon_log)
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
"""Liveness probe for compose / fly.io."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
||||||
|
# The client knows these paths and nothing about which model or prompt serves
|
||||||
|
# them. Bodies are validated against the canon log contract; the AI half is a
|
||||||
|
# stub until prompt routing lands.
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dm/narrate")
|
||||||
|
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||||
|
return {"detail": "not implemented"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dm/adjudicate")
|
||||||
|
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||||
|
return {"detail": "not implemented"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dm/improvise")
|
||||||
|
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||||
|
return {"detail": "not implemented"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/npc/speak")
|
||||||
|
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||||
|
return {"detail": "not implemented"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/party/banter")
|
||||||
|
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||||
|
return {"detail": "not implemented"}
|
||||||
21
api/fly.toml
Normal file
21
api/fly.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# fly.io deploy config for the proxy (charter §4, prod). Stub — set app name and
|
||||||
|
# region, then `fly deploy` from api/. Secrets (REPLICATE_API_TOKEN) go via
|
||||||
|
# `fly secrets set`, never in this file.
|
||||||
|
app = "coc-rpg-proxy" # TODO: claim a real app name
|
||||||
|
primary_region = "ord" # TODO: pick a region
|
||||||
|
|
||||||
|
[build]
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 8000
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = true
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
|
||||||
|
[[http_service.checks]]
|
||||||
|
method = "GET"
|
||||||
|
path = "/health"
|
||||||
|
interval = "15s"
|
||||||
|
timeout = "2s"
|
||||||
5
api/pytest.ini
Normal file
5
api/pytest.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
|
filterwarnings =
|
||||||
|
ignore:Using `httpx` with `starlette.testclient` is deprecated
|
||||||
2
api/requirements-dev.txt
Normal file
2
api/requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Dev/test deps. Installed on top of requirements.txt.
|
||||||
|
pytest==8.3.2
|
||||||
6
api/requirements.txt
Normal file
6
api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# FastAPI proxy runtime deps. Pinned from first resolve; bump deliberately.
|
||||||
|
fastapi==0.139.0
|
||||||
|
uvicorn[standard]==0.51.0
|
||||||
|
httpx==0.28.1 # calling Ollama / Replicate
|
||||||
|
pydantic==2.13.4 # request/response contracts
|
||||||
|
jsonschema==4.23.0 # canon log / origin contract validation
|
||||||
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
29
api/tests/fixtures/canon_log_valid.json
vendored
Normal file
29
api/tests/fixtures/canon_log_valid.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"player": {
|
||||||
|
"name": "Aldric",
|
||||||
|
"class_id": "sellsword",
|
||||||
|
"luck_descriptor": "Fortune spits on you"
|
||||||
|
},
|
||||||
|
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
|
||||||
|
"party": [
|
||||||
|
{ "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 },
|
||||||
|
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "disposition": 15 }
|
||||||
|
],
|
||||||
|
"recent_events": [
|
||||||
|
"Arrived at Greywater by barge before dawn",
|
||||||
|
"Brannoc recognised the harbourmaster and went quiet"
|
||||||
|
],
|
||||||
|
"established_facts": [
|
||||||
|
"the eastern bridge out of Greywater is washed out",
|
||||||
|
"the harbourmaster is named Oda Fenn"
|
||||||
|
],
|
||||||
|
"active_quests": [
|
||||||
|
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||||
|
"status": "active", "objective": "Find who took Fenn's ledger" }
|
||||||
|
],
|
||||||
|
"humiliations": [
|
||||||
|
{ "id": "h_0001", "text": "vomited on a shrine step in front of a priest",
|
||||||
|
"weight": 7, "turn": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
70
api/tests/test_canon_log_schema.py
Normal file
70
api/tests/test_canon_log_schema.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.canon_log import validate_canon_log
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "canon_log_valid.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _valid():
|
||||||
|
with open(FIXTURE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_canon_log_passes():
|
||||||
|
assert validate_canon_log(_valid()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_luck_is_rejected():
|
||||||
|
# Charter §7: the AI must never be able to calculate Luck. A stray numeric
|
||||||
|
# luck field must fail validation, not slip through.
|
||||||
|
doc = _valid()
|
||||||
|
doc["player"]["luck"] = 5
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_events_over_five_is_rejected():
|
||||||
|
doc = _valid()
|
||||||
|
doc["recent_events"] = [f"event {i}" for i in range(6)]
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disposition_out_of_range_is_rejected():
|
||||||
|
doc = _valid()
|
||||||
|
doc["party"][0]["disposition"] = 200
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_quest_status_is_rejected():
|
||||||
|
doc = _valid()
|
||||||
|
doc["active_quests"][0]["status"] = "abandoned"
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_humiliation_weight_bounds_enforced():
|
||||||
|
doc = _valid()
|
||||||
|
doc["humiliations"][0]["weight"] = 11
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_optional_arrays_are_allowed():
|
||||||
|
doc = _valid()
|
||||||
|
doc["party"] = []
|
||||||
|
doc["recent_events"] = []
|
||||||
|
doc["established_facts"] = []
|
||||||
|
doc["active_quests"] = []
|
||||||
|
doc["humiliations"] = []
|
||||||
|
assert validate_canon_log(doc) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_level_stray_field_is_rejected():
|
||||||
|
# §2/§7: stats/HP/inventory must never enter the log — additionalProperties:false at root.
|
||||||
|
doc = _valid()
|
||||||
|
doc["hp"] = 10
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_turn_is_rejected():
|
||||||
|
doc = _valid()
|
||||||
|
doc["humiliations"][0]["turn"] = -1
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
47
api/tests/test_content_resolution.py
Normal file
47
api/tests/test_content_resolution.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.canon_log import validate_origin
|
||||||
|
from app.content import load_world, load_origin, unresolved_refs
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CONTENT_ROOT = REPO_ROOT / "content"
|
||||||
|
DESERTER = CONTENT_ROOT / "origins" / "deserter.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deserter_origin_matches_schema():
|
||||||
|
assert validate_origin(load_origin(DESERTER)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_deserter_ids_all_resolve():
|
||||||
|
world = load_world(CONTENT_ROOT)
|
||||||
|
assert unresolved_refs(load_origin(DESERTER), world) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_location_ref_is_detected():
|
||||||
|
world = load_world(CONTENT_ROOT)
|
||||||
|
origin = copy.deepcopy(load_origin(DESERTER))
|
||||||
|
origin["start_location_id"] = "nowhere"
|
||||||
|
assert "location:nowhere" in unresolved_refs(origin, world)
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_item_ref_is_detected():
|
||||||
|
world = load_world(CONTENT_ROOT)
|
||||||
|
origin = copy.deepcopy(load_origin(DESERTER))
|
||||||
|
origin["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1})
|
||||||
|
assert "item:ghost_blade" in unresolved_refs(origin, world)
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_quest_ref_resolves():
|
||||||
|
world = load_world(CONTENT_ROOT)
|
||||||
|
origin = copy.deepcopy(load_origin(DESERTER))
|
||||||
|
origin["start_quest_id"] = None
|
||||||
|
assert unresolved_refs(origin, world) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_disposition_npc_ref_is_detected():
|
||||||
|
world = load_world(CONTENT_ROOT)
|
||||||
|
origin = copy.deepcopy(load_origin(DESERTER))
|
||||||
|
origin["disposition_overrides"]["ghost_npc"] = 10
|
||||||
|
assert "npc:ghost_npc" in unresolved_refs(origin, world)
|
||||||
47
api/tests/test_endpoints.py
Normal file
47
api/tests/test_endpoints.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
VALID_LOG = json.load(open(Path(__file__).parent / "fixtures" / "canon_log_valid.json"))
|
||||||
|
|
||||||
|
ROLE_ENDPOINTS = [
|
||||||
|
"/dm/narrate", "/dm/adjudicate", "/dm/improvise",
|
||||||
|
"/npc/speak", "/party/banter",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_ok():
|
||||||
|
assert client.get("/health").json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_role_endpoint_accepts_a_valid_canon_log():
|
||||||
|
for path in ROLE_ENDPOINTS:
|
||||||
|
r = client.post(path, json={"canon_log": VALID_LOG})
|
||||||
|
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_role_endpoint_rejects_an_invalid_canon_log():
|
||||||
|
bad = json.loads(json.dumps(VALID_LOG))
|
||||||
|
bad["player"]["luck"] = 5 # §7 leak
|
||||||
|
for path in ROLE_ENDPOINTS:
|
||||||
|
r = client.post(path, json={"canon_log": bad})
|
||||||
|
assert r.status_code == 422, f"{path} accepted an invalid log"
|
||||||
|
assert "canon_log_errors" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_canon_log_is_a_422():
|
||||||
|
r = client.post("/dm/narrate", json={})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "canon_log_errors" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_body_uses_the_unified_error_shape():
|
||||||
|
# A pydantic body failure returns the SAME envelope as a schema failure,
|
||||||
|
# so the client only ever parses one 422 shape.
|
||||||
|
r = client.post("/dm/narrate", json={"canon_log": "not-a-dict"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "canon_log_errors" in r.json()["detail"]
|
||||||
54
api/tests/test_origin_schema.py
Normal file
54
api/tests/test_origin_schema.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import copy
|
||||||
|
|
||||||
|
from app.canon_log import validate_origin
|
||||||
|
|
||||||
|
VALID_ORIGIN = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "deserter",
|
||||||
|
"display_name": "The Deserter",
|
||||||
|
"description": "You walked away from a company that doesn't allow walking away.",
|
||||||
|
"start_location_id": "greywater_docks",
|
||||||
|
"situation": ["Arrived at Greywater by barge before dawn, hood up"],
|
||||||
|
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
|
||||||
|
"disposition_overrides": {"brannoc_thane": 40, "cadwyn_vell": 15},
|
||||||
|
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
|
||||||
|
"start_quest_id": "find_the_ledger",
|
||||||
|
"build_constraints": {
|
||||||
|
"allowed_classes": ["sellsword", "assassin", "priest"],
|
||||||
|
"luck_modifier": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_origin_passes():
|
||||||
|
assert validate_origin(VALID_ORIGIN) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_start_quest_is_allowed():
|
||||||
|
doc = copy.deepcopy(VALID_ORIGIN)
|
||||||
|
doc["start_quest_id"] = None
|
||||||
|
assert validate_origin(doc) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_class_is_rejected():
|
||||||
|
doc = copy.deepcopy(VALID_ORIGIN)
|
||||||
|
doc["build_constraints"]["allowed_classes"] = ["bard"]
|
||||||
|
assert validate_origin(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_field_is_rejected():
|
||||||
|
doc = copy.deepcopy(VALID_ORIGIN)
|
||||||
|
del doc["start_location_id"]
|
||||||
|
assert validate_origin(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_field_is_rejected():
|
||||||
|
doc = copy.deepcopy(VALID_ORIGIN)
|
||||||
|
doc["surprise"] = True
|
||||||
|
assert validate_origin(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disposition_override_key_must_match_id_pattern():
|
||||||
|
doc = copy.deepcopy(VALID_ORIGIN)
|
||||||
|
doc["disposition_overrides"] = {"Brannoc Thane": 40}
|
||||||
|
assert validate_origin(doc) != []
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
# /content — authored game data
|
# /content — authored game data
|
||||||
|
|
||||||
Cross-cutting authored writing. Consumed by **both** sides: the client ships fallback text and quest/story data; the api reads NPC knowledge lists to build prompts. That shared ownership is why it sits at the repo root, not inside either folder.
|
Cross-cutting authored writing, split by role in the data model (see the canon
|
||||||
|
log spec in [`/docs`](../docs)):
|
||||||
|
|
||||||
```
|
```
|
||||||
/quests Story skeletons and quest definitions (authored, not AI-generated — §17)
|
/world Static, ID-referenced game content. Identical every playthrough.
|
||||||
/npcs Per-NPC knowledge lists — the entire content an NPC can draw on (§6)
|
/locations The map — towns, dungeons, points of interest (by id)
|
||||||
/fallback Authored degraded-DM text for every AI surface (§13)
|
/npcs Per-NPC persona + knowledge lists (charter §6)
|
||||||
|
/quests Story skeletons and quest definitions (charter §17)
|
||||||
|
/items Item definitions — gear, consumables, cursed items (§7)
|
||||||
|
/origins Thin starting-state seeds. One per starting point. POC authors one.
|
||||||
|
/fallback Authored degraded-DM text for every AI surface (charter §13)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## The three layers
|
||||||
|
|
||||||
|
- **`world/`** is static content the origin and the canon log reference by stable
|
||||||
|
string **id**. Authored once; the same for every play.
|
||||||
|
- **`origins/`** are thin seeds — where the player starts, the situation, and
|
||||||
|
disposition/quest/item/build seeds. Values, not maps. Increase replayability
|
||||||
|
without multiplying authoring work.
|
||||||
|
- **`fallback/`** is not world content — it is degraded-DM prose (§13), consumed
|
||||||
|
when the API is down. It sits outside `world/` on purpose: nothing resolves an
|
||||||
|
id against it.
|
||||||
|
|
||||||
|
At new-game, code constructs the runtime **canon log** from a chosen origin +
|
||||||
|
world content + character creation. See [`/docs/canon-log.md`](../docs) (spec) —
|
||||||
|
authored via the design under [`/docs/superpowers/specs`](../docs/superpowers/specs).
|
||||||
|
|
||||||
## Authoring notes
|
## Authoring notes
|
||||||
|
|
||||||
- **NPC knowledge lists are the whole design** (§6). They are the only thing stopping the blacksmith from revealing the twist. Real authoring work — budget for it.
|
- **NPC knowledge lists are the whole design** (§6). The only thing stopping the
|
||||||
- **Fallback text is content, not error handling** (§13). Written in the DM's voice, lives beside the rest of the writing. Every AI-dependent surface needs one before it ships.
|
blacksmith from revealing the twist. Real authoring work — budget for it.
|
||||||
- Story skeletons are authored for now. AI-generated skeletons are v2, out of POC scope (§17).
|
- **Fallback text is content, not error handling** (§13). Every AI-dependent
|
||||||
|
surface needs one before it ships.
|
||||||
|
- Story skeletons are authored for now. AI-generated skeletons are v2 (§17).
|
||||||
|
|||||||
26
content/origins/README.md
Normal file
26
content/origins/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# /content/origins — starting-state seeds
|
||||||
|
|
||||||
|
Thin authored files, one per starting point. An origin is an **initial-state
|
||||||
|
overlay** on the static shared world (`/content/world`): where the player begins,
|
||||||
|
the opening situation, and seeds for dispositions, inventory, starting quest, and
|
||||||
|
character-creation constraints. Values and id references — never maps or personas.
|
||||||
|
|
||||||
|
At new-game the player picks an origin; code constructs the runtime canon log
|
||||||
|
from origin + world content + character creation. See the spec in
|
||||||
|
[`/docs/canon-log.md`](../../docs) (design under
|
||||||
|
[`/docs/superpowers/specs`](../../docs/superpowers/specs)).
|
||||||
|
|
||||||
|
## Scope (§17)
|
||||||
|
|
||||||
|
Schema supports **N** origins; the POC authors **one**. One world, one map, fixed
|
||||||
|
NPCs — origins change only the player's starting point and situation. More
|
||||||
|
replayability, flat authoring cost.
|
||||||
|
|
||||||
|
## Fields (summary)
|
||||||
|
|
||||||
|
`id` · `display_name` · `description` · `start_location_id` · `situation[]` ·
|
||||||
|
`opening_facts[]` · `disposition_overrides{}` · `inventory_grants[]` ·
|
||||||
|
`start_quest_id` · `build_constraints{}`
|
||||||
|
|
||||||
|
`humiliations` are never seeded — they are earned in play (§7/§9). Validated
|
||||||
|
against `origin.schema.json`; every id must resolve in `/content/world`.
|
||||||
25
content/origins/deserter.json
Normal file
25
content/origins/deserter.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "deserter",
|
||||||
|
"display_name": "The Deserter",
|
||||||
|
"description": "You walked away from a company that doesn't allow walking away. Greywater was just far enough. You hoped.",
|
||||||
|
"start_location_id": "greywater_docks",
|
||||||
|
"situation": [
|
||||||
|
"Arrived at Greywater by barge before dawn, hood up",
|
||||||
|
"Down to your last coin and owed a favour you can't repay"
|
||||||
|
],
|
||||||
|
"opening_facts": [
|
||||||
|
"the player deserted the Iron Kettle mercenary company",
|
||||||
|
"a bounty notice for the player circulates in the northern towns"
|
||||||
|
],
|
||||||
|
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||||
|
"inventory_grants": [
|
||||||
|
{ "item_id": "worn_shortsword", "qty": 1 },
|
||||||
|
{ "item_id": "coin", "qty": 3 }
|
||||||
|
],
|
||||||
|
"start_quest_id": "find_the_ledger",
|
||||||
|
"build_constraints": {
|
||||||
|
"allowed_classes": ["sellsword", "assassin", "priest"],
|
||||||
|
"luck_modifier": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
16
content/world/README.md
Normal file
16
content/world/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# /content/world — static, ID-referenced content
|
||||||
|
|
||||||
|
The shared world. Authored once, identical every playthrough. Everything here
|
||||||
|
carries a stable string **id** that origins and the canon log reference. Never
|
||||||
|
seed initial *state* here — that is an origin's job (`/content/origins`).
|
||||||
|
|
||||||
|
```
|
||||||
|
/locations Towns, dungeons, points of interest. id → location
|
||||||
|
/npcs Persona + knowledge lists (charter §6). id → npc
|
||||||
|
/quests Story skeletons and quest definitions (charter §17). id → quest
|
||||||
|
/items Gear, consumables, cursed items (charter §7). id → item
|
||||||
|
```
|
||||||
|
|
||||||
|
New-game construction resolves every id an origin references (start location,
|
||||||
|
start quest, granted items, seeded companions) against this content, and fails
|
||||||
|
loudly if one is missing. Keep ids stable — a rename is a breaking change.
|
||||||
9
content/world/items/README.md
Normal file
9
content/world/items/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# /content/world/items
|
||||||
|
|
||||||
|
Item definitions — gear, consumables, cursed items — each with a stable **id**.
|
||||||
|
An origin's `inventory_grants` resolves item ids here.
|
||||||
|
|
||||||
|
Cursed/blessed items move Luck (charter §7): *a cursed blade granting +4 STR and
|
||||||
|
−5 LCK is the most interesting item in this game.* The item system supports the
|
||||||
|
STR/LCK split on day one. Numeric effects live in game state; only narrative-worthy
|
||||||
|
items surface as canon-log facts.
|
||||||
1
content/world/items/coin.json
Normal file
1
content/world/items/coin.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "id": "coin", "name": "coin", "slot": "currency" }
|
||||||
1
content/world/items/worn_shortsword.json
Normal file
1
content/world/items/worn_shortsword.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
|
||||||
8
content/world/locations/README.md
Normal file
8
content/world/locations/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# /content/world/locations
|
||||||
|
|
||||||
|
The map — towns, dungeons, points of interest, each with a stable **id**. An
|
||||||
|
origin's `start_location_id` resolves here; the canon log's `location` mirrors
|
||||||
|
the current one (id + display name).
|
||||||
|
|
||||||
|
POC scope (§17): one town, one dungeon (three fights, one boss). One reusable
|
||||||
|
world; origins vary only where the player begins within it.
|
||||||
2
content/world/locations/greywater_docks.json
Normal file
2
content/world/locations/greywater_docks.json
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{ "id": "greywater_docks", "name": "the Greywater docks",
|
||||||
|
"description": "A rot-black wharf where the river meets the sea trade." }
|
||||||
3
content/world/npcs/brannoc_thane.json
Normal file
3
content/world/npcs/brannoc_thane.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{ "id": "brannoc_thane", "name": "Brannoc Thane", "role": "companion",
|
||||||
|
"persona": "Dry, warm, economical. Twenty years past his prime and at peace with it.",
|
||||||
|
"knowledge": [] }
|
||||||
3
content/world/npcs/cadwyn_vell.json
Normal file
3
content/world/npcs/cadwyn_vell.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "role": "companion",
|
||||||
|
"persona": "Florid when performing, clipped when scared. A fine musician and a finer liar.",
|
||||||
|
"knowledge": [] }
|
||||||
2
content/world/quests/find_the_ledger.json
Normal file
2
content/world/quests/find_the_ledger.json
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||||
|
"objective": "Find who took Fenn's ledger" }
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Local dev — run the proxy in Docker with live reload.
|
||||||
|
# docker compose up --build
|
||||||
|
# Build context is the repo root so docs/schemas is available to the image.
|
||||||
|
# Ollama runs on the homelab (charter §4), not here — point OLLAMA_BASE_URL at it.
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: api/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- path: ./api/.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
PORT: 8000
|
||||||
|
# Mount source + schemas so edits are live without a rebuild.
|
||||||
|
volumes:
|
||||||
|
- ./api/app:/app/app:ro
|
||||||
|
- ./docs/schemas:/app/schemas:ro
|
||||||
|
command: >
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
110
docs/canon-log.md
Normal file
110
docs/canon-log.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Canon Log — living contract
|
||||||
|
|
||||||
|
The canon log is the compact structured state code maintains and injects into
|
||||||
|
every AI call (charter §11). This is the **contract** the Godot client and the
|
||||||
|
FastAPI proxy both bind to. The schemas are the single source of truth; this doc
|
||||||
|
is the human-readable companion.
|
||||||
|
|
||||||
|
- Schemas (authoritative): [`/docs/schemas/canon-log.schema.json`](schemas/canon-log.schema.json), [`/docs/schemas/origin.schema.json`](schemas/origin.schema.json)
|
||||||
|
- Design rationale: [`superpowers/specs/2026-07-09-canon-log-schema-design.md`](superpowers/specs/2026-07-09-canon-log-schema-design.md)
|
||||||
|
|
||||||
|
## The three layers
|
||||||
|
|
||||||
|
| Layer | Owner | Where | Mutable? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| World content | authored | `/content/world` (id-referenced) | no — static per playthrough |
|
||||||
|
| Origin seed | authored | `/content/origins` | no — an initial-state overlay |
|
||||||
|
| **Canon log** | **code (client)** | runtime + save | **yes — evolves every turn** |
|
||||||
|
|
||||||
|
The client **constructs** the canon log at new-game (origin + world + character
|
||||||
|
creation) and **maintains** it turn to turn. The proxy never mutates it — it
|
||||||
|
**validates** every posted log and rejects an invalid one before any prompt work.
|
||||||
|
|
||||||
|
## Canon log shape
|
||||||
|
|
||||||
|
Full field rules live in `canon-log.schema.json`. Summary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" },
|
||||||
|
"location": { "id": "…", "name": "…" },
|
||||||
|
"party": [ { "id": "…", "name": "…", "disposition": -100..100 } ],
|
||||||
|
"recent_events": ["… (≤5, rolling)"],
|
||||||
|
"established_facts": ["… (durable, uncapped)"],
|
||||||
|
"active_quests": [ { "id": "…", "name": "…", "status": "active|complete|failed", "objective": "…" } ],
|
||||||
|
"humiliations": [ { "id": "…", "text": "…", "weight": 1..10, "turn": 0.. } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The §7 boundary (enforced structurally).** The schema sets
|
||||||
|
`additionalProperties: false` on the root and on `player`, and `player` admits
|
||||||
|
only `name`, `class_id`, `luck_descriptor`. **Numeric Luck, stats, HP/MP, and
|
||||||
|
inventory cannot be represented in the canon log** — the AI never sees a number
|
||||||
|
it could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
|
||||||
|
you") crosses the boundary.
|
||||||
|
|
||||||
|
All string ids match `^[a-z0-9_]+$`.
|
||||||
|
|
||||||
|
## Origin seed shape
|
||||||
|
|
||||||
|
Full rules in `origin.schema.json`. An origin seeds the initial log:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "…", "display_name": "…", "description": "…",
|
||||||
|
"start_location_id": "…",
|
||||||
|
"situation": ["…"], "opening_facts": ["…"],
|
||||||
|
"disposition_overrides": { "<npc_id>": -100..100 },
|
||||||
|
"inventory_grants": [ { "item_id": "…", "qty": 1.. } ],
|
||||||
|
"start_quest_id": "…|null",
|
||||||
|
"build_constraints": { "allowed_classes": ["sellsword",…], "luck_modifier": 0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every id an origin references must resolve in world content — the proxy exposes
|
||||||
|
`content.unresolved_refs(origin, world)` for that check; `humiliations` are never
|
||||||
|
seeded (earned in play).
|
||||||
|
|
||||||
|
## API contract
|
||||||
|
|
||||||
|
The client posts game state to a role endpoint; the body is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "canon_log": { …a canon log… } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints (charter §4): `POST /dm/narrate`, `/dm/adjudicate`, `/dm/improvise`,
|
||||||
|
`/npc/speak`, `/party/banter`. Each validates `canon_log` and, on success,
|
||||||
|
returns the role's result. (Prompt routing is stubbed for now — a valid log
|
||||||
|
returns `{"detail": "not implemented"}`.)
|
||||||
|
|
||||||
|
### Error shape — ONE envelope for every 422
|
||||||
|
|
||||||
|
Whether the log fails the JSON Schema **or** the request body is malformed at the
|
||||||
|
pydantic layer (missing/mistyped `canon_log`), the proxy returns the **same**
|
||||||
|
shape, so the client parses one thing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
HTTP 422
|
||||||
|
{ "detail": { "canon_log_errors": ["<message>", "<message>", …] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Client rule: on 422, read `detail.canon_log_errors` (a list of strings). It is
|
||||||
|
never the raw pydantic error list. A degraded-DM fallback (charter §13) should
|
||||||
|
fire on any non-200 rather than surfacing these strings to the player — they are
|
||||||
|
for logs and development.
|
||||||
|
|
||||||
|
## Per-role injection
|
||||||
|
|
||||||
|
The canon log is the shared base of every call; roles add their own extras (the
|
||||||
|
log never grows per-role fields):
|
||||||
|
|
||||||
|
| Role | Log + … |
|
||||||
|
|---|---|
|
||||||
|
| Narrator | log only |
|
||||||
|
| NPC | log + persona, `knowledge[]`, `available_moves[]` (§6) |
|
||||||
|
| Improviser | log + the whitelisted event-change set (§7) |
|
||||||
|
| Banter | log, with `humiliations` as primary source (§9) |
|
||||||
|
| Adjudicator | log + the current legal action set |
|
||||||
83
docs/schemas/canon-log.schema.json
Normal file
83
docs/schemas/canon-log.schema.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://coc-rpg/schemas/canon-log.schema.json",
|
||||||
|
"title": "Canon Log",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version", "player", "location", "party",
|
||||||
|
"recent_events", "established_facts", "active_quests", "humiliations"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": { "type": "integer", "const": 1 },
|
||||||
|
"player": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "class_id", "luck_descriptor"],
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "minLength": 1 },
|
||||||
|
"class_id": { "enum": ["sellsword", "assassin", "priest"] },
|
||||||
|
"luck_descriptor": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"name": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"party": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "name", "disposition"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"name": { "type": "string", "minLength": 1 },
|
||||||
|
"disposition": { "type": "integer", "minimum": -100, "maximum": 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recent_events": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 5,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"established_facts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"active_quests": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "name", "status", "objective"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"name": { "type": "string", "minLength": 1 },
|
||||||
|
"status": { "enum": ["active", "complete", "failed"] },
|
||||||
|
"objective": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"humiliations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "text", "weight", "turn"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"text": { "type": "string", "minLength": 1 },
|
||||||
|
"weight": { "type": "integer", "minimum": 1, "maximum": 10 },
|
||||||
|
"turn": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
docs/schemas/origin.schema.json
Normal file
53
docs/schemas/origin.schema.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://coc-rpg/schemas/origin.schema.json",
|
||||||
|
"title": "Origin Seed",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version", "id", "display_name", "description",
|
||||||
|
"start_location_id", "situation", "opening_facts",
|
||||||
|
"disposition_overrides", "inventory_grants", "start_quest_id",
|
||||||
|
"build_constraints"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": { "type": "integer", "const": 1 },
|
||||||
|
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"display_name": { "type": "string", "minLength": 1 },
|
||||||
|
"description": { "type": "string", "minLength": 1 },
|
||||||
|
"start_location_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"situation": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||||
|
"opening_facts": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||||
|
"disposition_overrides": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"additionalProperties": { "type": "integer", "minimum": -100, "maximum": 100 }
|
||||||
|
},
|
||||||
|
"inventory_grants": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["item_id", "qty"],
|
||||||
|
"properties": {
|
||||||
|
"item_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"qty": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"start_quest_id": { "type": ["string", "null"], "pattern": "^[a-z0-9_]+$" },
|
||||||
|
"build_constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["allowed_classes", "luck_modifier"],
|
||||||
|
"properties": {
|
||||||
|
"allowed_classes": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "enum": ["sellsword", "assassin", "priest"] }
|
||||||
|
},
|
||||||
|
"luck_modifier": { "type": "integer" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
263
docs/superpowers/specs/2026-07-09-canon-log-schema-design.md
Normal file
263
docs/superpowers/specs/2026-07-09-canon-log-schema-design.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Canon Log Schema & Origin Seeds — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-09
|
||||||
|
**Status:** approved (design); implementation plan to follow
|
||||||
|
**Charter refs:** §2 (code owns state), §7 (Luck), §9 (companions/humiliations), §11 (canon log), §14 (cost/latency), §16 (layout)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The canon log (§11) is the compact structured fact list code maintains and injects
|
||||||
|
into every AI call. It is the spine that stops the AI contradicting itself, and
|
||||||
|
§11 warns it is "nearly impossible to retrofit." It must be designed before any
|
||||||
|
call site depends on it.
|
||||||
|
|
||||||
|
Concurrently, the player should be able to select one of several **starting
|
||||||
|
origins**, each with its own starting location and situation — for replayability
|
||||||
|
without multiplying authoring work.
|
||||||
|
|
||||||
|
## Reframe: origin = initial-state seed over a static world
|
||||||
|
|
||||||
|
Rather than "multiple maps," an origin is an **initial-state overlay** on a single
|
||||||
|
shared, static world. The world (one map, its towns, their fixed NPCs and
|
||||||
|
personalities/knowledge) is authored once. An origin only seeds *where the player
|
||||||
|
starts and the situation they are in*. This keeps charter §17 scope intact (one
|
||||||
|
world authored) while raising replayability (N thin origin seeds).
|
||||||
|
|
||||||
|
Confirmed scope: **schema supports N origins; the POC authors one.** An origin
|
||||||
|
seeds: starting location, opening situation, initial dispositions, initial
|
||||||
|
inventory/items, initial quest/objective, and player build constraints.
|
||||||
|
|
||||||
|
## Architecture — three layers, one direction of flow
|
||||||
|
|
||||||
|
```
|
||||||
|
WORLD CONTENT (static) /content/world map · npcs(+knowledge) · quests · items
|
||||||
|
│ referenced by stable string id
|
||||||
|
ORIGIN SEED (thin, N) /content/origins location + situation + dispo/quest/item/build seeds
|
||||||
|
│ new-game construct (origin + world + character creation)
|
||||||
|
CANON LOG (runtime) code-owned, saved the §11 structure, injected into every AI call
|
||||||
|
```
|
||||||
|
|
||||||
|
Content and seed are **immutable inputs**; the canon log is the **single mutable
|
||||||
|
product**. Nothing writes back up. This is §2 (code owns state) made concrete.
|
||||||
|
|
||||||
|
### The in/out boundary
|
||||||
|
|
||||||
|
What the canon log carries vs. what it must never carry:
|
||||||
|
|
||||||
|
| In the log (narrative context → AI) | Not in the log |
|
||||||
|
|---|---|
|
||||||
|
| Player name, class, **luck _descriptor_** | Numeric Luck value — §7: AI must never be able to calculate it |
|
||||||
|
| Current location (id + name) | Numeric stats, HP/MP, combat state — combat engine owns these |
|
||||||
|
| Party dispositions | Full inventory contents — game state; only narrative items become facts |
|
||||||
|
| Rolling recent events (≤5) | NPC knowledge lists — §6, injected only into that NPC's call |
|
||||||
|
| Established facts (harvested `[FACT]`) | Available moves — §6, injected only into the NPC call |
|
||||||
|
| Active quests | |
|
||||||
|
| Humiliations (Banter, §9) | |
|
||||||
|
|
||||||
|
Rationale: §7 requires the player to *feel* cursed but never *calculate* it, so the
|
||||||
|
numeric Luck physically cannot enter the AI payload — only `luck_descriptor` does.
|
||||||
|
Keeping stats/HP/inventory out keeps the payload small (§14) and separates combat
|
||||||
|
state from narrative context.
|
||||||
|
|
||||||
|
## Canon log schema (runtime state)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"player": {
|
||||||
|
"name": "Aldric",
|
||||||
|
"class_id": "sellsword",
|
||||||
|
"luck_descriptor": "Fortune spits on you"
|
||||||
|
},
|
||||||
|
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
|
||||||
|
"party": [
|
||||||
|
{ "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 },
|
||||||
|
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "disposition": 15 }
|
||||||
|
],
|
||||||
|
"recent_events": [
|
||||||
|
"Arrived at Greywater by barge before dawn",
|
||||||
|
"Brannoc recognised the harbourmaster and went quiet"
|
||||||
|
],
|
||||||
|
"established_facts": [
|
||||||
|
"the eastern bridge out of Greywater is washed out",
|
||||||
|
"the harbourmaster is named Oda Fenn"
|
||||||
|
],
|
||||||
|
"active_quests": [
|
||||||
|
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||||
|
"status": "active", "objective": "Find who took Fenn's ledger" }
|
||||||
|
],
|
||||||
|
"humiliations": [
|
||||||
|
{ "id": "h_0001", "text": "vomited on a shrine step in front of a priest",
|
||||||
|
"weight": 7, "turn": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `schema_version` | int | Migration handle; saved logs upgraded on load. |
|
||||||
|
| `player.name` | string | From character creation. |
|
||||||
|
| `player.class_id` | enum | `sellsword` \| `assassin` \| `priest` (POC, §8). Id, not display name. |
|
||||||
|
| `player.luck_descriptor` | string | **Only** Luck representation in the log (§7). Recomputed when numeric Luck drifts. Never the number. |
|
||||||
|
| `location` | `{id, name}` | id → world content; name → prose. |
|
||||||
|
| `party[]` | `{id, name, disposition}` | Companions. `disposition` int −100..100 (§9). |
|
||||||
|
| `recent_events[]` | string[] | Rolling window, **cap 5** (§11). Code writes each terse line, drops oldest. |
|
||||||
|
| `established_facts[]` | string[] | Durable. Harvested from `[FACT: …]` (§11) + origin seed. Deduped. Uncapped. |
|
||||||
|
| `active_quests[]` | `{id, name, status, objective}` | `status`: `active` \| `complete` \| `failed`. Definitions in world content. |
|
||||||
|
| `humiliations[]` | `{id, text, weight, turn}` | Banter memory (§9). **Append-only, stacks.** `weight` 1–10; `turn` drives decay of reference frequency. |
|
||||||
|
|
||||||
|
Decisions:
|
||||||
|
- Every entity carries both `id` (code keys/validates) and `name` (prompt reads) —
|
||||||
|
denormalized on purpose so the language model gets natural nouns and code gets
|
||||||
|
stable keys.
|
||||||
|
- `recent_events` cap = 5 (§11 says "3–5"; ceiling taken). Hard rolling window.
|
||||||
|
- `established_facts` uncapped — dropping a fact reintroduces the exact
|
||||||
|
contradiction §11 exists to prevent. If size bites (§14), fix is summarisation,
|
||||||
|
not truncation.
|
||||||
|
- No global `npc_dispositions`; an NPC's live disposition rides only in its
|
||||||
|
`/npc/speak` call (§6). Add later if the Narrator needs town-wide stances.
|
||||||
|
|
||||||
|
## Origin seed schema
|
||||||
|
|
||||||
|
Lives in `/content/origins/<id>.json`. All heavy nouns are id references into
|
||||||
|
world content; the origin owns only starting values.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "deserter",
|
||||||
|
"display_name": "The Deserter",
|
||||||
|
"description": "You walked away from a company that doesn't allow walking away.",
|
||||||
|
"start_location_id": "greywater_docks",
|
||||||
|
"situation": [
|
||||||
|
"Arrived at Greywater by barge before dawn, hood up",
|
||||||
|
"Down to your last coin and owed a favour you can't repay"
|
||||||
|
],
|
||||||
|
"opening_facts": [
|
||||||
|
"the player deserted the Iron Kettle mercenary company",
|
||||||
|
"a bounty notice for the player circulates in the northern towns"
|
||||||
|
],
|
||||||
|
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||||
|
"inventory_grants": [
|
||||||
|
{ "item_id": "worn_shortsword", "qty": 1 },
|
||||||
|
{ "item_id": "coin", "qty": 3 }
|
||||||
|
],
|
||||||
|
"start_quest_id": "find_the_ledger",
|
||||||
|
"build_constraints": {
|
||||||
|
"allowed_classes": ["sellsword", "assassin", "priest"],
|
||||||
|
"luck_modifier": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Feeds | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` / `display_name` / `description` | Origin-select screen | `description` is authored flavour shown at pick time. |
|
||||||
|
| `start_location_id` | `location` | Must resolve to a world location, else new-game fails validation. |
|
||||||
|
| `situation[]` | `recent_events` | Opening framing — why you're here, now. |
|
||||||
|
| `opening_facts[]` | `established_facts` | What is already canon in this origin. |
|
||||||
|
| `disposition_overrides{}` | `party[].disposition` (companions) / game-state NPC store (world NPCs) | Absolute starting values; omitted → world default. Companion ids land in the log; world-NPC ids land in game state and surface only at that NPC's `/npc/speak` call (§6) — the log has no global NPC-disposition field. |
|
||||||
|
| `inventory_grants[]` | game-state inventory | Not the log — inventory is state (§2). Narrative items may also seed a fact. |
|
||||||
|
| `start_quest_id` | `active_quests` | Nullable; resolved against world quest defs. `null` → no active quest. |
|
||||||
|
| `build_constraints{}` | character creation | `allowed_classes` gates the picker; `luck_modifier` feeds Luck gen. Acts *before* the log exists. |
|
||||||
|
|
||||||
|
`humiliations` is never seeded — earned in play only (§7/§9).
|
||||||
|
|
||||||
|
## New-game construction
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Player picks origin
|
||||||
|
load /content/origins/<id>.json → validate every id resolves in world content
|
||||||
|
2. Apply build_constraints → character creation
|
||||||
|
class picker limited to allowed_classes
|
||||||
|
Luck generated per §7 (5-roll average) + luck_modifier
|
||||||
|
3. Player creates character (name, class, rolled stats + Luck)
|
||||||
|
4. Construct initial canon log:
|
||||||
|
player ← creation (name, class_id) + luck→descriptor
|
||||||
|
location ← start_location_id (+ name from world)
|
||||||
|
party ← world default roster, companion dispositions ← disposition_overrides
|
||||||
|
(world-NPC dispositions from disposition_overrides → game-state NPC store, not the log)
|
||||||
|
recent_events ← situation[]
|
||||||
|
established_facts ← opening_facts[] (+ world always-true facts, if any)
|
||||||
|
active_quests ← resolve(start_quest_id) (or [])
|
||||||
|
humiliations ← [] (always empty)
|
||||||
|
schema_version ← 1
|
||||||
|
inventory_grants → applied to game-state inventory (NOT the log)
|
||||||
|
5. Log handed to the game loop; first Narrator call fires with it.
|
||||||
|
```
|
||||||
|
|
||||||
|
Construction reads all three layers and writes only the log. `build_constraints`
|
||||||
|
firing at step 2 (before the log exists at step 4) is why an origin is a distinct
|
||||||
|
schema, not a partial canon log: Luck is rolled during creation with the origin's
|
||||||
|
modifier, and only its *descriptor* reaches the log. Validation is front-loaded at
|
||||||
|
step 1 — a broken origin fails at new-game, loudly, not three scenes later.
|
||||||
|
|
||||||
|
## Maintenance — turn-to-turn evolution
|
||||||
|
|
||||||
|
Only code mutates the log. AI prose is never the source of truth (§2/§11); a
|
||||||
|
`[FACT]` tag is a *request* to record, and code records the canonical string that
|
||||||
|
later calls read.
|
||||||
|
|
||||||
|
| Trigger | Mutation | Rule |
|
||||||
|
|---|---|---|
|
||||||
|
| Any role emits `[FACT: …]` | Append to `established_facts` (dedup) | §11 harvest; only way facts enter. |
|
||||||
|
| A narrative beat resolves | Append terse line to `recent_events`, drop oldest past 5 | Code writes the line, not the AI. |
|
||||||
|
| Validated `[ADJUST_DISPOSITION: ±n]` (§6) / approval change (§9) | Update `party[].disposition`, clamp −100..100 | Only after the move passes validation; invalid dropped. |
|
||||||
|
| Event with embarrassment weight ≥ threshold | Append `{id, text, weight, turn}` to `humiliations` | Append-only, stacks forever (§9). |
|
||||||
|
| Numeric Luck drifts (§7) | Recompute `player.luck_descriptor` | Number stays in game state; only descriptor mirrored. |
|
||||||
|
| Quest state changes | Update `active_quests[].status` / objective | Definitions in world content; log tracks live status. |
|
||||||
|
| Player moves | Update `location` | id + name from world content. |
|
||||||
|
|
||||||
|
### Per-role injection
|
||||||
|
|
||||||
|
The canon log is the shared base of every call; roles add role-specific extras.
|
||||||
|
The log never grows per-role fields.
|
||||||
|
|
||||||
|
| Role | Log + … |
|
||||||
|
|---|---|
|
||||||
|
| Narrator | log only |
|
||||||
|
| NPC | log + this NPC's persona, `knowledge[]`, `available_moves[]` (§6) |
|
||||||
|
| Improviser | log + the whitelisted event-change set (§7 validator) |
|
||||||
|
| Banter | log, with `humiliations` as primary source (§9) |
|
||||||
|
| Adjudicator | log + the current legal action set |
|
||||||
|
|
||||||
|
## Storage & format
|
||||||
|
|
||||||
|
```
|
||||||
|
/content/
|
||||||
|
origins/ origin seeds (authored) + (later) validated against origin.schema.json
|
||||||
|
world/ static, id-referenced content
|
||||||
|
locations/ npcs/ quests/ items/
|
||||||
|
fallback/ degraded-DM text (§13) — NOT world content, no ids resolved against it
|
||||||
|
/docs/
|
||||||
|
canon-log.md living contract: this schema, field tables, example
|
||||||
|
schemas/
|
||||||
|
canon-log.schema.json JSON Schema — validated on both client and api
|
||||||
|
origin.schema.json JSON Schema — origin seed
|
||||||
|
save file → serialized canon log (JSON) + game state (Luck number, inventory, stats)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **JSON everywhere.** The log crosses the HTTP boundary to `/api` and seeds
|
||||||
|
prompts. The client (GDScript) models it as typed objects and serialises; `/api`
|
||||||
|
(Python) validates against the same `canon-log.schema.json`. One schema, two
|
||||||
|
languages, no drift.
|
||||||
|
- **The log is part of the save.** Load a save → same log → same injected context.
|
||||||
|
Pairs with §10's per-encounter seeding: reproducible AI context, not just
|
||||||
|
reproducible combat. §11 rejected "last N messages"; persisting the *structured
|
||||||
|
log* keeps the anti-amnesia guarantee across save/quit.
|
||||||
|
- **Schemas live in `/docs/schemas/`** — cross-cutting contracts both sides consume
|
||||||
|
(§16 doc split). Prose spec beside them in `/docs/canon-log.md`.
|
||||||
|
|
||||||
|
## Consequences / follow-ups
|
||||||
|
|
||||||
|
- `fallback/` stays outside `world/` (category: authored prose, not id-referenced).
|
||||||
|
- Implementation will create the two JSON Schemas, the construction routine, the
|
||||||
|
maintenance hooks, and one authored POC origin + minimal world content to
|
||||||
|
exercise new-game end-to-end.
|
||||||
|
- Out of scope here: the AI role prompts themselves, combat state, and the save
|
||||||
|
system's non-log portions. This spec defines the data contract they all consume.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None blocking. Thresholds (humiliation `weight` cutoff, Banter decay curve) are
|
||||||
|
tuning values, deferred to implementation.
|
||||||
Reference in New Issue
Block a user