From 710745e54872ee5a862ee3604cd686559d5356c4 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:26:39 -0500 Subject: [PATCH] feat(api): origin seed JSON Schema + validator Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/canon_log.py | 49 +++++++++++++++++++++++++++++++ api/pytest.ini | 3 ++ api/requirements-dev.txt | 2 ++ api/requirements.txt | 1 + api/tests/__init__.py | 0 api/tests/test_origin_schema.py | 48 ++++++++++++++++++++++++++++++ docs/schemas/origin.schema.json | 52 +++++++++++++++++++++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 api/app/canon_log.py create mode 100644 api/pytest.ini create mode 100644 api/requirements-dev.txt create mode 100644 api/tests/__init__.py create mode 100644 api/tests/test_origin_schema.py create mode 100644 docs/schemas/origin.schema.json diff --git a/api/app/canon_log.py b/api/app/canon_log.py new file mode 100644 index 0000000..bf7e30c --- /dev/null +++ b/api/app/canon_log.py @@ -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") diff --git a/api/pytest.ini b/api/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/api/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt new file mode 100644 index 0000000..f68e0b0 --- /dev/null +++ b/api/requirements-dev.txt @@ -0,0 +1,2 @@ +# Dev/test deps. Installed on top of requirements.txt. +pytest==8.3.2 diff --git a/api/requirements.txt b/api/requirements.txt index 30ba429..45f128a 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -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 diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/test_origin_schema.py b/api/tests/test_origin_schema.py new file mode 100644 index 0000000..bdfe8ca --- /dev/null +++ b/api/tests/test_origin_schema.py @@ -0,0 +1,48 @@ +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) != [] diff --git a/docs/schemas/origin.schema.json b/docs/schemas/origin.schema.json new file mode 100644 index 0000000..c4f7734 --- /dev/null +++ b/docs/schemas/origin.schema.json @@ -0,0 +1,52 @@ +{ + "$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", + "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" } + } + } + } +}