Files
pf1e-simulator/tests/test_conformance_p2.py
T
ctan 4d89305881 fix(rules): distinguish natural reach from reach weapons
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural
reach is a disk (a Large creature still threatens and strikes adjacent
squares), while a reach weapon doubles the wielder's natural reach but
'can't strike at their natural reach or less' (a ring whose dead zone is
the natural reach). The engine keyed the ring off reach_ft > 5, so
reach-10 natural attacks (giant belostomatid bite/claw, mounted
hooves) wrongly got a 5-ft dead zone.

AttackSpec gains reach_weapon (parsed from the combatant JSON); the
disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied
consistently to weapon selection, melee usability, threatened squares
and charge end placement. Esha's Lance is the only reach weapon in the
shipped data; Large natural attackers keep the default (disk).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 22:06:46 +02:00

618 lines
23 KiB
Python

"""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)