Skip to content

Commit a02e485

Browse files
pdbethkeclaude
andcommitted
fix: the build doc was lossy in three places, and none of them cost a point
The canonical build doc is meant to be what a file corpus stores: the rebuild document, no prose, round-trippable. Moving kirby-combat's suite onto it found three things it silently forgot. Every one of them round-tripped to the same TOTAL, which is why cost parity never caught any of them and never could. NATIVE_TONGUE. A native tongue is free, and the flag is the only thing that says so -- the element still carries the option's base cost, so Bokor's Creole is `OPTIONID="ACCENT" BASECOST="3.0"` with `NATIVE_TONGUE="Yes"`. `Language._init` has always read it; nothing wrote it back, so the reader rebuilt an ordinary three-point Language and Bokor came back 279 points against the .hdc's 276. This one DID move a total, and it is the reason the other two were looked for. EQUIPMENT. `_SECTION_TAG` listed seven sections and equipment was not one of them, so a character whose weapon is CARRIED round-tripped into being unarmed. The loader has always read an <EQUIPMENT> section, with the same `_load_powers_section` as powers -- 6E2 p.182: "Most equipment is built with Powers" -- and kirby-combat has read `hero.equipment` for its attacks since 2026-09-07. Only the document forgot. Verified on the HSEG 19th-century prefabs: 126 weapons in, 126 out. THE PD/ED SPLIT. HD writes PDLEVELS/EDLEVELS/MDLEVELS/POWDLEVELS on a Resistant Protection element and `ForceField.XML_ATTRS` has read them since a re-export was caught losing the whole power. The doc never wrote them, so Power Lad's 45 points rebuilt as 0/0 and the consumer was left guessing which half was physical -- kirby-combat guessed half and half, then capped the guess against natural PD, and he fought a gunfight at rPD 2 instead of 25. Costing does not notice: HD prices the power by LEVELS, so a doc without the split is worth exactly the same points and looks correct. What it loses is the fight. THE PATTERN IS THE FINDING. Three fields, three different places, and all three invisible to a totals comparison. A document that is checked only by its cost can drop anything the cost does not read -- which includes most of what combat consumes. What this wants next is a round-trip test that walks a real character field by field rather than comparing points. Suite 1811 passed, oracle parity unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lvepxj9B2mDc4cRN3jaWwn
1 parent 863c46f commit a02e485

4 files changed

Lines changed: 185 additions & 2 deletions

File tree

kirby_cost/io/build_json.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ class BuildDocError(ValueError):
1212
"characteristics": "CHARACTERISTICS", "powers": "POWERS", "skills": "SKILLS",
1313
"perks": "PERKS", "talents": "TALENTS", "martial_arts": "MARTIALARTS",
1414
"disadvantages": "DISADVANTAGES",
15+
# Carried gear. Shaped exactly like POWERS -- the loader builds it with
16+
# the same `_load_powers_section` -- and 6E2 p.182 says why: "Most
17+
# equipment is built with Powers". Missing here, a character whose
18+
# weapon is carried rather than innate round-tripped UNARMED, silently,
19+
# and kirby-combat has read `hero.equipment` for its attacks since
20+
# 2026-09-07. Last of the seven sections to be written down.
21+
"equipment": "EQUIPMENT",
1522
}
1623
# Cost-driving fields the loader's _init() reads off every element (power,
1724
# modifier, adder). Emitting the EFFECTIVE values back as their HDC attributes
@@ -55,14 +62,25 @@ class BuildDocError(ValueError):
5562
"body_levels": "BODYLEVELS", "width_levels": "WIDTHLEVELS",
5663
"cost_per_inch": "COSTPERINCH", "cost_per_body": "COSTPERBODY",
5764
"group": "GROUP",
65+
# Resistant Protection's PD/ED/MD/POWD split. HD costs the power by
66+
# LEVELS, so losing these did not move a single point and the round trip
67+
# looked clean -- but the split is what COMBAT reads, and without it a
68+
# consumer is left guessing which half of 45 is physical.
69+
"pd_levels": "PDLEVELS", "ed_levels": "EDLEVELS",
70+
"md_levels": "MDLEVELS", "powd_levels": "POWDLEVELS",
5871
}
5972
# Skill cost-mode flags the Skill loader reads in _init (skill.py:654-668). These
6073
# decide the base cost (familiarity=1 / proficiency=N / 3) and the adder-cost
6174
# discount path (AdderBasedSkill.total_cost). Without them a skill that defaulted
6275
# off in the HDC re-defaults ON (set_familiarity(True) at adder_based_skill.py:43),
6376
# dropping adder cost. Emit the EFFECTIVE flag (Yes/No) so the mode is preserved.
6477
_SKILL_FLAG = {"familiarity": "FAMILIARITY", "proficiency": "PROFICIENCY",
65-
"levels_only": "LEVELSONLY", "everyman": "EVERYMAN"}
78+
"levels_only": "LEVELSONLY", "everyman": "EVERYMAN",
79+
# A native tongue is FREE, and the flag is the only thing that
80+
# says so -- the element still carries the option's BASECOST.
81+
# Missing here, Bokor's Creole rebuilt as an ordinary 3-point
82+
# Language and he came back 279 points against the .hdc's 276.
83+
"native_tongue": "NATIVE_TONGUE"}
6684
#: Input-only spellings of the same three flags. `Skill.to_build_dict` writes
6785
#: `familiarity`/`proficiency`/`everyman`, and so does this module's emitter --
6886
#: but the ORACLE's dump (hd6cli, the shape `tests/fixtures/authored/*.json`

