"""The Sticknife pantheon: who the gods are, what they run, and who they live with. This module holds the *shape* of the pantheon. The words live one file per deity in ``templates/pantheon/`` — ``charon.toml``, ``hermes.toml``, and so on — so rewriting copy never means editing layout code. To change what a god says, edit its file; the change shows up on the next page load without a restart, the same as an HTML template. It feeds three things: * ``payload()`` — JSON embedded in the page for the force graph to lay out * ``fallback()`` — server-rendered markup shown when JavaScript does not run * ``tally()`` — a count line, if a page wants one A member's seal is not declared anywhere; it is discovered. Drop a new master into media/pantheon/, run ``scripts/build_pantheon_assets.py``, and the deity stops rendering as an empty niche and starts wearing its seal — no code change. Houses are functional groups. ``RING_ORDER`` sets the order they sit in, going clockwise, and ``RING_ANCHORS`` pins whichever of them have to land somewhere exact; the rest spread evenly through the arc between two anchors. Reorder that list to reorder the pantheon — the angles are derived, so nothing else needs touching and adding or dropping a house re-spaces the ring on its own. The house marked ``center`` is the exception: bare metal sits at the middle and does not orbit anything. A house may name a ``hub``: the member that sits at the middle of the family, with the rest hanging off it. Without one, a house with a ``role="host"`` member uses that instead, and a house with neither links every member to every other, which leaves nobody in particular at the middle. Tenants always hang off whatever they name in ``inside``, hub or not, so choosing a hub rearranges a family without breaking who lives with whom. Which mythology a name comes from is load-bearing in the layout, not trivia: the Greeks are sprung toward the ring, while the Mesopotamian names are pushed gently outward off it. Those four are exactly the tenants — Nabu on Hermes, Nanshe on Xenia, Uttu on Plutus, Gibil on Astrape — so every borrowed god hangs outside the circle, tethered to the Greek whose metal it lives on. """ from __future__ import annotations import html import json import tomllib from pathlib import Path HERE = Path(__file__).resolve().parent ART = HERE / "static" / "img" / "pantheon" ART_URL = "/static/img/pantheon" #: One file per deity, named for its key. COPY = HERE / "templates" / "pantheon" #: Fields a copy file may leave out, and what they mean when it does. A file with no ``house`` #: belongs to none and is not shown; no ``order`` puts it at the end of whatever house it names. OPTIONAL = {"host": "", "runs": "", "role": "", "inside": "", "house": "", "order": 99} #: Borrowed from Sumer and Babylon rather than Greece. The four on the ring are each a resident #: on a Greek host's metal, which is why the layout pushes them outside it; the Abzu names never #: touch the ring's physics at all, since their house lives in a drawer of its own. MESOPOTAMIAN = {"nabu", "nanshe", "uttu", "gibil", "marduk", "enki", "isimud", "kulla", "nisaba"} #: The northern names. They do not orbit the pantheon at all — they live in a panel of their #: own off the right-hand side, so the ring never has to make room for them. NORSE = {"thor", "garmr", "ratatoskr", "kari", "mimir", "bifrost", "yggdrasil"} def origin(key: str) -> str: if key in MESOPOTAMIAN: return "mesopotamian" if key in NORSE: return "norse" return "greek" #: Going round the ring, clockwise. This list is the only thing that decides the order — swap #: two ids to swap two houses, insert one and the ring re-spaces itself. Any house left out of #: it (the machines) does not orbit. RING_ORDER = [ "media", # at the top "message", # flanking one side "count", "access", # the gate, at the bottom "power", "make", # crafting, flanking the other side of media ] #: Houses pinned to an exact angle, in degrees clockwise from twelve o'clock: -90 is the top, #: 0 the right, 90 the bottom. Anything not pinned spreads evenly through the arc between its #: neighbouring anchors, keeping RING_ORDER. Pin nothing and the whole ring spaces evenly from #: the top. With six houses the anchors cost nothing — media sits three seats from access, so #: holding both top and bottom still leaves the rest exactly 60 degrees apart. At counts where #: 180 is not a multiple of 360/n the two anchors win and the arcs either side compress. RING_ANCHORS = { "media": -90, # media at twelve o'clock, flanked by make and message "access": 90, # the gate at the bottom } #: The order the families stack in on a narrow screen, where the ring gives way to a column of #: blocks. Deliberately separate from RING_ORDER: what reads well going clockwise is not what #: reads well scrolling down, and the ring has two houses that are not on it at all. Anything #: left out of this keeps its place from HOUSES, after everything that is listed. It is applied #: only under the mobile breakpoint, so reordering here cannot disturb the ring. STACK_ORDER = [ "media", "message", "make", "count", "access", "power", "control", "abzu", "norse", ] #: The houses themselves. Who is *in* each one is not listed here — every copy file names the #: house it belongs to and an ``order`` for where it sits along the ring, so adding a deity is #: dropping in a file. ``bias`` nudges a whole family along the ring in seats, trimming the #: lean a one-sided tenant puts on its kin. HOUSES: list[dict] = [ {"id": "access", "name": "policy & access", "hub": "nomos"}, {"id": "make", "name": "creation"}, {"id": "message", "name": "communication"}, {"id": "count", "name": "finance"}, {"id": "power", "name": "systems"}, { "id": "media", "name": "media", "hub": "dionysus", # Nanshe sits out beyond Xenia with nothing on the far side to answer her, and the push # of her leans the rest of the house anticlockwise; this puts Dionysus back on twelve. "bias": 0.1, }, { "id": "control", "name": "control", "center": True, # the machines do not orbit; they are the middle }, { "id": "abzu", "name": "abzu", "side": True, "hub": "marduk", }, { "id": "norse", "name": "norse", # Neither on the ring nor at the middle: this one lives in a drawer off the right edge # and is drawn by its own little simulation, so it never competes for ring seats. "side": True, "hub": "thor", # sits above the rest of the cluster, and larger }, ] # --------------------------------------------------------------------------------- the copy #: key -> (mtime, parsed). Re-read only when a file actually changes, so editing copy shows up #: on the next request without a restart but a page view is not nineteen parses. _copy: dict[str, tuple[int, dict]] = {} def paragraphs(text: str) -> list[str]: """Split prose on blank lines, one entry per paragraph. A blank line starts a new paragraph; a single line break is just where the author happened to wrap, and is undone. So copy can be hard-wrapped in the file to stay readable there without the wrapping meaning anything on the page. """ out = [] for block in text.split("\n\n"): joined = " ".join(block.split()) if joined: out.append(joined) return out def member(key: str) -> dict: """One deity, read from its copy file. A file being mid-edit should not take the page down: if it will not parse and we have read it successfully before, the last good version is served and the bad one simply does not take. With nothing cached to fall back on there is nothing to serve, and the error stands. """ path = COPY / f"{key}.toml" cached = _copy.get(key) try: stamp = path.stat().st_mtime_ns except OSError: if cached: return cached[1] raise if cached and cached[0] == stamp: return cached[1] try: with path.open("rb") as handle: loaded = tomllib.load(handle) except (tomllib.TOMLDecodeError, OSError): if cached: return cached[1] raise entry = {**OPTIONAL, **loaded, "key": key} entry["does"] = " ".join(entry.get("does", "").split()) entry["long"] = paragraphs(entry.get("long", "")) _copy[key] = (stamp, entry) return entry def roster() -> dict[str, list[str]]: """House id to member keys, in the order they sit along the ring. Membership is read from the copy files rather than declared here: each names its ``house`` and an ``order``, low to high. A file naming a house that does not exist is left out — the houses are the fixed part, and a typo should cost one deity rather than the page. """ grouped: dict[str, list[tuple[int, str]]] = {house["id"]: [] for house in HOUSES} for path in sorted(COPY.glob("*.toml")): key = path.stem who = member(key) if who["house"] in grouped: grouped[who["house"]].append((who["order"], key)) # ties break on key, so the order is stable whatever the filesystem hands back return {hid: [key for _, key in sorted(rows)] for hid, rows in grouped.items()} def _members() -> list[dict]: seats = roster() return [member(key) for house in HOUSES for key in seats[house["id"]]] def has_seal(key: str) -> bool: """Whether the asset build has produced art for this deity yet.""" return (ART / f"{key}-node.webp").is_file() def ring_angles() -> dict[str, float]: """Each ring house's angle in degrees clockwise from twelve o'clock. Pinned houses sit exactly where ``RING_ANCHORS`` puts them; the rest are spread evenly around the arc between whichever anchors they fall between, in ``RING_ORDER``. With no anchors at all this is a plain even ring starting at the top. """ def tidy(deg: float) -> float: return round(((deg + 180.0) % 360.0) - 180.0, 1) count = len(RING_ORDER) anchors = [(i, RING_ANCHORS[h]) for i, h in enumerate(RING_ORDER) if h in RING_ANCHORS] if not anchors: step = 360.0 / count return {h: tidy(-90.0 + i * step) for i, h in enumerate(RING_ORDER)} angles: dict[str, float] = {} for n, (index, degrees) in enumerate(anchors): angles[RING_ORDER[index]] = tidy(degrees) next_index, next_degrees = anchors[(n + 1) % len(anchors)] between = (next_index - index) % count or count # houses in this arc, plus one span = (next_degrees - degrees) % 360 or 360.0 # clockwise sweep to the next for step in range(1, between): angles[RING_ORDER[(index + step) % count]] = tidy(degrees + span * step / between) return angles def data() -> list[dict]: """The houses, with copy read in and art URLs filled in for whichever seals exist.""" angles = ring_angles() seats = roster() out = [] for house in HOUSES: members = [] for key in seats[house["id"]]: art = has_seal(key) entry = { **member(key), "origin": origin(key), "node": f"{ART_URL}/{key}-node.webp" if art else "", "icon": f"{ART_URL}/{key}-icon.webp" if art else "", "seal": f"{ART_URL}/{key}-seal.webp" if art else "", } if house.get("hub") == key: entry["hub"] = True members.append(entry) shape = {"id": house["id"], "name": house["name"], "members": members} if house.get("center"): shape["center"] = True elif house.get("side"): shape["side"] = True else: shape["angle"] = angles.get(house["id"]) if "bias" in house: shape["bias"] = house["bias"] out.append(shape) return out def payload() -> str: """JSON for the page, safe to drop inside a