fix(rules): correct P1 PF1e conformance bugs (area saves, concentration, prone, slots, helpless, nauseated)

This commit is contained in:
2026-09-10 20:52:28 +02:00
parent 1ca5aed758
commit 5afe495c4a
15 changed files with 790 additions and 86 deletions
+61
View File
@@ -73,6 +73,9 @@ def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
# B4 conformance: _cast_spell now refuses without a slot; test
# casters carry a generous pool so fixtures stay about spells.
spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99},
)
@@ -260,6 +263,64 @@ class TestFireballSave:
assert any("SAVED" in line for line in engine._transcript)
class TestAreaSavesPerSpellSaveType:
"""B1 (PF1e conformance): every creature in an area rolls the save the
SPELL lists (Will/Fort/Ref) — "save negates" areas are no longer
save-free, and secondary creatures do not always roll Reflex."""
def test_sleep_secondary_creature_rolls_will_and_negates(self) -> None:
# Sleep (Will negates, save_for_half=False): primary target saves on
# natural 20 (first roll), the caster — second creature in the area —
# rolls its own Will and fails on natural 1 (second roll).
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [20, 1], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Sleep")
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions)
assert any(ac.condition.name == "asleep" for ac in c.active_conditions)
def test_color_spray_primary_save_is_not_discarded(self) -> None:
# Color Spray (Will negates): the primary's pre-rolled save decides —
# natural 1 fails (stunned), the caster's own natural 20 succeeds.
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [1, 20], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Color Spray")
assert any(ac.condition.name == "stunned" for ac in t.active_conditions)
assert not any(ac.condition.name == "stunned" for ac in c.active_conditions)
def test_web_secondary_creature_rolls_reflex(self) -> None:
# Web (Reflex negates): secondary creature rolls its own Reflex.
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [20, 1], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Web")
assert not any(ac.condition.name == "grappled" for ac in t.active_conditions)
assert any(ac.condition.name == "grappled" for ac in c.active_conditions)
def test_sound_burst_secondary_rolls_fortitude_not_reflex(self) -> None:
# Sound Burst (Fortitude partial): caster has ref +4, fort +3 and the
# DC is 16 — d20 12 passes Reflex but fails Fortitude. The caster
# being stunned proves the FORT save was rolled (B1's hardcoded ref
# would have "saved" and left the caster unstunned).
caster = _make_caster()
target = _make_target(hp_max=200)
# rolls: primary (target) fort 20 = save; caster fort 12 = fail;
# then the 1d8 damage dice for each creature in state order.
engine, c, t = _make_engine(
caster, target, [20, 12, 3, 3], target_pos=(2, 0)
)
engine._cast_spell(c, t, "Sound Burst")
assert any(ac.condition.name == "stunned" for ac in c.active_conditions)
assert t.hp == 200 - 1 # saved: 3 // 2
class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False)
When: the target succeeds / fails its save