|
| 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)" |
0 commit comments