feat(api): canon log JSON Schema + validator, enforce §7 luck boundary

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:31:10 -05:00
parent 710745e548
commit 843ab11a5c
3 changed files with 170 additions and 0 deletions

29
api/tests/fixtures/canon_log_valid.json vendored Normal file
View 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 }
]
}

View File

@@ -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) == []

View 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 },
"text": { "type": "string", "minLength": 1 },
"weight": { "type": "integer", "minimum": 1, "maximum": 10 },
"turn": { "type": "integer", "minimum": 0 }
}
}
}
}
}