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 |
| + | |
| + | |
| # ─── Таблицы ──────────────────────────────────────────────────────────────── | |