Skip to content

Commit 1a09ffc

Browse files
pdbethkeclaude
andcommitted
Merge: the activation wheel — base vs temporal characteristics
Combat reads what the character sheet totals, not the bare CHARACTERISTICS section. A purchase HD keeps out of the totals (AFFECTS_TOTAL="No") contributes nothing; a purchase it counts does. JSON becomes a transport encoding of the HDC element tree rather than a curated subset, and three rules that had copies collapse to one home each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014fMnf6DgjcMQntuhoXvCTw
2 parents 95621cc + 284b740 commit 1a09ffc

18 files changed

Lines changed: 1643 additions & 169 deletions

CHANGELOG.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Changelog
2+
3+
## 0.6.1 — 2026-09-02
4+
5+
### Fixed
6+
- **The build doc carries `AFFECTS_PRIMARY` / `AFFECTS_TOTAL`.** 0.6.0 made
7+
those flags decide whether a purchase contributes to a temporal
8+
characteristic, but the (legacy, curated) build doc did not emit them — so a
9+
consumer that stores the doc relationally and rebuilds from it took the class
10+
default of True and counted purchases HD keeps out of the character's totals.
11+
Measured downstream: `Cobra.hdc` carries a +2 DCV power marked
12+
`AFFECTS_TOTAL="No"`, and the database rebuilt him at DCV 12 where the
13+
canonical load says 10. Emitted only when False, since True is the default on
14+
both, so a character with nothing situational produces the same doc as
15+
before. Read from the raw attributes rather than the `affect_*` properties —
16+
`affect_total`'s getter writes to `affects_total`, and serialising a
17+
character is not allowed to change it.
18+
19+
This is the flag-carrying half of 0.6.0's behaviour change. Consumers
20+
persisting the doc need columns for both, or they inherit the old answer.
21+
22+
## 0.6.0 — 2026-09-02
23+
24+
The theme is **one source of truth for what a build says**. Three places had
25+
copies of a rule the engine already owned, and each copy was quietly wrong.
26+
27+
### Added
28+
- **A front door and a back door.** `load_build(source, format=…)` and
29+
`hero.export(format=…)`, with formats registered (`@import_format` /
30+
`@export_format`) so adding one is additive rather than an edit to a
31+
dispatcher. Formats: `hdc` (bytes) and `json` (dict).
32+
- **`kirby_cost.model.modifiers`**`has_modifier` / `modifier_levels` /
33+
`find_modifier`, the one walk answering "does this modifier bind this
34+
purchase", with recursion through containers and inheritance from an
35+
enclosing purchase (minus PRIVATE modifiers).
36+
- **`LoadedHero.characteristic_states(xmlids)`** — the whole stat block from
37+
one walk of the purchases. `characteristic_state(xmlid)` is that call with a
38+
single xmlid.
39+
- **`tests/test_build_doc_fidelity.py`** — the JSON chain held to the same
40+
attribute-level property as the `.hdc` chain, sharing one survey
41+
(`tests/export_survey.py`) and carrying its own shrink-only ledger.
42+
43+
### Changed — this moves numbers
44+
- **A purchase HD keeps out of the character's totals no longer contributes to
45+
a temporal characteristic.** `AFFECTS_PRIMARY` / `AFFECTS_TOTAL` is HD's own
46+
record of whether a purchase raises the characteristic or merely sits on the
47+
sheet as a situational ability, and it was not being read. Gorgon's "Tail"
48+
+20 STR is `AFFECTS_TOTAL="No"` with its limitation aliased *"Only With
49+
Tail"* — a restrainable limb — and it was making him STR 80 instead of 60 in
50+
every calculation, thrown-object damage included. Ravel's pooled "Reinforced
51+
String" +30 STR is the same shape.
52+
- **JSON is now a transport encoding of the HDC element tree, not a curated
53+
subset.** The build doc was hand-written in both directions while the XML
54+
side wrote from declared descriptors, so five fields had been lost one at a
55+
time (TEXT, NOTES, a power's NAME, a modifier's ALIAS, AFFECTS_PRIMARY /
56+
AFFECTS_TOTAL) — all cost-neutral, and the doc's only gate compared summed
57+
cost. Measured over 794 corpus characters, `.hdc → hero → json → hero →
58+
.hdc` went from **0/794 clean to 791/794**, the same score the `.hdc` chain
59+
gets, with the same two `matches_hd` entries.
60+
- **HD's element IDs survive the JSON trip.** They were replaced by synthetic
61+
`O<n>`, so a character came back as a document HD would not recognise, every
62+
`PARENTID` target renamed. `extract_costs` keys `per_object` by the same rule,
63+
or those keys join to nothing.
64+
65+
### Fixed
66+
- `BuildNode` had no `.attrib`, so a campaign's whole `RULES` block took the
67+
character down on the JSON path while the `.hdc` path loaded it fine.
68+
- `hero_to_element` re-appends preserved elements with `deepcopy` and lxml
69+
rejects a `BuildNode`: 15 corpus characters carrying an embedded template
70+
failed to write at all.
71+
- Statedness (which attributes the source stated, in its order) is carried
72+
through the JSON encoding. Without it every explicitly-stated empty value
73+
(`NAME=""`) was dropped and a dozen defaults per element invented.
74+
- `source_encoding` rides in the JSON envelope; two characters came back
75+
XML-identical and byte-different without it.
76+
- `ChangeEnvironment.can_add` removed. It called a `super().can_add` that
77+
exists nowhere, so any call raised `AttributeError`, while its docstring
78+
advertised logic its body (`return True`) did not contain. Nothing called it.
79+
80+
### Unchanged
81+
- **Oracle parity: 656/656, residual ledger empty.** None of this touches the
82+
cost path — `characteristic_value` is untouched and remains what costs derive
83+
from. Suite 1798 passing.

