feat(content): POC deserter origin + world fixtures with id-resolution check
This commit is contained in:
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
|
||||
40
api/tests/test_content_resolution.py
Normal file
40
api/tests/test_content_resolution.py
Normal file
@@ -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) == []
|
||||
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" }
|
||||
Reference in New Issue
Block a user