fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak)
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user