Skip to content

Commit df7e188

Browse files
committed
feat(points): the 6E character-points model, beside HD's 5E-style figure
available_points (5E: Complications add to the pool) stays exactly as it was -- oracle-verified against HD across all 655 fixtures, untouched. Adds three properties implementing the printed 6E rule instead (6E1 p.30, p.269): complications_shortfall (falling short of the Matching Complications target costs 1:1; exceeding it grants nothing), spendable_points (base_points, already inclusive of matching complications in 6E, minus any shortfall, plus experience), and points_unspent (spendable_points minus total_points -- deliberately unclamped, so an overspent build reads negative). Tested against the three authored characters (hand-built from their committed oracle JSON, KIRBY_COST_AUTHORED unset here) plus a live-loaded bestiary character that took no complications against a 50-point target. Four mutation-tested guards: the shortfall floor, the available_points parity guard, points_unspent's sign, and fraction survival (Power Lad's 0.5) -- each applied, confirmed, watched to fail, and restored. disads_used (int-truncating) is left as found: 2,862 complications checked across the oracle corpus + authored characters, zero fractional, so the truncation is not currently lossy. Not changed without evidence. 656/656 oracle fixtures pass, residual ledger still empty, full suite 1433 passed / 12 skipped (pre-existing, KIRBY_COST_AUTHORED unset).
1 parent 38053a9 commit df7e188

2 files changed

Lines changed: 227 additions & 1 deletion

File tree

kirby_cost/io/hdc_loader.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,9 +450,63 @@ def total_points(self) -> float:
450450

451451
@property
452452
def available_points(self) -> float:
453-
"""base + disads used + experience - spent."""
453+
"""base + disads used + experience - spent.
454+
455+
This is Hero Designer's own figure — 5th-Edition-style arithmetic in
456+
which Complications ADD to the point pool. It is kept, unchanged, as
457+
the oracle-verified answer: it reproduces HD exactly across all 655
458+
oracle fixture characters. It is NOT the printed 6E rule (see
459+
``complications_shortfall`` / ``spendable_points`` / ``points_unspent``
460+
below for that). Two different, individually-correct answers to "how
461+
many points are left" live side by side here on purpose — this one is
462+
"what HD prints," the other three are "what the 6E rulebook says."
463+
"""
454464
return self.base_points + self.disads_used + self.experience - self.total_points
455465

466+
@property
467+
def complications_shortfall(self) -> float:
468+
"""Points lost for under-taking the campaign's Matching Complications.
469+
470+
HERO System 6th Edition, Volume 1, p.30: "You can take fewer points'
471+
worth of Complications if you want, but every 1 Character Point by
472+
which you don't meet the Matching Complications amount reduces your
473+
character's Total Points by 1. (You can select more Complications
474+
than are required if you want them for your character, but they
475+
don't provide you with extra Character Points to spend.)"
476+
477+
``disad_points`` is the campaign's Matching Complications target
478+
(HDC ``DISAD_POINTS``); ``disads_used`` is what the character
479+
actually took. Falling short costs 1:1; exceeding the target costs
480+
(and grants) nothing, hence the ``max(0, ...)`` floor.
481+
"""
482+
return max(0.0, self.disad_points - self.disads_used)
483+
484+
@property
485+
def spendable_points(self) -> float:
486+
"""The 6E point pool: Total Points minus any Complications shortfall.
487+
488+
HERO System 6th Edition, Volume 1, p.269 describes a "Standard
489+
Superheroic character (400 Total Points, including 75 points' worth
490+
of Matching Complications)" — in 6E, ``base_points`` (HDC
491+
``BASE_POINTS``) already IS the campaign's Total Points figure,
492+
inclusive of the matching complications. Complications do not add to
493+
it (contrast ``available_points`` above, which is HD's older 5E-style
494+
reading where they do); they can only subtract, via
495+
``complications_shortfall``, when the character comes up short.
496+
"""
497+
return self.base_points - self.complications_shortfall + self.experience
498+
499+
@property
500+
def points_unspent(self) -> float:
501+
"""Points left in the 6E pool: ``spendable_points`` minus what was spent.
502+
503+
Companion to ``spendable_points`` (see 6E1 p.30, p.269 there for the
504+
rule). Deliberately NOT clamped at zero: a negative value means the
505+
character is built over its 6E pool — a real, visible condition (see
506+
the Bokor oracle fixture, which comes in one point over).
507+
"""
508+
return self.spendable_points - self.total_points
509+
456510