kirby_cost/engine/serialize.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ def to_build_dict(self) -> dict:
160160
d["ultra_slot"] = False
161161
if getattr(self, "add_modifiers_to_base", False):
162162
d["add_modifiers_to_base"] = True
163+
# HD's own record of whether a purchase raises the character's
164+
# characteristic or merely sits on the sheet as a situational ability.
165+
# It decides whether the purchase contributes to a TEMPORAL
166+
# characteristic (kirby_cost.model.activation), so a consumer that
167+
# rebuilds from this doc and does not carry these two flags gets a
168+
# DIFFERENT CHARACTER: measured on kirby-api, a +2 DCV power marked
169+
# AFFECTS_TOTAL="No" was counted, and Cobra came back DCV 12 where the
170+
# canonical load says 10.
171+
#
172+
# Emitted only when False, because True is the class default on both —
173+
# so a doc for a character with nothing situational is unchanged.
174+
# Read from the raw attributes, NOT the affect_* properties:
175+
# `affect_total`'s getter WRITES to affects_total when affects_primary
176+
# is set, and serialising a character is not allowed to change it.
177+
if getattr(self, "affects_primary", True) is False:
178+
d["affects_primary"] = False
179+
if getattr(self, "affects_total", True) is False:
180+
d["affects_total"] = False
163181
self._emit_build_children(d)
164182
return d
165183

kirby_cost/io/build_cost.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class CostResult:
2525
points_spent: float
2626
per_object: dict[str, dict[str, float]] = field(default_factory=dict)
2727

