|
| 1 | +"""A campaign's .hdt states combat facts, and the engine emits them. |
| 2 | +
|
| 3 | +This is the seam that makes house rules possible. A GM who wants killing |
| 4 | +attacks to behave as normal damage in a heroic campaign, or who wants to turn |
| 5 | +knockback off, edits the template -- and every consumer downstream sees the |
| 6 | +changed fact without anyone editing code. kirby-cost is the only thing that |
| 7 | +reads the .hdt, so kirby-cost is where those facts have to enter the model. |
| 8 | +
|
| 9 | +Before 2026-08-25 they did not enter it at all: `KillingAttackRanged.__init__` |
| 10 | +hardcoded `self.killing = True` and the template's `KILLING="Yes"` was read by |
| 11 | +`hdt_parser` and dropped. Editing the .hdt changed nothing. |
| 12 | +""" |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +import xml.etree.ElementTree as ET |
| 16 | + |
| 17 | +import pytest |
| 18 | + |
| 19 | +from kirby_cost.io.hdt_parser import HDTParser |
| 20 | +from kirby_cost.template.dataclasses import TemplateData |
| 21 | +from kirby_cost.objects.powers.killing_attack_ranged import KillingAttackRanged |
| 22 | + |
| 23 | + |
| 24 | +def _parse(tag_text: str) -> dict: |
| 25 | + return HDTParser()._parse_generic_object(ET.fromstring(tag_text)) |
| 26 | + |
| 27 | + |
| 28 | +def test_the_parser_reads_killing_from_the_template(): |
| 29 | + entry = _parse('<RKA KILLING="Yes" DEFENSE="NORMAL"/>') |
| 30 | + |
| 31 | + assert entry["killing"] is True |
| 32 | + |
| 33 | + |
| 34 | +def test_a_template_that_says_no_is_not_the_same_as_a_template_that_is_silent(): |
| 35 | + """The tri-state is load-bearing. `KillingAttackRanged` sets killing in |
| 36 | + its own constructor, so "absent" must not read as "No" -- otherwise |
| 37 | + applying any template at all would quietly disarm every killing attack.""" |
| 38 | + assert _parse('<RKA KILLING="No"/>')["killing"] is False |
| 39 | + assert _parse('<RKA/>')["killing"] is None |
| 40 | + |
| 41 | + |
| 42 | +def test_a_house_rule_in_the_template_reaches_the_loaded_power(): |
| 43 | + """The whole point: change the .hdt, change what the engine emits.""" |
| 44 | + power = KillingAttackRanged() |
| 45 | + assert power.killing is True, "the class's own default" |
| 46 | + |
| 47 | + power.apply_template(TemplateData(xmlid="RKA", killing=False)) |
| 48 | + |
| 49 | + assert power.killing is False |
| 50 | + |
| 51 | + |
| 52 | +def test_a_silent_template_leaves_the_class_default_alone(): |
| 53 | + power = KillingAttackRanged() |
| 54 | + |
| 55 | + power.apply_template(TemplateData(xmlid="RKA")) |
| 56 | + |
| 57 | + assert power.killing is True |
| 58 | + |
| 59 | + |
| 60 | +@pytest.mark.parametrize("attribute", ["does_body", "does_knockback"]) |
| 61 | +def test_the_other_combat_facts_travel_the_same_road(attribute): |
| 62 | + """Not just killing -- knockback and BODY are stated by the template too, |
| 63 | + and a campaign that turns one off should not need a code change either.""" |
| 64 | + power = KillingAttackRanged() |
| 65 | + |
| 66 | + power.apply_template(TemplateData(xmlid="RKA", **{attribute: True})) |
| 67 | + assert getattr(power, attribute) is True |
| 68 | + |
| 69 | + power.apply_template(TemplateData(xmlid="RKA", **{attribute: False})) |
| 70 | + assert getattr(power, attribute) is False |
0 commit comments