457511
def _hold_slot(framework: GenericObject, slot: GenericObject) -> None:
458512
"""Record *slot* on *framework* as well as linking it upward.

tests/test_6e_points_model.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""The HERO System 6th Edition character-points model.
2+
3+
HD's own ``LoadedHero.available_points`` (``hdc_loader.py``) is 5th-Edition
4+
arithmetic: Complications ADD to the pool. 6E changed this —
5+
6+
- 6E1 p.30: falling short of the campaign's Matching Complications target
7+
reduces Total Points 1:1; taking MORE than the target buys nothing extra.
8+
- 6E1 p.269: the campaign's Total Points figure (HDC ``BASE_POINTS``)
9+
ALREADY INCLUDES the matching complications.
10+
11+
``complications_shortfall`` / ``spendable_points`` / ``points_unspent``
12+
implement that printed rule, alongside (never replacing) HD's own
13+
``available_points``. This file proves both readings, on the same
14+
characters, disagree exactly the way the two rulebooks disagree.
15+
16+
Worked examples are the three authored characters (Ravel, Bokor, Power Lad —
17+
see ``tests/fixtures/authored/``) plus the bestiary's Elemental - Air, which
18+
took NO complications against a 50-point target. The authored .hdc files are
19+
machine-bound (``KIRBY_COST_AUTHORED``, see ``tests/test_authored_characters.py``)
20+
so those three are hand-built here from the committed oracle JSON dumps
21+
instead of loaded live; Elemental - Air's .hdc ships in this repo's own
22+
oracle corpus and is loaded for real.
23+
"""
24+
from __future__ import annotations
25+
26+
import json
27+
from pathlib import Path
28+
29+
import pytest
30+
31+
from kirby_cost.io.hdc_loader import HDCLoader, LoadedHero
32+
from tests.conftest import make_object
33+
34+
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "authored"
35+
ELEMENTAL_AIR_FIXTURE = (
36+
Path(__file__).parent / "fixtures" / "oracle"
37+
/ "bestiary__HERO_System_Bestiary_6th_Edition_Character_Pack__HSB HD Files"
38+
"__CHAPTER_1__CREATURE_TEMPLATES__ELEMENTAL_AIR-HSB.json"
39+
)
40+
41+
42+
def _hand_built(*, base_points, disad_points, experience, taken, spent) -> LoadedHero:
43+
"""A LoadedHero with just enough state to exercise the points model.
44+
45+
``total_points`` is a computed property (it walks characteristics/
46+
skills/perks/talents/martial_arts/powers and sums real_cost), so a single
47+
synthetic power whose base_cost equals *spent* reproduces it exactly.
48+
``disads_used`` likewise sums real_cost over ``complications``, so one
49+
synthetic complication whose base_cost equals *taken* reproduces it.
50+
"""
51+
hero = LoadedHero()
52+
hero.base_points = base_points
53+
hero.disad_points = disad_points
54+
hero.experience = experience
55+
if spent:
56+
hero.powers.append(make_object(base_cost=spent, xmlid="TEST_SPENT"))
57+
if taken:
58+
hero.complications.append(make_object(base_cost=taken, xmlid="TEST_TAKEN"))
59+
return hero
60+
61+
62+
# name -> (base_points, disad_points, experience, taken, spent, hd_left, sixe_left)
63+
WORKED_EXAMPLES = {
64+
"Ravel": (400, 100, 50, 100, 450.0, 100.0, 0.0),
65+
"PowerLad": (400, 120, 0, 120, 399.5, 120.5, 0.5),
66+
"Bokor": (270, 40, 5, 40, 276.0, 39.0, -1.0),
67+
}
68+
69+
70+
@pytest.mark.parametrize("name", sorted(WORKED_EXAMPLES))
71+
def test_worked_examples_hand_built(name):
72+
base, disad, exp, taken, spent, hd_left, sixe_left = WORKED_EXAMPLES[name]
73+
hero = _hand_built(
74+
base_points=base, disad_points=disad, experience=exp,
75+
taken=taken, spent=spent,
76+
)
77+
assert hero.total_points == spent
78+
assert hero.available_points == hd_left
79+
assert hero.points_unspent == sixe_left
80+
81+
82+
@pytest.mark.parametrize("name", sorted(WORKED_EXAMPLES))
83+
def test_worked_examples_match_committed_oracle_fixture(name):
84+
"""The hand-built heroes above are not invented numbers — they are the
85+
committed oracle JSON dumps for these three characters, restated."""
86+
fixture = FIXTURE_DIR / f"{name}.json"
87+
if not fixture.exists():
88+
pytest.skip(f"{fixture} not present")
89+
oracle = json.loads(fixture.read_text())
90+
base, disad, exp, taken, spent, hd_left, sixe_left = WORKED_EXAMPLES[name]
91+
assert oracle["total_points"] == spent
92+
assert oracle["available_points"] == hd_left
93+
comps = oracle.get("complications") or []
94+
assert sum(c.get("real_cost", 0) for c in comps) == taken
95+
96+
97+
def test_elemental_air_took_no_complications_and_loses_the_whole_target():
98+
"""Real corpus character, loaded live. Took 0 of a 50-point Matching
99+
Complications target: the whole 50 comes off spendable_points, on top
100+
of whatever total_points already spent — 6E1 p.30's "every 1 point
101+
short reduces Total Points by 1" taken to its floor."""
102+
if not ELEMENTAL_AIR_FIXTURE.exists():
103+
pytest.skip("oracle fixture not present")
104+
oracle = json.loads(ELEMENTAL_AIR_FIXTURE.read_text())
105+
hdc_path = oracle["hdc_path"]
106+
if not Path(hdc_path).exists():
107+
pytest.skip(f"HDC file missing: {hdc_path}")
108+
109+
hero = HDCLoader().load_file(hdc_path)
110+
111+
assert hero.total_points == 200.0
112+
assert hero.available_points == -25.0
113+
assert hero.disads_used == 0
114+
assert hero.base_points == 175
115+
assert hero.disad_points == 50
116+
assert hero.complications_shortfall == 50.0
117+
assert hero.spendable_points == 125.0 # 175 base - 50 shortfall, no exp
118+
assert hero.points_unspent == -75.0
119+
120+
121+
def test_shortfall_costs_one_for_one_below_the_target():
122+
hero = _hand_built(base_points=200, disad_points=50, experience=0,
123+
taken=20, spent=100)
124+
assert hero.complications_shortfall == 30.0
125+
assert hero.spendable_points == 200 - 30.0
126+
assert hero.points_unspent == (200 - 30.0) - 100
127+
128+
129+
def test_excess_complications_grant_nothing():
130+
"""Taking MORE than the target must not raise spendable_points above the
131+
at-target figure — 6E1 p.30's parenthetical, the half of the rule most
132+
likely to be implemented as if surplus complications paid out."""
133+
at_target = _hand_built(base_points=200, disad_points=50, experience=0,
134+
taken=50, spent=100)
135+
over_target = _hand_built(base_points=200, disad_points=50, experience=0,
136+
taken=90, spent=100)
137+
assert at_target.complications_shortfall == 0.0
138+
assert over_target.complications_shortfall == 0.0
139+
assert over_target.spendable_points == at_target.spendable_points
140+
assert over_target.spendable_points == 200.0
141+
142+
143+
def test_points_unspent_is_negative_when_overspent():
144+
"""Bokor: built to 276 against a 6E pool of 275 (270 - 0 shortfall + 5
145+
exp) — one point over. Must not be clamped to zero."""
146+
hero = _hand_built(base_points=270, disad_points=40, experience=5,
147+
taken=40, spent=276.0)
148+
assert hero.spendable_points == 275.0
149+
assert hero.points_unspent == -1.0
150+
assert hero.points_unspent < 0
151+
152+
153+
def test_available_points_parity_guard_untouched():
154+
"""available_points must still be exactly HD's formula, unaffected by
155+
the 6E properties added alongside it."""
156+
hero = _hand_built(base_points=270, disad_points=40, experience=5,
157+
taken=40, spent=276.0)
158+
assert hero.available_points == (
159+
hero.base_points + hero.disads_used + hero.experience - hero.total_points
160+
)
161+
assert hero.available_points == 39.0
162+
163+
164+
def test_powerlad_fraction_survives_uncoerced():
165+
"""Power Lad's 0.5 must not be narrowed to an int anywhere in the new
166+
properties (spendable_points, points_unspent)."""
167+
base, disad, exp, taken, spent, hd_left, sixe_left = WORKED_EXAMPLES["PowerLad"]
168+
hero = _hand_built(base_points=base, disad_points=disad, experience=exp,
169+
taken=taken, spent=spent)
170+
assert hero.total_points == 399.5
171+
assert hero.points_unspent == 0.5
172+
assert hero.points_unspent * 2 == 1.0 # would silently become 0 if narrowed to int

0 commit comments

Comments
 (0)