Skip to content

Commit 7ae5b27

Browse files
pdbethkeclaude
andcommitted
Merge campaign rule overrides
A GM changes what the HERO template states, per campaign, and the change reaches costing, display and combat with no edit anywhere else. Six planned tasks plus a fix wave for the final whole-branch review, which found seven paths where a rule was accepted and then silently did nothing -- the exact defect the feature exists to eliminate. Notably: the design's stated premise that TemplateData is reached through ONE provider method was false (there are four, and the loader preferred an unpatched one), a forced defense/target/range lost to a second value-guard, and a forced uses_end=False could never take effect. All now wired or refused at set() time with a reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xof4ZUS5n6A5ibX3PXNYbs
2 parents 3a8484f + d54ecaf commit 7ae5b27

13 files changed

Lines changed: 996 additions & 10 deletions

kirby_cost/campaign/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""A campaign's own rules: what this GM changed about the HERO template."""
2+
from kirby_cost.campaign.active import use_campaign_rules
3+
from kirby_cost.campaign.rules import CampaignRules, OVERRIDABLE_FIELDS
4+
5+
__all__ = ["CampaignRules", "OVERRIDABLE_FIELDS", "use_campaign_rules"]

kirby_cost/campaign/active.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Scoping the active campaign.
2+
3+
The engine is global by design -- `set_active_hero` is a process-wide
4+
singleton and loading a character mutates it -- so a campaign slot matches the
5+
surrounding shape. What the bare setter does not give you is restoration, and
6+
without it a caller that sets rules and forgets leaks them into everything
7+
that follows in the process.
8+
9+
CONCURRENCY: process-global, exactly as `active_hero` already is. That is a
10+
pre-existing property of this engine, not one this feature introduces, and it
11+
is not solved here. Anyone serving two campaigns from one process needs to
12+
read this note first.
13+
"""
14+
from __future__ import annotations
15+
16+
from contextlib import contextmanager
17+
from typing import Optional
18+
19+
from kirby_cost.core.context import EngineContext
20+
21+
22+
@contextmanager
23+
def use_campaign_rules(rules: Optional["CampaignRules"]):
24+
"""Run a block with *rules* active, restoring the previous value after.
25+
26+
Named `use_` rather than plainly `campaign_rules` because the latter
27+
collides with `EngineContext.campaign_rules()`, the GETTER -- and a module
28+
that needs both (tests/test_campaign_active.py does) ends up importing two
29+
different things under one name.
30+
"""
31+
previous = EngineContext.campaign_rules()
32+
EngineContext.set_campaign_rules(rules)
33+
try:
34+
yield rules
35+
finally:
36+
EngineContext.set_campaign_rules(previous)

kirby_cost/campaign/rules.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""A campaign's rule overrides, as a diff over the template.
2+
3+
kirby-cost is the only thing in the platform that reads a `.hdt`, which makes
4+
it the only place a house rule can enter the model. This holds what a campaign
5+
changed -- four values wide for a campaign that changes four rules -- so that
6+
"what does this campaign change?" stays a question something can answer. A
7+
copy of Main6E cannot answer it.
8+
"""
9+
from __future__ import annotations
10+
11+
import dataclasses
12+
import difflib
13+
from typing import Any, Iterable, Optional
14+
15+
from kirby_cost.template.dataclasses import TemplateData
16+
17+
#: Bookkeeping the provider writes, never a rule a GM sets. Excluded so it
18+
#: cannot be set as an override and then silently clobbered.
19+
_NOT_OVERRIDABLE = frozenset({"campaign_forced"})
20+
21+
#: Fields a campaign may NOT set, and why. Derivation stays the rule so a new
22+
#: template fact is overridable automatically; this names the exceptions.
23+
#:
24+
#: Accepting a field nothing reads would mean a GM writes a rule, gets no
25+
#: error, and the rule silently does nothing -- the exact defect this feature
26+
#: exists to remove.
27+
#:
28+
#: HOW THIS SET WAS ESTABLISHED, field by field, so a later reader knows what
29+
#: is measurement and what is inference. Every `TemplateData` field except
30+
#: `campaign_forced` was accounted for. Each entry listed below was measured
31+
#: INERT -- forced, and the loaded object did not change. Of the rest:
32+
#:
33+
#: * killing, does_body, does_damage, does_knockback, defense, target,
34+
#: range, uses_end: wired by `apply_template` and each one locked by a
35+
#: test in tests/test_campaign_beats_hdc.py that asserts the forced value
36+
#: reaches the object and outranks a document-stated one.
37+
#: * level_cost: measured end to end (tests/test_campaign_cost_fields.py:
38+
#: Ravel's RKA moves 45 -> 30). display, level_value, level_power,
39+
#: level_multiplier, min_set, max_set: read directly by `apply_template`
40+
#: (base.py:398, :487-:509, :545, :551) and by `Adder.apply_template`.
41+
#: * The nine below were the ones the first pass never probed. Each was
42+
#: then forced through `CampaignRules` and observed changing a loaded
43+
#: object, on 2026-08-25 (six via real character loads; three via
44+
#: constructed objects, noted below):
45+
#: * types -> forcing ("PROBE",) on RKA gave Ravel's RKA `_types
46+
#: == ["PROBE"]` (base.py, the `tmpl.types` loop).
47+
#: * attributes -> forcing SHOWOPTIONONLY="Yes" on PENETRATING flipped
48+
#: that modifier's `show_option_only` to True (base.py:291).
49+
#: * adders -> forcing {} emptied `_template_adder_order` on RKA.
50+
#: * options -> forcing LARGE's level_cost=77/level_multiplier=9 moved
51+
#: Bokor's GROWTH to exactly those (base.py:428).
52+
#: * option_aliases -> with LARGE removed from `options`, forcing
53+
#: {"LARGE": "HUGE"} resolved GROWTH's option to HUGE.
54+
#: * base_value -> forcing 42.0 on STR moved Ravel's STR `base_level`
55+
#: from 10.0 to 42.0 (hdc_loader.py:655).
56+
#: * all_cost / group_cost / sense_cost -> forcing 55.0 on a sense-rate
57+
#: xmlid put 55.0 on all three of a constructed SenseAdder
58+
#: (base.py, the sense-rate loop; hdc_loader.py:1615).
59+
#: (These three were probed via apply_template on a built
60+
#: object, not a real character load, because none of the
61+
#: authored characters carries a SenseAdder.)
62+
#:
63+
#: The one field that is neither wired nor listed as a rule is `class_name`,
64+
#: below: it has ZERO reads off a `TemplateData` anywhere in `kirby_cost/`.
65+
_UNSUPPORTED_FIELDS = {
66+
# apply_template never assigns tmpl.base_cost to a non-maneuver object
67+
# (its only base_cost write is from an OPTION, base.py:440), and never
68+
# reads _base_cost_from_xml. Making the template's price authoritative for
69+
# every object changes how the engine costs everything and needs its own
70+
# oracle-gated change. Use level_cost to re-price a power.
71+
"base_cost": "not applied by apply_template; use level_cost to re-price",
72+
# apply_template DOES assign these (base.py:546, :552) -- but gated on
73+
# tmpl.min_set / tmpl.max_set, which are false for a typical power. And
74+
# forcing min_set/max_set on would surface the TEMPLATE's own
75+
# minimum_cost/max_cost, never a campaign-forced one, because this field
76+
# is itself blocked. So the value a GM sets here can never reach an
77+
# object by either route.
78+
"minimum_cost": "gated on min_set, and forcing min_set surfaces the template's value, not this one",
79+
"max_cost": "gated on max_set, and forcing max_set surfaces the template's value, not this one",
80+
# Gated on the object not already having a duration, which it does by the
81+
# time a campaign could matter.
82+
"duration": "the object already holds a duration when the template applies",
83+
# Not rules. Identity and a derived predicate.
84+
"xmlid": "identity, not a rule",
85+
"is_power": "derived from the object, not settable",
86+
# Parsed by hdt_provider (:323) and dataclasses (:196) and then read by
87+
# NOTHING: `grep -rn class_name kirby_cost/` finds only unrelated local
88+
# variables in modifier.py and behaviors/registry.py, never a read off a
89+
# TemplateData. Forcing it can therefore not change any loaded object.
90+
"class_name": "carried by TemplateData but read by nothing, so forcing it cannot change a loaded object",
91+
}
92+
93+
#: Every field of TemplateData, DERIVED rather than listed. Adding a template
94+
#: fact makes it overridable automatically. A hand-maintained list here would
95+
#: rebuild the exact trap this work came from -- a fact parsed into one
96+
#: structure and dropped because a second structure had no field for it.
97+
OVERRIDABLE_FIELDS = frozenset(
98+
f.name for f in dataclasses.fields(TemplateData)
99+
) - _NOT_OVERRIDABLE - frozenset(_UNSUPPORTED_FIELDS)
100+
101+
102+
class CampaignRules:
103+
"""What one campaign changes about the template.
104+
105+
Validates at `set()` time against a real template, so a typo raises at the
106+
line that wrote it rather than silently matching nothing all session.
107+
"""
108+
109+
def __init__(self, provider: Optional[Any] = None) -> None:
110+
if provider is None:
111+
from kirby_cost.template.hdt_provider import HDTTemplateProvider
112+
# Raises FileNotFoundError with the provider's own message when no
113+
# template is configured -- authoring rules that cannot be checked
114+
# is the failure this class exists to prevent.
115+
provider = HDTTemplateProvider()
116+
self._provider = provider
117+
self._overrides: dict[tuple[str, str], Any] = {}
118+
119+
def set(self, xmlid: str, field: str, value: Any) -> None:
120+
# Checked before the OVERRIDABLE_FIELDS membership test below, and
121+
# given its own message: an unsupported field IS a template field
122+
# (it's a name a GM typed correctly), so the near-miss/typo message
123+
# for an unknown field would be actively misleading here.
124+
if field in _UNSUPPORTED_FIELDS:
125+
raise ValueError(
126+
f"{field!r} is a template field, but apply_template does not "
127+
f"wire it into a loaded object, so a campaign rule on it "
128+
f"would never take effect: {_UNSUPPORTED_FIELDS[field]}."
129+
)
130+
if field not in OVERRIDABLE_FIELDS:
131+
near = difflib.get_close_matches(field, sorted(OVERRIDABLE_FIELDS), n=3)
132+
hint = f" Did you mean: {', '.join(near)}?" if near else ""
133+
raise ValueError(
134+
f"{field!r} is not a template field, so nothing would ever "
135+
f"read it.{hint}"
136+
)
137+
# A rule that sets None cannot do anything. `apply_template` skips a
138+
# tri-state field whose template value is None (`if stated_value is
139+
# None ... continue`), which is how "the template says nothing" is
140+
# spelled -- so None means ABSENCE, not "revert to the class
141+
# default". That second meaning is not a capability this engine has,
142+
# and accepting None would only add another accepted-and-inert path.
143+
if value is None:
144+
raise ValueError(
145+
f"a campaign rule cannot set {field!r} to None: None means "
146+
f"'the template says nothing', so the rule would be skipped "
147+
f"and do nothing. Set the value you want instead. (Reverting "
148+
f"a field to its class default is not a capability that "
149+
f"exists today.)"
150+
)
151+
# Template maneuvers carry no XMLID — DISPLAY is their sole identity.
152+
# So `hdc_loader` routes them to `get_maneuver` (keyed by display), not
153+
# to `get_template_data`. If allowed here, this rule would find nothing
154+
# on the template side and do nothing. Moreover, patching here by xmlid
155+
# would rewrite all 53 maneuvers at once (they all parse with the same
156+
# XMLID="MANEUVER" in HDC). Refused here instead, before the check.
157+
if xmlid.upper() == "MANEUVER":
158+
raise ValueError(
159+
"'MANEUVER' cannot be the subject of a campaign rule: template "
160+
"maneuvers carry no XMLID (DISPLAY is their identity), so this "
161+
"rule would find nothing on the template side. Moreover, all 53 "
162+
"HDC maneuvers are written XMLID=\"MANEUVER\", so patching by "
163+
"xmlid would rewrite all 53 at once. Overriding one maneuver "
164+
"needs a rule keyed by display, which does not exist yet."
165+
)
166+
if self._provider.get_template_data(xmlid) is None:
167+
raise ValueError(
168+
f"{xmlid!r} is not in the loaded template, so this rule would "
169+
f"never match anything. (An xmlid that EXISTS but that no "
170+
f"character owns is fine -- this is about a name the template "
171+
f"has never heard of.)"
172+
)
173+
self._overrides[(xmlid, field)] = value
174+
175+
def get(self, xmlid: str, field: str, default: Any = None) -> Any:
176+
return self._overrides.get((xmlid, field), default)
177+
178+
def items(self) -> Iterable[tuple[str, str, Any]]:
179+
return [(x, f, v) for (x, f), v in self._overrides.items()]
180+
181+
def fields_for(self, xmlid: str) -> frozenset:
182+
"""Field names this campaign forces for *xmlid*."""
183+
return frozenset(f for (x, f) in self._overrides if x == xmlid)
184+
185+
def __bool__(self) -> bool:
186+
return bool(self._overrides)
187+
188+
def __repr__(self) -> str: # pragma: no cover - debugging aid
189+
return f"CampaignRules({len(self._overrides)} overrides)"

