Skip to content

Commit 4c49dc8

Browse files
pdbethkeclaude
andcommitted
feat(template): the 5E fallback supplies definitions, not rules — stateful ledger 0, prototype 1
PeterB ruling (kirby-cost is 6E-only): an entry loaded from the earlier-edition sibling keeps costs/options/adders but drops its EXCLUDES/REQUIRES and its non-framework TYPEs — HD's own verdicts prove the line (it allows DOUBLEENDCOST+ENDRESERVEOREND together, and a bare 5E MULTIPLESFX on a plain Blast, on a Main6E character). Framework-binding types (VPP/MP/EC/LIST) survive: they classify private-vs-common cost propagation. A MULTIPLESFX sink state lets HD keep adjudicating. Also: CustomPower ports its template-init base-cost floor (CustomPower.java:324-325, if baseCost==0 -> 1; a stated BASECOST still wins), closing the five prototype-END cells. REDUCEDEND-on-SHAPESHIFT is the one cell left, honestly labelled unexplained (HD allows where its own visible code path would refuse; suspect the clone at ReducedEND.java:130). Stateful matrix: 0 ledgered of 9,867. Prototype: 1 of 8,395. Sink cost ledger: 10. Parity 695/695, display 91,221, suite 1,728. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PrVDXgpDfQpzEGErZTLjEs
1 parent 86a4328 commit 4c49dc8

9 files changed

Lines changed: 4117 additions & 3900 deletions

kirby_cost/objects/powers/custom_power.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def __init__(self):
8282
self.use_custom_column3: bool = False
8383

8484
@property
85+
8586
def levels(self) -> int:
8687
"""CustomPower levels = roundUp(baseCost)."""
8788
return round_up(self._base_cost)
@@ -90,6 +91,17 @@ def levels(self) -> int:
9091
def levels(self, value) -> None:
9192
self._levels = value
9293

94+
def apply_template(self, tmpl, option_id: str = None) -> None:
95+
"""CustomPower.java:315-328 (template init): after the template is
96+
read, `if (baseCost == 0) baseCost = 1` -- HD's prototype floors a
97+
zero base cost at 1 point, which is why its prototype reports
98+
active_cost 1 and END 1. The document restore runs AFTER init in HD
99+
and overwrites the floor, so a stated BASECOST (even an explicit 0)
100+
wins -- mirrored here with the _base_cost_from_xml guard."""
101+
super().apply_template(tmpl, option_id)
102+
if self.base_cost == 0 and not self._base_cost_from_xml:
103+
self.base_cost = 1.0
104+
93105
@property
94106
def active_cost(self) -> float:
95107
"""Calculate the active cost."""

kirby_cost/template/hdt_provider.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,14 @@ def __init__(self, path: Optional[str | Path] = None,
432432
extras = [s for p in chain for s in self._siblings(p)]
433433
for extra in extras:
434434
if extra.is_file():
435-
self._load(extra)
435+
# kirby-cost is 6E-only (the 2026-08 purge); the earlier-edition
436+
# sibling supplies DEFINITIONS HD itself resolves through it
437+
# (ARMOR, SUPPRESS, TRANSFER, SUCCOR, DAMAGERESISTANCE,
438+
# ENDURANCERESERVEREC) -- never 5E applicability RULES. HD's
439+
# own verdicts prove the line: it allows DOUBLEENDCOST +
440+
# ENDRESERVEOREND together on a Main6E character even though
441+
# Main.hdt declares them mutually exclusive.
442+
self._load(extra, fallback=True)
436443

437444
@staticmethod
438445
def _resolve_builtin(name: str, beside: Path) -> Optional[Path]:
@@ -502,8 +509,35 @@ def sense_xmlids(self) -> frozenset[str]:
502509
"""
503510
return frozenset(self._sense_xmlids)
504511

505-
def _load(self, path: Path) -> None:
512+
def _strip_5e_rules(self, data: "TemplateData") -> "TemplateData":
513+
"""Definitions-only fallback (PeterB ruling 2026-08-30): an entry
514+
loaded from an earlier-edition sibling keeps its costs, options and
515+
adders but drops EXCLUDES/REQUIRES -- 5E applicability rules a 6E
516+
engine must not enforce. TYPE is kept: it feeds oracle-gated cost
517+
propagation and HD gave no verdict against it."""
518+
if not getattr(self, "_loading_fallback", False):
519+
return data
520+
strip = {}
521+
if getattr(data, "excludes", ()) or getattr(data, "requires", ()):
522+
strip.update(excludes=(), requires=(), requires_all=False)
523+
# TYPE too, for MODIFIER entries: HD allows a bare 5E-fallback
524+
# MULTIPLESFX on a plain Blast (the stateful fixture's verdict), so
525+
# the type gate is an applicability rule the fallback must not carry.
526+
# Power entries keep their types -- those feed type-matching and
527+
# oracle-gated cost paths for definitions HD genuinely resolves
528+
# through the sibling (ARMOR, SUPPRESS, TRANSFER...).
529+
# ...but FRAMEWORK-binding types (VPP/MP/EC/LIST) survive: they
530+
# classify a modifier as private-vs-common, which feeds cost
531+
# propagation -- a cost fact, not an applicability rule.
532+
if not getattr(data, "is_power", False) and getattr(data, "types", ()):
533+
kept = tuple(t for t in data.types if t in ("VPP", "MP", "EC", "LIST"))
534+
if kept != tuple(data.types):
535+
strip["types"] = kept
536+
return replace(data, **strip) if strip else data
537+
538+
def _load(self, path: Path, *, fallback: bool = False) -> None:
506539
parsed = _parse_cached(path)
540+
self._loading_fallback = fallback
507541
nested_later: list = []
508542
groups = _sense_groups(parsed)
509543
self._index_maneuvers(parsed.get("martial_arts") or [])
@@ -527,9 +561,10 @@ def _load(self, path: Path) -> None:
527561
# section's own definition is still the right one for
528562
# a caller that knows which kind of object it holds.
529563
self._by_section.setdefault(
530-
(section, xmlid), _template_data(entry, is_power=is_power))
564+
(section, xmlid),
565+
self._strip_5e_rules(_template_data(entry, is_power=is_power)))
531566
continue
532-
data = _template_data(entry, is_power=is_power)
567+
data = self._strip_5e_rules(_template_data(entry, is_power=is_power))
533568
synthetic = _sense_group_options(xmlid, entry, groups)
534569
if synthetic:
535570
data = replace(data, options=synthetic)

0 commit comments

Comments
 (0)