kirby_cost/objects/powers/force_field.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ class ForceField(Power, xmlid="FORCEFIELD"):
3131
XMLAttr("POWDLEVELS", "powd_levels", "int"),
3232
)
3333

34+
def to_build_dict(self) -> dict:
35+
"""The split, which the cost does not depend on and the FIGHT does.
36+
37+
`XML_ATTRS` has read PDLEVELS/EDLEVELS off the element since a
38+
re-export was found dropping the whole power. Writing them back was
39+
missed because nothing in the COST notices: HD prices Resistant
40+
Protection by LEVELS, so a doc without the split round-trips to the
41+
same points and looks correct. What it loses is which half is
42+
physical -- and kirby-combat, handed 45 and no split, guessed half
43+
and half.
44+
45+
Emitted whenever non-zero, matching ForceWall's exporter one file
46+
over; a power with no split of its own says nothing rather than
47+
writing four zeroes.
48+
"""
49+
d = super().to_build_dict()
50+
for field in ("pd_levels", "ed_levels", "md_levels", "powd_levels"):
51+
if getattr(self, field, 0):
52+
d[field] = getattr(self, field)
53+
return d
54+
3455
def __init__(self):
3556
"""Initialize a Force Field power."""
3657
super().__init__()

kirby_cost/objects/skills/language.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,26 @@ def __init__(self, xmlid: str = None):
106106
self._minimum_cost = 0.0
107107
self._minimum_level = 0
108108
self._alias = "Language"
109-
109+
110+
def to_build_dict(self) -> dict:
111+
"""Everything `Skill` exports, plus the one flag that sets the price.
112+
113+
A native tongue is free. The element still carries the option's
114+
BASECOST -- Bokor's Creole is `OPTIONID="ACCENT" BASECOST="3.0"` --
115+
so `NATIVE_TONGUE` is the ONLY thing distinguishing a free language
116+
from a three-point one. `_init` has always read it; nothing wrote it
117+
back, so the canonical build doc was lossy in a way that changed a
118+
character's total: Bokor round-tripped 276 -> 279, and every one of
119+
those points was his mother tongue.
120+
121+
Written unconditionally rather than only when true: a reader that
122+
sees the key absent cannot tell "not native" from "written by an
123+
older exporter", and this document is the corpus's shape.
124+
"""
125+
d = super().to_build_dict()
126+
d["native_tongue"] = bool(self.native_tongue)
127+
return d
128+
110129
def _init(self, element) -> None:
111130
"""Initialize from XML element, including native tongue flag."""
112131
super()._init(element)

