From 4aa65c9d4c189270ba50a729deba5d80e1e8a173 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:37:29 -0500 Subject: [PATCH] feat(content): POC deserter origin + world fixtures with id-resolution check --- api/app/content.py | 50 ++++++++++++++++++++ api/tests/test_content_resolution.py | 40 ++++++++++++++++ content/origins/deserter.json | 25 ++++++++++ content/world/items/coin.json | 1 + content/world/items/worn_shortsword.json | 1 + content/world/locations/greywater_docks.json | 2 + content/world/npcs/brannoc_thane.json | 3 ++ content/world/npcs/cadwyn_vell.json | 3 ++ content/world/quests/find_the_ledger.json | 2 + 9 files changed, 127 insertions(+) create mode 100644 api/app/content.py create mode 100644 api/tests/test_content_resolution.py create mode 100644 content/origins/deserter.json create mode 100644 content/world/items/coin.json create mode 100644 content/world/items/worn_shortsword.json create mode 100644 content/world/locations/greywater_docks.json create mode 100644 content/world/npcs/brannoc_thane.json create mode 100644 content/world/npcs/cadwyn_vell.json create mode 100644 content/world/quests/find_the_ledger.json diff --git a/api/app/content.py b/api/app/content.py new file mode 100644 index 0000000..f95efd0 --- /dev/null +++ b/api/app/content.py @@ -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 diff --git a/api/tests/test_content_resolution.py b/api/tests/test_content_resolution.py new file mode 100644 index 0000000..133499c --- /dev/null +++ b/api/tests/test_content_resolution.py @@ -0,0 +1,40 @@ +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) == [] diff --git a/content/origins/deserter.json b/content/origins/deserter.json new file mode 100644 index 0000000..d7e0b47 --- /dev/null +++ b/content/origins/deserter.json @@ -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 + } +} diff --git a/content/world/items/coin.json b/content/world/items/coin.json new file mode 100644 index 0000000..eeb0ea3 --- /dev/null +++ b/content/world/items/coin.json @@ -0,0 +1 @@ +{ "id": "coin", "name": "coin", "slot": "currency" } diff --git a/content/world/items/worn_shortsword.json b/content/world/items/worn_shortsword.json new file mode 100644 index 0000000..6642e3a --- /dev/null +++ b/content/world/items/worn_shortsword.json @@ -0,0 +1 @@ +{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" } diff --git a/content/world/locations/greywater_docks.json b/content/world/locations/greywater_docks.json new file mode 100644 index 0000000..85c58ec --- /dev/null +++ b/content/world/locations/greywater_docks.json @@ -0,0 +1,2 @@ +{ "id": "greywater_docks", "name": "the Greywater docks", + "description": "A rot-black wharf where the river meets the sea trade." } diff --git a/content/world/npcs/brannoc_thane.json b/content/world/npcs/brannoc_thane.json new file mode 100644 index 0000000..2682e74 --- /dev/null +++ b/content/world/npcs/brannoc_thane.json @@ -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": [] } diff --git a/content/world/npcs/cadwyn_vell.json b/content/world/npcs/cadwyn_vell.json new file mode 100644 index 0000000..81583ec --- /dev/null +++ b/content/world/npcs/cadwyn_vell.json @@ -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": [] } diff --git a/content/world/quests/find_the_ledger.json b/content/world/quests/find_the_ledger.json new file mode 100644 index 0000000..d540c6b --- /dev/null +++ b/content/world/quests/find_the_ledger.json @@ -0,0 +1,2 @@ +{ "id": "find_the_ledger", "name": "The Missing Ledger", + "objective": "Find who took Fenn's ledger" }