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
This commit is contained in:
@@ -3385,6 +3385,7 @@ class CombatEngine:
|
|||||||
dc: int,
|
dc: int,
|
||||||
*,
|
*,
|
||||||
save_descriptors: frozenset[str] = frozenset(),
|
save_descriptors: frozenset[str] = frozenset(),
|
||||||
|
flat_bonus: int = 0,
|
||||||
) -> SaveResult:
|
) -> SaveResult:
|
||||||
"""Roll a saving throw (fort/ref/will) against ``dc``.
|
"""Roll a saving throw (fort/ref/will) against ``dc``.
|
||||||
|
|
||||||
@@ -3393,13 +3394,16 @@ class CombatEngine:
|
|||||||
target) are applied via ``resolve_modifiers`` — same bonus-type
|
target) are applied via ``resolve_modifiers`` — same bonus-type
|
||||||
stacking rules as attacks. ``save_descriptors`` (e.g. {"fear"},
|
stacking rules as attacks. ``save_descriptors`` (e.g. {"fear"},
|
||||||
{"poison"}) gates conditional modifiers like Bitter or Born Damned.
|
{"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()
|
roll = self._rng.d20()
|
||||||
base = getattr(state.combatant.saves, save_type)
|
base = getattr(state.combatant.saves, save_type)
|
||||||
bonus = resolve_modifiers(
|
bonus = resolve_modifiers(
|
||||||
self._stat_modifiers(state, save_type, save_descriptors=save_descriptors)
|
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)
|
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
||||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
||||||
|
|
||||||
@@ -7083,13 +7087,21 @@ class CombatEngine:
|
|||||||
total_damage = 0
|
total_damage = 0
|
||||||
total_conditions = 0
|
total_conditions = 0
|
||||||
total_buffs = 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:
|
for affected_state in affected:
|
||||||
if spell_resistance and not self._check_spell_resistance(caster, affected_state):
|
if spell_resistance and not self._check_spell_resistance(caster, affected_state):
|
||||||
continue
|
continue
|
||||||
creature_saved = self._area_save(
|
creature_saved = self._area_save(
|
||||||
affected_state, target, effect, saved=saved, dc=dc,
|
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:
|
for sub_effect in effect.effects:
|
||||||
target_live = self._stats[affected_state.combatant.id]
|
target_live = self._stats[affected_state.combatant.id]
|
||||||
@@ -7182,29 +7194,66 @@ class CombatEngine:
|
|||||||
saved: bool,
|
saved: bool,
|
||||||
dc: int = 15,
|
dc: int = 15,
|
||||||
save_type: SaveType = "none",
|
save_type: SaveType = "none",
|
||||||
|
origin_cells: frozenset[Pos] = frozenset(),
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Save result for one creature in an area effect.
|
"""Save result for one creature in an area effect.
|
||||||
|
|
||||||
PF1e (CRB): every creature in the area rolls the save the SPELL lists
|
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
|
grant it; whether a success negates or halves is decided per sub-effect
|
||||||
(``save_negates`` / ``half_on_save``), never by skipping the roll.
|
(``save_negates`` / ``half_on_save``), never by skipping the roll.
|
||||||
The resolved primary target re-uses its already-rolled save; every
|
The resolved primary target re-uses its already-rolled save; every
|
||||||
other creature in the area — and every creature of a point-targeted
|
other creature in the area - and every creature of a point-targeted
|
||||||
burst (no primary target, U7b) — rolls its own.
|
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":
|
if save_type == "none":
|
||||||
return False
|
return False
|
||||||
if affected_state is target:
|
if affected_state is target:
|
||||||
return saved
|
return saved
|
||||||
|
flat_bonus = (
|
||||||
|
self._reflex_cover_bonus(origin_cells, affected_state)
|
||||||
|
if save_type == "ref"
|
||||||
|
else 0
|
||||||
|
)
|
||||||
save_result = self.resolve_save(
|
save_result = self.resolve_save(
|
||||||
affected_state,
|
affected_state,
|
||||||
save_type,
|
save_type,
|
||||||
dc,
|
dc,
|
||||||
save_descriptors=save_descriptors_for_effects(effect.effects),
|
save_descriptors=save_descriptors_for_effects(effect.effects),
|
||||||
|
flat_bonus=flat_bonus,
|
||||||
)
|
)
|
||||||
return save_result.success
|
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(
|
def _apply_metamagic(
|
||||||
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
|
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
|
||||||
) -> tuple[SpellEffect, ...]:
|
) -> tuple[SpellEffect, ...]:
|
||||||
|
|||||||
@@ -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 = {
|
_REGISTRY = {
|
||||||
"Cone Sparks": _CONE_SPARKS,
|
"Cone Sparks": _CONE_SPARKS,
|
||||||
"Line Sparks": _LINE_SPARKS,
|
"Line Sparks": _LINE_SPARKS,
|
||||||
"Burst Sparks": _BURST_SPARKS,
|
"Burst Sparks": _BURST_SPARKS,
|
||||||
|
"Burst Gust": _BURST_GUST,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -129,7 +150,7 @@ def _caster(hp: int = 100) -> CombatantState:
|
|||||||
make_combatant(
|
make_combatant(
|
||||||
"caster",
|
"caster",
|
||||||
hp=hp,
|
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},
|
spell_slots={1: 9, 2: 9, 3: 9},
|
||||||
),
|
),
|
||||||
"players",
|
"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
|
# 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
|
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
|
# loader: shape parsing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user