Commit a7e5b3
2026-10-30 17:02:02 iociveteres: Выгрузка материалов, камней инкрустации, зачарований и естественного оружия materials.json и enchantments.json для листа, профили Natural Weapons и Deadly Natural Weapons в melee.json. Механика уровней (Special, множитель веса, записи условий) — таблицы правил в materials.py.| .scripts/gamedata/arsenal.py .. | |
| @@ 14,7 14,7 @@ | |
| import re | |
| import wiki | |
| - | from character import names |
| + | from character import TRAITS_FILE, names |
| ARSENAL = "iv арсенал" | |
| MELEE_FILES = ("1 рукопашное оружие/1 базовое.md", "1 рукопашное оружие/2 особое.md") | |
| @@ 65,11 65,11 @@ | |
| # Тип профиля рукопашного оружия (колонка «Тип») → select листа (MELEE_PROFILES). | |
| MELEE_PROFILES = { | |
| - | "кулак": "fist", "кулак.б": "fist.a", "нож": "knife", "когти.р": "claws.h", "когти.п": "claws.a", |
| + | "кулак": "fist", "кулак.б": "fist.a", "нож": "knife", "когти": "claws", "когти.р": "claws.h", "когти.п": "claws.a", |
| "меч": "sword", "рапира": "rapier", "сабля": "saber", "топор": "axe", "крюк": "hook", | |
| "молот": "hammer", "булава": "mace", "кистень": "flail", "копьё": "spear", "посох": "staff", | |
| "глефа": "glaive", "кнут": "whip", "штык": "bayonet", "щит": "shield", "укус": "bite", | |
| - | "—": "no", "–": "no", "-": "no", |
| + | "—": "no", "–": "no", "-": "no", "нет": "no", |
| } | |
| MELEE_GROUPS = {"primary", "primary (shield)", "chain", "shock", "power", "exotic", "mechadendrite"} | |
| SHIELD_SUBTYPES = { | |
| @@ 573,6 573,81 @@ | |
| return melee, ranged, shields | |
| + | # ─── Естественное оружие ──────────────────────────────────────────────────── |
| + | |
| + | # Трейты с таблицей профилей естественного оружия; X рейтинга трейта лист |
| + | # добавляет сам (задача 6 `_prd/item_materials/plan.md`), в записи его нет. |
| + | NATURAL_WEAPON_TRAITS = ("Natural Weapons (X)", "Deadly Natural Weapons (X)") |
| + | NATURAL_WEAPON_TYPE = "Трейт" |
| + | NATURAL_WEAPONS_EN = {"Кулаки": "Fists", "Когти": "Claws", "Косы": "Scythes", "Щупальца": "Tentacles", |
| + | "Рога": "Horns", "Укус": "Bite", "Копыта": "Hooves"} |
| + | NATURAL_DAMAGE_RE = re.compile(r"^(\d*d\d+)\+X\s+(\S+)$") |
| + | |
| + | |
| + | def natural_weapons(report, doc=None): |
| + | """Профили трейтов Natural Weapons и Deadly Natural Weapons — записи melee.json. |
| + | |
| + | Имя — «Claws (Natural Weapons)» / «Когти (Естественное Оружие)»; поле trait — |
| + | имя трейта в traits.json; урон без +X, Pen 0 (X у Deadly добавляет лист). |
| + | """ |
| + | doc = doc or wiki.read_document(wiki.ROOT / TRAITS_FILE) |
| + | by_name = {e.title.en: e for e in wiki.entries(doc, 4)} |
| + | out = [] |
| + | for trait in NATURAL_WEAPON_TRAITS: |
| + | e = by_name.get(trait) |
| + | if e is None: |
| + | report.add("required", doc.path, f"нет трейта «{trait}» — естественное оружие не выгружено") |
| + | continue |
| + | body = e.own_body() |
| + | table = next(iter(wiki.tables(body)), None) |
| + | if table is None: |
| + | report.entry("format", e, "нет таблицы профилей естественного оружия") |
| + | continue |
| + | description = wiki.plain_text([(n, t) for n, t in body if n >= table.end]) |
| + | en_kind = trait.removesuffix(" (X)") |
| + | ru_kind = e.title.ru |
| + | for row, line in zip(table.dicts(), table.row_lines): |
| + | where = f"{doc.path}:{line}" |
| + | ru = cell(row.get("Оружие", "")) |
| + | en = NATURAL_WEAPONS_EN.get(ru) |
| + | if en is None: |
| + | report.add("name", where, f"{trait}: оружие «{ru}» — нет английского имени в NATURAL_WEAPONS_EN") |
| + | continue |
| + | raw = cell(row.get("Тип", "")) |
| + | if raw.lower() not in MELEE_PROFILES: |
| + | report.add("profile", where, f"{trait}, {ru}: тип профиля «{raw}» не из списка листа") |
| + | m = NATURAL_DAMAGE_RE.match(cell(row.get("Dmg", ""))) |
| + | if m is None: |
| + | report.add("profile", where, f"{trait}, {ru}: Dmg «{cell(row.get('Dmg', ''))}» — нужно «1d10+X R»") |
| + | continue |
| + | if m.group(2) not in DAMAGE_TYPES: |
| + | report.add("profile", where, f"{trait}, {ru}: тип урона «{m.group(2)}» не из списка листа") |
| + | pen = cell(row.get("Pen", "")) |
| + | if pen not in ("0", "X"): |
| + | report.add("profile", where, f"{trait}, {ru}: Pen «{pen}» — ждём 0 или X") |
| + | out.append({ |
| + | "name": f"{en} ({en_kind})", |
| + | "name_ru": f"{ru} ({ru_kind})", |
| + | "group": "Primary", |
| + | "grip": "", |
| + | "balance": cell(row.get("Bl", "")), |
| + | "upgrades": "", |
| + | "tabs": [{ |
| + | "profile": MELEE_PROFILES.get(raw.lower(), ""), |
| + | "range": cell(row.get("Rng", "")), |
| + | "damage": m.group(1), |
| + | "pen": "0", |
| + | "damageType": m.group(2), |
| + | "special": cell(row.get("Свойства", "")), |
| + | }], |
| + | "shield": {}, |
| + | "description": description, |
| + | "entryType": NATURAL_WEAPON_TYPE, |
| + | "trait": trait, |
| + | }) |
| + | return out |
| + | |
| + | |
| # ─── Броня ────────────────────────────────────────────────────────────────── | |
| .scripts/gamedata/build.py .. | |
| @@ 16,6 16,7 @@ | |
| import bc as bcmod | |
| import character | |
| import conditions | |
| + | import materials |
| import spells | |
| import wiki | |
| from report import Report | |
| @@ 89,7 90,7 @@ | |
| def build_arsenal(report, bc, powers): | |
| - | """Арсенал и условия: melee, ranged, gear, cybernetics, модификации, conditions.""" |
| + | """Арсенал и условия: melee, ranged, gear, cybernetics, модификации, материалы, зачарования, conditions.""" |
| index = arsenal.Index() | |
| name = lambda x: x.name | |
| where = lambda x: x.where | |
| @@ 107,14 108,18 @@ | |
| 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, "модификация оружия") | |
| + | # Естественное оружие трейтов — не предметы: в gear.json его нет. |
| + | natural = arsenal.natural_weapons(report) |
| return { | |
| - | "melee.json": [w.record for w in melee], |
| + | "melee.json": [w.record for w in melee] + natural, |
| "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], | |
| + | "materials.json": materials.materials(report), |
| + | "enchantments.json": materials.enchantments(report), |
| "conditions.json": conditions.build(bc, pairs, powers, report), | |
| } | |
| /dev/null .. .scripts/gamedata/materials.py | |
| @@ 0,0 1,333 @@ | |
| + | """Материалы, камни инкрустации и зачарования предметов (Pathfinder Crusade). |
| + | |
| + | materials.json — материалы из таблицы и записей `8 материалы.md` (entryType — |
| + | колонка «Тип») и камни из таблицы «Инкрустация» того же файла (entryType |
| + | «Инкрустация»); enchantments.json — зачарования: таблица `7 зачарования.md` |
| + | (категория → entryType, «Кратко» → short) и записи `7 зачарования/*.md` |
| + | (уровни). Формат записей — `_prd/item_materials/plan.md` чарника. |
| + | |
| + | Механика, которую лист считает сам (conditions, special, weightFactor), — из |
| + | таблиц правил ниже: текст вики её не размечает. Правило, которое не нашло своей |
| + | записи или уровня, — в отчёт. |
| + | """ |
| + | |
| + | import re |
| + | |
| + | import wiki |
| + | from character import names |
| + | |
| + | ARSENAL = "iv арсенал" |
| + | MATERIALS_FILE = "8 материалы.md" |
| + | ENCHANTMENTS_INDEX = "7 зачарования.md" |
| + | ENCHANTMENTS_GLOB = "7 зачарования/*.md" |
| + | |
| + | INLAY_TYPE = "Инкрустация" |
| + | # В таблице камней только русские имена. |
| + | GEMS_EN = {"Кварц": "Quartz", "Оникс": "Onyx", "Рубин": "Ruby", "Изумруд": "Emerald", "Алмаз": "Diamond"} |
| + | |
| + | MATERIAL_STATS = ("Редкость", "Цена", "Банк", "Вес", "Прочность", "Ёмкость", "AP", "Pen") |
| + | MATERIAL_TEXTS = ("Применение", "Особое", "Описание") |
| + | # Поле записи → колонка таблицы и ключ JSON. |
| + | MATERIAL_FIELDS = { |
| + | "Редкость": ("R", "rarity"), |
| + | "Цена": ("Цена", "priceFactor"), |
| + | "Банк": ("Банк", "bankFactor"), |
| + | "Вес": ("Вес", "weightFactor"), |
| + | "Прочность": ("Прочность", "durability"), |
| + | "Ёмкость": ("Ёмкость", "capacityFactor"), |
| + | "AP": ("AP", "ap"), |
| + | "Pen": ("Pen", "pen"), |
| + | } |
| + | |
| + | # Записи условий (формы conditions ассетов) по материалу. |
| + | # CharacteristicCapBonus — «Cap Bonus»: поднимает Char. Cap характеристики (задача 4 плана). |
| + | MATERIAL_CONDITIONS = { |
| + | "Mithral": [{"type": "CharacteristicCapBonus", "name": "A", "value": "20"}], |
| + | } |
| + | |
| + | # Поля уровней зачарований по (имя, ёмкость). WeaponBonus — запись Weapon листа |
| + | # (weapon_bonus): name — какому оружию, «This item» — оружию, связанному с предметом |
| + | # (задача 5); значение «×2» — множитель (тоже задача 5). |
| + | ENCHANTMENT_LEVELS = { |
| + | ("Feather", 2): {"weightFactor": 0.5}, |
| + | ("Feather", 4): {"weightFactor": 0.25}, |
| + | ("Feather", 11): {"weightFactor": 0}, |
| + | ("Distance", 3): {"conditions": [{"type": "WeaponBonus", "name": "This item", "field": "rng", "value": "×2"}]}, |
| + | ("Hurling", 2): {"conditions": [{"type": "WeaponBonus", "name": "Throwing, Grenade", "field": "rng", "value": "×1.5"}]}, |
| + | ("Hurling", 4): {"conditions": [{"type": "WeaponBonus", "name": "Throwing, Grenade", "field": "rng", "value": "×2"}]}, |
| + | ("Self-Balancing", 7): {"conditions": [{"type": "WeaponBonus", "name": "This item", "field": "balance", "value": "1"}]}, |
| + | ("Controlling", 2): {"conditions": [{"type": "WeaponBonus", "name": "This item", "field": "rngMax", "value": "1"}]}, |
| + | ("Controlling", 4): {"conditions": [{"type": "WeaponBonus", "name": "This item", "field": "rngMax", "value": "2"}]}, |
| + | ("Agile", 1): {"conditions": [{"type": "CharacteristicCapBonus", "name": "A", "value": "5"}]}, |
| + | ("Agile", 3): {"conditions": [{"type": "CharacteristicCapBonus", "name": "A", "value": "10"}]}, |
| + | ("Agile", 6): {"conditions": [{"type": "CharacteristicCapBonus", "name": "A", "value": "15"}]}, |
| + | ("Blurring", 2): {"conditions": [{"type": "SkillBonus", "name": "Stealth", "value": "10"}, |
| + | {"type": "SkillBonus", "name": "Dodge", "value": "5"}]}, |
| + | } |
| + | |
| + | # «Оружие получает свойство Flame (d10), тест A–20.» → Flame (d10). |
| + | GRANTS_RE = re.compile(r"получает свойство ([A-Z][\w\- ]*(?:\([^)]*\))?)") |
| + | CAPACITY_RE = re.compile(r"^\*\*Ёмкость(?:\s+(\d+))?:\*\*\s*(.*)$") |
| + | BARE_HEADING_RE = re.compile(r"^#{1,6}\s*$") |
| + | DASHES = {"—", "–", "-", ""} |
| + | |
| + | |
| + | def path(rel): |
| + | return wiki.ROOT / ARSENAL / rel |
| + | |
| + | |
| + | def cell(value): |
| + | return wiki.plain_inline(value).strip() |
| + | |
| + | |
| + | def number(value): |
| + | """«×0.5» → 0.5, «−3» → -3, «+1» → 1, «—» → None; не число → ValueError.""" |
| + | s = cell(value).replace("−", "-").replace("–", "-").lstrip("×").replace(",", ".") |
| + | if s in DASHES: |
| + | return None |
| + | n = float(s) |
| + | return int(n) if n.is_integer() else n |
| + | |
| + | |
| + | # ─── Материалы ────────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def _table(doc, first_column): |
| + | """Таблица, чья первая колонка — first_column, или None.""" |
| + | for t in wiki.tables(doc.numbered(1, len(doc.lines) + 1)): |
| + | if t.header and t.header[0] == first_column: |
| + | return t |
| + | return None |
| + | |
| + | |
| + | def _material_fields(e): |
| + | """Поля записи материала: {метка: сырое значение}; текстовые метки тянутся до следующей метки.""" |
| + | out, current, extra = {}, None, [] |
| + | for n, t in e.own_body(): |
| + | fs = wiki.line_fields(t, n) |
| + | if fs: |
| + | current = None |
| + | for f in fs: |
| + | out[f.label] = [f.value] if f.label in MATERIAL_TEXTS else f.value |
| + | if f.label in MATERIAL_TEXTS: |
| + | current = f.label |
| + | elif f.label not in MATERIAL_STATS: |
| + | extra.append(f.label) |
| + | continue |
| + | if current is not None: |
| + | out[current].append(t) |
| + | for label in MATERIAL_TEXTS: |
| + | if label in out: |
| + | out[label] = wiki.plain_text(out[label]) |
| + | return out, extra |
| + | |
| + | |
| + | def materials(report, doc=None): |
| + | """Материалы и камни инкрустации: записи для materials.json.""" |
| + | doc = doc or wiki.read_document(path(MATERIALS_FILE)) |
| + | anchors = doc.anchors() |
| + | table = _table(doc, "Материал") |
| + | if table is None: |
| + | report.add("format", doc.path, "нет таблицы материалов (первая колонка «Материал»)") |
| + | return [] |
| + | |
| + | out = [] |
| + | seen = set() |
| + | for row, line in zip(table.dicts(), table.row_lines): |
| + | where = f"{doc.path}:{line}" |
| + | label, target = wiki.cell_link(row["Материал"]) |
| + | i = anchors.get(target.lstrip("#")) if target.startswith("#") else None |
| + | if i is None or doc.headings[i].level != 4: |
| + | report.add("link", where, f"материал «{label}»: ссылка «{target}» не ведёт на запись `####`") |
| + | continue |
| + | e = wiki.make_entry(doc, i) |
| + | seen.add(i) |
| + | if not e.title.en or not e.title.ru: |
| + | report.entry("name", e, "нужно `English / Русское`") |
| + | fields, extra = _material_fields(e) |
| + | for lab in extra: |
| + | report.entry("label", e, f"неизвестная метка «{lab}»") |
| + | |
| + | rec = {**names(e), "entryType": cell(row["Тип"]), "use": fields.get("Применение") or cell(row["Применение"])} |
| + | for label_, (column, key) in MATERIAL_FIELDS.items(): |
| + | raw = fields.get(label_, row.get(column, "")) |
| + | try: |
| + | value, from_table = number(raw), number(row.get(column, "")) |
| + | except ValueError: |
| + | report.entry("value", e, f"{label_} «{cell(raw)}» / в таблице «{cell(row.get(column, ''))}» — не число") |
| + | value = from_table = None |
| + | if value != from_table: |
| + | report.entry("value", e, f"{label_}: в записи {cell(raw)}, в таблице {cell(row.get(column, ''))}") |
| + | rec[key] = value |
| + | short = cell(row.get("Особое", "")) |
| + | rec["short"] = "" if short in DASHES else short |
| + | rec["special"] = fields.get("Особое", "") |
| + | rec["description"] = fields.get("Описание", "") |
| + | rec["conditions"] = MATERIAL_CONDITIONS.get(rec["name"], []) |
| + | out.append(rec) |
| + | |
| + | for e in wiki.entries(doc, 4): |
| + | if e.index not in seen: |
| + | report.entry("link", e, "материала нет в таблице материалов — не выгружен") |
| + | for name in MATERIAL_CONDITIONS: |
| + | if not any(r["name"] == name for r in out): |
| + | report.add("value", doc.path, f"правило условий материала «{name}»: нет такого материала") |
| + | return out + inlays(doc, report) |
| + | |
| + | |
| + | def inlays(doc, report): |
| + | """Камни таблицы «Инкрустация»: имя, редкость, цена (как в вики, без единицы), бонус Ёмкости.""" |
| + | table = _table(doc, "Камень") |
| + | if table is None: |
| + | report.add("format", doc.path, "нет таблицы инкрустации (первая колонка «Камень»)") |
| + | return [] |
| + | out = [] |
| + | for row, line in zip(table.dicts(), table.row_lines): |
| + | where = f"{doc.path}:{line}" |
| + | ru = cell(row["Камень"]) |
| + | en = GEMS_EN.get(ru) |
| + | if en is None: |
| + | report.add("name", where, f"камень «{ru}»: нет английского имени в GEMS_EN (.scripts/gamedata/materials.py)") |
| + | en = ru |
| + | try: |
| + | rarity, price, bonus = number(row["Редкость"]), number(row["Цена"]), number(row["Ёмкость"]) |
| + | except ValueError: |
| + | report.add("value", where, f"камень «{ru}»: редкость, цена или Ёмкость — не число") |
| + | continue |
| + | out.append({"name": en, "name_ru": ru, "entryType": INLAY_TYPE, |
| + | "rarity": rarity, "price": price, "capacityBonus": bonus}) |
| + | return out |
| + | |
| + | |
| + | # ─── Зачарования ──────────────────────────────────────────────────────────── |
| + | |
| + | |
| + | def _index_rows(doc, report): |
| + | """Строки таблицы зачарований: [(имя, ссылка, категория, [ёмкости], кратко, место)].""" |
| + | table = _table(doc, "Зачарование") |
| + | if table is None: |
| + | report.add("format", doc.path, "нет таблицы зачарований (первая колонка «Зачарование»)") |
| + | return [] |
| + | out = [] |
| + | for row, line in zip(table.dicts(), table.row_lines): |
| + | where = f"{doc.path}:{line}" |
| + | name, target = wiki.cell_link(row["Зачарование"]) |
| + | caps = [] |
| + | for part in cell(row["Ёмкость"]).split("/"): |
| + | try: |
| + | caps.append(int(part.strip())) |
| + | except ValueError: |
| + | report.add("value", where, f"{name}: Ёмкость «{cell(row['Ёмкость'])}» — не числа через /") |
| + | caps = [] |
| + | break |
| + | out.append((name, target, cell(row["Категория"]), caps, cell(row["Кратко"]), where)) |
| + | return out |
| + | |
| + | |
| + | def parse_levels(e, report): |
| + | """Описание, требование и уровни записи зачарования. |
| + | |
| + | Уровни — строки `**Ёмкость N:** текст` (текст тянется до следующего уровня) или |
| + | одна пара `**Ёмкость:** N` + `**Эффект:** текст`. Строки до первого уровня — |
| + | описание, кроме поля «Требования». |
| + | """ |
| + | head, levels, requirement = [], [], "" |
| + | single, effect = None, None |
| + | current = None |
| + | for n, t in e.own_body(): |
| + | # `####` без имени — не заголовок для Markdown, но конец записи. |
| + | if BARE_HEADING_RE.match(t): |
| + | report.add("name", f"{e.path}:{n}", f"заголовок `{t.strip()}` без имени — запись не выгружена") |
| + | break |
| + | m = CAPACITY_RE.match(t.strip()) |
| + | if m and m.group(1): |
| + | current = {"capacity": int(m.group(1)), "lines": [m.group(2)]} |
| + | levels.append(current) |
| + | continue |
| + | if m: |
| + | try: |
| + | single = int(cell(m.group(2))) |
| + | except ValueError: |
| + | report.entry("value", e, f"Ёмкость «{cell(m.group(2))}» — не число") |
| + | continue |
| + | fs = wiki.line_fields(t, n) |
| + | if fs and fs[0].label in ("Требования", "Требование"): |
| + | requirement = cell(fs[0].value) |
| + | continue |
| + | if fs and fs[0].label == "Эффект": |
| + | effect = [fs[0].value] |
| + | current = None |
| + | continue |
| + | if effect is not None and not levels: |
| + | effect.append(t) |
| + | elif current is not None: |
| + | current["lines"].append(t) |
| + | else: |
| + | head.append(t) |
| + | |
| + | if single is not None: |
| + | if levels: |
| + | report.entry("format", e, "и «**Ёмкость:** N», и «**Ёмкость N:**» — выгружены уровни") |
| + | else: |
| + | levels = [{"capacity": single, "lines": effect or []}] |
| + | if effect is None: |
| + | report.entry("required", e, "«**Ёмкость:** N» без «**Эффект:**»") |
| + | out = [] |
| + | for lv in levels: |
| + | text = wiki.plain_text(lv["lines"]) |
| + | m = GRANTS_RE.search(text) |
| + | out.append({"capacity": lv["capacity"], "text": text, "special": m.group(1).strip() if m else ""}) |
| + | return wiki.plain_text(head), requirement, out |
| + | |
| + | |
| + | def enchantments(report, index_doc=None, docs=None): |
| + | """Зачарования: записи для enchantments.json в порядке таблицы `7 зачарования.md`.""" |
| + | index_doc = index_doc or wiki.read_document(path(ENCHANTMENTS_INDEX)) |
| + | if docs is None: |
| + | docs = [wiki.read_document(p) for p in wiki.md_files(f"{ARSENAL}/{ENCHANTMENTS_GLOB}")] |
| + | |
| + | by_name = {} |
| + | for d in docs: |
| + | for e in wiki.entries(d, 4): |
| + | if not e.name: |
| + | report.entry("name", e, "запись без имени — не выгружена") |
| + | continue |
| + | if e.name.lower() in by_name: |
| + | report.entry("duplicate", e, f"зачарование «{e.name}» уже есть: {by_name[e.name.lower()].where}") |
| + | continue |
| + | by_name[e.name.lower()] = e |
| + | |
| + | out, used = [], set() |
| + | for name, target, category, caps, short, where in _index_rows(index_doc, report): |
| + | e = by_name.get(name.lower()) |
| + | rec = {"name": name, "entryType": category, "short": short} |
| + | if e is None: |
| + | report.add("link", where, f"{name}: нет записи `#### {name}` в {ENCHANTMENTS_GLOB} — уровни выгружены по таблице, текст — «Кратко»") |
| + | rec["levels"] = [{"capacity": c, "text": short, "special": ""} for c in caps] |
| + | else: |
| + | used.add(name.lower()) |
| + | key, anchor = wiki.link_target(index_doc, target) |
| + | if key != e.doc.key or anchor != wiki.slugify(e.doc.headings[e.index].text): |
| + | report.add("link", where, f"{name}: ссылка «{target}» не ведёт на запись (она в {e.where})") |
| + | description, requirement, levels = parse_levels(e, report) |
| + | if requirement: |
| + | rec["requirement"] = requirement |
| + | if description: |
| + | rec["description"] = description |
| + | if [lv["capacity"] for lv in levels] != caps: |
| + | report.entry("value", e, f"Ёмкость уровней {[lv['capacity'] for lv in levels]}, в таблице {caps} — выгружены уровни записи") |
| + | rec["levels"] = levels |
| + | out.append(rec) |
| + | |
| + | for key, e in by_name.items(): |
| + | if key not in used: |
| + | report.entry("link", e, "зачарования нет в таблице `7 зачарования.md` — не выгружено") |
| + | |
| + | found = {(r["name"], lv["capacity"]): lv for r in out for lv in r["levels"]} |
| + | for key, extra in ENCHANTMENT_LEVELS.items(): |
| + | lv = found.get(key) |
| + | if lv is None: |
| + | report.add("value", index_doc.path, f"правило уровня {key[0]} (Ёмкость {key[1]}): нет такого уровня") |
| + | continue |
| + | lv.update(extra) |
| + | return out |
| /dev/null .. .scripts/gamedata/test_materials.py | |
| @@ 0,0 1,234 @@ | |
| + | """Тесты материалов, зачарований и естественного оружия: `cd .scripts/gamedata && python -m unittest`.""" |
| + | |
| + | import textwrap |
| + | import unittest |
| + | |
| + | import arsenal |
| + | import materials |
| + | 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 messages(r, kind): |
| + | return [m for _, m, _ in r.items.get(kind, [])] |
| + | |
| + | |
| + | MATERIALS = """ |
| + | # Материалы |
| + | |
| + | | Материал | Тип | Применение | R | Цена | Банк | Вес | Прочность | Ёмкость | AP | Pen | Особое | |
| + | | --- | --- | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | --- | |
| + | | [Мифрил](#mithral-) | Металл | Универсальный | 3 | ×15 | ×1.5 | ×0.5 | 2 | ×1 | +1 | +1 | +20 Max.A | |
| + | | [Кожа](#leather-) | Кожа | Броня | −3 | ×0.3 | ×0.5 | ×1 | −1 | ×1 | 0 | — | — | |
| + | |
| + | #### Mithral / Мифрил |
| + | **Редкость:** 3 | **Цена:** ×15 | **Банк:** ×1.5 | **Вес:** ×0.5 | **Прочность:** 2 | **Ёмкость:** ×1 | **AP:** +1 | **Pen:** +1 |
| + | **Применение:** Универсальный |
| + | **Особое:** |
| + | - Броня получает +20 Max.A. |
| + | - Клинки острее. |
| + | |
| + | **Описание:** Лёгкий металл. |
| + | |
| + | #### Leather / Кожа |
| + | **Редкость:** −3 | **Цена:** ×0.3 | **Банк:** ×0.5 | **Вес:** ×1 | **Прочность:** −2 | **Ёмкость:** ×1 | **AP:** 0 | **Pen:** — |
| + | **Применение:** Броня |
| + | **Описание:** Выделанная кожа. |
| + | |
| + | #### Tin / Олово |
| + | **Редкость:** 0 |
| + | |
| + | ## Инкрустация |
| + | | Камень | Редкость | Цена | Ёмкость | |
| + | | --- | :---: | :---: | :---: | |
| + | | Кварц | 0 | 1500 | +1 | |
| + | | Янтарь | 1 | 2000 | +2 | |
| + | """ |
| + | |
| + | |
| + | class MaterialsTest(unittest.TestCase): |
| + | def setUp(self): |
| + | self.r = Report() |
| + | self.out = materials.materials(self.r, doc(MATERIALS)) |
| + | self.by_name = {m["name"]: m for m in self.out} |
| + | |
| + | def test_numbers_and_texts(self): |
| + | m = self.by_name["Mithral"] |
| + | self.assertEqual(m["name_ru"], "Мифрил") |
| + | self.assertEqual(m["entryType"], "Металл") |
| + | self.assertEqual((m["rarity"], m["priceFactor"], m["bankFactor"], m["weightFactor"]), (3, 15, 1.5, 0.5)) |
| + | self.assertEqual((m["durability"], m["capacityFactor"], m["ap"], m["pen"]), (2, 1, 1, 1)) |
| + | self.assertEqual(m["short"], "+20 Max.A") |
| + | self.assertEqual(m["special"], "- Броня получает +20 Max.A.\n- Клинки острее.") |
| + | self.assertEqual(m["description"], "Лёгкий металл.") |
| + | |
| + | def test_dash_is_none(self): |
| + | leather = self.by_name["Leather"] |
| + | self.assertEqual((leather["rarity"], leather["pen"], leather["short"]), (-3, None, "")) |
| + | |
| + | def test_mithral_cap_bonus(self): |
| + | self.assertEqual(self.by_name["Mithral"]["conditions"], |
| + | [{"type": "CharacteristicCapBonus", "name": "A", "value": "20"}]) |
| + | self.assertEqual(self.by_name["Leather"]["conditions"], []) |
| + | |
| + | def test_table_and_entry_disagree(self): |
| + | self.assertTrue(any("Прочность: в записи −2, в таблице −1" in m for m in messages(self.r, "value"))) |
| + | self.assertEqual(self.by_name["Leather"]["durability"], -2) |
| + | |
| + | def test_entry_without_row_is_reported(self): |
| + | self.assertNotIn("Tin", self.by_name) |
| + | self.assertTrue(any("Tin" in m for m in messages(self.r, "link"))) |
| + | |
| + | def test_gems(self): |
| + | gems = [m for m in self.out if m["entryType"] == materials.INLAY_TYPE] |
| + | self.assertEqual(gems[0], {"name": "Quartz", "name_ru": "Кварц", "entryType": "Инкрустация", |
| + | "rarity": 0, "price": 1500, "capacityBonus": 1}) |
| + | self.assertEqual(gems[1]["name"], "Янтарь") |
| + | self.assertTrue(any("Янтарь" in m for m in messages(self.r, "name"))) |
| + | |
| + | |
| + | INDEX = """ |
| + | # Зачарования |
| + | | Зачарование | Категория | Ёмкость | Кратко | |
| + | | --- | --- | :---: | --- | |
| + | | [Feather](/Iv%20Арсенал/7%20Зачарования/1%20Тест#feather) | Универсальное | 2 / 4 / 11 | Вес | |
| + | | [Flaming](/Iv%20Арсенал/7%20Зачарования/1%20Тест#flaming) | Оружие | 1 / 3 | Flame | |
| + | | [Greater Accuracy](/Iv%20Арсенал/7%20Зачарования/1%20Тест#greater-accuracy) | Дальнее оружие | 5 | Даёт Accurate | |
| + | | [Tempo](/Iv%20Арсенал/7%20Зачарования/1%20Тест#tempo) | Поножи | 8 / 12 | Стойки | |
| + | | [Grasping](/Iv%20Арсенал/7%20Зачарования/9%20Наручи#grasping) | Наручи | 3 | Остановить падение | |
| + | """ |
| + | |
| + | ENCHANTMENTS = """ |
| + | # Тест |
| + | |
| + | #### Feather |
| + | **Ёмкость 2:** Вес до ½. |
| + | **Ёмкость 4:** Вес до ¼. |
| + | **Ёмкость 11:** Вес до 0. |
| + | |
| + | #### Flaming |
| + | **Ёмкость 1:** Оружие получает свойство Flame (d5). |
| + | **Ёмкость 3:** Оружие получает свойство Flame (d10), тест A–20. |
| + | **Ёмкость 7:** Оружие получает свойство Flame (2d10). |
| + | |
| + | #### Greater Accuracy |
| + | **Ёмкость:** 5 |
| + | **Требования:** оружие без свойства Inaccurate |
| + | **Эффект:** Оружие получает свойство Accurate. |
| + | |
| + | #### Tempo |
| + | Дуэльные стойки. |
| + | |
| + | **Ёмкость 8:** |
| + | - **Агрессивная Стойка:** урон. |
| + | |
| + | **Ёмкость 12:** Как выше, но: |
| + | - **Агрессивная Стойка:** больше урона. |
| + | |
| + | #### |
| + | **Ёмкость 3:** Без имени. |
| + | |
| + | #### Orphan |
| + | **Ёмкость:** 1 |
| + | **Эффект:** Нет в таблице. |
| + | """ |
| + | |
| + | |
| + | class EnchantmentsTest(unittest.TestCase): |
| + | def setUp(self): |
| + | self.r = Report() |
| + | d = doc(ENCHANTMENTS, "iv арсенал/7 зачарования/1 тест.md") |
| + | self.out = materials.enchantments(self.r, doc(INDEX, "iv арсенал/7 зачарования.md"), [d]) |
| + | self.by_name = {e["name"]: e for e in self.out} |
| + | |
| + | def test_levels_and_weight_factor(self): |
| + | feather = self.by_name["Feather"] |
| + | self.assertEqual((feather["entryType"], feather["short"]), ("Универсальное", "Вес")) |
| + | self.assertEqual([(lv["capacity"], lv["weightFactor"]) for lv in feather["levels"]], [(2, 0.5), (4, 0.25), (11, 0)]) |
| + | self.assertEqual(feather["levels"][0]["text"], "Вес до ½.") |
| + | |
| + | def test_special_from_granted_property(self): |
| + | flaming = self.by_name["Flaming"] |
| + | self.assertEqual([lv["special"] for lv in flaming["levels"]], ["Flame (d5)", "Flame (d10)", "Flame (2d10)"]) |
| + | self.assertTrue(any("Flaming" in m and "[1, 3, 7]" in m for m in messages(self.r, "value"))) |
| + | |
| + | def test_single_level_with_requirement(self): |
| + | ga = self.by_name["Greater Accuracy"] |
| + | self.assertEqual(ga["requirement"], "оружие без свойства Inaccurate") |
| + | self.assertEqual(ga["levels"], [{"capacity": 5, "text": "Оружие получает свойство Accurate.", "special": "Accurate"}]) |
| + | |
| + | def test_multiline_level_and_description(self): |
| + | tempo = self.by_name["Tempo"] |
| + | self.assertEqual(tempo["description"], "Дуэльные стойки.") |
| + | self.assertEqual(tempo["levels"][0]["text"], "- Агрессивная Стойка: урон.") |
| + | self.assertEqual(tempo["levels"][1]["text"], "Как выше, но:\n- Агрессивная Стойка: больше урона.") |
| + | |
| + | def test_bare_heading_ends_the_entry(self): |
| + | self.assertEqual([lv["capacity"] for lv in self.by_name["Tempo"]["levels"]], [8, 12]) |
| + | self.assertTrue(any("без имени" in m for m in messages(self.r, "name"))) |
| + | |
| + | def test_row_without_entry_falls_back_to_index(self): |
| + | self.assertEqual(self.by_name["Grasping"]["levels"], [{"capacity": 3, "text": "Остановить падение", "special": ""}]) |
| + | self.assertTrue(any("Grasping" in m for m in messages(self.r, "link"))) |
| + | |
| + | def test_entry_without_row_is_reported(self): |
| + | self.assertNotIn("Orphan", self.by_name) |
| + | self.assertTrue(any("Orphan" in m for m in messages(self.r, "link"))) |
| + | |
| + | def test_rule_without_level_is_reported(self): |
| + | self.assertTrue(any("Distance" in m for m in messages(self.r, "value"))) |
| + | |
| + | |
| + | TRAITS = """ |
| + | # Трейты |
| + | |
| + | #### Natural Weapons (X) / Естественное Оружие |
| + | Когти. |
| + | |
| + | | Оружие | Тип | Rng | Dmg | Pen | Свойства | Bl | |
| + | |---|---|---|---|---|---|---| |
| + | | Кулаки | Кулак.Б | 0-1 | 1d10+X I(Cr) | 0 | Primitive | -1 | |
| + | | Рога | – | 0 | 2d10+X R | 0 | Primitive | -2 | |
| + | | Хвост | Кнут | 2-5 | 1d10+X R | 0 | Primitive | -2 | |
| + | |
| + | Рога — только в Натиск. |
| + | |
| + | #### Deadly Natural Weapons (X) / Смертельное Естественное Оружие |
| + | | Оружие | Тип | Rng | Dmg | Pen | Свойства | Bl | |
| + | |---|---|---|---|---|---|---| |
| + | | Копыта | Нет | 0 | 1d10+X I(Cr) | X | Imprecise, Reinforced | -2 | |
| + | """ |
| + | |
| + | |
| + | class NaturalWeaponsTest(unittest.TestCase): |
| + | def setUp(self): |
| + | self.r = Report() |
| + | self.out = arsenal.natural_weapons(self.r, doc(TRAITS, "iii продвижение/трейты.md")) |
| + | self.by_name = {w["name"]: w for w in self.out} |
| + | |
| + | def test_records(self): |
| + | fists = self.by_name["Fists (Natural Weapons)"] |
| + | self.assertEqual(fists["name_ru"], "Кулаки (Естественное Оружие)") |
| + | self.assertEqual((fists["entryType"], fists["trait"], fists["balance"]), ("Трейт", "Natural Weapons (X)", "-1")) |
| + | self.assertEqual(fists["tabs"], [{"profile": "fist.a", "range": "0-1", "damage": "1d10", "pen": "0", |
| + | "damageType": "I(Cr)", "special": "Primitive"}]) |
| + | self.assertEqual(fists["description"], "Рога — только в Натиск.") |
| + | |
| + | def test_dash_and_no_profiles(self): |
| + | self.assertEqual(self.by_name["Horns (Natural Weapons)"]["tabs"][0]["profile"], "no") |
| + | self.assertEqual(self.by_name["Horns (Natural Weapons)"]["tabs"][0]["damage"], "2d10") |
| + | hooves = self.by_name["Hooves (Deadly Natural Weapons)"] |
| + | self.assertEqual((hooves["tabs"][0]["profile"], hooves["tabs"][0]["pen"]), ("no", "0")) |
| + | self.assertEqual(hooves["trait"], "Deadly Natural Weapons (X)") |
| + | |
| + | def test_unknown_weapon_is_reported(self): |
| + | self.assertEqual(len(self.out), 3) |
| + | self.assertTrue(any("Хвост" in m for m in messages(self.r, "name"))) |
| + | |
| + | |
| + | if __name__ == "__main__": |
| + | unittest.main() |