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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user