"""P2/P3 PF1e conformance fixes (Oracle-adjudicated audit). Twelve RAW deviations, one section per bug: B8 Flanking: the flanking ally must actually THREATEN the target (a reach weapon's 5-ft dead zone does not threaten) and must not be unable to act. B9 Melee touch (kind "touch", no range increment) is a melee attack: it gains flanking, high ground, the vs-helpless +4 and can sneak attack; Power Attack stays melee-only. B10 Blinded: NO flat -2 on attack rolls (the modeled 50% total-concealment miss is the RAW penalty); the -4 Dex-skill penalty rides the dex_skills target (B22). B11 Disabled (exactly 0 hp): active, staggered (one move OR standard action), half speed; a strenuous action costs 1 hp at its end (hp -> -1, dying). B12 Death threshold: dead when hp <= -CON (PF1e has no -10 floor). B13 Combat maneuvers provoke an AoO from the target unless the attacker holds the Improved feat for that maneuver; escape never provokes; an AoO that takes the attacker below 0 hp forfeits the maneuver. B15 DR "and" bypass: a multi-component bypass list requires EVERY component (the lich's DR 15/bludgeoning and magic is not beaten by a mundane bludgeoning weapon). B17 Crit confirmation: a natural 20 on the confirmation roll auto-confirms. B18 Maneuver CMB: natural 20 always succeeds, natural 1 always fails. B19 moved_this_turn: charge and withdraw are mutually exclusive with movement already spent this turn, like move/5-foot-step/run. B22 dex_skills: the aggregate "dex_skills" modifier target now actually applies to the Dexterity-based skills. B23 Sneak attack: undead and constructs are immune to precision damage. """ from __future__ import annotations from collections.abc import Callable from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import Action, CombatantState, CombatEngine from pf1e_simulator.conditions import CONDITIONS_BY_NAME 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, DamageReduction, Saves, ) from pf1e_simulator.rng import ScriptedRng PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def _sword(cid: str, bonus: int = 5) -> AttackSpec: return AttackSpec( id=f"{cid}-sword", name="short sword", kind="melee", attack_bonus=bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], ) def _reach_sword(cid: str) -> AttackSpec: return AttackSpec( id=f"{cid}-glaive", name="glaive", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], reach_ft=10, reach_weapon=True, ) def _touch(cid: str, bonus: int = 10, increment: int | None = None) -> AttackSpec: return AttackSpec( id=f"{cid}-touch", name="paralyzing touch", kind="touch", attack_bonus=bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["cold"])], range_increment_ft=increment, ) def _club(cid: str, types: tuple[str, ...] = ("bludgeoning",)) -> AttackSpec: return AttackSpec( id=f"{cid}-club", name="club", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=list(types))], ) def _combatant( cid: str, *, level: int = 1, hp: int = 100, ac: int = 10, con: int = 10, bab: int = 1, creature_type: str = "living", dr: DamageReduction | None = None, attacks: tuple[AttackSpec, ...] = (), features: tuple[str, ...] = (), cmd: int | None = None, ) -> Combatant: return Combatant( id=cid, name=cid, level=level, size="Medium", creature_type=creature_type, abilities=AbilityScores( str_score=10, dex_score=10, con_score=con, int_score=10, wis_score=10, cha_score=10, ), hp_max=hp, ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=bab, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=0, ref=0, will=0), attacks=list(attacks) or [_sword(cid)], features=[AbilitySpec(name=f, category="feat") for f in features], dr=dr, cmd=cmd, ) def _engine( combatants: list[Combatant], positions: list[tuple[int, int]], sides: list[str], rng: list[int], policy: PolicyFn | None = None, ) -> CombatEngine: states = [ CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max) for c, pos, side in zip(combatants, positions, sides, strict=True) ] legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) grid = Grid.from_spec(spec) kwargs: dict[str, object] = {} if policy is None else {"policy": policy} return CombatEngine(ScriptedRng(rng), grid, states, **kwargs) def _add_condition(state: CombatantState, name: str) -> None: state.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test" ) ) # ── B8: flanking requires an ally that actually threatens ──────────────────── def test_flanking_denied_to_reach_ally_in_dead_zone() -> None: # Ally on the mirror cell with a reach weapon: the target stands 5 ft # away — inside the weapon's dead zone, so it is NOT threatened (CRB). atk = _combatant("atk") ally = _combatant("aly", attacks=(_reach_sword("aly"),)) tgt = _combatant("tgt") eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)], ["players", "players", "monsters"], []) assert eng._flanking_bonus(eng._states[0], eng._states[2]) == 0 def test_flanking_granted_by_threatening_ally() -> None: # Control: the same geometry with a normal melee weapon threatens. atk = _combatant("atk") ally = _combatant("aly") tgt = _combatant("tgt") eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)], ["players", "players", "monsters"], []) assert eng._flanking_bonus(eng._states[0], eng._states[2]) == 2 def test_flanking_denied_to_incapacitated_ally() -> None: # A stunned ally (cant_act) cannot threaten: no flanking (CRB). atk = _combatant("atk") ally = _combatant("aly") tgt = _combatant("tgt") eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)], ["players", "players", "monsters"], []) _add_condition(eng._states[1], "stunned") assert eng._flanking_bonus(eng._states[0], eng._states[2]) == 0 # ── B9: melee touch attacks are melee attacks ──────────────────────────────── def test_melee_touch_gains_flanking() -> None: atk = _combatant("atk", attacks=(_touch("atk"),)) ally = _combatant("aly") tgt = _combatant("tgt") eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)], ["players", "players", "monsters"], [12, 3]) result = eng.resolve_attack(eng._states[0], eng._states[2], _touch("atk")) # roll 12 + bonus 10 + 2 (flank) = 24 — the melee-touch swing flanks. assert result.flank_bonus == 2 assert result.total == 24 def test_melee_touch_gains_helpless_bonus() -> None: atk = _combatant("atk", attacks=(_touch("atk"),)) tgt = _combatant("tgt") eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [10, 3]) _add_condition(eng._states[1], "paralyzed") result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk")) # roll 10 + bonus 10 + 4 (melee vs helpless) = 24. assert result.total == 24 def test_melee_touch_can_sneak_attack() -> None: rogue = _combatant( "rogue", level=3, features=("Sneak Attack",), attacks=(_touch("rogue"),) ) tgt = _combatant("tgt") eng = _engine([rogue, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [15, 2, 3, 4]) _add_condition(eng._states[1], "flat_footed") result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("rogue")) # 2d6 sneak (2+3) added to the 1d6 weapon die (4): precision on a touch. assert result.damage == 9 assert any("sneak attack" in line for line in eng._transcript) def test_power_attack_still_excludes_melee_touch() -> None: atk = _combatant( "atk", bab=5, attacks=(_touch("atk"),), features=("Power Attack",) ) tgt = _combatant("tgt") eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [10, 2]) a = eng._states[0] a.power_attack = True result = eng.resolve_attack(a, eng._states[1], _touch("atk")) # Power Attack is melee-weapon-only: the touch swing keeps its full bonus. assert result.total == 10 + 10 # ── B10: blinded has no flat attack penalty ────────────────────────────────── def test_blinded_condition_has_no_attack_modifier() -> None: blinded = CONDITIONS_BY_NAME["blinded"] assert all(m.target != "attack" for m in blinded.modifiers) def test_blinded_attacker_suffers_no_attack_penalty() -> None: atk = _combatant("atk") tgt = _combatant("tgt", ac=99) eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [10]) _add_condition(eng._states[0], "blinded") result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("atk")) # roll 10 + bonus 5 = 15 — the RAW penalty is the 50% miss, not the hit. assert result.total == 15 def test_blinded_imposes_minus4_dex_skills() -> None: hero = _combatant("hero") eng = _engine([hero], [(0, 0)], ["players"], []) s = eng._states[0] _add_condition(s, "blinded") assert eng._skill_static_bonus(s, "stealth") == -4 assert eng._skill_static_bonus(s, "perception") == 0 # ── B11: disabled at exactly 0 hp ──────────────────────────────────────────── def _disabled_attack_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="full_attack", target_id="foe"),) return (Action(kind="wait"),) def test_disabled_is_active_staggered_and_half_speed() -> None: hero = _combatant("hero") foe = _combatant("foe") eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], []) s = eng._states[0] s.hp = 0 assert s.active is True assert s.dead is False assert eng._get_speed_mult(s) == 0.5 def test_disabled_full_attack_downgrades_and_costs_one_hp() -> None: hero = _combatant("hero") foe = _combatant("foe") eng = _engine( [hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], [15, 6], policy=_disabled_attack_policy, ) s = eng._states[0] s.hp = 0 eng._current_round = 1 eng._take_turn(s) # Staggered: the full attack collapsed to a single standard attack, and # completing that strenuous action costs 1 hp -> dying (CRB, Disabled). assert s.hp == -1 assert s.active is False assert any("strenuous action costs 1 hp" in ln for ln in eng._transcript) assert any("HIT 6 damage (100->94)" in ln for ln in eng._transcript) reasons = [(r.kind, r.reason) for r in s.last_action_results] assert ("full_attack", "downgraded:staggered") in reasons assert ("attack", "ok") in reasons def test_disabled_move_action_costs_no_hp() -> None: hero = _combatant("hero") foe = _combatant("foe") def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="move", target_id="foe"),) return (Action(kind="wait"),) eng = _engine( [hero, foe], [(0, 0), (0, 7)], ["players", "monsters"], [], policy=policy, ) s = eng._states[0] s.hp = 0 eng._current_round = 1 eng._take_turn(s) # A move action is not strenuous: the disabled creature stays at 0 hp. assert s.hp == 0 assert not any("costs 1 hp" in ln for ln in eng._transcript) def _disabled_one_action_policy(action: Action) -> PolicyFn: def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "hero": return (action,) return (Action(kind="wait"),) return policy def test_disabled_retrieve_weapon_costs_no_hp() -> None: # PF1e (CRB, Disabled): "Taking move actions doesn't risk further injury." # Drawing/retrieving a weapon is a move action, so it must not cost 1 hp. hero = _combatant("hero") foe = _combatant("foe") eng = _engine( [hero, foe], [(0, 0), (0, 7)], ["players", "monsters"], [], policy=_disabled_one_action_policy( Action(kind="retrieve_weapon", weapon_id="hero-sword") ), ) s = eng._states[0] s.hp = 0 s.weapon_stolen.add("hero-sword") # make the retrieve meaningful eng._current_round = 1 eng._take_turn(s) assert s.hp == 0 assert "hero-sword" not in s.weapon_stolen # the retrieve ran assert not any("costs 1 hp" in ln for ln in eng._transcript) def test_disabled_repair_weapon_costs_no_hp() -> None: # Mending a broken weapon is likewise a move action: no hp cost while 0 hp. hero = _combatant("hero") foe = _combatant("foe") eng = _engine( [hero, foe], [(0, 0), (0, 7)], ["players", "monsters"], [], policy=_disabled_one_action_policy( Action(kind="repair_weapon", weapon_id="hero-sword") ), ) s = eng._states[0] s.hp = 0 s.weapon_damage["hero-sword"] = 10 # a broken weapon, repairable s.weapon_broken.add("hero-sword") eng._current_round = 1 eng._take_turn(s) assert s.hp == 0 assert "hero-sword" not in s.weapon_broken # the repair ran assert not any("costs 1 hp" in ln for ln in eng._transcript) # ── B12: death threshold is -CON, no -10 floor ─────────────────────────────── def test_death_at_minus_con_no_floor() -> None: scrawny = _combatant("scrawny", con=8) hardy = _combatant("hardy", con=16) eng = _engine([scrawny, hardy], [(0, 0), (0, 4)], ["players", "monsters"], []) s, h = eng._states s.hp = -7 assert s.dead is False s.hp = -8 assert s.dead is True # hp <= -CON h.hp = -10 assert h.dead is False # no -10 rule in PF1e h.hp = -16 assert h.dead is True # ── B13: maneuvers provoke an AoO from the target ──────────────────────────── def test_maneuver_provokes_target_aoo_before_cmb() -> None: a = _combatant("a") b = _combatant("b") eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [1, 12]) eng._execute_maneuver(eng._states[0], eng._states[1], "trip") lines = "\n".join(eng._transcript).lower() # The target's AoO (d20 1 — natural 1 miss) resolves BEFORE the CMB check. assert "aoo vs a" in lines assert lines.index("aoo vs a") < lines.index("trip vs b") assert any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) def test_maneuver_aoo_can_drop_attacker_and_forfeit_trip() -> None: a = _combatant("a", hp=3) b = _combatant("b", attacks=(_sword("b", bonus=5),)) # b's AoO: d20 15 + 5 = 20 vs AC 10 -> HIT, damage 6 -> a at -3 (dying). eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [15, 6]) eng._execute_maneuver(eng._states[0], eng._states[1], "trip") assert eng._states[0].hp == -3 assert eng._states[0].active is False # The CMB check never happened: the queue is exhausted exactly by the AoO. assert not eng._rng._queue assert not any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) def test_improved_trip_skips_the_provoking_aoo() -> None: a = _combatant("a", features=("Improved Trip",)) b = _combatant("b") eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [12]) eng._execute_maneuver(eng._states[0], eng._states[1], "trip") assert not any("aoo" in ln.lower() for ln in eng._transcript) assert any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) def test_escape_does_not_provoke() -> None: a = _combatant("a") b = _combatant("b") eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [20]) _add_condition(eng._states[0], "grappled") eng._states[0].grappled_with = "b" eng._execute_maneuver(eng._states[0], eng._states[1], "escape") # Exactly one roll consumed (the escape check): no AoO die was spent. assert not eng._rng._queue assert eng._states[0].grappled_with is None # ── B15: DR "and" bypass requires every component ──────────────────────────── def test_dr_and_bypass_requires_all_components() -> None: lich_dr = DamageReduction( amount=15, bypass=frozenset({"bludgeoning", "magic"}) ) lich = _combatant("lich", dr=lich_dr) eng = _engine([lich], [(0, 0)], ["monsters"], []) # A mundane bludgeoning weapon does NOT bypass DR 15/bludgeoning and magic. assert eng._apply_dr(20, _club("m"), eng._states[0]) == 5 # Only an attack carrying EVERY component bypasses (model gap: weapons # carry no "magic" property, so this stays unreachable for real gear). assert eng._apply_dr( 20, _club("e", types=("bludgeoning", "magic")), eng._states[0] ) == 20 def test_dr_single_component_bypass_unchanged() -> None: dr = DamageReduction(amount=5, bypass=frozenset({"slashing"})) fighter = _combatant("f", dr=dr) eng = _engine([fighter], [(0, 0)], ["monsters"], []) assert eng._apply_dr(10, _sword("w"), eng._states[0]) == 10 assert eng._apply_dr(10, _club("w"), eng._states[0]) == 5 # ── B17: natural 20 auto-confirms a critical ───────────────────────────────── def test_natural_twenty_auto_confirms_crit() -> None: atk = _combatant("atk") tgt = _combatant("tgt", ac=99) eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [20, 20, 6]) result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("atk")) # Threaten on nat 20; confirm nat 20 is an automatic hit even though # 20 + 5 = 25 < AC 99 (old engine: no crit). assert result.hit is True assert result.crit is True assert result.damage == 12 # ── B18: natural 20 auto-succeeds a CMB check ──────────────────────────────── def test_maneuver_natural_twenty_auto_succeeds() -> None: a = _combatant("a", features=("Improved Trip",)) tgt = _combatant("tgt", cmd=99) eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [20]) eng._execute_maneuver(eng._states[0], eng._states[1], "trip") # 20 + CMB 1 = 21 << CMD 99, yet the natural 20 succeeds (attack roll). assert any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) def test_maneuver_natural_one_still_fails() -> None: a = _combatant("a", features=("Improved Trip",)) tgt = _combatant("tgt", cmd=2) eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [1]) eng._execute_maneuver(eng._states[0], eng._states[1], "trip") assert not any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) # ── B19: charge/withdraw respect moved_this_turn ───────────────────────────── def test_charge_after_move_is_noop() -> None: a = _combatant("a") b = _combatant("b") eng = _engine([a, b], [(0, 0), (0, 5)], ["players", "monsters"], []) s = eng._states[0] s.moved_this_turn = True eng._current_round = 1 eng._execute(s, Action(kind="charge", target_id="b")) # The empty RNG queue would raise on any roll: the charge never ran. assert s.pos == (0, 0) assert not any("charge" in ln for ln in eng._transcript) def test_withdraw_after_move_is_noop() -> None: a = _combatant("a") b = _combatant("b") eng = _engine([a, b], [(0, 0), (0, 1)], ["players", "monsters"], []) s = eng._states[0] s.moved_this_turn = True eng._withdraw(s, eng._states[1]) assert s.pos == (0, 0) assert not any("withdraw" in ln for ln in eng._transcript) # ── B22: dex_skills modifiers reach Dex-based skill checks ─────────────────── def test_grappled_dex_skills_minus4_applies() -> None: hero = _combatant("hero") eng = _engine([hero], [(0, 0)], ["players"], []) s = eng._states[0] _add_condition(s, "grappled") assert eng._skill_static_bonus(s, "stealth") == -4 assert eng._skill_static_bonus(s, "acrobatics") == -4 assert eng._skill_static_bonus(s, "escape_artist") == -4 # Wisdom (and other non-Dex) skills are untouched by dex_skills. assert eng._skill_static_bonus(s, "perception") == 0 def test_pinned_dex_skills_minus4_applies() -> None: hero = _combatant("hero") eng = _engine([hero], [(0, 0)], ["players"], []) s = eng._states[0] _add_condition(s, "pinned") assert eng._skill_static_bonus(s, "stealth") == -4 # ── B23: undead and constructs are immune to precision damage ──────────────── def test_sneak_attack_denied_vs_undead() -> None: rogue = _combatant("rogue", level=3, features=("Sneak Attack",)) skeleton = _combatant("skel", creature_type="undead") eng = _engine([rogue, skeleton], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4]) _add_condition(eng._states[1], "flat_footed") result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("rogue")) # Weapon die only — the precision dice never roll against undead. assert result.damage == 4 assert not any("sneak" in ln for ln in eng._transcript) def test_sneak_attack_denied_vs_construct() -> None: rogue = _combatant("rogue", level=3, features=("Sneak Attack",)) golem = _combatant("golem", creature_type="construct") eng = _engine([rogue, golem], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4]) _add_condition(eng._states[1], "flat_footed") result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("rogue")) assert result.damage == 4 assert not any("sneak" in ln for ln in eng._transcript) def test_sneak_attack_still_works_vs_living_control() -> None: rogue = _combatant("rogue", level=3, features=("Sneak Attack",)) victim = _combatant("man") eng = _engine([rogue, victim], [(0, 0), (0, 1)], ["players", "monsters"], [15, 2, 3, 4]) _add_condition(eng._states[1], "flat_footed") result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("rogue")) assert result.damage == 9 assert any("sneak attack" in ln for ln in eng._transcript)