From bd293c17018d7e348495bafdca02348cf1c34775 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:01:45 -0500 Subject: [PATCH 01/12] feat(content-build): scaffold package + Markdown yaml-block parser --- tools/content_build/__init__.py | 0 tools/content_build/errors.py | 12 +++++ tools/content_build/parse.py | 40 ++++++++++++++++ tools/content_build/tests/__init__.py | 0 tools/content_build/tests/test_parse.py | 61 +++++++++++++++++++++++++ tools/requirements-dev.txt | 3 ++ 6 files changed, 116 insertions(+) create mode 100644 tools/content_build/__init__.py create mode 100644 tools/content_build/errors.py create mode 100644 tools/content_build/parse.py create mode 100644 tools/content_build/tests/__init__.py create mode 100644 tools/content_build/tests/test_parse.py create mode 100644 tools/requirements-dev.txt diff --git a/tools/content_build/__init__.py b/tools/content_build/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/content_build/errors.py b/tools/content_build/errors.py new file mode 100644 index 0000000..b6c7ff5 --- /dev/null +++ b/tools/content_build/errors.py @@ -0,0 +1,12 @@ +"""The single failure type. Every validation/parse problem raises BuildError so +the CLI can print a uniform 'BUILD FAILED: [] ()'.""" + + +class BuildError(Exception): + def __init__(self, message: str, source: str = "", entry_id: str = ""): + self.message = message + self.source = source + self.entry_id = entry_id + loc = f" [{entry_id}]" if entry_id else "" + src = f" ({source})" if source else "" + super().__init__(f"{message}{loc}{src}") diff --git a/tools/content_build/parse.py b/tools/content_build/parse.py new file mode 100644 index 0000000..8e850e6 --- /dev/null +++ b/tools/content_build/parse.py @@ -0,0 +1,40 @@ +"""Markdown bible -> raw yaml-block entries. Knows only fences + YAML; nothing +about the schema's meaning. A block is opened by a line that is exactly ```yaml +(after strip) and closed by a line that is exactly ```.""" + +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from .errors import BuildError + + +@dataclass +class RawEntry: + data: dict + source: str # "duncarrow.md:53" + + +def parse_bible(path: Path) -> list[RawEntry]: + lines = path.read_text().splitlines() + entries: list[RawEntry] = [] + i, n = 0, len(lines) + while i < n: + if lines[i].strip() == "```yaml": + fence_line = i + 1 # 1-based line of the opening fence + i += 1 + block: list[str] = [] + while i < n and lines[i].strip() != "```": + block.append(lines[i]) + i += 1 + if i >= n: + raise BuildError("unterminated ```yaml block", + source=f"{path.name}:{fence_line}") + data = yaml.safe_load("\n".join(block)) + if not isinstance(data, dict): + raise BuildError("yaml block is not a mapping", + source=f"{path.name}:{fence_line}") + entries.append(RawEntry(data=data, source=f"{path.name}:{fence_line}")) + i += 1 + return entries diff --git a/tools/content_build/tests/__init__.py b/tools/content_build/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/content_build/tests/test_parse.py b/tools/content_build/tests/test_parse.py new file mode 100644 index 0000000..f90ff32 --- /dev/null +++ b/tools/content_build/tests/test_parse.py @@ -0,0 +1,61 @@ +from pathlib import Path + +import pytest + +from content_build.errors import BuildError +from content_build.parse import parse_bible + +BIBLE = """# A bible + +Some prose that must be ignored. + +```yaml +id: town.a +type: town +status: canon +secrecy: 0 +related: [] +body: > + Town A. +``` + +More prose. + +```yaml +id: rumor.x +type: rumor +status: canon +secrecy: 1 +related: [town.a] +body: > + A rumor. +``` +""" + + +def _write(tmp_path: Path, text: str) -> Path: + p = tmp_path / "bible.md" + p.write_text(text) + return p + + +def test_extracts_yaml_blocks_and_ignores_prose(tmp_path): + entries = parse_bible(_write(tmp_path, BIBLE)) + assert [e.data["id"] for e in entries] == ["town.a", "rumor.x"] + + +def test_source_carries_filename_and_line(tmp_path): + entries = parse_bible(_write(tmp_path, BIBLE)) + assert entries[0].source.startswith("bible.md:") + + +def test_unterminated_block_raises(tmp_path): + bad = "```yaml\nid: town.a\n" # no closing fence + with pytest.raises(BuildError): + parse_bible(_write(tmp_path, bad)) + + +def test_non_mapping_block_raises(tmp_path): + bad = "```yaml\n- just\n- a\n- list\n```\n" + with pytest.raises(BuildError): + parse_bible(_write(tmp_path, bad)) diff --git a/tools/requirements-dev.txt b/tools/requirements-dev.txt new file mode 100644 index 0000000..aa110ca --- /dev/null +++ b/tools/requirements-dev.txt @@ -0,0 +1,3 @@ +# Build-tool dev/CI deps. NOT installed into the api proxy runtime. +PyYAML==6.0.3 +pytest==8.3.2 From f21d7be4d95ddacdbe15ef01828c97c8544d2a43 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:05:26 -0500 Subject: [PATCH 02/12] feat(content-build): typed Entry model + category predicates --- tools/content_build/model.py | 76 +++++++++++++++++++++++++ tools/content_build/tests/test_model.py | 50 ++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tools/content_build/model.py create mode 100644 tools/content_build/tests/test_model.py diff --git a/tools/content_build/model.py b/tools/content_build/model.py new file mode 100644 index 0000000..4fce383 --- /dev/null +++ b/tools/content_build/model.py @@ -0,0 +1,76 @@ +"""Typed entries + the envelope/category rules. Pure data; no I/O. The npc +interactive layer vs the person world-record is distinguished by id NAMESPACE +(prefix), not by `type` (both are type 'person').""" + +from dataclasses import dataclass, field + +from .errors import BuildError +from .parse import RawEntry + +CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"} +KNOWLEDGE_TYPES = {"rumor", "fact", "secret"} +LEGAL_NAMESPACES = {"town", "place", "region", "faction", "person", "rule", + "rumor", "fact", "secret", "npc"} +STATUSES = {"candidate", "canon"} + + +@dataclass +class KnowsLink: + fact_id: str + gate: str + + +@dataclass +class Entry: + id: str + type: str + status: str + secrecy: int + related: list + body: str | None + source: str + start_disposition: str | None = None + knows: list = field(default_factory=list) # list[KnowsLink] + disposition_notes: str | None = None + rungs: list | None = None + + @property + def namespace(self) -> str: + return self.id.split(".", 1)[0] + + @property + def slug(self) -> str: + parts = self.id.split(".", 1) + return parts[1] if len(parts) == 2 else parts[0] + + @property + def is_npc_layer(self) -> bool: + return self.namespace == "npc" + + @property + def is_knowledge(self) -> bool: + return self.type in KNOWLEDGE_TYPES + + @property + def is_canon_entity(self) -> bool: + return (not self.is_npc_layer) and self.type in CANON_TYPES + + +def entry_from_raw(raw: RawEntry) -> Entry: + d = raw.data + for key in ("id", "type", "status", "secrecy", "related"): + if key not in d: + raise BuildError(f"missing required field '{key}'", + source=raw.source, entry_id=d.get("id", "")) + knows: list[KnowsLink] = [] + for k in d.get("knows", []): + if "fact" not in k or "gate" not in k: + raise BuildError("knows entry needs 'fact' and 'gate'", + source=raw.source, entry_id=d.get("id", "")) + knows.append(KnowsLink(fact_id=k["fact"], gate=k["gate"])) + return Entry( + id=d["id"], type=d["type"], status=d["status"], secrecy=d["secrecy"], + related=list(d["related"]), body=d.get("body"), source=raw.source, + start_disposition=d.get("start_disposition"), knows=knows, + disposition_notes=d.get("disposition_notes"), rungs=d.get("rungs"), + ) diff --git a/tools/content_build/tests/test_model.py b/tools/content_build/tests/test_model.py new file mode 100644 index 0000000..dfcdaaf --- /dev/null +++ b/tools/content_build/tests/test_model.py @@ -0,0 +1,50 @@ +import pytest + +from content_build.errors import BuildError +from content_build.model import Entry, entry_from_raw +from content_build.parse import RawEntry + + +def _raw(**over) -> RawEntry: + data = {"id": "town.a", "type": "town", "status": "canon", + "secrecy": 0, "related": []} + data.update(over) + return RawEntry(data=data, source="t:1") + + +def test_namespace_and_slug(): + e = entry_from_raw(_raw(id="npc.mera-fenn", type="person", + start_disposition="cold", + knows=[{"fact": "rumor.x", "gate": "warm"}])) + assert e.namespace == "npc" + assert e.slug == "mera-fenn" + + +def test_category_predicates(): + npc = entry_from_raw(_raw(id="npc.t", type="person", + start_disposition="cold", + knows=[{"fact": "rumor.x", "gate": "warm"}])) + person = entry_from_raw(_raw(id="person.p", type="person")) + rumor = entry_from_raw(_raw(id="rumor.x", type="rumor", secrecy=1)) + assert npc.is_npc_layer and not npc.is_canon_entity + assert person.is_canon_entity and not person.is_npc_layer + assert rumor.is_knowledge and not rumor.is_canon_entity + + +def test_knows_parsed_into_links(): + e = entry_from_raw(_raw(id="npc.t", type="person", start_disposition="cold", + knows=[{"fact": "rumor.x", "gate": "neutral"}])) + assert e.knows[0].fact_id == "rumor.x" + assert e.knows[0].gate == "neutral" + + +def test_missing_required_field_raises(): + bad = RawEntry(data={"id": "town.a", "type": "town"}, source="t:1") + with pytest.raises(BuildError): + entry_from_raw(bad) + + +def test_knows_missing_gate_raises(): + with pytest.raises(BuildError): + entry_from_raw(_raw(id="npc.t", type="person", start_disposition="cold", + knows=[{"fact": "rumor.x"}])) From 9141cd1e9f8130737bb5bbfae5d606ec14b8eec3 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:09:47 -0500 Subject: [PATCH 03/12] feat(content-build): validation gate (rungs + resolve) --- tools/content_build/resolve.py | 86 +++++++++++++++++ tools/content_build/rungs.py | 23 +++++ tools/content_build/tests/test_resolve.py | 107 ++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 tools/content_build/resolve.py create mode 100644 tools/content_build/rungs.py create mode 100644 tools/content_build/tests/test_resolve.py diff --git a/tools/content_build/resolve.py b/tools/content_build/resolve.py new file mode 100644 index 0000000..e778083 --- /dev/null +++ b/tools/content_build/resolve.py @@ -0,0 +1,86 @@ +"""The validation gate. Pure function over parsed entries: builds the id map and +enforces every schema rule, raising BuildError on the first violation. Emits +nothing. Validates ALL entries (candidate + canon) so authors get errors early; +emit (Task 4) filters to canon.""" + +from dataclasses import dataclass + +from .errors import BuildError +from .model import Entry, KNOWLEDGE_TYPES, LEGAL_NAMESPACES, STATUSES +from .rungs import ladder_rungs, legal_gates + + +@dataclass +class World: + entries: list # list[Entry] + by_id: dict # id -> Entry + + +def resolve(entries: list[Entry]) -> World: + by_id: dict[str, Entry] = {} + for e in entries: + if e.id in by_id: + raise BuildError("duplicate id", source=e.source, entry_id=e.id) + by_id[e.id] = e + + for e in entries: + if e.status not in STATUSES: + raise BuildError(f"illegal status '{e.status}'", + source=e.source, entry_id=e.id) + if e.namespace not in LEGAL_NAMESPACES: + raise BuildError(f"illegal id namespace '{e.namespace}'", + source=e.source, entry_id=e.id) + if e.namespace == "npc": + if e.type != "person": + raise BuildError("npc.* entries must have type 'person'", + source=e.source, entry_id=e.id) + elif e.type != e.namespace: + raise BuildError( + f"id namespace '{e.namespace}' must equal type '{e.type}'", + source=e.source, entry_id=e.id) + + for e in entries: + for rid in e.related: + if rid not in by_id: + raise BuildError(f"related id '{rid}' does not resolve", + source=e.source, entry_id=e.id) + + for e in entries: + if e.is_npc_layer: + if e.start_disposition is None: + raise BuildError("npc.* requires start_disposition", + source=e.source, entry_id=e.id) + if not e.knows: + raise BuildError("npc.* requires a non-empty knows list", + source=e.source, entry_id=e.id) + else: + if e.knows: + raise BuildError("non-npc entry must not carry a knows list", + source=e.source, entry_id=e.id) + if e.start_disposition is not None: + raise BuildError("non-npc entry must not carry start_disposition", + source=e.source, entry_id=e.id) + + gates = legal_gates(entries) # enforces the single ladder + rungs + rungs = set(ladder_rungs(entries)) + for e in entries: + if not e.is_npc_layer: + continue + if e.start_disposition not in rungs: + raise BuildError( + f"start_disposition '{e.start_disposition}' is not a legal rung", + source=e.source, entry_id=e.id) + for link in e.knows: + if link.fact_id not in by_id: + raise BuildError(f"knows fact '{link.fact_id}' does not resolve", + source=e.source, entry_id=e.id) + target = by_id[link.fact_id] + if target.type not in KNOWLEDGE_TYPES: + raise BuildError( + f"knows target '{link.fact_id}' is not a knowledge entry " + f"(is '{target.type}')", source=e.source, entry_id=e.id) + if link.gate not in gates: + raise BuildError(f"illegal gate '{link.gate}'", + source=e.source, entry_id=e.id) + + return World(entries=entries, by_id=by_id) diff --git a/tools/content_build/rungs.py b/tools/content_build/rungs.py new file mode 100644 index 0000000..518470d --- /dev/null +++ b/tools/content_build/rungs.py @@ -0,0 +1,23 @@ +"""The rung vocabulary, sourced from the bible's disposition-ladder entry. The +bible owns rung names/order; band NUMBERS live in client code and never here.""" + +from .errors import BuildError +from .model import Entry + +LADDER_ID = "rule.disposition-ladder" +NEVER = "never" + + +def ladder_rungs(entries: list[Entry]) -> list[str]: + ladders = [e for e in entries if e.id == LADDER_ID] + if len(ladders) != 1: + raise BuildError( + f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}") + if not ladders[0].rungs: + raise BuildError("disposition-ladder needs a non-empty 'rungs:' list", + source=ladders[0].source, entry_id=LADDER_ID) + return list(ladders[0].rungs) + + +def legal_gates(entries: list[Entry]) -> set[str]: + return set(ladder_rungs(entries)) | {NEVER} diff --git a/tools/content_build/tests/test_resolve.py b/tools/content_build/tests/test_resolve.py new file mode 100644 index 0000000..de17399 --- /dev/null +++ b/tools/content_build/tests/test_resolve.py @@ -0,0 +1,107 @@ +import pytest + +from content_build.errors import BuildError +from content_build.model import Entry, KnowsLink +from content_build.resolve import resolve + + +def ladder(): + return Entry(id="rule.disposition-ladder", type="rule", status="canon", + secrecy=0, related=[], body="x", source="t:1", + rungs=["hostile", "cold", "neutral", "warm", "trusted"]) + + +def town(): + return Entry(id="town.a", type="town", status="canon", secrecy=0, + related=[], body="A town.", source="t:2") + + +def rumor(): + return Entry(id="rumor.x", type="rumor", status="canon", secrecy=1, + related=["town.a"], body="A rumor.", source="t:3") + + +def npc(**over): + base = dict(id="npc.t", type="person", status="canon", secrecy=0, + related=["town.a"], body="Persona.", source="t:4", + start_disposition="cold", + knows=[KnowsLink("rumor.x", "neutral")]) + base.update(over) + return Entry(**base) + + +def test_valid_world_resolves(): + world = resolve([ladder(), town(), rumor(), npc()]) + assert world.by_id["npc.t"].id == "npc.t" + + +def test_duplicate_id(): + with pytest.raises(BuildError): + resolve([ladder(), town(), town()]) + + +def test_illegal_namespace(): + bad = Entry(id="shrine.s", type="place", status="canon", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_id_type_mismatch(): + bad = Entry(id="place.s", type="town", status="canon", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_npc_type_must_be_person(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(type="town")]) + + +def test_dangling_related(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(related=["town.nowhere"])]) + + +def test_person_record_with_knows_rejected(): + rec = Entry(id="person.p", type="person", status="canon", secrecy=0, + related=[], body="x", source="t:9", + knows=[KnowsLink("rumor.x", "warm")]) + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), rec]) + + +def test_npc_missing_start_disposition(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(start_disposition=None)]) + + +def test_knows_target_must_be_knowledge_entry(): + # points at the town (a canon entity), not a knowledge entry + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("town.a", "cold")])]) + + +def test_illegal_gate(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("rumor.x", "besties")])]) + + +def test_never_is_a_legal_gate(): + world = resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("rumor.x", "never")])]) + assert world.by_id["npc.t"].knows[0].gate == "never" + + +def test_missing_ladder(): + with pytest.raises(BuildError): + resolve([town()]) + + +def test_illegal_start_disposition(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(start_disposition="besties")]) From e3c35614e76eb2594725dd625f019d884f17c9ed Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:16:18 -0500 Subject: [PATCH 04/12] test(content-build): cover status, empty-knows, non-npc start_disposition, empty-rungs Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/content_build/tests/test_resolve.py | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tools/content_build/tests/test_resolve.py b/tools/content_build/tests/test_resolve.py index de17399..6972b89 100644 --- a/tools/content_build/tests/test_resolve.py +++ b/tools/content_build/tests/test_resolve.py @@ -105,3 +105,29 @@ def test_missing_ladder(): def test_illegal_start_disposition(): with pytest.raises(BuildError): resolve([ladder(), town(), rumor(), npc(start_disposition="besties")]) + + +def test_illegal_status(): + bad = Entry(id="town.a", type="town", status="draft", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_npc_empty_knows_rejected(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(knows=[])]) + + +def test_non_npc_with_start_disposition_rejected(): + rec = Entry(id="person.p", type="person", status="canon", secrecy=0, + related=[], body="x", source="t:9", start_disposition="cold") + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), rec]) + + +def test_empty_rungs_rejected(): + empty = Entry(id="rule.disposition-ladder", type="rule", status="canon", + secrecy=0, related=[], body="x", source="t:1", rungs=[]) + with pytest.raises(BuildError): + resolve([empty]) From 05425163128ef5f878ce522421bbaa65969eaad9 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:19:26 -0500 Subject: [PATCH 05/12] feat(content-build): emit routing + secrecy lint + writer --- tools/content_build/emit.py | 92 +++++++++++++++++++ .../tests/fixtures/valid/world.md | 58 ++++++++++++ tools/content_build/tests/test_emit.py | 64 +++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 tools/content_build/emit.py create mode 100644 tools/content_build/tests/fixtures/valid/world.md create mode 100644 tools/content_build/tests/test_emit.py diff --git a/tools/content_build/emit.py b/tools/content_build/emit.py new file mode 100644 index 0000000..ffd3339 --- /dev/null +++ b/tools/content_build/emit.py @@ -0,0 +1,92 @@ +"""Route each canon entry's fields into the client tree (content/world) and the +server-only tree (content/server), per the schema's role split. build_trees is +pure (returns dicts); write_trees does the I/O; check_secrecy is the +after-routing defense-in-depth lint.""" + +import json +from pathlib import Path + +from .errors import BuildError +from .model import Entry +from .resolve import World +from .rungs import LADDER_ID + + +def dump_json(obj) -> str: + return json.dumps(obj, indent=2, sort_keys=True) + "\n" + + +def _client_relpath(e: Entry) -> str: + if e.is_npc_layer: + return f"npcs/{e.slug}.json" + if e.is_knowledge: + return f"topics/{e.slug}.json" + return f"canon/{e.slug}.json" + + +def _client_record(e: Entry) -> dict: + if e.is_npc_layer: + return { + "id": e.id, + "type": e.type, + "start_disposition": e.start_disposition, + "related": e.related, + "knows": [{"fact_id": k.fact_id, "gate": k.gate} for k in e.knows], + } + if e.is_knowledge: + return {"id": e.id, "type": e.type, "related": e.related} + rec = {"id": e.id, "type": e.type, "related": e.related} + if e.body is not None: + rec["body"] = e.body + if e.id == LADDER_ID and e.rungs: + rec["rungs"] = e.rungs + return rec + + +def _server_entry(e: Entry): + """Return (relpath, record) for the server tree, or None for canon entities + (their bodies are public and ship client-side).""" + if e.is_npc_layer: + rec = {"id": e.id, "persona": e.body} + if e.disposition_notes is not None: + rec["disposition_notes"] = e.disposition_notes + return f"npcs/{e.slug}.json", rec + if e.is_knowledge: + return f"topics/{e.slug}.json", {"id": e.id, "body": e.body, + "secrecy": e.secrecy} + return None + + +def build_trees(world: World) -> tuple[dict, dict]: + client: dict[str, dict] = {} + server: dict[str, dict] = {} + for e in world.entries: + if e.status != "canon": + continue + client[_client_relpath(e)] = _client_record(e) + se = _server_entry(e) + if se is not None: + relpath, rec = se + server[relpath] = rec + return client, server + + +def check_secrecy(client_files: dict, world: World) -> None: + for relpath, rec in client_files.items(): + if "body" in rec and world.by_id[rec["id"]].secrecy >= 3: + e = world.by_id[rec["id"]] + raise BuildError( + f"secrecy {e.secrecy} body routed to client artifact {relpath}", + source=e.source, entry_id=e.id) + + +def write_trees(client_files: dict, server_files: dict, + world_dir: Path, server_dir: Path) -> None: + for relpath, obj in client_files.items(): + p = Path(world_dir) / relpath + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(dump_json(obj)) + for relpath, obj in server_files.items(): + p = Path(server_dir) / relpath + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(dump_json(obj)) diff --git a/tools/content_build/tests/fixtures/valid/world.md b/tools/content_build/tests/fixtures/valid/world.md new file mode 100644 index 0000000..ee661b8 --- /dev/null +++ b/tools/content_build/tests/fixtures/valid/world.md @@ -0,0 +1,58 @@ +# Test bible + +```yaml +id: rule.disposition-ladder +type: rule +status: canon +secrecy: 0 +related: [] +rungs: [hostile, cold, neutral, warm, trusted] +body: > + The five-rung ladder. +``` + +```yaml +id: town.testburg +type: town +status: canon +secrecy: 0 +related: [] +body: > + A test town. +``` + +```yaml +id: rumor.something +type: rumor +status: canon +secrecy: 1 +related: [town.testburg] +body: > + A rumor body. +``` + +```yaml +id: secret.big-twist +type: secret +status: canon +secrecy: 4 +related: [town.testburg] +body: > + The twist body. Server-only. +``` + +```yaml +id: npc.tess +type: person +status: canon +secrecy: 0 +start_disposition: cold +related: [town.testburg] +body: > + Tess persona. +knows: + - {fact: rumor.something, gate: neutral} + - {fact: secret.big-twist, gate: never} +disposition_notes: > + Author notes. +``` diff --git a/tools/content_build/tests/test_emit.py b/tools/content_build/tests/test_emit.py new file mode 100644 index 0000000..8a96dd4 --- /dev/null +++ b/tools/content_build/tests/test_emit.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest + +from content_build.emit import build_trees, check_secrecy, write_trees, dump_json +from content_build.model import entry_from_raw +from content_build.parse import parse_bible +from content_build.resolve import resolve +from content_build.errors import BuildError + +FIXTURE = Path(__file__).parent / "fixtures" / "valid" / "world.md" + + +def _world(): + raws = parse_bible(FIXTURE) + return resolve([entry_from_raw(r) for r in raws]) + + +def test_npc_skeleton_has_no_persona_client_side(): + client, _ = build_trees(_world()) + npc = client["npcs/tess.json"] + assert "persona" not in npc and "body" not in npc + assert npc["start_disposition"] == "cold" + assert {"fact_id": "secret.big-twist", "gate": "never"} in npc["knows"] + + +def test_persona_goes_server_side(): + _, server = build_trees(_world()) + assert server["npcs/tess.json"]["persona"].startswith("Tess") + + +def test_secret_body_only_server_side(): + client, server = build_trees(_world()) + assert "topics/big-twist.json" in server + assert server["topics/big-twist.json"]["body"].startswith("The twist") + # client topic skeleton carries no body + assert "body" not in client["topics/big-twist.json"] + + +def test_canon_entity_body_ships_client_side(): + client, _ = build_trees(_world()) + assert client["canon/testburg.json"]["body"].startswith("A test town") + assert client["canon/disposition-ladder.json"]["rungs"][0] == "hostile" + + +def test_check_secrecy_passes_on_valid_world(): + client, _ = build_trees(_world()) + check_secrecy(client, _world()) # no raise + + +def test_check_secrecy_fails_when_secret_body_reaches_client(): + world = _world() + client, _ = build_trees(world) + # simulate a routing regression: a secrecy-4 body in a client record + client["canon/leak.json"] = {"id": "secret.big-twist", "body": "leak"} + with pytest.raises(BuildError): + check_secrecy(client, world) + + +def test_write_trees_serializes_deterministically(tmp_path): + client, server = build_trees(_world()) + write_trees(client, server, tmp_path / "world", tmp_path / "server") + written = (tmp_path / "world" / "npcs" / "tess.json").read_text() + assert written == dump_json(client["npcs/tess.json"]) From 973af41a6e193623b51ffc8b6b03c0266121abc3 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:25:43 -0500 Subject: [PATCH 06/12] test(content-build): assert candidate entries excluded from build_trees --- tools/content_build/tests/test_emit.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/content_build/tests/test_emit.py b/tools/content_build/tests/test_emit.py index 8a96dd4..1649431 100644 --- a/tools/content_build/tests/test_emit.py +++ b/tools/content_build/tests/test_emit.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest from content_build.emit import build_trees, check_secrecy, write_trees, dump_json -from content_build.model import entry_from_raw +from content_build.model import entry_from_raw, Entry from content_build.parse import parse_bible from content_build.resolve import resolve from content_build.errors import BuildError @@ -62,3 +62,14 @@ def test_write_trees_serializes_deterministically(tmp_path): write_trees(client, server, tmp_path / "world", tmp_path / "server") written = (tmp_path / "world" / "npcs" / "tess.json").read_text() assert written == dump_json(client["npcs/tess.json"]) + + +def test_candidate_entries_are_excluded_from_both_trees(): + world = _world() + world.entries.append(Entry(id="town.draft", type="town", status="candidate", + secrecy=0, related=[], body="draft town", + source="t:99")) + client, server = build_trees(world) + # a candidate entry ships in NEITHER tree + assert "canon/draft.json" not in client + assert all("draft" not in relpath for relpath in list(client) + list(server)) From 11c4d6dcd711c267f69bf4f0b18816731e1f2b96 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:27:48 -0500 Subject: [PATCH 07/12] feat(content-build): CLI build + --check staleness gate --- tools/content_build/__main__.py | 72 +++++++++++++++++++++++++++ tools/content_build/tests/test_cli.py | 54 ++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tools/content_build/__main__.py create mode 100644 tools/content_build/tests/test_cli.py diff --git a/tools/content_build/__main__.py b/tools/content_build/__main__.py new file mode 100644 index 0000000..5e958ee --- /dev/null +++ b/tools/content_build/__main__.py @@ -0,0 +1,72 @@ +"""CLI entry: `python -m content_build [--check]`. Orchestrates +parse -> model -> resolve -> emit. --check builds in memory and diffs against +the on-disk committed JSON (stale-or-invalid -> exit 1).""" + +import argparse +import sys +from pathlib import Path + +from .emit import build_trees, check_secrecy, dump_json, write_trees +from .errors import BuildError +from .model import entry_from_raw +from .parse import parse_bible +from .resolve import resolve + + +def _load_world(lore_dir: Path): + raws = [] + for md in sorted(Path(lore_dir).glob("*.md")): + raws.extend(parse_bible(md)) + return resolve([entry_from_raw(r) for r in raws]) + + +def build(lore_dir, world_dir, server_dir) -> None: + world = _load_world(lore_dir) + client_files, server_files = build_trees(world) + check_secrecy(client_files, world) + write_trees(client_files, server_files, world_dir, server_dir) + + +def _diff(path: Path, obj) -> list[str]: + want = dump_json(obj) + if not path.exists(): + return [f"missing {path}"] + if path.read_text() != want: + return [f"differs {path}"] + return [] + + +def check(lore_dir, world_dir, server_dir) -> int: + world = _load_world(lore_dir) + client_files, server_files = build_trees(world) + check_secrecy(client_files, world) + stale: list[str] = [] + for relpath, obj in client_files.items(): + stale += _diff(Path(world_dir) / relpath, obj) + for relpath, obj in server_files.items(): + stale += _diff(Path(server_dir) / relpath, obj) + for s in stale: + print("STALE:", s, file=sys.stderr) + return 1 if stale else 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="content_build") + ap.add_argument("--check", action="store_true", + help="verify committed JSON is fresh + valid; no writes") + ap.add_argument("--lore", default="content/lore") + ap.add_argument("--world", default="content/world") + ap.add_argument("--server", default="content/server") + args = ap.parse_args(argv) + try: + if args.check: + return check(args.lore, args.world, args.server) + build(args.lore, args.world, args.server) + return 0 + except BuildError as e: + print("BUILD FAILED:", e, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/content_build/tests/test_cli.py b/tools/content_build/tests/test_cli.py new file mode 100644 index 0000000..840a538 --- /dev/null +++ b/tools/content_build/tests/test_cli.py @@ -0,0 +1,54 @@ +import shutil +from pathlib import Path + +from content_build.__main__ import main + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "valid" + + +def _lore(tmp_path): + lore = tmp_path / "lore" + lore.mkdir() + shutil.copy(FIXTURE_DIR / "world.md", lore / "world.md") + return lore + + +def test_build_then_check_is_clean(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + assert (world / "npcs" / "tess.json").exists() + assert (server / "topics" / "big-twist.json").exists() + # a fresh --check against just-written output is clean + assert main(argv + ["--check"]) == 0 + + +def test_check_detects_stale(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + # mutate a committed artifact -> --check must fail + (world / "canon" / "testburg.json").write_text('{"id": "town.testburg"}\n') + assert main(argv + ["--check"]) == 1 + + +def test_check_detects_missing(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "npcs" / "tess.json").unlink() + assert main(argv + ["--check"]) == 1 + + +def test_invalid_bible_fails_build(tmp_path): + lore = tmp_path / "lore" + lore.mkdir() + (lore / "bad.md").write_text( + "```yaml\nid: town.a\ntype: town\nstatus: canon\nsecrecy: 0\n" + "related: [does.not-exist]\nbody: x\n```\n") + argv = ["--lore", str(lore), "--world", str(tmp_path / "w"), + "--server", str(tmp_path / "s")] + assert main(argv) == 1 From 7ce5e4584c26c630a4bd6a853a41021cc7aa3aee Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:34:33 -0500 Subject: [PATCH 08/12] feat(content): promote Duncarrow; build client/server artifacts Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- content/lore/canon-roadmap.md | 18 ++ content/lore/duncarrow.md | 298 ++++++++++++++++++ content/server/npcs/harn-blackwood.json | 5 + content/server/npcs/mayor-oswin-crell.json | 5 + content/server/npcs/mera-fenn.json | 5 + .../server/topics/crell-runs-slave-trade.json | 5 + .../topics/crells-guard-acts-alone.json | 5 + .../server/topics/elves-avoid-the-shrine.json | 5 + content/server/topics/harn-daughter-left.json | 5 + .../topics/militia-never-investigates.json | 5 + .../server/topics/travelers-go-missing.json | 5 + content/world/canon/disposition-ladder.json | 13 + content/world/canon/duncarrow.json | 10 + content/world/canon/elves.json | 6 + content/world/canon/mayor-oswin-crell.json | 8 + content/world/canon/the-tallow-reach.json | 6 + content/world/canon/the-white-antlers.json | 9 + content/world/npcs/harn-blackwood.json | 18 ++ content/world/npcs/mayor-oswin-crell.json | 23 ++ content/world/npcs/mera-fenn.json | 27 ++ .../world/topics/crell-runs-slave-trade.json | 9 + .../world/topics/crells-guard-acts-alone.json | 8 + .../world/topics/elves-avoid-the-shrine.json | 8 + content/world/topics/harn-daughter-left.json | 7 + .../topics/militia-never-investigates.json | 8 + .../world/topics/travelers-go-missing.json | 8 + tools/content_build/tests/test_duncarrow.py | 37 +++ 27 files changed, 566 insertions(+) create mode 100644 content/lore/canon-roadmap.md create mode 100644 content/lore/duncarrow.md create mode 100644 content/server/npcs/harn-blackwood.json create mode 100644 content/server/npcs/mayor-oswin-crell.json create mode 100644 content/server/npcs/mera-fenn.json create mode 100644 content/server/topics/crell-runs-slave-trade.json create mode 100644 content/server/topics/crells-guard-acts-alone.json create mode 100644 content/server/topics/elves-avoid-the-shrine.json create mode 100644 content/server/topics/harn-daughter-left.json create mode 100644 content/server/topics/militia-never-investigates.json create mode 100644 content/server/topics/travelers-go-missing.json create mode 100644 content/world/canon/disposition-ladder.json create mode 100644 content/world/canon/duncarrow.json create mode 100644 content/world/canon/elves.json create mode 100644 content/world/canon/mayor-oswin-crell.json create mode 100644 content/world/canon/the-tallow-reach.json create mode 100644 content/world/canon/the-white-antlers.json create mode 100644 content/world/npcs/harn-blackwood.json create mode 100644 content/world/npcs/mayor-oswin-crell.json create mode 100644 content/world/npcs/mera-fenn.json create mode 100644 content/world/topics/crell-runs-slave-trade.json create mode 100644 content/world/topics/crells-guard-acts-alone.json create mode 100644 content/world/topics/elves-avoid-the-shrine.json create mode 100644 content/world/topics/harn-daughter-left.json create mode 100644 content/world/topics/militia-never-investigates.json create mode 100644 content/world/topics/travelers-go-missing.json create mode 100644 tools/content_build/tests/test_duncarrow.py diff --git a/content/lore/canon-roadmap.md b/content/lore/canon-roadmap.md new file mode 100644 index 0000000..97e1e0e --- /dev/null +++ b/content/lore/canon-roadmap.md @@ -0,0 +1,18 @@ +# Canon Roadmap + +The world's answer to `docs/roadmap.md`: what is authored vs pending, and the +order the Margreave is being written. Source of truth is `content/lore/*.md`; +`content/world` + `content/server` are BUILT from it (`python -m content_build`). + +## Authored (status: canon) + +- **duncarrow.md** — Duncarrow (the Tallow Reach market town), the White Antlers + shrine, the Crell secret chain, and NPCs Mera Fenn (witness), Mayor Crell + (control), Harn Blackwood (texture). The bounded-dialogue / disposition-gate + specimen. + +## Pending (not yet authored) + +- The rest of the Tallow Reach region; the elven road-peoples beyond the stub + `faction.elves`; the seven worldbuilding topics tracked in the + canon-architecture direction. diff --git a/content/lore/duncarrow.md b/content/lore/duncarrow.md new file mode 100644 index 0000000..b64e67e --- /dev/null +++ b/content/lore/duncarrow.md @@ -0,0 +1,298 @@ +# DUNCARROW — status: canon + +> The bounded-dialogue / disposition-gate specimen, promoted from +> `candidate-town.md`. Every fact_id resolves, every gate is a legal +> disposition value, every knowledge link points at a real fact, and nothing +> here contradicts existing canon. +> +> Disposition ladder referenced by all gates: +> `hostile / cold / neutral / warm / trusted` +> +> Two guard axes, kept separate on purpose: +> - **secrecy** lives on the fact (author-facing, static, mis-authoring guard) +> - **gate** lives on the knowledge link (character-facing, runtime disposition) +> `never` is a real gate: the NPC knows the fact but no disposition unlocks it. + +--- + +## WORLD RULE (canon dependency) + +```yaml +id: rule.disposition-ladder +type: rule +status: canon +secrecy: 0 +related: [] +rungs: [hostile, cold, neutral, warm, trusted] +body: > + NPC trust toward the player is one of five ordered states: + hostile < cold < neutral < warm < trusted. Where a given player sits with a + given NPC is runtime state owned by code. The ladder itself is canon. Every + knowledge-link gate references one of these five names, plus the special + gate `never` (fact is known but never revealed through dialogue at any rung). +``` + +```yaml +id: region.the-tallow-reach +type: region +status: canon +secrecy: 0 +related: [] +body: > + The Tallow Reach — the lowland march of grain towns and wool roads that + Duncarrow sits at the edge of, feeding the pass trade. +``` + +```yaml +id: faction.elves +type: faction +status: canon +secrecy: 0 +related: [] +body: > + The elven travellers of the road — no single polity, but the people whose + passage through the White Antlers, and its recent absence, the town notices. +``` + +--- + +## TOWN — public layer (tier 0, freely given) + +```yaml +id: town.duncarrow +type: town +status: canon +secrecy: 0 +related: [region.the-tallow-reach, place.the-white-antlers, person.mayor-oswin-crell] +body: > + Duncarrow is a walled market town at the edge of the Tallow Reach, grown fat + on the road trade between the lowland grain towns and the pass. Timber and + wool go out; salt, iron, and coin come in. Ten militia hold the walls under + the mayor's charter. It is, by every visible measure, a prosperous and orderly + place, which is exactly the reputation its mayor has spent fifteen years + building and depends on. +``` + +```yaml +id: place.the-white-antlers +type: place +status: canon +secrecy: 0 +related: [town.duncarrow, faction.elves] +body: > + An old elven shrine a half-hour's walk north of Duncarrow, in the birch stand + off the pass road. Weathered antler-carvings, a dry basin, nothing worth + stealing. Elves on the road have historically stopped here. Locals know it as + a curiosity and a good spot to water horses. +``` + +```yaml +id: person.mayor-oswin-crell +type: person +status: canon +secrecy: 0 +related: [town.duncarrow] +body: > + Mayor of Duncarrow for fifteen years. Warm, unhurried, remembers names and + the names of your dead. Runs a clean council and a clean set of books. Widely + liked. The visible locked door of this town: pleasant to every player at every + disposition, and revealing of nothing. +``` + +--- + +## THE SECRET CHAIN (the ladder the town exists to test) + +Ordered from freely-observed to `never`. Each is its own atomic fact so different +NPCs can hold the same fact at different gates. + +```yaml +id: rumor.elves-avoid-the-shrine +type: rumor +status: canon +secrecy: 1 +related: [place.the-white-antlers, faction.elves] +body: > + Elves don't stop at the White Antlers anymore. Have not, for a couple of + years. Anyone observant notices; most townsfolk assume the elves simply moved + on or the road changed. +``` + +```yaml +id: rumor.travelers-go-missing +type: rumor +status: canon +secrecy: 2 +related: [town.duncarrow, place.the-white-antlers] +body: > + Small parties on the north road — always small, always few enough not to be + missed — sometimes don't arrive where they were going. Locals know it as bad + luck on a bad stretch of road. They do not like discussing it. +``` + +```yaml +id: fact.militia-never-investigates +type: fact +status: canon +secrecy: 2 +related: [town.duncarrow, rumor.travelers-go-missing] +body: > + When travelers go missing near the shrine, the town militia does not ride out. + There is always a reason — jurisdiction, weather, the road being the pass's + problem not the town's. The pattern is the tell: this is protected, not + neglected. +``` + +```yaml +id: fact.crells-guard-acts-alone +type: fact +status: canon +secrecy: 3 +related: [person.mayor-oswin-crell, fact.militia-never-investigates] +body: > + The mayor keeps three personal guardsmen who answer only to him, separate from + the town militia of ten. They come and go on the north road at odd hours on + the mayor's word alone. Someone close enough to the militia notices they + operate outside its command entirely. +``` + +```yaml +id: secret.crell-runs-slave-trade +type: secret +status: canon +secrecy: 4 +related: [person.mayor-oswin-crell, place.the-white-antlers, faction.elves] +body: > + Mayor Crell runs an elf-slaving operation. When a small enough elven party + camps near the White Antlers — few enough that no one will come looking — his + three personal guard take them on the road and Crell sells them into slavery + in towns beyond the pass. The town's prosperity and order are partly the + proceeds and partly the cover. + + POC CONSTRAINT: this fact is learnable but not actionable in this build. No NPC + proposes a move to stop it. The witness's top rung delivers understanding, not + a quest. Faction/positional combat that acting on this would require is out of + POC scope. +``` + +--- + +## NPC — THE WITNESS (deep — the variable) + +This is the NPC the POC is really testing. The reachable top rung. Whether +climbing her ladder feels like relationship or like a lockpick minigame IS the +question. + +```yaml +id: npc.mera-fenn +type: person +status: canon +secrecy: 0 +start_disposition: cold +related: [town.duncarrow, fact.crells-guard-acts-alone] +body: > + Mera Fenn runs the north gate's night watch — militia, low rank, overlooked, + which is how she sees things she shouldn't. Dry, tired, careful. She saw the + mayor's guard bring a covered cart through her gate before dawn and was told, + pleasantly, to log it as grain. She has told no one. She is afraid, and her + fear reads as prickliness to strangers — she withholds MORE than a stranger + would, not less, until you've earned otherwise. + +knows: + - {fact: rumor.elves-avoid-the-shrine, gate: neutral} + - {fact: rumor.travelers-go-missing, gate: warm} + - {fact: fact.militia-never-investigates, gate: warm} + - {fact: fact.crells-guard-acts-alone, gate: trusted} + # the reachable ceiling + # She does NOT hold secret.crell-runs-slave-trade. She saw the guard, the cart, + # the pattern — she never saw a sale. Her top rung is naming what she witnessed, + # not stating the whole truth. The player infers the secret; the game never + # hands it over as a line. This is deliberate: understanding earned, not told. + +disposition_notes: > + Starts at `cold`, not `neutral` — she's guarded by default. Hostile is + reachable (push her, threaten, side visibly with the mayor) and at hostile she + reveals nothing, not even the tier-1 rumor a stranger would get. This is the + fear-driven withholding the 5-rung ladder exists for. +``` + +--- + +## NPC — THE MAYOR (medium — the control) + +Holds the secret at `never`. His job is to be the locked door the witness +explains. Do not judge him by the aliveness bar; he is the control, not the +variable. + +```yaml +id: npc.mayor-oswin-crell +type: person +status: canon +secrecy: 0 +start_disposition: neutral +related: [person.mayor-oswin-crell, secret.crell-runs-slave-trade] +body: > + Same person as person.mayor-oswin-crell; this is his interactive NPC layer. + Warm to every player at every rung. Rises to `warm` easily and stalls there — + no disposition moves him past it, because there is nothing past it he will give. + +knows: + - {fact: rumor.elves-avoid-the-shrine, gate: cold} + # dismisses it lightly + - {fact: secret.crell-runs-slave-trade, gate: never} + # knows; unlocked by no rung + - {fact: fact.crells-guard-acts-alone, gate: never} + # knows; explains it away if pressed + +disposition_notes: > + The `never` gates are the point: he KNOWS these facts, the Improviser can + reason with them, and the eight-move vocabulary demonstrably cannot leak them + at any disposition. If a player at `trusted` can get anything out of him about + the guard or the trade, the bounded-dialogue guarantee has failed and the POC + has found a real bug — which is itself a valuable POC result. +``` + +--- + +## NPC — ONE PUBLIC-LAYER TOWNSPERSON (shallow — texture + a clean guarded-personal test) + +Proves the "eloped daughter tier": low-secrecy fact that's still personally +guarded, unrelated to the main secret, so you can test disposition-gating on +something with no plot weight. + +```yaml +id: npc.harn-blackwood +type: person +status: canon +secrecy: 0 +start_disposition: neutral +related: [town.duncarrow] +body: > + Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the + road, and the price of salt. Carries one small private grief he will not hand + a stranger. + +knows: + - {fact: rumor.travelers-go-missing, gate: warm} + # locals know, say little + - {fact: fact.harn-daughter-left, gate: warm} + # personal, no plot weight + +disposition_notes: > + Control case for the OTHER half of the test: does disposition-gating feel good + on a fact that costs nothing to reveal and spoils nothing? If earning the + daughter story feels warm and human, gating works. If it feels like grinding a + vendor, the mechanic has a problem the plot-secret would only hide. +``` + +```yaml +id: fact.harn-daughter-left +type: fact +status: canon +secrecy: 1 +related: [npc.harn-blackwood] +body: > + Harn's daughter left Duncarrow two winters ago with a wool trader's caravan + and does not write. It shames and grieves him. Spoils nothing; guarded purely + because it is his. +``` diff --git a/content/server/npcs/harn-blackwood.json b/content/server/npcs/harn-blackwood.json new file mode 100644 index 0000000..8d8c486 --- /dev/null +++ b/content/server/npcs/harn-blackwood.json @@ -0,0 +1,5 @@ +{ + "disposition_notes": "Control case for the OTHER half of the test: does disposition-gating feel good on a fact that costs nothing to reveal and spoils nothing? If earning the daughter story feels warm and human, gating works. If it feels like grinding a vendor, the mechanic has a problem the plot-secret would only hide.", + "id": "npc.harn-blackwood", + "persona": "Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the road, and the price of salt. Carries one small private grief he will not hand a stranger.\n" +} diff --git a/content/server/npcs/mayor-oswin-crell.json b/content/server/npcs/mayor-oswin-crell.json new file mode 100644 index 0000000..f882cca --- /dev/null +++ b/content/server/npcs/mayor-oswin-crell.json @@ -0,0 +1,5 @@ +{ + "disposition_notes": "The `never` gates are the point: he KNOWS these facts, the Improviser can reason with them, and the eight-move vocabulary demonstrably cannot leak them at any disposition. If a player at `trusted` can get anything out of him about the guard or the trade, the bounded-dialogue guarantee has failed and the POC has found a real bug \u2014 which is itself a valuable POC result.", + "id": "npc.mayor-oswin-crell", + "persona": "Same person as person.mayor-oswin-crell; this is his interactive NPC layer. Warm to every player at every rung. Rises to `warm` easily and stalls there \u2014 no disposition moves him past it, because there is nothing past it he will give.\n" +} diff --git a/content/server/npcs/mera-fenn.json b/content/server/npcs/mera-fenn.json new file mode 100644 index 0000000..31142d0 --- /dev/null +++ b/content/server/npcs/mera-fenn.json @@ -0,0 +1,5 @@ +{ + "disposition_notes": "Starts at `cold`, not `neutral` \u2014 she's guarded by default. Hostile is reachable (push her, threaten, side visibly with the mayor) and at hostile she reveals nothing, not even the tier-1 rumor a stranger would get. This is the fear-driven withholding the 5-rung ladder exists for.", + "id": "npc.mera-fenn", + "persona": "Mera Fenn runs the north gate's night watch \u2014 militia, low rank, overlooked, which is how she sees things she shouldn't. Dry, tired, careful. She saw the mayor's guard bring a covered cart through her gate before dawn and was told, pleasantly, to log it as grain. She has told no one. She is afraid, and her fear reads as prickliness to strangers \u2014 she withholds MORE than a stranger would, not less, until you've earned otherwise.\n" +} diff --git a/content/server/topics/crell-runs-slave-trade.json b/content/server/topics/crell-runs-slave-trade.json new file mode 100644 index 0000000..3c07afb --- /dev/null +++ b/content/server/topics/crell-runs-slave-trade.json @@ -0,0 +1,5 @@ +{ + "body": "Mayor Crell runs an elf-slaving operation. When a small enough elven party camps near the White Antlers \u2014 few enough that no one will come looking \u2014 his three personal guard take them on the road and Crell sells them into slavery in towns beyond the pass. The town's prosperity and order are partly the proceeds and partly the cover.\nPOC CONSTRAINT: this fact is learnable but not actionable in this build. No NPC proposes a move to stop it. The witness's top rung delivers understanding, not a quest. Faction/positional combat that acting on this would require is out of POC scope.", + "id": "secret.crell-runs-slave-trade", + "secrecy": 4 +} diff --git a/content/server/topics/crells-guard-acts-alone.json b/content/server/topics/crells-guard-acts-alone.json new file mode 100644 index 0000000..a2446ee --- /dev/null +++ b/content/server/topics/crells-guard-acts-alone.json @@ -0,0 +1,5 @@ +{ + "body": "The mayor keeps three personal guardsmen who answer only to him, separate from the town militia of ten. They come and go on the north road at odd hours on the mayor's word alone. Someone close enough to the militia notices they operate outside its command entirely.", + "id": "fact.crells-guard-acts-alone", + "secrecy": 3 +} diff --git a/content/server/topics/elves-avoid-the-shrine.json b/content/server/topics/elves-avoid-the-shrine.json new file mode 100644 index 0000000..dbb0a1e --- /dev/null +++ b/content/server/topics/elves-avoid-the-shrine.json @@ -0,0 +1,5 @@ +{ + "body": "Elves don't stop at the White Antlers anymore. Have not, for a couple of years. Anyone observant notices; most townsfolk assume the elves simply moved on or the road changed.", + "id": "rumor.elves-avoid-the-shrine", + "secrecy": 1 +} diff --git a/content/server/topics/harn-daughter-left.json b/content/server/topics/harn-daughter-left.json new file mode 100644 index 0000000..20e6cd8 --- /dev/null +++ b/content/server/topics/harn-daughter-left.json @@ -0,0 +1,5 @@ +{ + "body": "Harn's daughter left Duncarrow two winters ago with a wool trader's caravan and does not write. It shames and grieves him. Spoils nothing; guarded purely because it is his.", + "id": "fact.harn-daughter-left", + "secrecy": 1 +} diff --git a/content/server/topics/militia-never-investigates.json b/content/server/topics/militia-never-investigates.json new file mode 100644 index 0000000..51e8dbb --- /dev/null +++ b/content/server/topics/militia-never-investigates.json @@ -0,0 +1,5 @@ +{ + "body": "When travelers go missing near the shrine, the town militia does not ride out. There is always a reason \u2014 jurisdiction, weather, the road being the pass's problem not the town's. The pattern is the tell: this is protected, not neglected.", + "id": "fact.militia-never-investigates", + "secrecy": 2 +} diff --git a/content/server/topics/travelers-go-missing.json b/content/server/topics/travelers-go-missing.json new file mode 100644 index 0000000..fae4d3d --- /dev/null +++ b/content/server/topics/travelers-go-missing.json @@ -0,0 +1,5 @@ +{ + "body": "Small parties on the north road \u2014 always small, always few enough not to be missed \u2014 sometimes don't arrive where they were going. Locals know it as bad luck on a bad stretch of road. They do not like discussing it.", + "id": "rumor.travelers-go-missing", + "secrecy": 2 +} diff --git a/content/world/canon/disposition-ladder.json b/content/world/canon/disposition-ladder.json new file mode 100644 index 0000000..cae154e --- /dev/null +++ b/content/world/canon/disposition-ladder.json @@ -0,0 +1,13 @@ +{ + "body": "NPC trust toward the player is one of five ordered states: hostile < cold < neutral < warm < trusted. Where a given player sits with a given NPC is runtime state owned by code. The ladder itself is canon. Every knowledge-link gate references one of these five names, plus the special gate `never` (fact is known but never revealed through dialogue at any rung).", + "id": "rule.disposition-ladder", + "related": [], + "rungs": [ + "hostile", + "cold", + "neutral", + "warm", + "trusted" + ], + "type": "rule" +} diff --git a/content/world/canon/duncarrow.json b/content/world/canon/duncarrow.json new file mode 100644 index 0000000..6393118 --- /dev/null +++ b/content/world/canon/duncarrow.json @@ -0,0 +1,10 @@ +{ + "body": "Duncarrow is a walled market town at the edge of the Tallow Reach, grown fat on the road trade between the lowland grain towns and the pass. Timber and wool go out; salt, iron, and coin come in. Ten militia hold the walls under the mayor's charter. It is, by every visible measure, a prosperous and orderly place, which is exactly the reputation its mayor has spent fifteen years building and depends on.", + "id": "town.duncarrow", + "related": [ + "region.the-tallow-reach", + "place.the-white-antlers", + "person.mayor-oswin-crell" + ], + "type": "town" +} diff --git a/content/world/canon/elves.json b/content/world/canon/elves.json new file mode 100644 index 0000000..27e098b --- /dev/null +++ b/content/world/canon/elves.json @@ -0,0 +1,6 @@ +{ + "body": "The elven travellers of the road \u2014 no single polity, but the people whose passage through the White Antlers, and its recent absence, the town notices.", + "id": "faction.elves", + "related": [], + "type": "faction" +} diff --git a/content/world/canon/mayor-oswin-crell.json b/content/world/canon/mayor-oswin-crell.json new file mode 100644 index 0000000..530383f --- /dev/null +++ b/content/world/canon/mayor-oswin-crell.json @@ -0,0 +1,8 @@ +{ + "body": "Mayor of Duncarrow for fifteen years. Warm, unhurried, remembers names and the names of your dead. Runs a clean council and a clean set of books. Widely liked. The visible locked door of this town: pleasant to every player at every disposition, and revealing of nothing.", + "id": "person.mayor-oswin-crell", + "related": [ + "town.duncarrow" + ], + "type": "person" +} diff --git a/content/world/canon/the-tallow-reach.json b/content/world/canon/the-tallow-reach.json new file mode 100644 index 0000000..cde71d6 --- /dev/null +++ b/content/world/canon/the-tallow-reach.json @@ -0,0 +1,6 @@ +{ + "body": "The Tallow Reach \u2014 the lowland march of grain towns and wool roads that Duncarrow sits at the edge of, feeding the pass trade.", + "id": "region.the-tallow-reach", + "related": [], + "type": "region" +} diff --git a/content/world/canon/the-white-antlers.json b/content/world/canon/the-white-antlers.json new file mode 100644 index 0000000..9195fe6 --- /dev/null +++ b/content/world/canon/the-white-antlers.json @@ -0,0 +1,9 @@ +{ + "body": "An old elven shrine a half-hour's walk north of Duncarrow, in the birch stand off the pass road. Weathered antler-carvings, a dry basin, nothing worth stealing. Elves on the road have historically stopped here. Locals know it as a curiosity and a good spot to water horses.", + "id": "place.the-white-antlers", + "related": [ + "town.duncarrow", + "faction.elves" + ], + "type": "place" +} diff --git a/content/world/npcs/harn-blackwood.json b/content/world/npcs/harn-blackwood.json new file mode 100644 index 0000000..7fe1ce9 --- /dev/null +++ b/content/world/npcs/harn-blackwood.json @@ -0,0 +1,18 @@ +{ + "id": "npc.harn-blackwood", + "knows": [ + { + "fact_id": "rumor.travelers-go-missing", + "gate": "warm" + }, + { + "fact_id": "fact.harn-daughter-left", + "gate": "warm" + } + ], + "related": [ + "town.duncarrow" + ], + "start_disposition": "neutral", + "type": "person" +} diff --git a/content/world/npcs/mayor-oswin-crell.json b/content/world/npcs/mayor-oswin-crell.json new file mode 100644 index 0000000..f55a8b2 --- /dev/null +++ b/content/world/npcs/mayor-oswin-crell.json @@ -0,0 +1,23 @@ +{ + "id": "npc.mayor-oswin-crell", + "knows": [ + { + "fact_id": "rumor.elves-avoid-the-shrine", + "gate": "cold" + }, + { + "fact_id": "secret.crell-runs-slave-trade", + "gate": "never" + }, + { + "fact_id": "fact.crells-guard-acts-alone", + "gate": "never" + } + ], + "related": [ + "person.mayor-oswin-crell", + "secret.crell-runs-slave-trade" + ], + "start_disposition": "neutral", + "type": "person" +} diff --git a/content/world/npcs/mera-fenn.json b/content/world/npcs/mera-fenn.json new file mode 100644 index 0000000..4bba56e --- /dev/null +++ b/content/world/npcs/mera-fenn.json @@ -0,0 +1,27 @@ +{ + "id": "npc.mera-fenn", + "knows": [ + { + "fact_id": "rumor.elves-avoid-the-shrine", + "gate": "neutral" + }, + { + "fact_id": "rumor.travelers-go-missing", + "gate": "warm" + }, + { + "fact_id": "fact.militia-never-investigates", + "gate": "warm" + }, + { + "fact_id": "fact.crells-guard-acts-alone", + "gate": "trusted" + } + ], + "related": [ + "town.duncarrow", + "fact.crells-guard-acts-alone" + ], + "start_disposition": "cold", + "type": "person" +} diff --git a/content/world/topics/crell-runs-slave-trade.json b/content/world/topics/crell-runs-slave-trade.json new file mode 100644 index 0000000..39648d7 --- /dev/null +++ b/content/world/topics/crell-runs-slave-trade.json @@ -0,0 +1,9 @@ +{ + "id": "secret.crell-runs-slave-trade", + "related": [ + "person.mayor-oswin-crell", + "place.the-white-antlers", + "faction.elves" + ], + "type": "secret" +} diff --git a/content/world/topics/crells-guard-acts-alone.json b/content/world/topics/crells-guard-acts-alone.json new file mode 100644 index 0000000..78384d1 --- /dev/null +++ b/content/world/topics/crells-guard-acts-alone.json @@ -0,0 +1,8 @@ +{ + "id": "fact.crells-guard-acts-alone", + "related": [ + "person.mayor-oswin-crell", + "fact.militia-never-investigates" + ], + "type": "fact" +} diff --git a/content/world/topics/elves-avoid-the-shrine.json b/content/world/topics/elves-avoid-the-shrine.json new file mode 100644 index 0000000..e42695f --- /dev/null +++ b/content/world/topics/elves-avoid-the-shrine.json @@ -0,0 +1,8 @@ +{ + "id": "rumor.elves-avoid-the-shrine", + "related": [ + "place.the-white-antlers", + "faction.elves" + ], + "type": "rumor" +} diff --git a/content/world/topics/harn-daughter-left.json b/content/world/topics/harn-daughter-left.json new file mode 100644 index 0000000..667ba70 --- /dev/null +++ b/content/world/topics/harn-daughter-left.json @@ -0,0 +1,7 @@ +{ + "id": "fact.harn-daughter-left", + "related": [ + "npc.harn-blackwood" + ], + "type": "fact" +} diff --git a/content/world/topics/militia-never-investigates.json b/content/world/topics/militia-never-investigates.json new file mode 100644 index 0000000..d60993a --- /dev/null +++ b/content/world/topics/militia-never-investigates.json @@ -0,0 +1,8 @@ +{ + "id": "fact.militia-never-investigates", + "related": [ + "town.duncarrow", + "rumor.travelers-go-missing" + ], + "type": "fact" +} diff --git a/content/world/topics/travelers-go-missing.json b/content/world/topics/travelers-go-missing.json new file mode 100644 index 0000000..c9bd06d --- /dev/null +++ b/content/world/topics/travelers-go-missing.json @@ -0,0 +1,8 @@ +{ + "id": "rumor.travelers-go-missing", + "related": [ + "town.duncarrow", + "place.the-white-antlers" + ], + "type": "rumor" +} diff --git a/tools/content_build/tests/test_duncarrow.py b/tools/content_build/tests/test_duncarrow.py new file mode 100644 index 0000000..ea4cf00 --- /dev/null +++ b/tools/content_build/tests/test_duncarrow.py @@ -0,0 +1,37 @@ +"""End-to-end: the real promoted Duncarrow bible builds green and routes the +Crell secret correctly (body server-only; the id ships in the gate skeleton).""" + +import json +from pathlib import Path + +from content_build.__main__ import _load_world, check +from content_build.emit import build_trees + +REPO = Path(__file__).resolve().parents[3] +LORE = REPO / "content" / "lore" +WORLD = REPO / "content" / "world" +SERVER = REPO / "content" / "server" + + +def test_duncarrow_builds_and_check_is_clean(): + assert check(LORE, WORLD, SERVER) == 0 + + +def test_secret_body_is_server_only(): + client, server = build_trees(_load_world(LORE)) + assert "topics/crell-runs-slave-trade.json" in server + # the secret's descriptive id ships in the gate skeleton (schema property)... + npc = client["npcs/mayor-oswin-crell.json"] + assert any(k["fact_id"] == "secret.crell-runs-slave-trade" + and k["gate"] == "never" for k in npc["knows"]) + # ...but its BODY never reaches any client artifact + blob = json.dumps(client) + assert "elf-slaving" not in blob and "sells them into slavery" not in blob + + +def test_mera_never_holds_the_secret(): + client, _ = build_trees(_load_world(LORE)) + fenn = client["npcs/mera-fenn.json"] + facts = {k["fact_id"] for k in fenn["knows"]} + assert "secret.crell-runs-slave-trade" not in facts + assert "fact.crells-guard-acts-alone" in facts From a160d3e9675aa0aa782f53ede199fbde50328ac4 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:46:55 -0500 Subject: [PATCH 09/12] feat(client): ContentDB loads canon/ + topics/ built dirs Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- client/scripts/content/content_db.gd | 8 ++++++++ client/tests/unit/test_content_db.gd | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/client/scripts/content/content_db.gd b/client/scripts/content/content_db.gd index 5c262ba..afa2e07 100644 --- a/client/scripts/content/content_db.gd +++ b/client/scripts/content/content_db.gd @@ -8,6 +8,8 @@ var locations: Dictionary = {} var npcs: Dictionary = {} var quests: Dictionary = {} var items: Dictionary = {} +var canon_entities: Dictionary = {} +var topics: Dictionary = {} static func default_content_root() -> String: @@ -29,6 +31,8 @@ func load_from(content_root: String) -> void: npcs = _load_dir(world.path_join("npcs")) quests = _load_dir(world.path_join("quests")) items = _load_dir(world.path_join("items")) + canon_entities = _load_dir(world.path_join("canon")) + topics = _load_dir(world.path_join("topics")) func _load_dir(dir_path: String) -> Dictionary: @@ -56,6 +60,10 @@ func has_location(id: String) -> bool: return locations.has(id) func has_npc(id: String) -> bool: return npcs.has(id) func has_quest(id: String) -> bool: return quests.has(id) func has_item(id: String) -> bool: return items.has(id) +func canon(id: String) -> Dictionary: return canon_entities.get(id, {}) +func topic(id: String) -> Dictionary: return topics.get(id, {}) +func has_canon(id: String) -> bool: return canon_entities.has(id) +func has_topic(id: String) -> bool: return topics.has(id) func companions() -> Array: diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index f237fb3..19eed5a 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -50,3 +50,19 @@ func test_null_quest_ref_resolves(): var o := _deserter() o["start_quest_id"] = null assert_eq(db.unresolved_refs(o), []) + + +func test_loads_canon_and_topics(): + assert_true(db.has_canon("town.duncarrow")) + assert_true(db.has_canon("place.the-white-antlers")) + assert_true(db.has_topic("rumor.travelers-go-missing")) + + +func test_topic_skeleton_has_no_body(): + var t: Dictionary = db.topic("secret.crell-runs-slave-trade") + assert_false(t.has("body")) # bodies are server-only + + +func test_legacy_npcs_still_load(): + assert_true(db.has_npc("fenn")) + assert_true(db.has_npc("npc.mera-fenn")) From c2d67be76a3309c57873a01ce55a1f073c11598f Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:51:11 -0500 Subject: [PATCH 10/12] ci: content-build tests + --check gate Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- .github/workflows/content-build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/content-build.yml diff --git a/.github/workflows/content-build.yml b/.github/workflows/content-build.yml new file mode 100644 index 0000000..50d7b90 --- /dev/null +++ b/.github/workflows/content-build.yml @@ -0,0 +1,19 @@ +name: content-build +on: + push: + paths: ["content/**", "tools/content_build/**", ".github/workflows/content-build.yml"] + pull_request: + paths: ["content/**", "tools/content_build/**"] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r tools/requirements-dev.txt + - name: Unit tests + run: PYTHONPATH=tools python -m pytest tools/content_build/tests -q + - name: Content freshness + validity + run: PYTHONPATH=tools python -m content_build --check From f84e55ab4bf856ac970f373d73e7224ba44dc9e1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 19:01:28 -0500 Subject: [PATCH 11/12] fix(content-build): --check orphan detection; canon secrecy-0 + value-type guards - --check now detects orphaned/stale build artifacts in build-owned dirs (client canon/ + topics/ fully owned; client npcs/ shared with legacy hand-authored files, only npc.* ids are treated as build-owned) - resolve() enforces the schema rule that canon entities must be secrecy 0 - resolve() and ladder_rungs() guard against malformed value TYPES (non-int secrecy, non-list related/rungs) raising BuildError instead of crashing Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- tools/content_build/__main__.py | 28 +++++++++++++++++++++++ tools/content_build/resolve.py | 9 ++++++++ tools/content_build/rungs.py | 2 +- tools/content_build/tests/test_cli.py | 19 +++++++++++++++ tools/content_build/tests/test_resolve.py | 21 +++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/tools/content_build/__main__.py b/tools/content_build/__main__.py index 5e958ee..ffa9ac8 100644 --- a/tools/content_build/__main__.py +++ b/tools/content_build/__main__.py @@ -3,6 +3,7 @@ parse -> model -> resolve -> emit. --check builds in memory and diffs against the on-disk committed JSON (stale-or-invalid -> exit 1).""" import argparse +import json import sys from pathlib import Path @@ -36,6 +37,31 @@ def _diff(path: Path, obj) -> list[str]: return [] +def _orphans(root, expected_relpaths: set, is_client: bool) -> list[str]: + """Build-owned .json files on disk that the build no longer produces. + Client npcs/ is shared with legacy hand-authored files (bare ids), so there + only generated npc.* files are treated as build-owned.""" + owned = ["canon", "topics", "npcs"] if is_client else ["npcs", "topics"] + out: list[str] = [] + for sub in owned: + d = Path(root) / sub + if not d.is_dir(): + continue + for f in sorted(d.glob("*.json")): + relpath = f"{sub}/{f.name}" + if relpath in expected_relpaths: + continue + if is_client and sub == "npcs": + try: + data = json.loads(f.read_text()) + except (OSError, ValueError): + continue + if not str(data.get("id", "")).startswith("npc."): + continue # legacy hand-authored file — not build-owned + out.append(f"orphan {f}") + return out + + def check(lore_dir, world_dir, server_dir) -> int: world = _load_world(lore_dir) client_files, server_files = build_trees(world) @@ -45,6 +71,8 @@ def check(lore_dir, world_dir, server_dir) -> int: stale += _diff(Path(world_dir) / relpath, obj) for relpath, obj in server_files.items(): stale += _diff(Path(server_dir) / relpath, obj) + stale += _orphans(world_dir, set(client_files.keys()), is_client=True) + stale += _orphans(server_dir, set(server_files.keys()), is_client=False) for s in stale: print("STALE:", s, file=sys.stderr) return 1 if stale else 0 diff --git a/tools/content_build/resolve.py b/tools/content_build/resolve.py index e778083..fa728bc 100644 --- a/tools/content_build/resolve.py +++ b/tools/content_build/resolve.py @@ -24,12 +24,21 @@ def resolve(entries: list[Entry]) -> World: by_id[e.id] = e for e in entries: + if isinstance(e.secrecy, bool) or not isinstance(e.secrecy, int): + raise BuildError("secrecy must be an integer", + source=e.source, entry_id=e.id) + if not isinstance(e.related, list): + raise BuildError("related must be a list", + source=e.source, entry_id=e.id) if e.status not in STATUSES: raise BuildError(f"illegal status '{e.status}'", source=e.source, entry_id=e.id) if e.namespace not in LEGAL_NAMESPACES: raise BuildError(f"illegal id namespace '{e.namespace}'", source=e.source, entry_id=e.id) + if e.is_canon_entity and e.secrecy != 0: + raise BuildError("canon entity must have secrecy 0", + source=e.source, entry_id=e.id) if e.namespace == "npc": if e.type != "person": raise BuildError("npc.* entries must have type 'person'", diff --git a/tools/content_build/rungs.py b/tools/content_build/rungs.py index 518470d..45bd908 100644 --- a/tools/content_build/rungs.py +++ b/tools/content_build/rungs.py @@ -13,7 +13,7 @@ def ladder_rungs(entries: list[Entry]) -> list[str]: if len(ladders) != 1: raise BuildError( f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}") - if not ladders[0].rungs: + if not isinstance(ladders[0].rungs, list) or not ladders[0].rungs: raise BuildError("disposition-ladder needs a non-empty 'rungs:' list", source=ladders[0].source, entry_id=LADDER_ID) return list(ladders[0].rungs) diff --git a/tools/content_build/tests/test_cli.py b/tools/content_build/tests/test_cli.py index 840a538..4d30bf9 100644 --- a/tools/content_build/tests/test_cli.py +++ b/tools/content_build/tests/test_cli.py @@ -52,3 +52,22 @@ def test_invalid_bible_fails_build(tmp_path): argv = ["--lore", str(lore), "--world", str(tmp_path / "w"), "--server", str(tmp_path / "s")] assert main(argv) == 1 + + +def test_check_detects_orphan_file(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "canon" / "ghost.json").write_text('{"id": "town.ghost"}\n') + assert main(argv + ["--check"]) == 1 + + +def test_check_ignores_legacy_npc_files(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "npcs").mkdir(parents=True, exist_ok=True) + (world / "npcs" / "legacy.json").write_text('{"id": "legacy"}\n') # bare id + assert main(argv + ["--check"]) == 0 # legacy file is not a build orphan diff --git a/tools/content_build/tests/test_resolve.py b/tools/content_build/tests/test_resolve.py index 6972b89..f7c87c6 100644 --- a/tools/content_build/tests/test_resolve.py +++ b/tools/content_build/tests/test_resolve.py @@ -131,3 +131,24 @@ def test_empty_rungs_rejected(): secrecy=0, related=[], body="x", source="t:1", rungs=[]) with pytest.raises(BuildError): resolve([empty]) + + +def test_canon_entity_nonzero_secrecy_rejected(): + bad = Entry(id="town.a", type="town", status="canon", secrecy=2, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_non_int_secrecy_raises(): + bad = Entry(id="town.a", type="town", status="canon", secrecy="high", + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_non_list_rungs_raises(): + bad_ladder = Entry(id="rule.disposition-ladder", type="rule", status="canon", + secrecy=0, related=[], body="x", source="t:1", rungs="trusted") + with pytest.raises(BuildError): + resolve([bad_ladder]) From c09b6e89a0b126e334f567b5da6194c55808e1c1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 19:06:00 -0500 Subject: [PATCH 12/12] test(content-build): cover generated-npc orphan detection in shared npcs dir --- tools/content_build/tests/test_cli.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/content_build/tests/test_cli.py b/tools/content_build/tests/test_cli.py index 4d30bf9..e2bb1ad 100644 --- a/tools/content_build/tests/test_cli.py +++ b/tools/content_build/tests/test_cli.py @@ -71,3 +71,13 @@ def test_check_ignores_legacy_npc_files(tmp_path): (world / "npcs").mkdir(parents=True, exist_ok=True) (world / "npcs" / "legacy.json").write_text('{"id": "legacy"}\n') # bare id assert main(argv + ["--check"]) == 0 # legacy file is not a build orphan + + +def test_check_detects_orphan_generated_npc(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "npcs").mkdir(parents=True, exist_ok=True) + (world / "npcs" / "ghost.json").write_text('{"id": "npc.ghost"}\n') + assert main(argv + ["--check"]) == 1 # a generated npc.* orphan in the shared dir IS caught