"""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 flat-footed base already excludes # ALL Dex (bonus and penalty alike), so effective Dex 0 adds exactly -5. # The old formula added -5 - (-1) = -4, double-counting the Dex penalty. 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 == 9 # 14 - 5 def _touch(cid: str) -> AttackSpec: return AttackSpec( id=f"{cid}-touch", name="touch", kind="touch", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["cold"])], ) def test_helpless_positive_dex_melee_ac_is_flat_footed_minus5() -> None: # Dex 16 (+3): flat-footed base drops the +3 already; Dex-0 adds -5. eng = _engine( [_combatant("atk"), _combatant("def", ac=16, dex=16)], [(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 == 11 # 16 - 5 def test_helpless_positive_dex_touch_ac_strips_dex_then_minus5() -> None: # Dex 16 (+3): touch base 16 includes the +3; effective Dex 0 strips it # (16 - 3) and adds -5 -> 8. eng = _engine( [_combatant("atk"), _combatant("def", ac=16, dex=16)], [(0, 0), (0, 1)], [10, 4], ) _add_condition(eng._states[1], PARALYZED) result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk")) assert result.ac == 8 def test_helpless_negative_dex_touch_ac_strips_penalty_then_minus5() -> None: # Dex 8 (-1): touch base 14 includes the -1; effective Dex 0 replaces it # (14 + 1) and adds -5 -> 10. 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], _touch("atk")) assert result.ac == 10 def test_flat_footed_negative_dex_touch_keeps_penalty_without_minus5() -> None: # Control: a merely flat-footed (NOT helpless) Dex 8 target loses only a # positive Dex bonus — the -1 stays on touch AC and no Dex-0 -5 applies. eng = _engine( [_combatant("atk"), _combatant("def", ac=14, dex=8)], [(0, 0), (0, 1)], [10, 4], ) _add_condition(eng._states[1], CONDITIONS_BY_NAME["flat_footed"]) result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk")) assert result.ac == 14 # ── 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: # SRD nauseated: "The only action such a character can take is a single # move action per turn" — only genuine move actions survive; the 5-foot # step and dropping prone are free actions, not move actions. for kind in ("move", "stand_up", "retrieve_weapon", "repair_weapon"): 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", "5foot_step", "drop_prone", ): 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 == ()