initial commit
This commit is contained in:
+400
@@ -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)
|
||||
Reference in New Issue
Block a user