Commit b1713b

2026-10-22 15:09:26 iociveteres: refactor(gamedata): drop the workarounds the wiki no longer needs Characteristic aptitudes come from the wiki only, Навык is no longer read as the magic test, and attack tables are read by their column names without a header check. Repeated aptitudes (Gen, Gen), a dash or words in place of a level or aptitudes, and one trait on several race pages are by design and no longer reported. C is a damage type; the spells of an elite archetype need no cost.
.scripts/gamedata/character.py ..
@@ 73,9 73,12 @@
def parse_level(e, f, report):
+ """Уровень таланта; «—» — талант без уровня (Sound Constitution, Power Rating)."""
if f is None:
report.entry("required", e, "нет «Уровень»")
return None
+ if pf.is_empty(f.value):
+ return None
m = re.match(r"^\s*(\d+)\s*(.*)$", f.value)
if not m:
report.entry("value", e, f"«Уровень: {f.value}» — не число")
@@ 90,15 93,18 @@
def check_aptitudes(e, f, report):
+ """Склонности через запятую. Повтор (`Gen, Gen`) — намеренно; «—» или словами
+ («Как у Навыка») — склонностей у записи нет, лист их не считает."""
if f is None:
report.entry("required", e, "нет «Скл.»")
return ""
- apts, bad = pf.parse_aptitudes(wiki.plain_inline(f.value))
+ value = wiki.plain_inline(f.value)
+ apts, bad = pf.parse_aptitudes(value)
+ good = [a for a in apts if a not in bad]
+ if pf.is_empty(value) or (not good and wiki.has_cyrillic(value)):
+ return ""
if bad:
report.entry("value", e, f"склонность не из списка: «{f.value}»")
- good = [a for a in apts if a not in bad]
- if len(set(good)) < len(good):
- report.entry("value", e, f"склонность повторяется: «{f.value}»")
return ", ".join(good)
@@ 129,9 135,14 @@
out = []
doc = wiki.read_document(wiki.ROOT / TRAITS_FILE)
out += [parse_trait(e, report) for e in wiki.entries(doc, 4)]
+ seen = set()
for path in wiki.md_files(*TRAIT_PAGES):
doc = wiki.read_document(path)
- out += [parse_trait(e, report) for e in wiki.entries(doc, 4, within=lambda g: g.get(2) == "Трейты")]
+ for e in wiki.entries(doc, 4, within=lambda g: g.get(2) == "Трейты"):
+ # Трейт нескольких рас (Vicious Strikes у орка и полуорка) — одна запись, первая.
+ if e.name.lower() not in seen:
+ seen.add(e.name.lower())
+ out.append(parse_trait(e, report))
return out
@@ 221,21 232,18 @@
title = wiki.parse_title(m.group(2))
found[m.group(1)] = (title.ru, check_aptitudes(e, fmap["Скл."], report) if "Скл." in fmap else None)
- missing = [c for c in pf.CHARACTERISTICS if found.get(c, (None, None))[1] is None]
- if missing:
- report.add("required", CHARACTERISTICS_FILE,
- f"нет «**Скл.:**» у {', '.join(missing)} — взяты склонности BC (Psy → Mag)")
out = []
for c in pf.CHARACTERISTICS:
ru, apts = found.get(c, ("", None))
if c not in found:
report.add("required", CHARACTERISTICS_FILE, f"нет заголовка характеристики ({c})")
- apts = apts if apts is not None else pf.DEFAULT_CHARACTERISTIC_APTITUDES[c]
+ elif apts is None:
+ report.add("required", CHARACTERISTICS_FILE, f"нет «**Скл.:**» у ({c})")
for level, step in enumerate(pf.CHARACTERISTIC_LEVELS, 1):
rec = {"type": "characteristic", "name": f"{c} {step}"}
if ru:
rec["name_ru"] = f"{ru} {step}"
rec["level"] = level
- rec["aptitudes"] = apts
+ rec["aptitudes"] = apts or ""
out.append(rec)
return out
.scripts/gamedata/pf.py ..
@@ 7,20 7,6 @@
APTITUDES = CHARACTERISTICS + ("Off", "Fin", "Def", "Mag", "Tec", "Knw", "Soc", "FC", "Gen")
-# Склонности характеристик, пока их нет в `характеристики.md` (`**Скл.:**`
-# там читается первым): таблица BC, Psy → Mag.
-DEFAULT_CHARACTERISTIC_APTITUDES = {
- "WS": "WS, Off",
- "BS": "BS, Fin",
- "S": "S, Off",
- "T": "T, Def",
- "A": "A, Fin",
- "I": "I, Knw",
- "P": "P, FC",
- "W": "W, Mag",
- "F": "F, Soc",
-}
-
# Уровни продвижения как в BC (`EXPERIENCE_LEVELS_BY_TYPE` листа).
SKILL_LEVELS = ("- -> +0", "+0 -> +10", "+10 -> +20", "+20 -> +30")
CHARACTERISTIC_LEVELS = ("+0 -> +5", "+5 -> +10", "+10 -> +15", "+15 -> +20", "+20 -> +25")
.scripts/gamedata/spells.py ..
@@ 18,9 18,10 @@
SPELL_LABELS = ("Стоимость", "Требования", "Действие", "Поддержание", "Тест магии", "Дальность", "Тип", "Эффект")
SPELL_REQUIRED = SPELL_LABELS
+# Заклинание элитного архетипа даётся с архетипом, без своей стоимости.
+ELITE_SPELL_REQUIRED = tuple(l for l in SPELL_LABELS if l != "Стоимость")
ARTS_LABELS = ("Уровень", "Скл.", "Требования", "Тест магии")
-ARTS_TEST_ALIASES = ("Навык",)
-DAMAGE_TYPE_RE = re.compile(r"^[RIEX](\([A-Za-z]+\))?$")
+DAMAGE_TYPE_RE = re.compile(r"^[RIEXC](\([A-Za-z]+\))?$")
COST_RE = re.compile(r"^\s*(\d[\d\s]*)\s*XP\s*$", re.I)
@@ 41,8 42,6 @@
continue
if not t.rows:
break
- if t.header != ["Rng", "RoF", "Dmg", "Pen", "Свойства"]:
- report.entry("format", e, f"шапка таблицы атаки «{' | '.join(t.header)}» (нужно Rng | RoF | Dmg | Pen | Свойства)")
row = t.rows[0]
cell = lambda k: wiki.plain_inline(row[k]).strip() if k is not None and k < len(row) else ""
profile["weaponRange"] = cell(rng)
@@ 74,7 73,7 @@
class Spell:
- def __init__(self, e, report, skills, discipline=None, subdiscipline=None):
+ def __init__(self, e, report, skills, discipline=None, subdiscipline=None, required=SPELL_REQUIRED):
self.entry = e
character.check_names(e, report)
fields, rest = wiki.split_fields(e.body(), open_labels=("Эффект",))
@@ 84,7 83,7 @@
for f in fields:
if f.label not in SPELL_LABELS:
report.entry("label", e, f"неизвестная метка «{f.label}»")
- for label in SPELL_REQUIRED:
+ for label in required:
if label not in fmap:
report.entry("required", e, f"нет «{label}»")
elif not fmap[label].value.strip() and not (label == "Эффект" and rest):
@@ 162,10 161,7 @@
for f in repeated:
report.entry("label", e, f"метка «{f.label}» повторяется")
for f in fields:
- if f.label in ARTS_TEST_ALIASES and "Тест магии" not in fmap:
- report.entry("label", e, f"«**{f.label}:**» вместо «**Тест магии:**»")
- fmap["Тест магии"] = f
- elif f.label not in ARTS_LABELS:
+ if f.label not in ARTS_LABELS:
report.entry("label", e, f"неизвестная метка «{f.label}»")
self.level = character.parse_level(e, fmap.get("Уровень"), report)
self.aptitudes = character.check_aptitudes(e, fmap.get("Скл."), report)
@@ 269,7 265,7 @@
return
e = self.entry
character.check_names(e, report)
- name = e.title.text
+ name = e.title.ru or e.title.en # дисциплина его заклинаний — по-русски
req = next((s for s in wiki.sections(doc, 2) if s.title.text == "Требования"), None)
if req is None:
@@ 301,7 297,7 @@
if "Дополнительные таланты" in groups or "Таланты" in groups:
self.talents.append(character.parse_talent(x, report))
elif "Заклинания" in groups:
- self.spells.append(Spell(x, report, skills, discipline=name, subdiscipline=""))
+ self.spells.append(Spell(x, report, skills, discipline=name, subdiscipline="", required=ELITE_SPELL_REQUIRED))
elif "Трейты" in groups:
self.traits.append(character.parse_trait(x, report))
.scripts/gamedata/test_wiki.py ..
@@ 302,6 302,23 @@
])
+ def test_special_levels_and_aptitudes(self):
+ r = Report()
+ mastery, sound, lore = [character.parse_talent(e, r) for e in wiki.entries(doc("""
+ # Общие
+ #### Mastery / Мастерство
+ **Уровень:** 3, **Скл.:** Как у Навыка
+ #### Sound Constitution / Крепкое Телосложение
+ **Уровень:** —, **Скл.:** —
+ #### Lore / Знания
+ **Уровень:** 1, **Скл.:** Gen, Gen
+ """))]
+ self.assertEqual((mastery.level, mastery.aptitudes), (3, ""))
+ self.assertEqual((sound.level, sound.aptitudes), (None, ""))
+ self.assertEqual(lore.aptitudes, "Gen, Gen")
+ self.assertEqual(dict(r.items), {})
+
+
class ReportTest(unittest.TestCase):
def test_collapses_groups(self):
r = Report()
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