Files
999/scripts/rebuild_rune_intersections.py

267 lines
11 KiB
Python

#!/usr/bin/env python3
"""Expand rune clusters, restore ghost primaries, stamp [Руны: …] on the 1000."""
from __future__ import annotations
import json
import re
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CANON = ROOT / "oracle_enricher" / "data" / "traditions_canon.json"
ASSET = ROOT / "assets" / "data" / "traditions_canon.json"
LEX = ROOT / "oracle_enricher" / "data" / "lexicon.json"
DART = ROOT / "lib" / "data" / "words_data.dart"
HTML = ROOT / "lexicon_viewer.html"
MD = ROOT / "words_alphabetical.md"
RUNE_TAG_RE = re.compile(r"\[Руны:[^\]]*\]\s*")
# Extra lexicon words on top of the station primary.
CLUSTER_OVERRIDE = {
"fehu": ["изобилие", "полнота", "успех", "благословение", "рост", "начало"],
"uruz": ["сила", "тело", "жизнь", "воля", "желание", "кровь"],
"jera": ["осень", "цикл", "год", "труд", "семя", "плод", "хлеб"],
"perthro": ["тайна", "игра", "случай", "судьба", "завеса", "сосуд", "жребий", "кость"],
"tiwaz": ["отец", "клятва", "закон", "мужчина", "меч", "копьё", "судья"],
"laguz": ["вода", "глубина", "волна", "девушка", "море", "река", "родник", "озеро", "ручей"],
}
# Viewer/lexicon name -> live dart name
WORD_REMAP = {
"солнцестояние": "солнцеворот",
}
# Live dart still has frequency-list verbs on cells whose canon primary is a sacred noun.
DART_RESTORE = {
"глядеть": "золото",
"забыть": "тёрн",
"возникнуть": "град",
"вспомнить": "нужда",
"возвращаться": "урожай",
}
RESTORE_META = {
"золото": (
"существительное",
"Священный Предмет",
"Совершенный солнечный металл алхимии, нетленный свет, царский венец и мера высшей ценности. "
"Золото не ржавеет в земле и не тускнеет от времени, знаменуя победу духа над распадом материи. "
"В раскладе это знак высшего успеха, обретения нетленного сокровища мудрости и кристальной чистоты намерения.",
),
"тёрн": (
"существительное",
"Сакральный Символ",
"Черный шип неизбежности, страж предела, колючая ограда рока, через суровость ведущая к очищению духа.",
),
"град": (
"существительное",
"Образ / Архетип",
"Стихийное ледяное зерно с небес, внезапный сокрушительный удар бури, ломающий старые формы ради очищения.",
),
"нужда": (
"существительное",
"Образ / Архетип",
"Священный крест необходимости, стеснение рока, из которого высекается внутренний огонь спасения и несгибаемая стойкость.",
),
"урожай": (
"существительное",
"Образ / Архетип",
"Благодатный сбор плодов долгого труда, венчание годового цикла, щедрость земли и торжество плодородия.",
),
}
def tag_for(st: dict) -> str:
name = st["name"]
symbol = st.get("symbol", "")
group = st.get("group", "")
if " (" in name:
ru, rest = name.split(" (", 1)
en = rest.rstrip(")")
return f"[Руны: {group} {ru} ({en} / {symbol})]"
return f"[Руны: {group} {name}]"
def restore_dart(text: str) -> str:
for old, new in DART_RESTORE.items():
pos, cat, desc = RESTORE_META[new]
desc_esc = desc.replace("\\", "\\\\").replace('"', '\\"')
def repl(m: re.Match, new=new, pos=pos, cat=cat, desc_esc=desc_esc) -> str:
return (
f"{m.group(1)}{new}{m.group(2)}"
f"{m.group(3)}{pos}{m.group(4)}"
f"{m.group(5)}{cat}{m.group(6)}"
f"{m.group(7)}{desc_esc}{m.group(8)}"
)
pat = re.compile(
rf'(word: "){re.escape(old)}(",\s*\n)'
rf'(\s*partOfSpeech: ")[^"]+(",\s*\n)'
rf'(\s*frequency: [^\n]+\n\s*category: ")[^"]+(",\s*\n)'
rf'(\s*description: ")[^"]*(")',
)
if f'word: "{new}",' in text and f'word: "{old}",' not in text:
continue
text2, n = pat.subn(repl, text, count=1)
if n != 1:
raise SystemExit(f"failed to restore {old} -> {new} (n={n})")
text = text2
return text
def payload_from(word: str, dart_id: int, lex_by_word: dict, old: dict | None) -> dict:
lex = lex_by_word.get(word) or {}
old = old or {}
return {
"id": dart_id,
"word": word,
"emoji": lex.get("emoji") or old.get("emoji") or "",
"pos": lex.get("pos") or old.get("pos") or "существительное",
"category": lex.get("category") or old.get("category") or "",
"desc": old.get("desc") or "",
}
def main() -> None:
dart_text = restore_dart(DART.read_text(encoding="utf-8"))
DART.write_text(dart_text, encoding="utf-8")
dart_by_word: dict[str, int] = {}
dart_pos: dict[str, str] = {}
dart_cat: dict[str, str] = {}
for m in re.finditer(
r'WordItem\(\s*id:\s*(\d+),\s*word:\s*"([^"]+)",\s*'
r'partOfSpeech:\s*"([^"]+)",\s*frequency:\s*[^\n]+\n\s*category:\s*"([^"]+)"',
dart_text,
):
dart_by_word[m.group(2)] = int(m.group(1))
dart_pos[m.group(2)] = m.group(3)
dart_cat[m.group(2)] = m.group(4)
lex_list = json.loads(LEX.read_text(encoding="utf-8"))
lex_by_word = {e["word"]: e for e in lex_list}
canon = json.loads(CANON.read_text(encoding="utf-8"))
runes = canon["runes"]
word_to_tags: dict[str, list[str]] = defaultdict(list)
for st in runes["stations"]:
sid = st["id"]
tag = tag_for(st)
prim = st.get("primary") or {}
prim_w = prim.get("word")
if prim_w in WORD_REMAP:
prim_w = WORD_REMAP[prim_w]
if prim_w not in dart_by_word:
raise SystemExit(f"missing primary {sid}/{prim_w} in dart")
st["primary"] = payload_from(prim_w, dart_by_word[prim_w], lex_by_word, prim)
# keep the existing primary desc (rune lore) if present
if prim.get("desc"):
st["primary"]["desc"] = prim["desc"]
old_cluster = {c.get("word"): c for c in st.get("cluster") or []}
if sid in CLUSTER_OVERRIDE:
extra_names = CLUSTER_OVERRIDE[sid]
else:
extra_names = [c.get("word") for c in st.get("cluster") or [] if c.get("word")]
extra: list[str] = []
for w in extra_names:
w = WORD_REMAP.get(w, w)
if w == prim_w or w not in dart_by_word or w in extra:
continue
extra.append(w)
st["cluster"] = [
payload_from(w, dart_by_word[w], lex_by_word, old_cluster.get(w))
for w in extra
]
word_to_tags[prim_w].append(tag)
for w in extra:
word_to_tags[w].append(tag)
text = json.dumps(canon, ensure_ascii=False, indent=2) + "\n"
CANON.write_text(text, encoding="utf-8")
ASSET.write_text(text, encoding="utf-8")
dart_lines = dart_text.splitlines(keepends=True)
nstamp = 0
i = 0
while i < len(dart_lines):
wm = re.match(r'(\s*word: ")([^"]+)(",\s*)$', dart_lines[i])
if wm:
word = wm.group(2)
for j in range(i + 1, min(i + 12, len(dart_lines))):
raw = dart_lines[j].rstrip("\n")
dm = re.match(r'^(\s*description: ")(.*)(",)\s*$', raw)
if dm:
body = RUNE_TAG_RE.sub("", dm.group(2)).strip()
tags = word_to_tags.get(word)
if tags:
dart_lines[j] = f"{dm.group(1)}{' '.join(tags)} {body}{dm.group(3)}\n"
nstamp += 1
else:
dart_lines[j] = f"{dm.group(1)}{body}{dm.group(3)}\n"
break
if dart_lines[j].strip().startswith("WordItem("):
break
i += 1
dart2 = "".join(dart_lines)
DART.write_text(dart2, encoding="utf-8")
html = HTML.read_text(encoding="utf-8")
start = html.find(" const TRADITIONS_CANON = ")
end = html.find(" const WORDS_DATA = ")
if start > 0 and end > start:
html = (
html[:start]
+ " const TRADITIONS_CANON = "
+ json.dumps(canon, ensure_ascii=False, indent=2)
+ ";\n\n"
+ html[end:]
)
m = re.search(r"const WORDS_DATA = (\[.*?\]);", html, flags=re.DOTALL)
if not m:
raise SystemExit("WORDS_DATA not found")
words = json.loads(m.group(1))
dart_descs = dict(re.findall(r'word: "([^"]+)"[\s\S]*?description: "([^"]*)"', dart2))
for w in words:
wd = w.get("word")
if wd in dart_descs:
w["description"] = dart_descs[wd]
html = html[: m.start(1)] + json.dumps(words, ensure_ascii=False) + html[m.end(1) :]
HTML.write_text(html, encoding="utf-8")
if MD.exists():
md = MD.read_text(encoding="utf-8")
def repl_block(mm: re.Match) -> str:
word = mm.group(1)
body = mm.group(3)
if word not in dart_descs:
return mm.group(0)
body2 = re.sub(
r"\* \*\*Толкование:\*\* .+",
f"* **Толкование:** {dart_descs[word]}",
body,
count=1,
)
return f"### {word} `{mm.group(2)}`\n{body2}"
md2 = re.sub(
r"### ([^\s`]+) `(#[^`]+)`\n([\s\S]*?)(?=\n### |\n## |\Z)",
repl_block,
md,
)
MD.write_text(md2, encoding="utf-8")
print(f"stamped {nstamp} dart words, tagged {len(word_to_tags)}")
for st in runes["stations"]:
prim = (st.get("primary") or {}).get("word")
cl = [c["word"] for c in st.get("cluster") or []]
print(f" {st['id']:12} prim={prim:12} n={len(cl)} {cl}")
if __name__ == "__main__":
main()