28+
from kirby_cost.io.build_json import _doc_id # noqa: E402
29+
30+
2831
def extract_costs(hero: LoadedHero) -> CostResult:
2932
"""Read the loaded build's live cost properties into a CostResult.
3033
@@ -42,7 +45,12 @@ def extract_costs(hero: LoadedHero) -> CostResult:
4245
idx += 1
4346
rc = float(getattr(o, "real_cost", 0) or 0)
4447
exact += rc
45-
per_object[f"O{idx}"] = {
48+
# Keyed by the SAME rule the document uses (build_json._doc_id):
49+
# HD's own ID where the object has one, synthetic O<n> only where
50+
# it does not. These keys exist to join back to the doc, so a
51+
# second numbering scheme here means they join to nothing — which
52+
# is what happened the moment the doc started stating real ids.
53+
per_object[_doc_id(o, idx)] = {
4654
"base_cost": float(getattr(o, "base_cost", 0) or 0),
4755
"active_cost": float(getattr(o, "active_cost", 0) or 0),
4856
"real_cost": rc,

kirby_cost/io/build_json.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class BuildDocError(ValueError):
3434
"min_cost": "MINCOST", "max_cost": "MAXCOST",
3535
"characteristic": "CHARACTERISTIC"}
3636
_BOOL = {"ultra_slot": "ULTRA_SLOT", "add_modifiers_to_base": "ADD_MODIFIERS_TO_BASE",
37+
# See SerializationMixin.to_build_dict: these decide whether a
38+
# purchase contributes to a temporal characteristic, so a rebuild
39+
# that drops them is a different character.
40+
"affects_primary": "AFFECTS_PRIMARY", "affects_total": "AFFECTS_TOTAL",
3741
# "is_power"→ISPOWER is accepted on input but NOT emitted by to_build_json
3842
# (is_power is derived from the element tag at load time, not stored as an
3943
# attribute). Does not survive a round-trip; keep for hand-authored docs.
@@ -210,19 +214,38 @@ def build_from_json(doc: dict[str, Any]) -> LoadedHero:
210214
return HDCLoader()._build_hero_from_root(_root_from_doc(doc))
211215

212216

217+
def _doc_id(o, idx: int) -> str:
218+
"""The id this object is known by in the document.
219+
220+
HD's own ID when the object has one, and only otherwise a synthetic
221+
``O<n>``. It used to be synthetic ALWAYS, which quietly re-identified every
222+
element in every character: HD writes ``ID="1288023596014"``, the doc said
223+
``"O1"``, and the rebuild wrote ``ID="204"``. So a character sent through
224+
the build doc came back as a document HD would not recognise as the same
225+
one — every PARENTID target renamed, and any external reference to an
226+
element broken. Nothing in the cost total moves, which is why the doc's
227+
only gate never saw it.
228+
229+
The synthetic fallback stays for hand-authored docs, which have no ids to
230+
keep.
231+
"""
232+
own = getattr(o, "id", None)
233+
return str(own) if own else f"O{idx}"
234+
235+
213236
def _obj_to_dict(o, idx: int, parent_id: str | None) -> dict[str, Any]:
214237
"""Document placement around the object's own export.
215238
216239
Everything about WHAT an object is now comes from the object
217240
(``SerializationMixin.to_build_dict``, overridden by the classes that have
218-
more to say). What is left here is where it sits in the document: the
219-
synthetic id and the parent link, which no object can know about itself.
241+
more to say). What is left here is where it sits in the document: the id
242+
and the parent link, which no object can know about itself.
220243
221244
This function used to be 120 lines that branched on isinstance for
222245
ForceWall, Sense, Skill and Maneuver — so adding a subclass with anything
223246
extra to export meant editing this module, and forgetting to was silent.
224247
"""
225-
d: dict[str, Any] = {"id": f"O{idx}"}
248+
d: dict[str, Any] = {"id": _doc_id(o, idx)}
226249
d.update(o.to_build_dict())
227250
if parent_id:
228251
d["parent"] = parent_id
@@ -245,7 +268,9 @@ def to_build_json(hero: LoadedHero) -> dict[str, Any]:
245268
attr = {"disadvantages": "complications"}.get(key, key)
246269
for o in getattr(hero, attr, []):
247270
counter += 1
248-
ids[id(o)] = f"O{counter}"
271+
# Same rule as the emitter, or a slot would name its pool by an id
272+
# the pool does not answer to.
273+
ids[id(o)] = _doc_id(o, counter)
249274
out_counter = 0
250275
for key in _SECTION_TAG:
251276
attr = {"disadvantages": "complications"}.get(key, key)

kirby_cost/io/formats.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""The front door and the back door — one build, several shapes.
2+
3+
hero.export(format="hdc") -> bytes
4+
hero.export(format="json") -> dict
5+
load_build(source, format="json") -> LoadedHero
6+
7+
**JSON here is a transport encoding of the HDC element tree, not a second
8+
serializer.** That distinction is the whole design, and it is a repair.
9+
10+
The build doc used to be a hand-written subset in both directions: a list of
11+
``if getattr(...)`` lines on the way out, and three lookup tables (``_ATTR``,
12+
``_BOOL``, ``_TYPED_ATTR``) on the way in. Meanwhile the XML side wrote from
13+
DECLARED descriptors (``XML_ATTRS`` / ``xml_schema()``), so an attribute added
14+
to a class appeared in the .hdc automatically and in the doc only if somebody
15+
remembered. Nobody remembered five times running: TEXT, NOTES, a power's NAME,
16+
a modifier's ALIAS (HD's "Only With Tail" — the descriptor that makes a
17+
limitation a limitation), and AFFECTS_PRIMARY / AFFECTS_TOTAL. Every one of
18+
them is cost-neutral, and the doc's only gate compared summed cost, so the
19+
losses were invisible until each was found by hand, downstream, in the
20+
database.
21+
22+
Measured before this module existed: **0 of 794 corpus characters** survived
23+
``.hdc -> hero -> doc -> hero -> .hdc`` intact — 609 element kinds dropped
24+
(59,099 NOTES elements, 3,055 FOCUS modifiers, whole adder families) and 5,014
25+
attribute keys churned.
26+
27+
So JSON does not get its own opinion about which fields exist. It encodes the
28+
element tree the .hdc writer already produces — ``{tag, attrs, children,
29+
text}`` — and decodes back to the same shape. Completeness is STRUCTURAL: there
30+
is no field list to drift, because there is no second field list. Anything the
31+
XML writer learns to say, JSON says the same day.
32+
"""
33+
from __future__ import annotations
34+
35+
from typing import Any, Callable
36+
37+
from kirby_cost.io.hdc_loader import BuildNode, HDCLoader, LoadedHero
38+
from kirby_cost.io.hdc_writer import hero_to_bytes, hero_to_element
39+
40+
41+
class UnknownFormat(ValueError):
42+
"""No door of that name. Names the ones there are, because a typo here
43+
would otherwise read as 'this build cannot be exported'."""
44+
45+
46+
_EXPORTERS: dict[str, Callable[[Any], Any]] = {}
47+
_IMPORTERS: dict[str, Callable[[Any], LoadedHero]] = {}
48+
49+
50+
def export_format(name: str):
51+
"""Register a back door. Additive: a new shape is a registration, never an
52+
edit to a dispatcher that has to be taught about it."""
53+
def register(fn):
54+
_EXPORTERS[name] = fn
55+
return fn
56+
return register
57+
58+
59+
def import_format(name: str):
60+
"""Register a front door, symmetric with its back door."""
61+
def register(fn):
62+
_IMPORTERS[name] = fn
63+
return fn
64+
return register
65+
66+
67+
def _known(registry: dict) -> str:
68+
return ", ".join(sorted(registry)) or "none registered"
69+
70+
71+
# ── the encoding ───────────────────────────────────────────────────────────
72+
73+
def element_to_json(element) -> dict[str, Any]:
74+
"""An element tree as plain JSON-able data.
75+
76+
Deliberately dumb: tag, attributes verbatim as the strings the document
77+
holds, children in document order, and text when there is any. No key is
78+
renamed and no value is coerced, so nothing here can decide a field is
79+
uninteresting — the decision about what an object states was already made
80+
once, by ``write_xml_attrs``, from the declared schema.
81+
"""
82+
node: dict[str, Any] = {"tag": element.tag, "attrs": dict(element.attrib)}
83+
children = [element_to_json(child) for child in element
84+
if isinstance(child.tag, str)]
85+
if children:
86+
node["children"] = children
87+
text = (element.text or "").strip()
88+
if text:
89+
node["text"] = text
90+
return node
91+
92+
93+
def json_to_element(node: Any) -> BuildNode:
94+
"""The inverse. Returns a ``BuildNode``, the loader's element-compatible
95+
adapter, so the decoded tree goes through the SAME construction core an
96+
.hdc does rather than a parallel one."""
97+
if not isinstance(node, dict) or "tag" not in node:
98+
raise ValueError(f"not an encoded element: {node!r}")
99+
attrs = {str(k): str(v) for k, v in (node.get("attrs") or {}).items()}
100+
return BuildNode(
101+
str(node["tag"]),
102+
attrs,
103+
[json_to_element(child) for child in (node.get("children") or [])],
104+
text=node.get("text"),
105+
# These attributes ARE what the document stated, in its order — that is
106+
# what makes this encoding faithful rather than a curated subset, and
107+
# the loader has to be told so or the rebuild writes back a different
108+
# set. See BuildNode.stated.
109+
stated=tuple(attrs),
110+
)
111+
112+
113+
# ── the doors ──────────────────────────────────────────────────────────────
114+
115+
@export_format("hdc")
116+
def _export_hdc(hero) -> bytes:
117+
return hero_to_bytes(hero)
118+
119+
120+
@export_format("json")
121+
def _export_json(hero) -> dict[str, Any]:
122+
"""The document, plus the document facts that live outside its tree.
123+
124+
``source_encoding`` is the one that bites: HD writes UTF-16 and some files
125+
are UTF-8, ``hero_to_bytes`` defaults to the encoding the character was
126+
READ from, and a hero rebuilt from JSON has not read anything. Without it
127+
two corpus characters came back XML-identical and byte-different — the
128+
same document in the wrong encoding, which is still not the file HD
129+
wrote.
130+
"""
131+
doc: dict[str, Any] = {"document": element_to_json(hero_to_element(hero))}
132+
encoding = getattr(hero, "source_encoding", "")
133+
if encoding:
134+
doc["encoding"] = encoding
135+
return doc
136+
137+
138+
@import_format("hdc")
139+
def _import_hdc(source) -> LoadedHero:
140+
return HDCLoader().load_file(str(source))
141+
142+
143+
@import_format("json")
144+
def _import_json(source) -> LoadedHero:
145+
"""Accepts the envelope, or a bare encoded document for hand-authored
146+
input — a document with no envelope simply states no encoding."""
147+
if isinstance(source, dict) and "document" in source:
148+
root, encoding = source["document"], source.get("encoding", "")
149+
else:
150+
root, encoding = source, ""
151+
hero = HDCLoader()._build_hero_from_root(json_to_element(root))
152+
if encoding:
153+
hero.source_encoding = encoding
154+
return hero
155+
156+
157+
def export_build(hero, *, format: str = "hdc"):
158+
"""The back door. ``LoadedHero.export`` is the method form of this."""
159+
try:
160+
exporter = _EXPORTERS[format]
161+
except KeyError:
162+
raise UnknownFormat(
163+
f"no exporter for {format!r}; have: {_known(_EXPORTERS)}") from None
164+
return exporter(hero)
165+
166+
167+
def load_build(source, *, format: str = "hdc") -> LoadedHero:
168+
"""The front door. ``source`` is a path for 'hdc', decoded data for 'json'."""
169+
try:
170+
importer = _IMPORTERS[format]
171+
except KeyError:
172+
raise UnknownFormat(
173+
f"no importer for {format!r}; have: {_known(_IMPORTERS)}") from None
174+
return importer(source)

0 commit comments

Comments
 (0)