feat(content-build): typed Entry model + category predicates
This commit is contained in:
76
tools/content_build/model.py
Normal file
76
tools/content_build/model.py
Normal file
@@ -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"),
|
||||||
|
)
|
||||||
50
tools/content_build/tests/test_model.py
Normal file
50
tools/content_build/tests/test_model.py
Normal file
@@ -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"}]))
|
||||||
Reference in New Issue
Block a user