From 8774e51a128919abd67f7fc72c92f290a6d8b677 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 14:16:26 +0200 Subject: [PATCH] fix(rules): cover grants a Reflex-save bonus against area effects (SRD cover.md 'Cover and Reflex Saves': +2 vs standard cover - half the AC value, so +1 partial/+2 standard/+4 improved; soft cover grants none) - _area_save adds terrain cover//2 from the effect's point of origin (burst center, caster square for cone/line) to Reflex rolls; resolve_save gains an explicit flat_bonus --- src/pf1e_simulator/combat.py | 59 ++++++++++++++++++++-- tests/test_area_shapes.py | 95 +++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 585073c..407f3c0 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3385,6 +3385,7 @@ class CombatEngine: dc: int, *, save_descriptors: frozenset[str] = frozenset(), + flat_bonus: int = 0, ) -> SaveResult: """Roll a saving throw (fort/ref/will) against ``dc``. @@ -3393,13 +3394,16 @@ class CombatEngine: target) are applied via ``resolve_modifiers`` — same bonus-type stacking rules as attacks. ``save_descriptors`` (e.g. {"fear"}, {"poison"}) gates conditional modifiers like Bitter or Born Damned. + ``flat_bonus`` is an untyped situational bonus added to the total + (used for the cover bonus on Reflex saves, CRB "Cover and Reflex + Saves"); natural 1/20 still override the roll regardless of it. """ roll = self._rng.d20() base = getattr(state.combatant.saves, save_type) bonus = resolve_modifiers( self._stat_modifiers(state, save_type, save_descriptors=save_descriptors) ) - total = roll + base + bonus + total = roll + base + bonus + flat_bonus success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) return SaveResult(success=success, roll=roll, total=total, dc=dc) @@ -7083,13 +7087,21 @@ class CombatEngine: total_damage = 0 total_conditions = 0 total_buffs = 0 + # Reflex-save cover is measured from the effect's point of origin: + # the burst center, or the caster's square for a cone/line (CRB + # "Cover and Reflex Saves": the bonus applies "against attacks that + # originate or burst out from a point on the other side of the cover + # from you"). + origin_cells = ( + frozenset({center}) if effect.shape == "burst" else caster.occupied_cells() + ) for affected_state in affected: if spell_resistance and not self._check_spell_resistance(caster, affected_state): continue creature_saved = self._area_save( affected_state, target, effect, saved=saved, dc=dc, - save_type=save_type, + save_type=save_type, origin_cells=origin_cells, ) for sub_effect in effect.effects: target_live = self._stats[affected_state.combatant.id] @@ -7182,29 +7194,66 @@ class CombatEngine: saved: bool, dc: int = 15, save_type: SaveType = "none", + origin_cells: frozenset[Pos] = frozenset(), ) -> bool: """Save result for one creature in an area effect. PF1e (CRB): every creature in the area rolls the save the SPELL lists - (Will / Fortitude / Reflex) — "save negates" and "save for half" both + (Will / Fortitude / Reflex) - "save negates" and "save for half" both grant it; whether a success negates or halves is decided per sub-effect (``save_negates`` / ``half_on_save``), never by skipping the roll. The resolved primary target re-uses its already-rolled save; every - other creature in the area — and every creature of a point-targeted - burst (no primary target, U7b) — rolls its own. + other creature in the area - and every creature of a point-targeted + burst (no primary target, U7b) - rolls its own. + + PF1e (CRB "Cover and Reflex Saves"): cover grants a Reflex bonus + against effects that burst from a point on the other side of the + cover - half the AC value (+1 partial / +2 standard / +4 improved); + soft cover from creatures grants none (CRB "Soft Cover"). The bonus + is measured from the effect's origin to the rolling creature. + Documented simplification: the primary target's reused save was + rolled on the generic spell path before the area resolved; for a + target-centered burst the origin IS its own square (bonus 0), so the + reuse only under-protects a cone/line primary standing behind cover + from the caster. """ if save_type == "none": return False if affected_state is target: return saved + flat_bonus = ( + self._reflex_cover_bonus(origin_cells, affected_state) + if save_type == "ref" + else 0 + ) save_result = self.resolve_save( affected_state, save_type, dc, save_descriptors=save_descriptors_for_effects(effect.effects), + flat_bonus=flat_bonus, ) return save_result.success + def _reflex_cover_bonus(self, origin_cells: frozenset[Pos], state: CombatantState) -> int: + """Reflex-save cover bonus from an area effect's origin (terrain only). + + CRB "Cover and Reflex Saves": "Cover grants you a +2 bonus on Reflex + saves against attacks that originate or burst out from a point on the + other side of the cover from you" - the Reflex bonus is half the AC + bonus (partial +2 AC/+1 Ref, standard +4/+2, improved +8/+4). CRB + "Soft Cover": creatures "provide no bonus on Reflex saves", so the + interposed-creature set is empty here. + """ + cover = cover_bonus_cells( + self._grid, + origin_cells, + state.occupied_cells(), + ranged=True, + occupied=frozenset(), + ) + return cover // 2 + def _apply_metamagic( self, spell: SpellSpec, metamagic_feats: tuple[str, ...] ) -> tuple[SpellEffect, ...]: diff --git a/tests/test_area_shapes.py b/tests/test_area_shapes.py index 2d1adb7..a6ba805 100644 --- a/tests/test_area_shapes.py +++ b/tests/test_area_shapes.py @@ -91,10 +91,31 @@ _BURST_SPARKS = SpellSpec( ), ) +_BURST_GUST = SpellSpec( + name="Burst Gust", + level=3, + school="evocation", + range="long", + save="ref", + base_dc=10, + effects=( + AreaEffect( + radius_ft=20, + effects=( + DamageEffect( + formula=parse_dice("1d6"), types=("fire",), half_on_save=True + ), + ), + save_for_half=True, + ), + ), +) + _REGISTRY = { "Cone Sparks": _CONE_SPARKS, "Line Sparks": _LINE_SPARKS, "Burst Sparks": _BURST_SPARKS, + "Burst Gust": _BURST_GUST, } @@ -129,7 +150,7 @@ def _caster(hp: int = 100) -> CombatantState: make_combatant( "caster", hp=hp, - spells=["Cone Sparks", "Line Sparks", "Burst Sparks"], + spells=["Cone Sparks", "Line Sparks", "Burst Sparks", "Burst Gust"], spell_slots={1: 9, 2: 9, 3: 9}, ), "players", @@ -137,6 +158,24 @@ def _caster(hp: int = 100) -> CombatantState: ) +def _cover_grid(cover_cells: dict[Pos, int], side: int = 12) -> Grid: + """Open floor with terrain-only cover cells; the char encodes the bonus.""" + legend = {".": TerrainType(type="floor", move_cost=1)} + for bonus in cover_cells.values(): + legend[str(bonus)] = TerrainType( + type=f"cover_{bonus}", move_cost=1, cover_bonus=bonus + ) + rows = tuple( + "".join( + str(cover_cells[(r, c)]) if (r, c) in cover_cells else "." + for c in range(side) + ) + for r in range(side) + ) + spec = MapSpec(name="cover", terrain=rows, legend=legend) + return Grid.from_spec(spec) + + # --------------------------------------------------------------------------- # cone: 15 ft in front of the caster, ±45° wide, origin cell excluded # --------------------------------------------------------------------------- @@ -393,6 +432,60 @@ def test_burst_still_centers_on_the_target_and_ignores_the_caster_side() -> None assert foe_out.hp == 100 +# --------------------------------------------------------------------------- +# cover and Reflex saves (CRB "Cover and Reflex Saves") +# --------------------------------------------------------------------------- + + +def test_improved_cover_grants_reflex_bonus_against_a_burst() -> None: + """Given: a Reflex-for-half burst point-cast at (6,6); an improved-cover + cell (+8, cover_bonus 8, no LoS block) at (6,7) between the center and + foe_cover (6,8); foe_open (4,6) inside the radius with no cover; both + roll d20=10 with a +0 Reflex bonus against DC 13 + When: the burst resolves + Then: foe_cover's Reflex total is 10+4=14 (the +8 cover grants +2 on + Reflex, half the AC bonus) -> saves -> half of 4 = 2 damage; foe_open + totals 10 -> fails -> full 4 damage.""" + caster = _caster() + caster.pos = (6, 0) + foe_cover = make_state(make_combatant("foe_cover", hp=100), "monsters", (6, 8)) + foe_open = make_state(make_combatant("foe_open", hp=100), "monsters", (4, 6)) + grid = _cover_grid({(6, 7): 8}) + eng = CombatEngine( + ScriptedRng([10, 4, 10, 4]), grid, [caster, foe_cover, foe_open], + spell_registry=_REGISTRY, + ) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Burst Gust", target_pos=(6, 6)) + ) + assert result.ok + assert foe_cover.hp == 100 - 2 + assert foe_open.hp == 100 - 4 + assert caster.hp == 100 # distance 6 > 20 ft: outside the burst + + +def test_partial_cover_grants_plus_one_reflex_against_a_burst() -> None: + """Given: the same Reflex-for-half burst, but the sheltering cell grants + partial cover (+2 AC) and foe_partial rolls d20=12 against DC 13 with a + +0 Reflex bonus + When: the burst resolves + Then: 12 + 1 (half of the +2 partial cover) = 13 meets the DC -> saves + -> half damage; the +2 Reflex a full-value bonus would grant is NOT + applied (CRB: partial cover is +2 AC / +1 Reflex).""" + caster = _caster() + caster.pos = (6, 0) + foe_partial = make_state(make_combatant("foe_partial", hp=100), "monsters", (6, 8)) + grid = _cover_grid({(6, 7): 2}) + eng = CombatEngine( + ScriptedRng([12, 4]), grid, [caster, foe_partial], spell_registry=_REGISTRY + ) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Burst Gust", target_pos=(6, 6)) + ) + assert result.ok + assert foe_partial.hp == 100 - 2 + + # --------------------------------------------------------------------------- # loader: shape parsing # ---------------------------------------------------------------------------