kirby_cost/core/context.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from kirby_cost.model.hero import Hero
1212
from kirby_cost.core.preferences import Preferences
1313
from kirby_cost.core.template import Template
14+
from kirby_cost.campaign.rules import CampaignRules
1415

1516

1617
class EngineContext:
@@ -24,6 +25,7 @@ class EngineContext:
2425
_active_hero: Optional['Hero'] = None
2526
_active_template: Optional['Template'] = None
2627
_preferences: Optional['Preferences'] = None
28+
_campaign_rules: Optional['CampaignRules'] = None
2729

2830
def __init__(self):
2931
"""Initialize EngineContext instance."""
@@ -60,7 +62,28 @@ def set_active_template(cls, template: Optional['Template']) -> None:
6062
"""Set the currently active template."""
6163
instance = cls.get_instance()
6264
instance._active_template = template
63-
65+
66+
@classmethod
67+
def campaign_rules(cls) -> Optional['CampaignRules']:
68+
"""The active campaign's rule overrides, or None.
69+
70+
DELIBERATELY separate from `active_template`. That slot has EIGHT
71+
readers whose branches were never finished -- they only ever ran their
72+
None side -- so populating it would switch eight stubs on at once
73+
(counted 2026-08-25: continuous, nonpersistent, norangemodifier,
74+
persistent, disadvantage, combat_sense, simulate_death,
75+
universal_translator; base.py:1814 mentions the slot in prose and does
76+
not read it). See the campaign-rule-overrides spec, section 7.
77+
"""
78+
return cls.get_instance()._campaign_rules
79+
80+
@classmethod
81+
def set_campaign_rules(cls, rules: Optional['CampaignRules']) -> None:
82+
"""Set the active campaign's rule overrides. Prefer the
83+
`kirby_cost.campaign.use_campaign_rules` context manager, which restores
84+
the previous value."""
85+
cls.get_instance()._campaign_rules = rules
86+
6487
@classmethod
6588
def prefs(cls) -> 'Preferences':
6689
"""Get preferences."""

