fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak)

This commit is contained in:
2026-09-10 21:45:50 +02:00
parent 5afe495c4a
commit 9aee4f6f6b
15 changed files with 934 additions and 134 deletions
+31 -18
View File
@@ -240,7 +240,7 @@ def test_count_two_resolves_two_swings() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine(
[10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 3], [a, b]
[10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 4], [a, b]
)
result = engine.run()
assert result.winner == "players"
@@ -254,12 +254,12 @@ def test_count_two_resolves_two_swings() -> None:
"round 2 a: short sword #1 vs b d20=13+2=15 AC 13 -> HIT 4 damage (9->5)",
"round 2 a: short sword #2 vs b d20=11+2=13 AC 13 -> HIT 2 damage (5->3)",
"round 2 b: short sword vs a d20=8+2=10 AC 13 -> MISS",
"round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (3->0)",
"round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 4 damage (3->-1)",
"round 3 a: b down",
"battle over: players win in 3 rounds",
)
assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=12, damage_taken=0)
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=12)
assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=13, damage_taken=0)
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=13)
def test_count_swings_stop_when_target_drops() -> None:
@@ -310,16 +310,18 @@ def test_hp_states_active_down_dead() -> None:
assert st.active is True
assert st.dead is False
st.hp = 0
assert st.active is False
# B11 conformance: exactly 0 hp is DISABLED — active (single action round).
assert st.active is True
assert st.dead is False
st.hp = -1
assert st.active is False # dying
assert st.dead is False
st.hp = -11
assert st.active is False
assert st.dead is False
# B12 conformance (CRB): death when negative hp reaches CON (hp <= -12).
st.hp = -12
assert st.active is False
assert st.dead is False # dead requires hp < -12, not <=
st.hp = -13
assert st.active is False
assert st.dead is True
@@ -556,7 +558,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1])
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 4, 12, 2, 8, 2, 14, 1])
engine = CombatEngine(rng, grid, states, round_cap=4)
result = engine.run()
assert result.winner == "players"
@@ -568,7 +570,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
"round 1 target: wait",
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 target: wait",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 3 damage (3->0)",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 4 damage (3->-1)",
"round 3 archer: target down",
"battle over: players win in 3 rounds",
)
@@ -842,24 +844,31 @@ def test_diagonal_flanking() -> None:
def test_flanking_requires_active_ally() -> None:
"""A downed ally on the opposite side does not grant flanking."""
"""A downed ally (below 0 hp, dying) on the opposite side does not flank.
B11 conformance: an ally at exactly 0 hp is disabled but active — it
would flank; only hp < 0 removes it from the fight."""
atk = make_combatant("atk", attack_bonus=2, speed=0)
aly = make_combatant("aly", attack_bonus=0, speed=0)
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
aly_state = make_state(aly, "players", (5, 4))
aly_state.hp = 0
aly_state.hp = -1
states = [
make_state(atk, "players", (3, 4)),
aly_state,
make_state(tgt, "monsters", (4, 4)),
]
engine = make_engine([10, 9, 8, 12, 5], states, round_cap=1)
engine = make_engine([10, 9, 8, 12, 5, 5], states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: atk d20=10+0=10",
"initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8",
"round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS",
(
"round 1 aly: dying — Constitution check d20(5) + CON(1) - 1 = 5 "
"vs DC 10 failed, loses 1 hp (hp -1 -> -2)"
),
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds",
)
@@ -935,9 +944,13 @@ def test_full_attack_bab11_three_iteratives() -> None:
def test_full_attack_stops_on_target_down() -> None:
"""Remaining iteratives are lost when the target drops mid-full-attack."""
"""Remaining iteratives are lost when the target drops mid-full-attack.
The scripted blow takes tgt below 0 (hp 4 - 5 = -1): a target at exactly
0 hp is disabled but still ACTIVE (B11 conformance) and would not drop.
"""
war = make_combatant("war", attack_bonus=6, hp=10, speed=0)
tgt = make_combatant("tgt", hp=5, ac=15, attack_bonus=0, speed=0)
tgt = make_combatant("tgt", hp=4, ac=15, attack_bonus=0, speed=0)
states = [
make_state(war, "players", (3, 4)),
make_state(tgt, "monsters", (4, 4)),
@@ -947,7 +960,7 @@ def test_full_attack_stops_on_target_down() -> None:
assert result.transcript == (
"initiative: war d20=10+0=10",
"initiative: tgt d20=8+0=8",
"round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 5 damage (5->0)",
"round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 5 damage (4->-1)",
"round 1 war: tgt down",
"battle over: players win in 1 rounds",
)
@@ -1020,7 +1033,7 @@ def test_aoo_drops_mover() -> None:
make_state(dest, "monsters", (4, 5)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3]),
ScriptedRng([10, 9, 8, 15, 4]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
@@ -1029,7 +1042,7 @@ def test_aoo_drops_mover() -> None:
"initiative: runner d20=10+0=10",
"initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 4 damage (3->-1)",
"round 1 guard: runner down",
"battle over: monsters win in 1 rounds",
)
+4 -2
View File
@@ -38,9 +38,11 @@ def test_blinded_attacker_misses_on_low_d100() -> None:
a, t = eng._states
_add_condition(a, "blinded")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# roll 15 + bonus 5 - 2 (blinded) = 18 beats AC 10, then d100 30 <= 50 misses.
# roll 15 + bonus 5 = 20 beats AC 10 (B10 conformance: blinded carries NO
# flat attack penalty — the 50% total-concealment miss below is the RAW
# penalty), then d100 30 <= 50 misses.
assert result.hit is True
assert result.total == 18
assert result.total == 20
assert result.damage == 0
assert t.hp == 100
assert any("attacker blinded" in line and "misses" in line for line in eng._transcript)
+565
View File
@@ -0,0 +1,565 @@
"""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,
)
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)
# ── 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)
+13 -10
View File
@@ -63,6 +63,9 @@ def make_grid() -> Grid:
def make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): the scripted queues carry a leading natural 1
# so the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -73,7 +76,7 @@ def test_reposition_direction_right() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
assert b.pos == (1, 3)
@@ -83,7 +86,7 @@ def test_reposition_direction_down() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(1, 0))
assert b.pos == (2, 2)
@@ -95,7 +98,7 @@ def test_drag_direction_up() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag", direction=(-1, 0))
assert b.pos == (0, 2)
assert a.pos == (1, 2)
@@ -108,7 +111,7 @@ def test_bull_rush_direction_left() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "bull_rush", direction=(0, -1))
assert b.pos == (1, 1)
@@ -120,7 +123,7 @@ def test_reposition_direction_out_of_bounds_no_move() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(-1, 0))
assert b.pos == (0, 0)
@@ -130,7 +133,7 @@ def test_reposition_direction_occupied_no_move() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
# direction points at the attacker's own cell -> occupied -> no move
engine._execute_maneuver(a, b, "reposition", direction=(0, -1))
assert b.pos == (1, 2)
@@ -141,7 +144,7 @@ def test_bull_rush_direction_out_of_bounds_no_move() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "bull_rush", direction=(-1, 0))
assert b.pos == (0, 0)
@@ -153,7 +156,7 @@ def test_reposition_none_uses_first_free_neighbor() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition")
# First free neighbor in _NEIGHBOR_OFFSETS order, skipping attacker's cell.
assert b.pos == (0, 1)
@@ -164,7 +167,7 @@ def test_drag_none_uses_beyond_cell() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag")
# Beyond the target from the attacker: (1, 3).
assert b.pos == (1, 3)
@@ -176,7 +179,7 @@ def test_bull_rush_none_uses_away_from_attacker() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
# Away from attacker (up, since target is above): (0, 2).
assert b.pos == (0, 2)
+9 -6
View File
@@ -63,6 +63,9 @@ def make_grid() -> Grid:
def make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): the scripted queues carry a leading natural 1
# so the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -77,7 +80,7 @@ def test_dirty_trick_blinded() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == ["blinded"]
@@ -89,7 +92,7 @@ def test_dirty_trick_default_dazzled() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert _condition_names(b) == ["dazzled"]
@@ -101,7 +104,7 @@ def test_dirty_trick_deafened() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="deafened")
assert _condition_names(b) == ["deafened"]
@@ -111,7 +114,7 @@ def test_dirty_trick_entangled() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="entangled")
assert _condition_names(b) == ["entangled"]
@@ -121,7 +124,7 @@ def test_dirty_trick_nauseated() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="nauseated")
assert _condition_names(b) == ["nauseated"]
@@ -133,7 +136,7 @@ def test_dirty_trick_failure_no_condition() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1], [a, b]) # roll 1 -> automatic failure
engine = make_engine([1, 1], [a, b]) # AoO miss, then roll 1 -> automatic failure
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == []
+5 -6
View File
@@ -156,19 +156,18 @@ def test_stabilized_then_healed_resets_stabilization() -> None:
def test_dying_bleeds_to_death_threshold() -> None:
eng, s = _solo(con_score=10) # death threshold: hp < min(-10, -10) = -10
# PF1e (B12 conformance): death when negative hp reaches the CON score
# (hp <= -CON = -10 here); there is no -10 floor.
eng, s = _solo(con_score=10)
s.hp = -9
eng._rng = ScriptedRng([1, 1]) # two failed checks: -9 -> -10 -> -11
eng._rng = ScriptedRng([1]) # failed check: -9 -> -10 reaches the threshold
eng._process_dying(s)
assert s.hp == -10
assert not s.dead
eng._process_dying(s)
assert s.hp == -11
assert s.dead
# Dead creatures never roll again (empty queue would raise if they did).
eng._rng = ScriptedRng([])
eng._process_dying(s)
assert s.hp == -11
assert s.hp == -10
def test_dying_tick_runs_on_initiative_slot_during_round() -> None:
+15 -11
View File
@@ -66,6 +66,9 @@ def make_grid() -> Grid:
def make_engine(
queue: list[int], states: list[CombatantState]
) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): scripted queues carry a leading natural 1 so
# the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -75,7 +78,7 @@ def test_sunder_damages_weapon_not_hp() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "sunder")
assert b.hp == hp_before
assert b.weapon_damage["b-w"] == 2
@@ -87,7 +90,8 @@ def test_sunder_destroys_weapon_and_disarms() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12, 12, 12, 12, 12], [a, b])
# Only the first sunder provokes: b's per-round AoO allowance is spent.
engine = make_engine([1, 12, 12, 12, 12, 12], [a, b])
for _ in range(5):
engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6
@@ -123,7 +127,7 @@ def test_overrun_knocks_prone_and_moves_through() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1
@@ -138,7 +142,7 @@ def test_overrun_prone_but_no_space() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1)
@@ -151,7 +155,7 @@ def test_dirty_trick_dazzles() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
assert dazzled.rounds_left == 1
@@ -163,7 +167,7 @@ def test_dirty_trick_fails_on_natural_1() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1], [a, b])
engine = make_engine([1, 1], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert not any(c.condition.name == "dazzled" for c in b.active_conditions)
assert not any(c.condition.name == "prone" for c in b.active_conditions)
@@ -176,7 +180,7 @@ def test_steal_takes_melee_weapon() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None
@@ -201,7 +205,7 @@ def test_reposition_moves_to_first_free_neighbor() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (0, 1)
assert c.pos == (1, 3)
@@ -217,7 +221,7 @@ def test_reposition_no_space() -> None:
]
a = make_state(a_spec, "players", (2, 1))
b = make_state(b_spec, "monsters", (1, 1))
engine = make_engine([12], [a, b, *blockers])
engine = make_engine([1, 12], [a, b, *blockers])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (1, 1)
assert "no space" in engine._transcript[-1]
@@ -228,7 +232,7 @@ def test_drag_moves_pair_together() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 3)
assert a.pos == (1, 2)
@@ -241,7 +245,7 @@ def test_drag_no_space() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 2)
assert a.pos == (1, 1)
+6 -3
View File
@@ -825,14 +825,17 @@ def test_observation_enriched_threatened_bounded_to_grid() -> None:
def test_observation_enriched_threatened_ignores_inactive_enemy() -> None:
"""Given: an enemy whose hp <= 0 (inactive)
"""Given: an enemy whose hp < 0 (dying, inactive)
When: an Observation is built
Then: threatened has no True cells (inactive enemies do not threaten)."""
Then: threatened has no True cells (inactive enemies do not threaten).
B11 conformance: at exactly 0 hp a creature is disabled but still active
and threatening — downing it requires hp < 0."""
actor = make_combatant("actor")
enemy = make_combatant("enemy")
actor_state = make_state(actor, "players", (0, 0))
enemy_state = make_state(enemy, "monsters", (0, 3))
enemy_state.hp = 0
enemy_state.hp = -1
engine = make_engine([actor_state, enemy_state])
obs = _snapshot(engine, actor_state)
+4 -2
View File
@@ -233,11 +233,13 @@ def test_stand_up_removes_trip_applied_prone() -> None:
hero = make_state(_combatant("hero"), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 1))
eng = CombatEngine(
ScriptedRng([10, 4, 3]), make_grid(), [hero, foe],
ScriptedRng([1, 10, 4, 3]), make_grid(), [hero, foe],
policy=lambda _e, _s: _WAIT, round_cap=1,
)
eng._current_round = 1
# Real trip handler: d20 10 + CMB 1 = 11 vs CMD 11 -> prone (active_conditions).
# The prone hero threatens, so the trip provokes its AoO first (d20 1 ->
# natural 1 miss); then the real trip handler: d20 10 + CMB 1 = 11 vs
# CMD 11 -> prone (active_conditions).
eng._execute_maneuver(foe, hero, "trip")
assert "trip vs hero d20=10+1=11 CMD 11 -> SUCCESS (prone)" in "\n".join(
eng._transcript
+1 -1
View File
@@ -186,7 +186,7 @@ def test_ranged_threat_empty_without_ranged_enemies() -> None:
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0)
)
archer.hp = 0 # inactive: hp <= 0
archer.hp = -1 # inactive: hp < 0 (0 hp would be disabled-but-active, B11)
hero = make_state(_combatant("hero", _sword("hero")), "players", (4, 4))
engine = _engine([hero, melee_foe, archer], _open_rows())
+1 -1
View File
@@ -144,7 +144,7 @@ def test_sunder_then_repair_restores_weapon() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12] * 5, [a, b])
engine = make_engine([1] + [12] * 5, [a, b])
for _ in range(5):
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder"))
assert "b-w" in b.weapon_broken
+2 -2
View File
@@ -77,7 +77,7 @@ def test_retrieve_restores_stolen_weapon() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen
assert "b-w" not in _weapon_ids(b)
@@ -121,7 +121,7 @@ def test_steal_then_retrieve_restores_weapon() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None