|
| 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