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
+2 -2
View File
@@ -160,8 +160,8 @@ def test_staggered_full_attack_downgrade_recorded_with_ok_attack() -> None:
def test_nauseated_full_attack_downgrade_recorded() -> None:
# Nauseated: full_attack is not a standard action → the filter empties the
# sequence; only the synthetic downgrade result remains.
# Nauseated (B6): only move-like actions survive the filter — full_attack
# is dropped; only the synthetic downgrade result remains.
eng = _engine(
[10, 5],
policy=_fixed_policy(Action(kind="full_attack", target_id="foe")),
+67 -5
View File
@@ -5,12 +5,15 @@ The concentration DC depends on the casting situation (CRB Table 9-1):
- grappled or pinned: DC = 10 + grappler's CMB + spell_level
- casting defensively: DC = 15 + 2 * spell_level
The check roll = d20 + caster_level + casting ability mod (+4 Combat Casting),
unchanged from the current logic.
The check roll = d20 + caster level + the casting ability modifier used to
determine bonus spells (Int/Wis/Cha) — NOT Constitution (PF1e conformance
fix B2). Combat Casting's +4 applies only when casting defensively or while
grappled — never for damage taken while casting.
"""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
@@ -32,6 +35,11 @@ def _combatant(
level: int = 5,
con: int = 12,
cmb: int | None = None,
casting_ability: str = "wis",
int_score: int = 10,
wis_score: int = 10,
cha_score: int = 10,
combat_casting: bool = False,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
@@ -40,6 +48,9 @@ def _combatant(
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
features = (
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
)
return Combatant(
id=cid,
name=cid,
@@ -49,9 +60,9 @@ def _combatant(
str_score=10,
dex_score=10,
con_score=con,
int_score=10,
wis_score=10,
cha_score=10,
int_score=int_score,
wis_score=wis_score,
cha_score=cha_score,
),
hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10),
@@ -61,6 +72,8 @@ def _combatant(
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
cmb=cmb,
casting_ability=casting_ability,
features=features,
)
@@ -113,3 +126,52 @@ class TestConcentrationDc:
assert engine_pass._check_concentration(c, 1, damage_taken=4) is True
engine_fail = _make_engine(caster, grappler, [9])[0]
assert engine_fail._check_concentration(c, 1, damage_taken=4) is False
class TestConcentrationUsesCastingAbilityNotCon:
"""B2 (PF1e conformance): the check is d20 + caster level + the ability
modifier used for bonus spells (Int/Wis/Cha) — NOT Constitution."""
def test_high_con_low_casting_ability_fails_where_con_would_pass(self) -> None:
# CON 18 (+4) but casting ability INT 10 (+0): injured DC = 10+4+1 = 15.
# Correct roll: d20 6 + level 5 + int 0 = 11 < 15 -> FAIL.
# The old CON-based check would have given 6 + 5 + 4 = 15 -> pass.
caster = _combatant("caster", level=5, con=18, casting_ability="int")
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [6])
assert engine._check_concentration(c, 1, damage_taken=4) is False
def test_charisma_modifier_is_used_for_a_sorcerer(self) -> None:
# CHA 18 (+4), CON 6 (-2), casting ability cha: injured DC = 10+4+1 = 15.
# d20 6 + level 5 + cha 4 = 15 >= 15 -> PASS (CON would give 13 -> fail).
caster = _combatant("caster", level=5, con=6, cha_score=18, casting_ability="cha")
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [6])
assert engine._check_concentration(c, 1, damage_taken=4) is True
class TestCombatCastingPlacement:
"""B2 (PF1e conformance): Combat Casting's +4 applies only when casting
defensively or while grappled — never for damage taken while casting."""
def test_combat_casting_does_not_help_when_injured(self) -> None:
# Injured DC = 10+4+1 = 15. d20 6 + level 5 + wis 0 = 11 < 15 -> FAIL.
# The old blanket +4 would have made 15 -> pass.
caster = _combatant("caster", level=5, con=10, combat_casting=True)
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [6])
assert engine._check_concentration(c, 1, damage_taken=4) is False
def test_combat_casting_helps_when_casting_defensively(self) -> None:
# Defensive DC = 15 + 2*1 = 17. d20 8 + level 5 + wis 0 + 4 = 17 -> PASS.
caster = _combatant("caster", level=5, con=10, combat_casting=True)
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [8])
assert engine._check_concentration(c, 1, damage_taken=0) is True
def test_combat_casting_helps_while_grappled(self) -> None:
# Grappled DC = 10 + CMB 3 + level 1 = 14. d20 5 + 5 + 0 + 4 = 14 -> PASS.
caster = _combatant("caster", level=5, con=10, combat_casting=True)
grappler = _combatant("grappler", cmb=3)
engine, c, _ = _make_engine(caster, grappler, [5], grappled=True)
assert engine._check_concentration(c, 1, damage_taken=0) is True
+1
View File
@@ -380,6 +380,7 @@ def _make_caster(cid: str = "caster") -> Combatant:
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
spell_slots={0: 99, 1: 99, 2: 99, 3: 99},
)
+4 -3
View File
@@ -164,9 +164,10 @@ def test_melee_vs_helpless_target_gains_plus4() -> None:
a, t = eng._states
_add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# roll 10 + bonus 5 + 4 (helpless melee) = 19; AC = flat-footed 10 (Dex denied).
# roll 10 + bonus 5 + 4 (helpless melee) = 19; AC = flat-footed 10 minus
# the Dex-0 penalty (helpless target has effective Dexterity 0: -5).
assert result.total == 19
assert result.ac == 10
assert result.ac == 5
assert result.hit is True
@@ -180,7 +181,7 @@ def test_melee_vs_helpless_plus4_applies_to_crit_confirmation() -> None:
a, t = eng._states
_add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# Confirm 10 + 5 + 4 = 19 >= 10 -> crit.
# Confirm 10 + 5 + 4 = 19 >= 5 (flat-footed 10 - 5 Dex-0) -> crit.
assert result.crit is True
assert result.damage == 8
+8 -5
View File
@@ -189,13 +189,14 @@ class TestStaggeredActionEconomy:
assert result is False
# ── Test 4: nauseated → single standard action only ───────────────────────────
# ── Test 4: nauseated → single move action only ───────────────────────────────
class TestNauseatedActionEconomy:
"""Given: a nauseated combatant
When: _take_turn is called
Then: they can only take standard actions (no full attack, no charge, no move)."""
Then: they can only take a single move action (no standard, no attack,
no spellcasting — CRB nauseated)."""
def test_nauseated_no_attack_and_no_full_round(self) -> None:
hero = _make_hero(bab=6)
@@ -241,7 +242,7 @@ class TestParalyzedNoActions:
assert found_wait, "paralyzed combatant should log 'wait (cannot act)'"
def test_paralyzed_defender_helpless_ac(self) -> None:
"""Paralyzed target: denies_dex_to_ac=True (flat-footed) + -4 AC (helpless)."""
"""Paralyzed is helpless: denies_dex_to_ac (flat-footed) + Dex 0 (-5)."""
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
@@ -249,8 +250,10 @@ class TestParalyzedNoActions:
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
)
result = engine.resolve_attack(h, f, hero.attacks[0])
# Paralyzed: denies_dex_to_ac → flat_footed AC 13; plus -4 helpless → 9
assert result.ac == 9
# Paralyzed: denies_dex_to_ac → flat_footed AC 13; helpless Dex 0 → -5;
# melee attacker also gains the +4 helpless bonus (roll 10 + 5 + 4 = 19).
assert result.ac == 8
assert result.total == 19
# ── Test 6: StatModifier condition filtering ──────────────────────────────────
+438
View File
@@ -0,0 +1,438 @@
"""P1 PF1e conformance fixes (Oracle-adjudicated audit).
Six genuine RAW deviations, one section per bug:
B1 Area saves: every creature in an area rolls the save the SPELL lists
(Will / Fortitude / Reflex); "save negates" areas save, and secondary
creatures are not hardcoded to Reflex.
B2 Concentration: d20 + caster level + the casting ability modifier
(Int/Wis/Cha), never Constitution; Combat Casting's +4 only applies
when casting defensively or while grappled.
B3 Prone: the prone attacker takes -4 on melee attacks (ranged treated as
crossbow-exempt at -4); the prone defender takes -4 AC vs melee and
gains +4 AC vs ranged.
B4 Spell slots: casting without an available slot is refused; cantrips
(level 0) cast freely.
B5 Helpless: paralyzed / asleep / unconscious (and helpless) are helpless —
melee attackers gain +4 and the target has effective Dexterity 0 (-5);
stunned is NOT helpless.
B6 Nauseated: only a single move action per turn — no standard actions, no
attacks, no spellcasting, no concentration.
B2 is covered in tests/test_concentration.py; B1's per-spell cases in
tests/test_spells.py (TestAreaSavesPerSpellSaveType). This file adds the
cross-cutting conformance locks.
"""
from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.combat import (
Action,
ActionResult,
CombatantState,
CombatEngine,
_nauseated_filter,
)
from pf1e_simulator.conditions import (
ASLEEP,
CONDITIONS_BY_NAME,
PARALYZED,
Condition,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
AreaEffect,
ConditionEffect,
DamageEffect,
SpellSpec,
)
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def _melee(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _bow(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-bow",
name="short bow",
kind="ranged",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
range_increment_ft=60,
)
def _combatant(
cid: str,
*,
level: int = 1,
hp: int = 100,
ac: int = 15,
dex: int = 10,
attacks: tuple[AttackSpec, ...] = (),
spells: tuple[str, ...] = (),
spell_slots: dict[int, int] | None = None,
) -> Combatant:
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=dex, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks) or [_melee(cid)],
spells=list(spells),
spell_slots=spell_slots or {},
)
def _engine(
combatants: list[Combatant],
positions: list[tuple[int, int]],
rng: list[int],
spell_registry: dict[str, SpellSpec] | None = None,
policy: PolicyFn | None = None,
) -> CombatEngine:
sides = ["players"] + ["monsters"] * (len(combatants) - 1)
states = [
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
for c, side, pos in zip(combatants, sides, positions, strict=True)
]
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
grid = Grid.from_spec(spec)
return CombatEngine(
ScriptedRng(rng), grid, states,
spell_registry=spell_registry or {}, policy=policy,
)
def _add_condition(state: CombatantState, cond: Condition) -> None:
state.active_conditions.append(
ActiveCondition(condition=cond, rounds_left=5, source="test")
)
# ── B1: point-cast area — every creature rolls the spell's save type ─────────
_SLUMBER_CLOUD = SpellSpec(
name="Slumber Cloud",
level=1,
school="enchantment",
range="close",
save="will",
effects=(
AreaEffect(
radius_ft=20,
effects=(ConditionEffect("asleep", 10, save_negates=True),),
save_for_half=False,
),
),
)
def test_point_cast_area_rolls_the_spells_save_per_creature() -> None:
# No primary target: each creature in the burst rolls its own Will save
# (the old code short-circuited save_for_half=False to "no save at all").
eng = _engine(
[
_combatant("caster", spell_slots={1: 1}),
_combatant("sleeper"),
_combatant("waker"),
],
[(0, 7), (0, 2), (0, 3)],
[1, 20],
spell_registry={"Slumber Cloud": _SLUMBER_CLOUD},
)
caster, sleeper, waker = eng._states
result = eng._execute(
caster,
Action(kind="cast_spell", spell_name="Slumber Cloud", target_pos=(0, 2)),
)
assert result.ok
# Caster is outside the 4-cell burst; sleeper fails its Will (d20 1),
# waker saves on natural 20 — the negate semantics now reach areas.
assert not any(ac.condition.name == "asleep" for ac in caster.active_conditions)
assert any(ac.condition.name == "asleep" for ac in sleeper.active_conditions)
assert not any(ac.condition.name == "asleep" for ac in waker.active_conditions)
# ── B3: prone rules by weapon kind ───────────────────────────────────────────
def test_prone_attacker_minus4_on_melee_attack() -> None:
eng = _engine([_combatant("atk"), _combatant("def")], [(0, 0), (0, 1)], [10, 4])
a, d = eng._states
_add_condition(a, CONDITIONS_BY_NAME["prone"])
result = eng.resolve_attack(a, d, a.combatant.attacks[0])
# roll 10 + bonus 5 - 4 (prone melee) = 11.
assert result.total == 11
def test_prone_defender_minus4_ac_vs_melee_plus4_vs_ranged() -> None:
melee_eng = _engine(
[_combatant("atk"), _combatant("def")], [(0, 0), (0, 1)], [10, 4]
)
_add_condition(melee_eng._states[1], CONDITIONS_BY_NAME["prone"])
melee = melee_eng.resolve_attack(
melee_eng._states[0], melee_eng._states[1], _melee("atk")
)
assert melee.ac == 11 # 15 - 4 vs melee
ranged_eng = _engine(
[_combatant("atk", attacks=(_bow("atk"),)), _combatant("def")],
[(0, 0), (0, 3)],
[10, 4],
)
_add_condition(ranged_eng._states[1], CONDITIONS_BY_NAME["prone"])
ranged = ranged_eng.resolve_attack(
ranged_eng._states[0], ranged_eng._states[1], _bow("atk")
)
assert ranged.ac == 19 # 15 + 4 vs ranged
def test_prone_ranged_attacker_minus4_crossbow_exemption_documented() -> None:
# The model cannot tell a crossbow from a bow: every ranged weapon is
# treated as crossbow-exempt — usable, but at -4 while prone.
eng = _engine(
[_combatant("atk", attacks=(_bow("atk"),)), _combatant("def")],
[(0, 0), (0, 3)],
[15, 4],
)
_add_condition(eng._states[0], CONDITIONS_BY_NAME["prone"])
result = eng.resolve_attack(
eng._states[0], eng._states[1], _bow("atk")
)
assert result.total == 16 # 15 + 5 - 4
# ── B4: spell slots enforced ─────────────────────────────────────────────────
_ZAP = SpellSpec(
name="Zap",
level=1,
school="evocation",
range="close",
save="none",
effects=(DamageEffect(formula=parse_dice("1d4"), types=("electricity",)),),
)
_SPARK = SpellSpec(
name="Spark",
level=0,
school="evocation",
range="close",
save="none",
effects=(DamageEffect(formula=parse_dice("1d3"), types=("electricity",)),),
)
def test_cast_without_a_slot_is_refused() -> None:
eng = _engine(
[_combatant("caster", spell_slots={1: 1}), _combatant("tgt")],
[(0, 0), (0, 2)],
[3, 3],
spell_registry={"Zap": _ZAP},
)
caster, tgt = eng._states
eng._cast_spell(caster, tgt, "Zap")
assert tgt.hp == 97 # first cast spent the only slot (1d4 → 3)
eng._cast_spell(caster, tgt, "Zap")
assert tgt.hp == 97 # second cast refused before any roll
assert any("NO SPELL SLOT" in line for line in eng._transcript)
assert caster.spell_slots_left[1] == 0
def test_cantrips_cast_freely_without_slots() -> None:
eng = _engine(
[_combatant("caster"), _combatant("tgt")],
[(0, 0), (0, 2)],
[3, 3, 3],
spell_registry={"Spark": _SPARK},
)
caster, tgt = eng._states
for _ in range(3):
eng._cast_spell(caster, tgt, "Spark")
assert tgt.hp == 100 - 9 # three casts of 1d3 → 3 each
assert not any("NO SPELL SLOT" in line for line in eng._transcript)
# ── B5: helpless extended to paralyzed / asleep / unconscious ────────────────
def _helpless_cases() -> list[Condition]:
return [
CONDITIONS_BY_NAME["helpless"],
PARALYZED,
ASLEEP,
Condition(name="unconscious", denies_dex_to_ac=True, cant_act=True),
]
def test_melee_vs_helpless_states_gains_plus4_and_dex_zero() -> None:
for cond in _helpless_cases():
eng = _engine(
[_combatant("atk"), _combatant("def", ac=16)], [(0, 0), (0, 1)], [10, 4]
)
_add_condition(eng._states[1], cond)
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
# roll 10 + 5 + 4 (helpless melee) = 19; AC 16 (flat-footed, Dex
# denied) - 5 (effective Dex 0) = 11.
assert result.total == 19, cond.name
assert result.ac == 11, cond.name
def test_stunned_is_not_helpless() -> None:
eng = _engine(
[_combatant("atk"), _combatant("def", ac=16)], [(0, 0), (0, 1)], [10, 4]
)
_add_condition(eng._states[1], CONDITIONS_BY_NAME["stunned"])
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
# No +4 to hit (stunned is not helpless); AC = flat-footed 16 - 2 (the
# stunned condition's own AC penalty), no Dex-0 -5.
assert result.total == 15
assert result.ac == 14
def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None:
# A Dex 8 (-1) helpless target: the -1 penalty gives way to the Dex-0 -5
# (adjustment -5 - (-1) = -4 on top of the flat-footed base).
eng = _engine(
[_combatant("atk"), _combatant("def", ac=14, dex=8)],
[(0, 0), (0, 1)],
[10, 4],
)
_add_condition(eng._states[1], PARALYZED)
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
assert result.ac == 10 # 14 - 4
# ── B6: nauseated — single move action only, no casting ──────────────────────
def test_nauseated_cannot_take_a_standard_attack() -> None:
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
if s.combatant.id == "hero":
return (Action(kind="attack", target_id="foe"),)
return (Action(kind="wait"),)
eng = _engine(
[_combatant("hero"), _combatant("foe")], [(0, 0), (0, 1)], [10, 5],
policy=policy,
)
hero, _foe = eng._states
_add_condition(hero, CONDITIONS_BY_NAME["nauseated"])
eng._roll_initiative()
eng._take_turn(hero)
assert not any(
" vs foe" in line and "short sword" in line for line in eng._transcript
)
assert hero.last_action_results == [
ActionResult(kind="attack", ok=False, reason="downgraded:nauseated")
]
def test_nauseated_keeps_only_the_first_move_action() -> None:
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
if s.combatant.id == "hero":
return (
Action(kind="move", target_id="foe"),
Action(kind="attack", target_id="foe"),
)
return (Action(kind="wait"),)
eng = _engine(
[_combatant("hero"), _combatant("foe")], [(0, 0), (0, 3)], [10, 5],
policy=policy,
)
hero, _foe = eng._states
_add_condition(hero, CONDITIONS_BY_NAME["nauseated"])
eng._roll_initiative()
eng._take_turn(hero)
assert any("hero: move" in line for line in eng._transcript)
assert hero.last_action_results == [
ActionResult(kind="attack", ok=False, reason="downgraded:nauseated"),
ActionResult(kind="move", ok=True, reason="ok"),
]
def test_nauseated_cannot_cast_via_action_execution() -> None:
# Defense in depth: even a cast that bypasses the action filter (a ready
# payload, say) is refused while nauseated.
eng = _engine(
[_combatant("caster", spell_slots={1: 9}), _combatant("tgt")],
[(0, 0), (0, 2)],
[],
spell_registry={"Zap": _ZAP},
)
caster, tgt = eng._states
_add_condition(caster, CONDITIONS_BY_NAME["nauseated"])
eng._execute_cast_spell(caster, tgt, "Zap")
assert tgt.hp == 100
assert caster.spell_slots_left[1] == 9
assert any(
"cast_spell rejected (cannot attack)" in line for line in eng._transcript
)
def test_nauseated_filter_keeps_move_kinds_drops_everything_else() -> None:
for kind in ("move", "5foot_step", "stand_up", "drop_prone"):
kept = _nauseated_filter((Action(kind=kind, target_id="foe"),))
assert len(kept) == 1, kind
assert kept[0].kind == kind, kind
for kind in ("attack", "cast_spell", "full_attack", "charge", "maneuver", "hex"):
assert _nauseated_filter((Action(kind=kind, target_id="foe"),)) == (), kind
# ── B3/B5 engine-side locks (unit level) ────────────────────────────────────
def test_stat_modifiers_no_longer_flatten_prone_or_paralyzed_ac() -> None:
# The unconditional -4 AC appends for prone/paralyzed are gone from
# _stat_modifiers: the adjustments are weapon-kind dependent and live in
# resolve_attack. A prone + paralyzed defender's collected "ac" mods
# carry no -4.
eng = _engine([_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], [])
_add_condition(eng._states[1], CONDITIONS_BY_NAME["prone"])
_add_condition(eng._states[1], PARALYZED)
mods = eng._stat_modifiers(eng._states[1], "ac")
assert all(m.value != -4 for m in mods)
def test_asleep_condition_no_longer_carries_a_flat_ac_penalty() -> None:
# The ASLEEP -4 AC approximation is superseded by the engine's Dex-0
# adjustment for helpless targets.
assert ASLEEP.modifiers == ()
+1
View File
@@ -312,6 +312,7 @@ def test_damage_taken_reaction_fires() -> None:
caster = make_combatant("caster", hp=50)
reactor = make_combatant("reactor", hp=50, kind="ranged")
caster_state = make_state(caster, "players", (1, 1))
caster_state.spell_slots_left = {1: 9}
reactor_state = make_state(reactor, "monsters", (1, 3))
states = [caster_state, reactor_state]
+2
View File
@@ -57,6 +57,7 @@ def make_combatant(
features: list[AbilitySpec] | None = None,
spells: list[str] | None = None,
ranged: bool = False,
spell_slots: dict[int, int] | None = None,
) -> Combatant:
"""Build a single-weapon combatant with known static stats.
@@ -103,6 +104,7 @@ def make_combatant(
attacks=[attack],
features=features or [],
spells=spells or [],
spell_slots=spell_slots or {},
)
+27 -9
View File
@@ -108,7 +108,9 @@ def _cast_scene(
(distance 0) but not foe_out (distance 5) nor the caster (distance 9).
"""
caster = make_state(
make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)
make_combatant(
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (7, 0)
)
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (0, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (4, 7))
@@ -187,7 +189,9 @@ def test_point_cast_without_line_of_effect_rejected() -> None:
When: cast_spell at that unseen point
Then: invalid_destination rejection without casting."""
caster = make_state(
make_combatant("caster", hp=100, spells=["Fireball"]), "players", (0, 0)
make_combatant(
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (0, 0)
)
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 6))
eng = _engine([caster, foe], [], grid=_walled_grid())
@@ -216,7 +220,9 @@ def test_point_cast_rolls_a_save_per_creature_in_area() -> None:
When: a target-less point cast at (0,4)
Then: no primary save is rolled (queue would shift otherwise) and EACH
creature rolls its own: foe_a fails (2) full 24, foe_b saves (20) half 12."""
caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0))
caster = make_state(make_combatant(
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (7, 0))
foe_a = make_state(make_combatant("foe_a", hp=100), "monsters", (0, 4))
foe_b = make_state(make_combatant("foe_b", hp=100), "monsters", (0, 5))
eng = _engine([caster, foe_a, foe_b], [2] + [3] * 8 + [20] + [3] * 8)
@@ -233,7 +239,9 @@ def test_point_cast_no_save_area_spell_rolls_damage_dice_only() -> None:
When: a target-less point cast
Then: no saving throw is rolled at all — only the 8 damage dice."""
caster = make_state(
make_combatant("caster", hp=100, spells=["Spark Burst"]), "players", (7, 0)
make_combatant(
"caster", hp=100, spells=["Spark Burst"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (7, 0)
)
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
eng = _engine([caster, foe], [3] * 8)
@@ -254,7 +262,9 @@ def test_creature_centered_cast_transcript_pinned() -> None:
When: cast_spell with target_id and NO target_pos (the legacy path)
Then: the transcript is the pinned creature-centered line — primary save
rolled once, reused for the target in the area, 8d6 all 3s."""
caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0))
caster = make_state(make_combatant(
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (7, 0))
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
eng = _engine([caster, foe], [2] + [3] * 8)
result = eng._execute(
@@ -276,13 +286,17 @@ def test_target_pos_at_target_cell_matches_creature_centered() -> None:
target_pos set to the target's own cell (target_id kept)
When: both casts resolve with identical scripted rolls
Then: transcripts and hp outcomes are byte-identical."""
caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0))
caster = make_state(make_combatant(
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (7, 0))
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
plain = _engine([caster, foe], [2] + [3] * 8)
plain._execute(
caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball")
)
caster2 = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0))
caster2 = make_state(make_combatant(
"caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (7, 0))
foe2 = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
pointed = _engine([caster2, foe2], [2] + [3] * 8)
pointed._execute(
@@ -304,7 +318,9 @@ def test_non_area_spell_with_target_pos_resolves_on_target() -> None:
Then: target_pos is ignored — the missile hits its creature (1d4+1 = 4)
and the transcript is the ordinary creature-centered line."""
caster = make_state(
make_combatant("caster", hp=100, spells=["Magic Missile"]), "players", (0, 0)
make_combatant(
"caster", hp=100, spells=["Magic Missile"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (0, 0)
)
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2))
eng = _engine([caster, foe], [3])
@@ -325,7 +341,9 @@ def test_non_area_point_cast_without_target_is_harmless_no_op() -> None:
When: the action is dispatched with an empty roll queue
Then: nothing crashes, nothing is cast, no die is rolled."""
caster = make_state(
make_combatant("caster", hp=100, spells=["Magic Missile"]), "players", (0, 0)
make_combatant(
"caster", hp=100, spells=["Magic Missile"], spell_slots={1: 9, 2: 9, 3: 9}
), "players", (0, 0)
)
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2))
eng = _engine([caster, foe], [])
+4 -2
View File
@@ -7,8 +7,10 @@ removes the ``prone`` condition from BOTH representations — the duration-track
``conditions`` list (how ``drop_prone`` applies it). ``drop_prone`` is a free
action that goes prone until the combatant stands up. Rejections: standing up
while not prone → ``not_prone``; dropping while already prone →
``already_prone``. The engine's existing prone behaviour (flat -4 AC in
``_stat_modifiers``, -4 ranged attacks) is unchanged.
``already_prone``. The engine's prone modifiers follow the CRB (B3
conformance): a prone attacker takes -4 on melee attacks (ranged weapons are
treated as crossbow-exempt — usable at -4, the model cannot tell them apart);
a prone defender takes -4 AC vs melee and gains +4 AC vs ranged.
"""
from __future__ import annotations
+8 -6
View File
@@ -24,6 +24,7 @@ def _make_combatant(
spell_resistance: int = 0,
resistances: dict[str, int] | None = None,
immunities: tuple[str, ...] = (),
spell_slots: dict[int, int] | None = None,
) -> Combatant:
return Combatant(
id=id_,
@@ -43,6 +44,7 @@ def _make_combatant(
spell_resistance=spell_resistance,
resistances=resistances or {},
immunities=immunities,
spell_slots=spell_slots or {},
)
@@ -102,7 +104,7 @@ _FIREBALL = SpellSpec(
def test_sr_blocks_all_effects() -> None:
caster = _make_combatant("caster", spell_resistance=0)
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant("target", spell_resistance=15)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
@@ -116,7 +118,7 @@ def test_sr_blocks_all_effects() -> None:
def test_sr_passes_when_cl_suffices() -> None:
caster = _make_combatant("caster", spell_resistance=0)
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant("target", spell_resistance=10)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
@@ -130,7 +132,7 @@ def test_sr_passes_when_cl_suffices() -> None:
def test_per_target_sr_in_area_spell() -> None:
caster = _make_combatant("caster", spell_resistance=0)
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
t1 = _make_combatant("t1", spell_resistance=15)
t2 = _make_combatant("t2", spell_resistance=0)
eng = _engine(
@@ -149,7 +151,7 @@ def test_per_target_sr_in_area_spell() -> None:
def test_no_sr_check_when_sr_zero() -> None:
caster = _make_combatant("caster", spell_resistance=0)
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant("target", spell_resistance=0)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
@@ -164,7 +166,7 @@ def test_no_sr_check_when_sr_zero() -> None:
def test_fire_resistance_reduces_spell_damage() -> None:
caster = _make_combatant("caster", spell_resistance=0)
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant(
"target",
spell_resistance=0,
@@ -182,7 +184,7 @@ def test_fire_resistance_reduces_spell_damage() -> None:
def test_fire_immunity_negates_damage() -> None:
caster = _make_combatant("caster", spell_resistance=0)
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant(
"target",
spell_resistance=0,
+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