kirby_cost/objects/base.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@
3232
from kirby_cost.objects.modifier import Modifier
3333
from kirby_cost.objects.list import List as HeroList
3434

35+
#: Fields whose XML attribute name is NOT the de-underscored upper-case form.
36+
#: `uses_end` is gated on "END", not "USESEND" -- derive it and a campaign's
37+
#: forced override silently misses the guard, leaving the document's own
38+
#: value to win with no error. Add to this map, don't trust the derivation,
39+
#: whenever a new field's XML name diverges from its Python name.
40+
_XML_NAME_OVERRIDES = {"uses_end": "END"}
41+
3542

3643
class GenericObject(CostMixin, ModifierMixin, XMLAttrsMixin,
3744
DeserializationMixin, SerializationMixin, ABC):
@@ -573,6 +580,33 @@ def apply_template(self, tmpl: "TemplateData", option_id: str = None) -> None:
573580
# subclass reads, and suppressing the template for an attribute that
574581
# was never loaded would leave the field at its constructor default.
575582
stated = self._stated_and_declared()
583+
# A campaign's rules outrank the document. Everything below is guarded
584+
# on "the source did not state this", which is right for a template
585+
# default and wrong for a house rule: a rule a character can be exempt
586+
# from is not a rule, so an attribute the campaign forced is removed
587+
# from the guard, per attribute -- forcing one does not free the
588+
# others. This does NOT cover the cost fields (base_cost,
589+
# minimum_cost, max_cost) even though the corpus states BASECOST 371
590+
# times: they are not gated by `stated` at all -- they are set through
591+
# min_set/max_set and _base_cost_from_xml. That was measured, and the
592+
# decision was to REFUSE those fields at `CampaignRules.set()` time
593+
# rather than build a second branch here; see `_UNSUPPORTED_FIELDS` in
594+
# kirby_cost/campaign/rules.py, which names each one and why. A GM who
595+
# wants to re-price a power uses level_cost, which IS wired.
596+
#
597+
# The XML name is usually the field name upper-cased with underscores
598+
# removed -- HD's own convention, used verbatim by the *_increase
599+
# family further down this method. It is NOT always that: `uses_end`
600+
# is gated on "END", not the derived "USESEND", so deriving it here
601+
# would subtract a name that was never in `stated` and the campaign's
602+
# rule would silently lose. Irregular names go in the override map.
603+
forced = getattr(tmpl, "campaign_forced", frozenset())
604+
forced_xml = {
605+
_XML_NAME_OVERRIDES.get(f, f.upper().replace("_", ""))
606+
for f in forced
607+
}
608+
if forced_xml:
609+
stated = stated - forced_xml
576610

