Skip to content

Commit 86a4328

Browse files
pdbethkeclaude
andcommitted
fix(objects): defense computes as Java's getDefense(); frameworks are Powers; Flight delegates usesEND
defense: NND/AVLD force SPECIAL, UOO POWER, BOECV its option, BASEDONCON NORMAL (GenericObject.java:1542-1567), over own + parent-list modifiers; defense_ignoring() ports the getDefense(ignoreID) variant; raw orig_defense kept for the template/document value. is_power: a framework restored from the POWERS section is a Power in HD's classification (Linked.java:455 asks the List itself) -- the loader now sets it, closing LINKED-on-Multipower/VPP. Flight.uses_end: the old override returned a private flag, bypassing the base's computed usesEND() -- Java's Flight.usesEND() (Flight.java:41-47) is GLIDING-in-6E-then-super. A Charges-carrying Flight claimed to use END. Stateful matrix 12 -> 2 (only the 5E-fallback EXCLUDES decision pair remains); parity 695/695, display 91,221, suite 1,727. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PrVDXgpDfQpzEGErZTLjEs
1 parent 55d9031 commit 86a4328

5 files changed

Lines changed: 61 additions & 18 deletions

File tree

kirby_cost/io/hdc_loader.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,13 @@ def _load_powers_section(
12151215
fw.xmlid = "ELEMENTALCONTROL"
12161216
# Store the framework XML tag for round-trip serialization
12171217
fw._framework_tag = tag
1218+
# A framework restored from the POWERS section is a Power in
1219+
# HD's classification -- Linked.java:455 asks o.isPower() of
1220+
# the List itself and HD answers yes (GenericObject.java:3547
1221+
# returns the field the powers-section restore sets). The
1222+
# engine never set it, so LINKED refused a Multipower/VPP HD
1223+
# allows.
1224+
fw._is_power = True
12181225
# Load framework modifiers
12191226
for mod_elem in elem.findall("MODIFIER"):
12201227
mod = self._build_modifier(mod_elem, fw)

kirby_cost/objects/base.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ def apply_template(self, tmpl: "TemplateData", option_id: str = None) -> None:
679679
if "DEFENSE" in forced_xml:
680680
self.defense = tmpl.defense
681681
elif tmpl.defense and "DEFENSE" not in stated:
682-
self.defense = tmpl.defense # template beats the constructor, as DURATION above
682+
self._defense = tmpl.defense # template beats the constructor, as DURATION above
683683
# Combat facts stated by the template. These are what let a GM's house
684684
# rules reach the fight: kirby-cost reads the .hdt, so a campaign that
685685
# edits KILLING="No" on the killing attacks, or turns knockback off,
@@ -1027,13 +1027,50 @@ def orig_duration(self, value: str) -> None:
10271027

10281028
@property
10291029
def defense(self) -> str:
1030-
"""Get the defense type."""
1031-
return self._defense
1030+
"""Java's computed getDefense() (GenericObject.java:1542-1567), not
1031+
the raw field: BASEDONCON forces NORMAL, BOECV its option (STANDARD
1032+
NORMAL, else MENTAL), UOO POWER, NND and AVLD SPECIAL -- read over the
1033+
object's own modifiers plus its parent List's, as the Java does."""
1034+
return self.defense_ignoring("")
1035+
1036+
def defense_ignoring(self, ignore_id: str) -> str:
1037+
"""getDefense(String ignoreID) (GenericObject.java:1578-1620): the
1038+
same computation with one modifier xmlid left out -- AVLD prices
1039+
itself against the defense the power would have without AVLD."""
1040+
ret = self._defense
1041+
mods = self.all_assigned_modifiers
1042+
1043+
def has(xmlid):
1044+
return xmlid != ignore_id and GenericObject.find_object_by_id(mods, xmlid) is not None
1045+
1046+
if has("BASEDONCON"):
1047+
ret = "NORMAL"
1048+
if has("BOECV"):
1049+
mod = GenericObject.find_object_by_id(mods, "BOECV")
1050+
opt = getattr(mod, "selected_option", None)
1051+
opt_id = (getattr(opt, "xmlid", "") or getattr(mod, "option_id", "") or "").upper()
1052+
ret = "NORMAL" if opt_id == "STANDARD" else "MENTAL"
1053+
if has("UOO"):
1054+
ret = "POWER"
1055+
if has("NND"):
1056+
ret = "SPECIAL"
1057+
if has("AVLD"):
1058+
ret = "SPECIAL"
1059+
return ret
10321060

10331061
@defense.setter
10341062
def defense(self, value: str) -> None:
10351063
self._defense = value
10361064

1065+
@property
1066+
def orig_defense(self) -> str:
1067+
"""The raw field -- what the template/document stated."""
1068+
return self._defense
1069+
1070+
@orig_defense.setter
1071+
def orig_defense(self, value: str) -> None:
1072+
self._defense = value
1073+
10371074
@property
10381075
def available_modifiers(self) -> List['Modifier']:
10391076
"""Get the available modifiers."""

kirby_cost/objects/powers/flight.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,15 @@ def uses_end(self) -> bool:
4141
if is_6e() and GenericObject.find_object_by_id(
4242
self.assigned_modifiers, "GLIDING") is not None:
4343
return False
44-
return getattr(self, "_uses_end_flag", False)
44+
# Java: `return super.usesEND()` -- the base's COMPUTED read (CHARGES
45+
# forces False, COSTSEND True...), not a private flag. The old flag
46+
# bypassed that computation and made a Charges-carrying Flight claim
47+
# to use END.
48+
return GenericObject.uses_end.fget(self)
4549

4650
@uses_end.setter
4751
def uses_end(self, value: bool) -> None:
48-
self._uses_end_flag = bool(value)
52+
self._uses_end = bool(value)
4953

5054
@property
5155
def damage_display(self) -> str:
Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
11
{
22
"_comment": "SHRINK-ONLY. Cells where the engine's included() disagrees with Hero Designer's, with what each side said. Written once as the raw survey baseline (2026-08-30); every later commit may only remove entries. The target is empty.",
3-
"_baseline": "304 of 9627 cells disagree (2026-08-30) (fixture regenerated after Task 4 added one state); re-seeded 236 of 9744 (2026-08-30) after the oracle tier-2 fix (Task 6 item 0); re-seeded 137 of 9744 (2026-08-30) after the raw-.target batch (Task 6 item 2, 23 overrides switched to effective_target(), plus the assigned-tier survey harness itself made to detach-then-ask like GenericObject.java:4584-4661 -- SELFONLY-on-Adjustable-Drain would otherwise regress, since it is asked about its own target while still attached) -- 100 closed across the 23 raw-target overrides + SELFONLY-on-Adjustable-Drain, 1 new: PERSISTENT-on-Resistant-Protection surfaced by the detach, the same constructor-hardcoded-duration loader follow-up as test_verify_modifiers.LOADER_DIVERGENCE); still shrink-only from here; now 12 remain (2026-08-30); loader precedence fix 2026-08-30: 107 closed, 6 new field-vs-computed cells accepted (now 36 remain)",
3+
"_baseline": "304 of 9627 cells disagree (2026-08-30) (fixture regenerated after Task 4 added one state); re-seeded 236 of 9744 (2026-08-30) after the oracle tier-2 fix (Task 6 item 0); re-seeded 137 of 9744 (2026-08-30) after the raw-.target batch (Task 6 item 2, 23 overrides switched to effective_target(), plus the assigned-tier survey harness itself made to detach-then-ask like GenericObject.java:4584-4661 -- SELFONLY-on-Adjustable-Drain would otherwise regress, since it is asked about its own target while still attached) -- 100 closed across the 23 raw-target overrides + SELFONLY-on-Adjustable-Drain, 1 new: PERSISTENT-on-Resistant-Protection surfaced by the detach, the same constructor-hardcoded-duration loader follow-up as test_verify_modifiers.LOADER_DIVERGENCE); still shrink-only from here; now 2 remain (2026-08-30); loader precedence fix 2026-08-30: 107 closed, 6 new field-vs-computed cells accepted (now 36 remain)",
44
"gaps": {
55
"assigned:20260830000135:DOUBLEENDCOST": "5E-fallback EXCLUDES/REQUIRES precedence, restated 2026-08-30 (anatomy note Follow-ups (2)): DOUBLEENDCOST/ENDRESERVEOREND are not Main6E.hdt modifiers at all (0 hits; only the 5E Main.hdt sibling declares them), so their assigned _excludes come from HDTTemplateProvider's fallback pass (hdt_provider.py:405-427) naming each other. HD's real assigned-tier ask (detached per Task 6 item 0) is allowed=True for both. A definitions-only fallback fix (clear excludes/requires/types on a fallback-sourced TemplateData in apply_template) was built and verified to close exactly this pair, but was reverted because it broke test_included_generic.py::test_requires_any_of_lists_the_options_and_is_met_by_one, whose docstring claimed MULTIPLESFX's REQUIRES was \"Main6E's only REQUIRES\" -- that claim was false: MULTIPLESFX's REQUIRES is ALSO fallback-only (5E Main.hdt has it, Main6E does not), with 0 Main6E entries and 0 HD rows in either fixture backing it, so the fix was blocked by an untested assertion, not a genuine 6E rule. The open question -- whether MULTIPLESFX's REQUIRES is real -- is settled by adding a MULTIPLESFX state to the sink and asking HD directly, not by more reading. HD state={\"active_cost\": 50.0, \"defense\": \"NORMAL\", \"does_body\": true, \"does_damage\": true, \"does_kb\": true, \"duration\": \"INSTANT\", \"end_usage\": 5, \"orig_duration\": \"INSTANT\", \"range_value\": 400, \"target\": \"DCV\"}",
6-
"assigned:20260830000135:ENDRESERVEOREND": "5E-fallback EXCLUDES/REQUIRES precedence, restated 2026-08-30: see the DOUBLEENDCOST cell at this same object -- identical cause and identical deferral (fix built, reverted only because it broke an untested REQUIRES assertion on MULTIPLESFX, not because MULTIPLESFX's REQUIRES is a genuine rule).",
7-
"template:20260830000009:TIMELIMIT": "follow-up: uses_end is a field (True) independent of end_usage (0 here) -- HD state end_usage=0 satisfies TimeLimit's END check directly; the engine's uses_end==True check (a stale/unrelated flag) trips the refusal instead. 2026-08-30 anatomy note Follow-ups (4).",
8-
"template:20260830000043:CUMULATIVE": "sink cost shape: active_cost HD=2.0 engine=3; defense HD=SPECIAL engine=NONE; HD costs this sink object differently from the engine -- the sink is not yet a cost fixture; Follow-ups (9)",
9-
"template:20260830000043:LINKED": "sink cost shape: active_cost HD=2.0 engine=3; defense HD=SPECIAL engine=NONE; HD costs this sink object differently from the engine -- the sink is not yet a cost fixture; Follow-ups (9)",
10-
"template:20260830000045:ARMORPIERCING": "sink cost shape: active_cost HD=0.0 engine=9.0; defense HD=SPECIAL engine=NONE; HD costs this sink object differently from the engine -- the sink is not yet a cost fixture; Follow-ups (9)",
11-
"template:20260830000045:CUMULATIVE": "sink cost shape: active_cost HD=0.0 engine=9.0; defense HD=SPECIAL engine=NONE; HD costs this sink object differently from the engine -- the sink is not yet a cost fixture; Follow-ups (9)",
12-
"template:20260830000046:ARMORPIERCING": "template precedence for target/defense: defense HD=SPECIAL engine=NONE; Follow-ups (7)",
13-
"template:20260830000046:CUMULATIVE": "template precedence for target/defense: defense HD=SPECIAL engine=NONE; Follow-ups (7)",
14-
"template:20260830000054:LINKED": "message/verdict-only: states agree, verdicts differ -- a rule port defect; engine allowed=False 'Linked can only be applied to Powers.' HD allowed=True '' HD state={\"active_cost\": 1.0, \"defense\": \"NONE\", \"does_body\": false, \"does_damage\": false, \"does_kb\": false, \"duration",
15-
"template:20260830000091:CANBEMISSILEDEFLECTED": "template precedence for target/defense: defense HD=SPECIAL engine=NORMAL; Follow-ups (7)",
16-
"template:20260830000111:CANBEMISSILEDEFLECTED": "template precedence for target/defense: defense HD=POWER engine=NORMAL; Follow-ups (7)"
6+
"assigned:20260830000135:ENDRESERVEOREND": "5E-fallback EXCLUDES/REQUIRES precedence, restated 2026-08-30: see the DOUBLEENDCOST cell at this same object -- identical cause and identical deferral (fix built, reverted only because it broke an untested REQUIRES assertion on MULTIPLESFX, not because MULTIPLESFX's REQUIRES is a genuine rule)."
177
}
188
}

tests/test_character_declared_template.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,9 @@ def test_a_main6e_character_is_unaffected():
7171
assert hero.total_points == 1456.0
7272
flight = next((p for p in hero.powers if p.xmlid == "FLIGHT"), None)
7373
if flight is not None:
74-
assert flight.uses_end is True
74+
# This Flight carries REDUCEDEND (ZERO END): the raw field stays True,
75+
# and the COMPUTED uses_end -- Java's usesEND(), GenericObject.java
76+
# :4295-4328, where only HALFEND keeps END -- is False. The old
77+
# `uses_end is True` assertion encoded the raw-field era.
78+
assert flight.orig_uses_end is True
79+
assert flight.uses_end is False

0 commit comments

Comments
 (0)