#!/usr/bin/env python3 """Rebuild alchemy atlas: real groups, metals, denser clusters, [Алхимия:] stamps.""" 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" YAML = ROOT / "oracle_enricher" / "data" / "sources" / "alchemy.yaml" ALCHEMY_TAG_RE = re.compile(r"\[Алхимия:[^\]]*\]\s*") DART_RESTORE = { "ладан": "серебро", "мирра": "ртуть", "старик": "медь", "парень": "железо", "девочка": "олово", "дома": "свинец", "маленький": "жёлтый", } RESTORE_META = { "серебро": ( "существительное", "Священный Предмет", "Чистый металл Луны, зеркало души и холодный проводник ночного света. " "Серебро отталкивает ложь, очищает темные воды и хранит память о первозданной чистоте. " "В раскладе говорит о главенстве интуиции, необходимости отразить реальность без искажений и довериться тонким предчувствиям.", ), "ртуть": ( "существительное", "Священный Предмет", "Жидкий металл алхимиков, живое серебро, неуловимый дух связи и трансмутации. " "Ртуть не удерживается пальцами — она скользит между состояниями, связывая плотное и тонкое. " "Знак указывает на высокую адаптивность, необходимость быстрой реакции и способность переплавить тупик в новое качество.", ), "медь": ( "существительное", "Священный Предмет", "Теплый металл Венеры, проводник сердечного огня, сплавов и созидательного ремесла. " "Медь придает устойчивость нежным формам, гармонизирует среду и резонирует с живой вибрацией. " "В раскладе знаменует союз, целебное тепло, раскрытие творческого дара и плодотворное сотрудничество.", ), "железо": ( "существительное", "Священный Предмет", "Кровный металл Марса, основа плуга, меча и несокрушимой брони. " "Железо куется только в сильном жаре и закаляется в воде, воплощая несгибаемую стойкость и тяжелый труд. " "Знак требует твердости характера, практического упорства и готовности защищать рубежи своего мира.", ), "олово": ( "существительное", "Священный Предмет", "Звонкий металл Юпитера, податливый для пайки, соединяющий разрозненные элементы в единое целое. " "Олово воплощает верность слову, мягкую силу благородства и способность соединить то, что казалось несовместимым. " "В раскладе указывает на примирение, надежный союз и доброе посредничество.", ), "свинец": ( "существительное", "Священный Предмет", "Тяжелая первоматерия Сатурна, темная руда предела, экранирующая любые лучи. " "Свинец символизирует грубое бремя материи, непроницаемость и точку наибольшего сгущения духа. " "Знак указывает на тяжесть долга, кармический груз или замедление; именно в свинце сокрыто зерно будущего золота.", ), "жёлтый": ( "прилагательное", "Свойство / Качество", "Цвет полуденного солнца, золота интеллекта, проницательности и озарения. " "Жёлтый рассеивает туман неопределенности, высвечивая логику вещей и даруя ясность взгляда. " "В раскладе указывает на необходимость ясного понимания, ученичества, передачи знания и трезвого расчета.", ), } # id, name, symbol, group, lore, primary, cluster STATIONS = [ ( "nigredo", "Нигредо (Чернота)", "⬛ 💀", "Magnum Opus · Стадия 1", "Чёрная работа: гниение старой формы, смерть эго, погружение в первоматерию. " "Пока тело Делания не станет пеплом, золото не начнётся.", "смерть", ["тьма", "пепел", "черный", "хаос", "ворон"], ), ( "albedo", "Альбедо (Белизна)", "⬜ 🕊️", "Magnum Opus · Стадия 2", "Лунное омовение: смывание шлака, серебряный свет, кристальная чистота. " "После черноты душа промывается до белизны, в которой уже можно различить форму.", "белый", ["чистота", "луна", "кристалл", "роса"], ), ( "citrinitas", "Цитринитас (Желтизна)", "🟨 🌅", "Magnum Opus · Стадия 3", "Солнечный рассвет Делания: свинец желтеет, разум просыпается, золото ещё не красное, но уже светит. " "Стадия озарения, ученичества и созревания света.", "рассвет", ["свет", "солнце", "золото", "жёлтый"], ), ( "rubedo", "Рубедо (Краснота)", "🟥 👑", "Magnum Opus · Стадия 4", "Венец Великого Делания: Философский Камень, Феникс из пепла, соединение духа и плоти. " "Красное золото — не металл витрины, а целостность, прошедшая смерть.", "красный", ["камень", "феникс", "корона", "золотой"], ), ( "fire", "Огонь (Ignis)", "🔥 ⚡", "Стихии · Огонь", "Сухой активный жар Делания, мужской принцип, очищение и горение. " "Без огня нет ни гниения, ни камня — только сырая материя.", "огонь", ["пламя", "искра", "жар"], ), ( "water", "Вода (Aqua)", "💧 🌊", "Стихии · Вода", "Универсальный растворитель (Alkahest), влажный женский принцип, колыбель тайн. " "Вода растворяет форму, чтобы её можно было собрать заново.", "вода", ["волна", "роса", "луна"], ), ( "air", "Воздух (Aer)", "💨 🕊️", "Стихии · Воздух", "Летучий медиатор между небом и землёй, дыхание духа, связующая среда мысли. " "Воздух носит ртуть: без него металлы не сообщаются.", "воздух", ["ветер", "дыхание"], ), ( "earth", "Земля (Terra)", "🌍 🪨", "Стихии · Земля", "Плотная опора воплощения, сосуд созревания, соль формы. " "Земля держит то, что огонь, вода и воздух только обещают.", "земля", ["камень", "кристалл", "соль"], ), ( "gold", "Золото (Sol)", "🪙 ☀️", "Металлы · Солнце", "Солнечный металл совершенства, нетленный свет, цель Делания. " "Золото не ржавеет: знак высшей ценности и кристальной чистоты намерения.", "золото", ["солнце", "корона"], ), ( "silver", "Серебро (Luna)", "🥈 🌙", "Металлы · Луна", "Лунный металл души, зеркало без лжи, проводник ночного света. " "Серебро — белизна Альбедо, отражение, ещё не ставшее золотом.", "серебро", ["луна", "чистота"], ), ( "mercury", "Ртуть (Mercurius)", "💧 ⚗️", "Металлы · Меркурий", "Живое серебро, дух связи и трансмутации, одно из Трёх Начал. " "Ртуть скользит между состояниями и связывает серу с солью, дух с телом.", "ртуть", ["сосуд", "воздух"], ), ( "copper", "Медь (Venus)", "🥉 ♀️", "Металлы · Венера", "Тёплый металл Венеры, проводник сердечного огня и сплавов. " "Медь гармонизирует и соединяет то, что железо только режет.", "медь", ["сердце", "союз"], ), ( "iron", "Железо (Mars)", "⚙️ ⚔️", "Металлы · Марс", "Кровный металл Марса, плуг и меч, стойкость, выкованная в жаре. " "Железо требует удара и закалки — без них это только руда.", "железо", ["меч", "воин", "кузница"], ), ( "tin", "Олово (Jupiter)", "🪞 ⚡", "Металлы · Юпитер", "Звонкий металл Юпитера, мягкая пайка разрозненного в целое. " "Олово не куёт империй — оно держит швы мира.", "олово", ["закон", "дар"], ), ( "lead", "Свинец (Saturnus)", "🧱 ⏳", "Металлы · Сатурн", "Первоматерия Сатурна, тяжёлая руда предела, зерно будущего золота. " "Свинец — Нигредо металлов: бремя, из которого Делание начинает путь.", "свинец", ["камень", "смерть", "холод"], ), ( "vessel", "Сосуд (Vas Hermeticum)", "🏺 🫙", "Орудия · Реторта", "Герметически закрытое пространство преображения, тигель души. " "Пока сосуд открыт, летучее уходит и Делание срывается.", "сосуд", ["кристалл", "яйцо"], ), ( "athanor", "Атанор (Athanor)", "🔥 🏺", "Орудия · Печь", "Печь непрерывного Делания: ровный священный жар, который не вспыхивает и не гаснет. " "Атанор — терпение огня, кузница, где время само является топливом.", "кузница", ["огонь", "жар", "железо"], ), ] 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(word: str, dart_by_word: dict, lex_by_word: dict) -> dict: lex = lex_by_word.get(word) or {} return { "id": dart_by_word[word], "word": word, "emoji": lex.get("emoji") or "", "pos": lex.get("pos") or "существительное", "category": lex.get("category") or "", "desc": "", } def tag_for(name: str) -> str: return f"[Алхимия: {name}]" def stamp_html_md(canon: dict, dart2: str) -> None: 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)) dart_words = set(dart_descs) for w in words: wd = w.get("word") if wd in dart_descs: w["description"] = dart_descs[wd] # HTML still had lexicon names for restored cells; keep them, descriptions already match. # Also rename HTML words that dart restored (should already be metals in HTML). 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") _ = dart_words def main() -> None: dart_text = restore_dart(DART.read_text(encoding="utf-8")) DART.write_text(dart_text, encoding="utf-8") dart_by_word = { w: int(i) for i, w in re.findall(r'WordItem\(\s*id:\s*(\d+),\s*word:\s*"([^"]+)"', dart_text) } lex_by_word = {e["word"]: e for e in json.loads(LEX.read_text(encoding="utf-8"))} canon = json.loads(CANON.read_text(encoding="utf-8")) word_to_tags: dict[str, list[str]] = defaultdict(list) stations = [] missing = [] for sid, name, symbol, group, lore, prim, extra in STATIONS: if prim not in dart_by_word: missing.append(f"primary {sid}/{prim}") continue cluster = [] seen = {prim} for w in extra: if w in seen or w not in dart_by_word: if w not in dart_by_word: missing.append(f"cluster {sid}/{w}") continue seen.add(w) cluster.append(payload(w, dart_by_word, lex_by_word)) st = { "id": sid, "name": name, "symbol": symbol, "group": group, "lore": lore, "primary": payload(prim, dart_by_word, lex_by_word), "cluster": cluster, } stations.append(st) tag = tag_for(name) word_to_tags[prim].append(tag) for c in cluster: word_to_tags[c["word"]].append(tag) if missing: print("WARN missing", missing) al = canon["alchemy"] al["name"] = "Алхимия (Magnum Opus)" al["count"] = len(stations) al["description"] = ( "Великое Делание: 4 стадии (Нигредо, Альбедо, Цитринитас, Рубедо), " "4 стихии, 7 металлов планетного септенера и орудия сосуда и атанора." ) al["stations"] = stations 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 = ALCHEMY_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") stamp_html_md(canon, dart2) print(f"alchemy stations {len(stations)} stamped {nstamp} tagged {len(word_to_tags)}") groups: dict[str, list[str]] = defaultdict(list) for st in stations: g = st["group"].split(" · ", 1)[0] cl = [c["word"] for c in st["cluster"]] groups[g].append(st["id"]) print(f" {st['id']:12} {st['group']:24} prim={st['primary']['word']:12} {cl}") print("groups", {k: len(v) for k, v in groups.items()}) if __name__ == "__main__": main()