Skip to content

Commit 6475639

Browse files
pdbethkeclaude
andcommitted
merge: the vocabulary guard, and a build doc that keeps what it is given
`test_vocabulary.py` pins that a library does not name its consumers' internals -- the guard that keeps a leaf package a leaf. The build-doc fixes are the larger half. The canonical document was lossy in three places -- NATIVE_TONGUE, the whole EQUIPMENT section, and Resistant Protection's PD/ED split -- and every one of them round-tripped to the same TOTAL, so cost parity never caught them and never could. One of the three did move a total (Bokor, 276 -> 279) and that is how the other two were found. Suite 1811, oracle parity unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lvepxj9B2mDc4cRN3jaWwn
2 parents bc0611c + a02e485 commit 6475639

5 files changed

Lines changed: 306 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

tests/test_vocabulary.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""A library does not name its consumers' internals.
2+
3+
kirby-combat, kirby-cost, kirby-dice and kirby-terrain are standalone
4+
packages. A comment here that names a module in a consuming application --
5+
or worse, cites line numbers inside it -- is a leaky abstraction and a
6+
guaranteed source of rot: those offsets were pinned against a file in
7+
another repository that no CI here can check, and they were wrong within
8+
weeks. Describe the ROLE ("the consumer's driver", "the chooser") and the
9+
comment stays true for as long as the role does.
10+
11+
The same rule covers vendor and product names. This package models
12+
CHOOSING -- a `chooser`, an `option`, a `situation`, a `decide()` -- and
13+
that vocabulary is deliberately neutral about what implements the choice.
14+
The neutrality is the better design as well as the tidier one: a scripted
15+
chooser and any other kind are then indistinguishable to the engine, which
16+
is exactly what lets a fight run with no dependencies at all.
17+
18+
A comment convention does not survive contact with a year of commits. This
19+
does, because it fails the build. It follows the same shape as
20+
kirby-terrain's leaf test: assert the property, then assert the assertion
21+
could actually fail.
22+
23+
To permit a genuine exception, add it to ALLOW with a reason. Deliberate
24+
and reviewable beats a silently widened pattern.
25+
"""
26+
import pathlib
27+
import re
28+
import subprocess
29+
30+
#: Unambiguous vendor, product and technique names. Deliberately NOT "ai":
31+
#: `ai = AttackInput(...)` is an ordinary local in several tests, so banning
32+
#: it would train everyone to ignore this test -- the failure mode that ends
33+
#: with the guard deleted.
34+
TERMS = [
35+
"llm", "ollama", "anthropic", "openai", "gpt", "chatgpt", "claude",
36+
"gemini", "mistral", "llama", "huggingface", "copilot", "bedrock",
37+
"medialib", "prompt",
38+
]
39+
40+
#: Underscore is a word character, so `\bllm\b` does NOT match `llm_driver` --
41+
#: the exact string this test exists to catch. These lookarounds treat any
42+
#: non-alphanumeric as a boundary, so `llm_driver`, `LLM-driver` and `(llm)`
43+
#: all match.
44+
PATTERN = re.compile(
45+
"|".join(rf"(?<![a-z0-9]){t}(?![a-z0-9])" for t in TERMS), re.IGNORECASE
46+
)
47+
48+
ROOT = pathlib.Path(__file__).resolve().parent.parent
49+
50+
#: (path, matched term) -> why it is allowed to stay.
51+
ALLOW: dict[tuple[str, str], str] = {
52+
(".gitignore", "claude"): "editor tooling directory, not a project reference",
53+
}
54+
55+
56+
def _tracked_files() -> list[str]:
57+
"""Every file git tracks -- source, README, pyproject, CI workflows.
58+
59+
Uses git rather than rglob so the scan covers exactly what is published
60+
and nothing that is not: no .venv, no build artefacts, no local scratch.
61+
"""
62+
out = subprocess.run(
63+
["git", "-C", str(ROOT), "ls-files"],
64+
capture_output=True, text=True, check=True,
65+
)
66+
return out.stdout.split()
67+
68+
69+
def _offenders() -> list[str]:
70+
found: list[str] = []
71+
here = pathlib.Path(__file__).name
72+
for rel in _tracked_files():
73+
# This file necessarily contains every banned term.
74+
if pathlib.Path(rel).name == here:
75+
continue
76+
path = ROOT / rel
77+
try:
78+
text = path.read_text(encoding="utf-8")
79+
except (UnicodeDecodeError, OSError):
80+
continue # binary or unreadable; nothing to read anyway
81+
for lineno, line in enumerate(text.splitlines(), 1):
82+
for match in PATTERN.finditer(line):
83+
term = match.group(0).lower()
84+
if (rel, term) in ALLOW:
85+
continue
86+
found.append(f"{rel}:{lineno}: {term!r} in {line.strip()[:80]!r}")
87+
return found
88+
89+
90+
def test_the_published_source_names_no_inference_vocabulary() -> None:
91+
offenders = _offenders()
92+
assert offenders == [], (
93+
"A library must not name its consumers' internals. These "
94+
"references do:\n "
95+
+ "\n ".join(offenders)
96+
+ "\n\nRephrase to describe the ROLE ('the consumer's driver', 'the "
97+
"chooser') rather than the implementation. If a reference is "
98+
"genuinely necessary, add it to ALLOW with a reason."
99+
)
100+
101+
102+
def test_the_guard_would_notice_a_leak() -> None:
103+
"""Guards the guard.
104+
105+
A word-boundary bug would make the test above pass on every input while
106+
appearing to work -- so assert against the specific string that motivated
107+
it, underscore and all.
108+
"""
109+
assert PATTERN.search("see llm_driver.py for details")
110+
assert PATTERN.search("calls the Anthropic API")
111+
assert PATTERN.search("OLLAMA_BASE_URL")
112+
# ...and does not fire on ordinary prose or an `ai` local.
113+
assert not PATTERN.search("ai = AttackInput(attacker=a, target=b)")
114+
assert not PATTERN.search("the domain contains available terrain")
115+
116+
117+
def test_the_scan_actually_reads_files() -> None:
118+
"""A `git ls-files` that returned nothing would make the guard vacuous."""
119+
tracked = _tracked_files()
120+
assert len(tracked) > 5, tracked
121+
assert any(f.endswith(".py") for f in tracked)

0 commit comments

Comments
 (0)