diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f6dc0f2..b2a456a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -141,6 +141,7 @@ from pf1e_simulator.spells import ( SaveType, SpellEffect, SummonEffect, + close_range_ft, save_descriptors_for_effects, spell_range_ft, spell_save_descriptors, @@ -8884,7 +8885,6 @@ def _normal_actions( _GROUPED_ENEMY_COUNT = 2 -_HEX_RANGE_FT = 25 def _emit_channel_energy( @@ -8910,12 +8910,17 @@ def _emit_channel_energy( def _emit_hex( engine: CombatEngine, state: CombatantState, target: CombatantState | None ) -> bool: - """True when nairda should hex: resource available and target within close range.""" + """True when nairda should hex: resource available and target within close range. + + APG hexes carry a close range, which scales with the caster level + (SRD, Magic - Range: "Close - 25 ft. + 5 ft. per 2 caster levels"). + """ if state.resources_left.get("hexes", 0) <= 0: return False if target is None or not target.active: return False - return engine.creature_distance(state, target) * _SQUARE_FT <= _HEX_RANGE_FT + dist_ft = engine.creature_distance(state, target) * _SQUARE_FT + return dist_ft <= close_range_ft(state.combatant.level) def _spell_combat_actions( diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 1d1411d..d023f8a 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -368,13 +368,19 @@ class SpellSpec: description: str = "" +def close_range_ft(caster_level: int) -> int: + """PF1e (SRD, Magic — Range): "Close — 25 ft. + 5 ft. per 2 caster levels". + Shared by spell ranges and class features with a close range (witch hexes).""" + return 25 + 5 * (caster_level // 2) + + def spell_range_ft(spell: SpellSpec, caster_level: int) -> int: if spell.range == "personal": return 0 if spell.range == "touch": return 5 if spell.range == "close": - return 25 + 5 * (caster_level // 2) + return close_range_ft(caster_level) if spell.range == "medium": return 100 + 10 * caster_level return 400 + 40 * caster_level diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index b5deea8..5c15671 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -319,6 +319,22 @@ def test_nairda_emits_hex_when_available() -> None: assert any(a.kind == "hex" for a in actions) +def test_nairda_hex_close_range_scales_with_caster_level() -> None: + """SRD (Magic - Range): close = 25 ft + 5 ft per 2 caster levels -> 35 ft + at level 5; the fixed 25 ft cut off a 30 ft target. APG hexes carry a + close range, so a goblin at 30 ft (6 squares, 4 diagonals) gets hexed + and one at 40 ft (8 squares) does not.""" + engine, me, _ = _engine(_combatant("nairda", resources={"hexes": 5}), []) + near = _state(_combatant("goblin", hp_max=6), "monsters", (1, 1), hp=6) + engine.states.append(near) + assert any(a.kind == "hex" for a in default_policy(engine, me)) + + engine2, me2, _ = _engine(_combatant("nairda-b", resources={"hexes": 5}), []) + far = _state(_combatant("goblin-b", hp_max=6), "monsters", (11, 9), hp=6) + engine2.states.append(far) + assert not any(a.kind == "hex" for a in default_policy(engine2, me2)) + + def test_nairda_silent_when_hexes_exhausted() -> None: nairda = _combatant("nairda", resources={"hexes": 0}) goblin = _combatant("goblin", hp_max=6)