Commit 76fe2d
2026-10-22 14:42:04 iociveteres: feat(gamedata): generate character assets for the sheet from the wiki Add .scripts/gamedata: build.py turns the wiki into the autocomplete assets of the pathfinder_crusade sheet kind (talents.json, traits.json, advancements.json, psychic_powers.json, Black Crusade shapes) and writes report.txt with the wiki problems to fix. wiki.py is the shared parser (entries, tags, fields, groups, tables, datatable blocks) for the arsenal and conditions domains later. Track .scripts/**/*.py in git.| .gitignore .. | |
| @@ 1,3 1,6 @@ | |
| *.py | |
| + | !.scripts/**/*.py |
| tree.txt | |
| _docs/ | |
| + | __pycache__/ |
| + | .scripts/gamedata/report.txt |
| /dev/null .. .scripts/gamedata/build.py | |
| @@ 0,0 1,105 @@ | |
| + | """Выгрузка вики Pathfinder Crusade в ассеты автодополнения листа. |
| + | |
| + | python .scripts/gamedata/build.py [--out DIR] [--report FILE] |
| + | |
| + | По умолчанию пишет в `../CharacterSheet/internal/gamedata/assets/pathfinder_crusade/` |
| + | (рядом с вики), отчёт — в `.scripts/gamedata/report.txt` и кратко в stdout. |
| + | Формы записей — как у ассетов Black Crusade (`internal/gamedata/*.go` чарника). |
| + | """ |
| + | |
| + | import argparse |
| + | import json |
| + | import sys |
| + | from pathlib import Path |
| + | |
| + | import character |
| + | import spells |
| + | import wiki |
| + | from report import Report |
| + | |
| + | HERE = Path(__file__).resolve().parent |
| + | DEFAULT_OUT = wiki.ROOT.parent / "CharacterSheet" / "internal" / "gamedata" / "assets" / "pathfinder_crusade" |
| + | DEFAULT_REPORT = HERE / "report.txt" |
| + | |
| + | |
| + | def unique(items, key, where, report, what): |
| + | """Первые записи с каждым английским именем; повторы — в отчёт.""" |
| + | seen, out = {}, [] |
| + | for item in items: |
| + | k = key(item).lower() |
| + | if k in seen: |
| + | report.add("duplicate", where(item), f"{what} «{key(item)}» уже есть: {seen[k]}") |
| + | continue |
| + | seen[k] = where(item) |
| + | out.append(item) |
| + | return out |
| + | |
| + | |
| + | def build(report): |
| + | """Все выгружаемые файлы: {имя файла: список записей}.""" |
| + | skill_list = character.skills(report) |
| + | skill_names = character.skill_groups(skill_list) |
| + | |
| + | talent_list = character.talents(report) |
| + | trait_list = character.traits(report) |
| + | spell_list = spells.spells(report, skill_names) |
| + | elites = spells.elite_archetypes(report, skill_names) |
| + | for el in elites: |
| + | talent_list += el.talents |
| + | spell_list += el.spells |
| + | trait_list += el.traits |
| + | art_list = spells.arts(report, spell_list, skill_names) |
| + | |
| + | talent_list = unique(talent_list, lambda t: t.entry.name, lambda t: t.entry.where, report, "талант") |
| + | trait_list = unique(trait_list, lambda t: t[1].name, lambda t: t[1].where, report, "трейт") |
| + | spell_list = unique(spell_list, lambda s: s.entry.name, lambda s: s.entry.where, report, "заклинание") |
| + | art_list = unique(art_list, lambda a: a.name, lambda a: a.entry.where, report, "Искусство") |
| + | skill_list = unique(skill_list, lambda s: s.en, lambda s: s.entry.where, report, "навык") |
| + | |
| + | advancements = character.characteristics(report) |
| + | for s in skill_list: |
| + | advancements += s.advancements() |
| + | advancements += [t.advancement() for t in talent_list] |
| + | advancements += [a.advancement() for a in art_list] |
| + | advancements += [s.advancement() for s in spell_list] |
| + | advancements += [el.advancement() for el in elites if el.entry is not None] |
| + | |
| + | return { |
| + | "talents.json": [t.record() for t in talent_list] + [a.talent() for a in art_list], |
| + | "traits.json": [t[0] for t in trait_list], |
| + | "advancements.json": advancements, |
| + | "psychic_powers.json": [s.power for s in spell_list] + [a.power() for a in art_list], |
| + | } |
| + | |
| + | |
| + | def main(argv=None): |
| + | ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| + | ap.add_argument("--out", type=Path, default=DEFAULT_OUT, help="папка ассетов PF (по умолчанию %(default)s)") |
| + | ap.add_argument("--report", type=Path, default=DEFAULT_REPORT, help="файл отчёта (по умолчанию %(default)s)") |
| + | args = ap.parse_args(argv) |
| + | |
| + | report = Report() |
| + | files = build(report) |
| + | |
| + | args.out.mkdir(parents=True, exist_ok=True) |
| + | for name, records in files.items(): |
| + | report.counts[name] = len(records) |
| + | with open(args.out / name, "w", encoding="utf-8", newline="\n") as f: |
| + | json.dump(records, f, ensure_ascii=False, indent=2) |
| + | f.write("\n") |
| + | |
| + | text = report.render() |
| + | args.report.write_text(text, encoding="utf-8", newline="\n") |
| + | |
| + | summary = text.split("\n\n## ")[0] |
| + | out = sys.stdout |
| + | if hasattr(out, "reconfigure"): |
| + | out.reconfigure(encoding="utf-8") |
| + | print(summary) |
| + | print(f"Ассеты: {args.out}") |
| + | print(f"Отчёт: {args.report}") |
| + | return 0 |
| + | |
| + | |
| + | if __name__ == "__main__": |
| + | sys.exit(main()) |
| /dev/null .. .scripts/gamedata/character.py | |
| @@ 0,0 1,241 @@ | |
| + | """Домен персонажа: таланты, трейты, навыки и характеристики. |
| + | |
| + | Записи для talents.json, traits.json и advancements.json (talent, skill, |
| + | characteristic). Заклинания, Искусства и элитные архетипы — в spells.py. |
| + | """ |
| + | |
| + | import re |
| + | |
| + | import pf |
| + | import wiki |
| + | |
| + | TALENTS_GLOB = "iii продвижение/таланты/*.md" |
| + | TRAITS_FILE = "iii продвижение/трейты.md" |
| + | TRAIT_PAGES = ("i создание персонажа/расы/*.md", "i создание персонажа/архетипы/*.md") |
| + | SKILLS_FILE = "iii продвижение/навыки.md" |
| + | CHARACTERISTICS_FILE = "iii продвижение/характеристики.md" |
| + | |
| + | TALENT_LABELS = ("Уровень", "Скл.", "Требования", "Специализации", "Действие", "Тип") |
| + | SKILL_CHAR_RE = re.compile(r"\s*\((WS|BS|S|T|A|I|P|W|F)\)\s*$") |
| + | |
| + | |
| + | def names(e): |
| + | """{name, name_ru} записи; без английского имени name — русское (и проблема в отчёте).""" |
| + | out = {"name": e.title.en or e.title.ru} |
| + | if e.title.en and e.title.ru: |
| + | out["name_ru"] = e.title.ru |
| + | return out |
| + | |
| + | |
| + | def check_names(e, report, need_ru=True): |
| + | """Пометки и имена заголовка в отчёт. Без русского имени можно только трейтам (need_ru=False).""" |
| + | report.title(e) |
| + | if not e.title.en: |
| + | report.entry("name", e, "нет английского имени (нужно `English / Русское`)") |
| + | elif need_ru and not e.title.ru: |
| + | report.entry("name", e, "нет русского имени (нужно `English / Русское`)") |
| + | |
| + | |
| + | # ─── Таланты ──────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | class Talent: |
| + | """Разобранный талант: запись talents.json и запись опыта.""" |
| + | |
| + | def __init__(self, e, fields, text, report): |
| + | self.entry = e |
| + | fmap, repeated = wiki.field_map(fields) |
| + | for f in repeated: |
| + | report.entry("label", e, f"метка «{f.label}» повторяется") |
| + | for f in fields: |
| + | if f.label not in TALENT_LABELS: |
| + | report.entry("label", e, f"неизвестная метка «{f.label}»") |
| + | |
| + | self.level = parse_level(e, fmap.get("Уровень"), report) |
| + | self.aptitudes = check_aptitudes(e, fmap.get("Скл."), report) |
| + | req = fmap.get("Требования") |
| + | self.requirements = None if req is None or pf.is_empty(req.value) else wiki.plain_inline(req.value).strip() |
| + | |
| + | extra = [f"{f.label}: {wiki.plain_inline(f.value)}" for f in fields if f.label in ("Специализации", "Действие", "Тип")] |
| + | self.description = "\n".join(extra + ([text] if text else [])) |
| + | |
| + | def record(self): |
| + | return {**names(self.entry), "description": self.description} |
| + | |
| + | def advancement(self): |
| + | return { |
| + | "type": "talent", |
| + | **names(self.entry), |
| + | "level": self.level, |
| + | "aptitudes": self.aptitudes, |
| + | "requirements": self.requirements, |
| + | } |
| + | |
| + | |
| + | def parse_level(e, f, report): |
| + | if f is None: |
| + | report.entry("required", e, "нет «Уровень»") |
| + | return None |
| + | m = re.match(r"^\s*(\d+)\s*(.*)$", f.value) |
| + | if not m: |
| + | report.entry("value", e, f"«Уровень: {f.value}» — не число") |
| + | return None |
| + | rest = m.group(2).strip(" ,|") |
| + | if rest: |
| + | report.entry("value", e, f"лишнее в строке уровня: «{rest}»") |
| + | level = int(m.group(1)) |
| + | if level not in pf.TALENT_LEVELS: |
| + | report.entry("value", e, f"уровень {level} вне 1–3") |
| + | return level |
| + | |
| + | |
| + | def check_aptitudes(e, f, report): |
| + | if f is None: |
| + | report.entry("required", e, "нет «Скл.»") |
| + | return "" |
| + | apts, bad = pf.parse_aptitudes(wiki.plain_inline(f.value)) |
| + | if bad: |
| + | report.entry("value", e, f"склонность не из списка: «{f.value}»") |
| + | good = [a for a in apts if a not in bad] |
| + | if len(set(good)) < len(good): |
| + | report.entry("value", e, f"склонность повторяется: «{f.value}»") |
| + | return ", ".join(good) |
| + | |
| + | |
| + | def parse_talent(e, report): |
| + | check_names(e, report) |
| + | fields, rest = wiki.split_fields(e.body()) |
| + | return Talent(e, fields, wiki.plain_text(rest), report) |
| + | |
| + | |
| + | def talents(report): |
| + | out = [] |
| + | for path in wiki.md_files(TALENTS_GLOB): |
| + | doc = wiki.read_document(path) |
| + | out += [parse_talent(e, report) for e in wiki.entries(doc, 4)] |
| + | return out |
| + | |
| + | |
| + | # ─── Трейты ───────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def parse_trait(e, report): |
| + | check_names(e, report, need_ru=False) |
| + | return {**names(e), "description": wiki.plain_text(e.body())}, e |
| + | |
| + | |
| + | def traits(report): |
| + | """Трейты из трейты.md и разделов `## Трейты` рас и архетипов: [(запись, wiki.Entry)].""" |
| + | out = [] |
| + | doc = wiki.read_document(wiki.ROOT / TRAITS_FILE) |
| + | out += [parse_trait(e, report) for e in wiki.entries(doc, 4)] |
| + | for path in wiki.md_files(*TRAIT_PAGES): |
| + | doc = wiki.read_document(path) |
| + | out += [parse_trait(e, report) for e in wiki.entries(doc, 4, within=lambda g: g.get(2) == "Трейты")] |
| + | return out |
| + | |
| + | |
| + | # ─── Навыки ───────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | class Skill: |
| + | def __init__(self, en, ru, aptitudes, e): |
| + | self.en, self.ru, self.aptitudes, self.entry = en, ru, aptitudes, e |
| + | |
| + | def advancements(self): |
| + | out = [] |
| + | for level, step in enumerate(pf.SKILL_LEVELS, 1): |
| + | rec = {"type": "skill", "name": f"{self.en} {step}"} |
| + | if self.ru: |
| + | rec["name_ru"] = f"{self.ru} {step}" |
| + | rec["level"] = level |
| + | rec["aptitudes"] = self.aptitudes |
| + | out.append(rec) |
| + | return out |
| + | |
| + | |
| + | def skill_names(e): |
| + | ru = SKILL_CHAR_RE.sub("", e.title.ru) |
| + | en = SKILL_CHAR_RE.sub("", e.title.en) |
| + | return en, ru |
| + | |
| + | |
| + | def skill_aptitudes(e, report): |
| + | fields, _ = wiki.split_fields(e.own_body()) |
| + | fmap, _ = wiki.field_map(fields) |
| + | for f in fields: |
| + | if f.label not in ("Скл.", "Действие"): |
| + | report.entry("label", e, f"неизвестная метка «{f.label}»") |
| + | if "Скл." not in fmap: |
| + | return None |
| + | return check_aptitudes(e, fmap["Скл."], report) |
| + | |
| + | |
| + | def skills(report): |
| + | """Навыки навыки.md: [Skill]; специализация `#####` — «Common Lore (Religion)».""" |
| + | doc = wiki.read_document(wiki.ROOT / SKILLS_FILE) |
| + | out = [] |
| + | for e in wiki.entries(doc, 4): |
| + | check_names(e, report) |
| + | en, ru = skill_names(e) |
| + | apts = skill_aptitudes(e, report) |
| + | specs = e.children(5) |
| + | if not specs: |
| + | if apts is None: |
| + | report.entry("required", e, "нет «Скл.»") |
| + | out.append(Skill(en, ru, apts or "", e)) |
| + | continue |
| + | for s in specs: |
| + | check_names(s, report) |
| + | sen, sru = skill_names(s) |
| + | own = skill_aptitudes(s, report) |
| + | if own is None and apts is None: |
| + | report.entry("required", s, "нет «Скл.» ни у навыка, ни у специализации") |
| + | out.append(Skill(f"{en} ({sen})", f"{ru} ({sru})" if ru and sru else "", own or apts or "", s)) |
| + | return out |
| + | |
| + | |
| + | def skill_groups(skill_list): |
| + | """Английские имена навыков для проверки тестов: «Common Lore», «Acrobatics»…""" |
| + | out = set() |
| + | for s in skill_list: |
| + | out.add(s.en) |
| + | out.add(re.sub(r"\s*\(.*\)$", "", s.en)) |
| + | return out |
| + | |
| + | |
| + | # ─── Характеристики ───────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def characteristics(report): |
| + | """Записи опыта characteristic: 9 характеристик PF × 5 уровней.""" |
| + | doc = wiki.read_document(wiki.ROOT / CHARACTERISTICS_FILE) |
| + | found = {} |
| + | for i, h in enumerate(doc.headings): |
| + | m = re.match(r"^\((\w+)\)\s*(.*)$", h.text) |
| + | if not m: |
| + | continue |
| + | e = wiki.make_entry(doc, i) |
| + | fields, _ = wiki.split_fields(e.own_body()) |
| + | fmap, _ = wiki.field_map(fields) |
| + | title = wiki.parse_title(m.group(2)) |
| + | found[m.group(1)] = (title.ru, check_aptitudes(e, fmap["Скл."], report) if "Скл." in fmap else None) |
| + | |
| + | missing = [c for c in pf.CHARACTERISTICS if found.get(c, (None, None))[1] is None] |
| + | if missing: |
| + | report.add("required", CHARACTERISTICS_FILE, |
| + | f"нет «**Скл.:**» у {', '.join(missing)} — взяты склонности BC (Psy → Mag)") |
| + | out = [] |
| + | for c in pf.CHARACTERISTICS: |
| + | ru, apts = found.get(c, ("", None)) |
| + | if c not in found: |
| + | report.add("required", CHARACTERISTICS_FILE, f"нет заголовка характеристики ({c})") |
| + | apts = apts if apts is not None else pf.DEFAULT_CHARACTERISTIC_APTITUDES[c] |
| + | for level, step in enumerate(pf.CHARACTERISTIC_LEVELS, 1): |
| + | rec = {"type": "characteristic", "name": f"{c} {step}"} |
| + | if ru: |
| + | rec["name_ru"] = f"{ru} {step}" |
| + | rec["level"] = level |
| + | rec["aptitudes"] = apts |
| + | out.append(rec) |
| + | return out |
| /dev/null .. .scripts/gamedata/pf.py | |
| @@ 0,0 1,67 @@ | |
| + | """Константы Pathfinder Crusade, которых нет в разметке вики, и разбор тестов.""" |
| + | |
| + | import re |
| + | |
| + | # Характеристики PF (без Inf, Cor, Fa) в порядке листа. |
| + | CHARACTERISTICS = ("WS", "BS", "S", "T", "A", "I", "P", "W", "F") |
| + | |
| + | APTITUDES = CHARACTERISTICS + ("Off", "Fin", "Def", "Mag", "Tec", "Knw", "Soc", "FC", "Gen") |
| + | |
| + | # Склонности характеристик, пока их нет в `характеристики.md` (`**Скл.:**` |
| + | # там читается первым): таблица BC, Psy → Mag. |
| + | DEFAULT_CHARACTERISTIC_APTITUDES = { |
| + | "WS": "WS, Off", |
| + | "BS": "BS, Fin", |
| + | "S": "S, Off", |
| + | "T": "T, Def", |
| + | "A": "A, Fin", |
| + | "I": "I, Knw", |
| + | "P": "P, FC", |
| + | "W": "W, Mag", |
| + | "F": "F, Soc", |
| + | } |
| + | |
| + | # Уровни продвижения как в BC (`EXPERIENCE_LEVELS_BY_TYPE` листа). |
| + | SKILL_LEVELS = ("- -> +0", "+0 -> +10", "+10 -> +20", "+20 -> +30") |
| + | CHARACTERISTIC_LEVELS = ("+0 -> +5", "+5 -> +10", "+10 -> +15", "+15 -> +20", "+20 -> +25") |
| + | TALENT_LEVELS = (1, 2, 3) |
| + | |
| + | # Класс Искусств по файлу списка: англ. для типа `Arts (X)`, рус. для имени. |
| + | ARTS_CLASSES = { |
| + | "искусства барда": ("Bard", "Бард"), |
| + | "искусства паладина": ("Paladin", "Паладин"), |
| + | "искусства следопыта": ("Ranger", "Следопыт"), |
| + | } |
| + | |
| + | EMPTY_VALUES = {"", "-", "–", "—", "нет"} |
| + | |
| + | |
| + | def is_empty(value): |
| + | return value.strip().lower() in EMPTY_VALUES |
| + | |
| + | |
| + | def parse_aptitudes(value): |
| + | """`I, FC` → (['I', 'FC'], [непонятные]).""" |
| + | names = [a.strip() for a in re.split(r"[,;]", value) if a.strip()] |
| + | bad = [a for a in names if a not in APTITUDES] |
| + | return names, bad |
| + | |
| + | |
| + | def test_base(test, skills): |
| + | """С чего начинается тест магии: ('cs'|'characteristic'|'skill', имя) или (None, токен). |
| + | |
| + | Берётся первая альтернатива (до « или »); skills — английские имена |
| + | навыков PF. `Trade(Bardic)(F)` → навык Trade. |
| + | """ |
| + | s = test.strip().replace("−", "-").replace("–", "-") |
| + | s = re.split(r"\s+или\s+", s)[0] |
| + | if re.match(r"^Cs(?![A-Za-z])", s): |
| + | return "cs", "Cs" |
| + | m = re.match(r"^(WS|BS|S|T|A|I|P|W|F)(?![A-Za-z])", s) |
| + | if m: |
| + | return "characteristic", m.group(1) |
| + | for name in sorted(skills, key=len, reverse=True): |
| + | if s.lower().startswith(name.lower()) and not re.match(r"[A-Za-z]", s[len(name):len(name) + 1]): |
| + | return "skill", name |
| + | token = re.match(r"^[^\s(+\-]*", s).group(0) or s[:20] |
| + | return None, token |
| /dev/null .. .scripts/gamedata/report.py | |
| @@ 0,0 1,102 @@ | |
| + | """Отчёт генератора: что поправить в вики, сгруппировано по видам проблем.""" |
| + | |
| + | from collections import Counter, defaultdict |
| + | |
| + | # Порядок и заголовки разделов отчёта. |
| + | KINDS = { |
| + | "format": "Формат записи", |
| + | "label": "Неизвестные или лишние метки", |
| + | "required": "Нет обязательного поля", |
| + | "value": "Значение поля не читается", |
| + | "name": "Имена", |
| + | "duplicate": "Дубли английских имён", |
| + | "test": "Тест магии не на Cs, характеристику или навык PF", |
| + | "arts": "Искусства", |
| + | "review": "Пометка #Review (выгружено как есть)", |
| + | } |
| + | |
| + | |
| + | class Report: |
| + | def __init__(self): |
| + | self.items = defaultdict(list) # kind → [(where, message, group)] |
| + | self.tags = Counter() # (tag) → сколько записей |
| + | self.counts = {} # файл выгрузки → записей |
| + | self.notes = [] |
| + | |
| + | def add(self, kind, where, message, group=None): |
| + | """Проблема в месте where («файл:строка»). |
| + | |
| + | group — общая формулировка однотипных проблем: больше GROUP_MIN таких |
| + | в одном файле сворачиваются в одну строку со списком записей. |
| + | """ |
| + | if kind not in KINDS: |
| + | raise ValueError(f"unknown report kind {kind}") |
| + | item = (where, message, group) |
| + | if item not in self.items[kind]: |
| + | self.items[kind].append(item) |
| + | |
| + | def entry(self, kind, e, message, group=None): |
| + | """Проблема записи e (wiki.Entry).""" |
| + | self.add(kind, e.where, f"{e.name}: {message}" if e.name else message, group) |
| + | |
| + | def title(self, e): |
| + | """Пометки и проблемы заголовка записи.""" |
| + | for t in e.title.tags: |
| + | self.tags[t] += 1 |
| + | if "Review" in e.title.tags: |
| + | self.add("review", e.where, e.name) |
| + | for p in e.title.problems: |
| + | self.add("format", e.where, p) |
| + | |
| + | def note(self, message): |
| + | self.notes.append(message) |
| + | |
| + | def problems(self): |
| + | return sum(len(v) for k, v in self.items.items() if k != "review") |
| + | |
| + | def render(self): |
| + | out = ["Pathfinder Crusade → gamedata: отчёт", ""] |
| + | out.append("Выгружено:") |
| + | for f, n in self.counts.items(): |
| + | out.append(f" {f}: {n}") |
| + | if self.tags: |
| + | out.append("Пометки: " + ", ".join(f"#{t} {n}" for t, n in sorted(self.tags.items()))) |
| + | out.append(f"Проблем: {self.problems()}, #Review: {len(self.items['review'])}") |
| + | for note in self.notes: |
| + | out.append(f"Примечание: {note}") |
| + | for kind, head in KINDS.items(): |
| + | items = self.items.get(kind) |
| + | if not items: |
| + | continue |
| + | out += ["", f"## {head} ({len(items)})"] |
| + | for where, message in _collapse(items): |
| + | out.append(f"- {where} — {message}") |
| + | return "\n".join(out) + "\n" |
| + | |
| + | |
| + | GROUP_MIN = 3 |
| + | |
| + | |
| + | def _collapse(items): |
| + | """(место, текст) по порядку; группы больше GROUP_MIN в одном файле — одной строкой.""" |
| + | groups = defaultdict(list) |
| + | for where, message, group in items: |
| + | if group: |
| + | groups[(where.rpartition(":")[0] or where, group)].append((where, message)) |
| + | out = [] |
| + | for where, message, group in items: |
| + | key = (where.rpartition(":")[0] or where, group) |
| + | if group and len(groups[key]) > GROUP_MIN: |
| + | if groups[key][0][0] == where: |
| + | names = ", ".join(m.split(":")[0] for _, m in groups[key]) |
| + | out.append((key[0], f"{group} — {len(groups[key])} записей: {names}")) |
| + | continue |
| + | out.append((where, message)) |
| + | return sorted(out, key=lambda i: _sort_key(i[0])) |
| + | |
| + | |
| + | def _sort_key(where): |
| + | path, _, line = where.rpartition(":") |
| + | if line.isdigit(): |
| + | return (path, int(line)) |
| + | return (where, 0) |
| /dev/null .. .scripts/gamedata/spells.py | |
| @@ 0,0 1,317 @@ | |
| + | """Заклинания, Искусства и элитные архетипы. |
| + | |
| + | psychic_powers.json — профиль заклинания (ключи BC, `_prd/pathfinder_gamedata/spells.md`), |
| + | advancements.json — записи `psychic power`, Искусства как `talent`, `elite archetype`. |
| + | """ |
| + | |
| + | import copy |
| + | import re |
| + | |
| + | import character |
| + | import pf |
| + | import wiki |
| + | |
| + | SPELLS_GLOB = "v магия/5 заклинания/**/*.md" |
| + | ARTS_GLOB = "v магия/искусства/*.md" |
| + | ELITE_GLOB = "iii продвижение/элитные архетипы/*.md" |
| + | |
| + | SPELL_LABELS = ("Стоимость", "Требования", "Действие", "Поддержание", "Тест магии", "Дальность", "Тип", "Эффект") |
| + | SPELL_REQUIRED = SPELL_LABELS |
| + | ARTS_LABELS = ("Уровень", "Скл.", "Требования", "Тест магии") |
| + | ARTS_TEST_ALIASES = ("Навык",) |
| + | DAMAGE_TYPE_RE = re.compile(r"^[RIEX](\([A-Za-z]+\))?$") |
| + | COST_RE = re.compile(r"^\s*(\d[\d\s]*)\s*XP\s*$", re.I) |
| + | |
| + | |
| + | def parse_cost(value): |
| + | m = COST_RE.match(value) |
| + | return int(re.sub(r"\s", "", m.group(1))) if m else None |
| + | |
| + | |
| + | # ─── Профиль атаки ────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def attack_profile(e, lines, report): |
| + | """Таблица `Rng | RoF | Dmg | Pen | Свойства` в тексте: (поля профиля, строки без неё).""" |
| + | profile = {k: "" for k in ("weaponRange", "damage", "pen", "damageType", "rofSingle", "rofShort", "rofLong", "special")} |
| + | for t in wiki.tables(lines): |
| + | rng, dmg = t.column("Rng"), t.column("Dmg") |
| + | if rng is None or dmg is None: |
| + | continue |
| + | if not t.rows: |
| + | break |
| + | if t.header != ["Rng", "RoF", "Dmg", "Pen", "Свойства"]: |
| + | report.entry("format", e, f"шапка таблицы атаки «{' | '.join(t.header)}» (нужно Rng | RoF | Dmg | Pen | Свойства)") |
| + | row = t.rows[0] |
| + | cell = lambda k: wiki.plain_inline(row[k]).strip() if k is not None and k < len(row) else "" |
| + | profile["weaponRange"] = cell(rng) |
| + | damage = cell(dmg) |
| + | parts = damage.rsplit(" ", 1) |
| + | if len(parts) == 2 and DAMAGE_TYPE_RE.match(parts[1]): |
| + | profile["damage"], profile["damageType"] = parts |
| + | else: |
| + | profile["damage"] = damage |
| + | if not pf.is_empty(damage): |
| + | report.entry("value", e, f"у урона «{damage}» нет типа последним токеном (R, I, E, X…)") |
| + | profile["pen"] = cell(t.column("Pen")) |
| + | profile["special"] = cell(t.column("Свойства")) |
| + | rof = t.column("RoF") |
| + | if rof is not None: |
| + | # «S\–\–»: разделитель — обратный слеш (или «/»). |
| + | raw = row[rof] if rof < len(row) else "" |
| + | parts = [p.strip() for p in re.split(r"[\\/]", raw)] |
| + | if len(parts) == 3: |
| + | profile["rofSingle"], profile["rofShort"], profile["rofLong"] = parts |
| + | else: |
| + | report.entry("value", e, f"RoF «{raw}» — не три части") |
| + | profile["rofSingle"] = wiki.plain_inline(raw) |
| + | return profile, wiki.without_lines(lines, t.line, t.end) |
| + | return profile, lines |
| + | |
| + | |
| + | # ─── Заклинания ───────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | class Spell: |
| + | def __init__(self, e, report, skills, discipline=None, subdiscipline=None): |
| + | self.entry = e |
| + | character.check_names(e, report) |
| + | fields, rest = wiki.split_fields(e.body(), open_labels=("Эффект",)) |
| + | fmap, repeated = wiki.field_map(fields) |
| + | for f in repeated: |
| + | report.entry("label", e, f"метка «{f.label}» повторяется") |
| + | for f in fields: |
| + | if f.label not in SPELL_LABELS: |
| + | report.entry("label", e, f"неизвестная метка «{f.label}»") |
| + | for label in SPELL_REQUIRED: |
| + | if label not in fmap: |
| + | report.entry("required", e, f"нет «{label}»") |
| + | elif not fmap[label].value.strip() and not (label == "Эффект" and rest): |
| + | report.entry("required", e, f"пустое «{label}»") |
| + | val = lambda label: wiki.plain_inline(fmap[label].value).strip() if label in fmap else "" |
| + | |
| + | self.discipline = e.groups.get(1, "") if discipline is None else discipline |
| + | self.subdiscipline = e.groups.get(2, "") if subdiscipline is None else subdiscipline |
| + | self.cost = None |
| + | if "Стоимость" in fmap: |
| + | self.cost = parse_cost(val("Стоимость")) |
| + | if self.cost is None: |
| + | report.entry("value", e, f"«Стоимость: {val('Стоимость')}» — не «N XP»") |
| + | req = val("Требования") |
| + | self.requirements = None if pf.is_empty(req) else req |
| + | |
| + | profile, rest = attack_profile(e, rest, report) |
| + | effect = val("Эффект") |
| + | tail = wiki.plain_text(rest) |
| + | effect = "\n".join(p for p in (effect, tail) if p) |
| + | |
| + | self.test = val("Тест магии") |
| + | if self.test: |
| + | check_test(e, self.test, skills, report) |
| + | |
| + | self.power = { |
| + | **character.names(e), |
| + | "type": self.discipline, |
| + | "subtypes": val("Тип"), |
| + | "range": val("Дальность"), |
| + | "psychotest": self.test, |
| + | "action": val("Действие"), |
| + | "sustained": val("Поддержание"), |
| + | **profile, |
| + | "effect": effect, |
| + | } |
| + | |
| + | def advancement(self): |
| + | return { |
| + | "type": "psychic power", |
| + | **character.names(self.entry), |
| + | "discipline": self.discipline, |
| + | "subdiscipline": self.subdiscipline, |
| + | "experienceCost": self.cost, |
| + | "requirements": self.requirements, |
| + | } |
| + | |
| + | |
| + | def check_test(e, test, skills, report): |
| + | kind, token = pf.test_base(test, skills) |
| + | if kind is None: |
| + | report.entry("test", e, f"«{test}» начинается с «{token}»", group=f"тесты начинаются с «{token}»") |
| + | |
| + | |
| + | def spells(report, skills): |
| + | out = [] |
| + | for path in wiki.md_files(SPELLS_GLOB): |
| + | doc = wiki.read_document(path) |
| + | out += [Spell(e, report, skills) for e in wiki.entries(doc, 4)] |
| + | return out |
| + | |
| + | |
| + | # ─── Искусства ────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | class Art: |
| + | """Искусство класса: заклинание с профилем базового и талант.""" |
| + | |
| + | def __init__(self, e, cls, base, report, skills): |
| + | self.entry = e |
| + | self.cls_en, self.cls_ru = cls |
| + | character.check_names(e, report) |
| + | fields, rest = wiki.split_fields(e.body()) |
| + | fmap, repeated = wiki.field_map(fields) |
| + | for f in repeated: |
| + | report.entry("label", e, f"метка «{f.label}» повторяется") |
| + | for f in fields: |
| + | if f.label in ARTS_TEST_ALIASES and "Тест магии" not in fmap: |
| + | report.entry("label", e, f"«**{f.label}:**» вместо «**Тест магии:**»") |
| + | fmap["Тест магии"] = f |
| + | elif f.label not in ARTS_LABELS: |
| + | report.entry("label", e, f"неизвестная метка «{f.label}»") |
| + | self.level = character.parse_level(e, fmap.get("Уровень"), report) |
| + | self.aptitudes = character.check_aptitudes(e, fmap.get("Скл."), report) |
| + | req = fmap.get("Требования") |
| + | self.requirements = None if req is None or pf.is_empty(req.value) else wiki.plain_inline(req.value).strip() |
| + | test = fmap.get("Тест магии") |
| + | self.test = wiki.plain_inline(test.value).strip() if test else "" |
| + | if not test: |
| + | report.entry("required", e, "нет «Тест магии»") |
| + | elif self.test: |
| + | check_test(e, self.test, skills, report) |
| + | self.note = wiki.plain_text(rest) |
| + | |
| + | self.base = base |
| + | ru = e.title.ru or (base.entry.title.ru if base and e.title.en else "") |
| + | self.name = f"{e.title.en or e.title.ru} ({self.cls_en})" |
| + | self.name_ru = f"{ru} ({self.cls_ru})" if e.title.en and ru else "" |
| + | if base is None: |
| + | report.entry("arts", e, "нет базового заклинания с этим английским именем — профиль пустой") |
| + | elif e.title.ru and base.entry.title.ru and norm(e.title.ru) != norm(base.entry.title.ru): |
| + | report.entry("arts", e, f"русское имя «{e.title.ru}», у базового заклинания — «{base.entry.title.ru}»") |
| + | |
| + | def names(self): |
| + | return {"name": self.name, **({"name_ru": self.name_ru} if self.name_ru else {})} |
| + | |
| + | def power(self): |
| + | arts_type = f"Arts ({self.cls_en})" |
| + | if self.base: |
| + | p = copy.deepcopy(self.base.power) |
| + | subtypes = ", ".join(s for s in (p["subtypes"], arts_type) if s) |
| + | effect = "\n\n".join(s for s in (self.note, p["effect"]) if s) |
| + | else: |
| + | p = {"type": self.entry.groups.get(2, ""), "range": "", "action": "", "sustained": "", |
| + | "weaponRange": "", "damage": "", "pen": "", "damageType": "", |
| + | "rofSingle": "", "rofShort": "", "rofLong": "", "special": ""} |
| + | subtypes, effect = arts_type, self.note |
| + | out = {**self.names(), "type": p["type"], "subtypes": subtypes, "level": self.level} |
| + | for k, v in p.items(): |
| + | if k not in ("name", "name_ru", "type", "subtypes", "effect"): |
| + | out[k] = v |
| + | out["psychotest"] = self.test |
| + | out["effect"] = effect |
| + | return out |
| + | |
| + | def talent(self): |
| + | lines = [f"Даёт Искусство «{self.entry.title.text}» ({self.entry.groups.get(1, '')}).", |
| + | f"Тест магии: {self.test}" if self.test else ""] |
| + | if self.note: |
| + | lines.append(self.note) |
| + | return {**self.names(), "description": "\n".join(l for l in lines if l)} |
| + | |
| + | def advancement(self): |
| + | return { |
| + | "type": "talent", |
| + | **self.names(), |
| + | "level": self.level, |
| + | "aptitudes": self.aptitudes, |
| + | "requirements": self.requirements, |
| + | } |
| + | |
| + | |
| + | def norm(s): |
| + | return s.lower().replace("ё", "е").strip() |
| + | |
| + | |
| + | def find_base(by_name, e): |
| + | """Базовое заклинание Искусства: то же английское имя, при дублях — та же дисциплина (`##`).""" |
| + | found = by_name.get(e.title.en.lower(), []) if e.title.en else [] |
| + | same = [s for s in found if norm(s.discipline) == norm(e.groups.get(2, ""))] |
| + | return (same or found or [None])[0] |
| + | |
| + | |
| + | def arts(report, spell_list, skills): |
| + | by_name = {} |
| + | for s in spell_list: |
| + | by_name.setdefault(s.entry.title.en.lower(), []).append(s) |
| + | out = [] |
| + | for path in wiki.md_files(ARTS_GLOB): |
| + | cls = pf.ARTS_CLASSES.get(path.stem.lower()) |
| + | if cls is None: |
| + | report.add("format", wiki.rel(path), "файл Искусств не из списка классов (Bard, Paladin, Ranger)") |
| + | continue |
| + | doc = wiki.read_document(path) |
| + | for e in wiki.entries(doc, 4): |
| + | out.append(Art(e, cls, find_base(by_name, e), report, skills)) |
| + | return out |
| + | |
| + | |
| + | # ─── Элитные архетипы ─────────────────────────────────────────────────────── |
| + | |
| + | |
| + | class EliteArchetype: |
| + | def __init__(self, doc, report, skills): |
| + | self.doc = doc |
| + | tops = wiki.sections(doc, 1) |
| + | self.entry = tops[0] if tops else None |
| + | self.spells, self.talents, self.traits = [], [], [] |
| + | self.cost, self.requirements = None, None |
| + | if self.entry is None: |
| + | report.add("format", doc.path, "нет заголовка `# English / Русское`") |
| + | return |
| + | e = self.entry |
| + | character.check_names(e, report) |
| + | name = e.title.text |
| + | |
| + | req = next((s for s in wiki.sections(doc, 2) if s.title.text == "Требования"), None) |
| + | if req is None: |
| + | report.entry("required", e, "нет раздела «## Требования»") |
| + | else: |
| + | fields, rest = wiki.split_fields(req.own_body()) |
| + | parts = [] |
| + | for f in fields: |
| + | if f.label == "Стоимость": |
| + | self.cost = parse_cost(wiki.plain_inline(f.value)) |
| + | if self.cost is None: |
| + | report.entry("value", e, f"«Стоимость: {f.value}» — не «N XP»") |
| + | elif not pf.is_empty(f.value): |
| + | parts.append(wiki.plain_inline(f.value).strip()) |
| + | self.requirements = ", ".join(parts) or None |
| + | if self.cost is None: |
| + | for n, text in rest: |
| + | cost = parse_cost(text) |
| + | if cost is not None: |
| + | self.cost = cost |
| + | report.add("format", f"{doc.path}:{n}", |
| + | f"{e.name}: стоимость строкой «{text.strip()}» — нужно «**Стоимость:** {cost} XP»") |
| + | break |
| + | if self.cost is None: |
| + | report.entry("required", e, "нет «Стоимость»") |
| + | |
| + | for x in wiki.entries(doc, 4): |
| + | groups = set(x.groups.values()) |
| + | if "Дополнительные таланты" in groups or "Таланты" in groups: |
| + | self.talents.append(character.parse_talent(x, report)) |
| + | elif "Заклинания" in groups: |
| + | self.spells.append(Spell(x, report, skills, discipline=name, subdiscipline="")) |
| + | elif "Трейты" in groups: |
| + | self.traits.append(character.parse_trait(x, report)) |
| + | |
| + | def advancement(self): |
| + | return { |
| + | "type": "elite archetype", |
| + | **character.names(self.entry), |
| + | "experienceCost": self.cost, |
| + | "requirements": self.requirements, |
| + | } |
| + | |
| + | |
| + | def elite_archetypes(report, skills): |
| + | return [EliteArchetype(wiki.read_document(p), report, skills) for p in wiki.md_files(ELITE_GLOB)] |
| /dev/null .. .scripts/gamedata/test_wiki.py | |
| @@ 0,0 1,317 @@ | |
| + | """Тесты разбора вики: `cd .scripts/gamedata && python -m unittest`.""" |
| + | |
| + | import textwrap |
| + | import unittest |
| + | |
| + | import character |
| + | import pf |
| + | import spells |
| + | import wiki |
| + | from report import Report |
| + | |
| + | |
| + | def doc(text, path="test.md"): |
| + | return wiki.parse_document(textwrap.dedent(text).lstrip("\n"), path) |
| + | |
| + | |
| + | class TitleTest(unittest.TestCase): |
| + | def test_pair_and_tags(self): |
| + | t = wiki.parse_title("Bite (X) / Укус #New") |
| + | self.assertEqual((t.en, t.ru, t.tags, t.problems), ("Bite (X)", "Укус", ["New"], [])) |
| + | |
| + | def test_several_tags(self): |
| + | t = wiki.parse_title("Hatred / Ненависть #Changed #Review") |
| + | self.assertEqual(t.tags, ["Changed", "Review"]) |
| + | self.assertEqual(t.text, "Hatred / Ненависть") |
| + | |
| + | def test_unknown_tag_counts_as_review(self): |
| + | t = wiki.parse_title("Trait / Трейт # TO DO") |
| + | self.assertEqual((t.en, t.ru, t.tags), ("Trait", "Трейт", ["Review"])) |
| + | self.assertEqual(len(t.problems), 1) |
| + | |
| + | def test_slash_inside_names(self): |
| + | t = wiki.parse_title("Open/Lock / Открыть/Запереть") |
| + | self.assertEqual((t.en, t.ru, t.problems), ("Open/Lock", "Открыть/Запереть", [])) |
| + | |
| + | def test_separator_without_spaces(self): |
| + | t = wiki.parse_title("Dance of the Fallen Leaf/ Пляска опавшего листа") |
| + | self.assertEqual((t.en, t.ru), ("Dance of the Fallen Leaf", "Пляска опавшего листа")) |
| + | self.assertEqual(len(t.problems), 1) |
| + | |
| + | def test_single_names(self): |
| + | t = wiki.parse_title("Spikes (X, Y) #New") |
| + | self.assertEqual((t.en, t.ru, t.tags), ("Spikes (X, Y)", "", ["New"])) |
| + | t = wiki.parse_title("Нежить #New") |
| + | self.assertEqual((t.en, t.ru), ("", "Нежить")) |
| + | |
| + | def test_placeholder_in_english_name(self): |
| + | t = wiki.parse_title("Battle Cant (<Организация>) / Боевой Кант (<Организация>) (I)") |
| + | self.assertEqual(t.problems, []) |
| + | |
| + | def test_link_in_title(self): |
| + | t = wiki.parse_title("[Lantern / Фонарь](#lantern-)") |
| + | self.assertEqual((t.en, t.ru), ("Lantern", "Фонарь")) |
| + | |
| + | |
| + | class FieldsTest(unittest.TestCase): |
| + | def test_several_labels_in_a_line(self): |
| + | fs = wiki.line_fields("**Уровень:** 1, **Скл.:** I, FC ", 3) |
| + | self.assertEqual([(f.label, f.value, f.line) for f in fs], [("Уровень", "1", 3), ("Скл.", "I, FC", 3)]) |
| + | |
| + | def test_pipe_separated(self): |
| + | fs = wiki.line_fields("**Редкость:** 1 | **Вес:** 0.5 | **Цена:** 1 000 gp") |
| + | self.assertEqual([(f.label, f.value) for f in fs], [("Редкость", "1"), ("Вес", "0.5"), ("Цена", "1 000 gp")]) |
| + | |
| + | def test_god_leftover_stays_in_value(self): |
| + | fs = wiki.line_fields("**Уровень:** 3, Бог: Тзинч | **Скл.:** W, Def") |
| + | self.assertEqual(fs[0].value, "3, Бог: Тзинч") |
| + | |
| + | def test_not_a_field_line(self): |
| + | self.assertEqual(wiki.line_fields("Текст **Метка:** внутри"), []) |
| + | |
| + | def test_split_fields_stops_at_open_label(self): |
| + | lines = [(1, "**Стоимость:** 100 XP "), (2, ""), (3, "**Эффект:** Начало."), (4, "**Раны:** 10"), (5, "Хвост")] |
| + | fields, rest = wiki.split_fields(lines, open_labels=("Эффект",)) |
| + | self.assertEqual([f.label for f in fields], ["Стоимость", "Эффект"]) |
| + | self.assertEqual(rest, [(4, "**Раны:** 10"), (5, "Хвост")]) |
| + | |
| + | def test_split_fields_stops_at_text(self): |
| + | fields, rest = wiki.split_fields([(1, "**Скл.:** A, Gen"), (2, "Текст."), (3, "**Действие:** Полное")]) |
| + | self.assertEqual([f.label for f in fields], ["Скл."]) |
| + | self.assertEqual([n for n, _ in rest], [2, 3]) |
| + | |
| + | |
| + | class DocumentTest(unittest.TestCase): |
| + | SRC = """ |
| + | # Телепатия |
| + | ## Месмеризм |
| + | #### Delude / Заблуждение |
| + | Текст. |
| + | ##### Sub / Под |
| + | Ещё. |
| + | ### Глубже |
| + | #### Passion / Страсть #New |
| + | ``` |
| + | # не заголовок |
| + | ``` |
| + | ## Иллюзионизм |
| + | #### Noise / Шум |
| + | """ |
| + | |
| + | def test_entries_and_groups(self): |
| + | es = wiki.entries(doc(self.SRC)) |
| + | self.assertEqual([e.name for e in es], ["Delude", "Passion", "Noise"]) |
| + | self.assertEqual(es[0].groups, {1: "Телепатия", 2: "Месмеризм"}) |
| + | self.assertEqual(es[1].groups, {1: "Телепатия", 2: "Месмеризм", 3: "Глубже"}) |
| + | self.assertEqual(es[2].groups, {1: "Телепатия", 2: "Иллюзионизм"}) |
| + | self.assertEqual(es[1].title.tags, ["New"]) |
| + | |
| + | def test_body_and_children(self): |
| + | d = doc(self.SRC) |
| + | e = wiki.entries(d)[0] |
| + | self.assertEqual([t for _, t in e.body()], ["Текст.", "##### Sub / Под", "Ещё."]) |
| + | self.assertEqual([t for _, t in e.own_body()], ["Текст."]) |
| + | self.assertEqual([c.name for c in e.children()], ["Sub"]) |
| + | self.assertEqual(e.where, "test.md:3") |
| + | |
| + | def test_within(self): |
| + | es = wiki.entries(doc(self.SRC), within=lambda g: g.get(2) == "Иллюзионизм") |
| + | self.assertEqual([e.name for e in es], ["Noise"]) |
| + | |
| + | |
| + | class TextTest(unittest.TestCase): |
| + | def test_plain_inline(self): |
| + | s = wiki.plain_inline("**Жирный** [Ссылка](/a#b) и [[Вики|текст]] S\\–\\– <span>x</span><br>y") |
| + | self.assertEqual(s, "Жирный Ссылка и текст S–– x\ny") |
| + | |
| + | def test_keeps_placeholders(self): |
| + | self.assertEqual(wiki.plain_inline("Cant (<Организация>)"), "Cant (<Организация>)") |
| + | |
| + | def test_plain_text(self): |
| + | s = wiki.plain_text([(1, "Первая "), (2, "##### Подзаголовок"), (3, ""), (4, ""), (5, ""), (6, "Вторая")]) |
| + | self.assertEqual(s, "Первая\nПодзаголовок\n\nВторая") |
| + | |
| + | |
| + | class TablesTest(unittest.TestCase): |
| + | def test_markdown_table(self): |
| + | d = doc(""" |
| + | Текст |
| + | | Rng | RoF | Dmg | |
| + | |-----|-----|-----| |
| + | | 30м | S\\–\\– | 1d10 E | |
| + | После |
| + | """) |
| + | [t] = wiki.tables(d.numbered(1, 99)) |
| + | self.assertEqual(t.header, ["Rng", "RoF", "Dmg"]) |
| + | self.assertEqual(t.rows, [["30м", "S\\–\\–", "1d10 E"]]) |
| + | self.assertEqual((t.line, t.end), (2, 5)) |
| + | self.assertEqual(t.column("dmg"), 2) |
| + | |
| + | def test_datatable(self): |
| + | d = doc(""" |
| + | {{datatable |
| + | |paging=false |
| + | |sortable=true |
| + | | Название | Цена | |
| + | | --- | :---: | |
| + | | [Recoil Glove / Откатная Перчатка](#recoil-glove-) | 50 gp | |
| + | | Warhammer | 1<br>2 | |
| + | }} |
| + | #### Recoil Glove / Откатная Перчатка |
| + | """) |
| + | [t] = wiki.tables(d.numbered(1, 99)) |
| + | self.assertEqual(t.options, {"paging": "false", "sortable": "true"}) |
| + | self.assertEqual(t.dicts()[0]["Цена"], "50 gp") |
| + | self.assertEqual(wiki.cell_link(t.rows[0][0]), ("Recoil Glove / Откатная Перчатка", "#recoil-glove-")) |
| + | self.assertEqual(wiki.split_br(t.rows[1][1]), ["1", "2"]) |
| + | self.assertEqual(t.end, 9) |
| + | |
| + | def test_escaped_pipe(self): |
| + | self.assertEqual(wiki.split_row("| a\\|b | c |"), ["a\\|b", "c"]) |
| + | |
| + | |
| + | class TestBaseTest(unittest.TestCase): |
| + | SKILLS = {"Deceive", "Trade", "Intimidate", "Sleight of Hand", "Spellcraft"} |
| + | |
| + | def test_bases(self): |
| + | cases = { |
| + | "Cs+20/10/0 (vss S+0)": ("cs", "Cs"), |
| + | "Cs–10 vs W+0": ("cs", "Cs"), |
| + | "W–20 vs W+0": ("characteristic", "W"), |
| + | "S+0": ("characteristic", "S"), |
| + | "Deceive(F) +0 vs W +0": ("skill", "Deceive"), |
| + | "Trade(Bardic)(Стат Навыка) +0 или Intimidate(F или W) +0": ("skill", "Trade"), |
| + | "Sleight of Hand(F) +0": ("skill", "Sleight of Hand"), |
| + | "Spellcraft(P)+10": ("skill", "Spellcraft"), |
| + | "Psyniscience(P)+30": (None, "Psyniscience"), |
| + | "Cast+10": (None, "Cast"), |
| + | "Cor+0": (None, "Cor"), |
| + | } |
| + | for test, want in cases.items(): |
| + | self.assertEqual(pf.test_base(test, self.SKILLS), want, test) |
| + | |
| + | def test_aptitudes(self): |
| + | self.assertEqual(pf.parse_aptitudes("I, FC"), (["I", "FC"], [])) |
| + | self.assertEqual(pf.parse_aptitudes("Bs, Fin"), (["Bs", "Fin"], ["Bs"])) |
| + | |
| + | |
| + | SPELLS = """ |
| + | # Телекинез |
| + | ## Базовая Сила |
| + | #### Mind Over Matter / Разум Превыше Материи |
| + | **Стоимость:** 100 XP |
| + | **Требования:** PR 1+ |
| + | **Действие:** Полудействие |
| + | **Поддержание:** Свободное действие |
| + | **Тест магии:** Cs+20/10/0 (vss S+0) |
| + | **Дальность:** PR×10м |
| + | **Тип:** Атака |
| + | **Эффект:** Начало [ссылка](#x). |
| + | 1) Режим. |
| + | |
| + | | Rng | RoF | Dmg | Pen | Свойства | |
| + | |-----|-----|-----|-----|----------| |
| + | | PR×10м | S\\–\\– | 2d10+2×PR E(Fl) | 0 | Primitive | |
| + | |
| + | После таблицы. |
| + | ## Разное |
| + | #### Jump / Прыжок |
| + | **Стоимость:** 1 000 xp |
| + | **Требования:** Нет |
| + | **Тест магии:** Cor+0 |
| + | **Цвет:** синий |
| + | **Эффект:** Прыг. |
| + | """ |
| + | |
| + | |
| + | class SpellTest(unittest.TestCase): |
| + | def test_spell(self): |
| + | r = Report() |
| + | mom, jump = [spells.Spell(e, r, {"Athletics"}) for e in wiki.entries(doc(SPELLS))] |
| + | p = mom.power |
| + | self.assertEqual((p["name"], p["name_ru"], p["type"]), ("Mind Over Matter", "Разум Превыше Материи", "Телекинез")) |
| + | self.assertEqual((p["psychotest"], p["subtypes"], p["range"]), ("Cs+20/10/0 (vss S+0)", "Атака", "PR×10м")) |
| + | self.assertEqual((p["damage"], p["damageType"], p["pen"], p["special"]), ("2d10+2×PR", "E(Fl)", "0", "Primitive")) |
| + | self.assertEqual((p["rofSingle"], p["rofShort"], p["rofLong"], p["weaponRange"]), ("S", "–", "–", "PR×10м")) |
| + | self.assertEqual(p["effect"], "Начало ссылка.\n1) Режим.\n\nПосле таблицы.") |
| + | self.assertNotIn("roll", p) |
| + | self.assertEqual(mom.advancement(), { |
| + | "type": "psychic power", "name": "Mind Over Matter", "name_ru": "Разум Превыше Материи", |
| + | "discipline": "Телекинез", "subdiscipline": "Базовая Сила", "experienceCost": 100, "requirements": "PR 1+", |
| + | }) |
| + | a = jump.advancement() |
| + | self.assertEqual((a["experienceCost"], a["requirements"], a["subdiscipline"]), (1000, None, "Разное")) |
| + | |
| + | kinds = {k: [m for _, m, _ in v] for k, v in r.items.items()} |
| + | self.assertEqual(kinds["label"], ["Jump: неизвестная метка «Цвет»"]) |
| + | self.assertEqual(kinds["test"], ["Jump: «Cor+0» начинается с «Cor»"]) |
| + | self.assertIn("Jump: нет «Действие»", kinds["required"]) |
| + | self.assertNotIn("format", kinds) |
| + | |
| + | def test_art_takes_base_profile(self): |
| + | r = Report() |
| + | [mom, _] = [spells.Spell(e, r, set()) for e in wiki.entries(doc(SPELLS))] |
| + | arts_doc = doc(""" |
| + | # Искусства Барда |
| + | ## Телекинез |
| + | #### Mind Over Matter / Разум над Материей |
| + | **Уровень:** 2, **Скл.:** Gen, Mag |
| + | **Требования:** Trade(Bardic) +10 |
| + | **Тест магии:** Trade(Bardic)(Стат Навыка) +0 |
| + | Отличие от базового. |
| + | """) |
| + | [e] = wiki.entries(arts_doc) |
| + | art = spells.Art(e, ("Bard", "Бард"), mom, r, {"Trade"}) |
| + | p = art.power() |
| + | self.assertEqual((p["name"], p["name_ru"]), ("Mind Over Matter (Bard)", "Разум над Материей (Бард)")) |
| + | self.assertEqual(p["subtypes"], "Атака, Arts (Bard)") |
| + | self.assertEqual((p["level"], p["psychotest"], p["damage"]), (2, "Trade(Bardic)(Стат Навыка) +0", "2d10+2×PR")) |
| + | self.assertTrue(p["effect"].startswith("Отличие от базового.\n\nНачало")) |
| + | self.assertNotIn("roll", p) |
| + | self.assertEqual(art.advancement(), { |
| + | "type": "talent", "name": "Mind Over Matter (Bard)", "name_ru": "Разум над Материей (Бард)", |
| + | "level": 2, "aptitudes": "Gen, Mag", "requirements": "Trade(Bardic) +10", |
| + | }) |
| + | self.assertEqual(mom.power["subtypes"], "Атака", "the base spell stays as it was") |
| + | self.assertEqual([m for _, m, _ in r.items["arts"]], |
| + | ["Mind Over Matter: русское имя «Разум над Материей», у базового заклинания — «Разум Превыше Материи»"]) |
| + | |
| + | |
| + | class TalentTest(unittest.TestCase): |
| + | def test_talent(self): |
| + | r = Report() |
| + | [t, bad] = [character.parse_talent(e, r) for e in wiki.entries(doc(""" |
| + | # Общие |
| + | #### Combat Formation / Боевое Построение |
| + | **Уровень:** 1, **Скл.:** I, FC |
| + | **Требования:** I 40 |
| + | Текст [ссылка](#a). |
| + | #### Pain Is an Illusion / Боль — Иллюзия |
| + | **Уровень:** 3, Бог: Тзинч | **Скл.:** W, Psy |
| + | **Специализации:** Нож, Меч |
| + | """))] |
| + | self.assertEqual(t.record(), {"name": "Combat Formation", "name_ru": "Боевое Построение", "description": "Текст ссылка."}) |
| + | self.assertEqual(t.advancement(), { |
| + | "type": "talent", "name": "Combat Formation", "name_ru": "Боевое Построение", |
| + | "level": 1, "aptitudes": "I, FC", "requirements": "I 40", |
| + | }) |
| + | self.assertEqual((bad.level, bad.aptitudes, bad.requirements, bad.description), (3, "W", None, "Специализации: Нож, Меч")) |
| + | self.assertEqual(sorted(m for _, m, _ in r.items["value"]), [ |
| + | "Pain Is an Illusion: лишнее в строке уровня: «Бог: Тзинч»", |
| + | "Pain Is an Illusion: склонность не из списка: «W, Psy»", |
| + | ]) |
| + | |
| + | |
| + | class ReportTest(unittest.TestCase): |
| + | def test_collapses_groups(self): |
| + | r = Report() |
| + | for n in range(5): |
| + | r.add("test", f"a.md:{n + 1}", f"S{n}: «Cor+0»", group="тесты начинаются с «Cor»") |
| + | r.add("test", "b.md:1", "X: «Cor+0»", group="тесты начинаются с «Cor»") |
| + | text = r.render() |
| + | self.assertIn("- a.md — тесты начинаются с «Cor» — 5 записей: S0, S1, S2, S3, S4", text) |
| + | self.assertIn("- b.md:1 — X: «Cor+0»", text) |
| + | |
| + | |
| + | if __name__ == "__main__": |
| + | unittest.main() |
| /dev/null .. .scripts/gamedata/wiki.py | |
| @@ 0,0 1,449 @@ | |
| + | """Разбор Markdown вики Pathfinder Crusade для выгрузки в gamedata. |
| + | |
| + | Общий для всех доменов (персонаж, арсенал, статусы). Контракт формата — |
| + | `_prd/pathfinder_gamedata/wiki_plan.md` чарника, коротко: |
| + | |
| + | - запись — заголовок `#### English / Русское`, пометки `#New`, `#Changed`, |
| + | `#Review` в конце заголовка срезаются в `Title.tags`; |
| + | - поля — строки `**Метка:** значение` (несколько меток в строке можно |
| + | разделять `,` или `|`); |
| + | - заголовки выше записи (`#`, `##`, `###`) — группировка, `Entry.groups`; |
| + | - таблицы — Markdown (`| a | b |`) или блок `{{datatable … }}`. |
| + | |
| + | Модуль только читает: что делать с записью и что считать ошибкой, решает |
| + | домен. Проблемы разбора (заголовок без пары имён и т.п.) он возвращает |
| + | списком строк, домен кладёт их в отчёт. |
| + | """ |
| + | |
| + | import re |
| + | from dataclasses import dataclass, field |
| + | from pathlib import Path |
| + | |
| + | ROOT = Path(__file__).resolve().parents[2] |
| + | |
| + | KNOWN_TAGS = ("New", "Changed", "Review") |
| + | |
| + | HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$") |
| + | FENCE_RE = re.compile(r"^[ \t]*(```|~~~)") |
| + | CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]") |
| + | # Пометка в конце заголовка: «#New», «# TO DO». |
| + | TAG_TAIL_RE = re.compile(r"\s+#\s*([A-Za-zА-Яа-яЁё][\w .]*?)\s*$") |
| + | LABEL_RE = re.compile(r"\*\*([^*\n]+?):\*\*") |
| + | LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(([^)\s]*)(?:\s+\"[^\"]*\")?\)") |
| + | IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)") |
| + | WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]") |
| + | HTML_TAG_RE = re.compile(r"</?(?:span|div|p|b|i|u|s|em|strong|small|sup|sub|font|center)\b[^>]*>", re.I) |
| + | BR_RE = re.compile(r"<br\s*/?>", re.I) |
| + | TABLE_SEP_RE = re.compile(r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$") |
| + | |
| + | |
| + | # ─── Файлы и заголовки ────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def rel(path): |
| + | """Путь файла относительно корня вики, с прямыми слешами.""" |
| + | return Path(path).resolve().relative_to(ROOT).as_posix() |
| + | |
| + | |
| + | def is_template(path): |
| + | return Path(path).stem.lower().endswith("template") |
| + | |
| + | |
| + | def md_files(*globs, root=ROOT): |
| + | """Файлы вики по шаблонам `glob` от корня, без шаблонов `* template.md`, по порядку имён.""" |
| + | out = [] |
| + | for g in globs: |
| + | out.extend(p for p in Path(root).glob(g) if p.is_file() and p.suffix == ".md" and not is_template(p)) |
| + | return sorted(set(out), key=lambda p: p.as_posix()) |
| + | |
| + | |
| + | @dataclass |
| + | class Heading: |
| + | level: int |
| + | text: str # как в файле, без `#` |
| + | line: int # номер строки, с 1 |
| + | |
| + | |
| + | @dataclass |
| + | class Document: |
| + | path: str # относительно корня вики |
| + | lines: list # строки файла без `\n` |
| + | headings: list # Heading по порядку |
| + | |
| + | def section_end(self, i): |
| + | """Строка (с 1), до которой (не включая) тянется заголовок i вместе с подзаголовками.""" |
| + | level = self.headings[i].level |
| + | for h in self.headings[i + 1:]: |
| + | if h.level <= level: |
| + | return h.line |
| + | return len(self.lines) + 1 |
| + | |
| + | def own_end(self, i): |
| + | """Строка, до которой тянется собственный текст заголовка i (до любого следующего заголовка).""" |
| + | if i + 1 < len(self.headings): |
| + | return self.headings[i + 1].line |
| + | return len(self.lines) + 1 |
| + | |
| + | def numbered(self, start, end): |
| + | """Строки [start, end) как пары (номер, текст).""" |
| + | return [(n, self.lines[n - 1]) for n in range(start, min(end, len(self.lines) + 1))] |
| + | |
| + | def groups(self, i): |
| + | """Заголовки над заголовком i: {уровень: текст без пометок}.""" |
| + | out = {} |
| + | level = self.headings[i].level |
| + | for h in reversed(self.headings[:i]): |
| + | if h.level < level and h.level not in out: |
| + | out[h.level] = parse_title(h.text).text |
| + | level = h.level |
| + | return dict(sorted(out.items())) |
| + | |
| + | |
| + | def parse_document(text, path=""): |
| + | lines = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").split("\n") |
| + | headings = [] |
| + | fence = False |
| + | for n, line in enumerate(lines, 1): |
| + | if FENCE_RE.match(line): |
| + | fence = not fence |
| + | continue |
| + | if fence: |
| + | continue |
| + | m = HEADING_RE.match(line) |
| + | if m: |
| + | headings.append(Heading(len(m.group(1)), m.group(2), n)) |
| + | return Document(path, lines, headings) |
| + | |
| + | |
| + | def read_document(path): |
| + | p = Path(path).resolve() |
| + | try: |
| + | name = rel(p) |
| + | except ValueError: |
| + | name = p.as_posix() |
| + | return parse_document(p.read_text(encoding="utf-8"), name) |
| + | |
| + | |
| + | # ─── Заголовок записи ─────────────────────────────────────────────────────── |
| + | |
| + | |
| + | @dataclass |
| + | class Title: |
| + | text: str # без пометок |
| + | en: str |
| + | ru: str |
| + | tags: list = field(default_factory=list) # известные пометки: New, Changed, Review |
| + | problems: list = field(default_factory=list) # строки для отчёта |
| + | |
| + | |
| + | def parse_title(raw): |
| + | """`Bite (X) / Укус #New` → Title(en='Bite (X)', ru='Укус', tags=['New']).""" |
| + | text = plain_inline(raw).strip() |
| + | tags, problems = [], [] |
| + | while True: |
| + | m = TAG_TAIL_RE.search(text) |
| + | if not m: |
| + | break |
| + | tag = m.group(1).strip() |
| + | known = next((t for t in KNOWN_TAGS if t.lower() == tag.lower()), None) |
| + | if known: |
| + | tags.insert(0, known) |
| + | else: |
| + | problems.append(f"пометка «#{tag}» вместо #New/#Changed/#Review") |
| + | tags.insert(0, "Review") |
| + | text = text[: m.start()].rstrip() |
| + | if "#" in text: |
| + | problems.append(f"«#» внутри заголовка «{text}»") |
| + | |
| + | en, ru = split_names(text, problems) |
| + | return Title(text, en, ru, tags, problems) |
| + | |
| + | |
| + | def split_names(text, problems): |
| + | parts = re.split(r"\s+/\s+", text) |
| + | if len(parts) == 2: |
| + | en, ru = parts |
| + | elif len(parts) > 2: |
| + | # «A / B / Русское»: русская часть — последняя. |
| + | en, ru = " / ".join(parts[:-1]), parts[-1] |
| + | problems.append(f"несколько « / » в заголовке «{text}»") |
| + | else: |
| + | # «Leaf/ Лист»: разделитель без пробелов, справа кириллица. |
| + | m = re.match(r"^(.*?[^\s/])\s*/\s*([А-Яа-яЁё].*)$", text) |
| + | if m and not CYRILLIC_RE.search(m.group(1)): |
| + | en, ru = m.group(1), m.group(2) |
| + | problems.append(f"разделитель имён без пробелов: «{text}» (нужно « / »)") |
| + | elif CYRILLIC_RE.search(text): |
| + | en, ru = "", text |
| + | else: |
| + | en, ru = text, "" |
| + | en, ru = en.strip(), ru.strip() |
| + | # «Battle Cant (<Организация>)»: подстановка в угловых скобках — не имя. |
| + | if en and CYRILLIC_RE.search(re.sub(r"<[^>]*>", "", en)): |
| + | problems.append(f"кириллица в английском имени «{en}»") |
| + | return en, ru |
| + | |
| + | |
| + | # ─── Записи ───────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | @dataclass |
| + | class Entry: |
| + | doc: Document |
| + | index: int # номер заголовка в doc.headings |
| + | title: Title |
| + | groups: dict # {уровень: текст} заголовков выше |
| + | |
| + | @property |
| + | def path(self): |
| + | return self.doc.path |
| + | |
| + | @property |
| + | def line(self): |
| + | return self.doc.headings[self.index].line |
| + | |
| + | @property |
| + | def level(self): |
| + | return self.doc.headings[self.index].level |
| + | |
| + | @property |
| + | def name(self): |
| + | """Английское имя, а без него — русское (для ключей и отчёта).""" |
| + | return self.title.en or self.title.ru |
| + | |
| + | @property |
| + | def where(self): |
| + | return f"{self.path}:{self.line}" |
| + | |
| + | def body(self): |
| + | """Строки записи до следующего заголовка того же или старшего уровня, с подзаголовками.""" |
| + | return self.doc.numbered(self.line + 1, self.doc.section_end(self.index)) |
| + | |
| + | def own_body(self): |
| + | """Строки записи до любого следующего заголовка.""" |
| + | return self.doc.numbered(self.line + 1, self.doc.own_end(self.index)) |
| + | |
| + | def children(self, level=None): |
| + | """Подзаголовки записи как записи (по умолчанию — ближайшего уровня).""" |
| + | end = self.doc.section_end(self.index) |
| + | subs = [i for i in range(self.index + 1, len(self.doc.headings)) if self.doc.headings[i].line < end] |
| + | if not subs: |
| + | return [] |
| + | level = level or min(self.doc.headings[i].level for i in subs) |
| + | return [make_entry(self.doc, i) for i in subs if self.doc.headings[i].level == level] |
| + | |
| + | |
| + | def make_entry(doc, i): |
| + | return Entry(doc, i, parse_title(doc.headings[i].text), doc.groups(i)) |
| + | |
| + | |
| + | def entries(doc, level=4, within=None): |
| + | """Записи уровня level. within — фильтр по группам: функция groups → bool.""" |
| + | out = [] |
| + | for i, h in enumerate(doc.headings): |
| + | if h.level != level: |
| + | continue |
| + | e = make_entry(doc, i) |
| + | if within is None or within(e.groups): |
| + | out.append(e) |
| + | return out |
| + | |
| + | |
| + | def sections(doc, level): |
| + | """Заголовки уровня level как записи (для `## Трейты` и т.п.).""" |
| + | return [make_entry(doc, i) for i, h in enumerate(doc.headings) if h.level == level] |
| + | |
| + | |
| + | # ─── Поля ─────────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | @dataclass |
| + | class Field: |
| + | label: str |
| + | value: str # сырой Markdown значения, без хвостовых `,`/`|` |
| + | line: int |
| + | |
| + | |
| + | def line_fields(text, line=0): |
| + | """Поля строки `**A:** 1, **B:** x | **C:** y`, если строка начинается с метки; иначе [].""" |
| + | s = text.strip() |
| + | if not LABEL_RE.match(s): |
| + | return [] |
| + | marks = list(LABEL_RE.finditer(s)) |
| + | out = [] |
| + | for k, m in enumerate(marks): |
| + | end = marks[k + 1].start() if k + 1 < len(marks) else len(s) |
| + | value = s[m.end():end].strip() |
| + | value = re.sub(r"[\s,|]+$", "", value) |
| + | out.append(Field(m.group(1).strip(), value, line)) |
| + | return out |
| + | |
| + | |
| + | def split_fields(lines, open_labels=()): |
| + | """Блок полей в начале записи и остаток. |
| + | |
| + | lines — пары (номер, текст). Блок — подряд идущие строки с метками в |
| + | начале (пустые строки между ними пропускаются). Метка из open_labels |
| + | (например «Эффект») заканчивает блок: её значение продолжается текстом |
| + | ниже, и он попадает в остаток. |
| + | |
| + | Возвращает (поля, остаток строк). |
| + | """ |
| + | fields = [] |
| + | k = 0 |
| + | while k < len(lines): |
| + | n, text = lines[k] |
| + | if not text.strip(): |
| + | k += 1 |
| + | continue |
| + | fs = line_fields(text, n) |
| + | if not fs: |
| + | break |
| + | fields.extend(fs) |
| + | k += 1 |
| + | if any(f.label in open_labels for f in fs): |
| + | break |
| + | return fields, lines[k:] |
| + | |
| + | |
| + | def field_map(fields): |
| + | """{метка: первое поле}; повторы метки — во втором значении.""" |
| + | out, repeated = {}, [] |
| + | for f in fields: |
| + | if f.label in out: |
| + | repeated.append(f) |
| + | else: |
| + | out[f.label] = f |
| + | return out, repeated |
| + | |
| + | |
| + | # ─── Текст ────────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def unescape(text): |
| + | """Markdown-экранирование `\\*`, `\\|`, `\\–` → символ.""" |
| + | return re.sub(r"\\([^\w\s])", r"\1", text) |
| + | |
| + | |
| + | def plain_inline(text): |
| + | """Строка без разметки: ссылки → текст, без `**`/`__`, HTML-тегов и экранирования.""" |
| + | text = IMAGE_RE.sub("", text) |
| + | text = LINK_RE.sub(r"\1", text) |
| + | text = WIKILINK_RE.sub(lambda m: m.group(2) or m.group(1), text) |
| + | text = BR_RE.sub("\n", text) |
| + | text = HTML_TAG_RE.sub("", text) |
| + | text = text.replace("**", "").replace("__", "") |
| + | return unescape(text) |
| + | |
| + | |
| + | def plain_text(lines): |
| + | """Абзацы записи как простой текст: строки через `\\n`, абзацы через пустую строку. |
| + | |
| + | lines — пары (номер, текст) или строки. Заголовки внутри становятся |
| + | строками текста, разметка ссылок и выделения снимается. |
| + | """ |
| + | out = [] |
| + | for item in lines: |
| + | text = item[1] if isinstance(item, tuple) else item |
| + | m = HEADING_RE.match(text) |
| + | if m: |
| + | text = m.group(2) |
| + | out.append(plain_inline(text).rstrip()) |
| + | s = "\n".join(out) |
| + | s = re.sub(r"[ \t]+\n", "\n", s) |
| + | s = re.sub(r"\n{3,}", "\n\n", s) |
| + | return s.strip() |
| + | |
| + | |
| + | def has_cyrillic(text): |
| + | return bool(CYRILLIC_RE.search(text)) |
| + | |
| + | |
| + | # ─── Таблицы ──────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | @dataclass |
| + | class Table: |
| + | header: list # заголовки колонок, простым текстом |
| + | rows: list # строки: списки ячеек (сырой Markdown) |
| + | line: int # строка шапки |
| + | end: int # строка после таблицы (или после `}}` блока datatable) |
| + | options: dict = field(default_factory=dict) # `|paging=false` блока datatable |
| + | row_lines: list = field(default_factory=list) # номера строк rows |
| + | |
| + | def dicts(self): |
| + | """Строки как словари {шапка: ячейка}; лишние ячейки отбрасываются, недостающие — "".""" |
| + | return [{h: (row[k] if k < len(row) else "") for k, h in enumerate(self.header)} for row in self.rows] |
| + | |
| + | def column(self, *names): |
| + | """Индекс первой колонки, чья шапка (без пробелов, регистр не важен) есть в names, или None.""" |
| + | want = {n.lower() for n in names} |
| + | for k, h in enumerate(self.header): |
| + | if h.strip().lower() in want: |
| + | return k |
| + | return None |
| + | |
| + | |
| + | def split_row(text): |
| + | """`| a | b\\|c |` → ['a', 'b\\|c'] (экранированный `|` остаётся в ячейке).""" |
| + | s = text.strip() |
| + | if s.startswith("|"): |
| + | s = s[1:] |
| + | if s.endswith("|") and not s.endswith("\\|"): |
| + | s = s[:-1] |
| + | cells = re.split(r"(?<!\\)\|", s) |
| + | return [c.strip() for c in cells] |
| + | |
| + | |
| + | def split_br(cell): |
| + | """Профили ячейки, разделённые `<br>`.""" |
| + | return [p.strip() for p in BR_RE.split(cell)] |
| + | |
| + | |
| + | def cell_link(cell): |
| + | """`[Имя / Name](#anchor)` → ('Имя / Name', '#anchor'); без ссылки → (текст, '').""" |
| + | m = LINK_RE.search(cell) |
| + | if m: |
| + | return plain_inline(m.group(1)).strip(), m.group(2) |
| + | return plain_inline(cell).strip(), "" |
| + | |
| + | |
| + | def tables(lines): |
| + | """Таблицы в строках (пары (номер, текст)): Markdown и `{{datatable … }}`.""" |
| + | out = [] |
| + | k = 0 |
| + | while k < len(lines): |
| + | n, text = lines[k] |
| + | s = text.strip() |
| + | options = {} |
| + | if s.lower().startswith("{{datatable"): |
| + | k += 1 |
| + | while k < len(lines) and re.match(r"^\s*\|\s*\w+\s*=", lines[k][1]): |
| + | key, _, value = lines[k][1].strip()[1:].partition("=") |
| + | options[key.strip()] = value.strip() |
| + | k += 1 |
| + | if k >= len(lines): |
| + | break |
| + | n, text = lines[k] |
| + | s = text.strip() |
| + | if (s.startswith("|") and k + 1 < len(lines) and TABLE_SEP_RE.match(lines[k + 1][1].strip())): |
| + | header = [plain_inline(c).strip() for c in split_row(s)] |
| + | rows, row_lines = [], [] |
| + | k += 2 |
| + | while k < len(lines) and lines[k][1].strip().startswith("|"): |
| + | rows.append(split_row(lines[k][1])) |
| + | row_lines.append(lines[k][0]) |
| + | k += 1 |
| + | if options or (k < len(lines) and lines[k][1].strip() == "}}"): |
| + | if k < len(lines) and lines[k][1].strip() == "}}": |
| + | k += 1 |
| + | end = lines[k][0] if k < len(lines) else (lines[-1][0] + 1 if lines else n + 1) |
| + | out.append(Table(header, rows, n, end, options, row_lines)) |
| + | continue |
| + | k += 1 |
| + | return out |
| + | |
| + | |
| + | def without_lines(lines, start, end): |
| + | """Строки (пары) без диапазона номеров [start, end).""" |
| + | return [(n, t) for n, t in lines if not (start <= n < end)] |
| /dev/null .. .scripts/wikilinks.py | |
| @@ 0,0 1,307 @@ | |
| + | """Ссылки на заголовки вики (An Otter Wiki v2.25). |
| + | |
| + | python .scripts/wikilinks.py check проверить все ссылки |
| + | python .scripts/wikilinks.py tables показать, что поменяется в таблицах |
| + | python .scripts/wikilinks.py tables --write записать изменения |
| + | |
| + | id заголовка считается так же, как в движке: NFKD → выкинуть не-ASCII → |
| + | strip → lower → удалить пунктуацию → пробелы/дефисы в один дефис. |
| + | Поэтому «Steel / Сталь» → steel-, а заголовок только из кириллицы получает |
| + | номер по порядку (-1, -2…). |
| + | """ |
| + | |
| + | import re |
| + | import sys |
| + | import unicodedata |
| + | from dataclasses import dataclass, field |
| + | from pathlib import Path |
| + | from urllib.parse import unquote |
| + | |
| + | ROOT = Path(__file__).resolve().parent.parent |
| + | SKIP_DIRS = {".git", ".scripts", "_docs"} |
| + | ITEM_SECTION = "iv арсенал/" |
| + | |
| + | HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$") |
| + | FENCE_RE = re.compile(r"^[ \t]*(```|~~~)") |
| + | LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(([^)\s]*)\)") |
| + | WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]") |
| + | TABLE_SEP_RE = re.compile(r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$") |
| + | |
| + | |
| + | def slugify(text): |
| + | s = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() |
| + | s = s.strip().lower() |
| + | s = re.sub(r"[^\w\s-]", "", s) |
| + | return re.sub(r"[-\s]+", "-", s) |
| + | |
| + | |
| + | def plain(text): |
| + | """Текст ячейки/заголовка без разметки ссылок и выделения.""" |
| + | text = LINK_RE.sub(r"\1", text) |
| + | return re.sub(r"[*_`]", "", text).strip() |
| + | |
| + | |
| + | def keys(text): |
| + | """Варианты имени для сопоставления: целиком, английская и русская часть. |
| + | |
| + | Скобки в конце («(15 м)», «(тяжёлый)») отбрасываются. |
| + | """ |
| + | text = plain(text).replace("’", "'").replace("ё", "е").replace("Ё", "Е") |
| + | out = set() |
| + | for part in [text, *text.split(" / ")]: |
| + | part = re.sub(r"\s*\([^)]*\)\s*$", "", part).strip().lower() |
| + | if part: |
| + | out.add(part) |
| + | return out |
| + | |
| + | |
| + | @dataclass |
| + | class Heading: |
| + | text: str |
| + | id: str |
| + | line: int |
| + | |
| + | @property |
| + | def stable(self): |
| + | # Заголовок без латиницы получает номер по порядку и сдвигается. |
| + | return slugify(self.text) != "" |
| + | |
| + | |
| + | @dataclass |
| + | class Page: |
| + | path: Path |
| + | lines: list = field(default_factory=list) |
| + | headings: list = field(default_factory=list) |
| + | |
| + | @property |
| + | def rel(self): |
| + | return self.path.relative_to(ROOT).with_suffix("").as_posix() |
| + | |
| + | @property |
| + | def url(self): |
| + | # Как в рабочих ссылках: каждое слово с заглавной, пробелы как %20. |
| + | parts = [" ".join(w[:1].upper() + w[1:] for w in p.split(" ")) |
| + | for p in self.rel.split("/")] |
| + | return "/" + "/".join(parts).replace(" ", "%20") |
| + | |
| + | @property |
| + | def ids(self): |
| + | return {h.id: h for h in self.headings} |
| + | |
| + | |
| + | def load_page(path): |
| + | with open(path, encoding="utf-8", newline="") as f: |
| + | lines = f.read().splitlines(keepends=True) |
| + | page = Page(path, lines) |
| + | seen = {} |
| + | fenced = False |
| + | for n, line in enumerate(lines, 1): |
| + | if FENCE_RE.match(line): |
| + | fenced = not fenced |
| + | continue |
| + | m = not fenced and HEADING_RE.match(line.rstrip("\r\n")) |
| + | if not m: |
| + | continue |
| + | slug = slugify(m.group(2)) |
| + | if slug in seen: |
| + | seen[slug] += 1 |
| + | hid = f"{slug}-{seen[slug]}" |
| + | else: |
| + | seen[slug] = 0 |
| + | hid = slug |
| + | page.headings.append(Heading(m.group(2), hid, n)) |
| + | return page |
| + | |
| + | |
| + | def load_wiki(): |
| + | pages = {} |
| + | for path in sorted(ROOT.rglob("*.md")): |
| + | if SKIP_DIRS & set(path.relative_to(ROOT).parts): |
| + | continue |
| + | page = load_page(path) |
| + | pages[page.rel.lower()] = page |
| + | return pages |
| + | |
| + | |
| + | def resolve(pages, page, target): |
| + | """Разбирает адрес ссылки. Возвращает (страница, якорь) или текст ошибки.""" |
| + | path, _, anchor = target.partition("#") |
| + | if not path: |
| + | return page, anchor |
| + | if not path.startswith("/"): |
| + | return "относительный путь — откроется не та страница" |
| + | other = pages.get(unquote(path).strip("/").lower()) |
| + | if other is None: |
| + | return "страница не найдена" |
| + | return other, anchor |
| + | |
| + | |
| + | def check(pages): |
| + | problems = 0 |
| + | for page in pages.values(): |
| + | fenced = False |
| + | for n, line in enumerate(page.lines, 1): |
| + | if FENCE_RE.match(line): |
| + | fenced = not fenced |
| + | if fenced: |
| + | continue |
| + | for m in LINK_RE.finditer(line): |
| + | text, target = m.groups() |
| + | if not target or re.match(r"^[a-z]+:", target): |
| + | continue |
| + | res = resolve(pages, page, target) |
| + | if isinstance(res, str): |
| + | msg = res |
| + | else: |
| + | other, anchor = res |
| + | h = other.ids.get(anchor) if anchor else None |
| + | if anchor and h is None: |
| + | msg = "якоря нет" |
| + | hint = find_heading(pages, page, text) |
| + | if isinstance(hint, tuple): |
| + | msg += f"; по тексту подходит {link_target(page, *hint)}" |
| + | elif h is not None and not h.stable: |
| + | msg = f"якорь на заголовок без латиницы «{h.text}» — сдвинется" |
| + | else: |
| + | continue |
| + | problems += 1 |
| + | print(f"{page.rel}.md:{n}: [{text}]({target}) — {msg}") |
| + | for m in WIKILINK_RE.finditer(line): |
| + | inner = m.group(1) |
| + | target = inner.split("|", 1)[1] if "|" in inner else inner |
| + | path = target.partition("#")[0].strip("/").lower() |
| + | if path and path not in pages: |
| + | problems += 1 |
| + | print(f"{page.rel}.md:{n}: [[{inner}]] — страница не найдена") |
| + | print(f"\nпроблем: {problems}") |
| + | return problems |
| + | |
| + | |
| + | def find_heading(pages, page, text): |
| + | """Ищет карточку для имени: сначала на этой странице, потом в том же разделе. |
| + | |
| + | Возвращает (страница, заголовок) или текст ошибки. |
| + | """ |
| + | want = keys(text) |
| + | if not want: |
| + | return "пустое имя" |
| + | section = page.rel.split("/")[0] |
| + | scopes = [[page], [p for p in pages.values() |
| + | if p is not page and p.rel.split("/")[0] == section]] |
| + | for scope in scopes: |
| + | found = [(p, h) for p in scope for h in p.headings |
| + | if h.line > 1 and want & keys(h.text)] |
| + | if len(found) == 1: |
| + | p, h = found[0] |
| + | if not h.stable: |
| + | return f"карточка «{h.text}» без английской части — якорь сдвинется" |
| + | return found[0] |
| + | if found: |
| + | where = ", ".join(f"{p.rel}#{h.id}" for p, h in found) |
| + | return f"несколько карточек: {where}" |
| + | return "карточка не найдена" |
| + | |
| + | |
| + | def link_target(page, other, heading): |
| + | prefix = "" if other is page else other.url |
| + | return f"{prefix}#{heading.id}" |
| + | |
| + | |
| + | def split_row(line): |
| + | body = line.rstrip("\r\n") |
| + | eol = line[len(body):] |
| + | cells = re.split(r"(?<!\\)\|", body) |
| + | return cells, eol |
| + | |
| + | |
| + | def table_rows(page): |
| + | """Строки данных таблиц страницы, сгруппированные по таблицам: [[индекс строки]].""" |
| + | tables, rows = [], None |
| + | for i, line in enumerate(page.lines): |
| + | stripped = line.strip() |
| + | if not stripped.startswith("|"): |
| + | rows = None |
| + | elif TABLE_SEP_RE.match(stripped): |
| + | rows = [] |
| + | tables.append(rows) |
| + | elif rows is not None: |
| + | rows.append(i) |
| + | return tables |
| + | |
| + | |
| + | def name_cell_fix(pages, page, name): |
| + | """Что сделать с ячейкой названия. |
| + | |
| + | Возвращает (новый текст или None, ошибка или None, есть ли у строки карточка). |
| + | """ |
| + | m = LINK_RE.search(name) |
| + | if m: |
| + | res = resolve(pages, page, m.group(2)) |
| + | if not isinstance(res, str): |
| + | other, anchor = res |
| + | if not anchor or anchor in other.ids: |
| + | return None, None, True # ссылка рабочая, не трогаем |
| + | found = find_heading(pages, page, m.group(1)) |
| + | if isinstance(found, str): |
| + | return None, f"ссылка сломана, {found}", True |
| + | return name[:m.start(2)] + link_target(page, *found) + name[m.end(2):], None, True |
| + | found = find_heading(pages, page, name) |
| + | if isinstance(found, str): |
| + | return None, found, False |
| + | return f"[{name}]({link_target(page, *found)})", None, True |
| + | |
| + | |
| + | def tables(pages, write): |
| + | """Ссылки из первого столбца таблиц Арсенала на карточки предметов. |
| + | |
| + | Таблицы, где ни у одной строки нет карточки (базовое оружие, таблицы |
| + | внутри карточек), пропускаются молча. |
| + | """ |
| + | changed = unresolved = 0 |
| + | for page in pages.values(): |
| + | if not page.rel.startswith(ITEM_SECTION): |
| + | continue |
| + | dirty = False |
| + | for rows in table_rows(page): |
| + | results = [] |
| + | for i in rows: |
| + | cells, eol = split_row(page.lines[i]) |
| + | name = cells[1].strip() if len(cells) > 2 else "" |
| + | if not re.search(r"[A-Za-zА-Яа-яЁё]", name): |
| + | continue |
| + | results.append((i, cells, eol, name, *name_cell_fix(pages, page, name))) |
| + | if not any(has_card for *_, has_card in results): |
| + | continue |
| + | for i, cells, eol, name, new, error, _ in results: |
| + | where = f"{page.rel}.md:{i + 1}" |
| + | if error: |
| + | unresolved += 1 |
| + | print(f"{where}: {name} — {error}") |
| + | elif new: |
| + | print(f"{where}: {name} → {new}") |
| + | cells[1] = cells[1].replace(name, new, 1) |
| + | page.lines[i] = "|".join(cells) + eol |
| + | dirty = True |
| + | changed += 1 |
| + | if dirty and write: |
| + | with open(page.path, "w", encoding="utf-8", newline="") as f: |
| + | f.write("".join(page.lines)) |
| + | verb = "изменено" if write else "будет изменено" |
| + | print(f"\n{verb}: {changed}, не удалось: {unresolved}") |
| + | |
| + | |
| + | def main(): |
| + | sys.stdout.reconfigure(encoding="utf-8") |
| + | args = sys.argv[1:] |
| + | if not args or args[0] not in {"check", "tables"}: |
| + | print(__doc__) |
| + | return 2 |
| + | pages = load_wiki() |
| + | if args[0] == "check": |
| + | return 1 if check(pages) else 0 |
| + | tables(pages, write="--write" in args) |
| + | return 0 |
| + | |
| + | |
| + | if __name__ == "__main__": |
| + | sys.exit(main()) |