From 843ab11a5c7cee8aaeb411582df6b047b8f84c96 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:31:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20canon=20log=20JSON=20Schema=20+=20?= =?UTF-8?q?validator,=20enforce=20=C2=A77=20luck=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- api/tests/fixtures/canon_log_valid.json | 29 +++++++++ api/tests/test_canon_log_schema.py | 58 +++++++++++++++++ docs/schemas/canon-log.schema.json | 83 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 api/tests/fixtures/canon_log_valid.json create mode 100644 api/tests/test_canon_log_schema.py create mode 100644 docs/schemas/canon-log.schema.json diff --git a/api/tests/fixtures/canon_log_valid.json b/api/tests/fixtures/canon_log_valid.json new file mode 100644 index 0000000..b0587c7 --- /dev/null +++ b/api/tests/fixtures/canon_log_valid.json @@ -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 } + ] +} diff --git a/api/tests/test_canon_log_schema.py b/api/tests/test_canon_log_schema.py new file mode 100644 index 0000000..326d289 --- /dev/null +++ b/api/tests/test_canon_log_schema.py @@ -0,0 +1,58 @@ +import copy +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) == [] diff --git a/docs/schemas/canon-log.schema.json b/docs/schemas/canon-log.schema.json new file mode 100644 index 0000000..795a2e9 --- /dev/null +++ b/docs/schemas/canon-log.schema.json @@ -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 }, + "text": { "type": "string", "minLength": 1 }, + "weight": { "type": "integer", "minimum": 1, "maximum": 10 }, + "turn": { "type": "integer", "minimum": 0 } + } + } + } + } +}