Commit 8af3c6

2026-10-22 15:14:53 iociveteres: feat(gamedata): generate arsenal and condition assets from the wiki build.py now also writes melee, ranged, gear, cybernetics, armour_mods, weapon_mods and conditions in the shapes of the Black Crusade assets. Items are rows of the arsenal tables described by the entry their link points to; the mechanics of chemistry, statuses and spell buffs come from the BC assets through bc_names.json, and the report lists pairs whose texts differ, BC numbers missing from the PF text, odd profiles, rows without links and repeated English names.
/dev/null .. .scripts/gamedata/arsenal.py
@@ 0,0 1,639 @@
+"""Домен арсенала: оружие, щиты, броня, снаряжение, протезы и модификации.
+
+Записи для melee.json, ranged.json, gear.json, cybernetics.json,
+armour_mods.json и weapon_mods.json в формах ассетов BC
+(`internal/gamedata/*.go` чарника). Механика химии из BC — в bc.py.
+
+Предмет — строка таблицы раздела; его описание — запись `####`, на якорь
+которой ведёт ссылка в строке (у оружия — запись с тем же именем, если
+есть). Несколько строк могут вести на одну общую запись (Hooded и Bullseye
+Lantern → Lantern): каждая строка — свой предмет с описанием записи.
+Записи, на которые не ведёт ни одна строка, — предметы сами по себе.
+"""
+
+import re
+
+import wiki
+from character import names
+
+ARSENAL = "iv арсенал"
+MELEE_FILES = ("1 рукопашное оружие/1 базовое.md", "1 рукопашное оружие/2 особое.md")
+SHIELDS_FILE = "1 рукопашное оружие/3 щиты.md"
+RANGED_FILES = ("2 дальнобойное оружие/1 базовое.md", "2 дальнобойное оружие/2 особое.md")
+ARMOUR_FILE = "3 броня/1 базовая.md"
+ARMOUR_MODS_FILE = "3 броня/2 модификации.md"
+WEAPON_MODS_FILES = (
+ ("1 рукопашное оружие/4 модификации.md", "Рукопашное"),
+ ("2 дальнобойное оружие/3 модификации.md", "Дальнобойное"),
+)
+CYBERNETICS_GLOB = "8 бионика и кибернетика/*.md"
+
+# Снаряжение: (шаблон файлов, gearType или {группа `##` записи: gearType}).
+# Первый подходящий шаблон решает; entryType — заголовок `#` файла.
+GEAR_FILES = (
+ ("4 экипировка/*.md", "gear"),
+ ("5 инструмент/*.md", "tool"),
+ ("6 химия/*.md", "consumable"),
+ ("9 товары и услуги/01 *.md", "consumable"),
+ ("9 товары и услуги/02 *.md", ""),
+ ("9 товары и услуги/06 *.md", "tool"),
+ ("9 товары и услуги/10 *.md", "tool"),
+ ("9 товары и услуги/13 *.md", {"Животные": "mount", "Сбруя": "gear", "Повозки": "mount", "Суда": "mount"}),
+ ("9 товары и услуги/*.md", "gear"),
+)
+
+# entryType оружия и брони: в BC — вид оружия, в вики — раздел арсенала.
+MELEE_TYPE = "Рукопашное оружие"
+SHIELD_TYPE = "Щиты"
+RANGED_TYPE = "Дальнобойное оружие"
+ARMOUR_TYPE = "Броня"
+
+# Метки записей арсенала; остальные — в отчёт.
+STAT_LABELS = ("Редкость", "Вес", "Цена")
+QUALITY_LABELS = ("Poor.Q", "Comm.Q", "Good.Q", "Best.Q")
+CHEMISTRY_LABELS = ("Приём", "Эффект", "Пост-Эффект", "Зависимость")
+LABELS = STAT_LABELS + QUALITY_LABELS + CHEMISTRY_LABELS + ("Варианты", "Требование", "Пример")
+# Метки, перед которыми описание BC ставит пустую строку.
+BLOCK_LABELS = QUALITY_LABELS + ("Приём", "Варианты")
+
+# Тип профиля рукопашного оружия (колонка «Тип») → select листа (MELEE_PROFILES).
+MELEE_PROFILES = {
+ "кулак": "fist", "кулак.б": "fist.a", "нож": "knife", "когти.р": "claws.h", "когти.п": "claws.a",
+ "меч": "sword", "рапира": "rapier", "сабля": "saber", "топор": "axe", "крюк": "hook",
+ "молот": "hammer", "булава": "mace", "кистень": "flail", "копьё": "spear", "посох": "staff",
+ "глефа": "glaive", "кнут": "whip", "штык": "bayonet", "щит": "shield", "укус": "bite",
+ "—": "no", "–": "no", "-": "no",
+}
+MELEE_GROUPS = {"primary", "primary (shield)", "chain", "shock", "power", "exotic", "mechadendrite"}
+SHIELD_SUBTYPES = {
+ "баклер": "buckler", "тарг": "targ", "экю": "ecu", "круглый": "round", "каплевидный": "teardrop",
+ "лёгкий башенный": "light tower", "башенный": "tower",
+}
+RANGED_CLASSES = {"pistol", "rifle", "long rifle", "heavy", "throwing", "grenade", "special"}
+DAMAGE_TYPES = {"I", "I(Cr)", "R", "X", "X(Fr)", "E", "E(El)", "E(Ls)", "E(Fl)", "C", "C(Tx)"}
+
+DICE = r"(?:\d*d\d+|\d+|[A-Z]{1,2}\.b)"
+DAMAGE_RE = re.compile(rf"^{DICE}(?:[+\-–]{DICE})*$")
+ROF_RE = re.compile(r"S|\d+|[–—-]")
+EMPTY = {"", "-", "–", "—"}
+
+
+def path(rel):
+ return wiki.ROOT / ARSENAL / rel
+
+
+# ─── Страницы и ссылки ──────────────────────────────────────────────────────
+
+
+class Page:
+ """Страница арсенала: записи `####`, их якоря и таблицы."""
+
+ def __init__(self, doc):
+ self.doc = doc
+ self.anchors = doc.anchors()
+ self.entries = wiki.entries(doc, 4)
+ self.tables = wiki.tables(doc.numbered(1, len(doc.lines) + 1))
+ self.used = set() # номера заголовков записей, на которые ведут строки
+
+ def heading(self):
+ """Заголовок `#` страницы — раздел, entryType предметов."""
+ h = next((h for h in self.doc.headings if h.level == 1), None)
+ return wiki.parse_title(h.text).text if h else ""
+
+ def by_name(self, en):
+ """Запись с английским именем en (без учёта регистра) или None."""
+ en = en.lower()
+ return next((e for e in self.entries if e.title.en.lower() == en), None)
+
+
+class Index:
+ """Страницы арсенала по ключу ссылки (`iv арсенал/6 химия/2 наркотики`)."""
+
+ def __init__(self):
+ self.pages = {}
+
+ def page(self, p):
+ doc = wiki.read_document(p)
+ if doc.key not in self.pages:
+ self.pages[doc.key] = Page(doc)
+ return self.pages[doc.key]
+
+ def resolve(self, page, target):
+ """Запись, на которую ведёт ссылка со страницы page: (Page, Entry) или (None, текст проблемы)."""
+ key, anchor = wiki.link_target(page.doc, target)
+ other = self.pages.get(key)
+ if other is None:
+ files = {wiki.rel(q).lower(): q for q in wiki.md_files("**/*.md")}
+ if key + ".md" not in files:
+ return None, f"ссылка «{target}» — страница не найдена"
+ other = self.page(files[key + ".md"])
+ i = other.anchors.get(anchor)
+ if i is None:
+ return None, f"ссылка «{target}» — нет такого заголовка"
+ if other.doc.headings[i].level != 4:
+ return None, f"ссылка «{target}» ведёт на раздел, а не на запись `####`"
+ return other, wiki.make_entry(other.doc, i)
+
+
+# ─── Значения ───────────────────────────────────────────────────────────────
+
+
+def cell(value):
+ return wiki.plain_inline(value).strip()
+
+
+def parse_weight(value):
+ """«1.5», «+1.5 кг», «2кг» → 1.5; пусто, «-», «по варианту», «+10%» → None."""
+ s = cell(value).replace(",", ".")
+ m = re.match(r"^\+?(\d+(?:\.\d+)?)\s*(?:кг)?$", s)
+ return float(m.group(1)) if m else None
+
+
+def weight_of(value, where, report, what="вес"):
+ """Вес ячейки или поля; не число (кроме пустого и «по варианту») — в отчёт, 0."""
+ w = parse_weight(value)
+ if w is not None:
+ return w
+ s = cell(value)
+ if s not in EMPTY and s != "по варианту":
+ report.add("value", where, f"{what} «{s}» — не число, выгружен 0", group=f"{what} не число")
+ return 0.0
+
+
+def entry_description(e, extra=()):
+ """Описание записи как в BC: текст без строки «Редкость | Вес | Цена», поля «Метка: значение».
+
+ extra — строки в начало описания. Возвращает (поля записи, текст).
+ """
+ fields, lines = [], []
+ for n, t in e.body():
+ fs = wiki.line_fields(t, n)
+ fields += fs
+ if fs and all(f.label in STAT_LABELS for f in fs):
+ continue
+ if fs and fs[0].label in BLOCK_LABELS and lines and lines[-1][1].strip() and not _after_block(lines):
+ lines.append((n, ""))
+ lines.append((n, t))
+ text = wiki.plain_text(lines)
+ return fields, "\n".join([*extra, text] if text else extra)
+
+
+def _after_block(lines):
+ """Уже был блок с меткой из BLOCK_LABELS (пустую строку ставим только перед первым)."""
+ return any((fs := wiki.line_fields(t)) and fs[0].label in BLOCK_LABELS for _, t in lines)
+
+
+def check_labels(e, fields, report, labels=LABELS):
+ for f in fields:
+ if f.label not in labels:
+ report.entry("label", e, f"неизвестная метка «{f.label}»", group="неизвестная метка")
+
+
+def field_value(fields, label):
+ return next((f.value for f in fields if f.label == label), None)
+
+
+def parse_damage(text, where, report):
+ """`1d10–3+S.b R` → ('1d10–3+S.b', 'R'): тип урона — последний токен."""
+ s = cell(text)
+ m = re.match(r"^(.*\S)\s+(\S+)$", s)
+ if not m:
+ report.add("profile", where, f"Dmg «{s}» — нет типа урона")
+ return s, ""
+ damage, kind = m.group(1), m.group(2)
+ if kind not in DAMAGE_TYPES:
+ report.add("profile", where, f"тип урона «{kind}» не из списка листа ({', '.join(sorted(DAMAGE_TYPES))})")
+ if not DAMAGE_RE.match(damage.replace(" ", "")):
+ report.add("profile", where, f"Dmg «{damage}» — не выражение урона")
+ return damage, kind
+
+
+def parse_rof(text, where, report):
+ """`S\\–\\–`, `S/2/–` → ('S', '–', '–')."""
+ s = cell(text)
+ parts = [p.strip() for p in s.split("/")] if "/" in s else ROF_RE.findall(s)
+ if len(parts) != 3 or ("/" not in s and "".join(parts) != s.replace(" ", "")):
+ report.add("profile", where, f"RoF «{s}» — не три значения S/x/x")
+ parts = (parts + ["", "", ""])[:3]
+ return [p if p not in ("-", "—") else "–" for p in parts]
+
+
+def parse_ap(text, where, report):
+ """`–/4/4/4` → {head, torso, arms, legs}."""
+ s = cell(text)
+ parts = [p.strip() for p in s.split("/")]
+ if len(parts) != 4 or not all(re.match(r"^(\d+|[–—-])$", p) for p in parts):
+ report.add("profile", where, f"AP «{s}» — не четыре значения голова/торс/руки/ноги")
+ parts = (parts + ["", "", "", ""])[:4]
+ parts = ["–" if p in ("-", "—") else p for p in parts]
+ return dict(zip(("head", "torso", "arms", "legs"), parts))
+
+
+# ─── Строки таблиц ──────────────────────────────────────────────────────────
+
+
+class Row:
+ """Строка таблицы: имя, запись описания и ячейки по шапке."""
+
+ def __init__(self, page, table, k, name_col):
+ self.page = page
+ self.cells = table.dicts()[k]
+ self.line = table.row_lines[k]
+ text, self.target = wiki.cell_link(self.cells[name_col])
+ self.title = wiki.parse_title(text)
+ self.entry = None # wiki.Entry описания
+ self.entry_page = None
+
+ @property
+ def where(self):
+ return f"{self.page.doc.path}:{self.line}"
+
+ @property
+ def name(self):
+ return self.title.en or self.title.ru
+
+ def names(self):
+ """{name, name_ru}: из строки, русское — из записи, если в строке его нет."""
+ en = self.title.en or (self.entry.title.en if self.entry and not self.title.ru else "")
+ ru = self.title.ru or (self.entry.title.ru if self.entry and self.entry.title.en.lower() == self.title.en.lower() else "")
+ out = {"name": en or ru}
+ if en and ru:
+ out["name_ru"] = ru
+ return out
+
+
+def rows(page, index, report, link_required=True):
+ """Строки всех таблиц страницы с найденными записями описаний."""
+ out = []
+ for t in page.tables:
+ col = t.column("Название", "Модификация")
+ if col is None:
+ report.add("format", f"{page.doc.path}:{t.line}", f"таблица без колонки «Название»: {t.header}")
+ continue
+ for k in range(len(t.rows)):
+ r = Row(page, t, k, t.header[col])
+ for p in r.title.problems:
+ report.add("format", r.where, p)
+ for tag in r.title.tags:
+ report.tags[tag] += 1
+ if not r.title.en:
+ report.add("name", r.where, f"{r.name}: нет английского имени в строке таблицы (нужно `[English / Русское](#якорь)`)")
+ if r.target:
+ other, e = index.resolve(page, r.target)
+ if other is None:
+ report.add("link", r.where, f"{r.name}: {e}")
+ else:
+ r.entry_page, r.entry = other, e
+ if other is page: # строка другой страницы — ссылка, запись там остаётся предметом
+ other.used.add(e.index)
+ else:
+ e = page.by_name(r.title.en) if r.title.en else None
+ if e is not None:
+ r.entry_page, r.entry = page, e
+ page.used.add(e.index)
+ report.add("link", r.where, f"{r.name}: строка без ссылки на запись {e.where}", group="строка без ссылки на запись")
+ elif link_required:
+ report.add("link", r.where, f"{r.name}: строка без ссылки и без записи", group="строка без ссылки и без записи")
+ out.append(r)
+ return out
+
+
+def row_description(r):
+ """Описание строки — текст её записи (поля записи проверяются один раз, в check_entries)."""
+ if r.entry is None:
+ return ""
+ return entry_description(r.entry)[1]
+
+
+# ─── Оружие ─────────────────────────────────────────────────────────────────
+
+
+class Weapon:
+ """Строка таблицы оружия или щита: записи melee/ranged и gear."""
+
+ def __init__(self, row, record, weight, entry_type):
+ self.row, self.record, self.weight, self.entry_type = row, record, weight, entry_type
+
+ @property
+ def name(self):
+ return self.record["name"]
+
+ @property
+ def where(self):
+ return self.row.where
+
+ def gear(self):
+ return gear_record(self.record, self.weight, self.record["description"], self.entry_type, "weapon")
+
+
+def gear_record(name_fields, weight, description, entry_type, gear_type, armour=None, conditions=None):
+ out = {"name": name_fields["name"]}
+ if "name_ru" in name_fields:
+ out["name_ru"] = name_fields["name_ru"]
+ out.update({
+ "weight": weight,
+ "armour": armour or {},
+ "conditions": conditions or [],
+ "description": description,
+ "entryType": entry_type,
+ "gearType": gear_type,
+ })
+ return out
+
+
+def split_profiles(r, columns, report):
+ """Колонки с профилями через `<br>`: [{колонка: значение}] по профилям."""
+ parts = {c: wiki.split_br(r.cells.get(c, "")) for c in columns}
+ n = max(len(v) for v in parts.values())
+ for c, v in parts.items():
+ if len(v) not in (1, n):
+ report.add("profile", r.where, f"{r.name}: в «{c}» {len(v)} профилей через <br>, в других {n}")
+ return [{c: (v[k] if k < len(v) else v[-1]) for c, v in parts.items()} for k in range(n)]
+
+
+def melee_weapon(r, report, shield=False):
+ c = r.cells
+ group = cell(c.get("Группа", ""))
+ if group.lower() not in MELEE_GROUPS:
+ report.add("profile", r.where, f"{r.name}: группа «{group}» не из списка листа")
+ tabs = []
+ for p in split_profiles(r, ("Тип", "Rng", "Dmg", "Pen", "Свойства") if not shield else ("Rng", "Dmg", "Pen", "Свойства"), report):
+ damage, kind = parse_damage(p["Dmg"], r.where, report)
+ profile = ""
+ if not shield:
+ raw = cell(p["Тип"])
+ profile = MELEE_PROFILES.get(raw.lower(), "")
+ if raw.lower() not in MELEE_PROFILES:
+ report.add("profile", r.where, f"{r.name}: тип профиля «{raw}» не из списка листа")
+ tabs.append({
+ "profile": profile,
+ "range": cell(p["Rng"]),
+ "damage": damage,
+ "pen": cell(p["Pen"]),
+ "damageType": kind,
+ "special": cell(p["Свойства"]),
+ })
+ shield_rec = {}
+ if shield:
+ raw = cell(c.get("Подтип", ""))
+ subtype = SHIELD_SUBTYPES.get(raw.lower(), "")
+ if not subtype:
+ report.add("profile", r.where, f"{r.name}: подтип щита «{raw}» не из списка листа")
+ ap = cell(c.get("AP", ""))
+ if not ap.isdigit():
+ report.add("profile", r.where, f"{r.name}: AP щита «{ap}» — не число")
+ sectors = cell(c.get("Защита", ""))
+ shield_rec = {"subtype": subtype, "ap": int(ap) if ap.isdigit() else 0,
+ "defenseSectors": "" if sectors in EMPTY else sectors}
+ return {
+ **r.names(),
+ "group": group,
+ "grip": cell(c.get("Хват", "")),
+ "balance": cell(c.get("Bl", "")),
+ "upgrades": "",
+ "tabs": tabs,
+ "shield": shield_rec,
+ "description": row_description(r),
+ }
+
+
+def ranged_weapon(r, report):
+ c = r.cells
+ kinds = wiki.split_br(c.get("Тип", ""))
+ cls = cell(kinds[-1]).lower() if len(kinds) > 1 else ""
+ if cls not in RANGED_CLASSES:
+ report.add("profile", r.where, f"{r.name}: класс «{cell(c.get('Тип', ''))}» — нужно `Вид<br>класс`, класс из {sorted(RANGED_CLASSES)}")
+ damage, kind = parse_damage(c.get("Dmg", ""), r.where, report)
+ rof = parse_rof(c.get("RoF", ""), r.where, report)
+ clip = cell(c.get("Clip", ""))
+ return {
+ **r.names(),
+ "class": cls,
+ "range": cell(c.get("Rng", "")),
+ "damage": damage,
+ "pen": cell(c.get("Pen", "")),
+ "damageType": kind,
+ "rofSingle": rof[0],
+ "rofShort": rof[1],
+ "rofLong": rof[2],
+ "clipCur": clip,
+ "clipMax": clip,
+ "reload": cell(c.get("Rld", "")),
+ "special": cell(c.get("Свойства", "")),
+ "upgrades": "",
+ "description": row_description(r),
+ }
+
+
+def table_weight(r, report):
+ if "Wt" not in r.cells:
+ return 0.0
+ s = cell(r.cells["Wt"])
+ if "кг" in s:
+ report.add("value", r.where, f"{r.name}: Wt «{s}» с единицей — в таблицах вес без «кг»", group="Wt с единицей")
+ return weight_of(r.cells["Wt"], r.where, report, "Wt")
+
+
+def weapons(index, report):
+ """(melee, ranged, щиты): списки Weapon в порядке таблиц."""
+ melee, ranged, shields = [], [], []
+ for f in MELEE_FILES:
+ page = index.page(path(f))
+ check_entries(page, report)
+ for r in rows(page, index, report, link_required=False):
+ rec = melee_weapon(r, report)
+ melee.append(Weapon(r, {**rec, "entryType": MELEE_TYPE}, table_weight(r, report), MELEE_TYPE))
+ page = index.page(path(SHIELDS_FILE))
+ check_entries(page, report)
+ for r in rows(page, index, report, link_required=False):
+ rec = melee_weapon(r, report, shield=True)
+ shields.append(Weapon(r, {**rec, "entryType": SHIELD_TYPE}, table_weight(r, report), SHIELD_TYPE))
+ for f in RANGED_FILES:
+ page = index.page(path(f))
+ check_entries(page, report)
+ for r in rows(page, index, report, link_required=False):
+ rec = ranged_weapon(r, report)
+ ranged.append(Weapon(r, {**rec, "entryType": RANGED_TYPE}, table_weight(r, report), RANGED_TYPE))
+ return melee, ranged, shields
+
+
+# ─── Броня ──────────────────────────────────────────────────────────────────
+
+
+class Item:
+ """Предмет снаряжения (строка таблицы или запись): запись gear.json и место для отчёта."""
+
+ def __init__(self, record, where, entry=None, row=None):
+ self.record, self.where, self.entry, self.row = record, where, entry, row
+
+ @property
+ def name(self):
+ return self.record["name"]
+
+
+def armour(index, report):
+ page = index.page(path(ARMOUR_FILE))
+ out = []
+ for r in rows(page, index, report):
+ c = r.cells
+ cap = cell(c.get("Max.A", ""))
+ conditions = []
+ if cap not in EMPTY:
+ if cap.isdigit():
+ conditions.append({"type": "CharacteristicCap", "name": "A", "value": cap})
+ else:
+ report.add("profile", r.where, f"{r.name}: Max.A «{cap}» — не число")
+ rec = gear_record(r.names(), table_weight(r, report), row_description(r), ARMOUR_TYPE, "armour",
+ armour={"ap": parse_ap(c.get("AP", ""), r.where, report),
+ "special": cell(c.get("Свойства", "")), "upgrades": ""},
+ conditions=conditions)
+ out.append(Item(rec, r.where, r.entry, r))
+ check_entries(page, report)
+ return out
+
+
+def check_entries(page, report, labels=LABELS, need_ru=False):
+ """Имена и метки записей страницы; записи без строки в таблице — в отчёт."""
+ for e in page.entries:
+ 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 / Русское`)", group="нет русского имени")
+ fields, _ = entry_description(e)
+ check_labels(e, fields, report, labels)
+
+
+# ─── Снаряжение ─────────────────────────────────────────────────────────────
+
+
+def gear_type_for(rule, e):
+ if isinstance(rule, str):
+ return rule
+ group = e.groups.get(2, "") if e is not None else ""
+ return rule.get(group, "gear")
+
+
+def page_items(page, index, report, gear_type, entry_type):
+ """Предметы страницы снаряжения: строки таблиц, потом записи без строк."""
+ out = []
+ for r in rows(page, index, report):
+ if r.entry is not None and r.entry_page is not page and r.entry.title.en.lower() == r.title.en.lower():
+ continue # ссылка на предмет другой страницы (Coffee в «Еде» → Наркотики): он выгружен там
+ weight = table_weight(r, report) if "Wt" in r.cells else None
+ if weight is None and r.entry is not None:
+ fields, _ = entry_description(r.entry)
+ w = field_value(fields, "Вес")
+ weight = weight_of(w, r.entry.where, report) if w is not None else 0.0
+ unit = cell(r.cells.get("Единица", ""))
+ extra = [f"Единица: {unit}"] if unit else []
+ if weight is None or weight == 0.0 and unit:
+ weight = parse_weight(unit) or 0.0
+ description = entry_description(r.entry, extra)[1] if r.entry is not None else "\n".join(extra)
+ rec = gear_record(r.names(), weight or 0.0, description, entry_type, gear_type_for(gear_type, r.entry))
+ out.append(Item(rec, r.where, r.entry, r))
+ for e in page.entries:
+ if e.index in page.used:
+ continue
+ report_unlisted(page, e, report)
+ fields, description = entry_description(e)
+ w = field_value(fields, "Вес")
+ weight = weight_of(w, e.where, report) if w is not None else 0.0
+ out.append(Item(gear_record(names(e), weight, description, entry_type, gear_type_for(gear_type, e)), e.where, e))
+ return out
+
+
+def report_unlisted(page, e, report):
+ if page.tables:
+ report.entry("link", e, "запись без строки в таблице раздела — выгружена отдельным предметом",
+ group="запись без строки в таблице")
+
+
+def gear_files():
+ """[(путь, правило gearType)] файлов снаряжения по GEAR_FILES."""
+ out, seen = [], set()
+ for pattern, rule in GEAR_FILES:
+ for p in wiki.md_files(f"{ARSENAL}/{pattern}"):
+ if p not in seen:
+ seen.add(p)
+ out.append((p, rule))
+ return out
+
+
+def gear(index, report):
+ """Снаряжение разделов 4, 5, 6, 9: [Item]."""
+ out = []
+ for p, rule in gear_files():
+ page = index.page(p)
+ check_entries(page, report, need_ru=True)
+ out += page_items(page, index, report, rule, page.heading())
+ return out
+
+
+def cybernetics(index, report):
+ out = []
+ for p in wiki.md_files(f"{ARSENAL}/{CYBERNETICS_GLOB}"):
+ page = index.page(p)
+ check_entries(page, report, need_ru=True)
+ for it in page_items(page, index, report, "gear", page.heading()):
+ rec = {k: v for k, v in it.record.items() if k in ("name", "name_ru")}
+ rec.update({"conditions": [], "description": it.record["description"], "entryType": it.record["entryType"]})
+ out.append(Item(rec, it.where, it.entry, it.row))
+ return out
+
+
+# ─── Модификации ────────────────────────────────────────────────────────────
+
+
+def mods(page, index, report, entry_type):
+ """Модификации страницы: записи `####` с категорией `##`; таблица — оглавление."""
+ rows(page, index, report) # ссылки оглавления → в отчёт
+ check_entries(page, report, need_ru=True)
+ out = []
+ for e in page.entries:
+ if e.index not in page.used:
+ report_unlisted(page, e, report)
+ fields, _ = entry_description(e)
+ raw = field_value(fields, "Вес")
+ weight = parse_weight(raw) if raw is not None else None
+ extra = []
+ if raw is not None and weight is None and cell(raw) not in EMPTY:
+ extra.append(f"Вес: {cell(raw)}") # +20% или ×2 от веса оружия
+ _, description = entry_description(e, extra)
+ rec = {**names(e), "weight": weight or 0.0, "description": description,
+ "entryType": entry_type(e)}
+ out.append(Item(rec, e.where, e))
+ return out
+
+
+def armour_mods(index, report):
+ page = index.page(path(ARMOUR_MODS_FILE))
+ out = mods(page, index, report, lambda e: e.groups.get(2, ""))
+ for it in out:
+ rec = it.record
+ it.record = {**{k: rec[k] for k in ("name", "name_ru") if k in rec}, "weight": rec["weight"],
+ "conditions": [], "description": rec["description"], "entryType": rec["entryType"]}
+ return out
+
+
+def weapon_mods(index, report):
+ out = []
+ for f, kind in WEAPON_MODS_FILES:
+ page = index.page(path(f))
+ out += mods(page, index, report, lambda e, kind=kind: f"{kind}: {e.groups.get(2, '')}")
+ return disambiguate(out, report)
+
+
+def disambiguate(items, report):
+ """Одноимённые модификации разных разделов (Chain рукопашного и дальнобойного) → «Chain (Рукопашное)»."""
+ count = {}
+ for it in items:
+ count[it.name.lower()] = count.get(it.name.lower(), 0) + 1
+ for it in items:
+ if count[it.name.lower()] > 1:
+ name, kind = it.name, it.record["entryType"].split(":")[0]
+ it.record["name"] = f"{name} ({kind})"
+ if "name_ru" in it.record:
+ it.record["name_ru"] = f"{it.record['name_ru']} ({kind.lower()})"
+ report.add("duplicate", it.where, f"модификация «{name}» есть и в другом разделе — выгружена как «{it.record['name']}»")
+ return items
/dev/null .. .scripts/gamedata/bc.py
@@ 0,0 1,135 @@
+"""Механика из ассетов Black Crusade: `conditions` предметов и условия conditions.json.
+
+Вики механику не хранит: химия PF в основном переименована из BC, статусы и
+баффы заклинаний совпадают с BC по английскому имени. Имена PF → BC, где они
+разные, — в `bc_names.json` рядом. Пары сверяются по тексту описания, пары с
+непохожим текстом и предметы PF без пары в BC идут в отчёт.
+"""
+
+import difflib
+import json
+import re
+from pathlib import Path
+
+import wiki
+
+HERE = Path(__file__).resolve().parent
+NAMES_FILE = HERE / "bc_names.json"
+DEFAULT_ASSETS = wiki.ROOT.parent / "CharacterSheet" / "internal" / "gamedata" / "assets"
+
+# Ниже этой похожести описаний пара PF ↔ BC идёт в отчёт.
+SIMILAR = 0.75
+# Хвост имени условия BC после имени предмета: « (Post-Effect)», « – Hypex».
+SUFFIX_RE = re.compile(r"(\s+–\s+.*|\s+\(.*\))$")
+
+
+def load_names(path=NAMES_FILE):
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+class BC:
+ """Ассеты BC (gear.json, conditions.json) и таблица имён PF → BC."""
+
+ def __init__(self, gear, conditions, names):
+ self.gear = gear
+ self.conditions = conditions
+ self.names = names.get("gear", {})
+ self.status_names = names.get("status_names", {})
+ self.gear_by_name = {g["name"].lower(): g for g in gear}
+
+ @classmethod
+ def load(cls, assets=DEFAULT_ASSETS, names_file=NAMES_FILE):
+ def read(name):
+ with open(Path(assets) / name, encoding="utf-8") as f:
+ return json.load(f)
+
+ return cls(read("gear.json"), read("conditions.json"), load_names(names_file))
+
+ def bc_name(self, en):
+ return self.names.get(en, en)
+
+ def gear_matches(self, en):
+ """Предметы BC для предмета PF: с тем же именем или его варианты «Имя – Вариант»."""
+ bc = self.bc_name(en).lower()
+ exact = self.gear_by_name.get(bc)
+ if exact is not None:
+ return [exact]
+ return [g for g in self.gear if g["name"].lower().startswith(bc + " – ")]
+
+ def item_conditions(self, en):
+ """`conditions` предмета BC для предмета PF (у вариантов — первого)."""
+ ms = self.gear_matches(en)
+ return [dict(c) for c in ms[0].get("conditions") or []] if ms else []
+
+ def conditions_for(self, base_bc, en, ru):
+ """Условия BC предмета base_bc (сам он и «base (Post-Effect)», «base – Hypex») под именами PF."""
+ out = []
+ b = base_bc.lower()
+ for c in self.conditions:
+ name = c["name"]
+ if name.lower() != b and not (name.lower().startswith(b) and SUFFIX_RE.fullmatch(name[len(b):])):
+ continue
+ suffix = name[len(b):]
+ rec = {"name": en + suffix}
+ if ru:
+ m = SUFFIX_RE.search(c.get("name_ru") or "")
+ rec["name_ru"] = ru + (m.group(1) if m and suffix else "")
+ rec["x"] = c.get("x")
+ rec["conditions"] = [dict(x) for x in c.get("conditions") or []]
+ out.append((rec, name))
+ return out
+
+
+def normalize(text):
+ text = text.lower().replace("ё", "е").replace("—", "–").replace("−", "–")
+ return re.sub(r"\s+", " ", text).strip()
+
+
+def similarity(a, b):
+ """Похожесть описаний: всего текста или, если выше, строки «Эффект:» (вступление часто переписано)."""
+ ratio = lambda x, y: difflib.SequenceMatcher(None, normalize(x), normalize(y)).ratio()
+ ea, eb = effect(a), effect(b)
+ return max(ratio(a, b), ratio(ea, eb) if ea and eb else 0.0)
+
+
+def effect(text):
+ """Текст «Эффект:» описания до следующей строки с меткой."""
+ m = re.search(r"^Эффект:(.*?)(?=^[\w.-]+:|\Z)", text, re.M | re.S)
+ return m.group(1).strip() if m else ""
+
+
+def chemistry(items, bc, report):
+ """Сверка химии PF с BC: `conditions` из BC, пары без похожего текста и без пары — в отчёт.
+
+ items — arsenal.Item раздела «Химия». Возвращает [(Item, имя BC)] найденных пар.
+ """
+ pairs = []
+ pf_names = {it.name.lower() for it in items}
+ for pf_en in bc.names:
+ if pf_en.lower() not in pf_names:
+ report.add("bc", bc_names_where(), f"«{pf_en}» → «{bc.names[pf_en]}»: в химии PF нет «{pf_en}»")
+ for it in items:
+ ms = bc.gear_matches(it.name)
+ if not ms:
+ if it.name in bc.names:
+ report.add("bc", it.where, f"{it.name}: в bc_names.json → «{bc.names[it.name]}», но в BC нет такого предмета")
+ else:
+ report.add("bc", it.where, f"{it.name}: нет пары в BC — механики нет", group="нет пары в BC")
+ continue
+ score = max(similarity(it.record["description"], g["description"]) for g in ms)
+ if score < SIMILAR:
+ report.add("bc", it.where, f"{it.name} ↔ {ms[0]['name']}: описания похожи на {score:.2f} — проверить пару")
+ it.record["conditions"] = bc.item_conditions(it.name)
+ pairs.append((it, bc.bc_name(it.name)))
+ used = {b.lower() for _, b in pairs}
+ left = [g["name"] for g in bc.gear
+ if g["gearType"] == "consumable"
+ and not any(g["name"].lower() == u or g["name"].lower().startswith(u + " – ") for u in used)]
+ if left:
+ report.note(f"химия BC без пары в PF ({len(left)}): {', '.join(left)}")
+ return pairs
+
+
+def bc_names_where():
+ return wiki.rel(NAMES_FILE)
/dev/null .. .scripts/gamedata/bc_names.json
@@ 0,0 1,44 @@
+{
+ "_about": "Имена PF → BC для механики (conditions) из ассетов Black Crusade. В gear PF и BC совпадающие имена здесь не нужны. Пары сверены по тексту эффекта (build.py пишет в отчёт пары с непохожим текстом).",
+ "gear": {
+ "Purging Draught": "Detox",
+ "Sealing Salve": "Syntskin Spray",
+ "Venomward Elixir": "Countertox",
+ "Mendflesh Salve": "Defrag",
+ "Bloodwell Elixir": "Haemogenesis",
+ "Coffee": "Recaf",
+ "Tobacco": "Lho",
+ "Invigorating Draught": "Stimm",
+ "Kindle": "Hyperexia",
+ "Carrion Oil": "Scav-Glysten",
+ "Wyrdroot": "Wyerder",
+ "Fleetvine": "Barkweed Juice",
+ "Greymist": "Greyve",
+ "Red-Eye": "Karrikan Red-Eye",
+ "Shifter's Elixir": "Polymoprhyne",
+ "Stone Sleep": "Thanzex",
+ "Soul Dust": "D-Dust",
+ "Drow Combat Drugs": "Commorrite Combat Drugs",
+ "Dreammist": "Hallucinogen",
+ "Black Sleep": "Tranq",
+ "Blabber": "Untangler",
+ "Dragon's Bile": "TP-III",
+ "Kingsbane": "Genophage"
+ },
+ "status_names": {
+ "Усталость": "Fatigue",
+ "Сбитие с Ног": "Prone",
+ "Оглушение": "Stunned",
+ "Ступор": "Stupor",
+ "Без Сознания": "Unconscious",
+ "Беспомощный": "Helpless",
+ "Ослеплённый": "Blinded",
+ "Оглохший": "Deafened",
+ "Удушье": "Suffocation",
+ "Отравление": "Poisoned",
+ "Кровотечение": "Blood Loss",
+ "Огонь": "On Fire",
+ "Потеря Конечностей": "Lost Limb",
+ "Гангрена": "Gangrene"
+ }
+}
.scripts/gamedata/build.py ..
@@ 12,7 12,10 @@
import sys
from pathlib import Path
+import arsenal
+import bc as bcmod
import character
+import conditions
import spells
import wiki
from report import Report
@@ 22,20 25,23 @@
DEFAULT_REPORT = HERE / "report.txt"
-def unique(items, key, where, report, what):
- """Первые записи с каждым английским именем; повторы — в отчёт."""
+def unique(items, key, where, report, what, group=None):
+ """Первые записи с каждым английским именем; повторы — в отчёт (group — свернуть по файлу)."""
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]}")
+ if group:
+ report.add("duplicate", where(item), f"{key(item)}: {what} уже есть ({seen[k]})", group=group)
+ else:
+ report.add("duplicate", where(item), f"{what} «{key(item)}» уже есть: {seen[k]}")
continue
seen[k] = where(item)
out.append(item)
return out
-def build(report):
+def build(report, bc_assets=bcmod.DEFAULT_ASSETS):
"""Все выгружаемые файлы: {имя файла: список записей}."""
skill_list = character.skills(report)
skill_names = character.skill_groups(skill_list)
@@ 64,22 70,63 @@
advancements += [s.advancement() for s in spell_list]
advancements += [el.advancement() for el in elites if el.entry is not None]
- return {
+ files = {
"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],
}
+ powers = [(s.power["name"], s.power.get("name_ru", ""), s.power.get("effect", ""), s.entry.where) for s in spell_list]
+ powers += [(a.name, a.power().get("name_ru", ""), a.power().get("effect", ""), a.entry.where) for a in art_list]
+ powers += [(r["name"], r.get("name_ru", ""), r["description"], t.entry.where)
+ for t in talent_list for r in [t.record()]]
+ powers += [(r["name"], r.get("name_ru", ""), r["description"], e.where) for r, e in trait_list]
+ files.update(build_arsenal(report, bcmod.BC.load(bc_assets), powers))
+ return files
+
+
+def build_arsenal(report, bc, powers):
+ """Арсенал и условия: melee, ranged, gear, cybernetics, модификации, conditions."""
+ index = arsenal.Index()
+ name = lambda x: x.name
+ where = lambda x: x.where
+
+ melee, ranged, shields = arsenal.weapons(index, report)
+ melee = unique(melee + shields, name, where, report, "рукопашное оружие")
+ ranged = unique(ranged, name, where, report, "дальнобойное оружие")
+ armour = arsenal.armour(index, report)
+ gear = arsenal.gear(index, report)
+ chemistry = [it for it in gear if it.record["gearType"] == "consumable" and "6 химия/" in it.where]
+ pairs = bcmod.chemistry(chemistry, bc, report)
+ weapons = [arsenal.Item(w.gear(), w.where) for w in melee + ranged]
+ gear = unique(weapons + armour + gear, name, where, report, "предмет",
+ group="английское имя повторяет строку выше — выгружена первая (нужно уточнение в английском имени)")
+ cybernetics = unique(arsenal.cybernetics(index, report), name, where, report, "протез")
+ armour_mods = unique(arsenal.armour_mods(index, report), name, where, report, "модификация брони")
+ weapon_mods = unique(arsenal.weapon_mods(index, report), name, where, report, "модификация оружия")
+
+ return {
+ "melee.json": [w.record for w in melee],
+ "ranged.json": [w.record for w in ranged],
+ "gear.json": [it.record for it in gear],
+ "cybernetics.json": [it.record for it in cybernetics],
+ "armour_mods.json": [it.record for it in armour_mods],
+ "weapon_mods.json": [it.record for it in weapon_mods],
+ "conditions.json": conditions.build(bc, pairs, powers, report),
+ }
+
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)")
+ ap.add_argument("--bc", type=Path, default=bcmod.DEFAULT_ASSETS,
+ help="ассеты Black Crusade — механика химии и статусов (по умолчанию %(default)s)")
args = ap.parse_args(argv)
report = Report()
- files = build(report)
+ files = build(report, args.bc)
args.out.mkdir(parents=True, exist_ok=True)
for name, records in files.items():
/dev/null .. .scripts/gamedata/conditions.py
@@ 0,0 1,139 @@
+"""Условия conditions.json: статусы вики и механика BC.
+
+Записи — в форме BC (`name`, `name_ru`, `x`, `conditions`). Механики в вики
+нет, она приходит из BC (bc.py):
+- статусы `ii механика/06 раны и урон/1 статусы.md` — по английскому имени
+ (пока в вики только русские заголовки `##` — английское из bc_names.json);
+- урон характеристик PF (Characteristic Damage (WS) …);
+- химия — по таблице имён PF → BC («Coffee» ← «Recaf», «… (Post-Effect)»);
+- заклинания, Искусства, таланты и трейты PF с английским именем условия BC
+ (Glacial Carapace, Frenzy (Cold Fury)).
+"""
+
+import re
+
+import bc as bcmod
+import pf
+import wiki
+
+STATUSES_FILE = "ii механика/06 раны и урон/1 статусы.md"
+CHARACTERISTIC_DAMAGE = "Characteristic Damage"
+NUMBER_RE = re.compile(r"\d+")
+
+
+class Status:
+ def __init__(self, e, en, ru):
+ self.entry, self.en, self.ru = e, en, ru
+
+ @property
+ def where(self):
+ return self.entry.where
+
+ def text(self):
+ return wiki.plain_text(self.entry.own_body())
+
+
+def statuses(bc, report, doc=None):
+ """Статусы вики: записи `####`, а пока их нет — разделы `##` (и их `#####`)."""
+ doc = doc or wiki.read_document(wiki.ROOT / STATUSES_FILE)
+ level = 4 if any(h.level == 4 for h in doc.headings) else 2
+ found = []
+ for e in wiki.sections(doc, level):
+ found.append(e)
+ found += [c for c in e.children() if c.level > level]
+ out = []
+ for e in found:
+ report.title(e)
+ en, ru = e.title.en, e.title.ru
+ if not en:
+ en = bc.status_names.get(ru, "")
+ if en:
+ report.entry("name", e, f"нет английского имени — взято «{en}» из bc_names.json "
+ f"(нужно `#### {en} / {ru}`)", group="статус без английского имени")
+ else:
+ report.entry("name", e, "нет английского имени и его нет в bc_names.json — выгружен с русским")
+ elif not ru:
+ report.entry("name", e, "нет русского имени (нужно `English / Русское`)")
+ out.append(Status(e, en, ru))
+ return out
+
+
+def record(en, ru, x=None, conditions=()):
+ out = {"name": en or ru}
+ if en and ru:
+ out["name_ru"] = ru
+ out["x"] = x
+ out["conditions"] = list(conditions)
+ return out
+
+
+def check_numbers(rec, text, source, where, report):
+ """Числа механики BC, которых нет в тексте PF, — в отчёт: механику проверить руками."""
+ have = set(NUMBER_RE.findall(text))
+ missing = []
+ for c in rec["conditions"]:
+ nums = [n for n in NUMBER_RE.findall(str(c.get("value") or "")) if n != "0"]
+ entry = f"{c.get('type')} {c.get('name', '')} {c.get('value')}".replace(" ", " ")
+ if any(n not in have for n in nums) and entry not in missing:
+ missing.append(entry)
+ if missing:
+ report.add("bc_check", where, f"{rec['name']} ← {source}: в тексте PF нет {'; '.join(missing)}")
+
+
+def build(bc, chemistry_pairs, powers, report, status_doc=None):
+ """conditions.json. chemistry_pairs — [(arsenal.Item, имя BC)] из bc.chemistry;
+ powers — [(имя, русское имя, текст, место)] заклинаний, Искусств, талантов и трейтов PF."""
+ out, seen = [], set()
+
+ def add(rec, source, text, where, renamed):
+ key = rec["name"].lower()
+ if key in seen:
+ report.add("duplicate", where, f"условие «{rec['name']}» уже есть")
+ return
+ seen.add(key)
+ out.append(rec)
+ if renamed and source.lower() != rec["name"].lower():
+ report.add("bc_moved", where, f"{rec['name']} ← {source}")
+ elif renamed:
+ report.add("bc_moved", where, rec["name"], group="механика BC по тому же имени")
+ if text is not None:
+ check_numbers(rec, text, source, where, report)
+
+ for s in statuses(bc, report, status_doc):
+ moved = bc.conditions_for(s.en, s.en, s.ru) if s.en else []
+ if not moved:
+ add(record(s.en, s.ru), None, None, s.where, False)
+ for rec, source in moved:
+ add(rec, source, s.text(), s.where, False)
+
+ damage = {f"{CHARACTERISTIC_DAMAGE} ({c})".lower() for c in pf.CHARACTERISTICS}
+ for c in bc.conditions:
+ if c["name"].lower() in damage:
+ add(record(c["name"], c.get("name_ru", ""), c.get("x"), copy(c)), c["name"], None, "BC conditions.json", False)
+
+ for it, base in chemistry_pairs:
+ for rec, source in bc.conditions_for(base, it.name, it.record.get("name_ru", "")):
+ add(rec, source, it.record["description"], it.where, source.lower() != rec["name"].lower())
+
+ by_name = {}
+ for p in powers:
+ by_name.setdefault(p[0].lower(), p)
+ for c in bc.conditions:
+ name = c["name"]
+ if name.lower() in seen:
+ continue
+ p, suffix = by_name.get(name.lower()), ""
+ if p is None:
+ base = bcmod.SUFFIX_RE.sub("", name)
+ p, suffix = by_name.get(base.lower()), name[len(base):]
+ if p is None:
+ continue
+ en, ru, text, where = p
+ m = bcmod.SUFFIX_RE.search(c.get("name_ru") or "")
+ rec_ru = ru + (m.group(1) if suffix and m else "") if ru else ""
+ add(record(en + suffix, rec_ru, c.get("x"), copy(c)), name, text, where, True)
+ return out
+
+
+def copy(c):
+ return [dict(x) for x in c.get("conditions") or []]
.scripts/gamedata/report.py ..
@@ 12,9 12,17 @@
"duplicate": "Дубли английских имён",
"test": "Тест магии не на Cs, характеристику или навык PF",
"arts": "Искусства",
+ "link": "Строки таблиц арсенала и ссылки на записи",
+ "profile": "Профили оружия и брони (Dmg, RoF, AP, тип, класс)",
+ "bc": "Механика BC: нет пары, непохожий текст",
"review": "Пометка #Review (выгружено как есть)",
+ "bc_check": "Механика BC: числа, которых нет в тексте PF (проверить руками)",
+ "bc_moved": "Механика BC, перенесённая под другим именем или по заклинанию, таланту, трейту",
}
+# Справочные разделы: не проблемы вики.
+INFO_KINDS = {"review", "bc_check", "bc_moved"}
+
class Report:
def __init__(self):
@@ 52,7 60,7 @@
self.notes.append(message)
def problems(self):
- return sum(len(v) for k, v in self.items.items() if k != "review")
+ return sum(len(v) for k, v in self.items.items() if k not in INFO_KINDS)
def render(self):
out = ["Pathfinder Crusade → gamedata: отчёт", ""]
@@ 83,11 91,12 @@
for where, message, group in items:
if group:
groups[(where.rpartition(":")[0] or where, group)].append((where, message))
- out = []
+ out, done = [], set()
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:
+ if key not in done:
+ done.add(key)
names = ", ".join(m.split(":")[0] for _, m in groups[key])
out.append((key[0], f"{group} — {len(groups[key])} записей: {names}"))
continue
/dev/null .. .scripts/gamedata/test_arsenal.py
@@ 0,0 1,367 @@
+"""Тесты арсенала и механики BC: `cd .scripts/gamedata && python -m unittest`."""
+
+import textwrap
+import unittest
+
+import arsenal
+import bc as bcmod
+import conditions
+import wiki
+from report import Report
+
+
+def doc(text, path="iv арсенал/test/1 тест.md"):
+ return wiki.parse_document(textwrap.dedent(text).lstrip("\n"), path)
+
+
+def index(*docs):
+ """arsenal.Index со страницами docs (без чтения файлов)."""
+ idx = arsenal.Index()
+ for d in docs:
+ idx.pages[d.key] = arsenal.Page(d)
+ return idx, [idx.pages[d.key] for d in docs]
+
+
+def messages(r, kind):
+ return [m for _, m, _ in r.items.get(kind, [])]
+
+
+class AnchorsTest(unittest.TestCase):
+ def test_slugify_drops_cyrillic(self):
+ self.assertEqual(wiki.slugify("Backpack / Рюкзак"), "backpack-")
+ self.assertEqual(wiki.slugify("Craftsman's Tools / Инструменты"), "craftsmans-tools-")
+ self.assertEqual(wiki.slugify("Buckler"), "buckler")
+
+ def test_anchors_number_repeats(self):
+ d = doc("""
+ # Раздел
+ ## Огонь
+ #### Lantern / Фонарь
+ ## Масло
+ #### Lantern / Фонарь
+ """)
+ a = d.anchors()
+ self.assertEqual((a["lantern-"], a["lantern--1"]), (2, 4))
+ self.assertEqual((a[""], a["-1"], a["-2"]), (0, 1, 3))
+
+ def test_link_target(self):
+ d = doc("x")
+ self.assertEqual(wiki.link_target(d, "#coffee-"), ("iv арсенал/test/1 тест", "coffee-"))
+ self.assertEqual(wiki.link_target(d, "/Iv%20Арсенал/6%20Химия/2%20Наркотики#coffee-"),
+ ("iv арсенал/6 химия/2 наркотики", "coffee-"))
+
+
+class ValuesTest(unittest.TestCase):
+ def test_damage(self):
+ r = Report()
+ self.assertEqual(arsenal.parse_damage("1d10–3+S.b R", "a:1", r), ("1d10–3+S.b", "R"))
+ self.assertEqual(arsenal.parse_damage("2d10+1 I(Cr)", "a:1", r), ("2d10+1", "I(Cr)"))
+ self.assertEqual(r.items, {})
+ self.assertEqual(arsenal.parse_damage("1d5–1R R", "a:2", r), ("1d5–1R", "R"))
+ self.assertEqual(arsenal.parse_damage("1d10 Q", "a:3", r), ("1d10", "Q"))
+ self.assertEqual(len(messages(r, "profile")), 2)
+
+ def test_rof(self):
+ r = Report()
+ self.assertEqual(arsenal.parse_rof("S\\–\\–", "a:1", r), ["S", "–", "–"])
+ self.assertEqual(arsenal.parse_rof("S/2/–", "a:1", r), ["S", "2", "–"])
+ self.assertEqual(arsenal.parse_rof("–/3/10", "a:1", r), ["–", "3", "10"])
+ self.assertEqual(r.items, {})
+ arsenal.parse_rof("S/2", "a:2", r)
+ self.assertEqual(len(messages(r, "profile")), 1)
+
+ def test_ap(self):
+ r = Report()
+ self.assertEqual(arsenal.parse_ap("–/4/4/4", "a:1", r), {"head": "–", "torso": "4", "arms": "4", "legs": "4"})
+ self.assertEqual(arsenal.parse_ap("-/3/2/2", "a:1", r)["head"], "–")
+ self.assertEqual(r.items, {})
+ arsenal.parse_ap("4/4/4", "a:2", r)
+ self.assertEqual(len(messages(r, "profile")), 1)
+
+ def test_weight(self):
+ self.assertEqual(arsenal.parse_weight("+1.5 кг"), 1.5)
+ self.assertEqual(arsenal.parse_weight("2кг"), 2.0)
+ self.assertEqual(arsenal.parse_weight("0,5"), 0.5)
+ for s in ("—", "-", "по варианту", "+20%", "×2"):
+ self.assertIsNone(arsenal.parse_weight(s), s)
+
+
+WEAPONS = """
+ # Базовое оружие
+ {{datatable
+ |paging=false
+ | Название | Группа | Хват | Тип | Rng | Dmg | Pen | Свойства | Bl | Wt | R | Цена |
+ | --- | --- | --- | --- | :---: | :---: | :---: | --- | :---: | :---: | :---: | :---: |
+ | Warhammer | Primary | 1р [2р, Бл] | Молот<br>Крюк | 3<br>3 | 1d10+2 I(Cr)<br>1d10+1 R | 0<br>0 | Imprecise, Primitive<br>Felling (2), Primitive | -1 | 0.8 | -2 | 12 gp |
+ | [Pit Net](#pit-net) | Exotic | 1р | — | 3 | 1d5+1 R | 0 | Flexible, Snare (2) | -1 | 2 | 1 | 20 gp |
+ | Bayonet (Rifle) | Primary | 2р [Бл] | Штык<br>Посох<br>Нож | 3-4<br>2-3 | 1d10 R | 0 | Primitive | -1 | – | -3 | 5 gp |
+ }}
+ #### Pit Net
+ Гладиаторская сеть.
+"""
+
+SHIELDS = """
+ # Щиты
+ | Название | Группа | Хват | Подтип | Rng | Dmg | Pen | AP | Защита | Свойства | Wt | R | Цена |
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+ | [Kite Shield](#kite-shield) | Primary (Shield) | 1р+П | Каплевидный | 0-2 | 1d5 I(Cr) | 0 | 2 | T+A1+(H)/(L1+L2) | Defensive, Primitive | 4 | -2 | 20 gp |
+ | [Buckler](#buckler) | Primary (Shield) | 1р | Баклер | 0-2 | 1d5+1 I(Cr) | 0 | 0 | — | Defensive | 1 | -3 | 9 gp |
+ #### Kite Shield
+ Пехотный щит.
+ #### Buckler
+ Маленький щит.
+"""
+
+RANGED = """
+ # Базовое
+ | Название | Тип | Rng | RoF | Dmg | Pen | Clip | Rld | Свойства | Wt | R | Цена |
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+ | [Longbow](#longbow) | Bow<br>special | S.b×15м | S\\–\\– | 1d10–1+S.b R | 1 | 1 | ½ | Draw (4-5), Primitive | 3 | -2 | 75 gp |
+ | Кремневый Пистолет | Exotic(SP)<br>Pistol | 15м | S/–/– | 1d10+4 I | 6 | 1 | 2 | Inaccurate | 1.5кг | 2 | 500 gp |
+ ## Описание оружия
+ Общий текст про луки.
+ #### Longbow
+ Тяжелый боевой лук.
+"""
+
+
+class WeaponsTest(unittest.TestCase):
+ def test_melee_profiles(self):
+ r = Report()
+ idx, [page] = index(doc(WEAPONS))
+ hammer, net, bayonet = [arsenal.melee_weapon(row, r) for row in arsenal.rows(page, idx, r, link_required=False)]
+ self.assertEqual(hammer["tabs"], [
+ {"profile": "hammer", "range": "3", "damage": "1d10+2", "pen": "0", "damageType": "I(Cr)", "special": "Imprecise, Primitive"},
+ {"profile": "hook", "range": "3", "damage": "1d10+1", "pen": "0", "damageType": "R", "special": "Felling (2), Primitive"},
+ ])
+ self.assertEqual((hammer["group"], hammer["grip"], hammer["balance"], hammer["shield"]), ("Primary", "1р [2р, Бл]", "-1", {}))
+ self.assertEqual((net["tabs"][0]["profile"], net["description"]), ("no", "Гладиаторская сеть."))
+ # 3 типа профиля, 2 дальности, 1 урон: недостающие берут последнее значение, разнобой — в отчёт.
+ self.assertEqual([t["profile"] for t in bayonet["tabs"]], ["bayonet", "staff", "knife"])
+ self.assertEqual([t["range"] for t in bayonet["tabs"]], ["3-4", "2-3", "2-3"])
+ self.assertEqual(messages(r, "profile"), ["Bayonet (Rifle): в «Rng» 2 профилей через <br>, в других 3"])
+ self.assertEqual(r.items.get("link"), None)
+
+ def test_shield(self):
+ r = Report()
+ idx, [page] = index(doc(SHIELDS))
+ kite, buckler = [arsenal.melee_weapon(row, r, shield=True) for row in arsenal.rows(page, idx, r)]
+ self.assertEqual(kite["shield"], {"subtype": "teardrop", "ap": 2, "defenseSectors": "T+A1+(H)/(L1+L2)"})
+ self.assertEqual((kite["tabs"][0]["profile"], kite["description"]), ("", "Пехотный щит."))
+ self.assertEqual(buckler["shield"], {"subtype": "buckler", "ap": 0, "defenseSectors": ""})
+ self.assertEqual(r.items, {})
+
+ def test_ranged(self):
+ r = Report()
+ idx, [page] = index(doc(RANGED))
+ bow, pistol = [arsenal.ranged_weapon(row, r) for row in arsenal.rows(page, idx, r, link_required=False)]
+ self.assertEqual(bow, {
+ "name": "Longbow", "class": "special", "range": "S.b×15м", "damage": "1d10–1+S.b", "pen": "1",
+ "damageType": "R", "rofSingle": "S", "rofShort": "–", "rofLong": "–", "clipCur": "1", "clipMax": "1",
+ "reload": "½", "special": "Draw (4-5), Primitive", "upgrades": "", "description": "Тяжелый боевой лук.",
+ })
+ self.assertEqual((pistol["name"], pistol["class"], pistol["rofSingle"]), ("Кремневый Пистолет", "pistol", "S"))
+ self.assertEqual(len(messages(r, "name")), 1)
+
+
+GEAR = """
+ # Освещение
+ {{datatable
+ |paging=false
+ | Название | R | Wt | Цена |
+ | --- | :---: | :---: | :---: |
+ | [Hooded Lantern / Фонарь с Заслонкой](#lantern-) | -2 | 1 | 7 gp |
+ | [Bullseye Lantern / Направленный Фонарь](#lantern-) | -2 | 1.5 | 12 gp |
+ | [Coffee / Кофе (чашка)](/Iv%20Арсенал/Test/2%20Химия#coffee-) | -1 | - | 5 cp |
+ | [Glow-Globe / Светосфера](#nowhere) | 1 | 0.5 | 110 gp |
+ }}
+
+ ## Масло
+
+ #### Lantern / Фонарь
+ **Редкость:** -2 | **Вес:** по варианту | **Цена:** по варианту
+ Масляный фонарь.
+ **Poor.Q:** тусклый.
+ **Best.Q:** яркий.
+
+ **Варианты:**
+ - **С заслонкой.** **Вес:** 1.
+
+ #### Oil / Масло
+ **Редкость:** -4 | **Вес:** 0.5 | **Цена:** 1 sp
+ **Мощность:** 3
+ Лампадное масло.
+"""
+
+CHEMISTRY = """
+ # Наркотики
+ #### Coffee / Кофе
+ **Редкость:** -1 | **Цена:** 5 cp
+ Напиток из кофейных зёрен.
+ **Приём:** Жидкость
+ **Эффект:** Штраф –10 к тестам W, кроме тестов магии.
+"""
+
+
+class GearTest(unittest.TestCase):
+ def setUp(self):
+ self.r = Report()
+ self.idx, (self.page, self.chem) = index(doc(GEAR), doc(CHEMISTRY, "iv арсенал/test/2 химия.md"))
+ arsenal.check_entries(self.page, self.r, need_ru=True)
+ self.items = arsenal.page_items(self.page, self.idx, self.r, "gear", self.page.heading())
+
+ def test_shared_entry_gives_each_row_its_description(self):
+ hooded, bullseye = self.items[0].record, self.items[1].record
+ self.assertEqual((hooded["name"], hooded["name_ru"], hooded["weight"]), ("Hooded Lantern", "Фонарь с Заслонкой", 1.0))
+ self.assertEqual((bullseye["name"], bullseye["weight"]), ("Bullseye Lantern", 1.5))
+ self.assertEqual(hooded["description"], bullseye["description"])
+ self.assertEqual(hooded["description"],
+ "Масляный фонарь.\n\nPoor.Q: тусклый.\nBest.Q: яркий.\n\nВарианты:\n- С заслонкой. Вес: 1.")
+ self.assertEqual((hooded["entryType"], hooded["gearType"], hooded["armour"], hooded["conditions"]),
+ ("Освещение", "gear", {}, []))
+
+ def test_row_of_another_page_item_is_left_to_it(self):
+ self.assertNotIn("Coffee", [it.name for it in self.items])
+
+ def test_broken_link_and_entry_without_row(self):
+ names = [it.name for it in self.items]
+ self.assertEqual(names, ["Hooded Lantern", "Bullseye Lantern", "Glow-Globe", "Oil"])
+ oil = self.items[-1].record
+ self.assertEqual((oil["weight"], oil["description"]), (0.5, "Мощность: 3\nЛампадное масло."))
+ link = messages(self.r, "link")
+ self.assertEqual(len(link), 2)
+ self.assertIn("нет такого заголовка", link[0])
+ self.assertIn("Oil: запись без строки", link[1])
+ self.assertEqual(messages(self.r, "label"), ["Oil: неизвестная метка «Мощность»"])
+
+ def test_chemistry_description_like_bc(self):
+ items = arsenal.page_items(self.chem, self.idx, self.r, "consumable", self.chem.heading())
+ self.assertEqual(items[0].record["description"],
+ "Напиток из кофейных зёрен.\n\nПриём: Жидкость\nЭффект: Штраф –10 к тестам W, кроме тестов магии.")
+
+
+MODS = """
+ # Модификации
+ | Модификация | Категория | Требование | Кратко | Вес | R | Цена |
+ | --- | --- | --- | --- | --- | --- | --- |
+ | [Chain](#chain-) | Разное | Любое | Цепь | — | 0 | 15 gp |
+ | [Weighted](#weighted-) | Разное | Баланс | +3 Dmg | +20% | -1 | 10 gp |
+ ## Разное
+ #### Chain / Цепь
+ **Редкость:** 0 | **Цена:** 15 gp
+ **Требование:** Любое.
+ Оружие висит на цепи.
+ #### Weighted / Утяжелённое
+ **Редкость:** -1 | **Вес:** +20% | **Цена:** 10 gp ×М
+ **Требование:** Баланс −1 или выше.
+ Тяжелее.
+ #### Spikes / Шипы
+ **Редкость:** -1 | **Вес:** +1.5 кг | **Цена:** 50 gp
+ Шипы.
+"""
+
+
+class ModsTest(unittest.TestCase):
+ def test_mods(self):
+ r = Report()
+ idx, [page] = index(doc(MODS))
+ chain, weighted, spikes = [it.record for it in arsenal.mods(page, idx, r, lambda e: e.groups.get(2, ""))]
+ self.assertEqual(chain, {"name": "Chain", "name_ru": "Цепь", "weight": 0.0,
+ "description": "Требование: Любое.\nОружие висит на цепи.", "entryType": "Разное"})
+ self.assertEqual((weighted["weight"], weighted["description"]),
+ (0.0, "Вес: +20%\nТребование: Баланс −1 или выше.\nТяжелее."))
+ self.assertEqual(spikes["weight"], 1.5)
+ self.assertEqual(messages(r, "link"), ["Spikes: запись без строки в таблице раздела — выгружена отдельным предметом"])
+
+ def test_same_name_in_two_sections(self):
+ r = Report()
+ items = [arsenal.Item({"name": "Chain", "name_ru": "Цепь", "entryType": "Рукопашное: Разное"}, "a.md:1"),
+ arsenal.Item({"name": "Chain", "entryType": "Дальнобойное: Прочие"}, "b.md:1"),
+ arsenal.Item({"name": "Scope", "entryType": "Дальнобойное: Прицелы"}, "b.md:2")]
+ arsenal.disambiguate(items, r)
+ self.assertEqual([(it.name, it.record.get("name_ru")) for it in items],
+ [("Chain (Рукопашное)", "Цепь (рукопашное)"), ("Chain (Дальнобойное)", None), ("Scope", None)])
+ self.assertEqual(len(messages(r, "duplicate")), 2)
+
+
+BC_GEAR = [
+ {"name": "Recaf", "description": "Напитки.\n\nПрием: Жидкость\nЭффект: Штраф –10 к тестам W, кроме психотестов.",
+ "gearType": "consumable", "conditions": []},
+ {"name": "Commorrite Combat Drugs – Hypex", "description": "Боевые наркотики.", "gearType": "consumable", "conditions": []},
+ {"name": "Mjod", "description": "Хмель.", "gearType": "consumable", "conditions": []},
+]
+BC_CONDITIONS = [
+ {"name": "Characteristic Damage (WS)", "name_ru": "Урон Характеристики (WS)", "x": "урон",
+ "conditions": [{"type": "CharacteristicBonus", "name": "WS", "value": "-X"}]},
+ {"name": "Recaf", "name_ru": "Рекаф", "x": None,
+ "conditions": [{"type": "RollBonus", "name": "W", "value": "-10", "except": ["psychic"]}]},
+ {"name": "Recaf (Withdrawal)", "name_ru": "Рекаф (Ломка)", "x": None,
+ "conditions": [{"type": "RollBonus", "name": "F", "value": "-15"}]},
+ {"name": "Recafine", "name_ru": "Не то", "x": None, "conditions": []},
+ {"name": "Commorrite Combat Drugs – Hypex", "name_ru": "Комморритские Боевые Наркотики – Гипекс", "x": None,
+ "conditions": [{"type": "MovementBonus", "value": "5"}]},
+ {"name": "Prone", "name_ru": "Сбит с Ног", "x": None,
+ "conditions": [{"type": "RollBonus", "name": "WS", "value": "-20"}]},
+ {"name": "Hex of Decrepitude (1)", "name_ru": "Порча Дряхлости (1)", "x": None,
+ "conditions": [{"type": "CharacteristicBonus", "name": "T", "value": "-10"}]},
+]
+NAMES = {"gear": {"Coffee": "Recaf", "Drow Combat Drugs": "Commorrite Combat Drugs", "Gone": "Nothing"},
+ "status_names": {"Сбитие с Ног": "Prone"}}
+
+STATUSES = """
+ # Статусы
+ ## Сбитие с Ног
+ Лежачий персонаж получает штраф −20 на WS.
+ ## Ступор
+ Не может действовать.
+"""
+
+
+class BCTest(unittest.TestCase):
+ def setUp(self):
+ self.bc = bcmod.BC(BC_GEAR, BC_CONDITIONS, NAMES)
+ self.r = Report()
+
+ def item(self, name, ru, description, where="c.md:1"):
+ return arsenal.Item({"name": name, "name_ru": ru, "description": description, "conditions": []}, where)
+
+ def test_chemistry_pairs(self):
+ coffee = self.item("Coffee", "Кофе", "Напиток.\n\nПриём: Жидкость\nЭффект: Штраф –10 к тестам W, кроме тестов магии.")
+ drow = self.item("Drow Combat Drugs", "Боевые Наркотики Дроу", "Совсем другой текст про эльфов и их яды.")
+ new = self.item("Spice", "Пряность", "Новое.")
+ pairs = bcmod.chemistry([coffee, drow, new], self.bc, self.r)
+ self.assertEqual([(it.name, b) for it, b in pairs], [("Coffee", "Recaf"), ("Drow Combat Drugs", "Commorrite Combat Drugs")])
+ bc_msgs = messages(self.r, "bc")
+ self.assertTrue(any("«Gone» → «Nothing»" in m for m in bc_msgs))
+ self.assertTrue(any(m.startswith("Drow Combat Drugs ↔ Commorrite Combat Drugs – Hypex") for m in bc_msgs))
+ self.assertIn("Spice: нет пары в BC — механики нет", bc_msgs)
+ self.assertNotIn("Coffee", " ".join(bc_msgs))
+ self.assertEqual(self.r.notes, ["химия BC без пары в PF (1): Mjod"])
+
+ def test_conditions(self):
+ coffee = self.item("Coffee", "Кофе", "Эффект: штраф –10 к тестам W.")
+ drow = self.item("Drow Combat Drugs", "Боевые Наркотики Дроу", "Гипекс: +5 к SPD.")
+ powers = [("Hex of Decrepitude", "Порча Дряхлости", "Цель получает −10 к T.", "s.md:5")]
+ out = conditions.build(self.bc, [(coffee, "Recaf"), (drow, "Commorrite Combat Drugs")], powers, self.r,
+ status_doc=doc(STATUSES, "ii механика/статусы.md"))
+ by = {c["name"]: c for c in out}
+ self.assertEqual(list(by), ["Prone", "Ступор", "Characteristic Damage (WS)", "Coffee", "Coffee (Withdrawal)",
+ "Drow Combat Drugs – Hypex", "Hex of Decrepitude (1)"])
+ self.assertEqual(by["Prone"], {"name": "Prone", "name_ru": "Сбитие с Ног", "x": None,
+ "conditions": [{"type": "RollBonus", "name": "WS", "value": "-20"}]})
+ self.assertEqual(by["Ступор"]["conditions"], [])
+ self.assertEqual(by["Coffee"]["conditions"], BC_CONDITIONS[1]["conditions"])
+ self.assertEqual(by["Coffee (Withdrawal)"]["name_ru"], "Кофе (Ломка)")
+ self.assertEqual(by["Drow Combat Drugs – Hypex"]["name_ru"], "Боевые Наркотики Дроу – Гипекс")
+ self.assertEqual(by["Hex of Decrepitude (1)"]["name_ru"], "Порча Дряхлости (1)")
+ self.assertEqual(by["Characteristic Damage (WS)"]["name_ru"], "Урон Характеристики (WS)")
+ # Числа механики, которых нет в тексте PF: ломка −15 у кофе.
+ self.assertEqual(messages(self.r, "bc_check"), ["Coffee (Withdrawal) ← Recaf (Withdrawal): в тексте PF нет RollBonus F -15"])
+ names = messages(self.r, "name")
+ self.assertEqual(len(names), 2)
+ self.assertIn("взято «Prone» из bc_names.json", names[0])
+ self.assertIn("нет английского имени и его нет в bc_names.json", names[1])
+
+
+if __name__ == "__main__":
+ unittest.main()
.scripts/gamedata/wiki.py ..
@@ 16,8 16,10 @@
"""
import re
+import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
+from urllib.parse import unquote
ROOT = Path(__file__).resolve().parents[2]
@@ 98,6 100,24 @@
level = h.level
return dict(sorted(out.items()))
+ def anchors(self):
+ """{id заголовка в движке вики: номер заголовка}, как `.scripts/wikilinks.py`."""
+ out, seen = {}, {}
+ for i, h in enumerate(self.headings):
+ slug = slugify(h.text)
+ if slug in seen:
+ seen[slug] += 1
+ out[f"{slug}-{seen[slug]}"] = i
+ else:
+ seen[slug] = 0
+ out[slug] = i
+ return out
+
+ @property
+ def key(self):
+ """Путь страницы, как его пишут ссылки: без `.md`, в нижнем регистре."""
+ return re.sub(r"\.md$", "", self.path).lower()
+
def parse_document(text, path=""):
lines = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").split("\n")
@@ 359,6 379,25 @@
return bool(CYRILLIC_RE.search(text))
+# ─── Ссылки ─────────────────────────────────────────────────────────────────
+
+
+def slugify(text):
+ """id заголовка в An Otter Wiki: «Steel / Сталь» → `steel-` (кириллица пропадает)."""
+ 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 link_target(doc, target):
+ """Адрес ссылки со страницы doc → (ключ страницы, якорь); `#a` — та же страница."""
+ path, _, anchor = target.partition("#")
+ if not path:
+ return doc.key, anchor
+ return unquote(path).strip("/").lower(), anchor
+
+
# ─── Таблицы ────────────────────────────────────────────────────────────────
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9