Commit d2fa1a

2026-10-24 03:16:05 iociveteres: feat(gamedata): клятвы паладина и пути монаха в выгрузке Клятвы и их способности — трейты, способности путей — таланты со склонностью Cul, обеты Отшельника — трейты. split_fields понимает, где кончается блок меток.
.scripts/gamedata/build.py ..
@@ 46,8 46,9 @@
skill_list = character.skills(report)
skill_names = character.skill_groups(skill_list)
- talent_list = character.talents(report)
- trait_list = character.traits(report)
+ path_abilities, vows = character.monk_paths(report)
+ talent_list = character.talents(report) + path_abilities
+ trait_list = character.traits(report) + character.oaths(report) + vows
spell_list = spells.spells(report, skill_names)
elites = spells.elite_archetypes(report, skill_names)
for el in elites:
.scripts/gamedata/character.py ..
@@ 15,7 15,13 @@
SKILLS_FILE = "iii продвижение/навыки.md"
CHARACTERISTICS_FILE = "iii продвижение/характеристики.md"
-TALENT_LABELS = ("Уровень", "Скл.", "Требования", "Специализации", "Действие", "Тип")
+OATHS_FILE = "i создание персонажа/архетипы/паладин/клятвы.md"
+MONK_PATHS_GLOB = "i создание персонажа/архетипы/монах/*.md"
+
+# «Свершение» — у способностей путей монаха: поступок, без которого способность не открыть.
+TALENT_LABELS = ("Уровень", "Скл.", "Требования", "Специализации", "Действие", "Тип", "Свершение")
+# Метки, которые идут в начало описания таланта.
+TALENT_TEXT_LABELS = ("Свершение", "Специализации", "Действие", "Тип")
SKILL_CHAR_RE = re.compile(r"\s*\((WS|BS|S|T|A|I|P|W|F)\)\s*$")
@@ 56,7 62,7 @@
req = fmap.get("Требования")
self.requirements = None if req is None or pf.is_empty(req.value) else wiki.plain_inline(req.value).strip()
- extra = [f"{f.label}: {wiki.plain_inline(f.value)}" for f in fields if f.label in ("Специализации", "Действие", "Тип")]
+ extra = [f"{f.label}: {wiki.plain_inline(f.value)}" for f in fields if f.label in TALENT_TEXT_LABELS]
self.description = "\n".join(extra + ([text] if text else []))
def record(self):
@@ 157,6 163,76 @@
return out
+# ─── Клятвы паладина ────────────────────────────────────────────────────────
+
+
+def oaths(report, doc=None):
+ """Клятвы `паладин/клятвы.md` как трейты: [(запись, wiki.Entry)].
+
+ Клятва — раздел `## Oath of X` (кредо, способность, Искусство клятвы),
+ её способность — запись `####` в разделе. Обе — трейты: клятву ищут по
+ имени клятвы, способность — по своему. doc — вместо файла (тесты).
+ """
+ doc = doc or wiki.read_document(wiki.ROOT / OATHS_FILE)
+ out = []
+ for oath in wiki.sections(doc, 2):
+ check_names(oath, report, need_ru=False)
+ abilities = oath.children(4)
+ if not abilities:
+ report.entry("required", oath, "нет способности клятвы (`#### English / Русское`)")
+ lines, traits = [wiki.plain_text(oath.own_body()), ""], []
+ for a in abilities:
+ check_names(a, report)
+ text = wiki.plain_text(a.body())
+ traits.append(({**names(a), "description": f"Способность клятвы {oath.title.text}.\n{text}"}, a))
+ lines.append(f"Способность клятвы: {a.title.text}")
+ art = next((f for n, t in a.body() for f in wiki.line_fields(t, n) if f.label == "Искусство клятвы"), None)
+ if art is not None:
+ lines.append(f"Искусство клятвы: {wiki.plain_inline(art.value).strip()}")
+ out.append(({**names(oath), "description": "\n".join(lines).strip()}, oath))
+ out += traits
+ return out
+
+
+# ─── Пути монаха ────────────────────────────────────────────────────────────
+
+
+class PathAbility(Talent):
+ """Способность пути культивации: покупается как талант; без уровня (черновик) — без записи опыта."""
+
+ def __init__(self, e, path, report):
+ check_names(e, report)
+ fields, rest = wiki.split_fields(e.body(), known=TALENT_LABELS)
+ super().__init__(e, fields, wiki.plain_text(rest), report)
+ self.description = f"Способность пути культивации «{path}».\n{self.description}"
+
+ def advancements(self):
+ return [] if self.level is None else super().advancements()
+
+
+def monk_paths(report, docs=None):
+ """Пути `монах/*.md` (страницы с `**Характеристика:**`): (способности как таланты, обеты как трейты).
+
+ Способности — записи `####` прямо под `# Путь`; записи в `## Обеты` (Отшельник) — трейты.
+ docs — вместо файлов (тесты).
+ """
+ abilities, vows = [], []
+ for doc in docs or map(wiki.read_document, wiki.md_files(MONK_PATHS_GLOB)):
+ top = wiki.sections(doc, 1)
+ if not top:
+ continue
+ fields, _ = wiki.split_fields(top[0].own_body())
+ if not any(f.label == "Характеристика" for f in fields):
+ continue # общие правила путей, не путь
+ name = top[0].title.text
+ for e in wiki.entries(doc, 4):
+ if e.groups.get(2) == "Обеты":
+ vows.append(parse_trait(e, report))
+ elif 2 not in e.groups:
+ abilities.append(PathAbility(e, name, report))
+ return abilities, vows
+
+
# ─── Навыки ─────────────────────────────────────────────────────────────────
.scripts/gamedata/pf.py ..
@@ 5,7 5,8 @@
# Характеристики PF (без Inf, Cor, Fa) в порядке листа.
CHARACTERISTICS = ("WS", "BS", "S", "T", "A", "I", "P", "W", "F")
-APTITUDES = CHARACTERISTICS + ("Off", "Fin", "Def", "Mag", "Tec", "Knw", "Soc", "FC", "Gen")
+# Cul — склонность путей культивации монаха (`архетипы/монах/0 пути.md`), в `склонности.md` её нет.
+APTITUDES = CHARACTERISTICS + ("Off", "Fin", "Def", "Mag", "Tec", "Knw", "Soc", "FC", "Gen", "Cul")
# Уровни продвижения как в BC (`EXPERIENCE_LEVELS_BY_TYPE` листа).
SKILL_LEVELS = ("- -> +0", "+0 -> +10", "+10 -> +20", "+20 -> +30")
.scripts/gamedata/test_wiki.py ..
@@ 75,6 75,12 @@
self.assertEqual([f.label for f in fields], ["Стоимость", "Эффект"])
self.assertEqual(rest, [(4, "**Раны:** 10"), (5, "Хвост")])
+ def test_split_fields_stops_at_unknown_label(self):
+ lines = [(1, "**Уровень:** 1, **Скл.:** W, Cul"), (2, "**Пассивно:** Текст."), (3, "**Активно (1 Ки):** Ещё.")]
+ fields, rest = wiki.split_fields(lines, known=("Уровень", "Скл."))
+ self.assertEqual([f.label for f in fields], ["Уровень", "Скл."])
+ self.assertEqual([n for n, _ in rest], [2, 3])
+
def test_split_fields_stops_at_text(self):
fields, rest = wiki.split_fields([(1, "**Скл.:** A, Gen"), (2, "Текст."), (3, "**Действие:** Полное")])
self.assertEqual([f.label for f in fields], ["Скл."])
@@ 334,6 340,70 @@
self.assertEqual((recs[0]["type"], recs[0]["aptitudes"]), ("talent", "Gen"))
+class OathsTest(unittest.TestCase):
+ def test_oath_and_its_ability_are_traits(self):
+ r = Report()
+ [(oath, _), (ability, _)] = character.oaths(r, doc("""
+ # Клятвы
+ ## Oath of the Crown
+ Справедливость.
+
+ Я поддерживаю закон.
+
+ #### Order on the Battlefield / Порядок на Поле Боя
+ Паладин командует.
+
+ **Искусство клятвы:** [Achilles Heel / Ахиллесова Пята](/V%20Магия/Искусства#achilles-heel-)
+ """))
+ self.assertEqual(oath, {"name": "Oath of the Crown", "description":
+ "Справедливость.\n\nЯ поддерживаю закон.\n\n"
+ "Способность клятвы: Order on the Battlefield / Порядок на Поле Боя\n"
+ "Искусство клятвы: Achilles Heel / Ахиллесова Пята"})
+ self.assertEqual(ability, {"name": "Order on the Battlefield", "name_ru": "Порядок на Поле Боя", "description":
+ "Способность клятвы Oath of the Crown.\nПаладин командует.\n\n"
+ "Искусство клятвы: Achilles Heel / Ахиллесова Пята"})
+ self.assertEqual(dict(r.items), {})
+
+
+class MonkPathsTest(unittest.TestCase):
+ def test_abilities_are_talents_and_vows_traits(self):
+ r = Report()
+ rules = doc("""
+ # Пути культивации
+ #### Навык пути
+ Общие правила.
+ """)
+ path = doc("""
+ # Отшельник
+ **Характеристика:** W
+ #### Way of Vows / Путь Обетов
+ **Уровень:** 1, **Скл.:** W, Cul
+
+ **Пассивно:** Монах даёт обеты.
+ #### Geas / Зарок
+ **Уровень:** 3, **Скл.:** W, Cul
+ **Требования:** Way of Vows
+ **Свершение:** отпустить врага.
+ **Активно (2 Ки):** Накладывает обет.
+ #### Needle / Игла
+ **Активно (1 Ки):** Черновик без уровня.
+ ## Обеты
+ #### Обет Молчания
+ **Ограничение:** Молчит.
+ """)
+ abilities, vows = character.monk_paths(r, [rules, path])
+ way, geas, needle = abilities
+ self.assertEqual(way.record()["description"], "Способность пути культивации «Отшельник».\nПассивно: Монах даёт обеты.")
+ self.assertEqual(geas.record()["description"],
+ "Способность пути культивации «Отшельник».\nСвершение: отпустить врага.\nАктивно (2 Ки): Накладывает обет.")
+ self.assertEqual(geas.advancements(), [{"type": "talent", "name": "Geas", "name_ru": "Зарок", "level": 3,
+ "aptitudes": "W, Cul", "requirements": "Way of Vows"}])
+ self.assertEqual(needle.advancements(), [])
+ self.assertEqual(vows, [({"name": "Обет Молчания", "description": "Ограничение: Молчит."}, vows[0][1])])
+ self.assertEqual(sorted(m for _, m, _ in r.items["required"]), ["Needle: нет «Скл.»", "Needle: нет «Уровень»"])
+ self.assertEqual([m for _, m, _ in r.items["name"]], ["Обет Молчания: нет английского имени (нужно `English / Русское`)"])
+ self.assertNotIn("label", r.items)
+
class ReportTest(unittest.TestCase):
def test_collapses_groups(self):
r = Report()
.scripts/gamedata/wiki.py ..
@@ 299,13 299,14 @@
return out
-def split_fields(lines, open_labels=()):
+def split_fields(lines, open_labels=(), known=None):
"""Блок полей в начале записи и остаток.
lines — пары (номер, текст). Блок — подряд идущие строки с метками в
начале (пустые строки между ними пропускаются). Метка из open_labels
(например «Эффект») заканчивает блок: её значение продолжается текстом
- ниже, и он попадает в остаток.
+ ниже, и он попадает в остаток. known — метки блока: строка, которая
+ начинается с другой метки («**Пассивно:**»), — уже текст записи.
Возвращает (поля, остаток строк).
"""
@@ 317,7 318,7 @@
k += 1
continue
fs = line_fields(text, n)
- if not fs:
+ if not fs or (known is not None and fs[0].label not in known):
break
fields.extend(fs)
k += 1
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