tests/test_build_json.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,128 @@ def test_user_wording_and_notes_survive_the_build_doc():
141141
again = build_from_json(to_build_json(hero)).powers[0]
142142
assert again.text_output == power.text_output
143143
assert again.notes == power.notes
144+
145+
146+
# ── A native tongue is free, and the doc has to remember that ────────────────
147+
148+
NATIVE = dict(MINIMAL, skills=[
149+
# Bokor's Creole, as HERO Designer writes it: OPTIONID="ACCENT",
150+
# BASECOST="3.0", NATIVE_TONGUE="Yes". The base cost on the element is
151+
# the price of the option, and the flag is what makes it free.
152+
{"id": "S1", "xmlid": "LANGUAGES", "option_id": "ACCENT",
153+
"base_cost": 3.0, "alias": "Language", "input": "Creole",
154+
"skill": True, "native_tongue": True},
155+
])
156+
157+
158+
def _creole(hero):
159+
return [s for s in hero.skills if s.xmlid == "LANGUAGES"][0]
160+
161+
162+
def test_a_native_tongue_costs_nothing_through_the_build_doc():
163+
"""Found by moving kirby-combat's suite onto build docs (2026-09-08):
164+
Bokor came back 279 points against the .hdc's 276, and the whole
165+
difference was his Creole.
166+
167+
`Language` parses `NATIVE_TONGUE` and prices on it, but had no
168+
`to_build_dict` of its own, so the flag never reached the document.
169+
The reader then rebuilt an ordinary 3-point Language. The .hdc path
170+
is the one validated against the Java oracle, and it says 0.
171+
"""
172+
assert _creole(build_from_json(NATIVE)).real_cost == 0.0
173+
174+
175+
def test_a_language_that_is_not_native_still_costs():
176+
"""Guards the guard: the flag must not zero every Language."""
177+
doc = dict(NATIVE, skills=[dict(NATIVE["skills"][0], native_tongue=False)])
178+
assert _creole(build_from_json(doc)).real_cost == 3.0
179+
180+
181+
def test_the_flag_survives_a_round_trip_out_and_back():
182+
"""The writer's half. A doc that loses the flag reads as a legal
183+
document and prices a character wrongly, which is the worst shape a
184+
serializer can have."""
185+
once = build_from_json(NATIVE)
186+
doc = to_build_json(once)
187+
assert doc["skills"][0].get("native_tongue") is True
188+
assert _creole(build_from_json(doc)).real_cost == 0.0
189+
190+
191+
# ── A carried weapon is part of the build ────────────────────────────────────
192+
193+
EQUIPPED = dict(MINIMAL, equipment=[
194+
{"id": "E1", "xmlid": "RKA", "levels": 2, "base_cost": 0.0,
195+
"level_cost": 15.0, "level_value": 1.0,
196+
"alias": "Killing Attack - Ranged", "name": "Revolver"},
197+
])
198+
199+
200+
def test_equipment_survives_the_build_doc():
201+
"""Found converting kirby-combat off raw .hdc (2026-09-08): the doc had
202+
no equipment section at all.
203+
204+
`_SECTION_TAG` listed characteristics, powers, skills, perks, talents,
205+
martial arts and disadvantages -- so a character whose weapon is CARRIED
206+
round-tripped without it, silently, and arrived unarmed. The loader has
207+
always read one (`hero.equipment = _load_powers_section(root,
208+
"EQUIPMENT")`), and kirby-combat has read `hero.equipment` for its
209+
attacks since 2026-09-07; only the document forgot.
210+
"""
211+
hero = build_from_json(EQUIPPED)
212+
assert [e.name for e in hero.equipment] == ["Revolver"]
213+
214+
215+
def test_equipment_is_written_back_out():
216+
doc = to_build_json(build_from_json(EQUIPPED))
217+
assert [e["name"] for e in doc.get("equipment", [])] == ["Revolver"]
218+
219+
220+
def test_a_carried_weapon_keeps_its_cost_across_the_round_trip():
221+
"""The half that matters: equipment that survives by name but not by
222+
cost is worse than equipment that vanishes, because it looks right."""
223+
once = build_from_json(EQUIPPED)
224+
twice = build_from_json(to_build_json(once))
225+
costs = [e.real_cost for e in once.equipment]
226+
assert costs, "nothing to compare — the weapon did not survive at all"
227+
assert [e.real_cost for e in twice.equipment] == costs
228+
229+
230+
# ── Resistant Protection keeps its PD/ED split ───────────────────────────────
231+
232+
PROTECTED = dict(MINIMAL, powers=[
233+
# Power Lad's "Body Like Iron": LEVELS 45 split 25 PD / 20 ED. HD costs
234+
# the power by the split, and combat READS the split -- 25 rPD is the
235+
# difference between shrugging off a revolver and being killed by one.
236+
{"id": "P1", "xmlid": "FORCEFIELD", "levels": 45, "base_cost": 0.0,
237+
"level_cost": 3.0, "level_value": 2.0, "alias": "Resistant Protection",
238+
"name": "Body Like Iron", "pd_levels": 25, "ed_levels": 20},
239+
])
240+
241+
242+
def _protection(hero):
243+
return [p for p in hero.powers if p.xmlid == "FORCEFIELD"][0]
244+
245+
246+
def test_resistant_protection_keeps_its_split_through_the_build_doc():
247+
"""`ForceField.XML_ATTRS` has read PDLEVELS/EDLEVELS off the .hdc since
248+
the day someone noticed a re-export losing the whole power. The build
249+
doc never wrote them, so a character whose armor is Resistant
250+
Protection rebuilt with the split at 0/0 and the consumer left to
251+
guess -- kirby-combat guessed half and half, then capped the guess
252+
against natural PD, and Power Lad fought at rPD 2 against 25.
253+
"""
254+
p = _protection(build_from_json(PROTECTED))
255+
assert (p.pd_levels, p.ed_levels) == (25, 20)
256+
257+
258+
def test_the_split_is_written_back_out():
259+
doc = to_build_json(build_from_json(PROTECTED))
260+
node = [n for n in doc["powers"] if n["xmlid"] == "FORCEFIELD"][0]
261+
assert (node.get("pd_levels"), node.get("ed_levels")) == (25, 20)
262+
263+
264+
def test_the_split_survives_two_round_trips():
265+
once = build_from_json(PROTECTED)
266+
twice = build_from_json(to_build_json(once))
267+
assert (twice.powers[0].pd_levels, twice.powers[0].ed_levels) == (25, 20)
268+
assert twice.powers[0].real_cost == once.powers[0].real_cost

0 commit comments

Comments
 (0)