Compare commits
8 Commits
38c659cbfe
...
0e9d1b2a43
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e9d1b2a43 | |||
| e39d1a446e | |||
| 4ab0e564ef | |||
| 6d5510773a | |||
| 4aa65c9d4c | |||
| e80a4071f6 | |||
| 843ab11a5c | |||
| 710745e548 |
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.*
|
||||
@@ -1,16 +0,0 @@
|
||||
# Keep the build context small and secrets out of the image.
|
||||
.env
|
||||
.env.*
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
docs/
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -1,8 +1,7 @@
|
||||
# /api — FastAPI proxy (charter §4). Docker-based for parity across
|
||||
# dev / homelab VPS / fly.io.
|
||||
# /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
|
||||
|
||||
# Faster, quieter Python in containers.
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
@@ -10,11 +9,12 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first so the layer caches when only app code changes.
|
||||
COPY requirements.txt .
|
||||
COPY api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# App code.
|
||||
COPY app ./app
|
||||
# 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
|
||||
|
||||
49
api/app/canon_log.py
Normal file
49
api/app/canon_log.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""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)
|
||||
return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: list(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
|
||||
@@ -1,15 +1,31 @@
|
||||
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
|
||||
|
||||
Skeleton only: a health check plus the five role endpoints as stubs so the
|
||||
container runs and the client has something to point at. Real prompt routing,
|
||||
model selection, logging, and auth land later — all server-side (§4, §5).
|
||||
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 FastAPI
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .canon_log import validate_canon_log
|
||||
|
||||
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
||||
|
||||
|
||||
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."""
|
||||
@@ -18,30 +34,30 @@ def health() -> dict:
|
||||
|
||||
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
||||
# The client knows these paths and nothing about which model or prompt serves
|
||||
# them. Stubs for now; each will load its prompt from api/prompts/ and route to
|
||||
# the configured model.
|
||||
# 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() -> dict:
|
||||
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
|
||||
@app.post("/dm/adjudicate")
|
||||
def adjudicate() -> dict:
|
||||
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
|
||||
@app.post("/dm/improvise")
|
||||
def improvise() -> dict:
|
||||
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
|
||||
@app.post("/npc/speak")
|
||||
def npc_speak() -> dict:
|
||||
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
|
||||
@app.post("/party/banter")
|
||||
def banter() -> dict:
|
||||
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
3
api/pytest.ini
Normal file
3
api/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
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
|
||||
@@ -3,3 +3,4 @@ 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)
|
||||
38
api/tests/test_endpoints.py
Normal file
38
api/tests/test_endpoints.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
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) != []
|
||||
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
|
||||
}
|
||||
}
|
||||
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" }
|
||||
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" }
|
||||
@@ -1,17 +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: ./api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: api/Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ./api/.env
|
||||
- path: ./api/.env
|
||||
required: false
|
||||
environment:
|
||||
PORT: 8000
|
||||
# Mount source + reload so edits are live without a rebuild.
|
||||
# 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
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user