@@ -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