Skip to content

Commit 3a8484f

Browse files
pdbethkeclaude
andcommitted
feat(template): carry the combat facts the .hdt states, so house rules reach combat
Main6E states them on the power itself -- KILLING, DOESBODY, DOESDAMAGE, DOESKNOCKBACK, alongside the DEFENSE carried on 2026-08-24. hdt_parser read only DEFENSE; `killing` came from KillingAttackRanged.__init__ hardcoding True. Parsed then dropped, the same shape as `types` and AdderTemplate.options. That made a campaign's template edits inert. kirby-cost is the only thing in the platform that reads a .hdt, so it is the only place a house rule can enter the model -- a GM setting KILLING="No" for a heroic campaign got a character that still resolved killing damage, with nothing to indicate the edit was ignored. Proved by copying Main6E with that one edit: Ravel's RKA still reported killing=True. It now reports False, with no code changed. The flags are TRI-STATE. None means "the template said nothing", which is not False: a constructor may set the fact itself, and a silent template must not undo it while an explicit "No" must. apply_template also skips any attribute exposed as a read-only property -- Maneuver derives does_damage from its own effect string, and an object that works the answer out from itself is not waiting to be told. 1456 passed, 0 skipped (KIRBY_COST_AUTHORED + KIRBY_COST_HDT set). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xof4ZUS5n6A5ibX3PXNYbs
1 parent dd0698b commit 3a8484f

5 files changed

Lines changed: 140 additions & 0 deletions

File tree

kirby_cost/io/hdt_parser.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@ def _parse_generic_object(self, elem: etree.Element) -> Dict[str, Any]:
331331
'target': elem.get('TARGET', ''),
332332
'range': elem.get('RANGE', ''),
333333
'defense': elem.get('DEFENSE', ''),
334+
'killing': self._parse_optional_yesno(elem, 'KILLING'),
335+
'does_body': self._parse_optional_yesno(elem, 'DOESBODY'),
336+
'does_damage': self._parse_optional_yesno(elem, 'DOESDAMAGE'),
337+
'does_knockback': self._parse_optional_yesno(elem, 'DOESKNOCKBACK'),
334338

335339
# Input/option labels
336340
'input_label': elem.get('INPUTLABEL', ''),
@@ -540,6 +544,20 @@ def _get_text(self, elem: Optional[etree.Element]) -> str:
540544
return ''
541545
return (elem.text or '').strip()
542546

547+
@staticmethod
548+
def _parse_optional_yesno(elem, attribute: str):
549+
"""Yes/No attribute as a tri-state: None when the attribute is absent.
550+
551+
The distinction matters. `killing` is also set by some object
552+
constructors, so "the template did not mention it" has to be
553+
distinguishable from "the template said No" -- otherwise applying a
554+
template would silently clear a class's own answer.
555+
"""
556+
raw = elem.get(attribute)
557+
if raw is None:
558+
return None
559+
return raw.strip().upper().startswith('Y')
560+
543561
def _parse_float(self, value: str) -> float:
544562
"""Parse a string to float, returning 0.0 on error."""
545563
if not value:

kirby_cost/objects/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,34 @@ def apply_template(self, tmpl: "TemplateData", option_id: str = None) -> None:
591591
self._duration = tmpl.duration
592592
if tmpl.target and self.target in ("", "N/A") and "TARGET" not in stated:
593593
self.target = tmpl.target
594+
# DEFENSE, on the same terms as TARGET: the template states it, the
595+
# document rarely does, and the constructor's default is "NONE" --
596+
# which reads as an answer rather than as an absence.
597+
if tmpl.defense and self.defense in ("", "NONE") and "DEFENSE" not in stated:
598+
self.defense = tmpl.defense
599+
# Combat facts stated by the template. These are what let a GM's house
600+
# rules reach the fight: kirby-cost reads the .hdt, so a campaign that
601+
# edits KILLING="No" on the killing attacks, or turns knockback off,
602+
# emits changed facts that kirby-combat consumes without knowing a rule
603+
# was changed. The tri-state is load-bearing -- see TemplateData.killing.
604+
# An HDC file that states the attribute itself still wins over both.
605+
for attribute, xml_name in (
606+
("killing", "KILLING"),
607+
("does_body", "DOESBODY"),
608+
("does_damage", "DOESDAMAGE"),
609+
("does_knockback", "DOESKNOCKBACK"),
610+
):
611+
stated_value = getattr(tmpl, attribute, None)
612+
if stated_value is None or xml_name in stated:
613+
continue
614+
# A subclass may DERIVE the fact rather than store it -- Maneuver
615+
# computes does_damage from its own effect string and exposes it
616+
# read-only. That is an authority, not a gap: an object that works
617+
# the answer out from itself is not waiting to be told. Skip it.
618+
descriptor = getattr(type(self), attribute, None)
619+
if isinstance(descriptor, property) and descriptor.fset is None:
620+
continue
621+
setattr(self, attribute, stated_value)
594622
# RANGE is the word that decides whether a power reaches at all, and
595623
# only the template states it — an HDC file never does. Without it
596624
# every power read as un-ranged and range_value returned 0.

kirby_cost/template/dataclasses.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
loader converts raw dicts into these before handing them to domain objects.
66
"""
77
from dataclasses import dataclass, field
8+
from typing import Optional
89

910

1011
@dataclass(frozen=True)
@@ -95,6 +96,24 @@ class TemplateData:
9596
#: at all, and it is stated only by the template.
9697
range: str = ""
9798
target: str = ""
99+
#: Which defence the power is tested against -- NORMAL, MENTAL, POWER,
100+
#: SPECIAL, NONE. `hdt_parser` has always read it (:333) and nothing
101+
#: carried it, so every loaded power reported the constructor's "NONE"
102+
#: and a consumer had to guess. kirby-combat guessed by xmlid and said so:
103+
#: "without a reliable signal in kirby-cost's parse we default to PD for
104+
#: HKA/HTH and ED for ranged blasts".
105+
defense: str = ""
106+
#: Combat facts the template states about the power itself. TRI-STATE:
107+
#: ``None`` means "the template said nothing", which is NOT the same as
108+
#: ``False`` -- a class such as KillingAttackRanged sets ``killing = True``
109+
#: in its constructor, and a template that is silent must not undo that.
110+
#: An explicit ``KILLING="No"`` in the .hdt DOES undo it, which is the
111+
#: point: a GM running a heroic campaign edits the template, kirby-cost
112+
#: emits the changed fact, and kirby-combat acts on it. Same for the rest.
113+
killing: Optional[bool] = None
114+
does_body: Optional[bool] = None
115+
does_damage: Optional[bool] = None
116+
does_knockback: Optional[bool] = None
98117
uses_end: bool = False
99118
is_power: bool = False
100119
class_name: str = ""

kirby_cost/template/hdt_provider.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,11 @@ def _template_data(entry: dict[str, Any], *, is_power: bool) -> TemplateData:
311311
duration=entry.get("duration") or "",
312312
range=(_attrs(entry).get("RANGE") or ""),
313313
target=entry.get("target") or "",
314+
defense=entry.get("defense") or "",
315+
killing=entry.get("killing"),
316+
does_body=entry.get("does_body"),
317+
does_damage=entry.get("does_damage"),
318+
does_knockback=entry.get("does_knockback"),
314319
uses_end=bool(entry.get("uses_end")),
315320
is_power=is_power,
316321
# The engine resolves classes through its own xmlid registry; the
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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

Comments
 (0)