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"}]))