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()
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9