initial commit

This commit is contained in:
ryan
2026-08-15 11:15:31 +02:00
commit b05c7310a6
231 changed files with 5311 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
"""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 <script> element."""
raw = json.dumps(data(), separators=(",", ":"))
return raw.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
def tally() -> str:
members = _members()
struck = sum(1 for m in members if has_seal(m["key"]))
answering = sum(1 for m in members if m["state"] in ("live", "here", "locked"))
return f"{len(members)} named · {struck} seals struck · {answering} answering"
def stack_rank() -> dict[str, int]:
"""Each house's place in the stacked view, by id.
STACK_ORDER first, in the order given; anything it does not mention keeps its HOUSES order
behind them, so a house added to HOUSES still appears without having to be listed twice.
"""
rank = {hid: i for i, hid in enumerate(STACK_ORDER)}
tail = len(STACK_ORDER)
for house in HOUSES:
if house["id"] not in rank:
rank[house["id"]] = tail
tail += 1
return rank
def action(m: dict) -> str:
"""The one thing you can do with a deity, if anything."""
if m["state"] == "locked":
return '<a class="sn-btn" href="/auth/start?next=/profile">Request access</a>'
if m["state"] == "here":
return '<a class="sn-btn sn-btn-quiet" href="/profile">Your account</a>'
if m["state"] == "machine":
return ""
if not m["host"]:
# only claim a thing is unbuilt if it says so itself
return ('<span class="sn-btn sn-btn-quiet">Not built yet</span>'
if m["state"] == "planned" else "")
host = html.escape(m["host"], quote=True)
return f'<a class="sn-btn" href="https://{host}">Open {host}</a>'
def fallback() -> str:
"""The pantheon as stacked blocks: one section per house, each member a card that opens.
This is three things at once. It is what anyone without JavaScript gets; it is what the
page falls back to if the graph cannot start; and it is the whole layout on a narrow
screen, where a force-directed ring of thirty-two seals is unreadable and a list of
families is not. Being server-rendered, that last one costs a phone no physics at all —
the stylesheet simply shows this instead of the field.
Everything the detail panel shows is here too, folded into a <details> per member, so the
small screen never needs the panel to slide over it.
"""
def esc(value: object) -> str:
return html.escape(str(value), quote=True)
seats = roster()
names = {m["key"]: m["name"] for m in _members()}
rank = stack_rank()
# ships hidden: the stylesheet reveals it on a narrow screen, the noscript rule reveals it
# when scripts are off, and pantheon.js reveals it if the graph cannot start
parts = ['<div class="sn-fallback" id="sn-fallback" hidden>']
for house in HOUSES:
parts.append(f'<section class="sn-family" data-house="{esc(house["id"])}"'
f' style="--stack:{rank[house["id"]]}">'
f'<h2>{esc(house["name"])}</h2><ul>')
for key in seats[house["id"]]:
m = member(key)
art = has_seal(key)
face = (f'<img src="{ART_URL}/{key}-node.webp" alt="" width="240" height="240"'
f' loading="lazy" decoding="async">' if art
else f'<span class="sn-empty">{esc(m["name"][:1])}</span>')
kin = [names[k] for k in seats[house["id"]]
if k != key and member(k)["inside"] == m["inside"]]
below = [names[k] for k in seats[house["id"]] if member(k)["inside"] == key]
spec = [f"<dt>house</dt><dd>{esc(house['name'])}</dd>"]
if m["inside"]:
spec.append(f"<dt>lives with</dt><dd>{esc(names[m['inside']])}</dd>")
if kin:
spec.append(f"<dt>siblings</dt><dd>{esc(', '.join(kin))}</dd>")
if below:
spec.append(f"<dt>descendants</dt><dd>{esc(', '.join(below))}</dd>")
if m["host"]:
spec.append(f"<dt>host</dt><dd>{esc(m['host'])}</dd>")
spec.append(f"<dt>status</dt><dd>{esc(m['label'])}</dd>")
body = "".join(f"<p>{esc(para)}</p>" for para in m["long"])
parts.append(
f'<li><details class="sn-card" data-state="{esc(m["state"])}">'
f'<summary><span class="sn-card-face">{face}</span>'
f'<span class="sn-card-head"><b>{esc(m["name"])}</b>'
f'<span class="sn-card-domain">{esc(m["domain"])}</span>'
f'<span class="sn-card-does">{esc(m["does"])}</span></span></summary>'
f'<div class="sn-card-body">{body}'
f'<dl class="sn-spec">{"".join(spec)}</dl>'
f'<div class="sn-actions">{action(m)}</div></div>'
f'</details></li>')
parts.append("</ul></section>")
parts.append("</div>")
return "".join(parts)