577611
# TYPES come from the TEMPLATE. An object's own element rarely states
578612
# them -- Main6E declares `<DETECT ...><TYPE>SPECIAL</TYPE>
@@ -585,16 +619,33 @@ def apply_template(self, tmpl: "TemplateData", option_id: str = None) -> None:
585619
if declared and declared not in self._types:
586620
self._types.append(declared)
587621

588-
if tmpl.uses_end and "END" not in stated:
622+
# Each of these four carries a SECOND guard beyond `stated` -- on the
623+
# object's CURRENT value ("only if it has no duration", "only if
624+
# target is empty or N/A"), or, for uses_end, by only ever assigning
625+
# True. Those guards stand in for the same precedence `stated` does,
626+
# so removing a name from `stated` alone left the campaign losing
627+
# anyway: measured, forced defense=NORMAL over a document that stated
628+
# MENTAL came back MENTAL, forced target=SELFONLY over DCV came back
629+
# DCV, forced range=LOS over "No" came back "No", and a forced
630+
# uses_end=False could never be assigned at all. A forced field is
631+
# therefore assigned UNCONDITIONALLY; the heuristic guards remain for
632+
# the ordinary, uncampaigned path.
633+
if "END" in forced_xml:
634+
self.uses_end = tmpl.uses_end
635+
elif tmpl.uses_end and "END" not in stated:
589636
self.uses_end = True
590637
if tmpl.duration and not self._duration and "DURATION" not in stated:
591638
self._duration = tmpl.duration
592-
if tmpl.target and self.target in ("", "N/A") and "TARGET" not in stated:
639+
if "TARGET" in forced_xml:
640+
self.target = tmpl.target
641+
elif tmpl.target and self.target in ("", "N/A") and "TARGET" not in stated:
593642
self.target = tmpl.target
594643
# DEFENSE, on the same terms as TARGET: the template states it, the
595644
# document rarely does, and the constructor's default is "NONE" --
596645
# which reads as an answer rather than as an absence.
597-
if tmpl.defense and self.defense in ("", "NONE") and "DEFENSE" not in stated:
646+
if "DEFENSE" in forced_xml:
647+
self.defense = tmpl.defense
648+
elif tmpl.defense and self.defense in ("", "NONE") and "DEFENSE" not in stated:
598649
self.defense = tmpl.defense
599650
# Combat facts stated by the template. These are what let a GM's house
600651
# rules reach the fight: kirby-cost reads the .hdt, so a campaign that
@@ -622,7 +673,9 @@ def apply_template(self, tmpl: "TemplateData", option_id: str = None) -> None:
622673
# RANGE is the word that decides whether a power reaches at all, and
623674
# only the template states it — an HDC file never does. Without it
624675
# every power read as un-ranged and range_value returned 0.
625-
if getattr(tmpl, "range", "") and not (self.range or "").strip() \
676+
if "RANGE" in forced_xml:
677+
self.range = tmpl.range
678+
elif getattr(tmpl, "range", "") and not (self.range or "").strip() \
626679
and "RANGE" not in stated:
627680
self.range = tmpl.range
628681

0 commit comments

Comments
 (0)