Skip to content

Commit 04f8c22

Browse files
pdbethkeclaude
andcommitted
fix(objects): getRangeValue ported in full -- all three RANGE-word branches
The port covered only word==YES and a bare word==LOS. Java's else branch (GenericObject.java:2474-2545) makes an HTH power ranged via the RANGED modifier (HKA Ranged: 150m); the LOS branch's NORMALRANGE case (:2419-2452) converts a Line Of Sight power to cost-based range (Telepathy: 200m); plus INCREASEDMAXRANGE's remove-recurse-multiply dance, UOO/UAA, and each branch's exact rounding (half-up vs half-down). Parity 695/695; stateful matrix 36 -> 22 (all 14 getRangeValue cells closed); two named tests on the sink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PrVDXgpDfQpzEGErZTLjEs
1 parent 8c93d1f commit 04f8c22

3 files changed

Lines changed: 105 additions & 29 deletions

File tree

kirby_cost/objects/base.py

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,38 +1134,107 @@ def range_value(self) -> int:
11341134
No Range and Damage Shield to nothing, Line Of Sight and Based On ECV
11351135
to unlimited.
11361136
"""
1137-
from kirby_cost.util.rounder import round_half_up
1137+
from kirby_cost.util.rounder import round_half_down, round_half_up
11381138
word = (self.range or "No").strip().upper()
11391139
mods = self.all_assigned_modifiers
11401140

11411141
def has(xmlid):
11421142
return GenericObject.find_object_by_id(mods, xmlid) is not None
11431143

1144+
def cost_metres(round_fn):
1145+
# 6E: ten metres per point of TOTAL cost, before the adders that
1146+
# are not included in the base; 5E: five per Active Point.
1147+
if is_6e():
1148+
tot = self.total_cost
1149+
for ad in self.assigned_adders:
1150+
if not ad.include_in_base() and not ad.custom:
1151+
tot -= ad.total_cost
1152+
return int(round_fn(tot * 10))
1153+
return int(round_fn(self.active_cost * 5))
1154+
1155+
def increased_max_range():
1156+
"""The recursive dance at GenericObject.java:2365-2382 (and its two
1157+
twins): remove INCREASEDMAXRANGE from the OWN list, re-derive the
1158+
range without it, restore, multiply by power^(levels*level_value),
1159+
round half DOWN. Returns None when the branch does not fire."""
1160+
inc = GenericObject.find_object_by_id(self.assigned_modifiers,
1161+
"INCREASEDMAXRANGE")
1162+
if inc is None or inc.levels <= 0:
1163+
return None
1164+
value = float(inc.level_power) ** (inc.levels * inc.level_value)
1165+
if value <= 0:
1166+
return None
1167+
own = self.assigned_modifiers
1168+
own.remove(inc)
1169+
try:
1170+
real = self.range_value
1171+
finally:
1172+
own.append(inc)
1173+
return int(round_half_down(value * real))
1174+
1175+
def uoo_uaa():
1176+
mod = GenericObject.find_object_by_id(mods, "UOO")
1177+
if mod is None:
1178+
return None
1179+
opt = getattr(mod, "selected_option", None)
1180+
opt_id = (getattr(opt, "xmlid", "") or getattr(mod, "option_id", "") or "").upper()
1181+
return mod, opt_id == "UAA"
1182+
11441183
ranged = word == "YES" or has("BASEDONCON")
11451184
if ranged and (not has("MOBILE") or is_6e()):
1185+
# GenericObject.java:2346-2415
11461186
if has("NORANGE") or has("DAMAGESHIELD"):
11471187
return 0
11481188
if has("BOECV") and not has("NORMALRANGE"):
11491189
return -1
1190+
imr = increased_max_range()
1191+
if imr is not None:
1192+
return imr
11501193
if has("LOS"):
11511194
return -1
1152-
total = self.total_cost
1153-
if is_6e():
1154-
for ad in self.assigned_adders:
1155-
# Both halves were dead. `include_in_base` is a METHOD,
1156-
# so the getattr handed back a bound method and `not` on
1157-
# it was always False; and the attribute is `custom`, not
1158-
# `is_custom`, so that getattr always defaulted. Nothing
1159-
# was ever subtracted, and a Clairsentience with three
1160-
# adders reported 500m of range where HD reports 200m.
1161-
if not ad.include_in_base() and not ad.custom:
1162-
total -= ad.total_cost
1163-
return int(round_half_up(total * 10))
1164-
return int(round_half_up(self.active_cost * 5))
1195+
ret = cost_metres(round_half_up)
1196+
uoo = uoo_uaa()
1197+
if uoo is not None:
1198+
_, uaa = uoo
1199+
if uaa:
1200+
if has("LOS"):
1201+
return -1
1202+
if has("RANGED"):
1203+
return ret
1204+
return 0
1205+
return ret
1206+
return ret
11651207
if word == "LOS" and not has("BASEDONCON") and (not has("MOBILE") or is_6e()):
1208+
# GenericObject.java:2419-2472: a Line Of Sight power.
11661209
if has("DAMAGESHIELD"):
11671210
return 0
1211+
if has("NORMALRANGE"):
1212+
imr = increased_max_range()
1213+
if imr is not None:
1214+
return imr
1215+
return cost_metres(round_half_up if is_6e() else round_half_down)
1216+
if has("SKINCONTACTREQUIRED"):
1217+
return 0
1218+
uoo = uoo_uaa()
1219+
if uoo is not None:
1220+
_, uaa = uoo
1221+
if uaa:
1222+
if has("LOS"):
1223+
return -1
1224+
if has("RANGED"):
1225+
return cost_metres(round_half_down)
1226+
return 0
1227+
return -1
11681228
return -1
1229+
# GenericObject.java:2474-2545: any other RANGE word (No, Self, HTH...)
1230+
# -- the RANGED (or Ranged Recombination) modifier makes it ranged.
1231+
if has("RANGED") or has("RANGEDRECOMBINATION"):
1232+
if has("BOECV") or has("LOS"):
1233+
return -1
1234+
imr = increased_max_range()
1235+
if imr is not None:
1236+
return imr
1237+
return cost_metres(round_half_down)
11691238
return 0
11701239

11711240
@property

tests/fixtures/included_stateful_known_gaps.json

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,12 @@
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 36 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 22 remain (2026-08-30); loader precedence fix 2026-08-30: 107 closed, 6 new field-vs-computed cells accepted (now 36 remain)",
44
"gaps": {
5-
"assigned:20260830000020:LOS": "rule: getRangeValue() port -- range_value HD=200 engine=-1; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
65
"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\"}",
76
"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).",
87
"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).",
98
"template:20260830000011:TIMELIMIT": "loader: duration HD=CONSTANT engine=PERSISTENT; a constructor/loader duration still beats the template here after the 2026-08-30 apply_template fix -- see Follow-ups (1)",
109
"template:20260830000015:COSTSENDTOMAINTAIN:FULL": "follow-up: continuing_effect is inferred, not the HD field -- engine continuing_effect=True (inferred from duration modifiers) where HD's own field is False, which is what lets \"...do not already cost END to maintain\" apply on HD's side and not the engine's (orig_duration=INSTANT on both). 2026-08-30 anatomy note Follow-ups (4).",
11-
"template:20260830000020:INCREASEDMAXRANGE": "rule: getRangeValue() port -- range_value HD=200 engine=-1; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
12-
"template:20260830000020:LIMITEDRANGE": "rule: getRangeValue() port -- range_value HD=200 engine=-1; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
13-
"template:20260830000020:LOS": "rule: getRangeValue() port -- range_value HD=200 engine=-1; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
14-
"template:20260830000020:MEGASCALE": "rule: getRangeValue() port -- range_value HD=200 engine=-1; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
15-
"template:20260830000020:NORMALRANGE": "rule: getRangeValue() port -- range_value HD=200 engine=-1; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
16-
"template:20260830000024:BEAM": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
17-
"template:20260830000024:HALFRANGEMODIFIER": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
18-
"template:20260830000024:INCREASEDMAXRANGE": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
19-
"template:20260830000024:LOS": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
20-
"template:20260830000024:MEGASCALE": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
21-
"template:20260830000024:NORANGEMODIFIER": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
22-
"template:20260830000024:RANGED:RANGED": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
23-
"template:20260830000024:REDUCEDBYRANGE": "rule: getRangeValue() port -- range_value HD=150 engine=0; the engine's range for this shape differs from HD's (GenericObject.java:2342); Follow-ups (8)",
2410
"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)",
2511
"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)",
2612
"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)",

tests/test_included_generic.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,3 +807,24 @@ def test_the_raw_target_batch_reads_the_modifier_aware_target():
807807
reason = template_modifier("RANGED").included(drain)
808808
assert reason == stateful_cell["reason"]
809809
assert reason == "Ranged cannot be applied to Self-Only Powers."
810+
811+
812+
def test_a_ranged_modifier_gives_an_hth_power_cost_based_range():
813+
"""RANGE is a word; an HKA says HTH, and the RANGED advantage makes it
814+
ranged anyway -- GenericObject.java:2474-2545's else branch: ten metres
815+
per point of total cost in 6E. HD rule, no page (the book states range
816+
as 10m x Active Points/5 on 6E1 p.316; HD derives from total cost)."""
817+
from tests.matrix_support import object_index, sink_hero
818+
hka = next(o for o in sink_hero().powers if o.name == "HKA Ranged")
819+
assert hka.range == "HTH"
820+
assert hka.range_value == 150 # HD's echo for object 20260830000024
821+
822+
823+
def test_a_los_power_with_normal_range_reports_cost_based_range():
824+
"""Telepathy is RANGE="LOS"; the Normal Range limitation converts it to a
825+
ranged power -- GenericObject.java:2419-2452: 6E, total cost x 10.
826+
HD rule, no page."""
827+
from tests.matrix_support import object_index, sink_hero
828+
tp = next(o for o in sink_hero().powers if o.name == "Telepathy LOS")
829+
assert tp.range == "LOS"
830+
assert tp.range_value == 200 # HD's echo for object 20260830000020

0 commit comments

Comments
 (0)