Files
pf1e-simulator/tests/test_accuracy_rules.py
T
ctan d152d42af9 fix(rules): avoid the firing-into-melee penalty at 10 feet
SRD 'Attack - Shooting or Throwing into a Melee': a target at least 10 ft from the nearest friendly character lets you avoid the -4 penalty entirely (0, not -2). The -2 is reserved for targets two size categories larger than the engaged friendlies (0 at three+), which is not modeled (documented). Golden transcript unaffected (shot still misses).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:21:26 +02:00

462 lines
14 KiB
Python

"""Concealment miss chance, sneak attack, and firing into melee (Package P2)."""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import 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.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from tests.test_observation_enriched import _snapshot, make_grid, make_state
def _melee(name: str, attack_bonus: int = 5) -> AttackSpec:
return AttackSpec(
id=f"{name}-sword",
name="short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _bow(name: str, attack_bonus: int = 5) -> AttackSpec:
return AttackSpec(
id=f"{name}-bow",
name="short bow",
kind="ranged",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
range_increment_ft=60,
)
def _combatant(
id_: str,
*,
level: int = 1,
ac: int = 10,
attacks: tuple[AttackSpec, ...] = (),
features: tuple[str, ...] = (),
) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=level,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=1,
initiative_mod=0,
speed_land_ft=30,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks) or [_melee(id_)],
features=[AbilitySpec(name=f, category="class") for f in features],
)
def _engine(
combatants: list[Combatant],
positions: list[tuple[int, int]],
sides: list[str],
rng: list[int],
) -> CombatEngine:
states = [
make_state(c, side, pos)
for c, side, pos in zip(combatants, sides, positions, strict=True)
]
return CombatEngine(ScriptedRng(rng), make_grid(), states)
def _add_condition(state: CombatantState, name: str, rounds: int = 5) -> None:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME[name],
rounds_left=rounds,
source="test",
)
)
# ── Rule A: concealment miss chance ──────────────────────────────────────────
def test_concealment_blur_misses_on_low_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 10],
)
a, t = eng._states
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 0
assert t.hp == 100
assert any("concealment" in line for line in eng._transcript)
def test_concealment_blur_lands_on_high_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 50, 4],
)
a, t = eng._states
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 4
assert t.hp == 96
def test_total_concealment_triggers_at_50() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 50],
)
a, t = eng._states
_add_condition(t, "displaced")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 0
assert t.hp == 100
def test_total_concealment_denies_sneak_attack() -> None:
"""(A7) total concealment (displaced 50%) denies the sneak attack."""
sneak = _combatant("atk", level=3, features=("Sneak Attack",))
eng = _engine(
[sneak, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 90, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
_add_condition(t, "displaced")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# d20=15 hits, d100=90 > 50% lands; sneak denied by total concealment,
# so only the 1d6 weapon die (3) lands.
assert result.damage == 3
assert t.hp == 97
assert not any("sneak" in line for line in eng._transcript)
def test_partial_concealment_still_allows_sneak_attack() -> None:
"""(A7) a blurred (20%) target CAN be sneak attacked — CRB rogue text is
"cannot sneak attack while striking a creature with total concealment",
so partial concealment no longer denies the precision damage."""
sneak = _combatant("atk", level=3, features=("Sneak Attack",))
eng = _engine(
[sneak, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 90, 6, 6, 4],
)
a, t = eng._states
_add_condition(t, "flat_footed")
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# d20=15 hits, d100=90 > 20% lands; 2d6 sneak (6+6=12) + 1d6 weapon (4)
# = 16 precision-inclusive damage.
assert result.damage == 16
assert t.hp == 84
assert any("sneak attack" in line and "2d6" in line for line in eng._transcript)
def test_concealment_miss_does_not_consume_deed() -> None:
eng = _engine(
[
_combatant("atk", attacks=(_bow("atk", attack_bonus=15),)),
_combatant("tgt"),
],
[(0, 0), (0, 2)],
["players", "monsters"],
[15, 5],
)
a, t = eng._states
a.deed_up_close_and_deadly = True
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 0
assert t.hp == 100
assert a.deed_up_close_and_deadly is True
# ── Rule B: sneak attack ─────────────────────────────────────────────────────
def test_sneak_flanked_melee_dice_count() -> None:
rogue = _combatant("rogue", level=5, features=("Sneak Attack",))
eng = _engine(
[rogue, _combatant("tgt"), _combatant("ally")],
[(0, 0), (0, 1), (0, 2)],
["players", "monsters", "players"],
[15, 2, 3, 4, 5],
)
a, t, _ally = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 22
assert result.damage == 14
assert t.hp == 86
assert any("sneak attack" in line and "3d6" in line for line in eng._transcript)
def test_sneak_not_multiplied_on_crit() -> None:
rogue = _combatant("rogue", level=3, features=("Sneak Attack",))
weapon = AttackSpec(
id="rogue-sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
crit_range=20,
crit_mult=2,
)
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[20, 20, 6, 6, 2],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, weapon)
assert result.crit is True
assert result.damage == 16
assert t.hp == 84
def test_sneak_ranged_within_30ft() -> None:
rogue = _combatant(
"rogue", level=1, features=("Sneak Attack",), attacks=(_bow("rogue"),)
)
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 4)],
["players", "monsters"],
[15, 4, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 7
assert t.hp == 93
def test_sneak_ranged_beyond_30ft_denied() -> None:
rogue = _combatant(
"rogue", level=1, features=("Sneak Attack",), attacks=(_bow("rogue"),)
)
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 7)],
["players", "monsters"],
[15, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 3
assert t.hp == 97
assert not any("sneak" in line for line in eng._transcript)
def test_sneak_denied_dex_melee() -> None:
rogue = _combatant("rogue", level=1, features=("Sneak Attack",))
eng = _engine(
[rogue, _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 2, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 5
assert t.hp == 95
def test_no_sneak_without_feature() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 3],
)
a, t = eng._states
_add_condition(t, "flat_footed")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.damage == 3
assert not any("sneak" in line for line in eng._transcript)
# ── Rule C: firing into melee / Precise Shot ─────────────────────────────────
def _firing_setup(rng: list[int], *, precise: bool = False) -> CombatEngine:
archer = _combatant(
"archer",
ac=99,
attacks=(_bow("archer", attack_bonus=8),),
features=("Precise Shot",) if precise else (),
)
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
return _engine(
[archer, tgt, guard],
[(0, 0), (0, 1), (1, 1)],
["players", "monsters", "players"],
rng,
)
def test_firing_into_melee_minus4() -> None:
eng = _firing_setup([10])
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 14
assert result.melee_penalty == -4
assert result.hit is False
assert t.hp == 100
def test_firing_into_melee_avoided_when_target_10ft_away() -> None:
# PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target
# [...] is at least 10 feet away from the nearest friendly character, you
# can avoid the -4 penalty" — the 10+ ft clause is 0, not -2 (the -2 is
# reserved for targets two size categories larger, not modeled).
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
eng = _engine(
[archer, tgt, guard],
[(0, 0), (0, 2), (0, 3)],
["players", "monsters", "players"],
[9, 3],
)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 17
assert result.melee_penalty == 0
assert result.hit is True
assert result.damage == 3
def test_precise_shot_negates_penalty() -> None:
eng = _firing_setup([10, 3], precise=True)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 18
assert result.melee_penalty == 0
assert result.hit is True
assert result.damage == 3
def test_melee_attack_unaffected_by_firing_rule() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt"), _combatant("ally")],
[(0, 0), (0, 2), (0, 3)],
["players", "monsters", "players"],
[15, 3],
)
a, t, _al = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.melee_penalty == 0
assert result.total == 20
def test_firing_into_melee_shown_in_transcript() -> None:
eng = _firing_setup([10])
a, t, _g = eng._states
eng._single_attack(a, t, a.combatant.attacks[0])
line = [ln for ln in eng._transcript if "short bow" in ln][-1]
assert "8-4(melee)=14" in line
assert "MISS" in line
def test_firing_into_melee_applies_to_crit_confirm() -> None:
archer = _combatant(
"archer",
ac=99,
attacks=(
AttackSpec(
id="archer-bow",
name="short bow",
kind="ranged",
attack_bonus=8,
damage=[
DamageComponent(formula=parse_dice("1d6"), types=["piercing"])
],
crit_range=20,
range_increment_ft=60,
),
),
)
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99)
# d20=20 hits (nat 20); confirm d20=1 -> 1+8-4=5 < 15 -> no crit.
eng = _engine(
[archer, tgt, guard],
[(0, 0), (0, 1), (1, 1)],
["players", "monsters", "players"],
[20, 1, 3],
)
a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.crit is False
assert result.damage == 3
# ── Snapshot keys ────────────────────────────────────────────────────────────
def test_snapshot_sneak_dice_and_engaged() -> None:
rogue = _combatant("rogue", level=5, features=("Sneak Attack",))
brute = _combatant("brute")
distant = _combatant("distant", attacks=(_bow("distant"),))
eng = _engine(
[rogue, brute, distant],
[(0, 0), (0, 1), (0, 7)],
["players", "monsters", "players"],
[],
)
obs = _snapshot(eng, eng._states[0])
by_id = {c["id"]: c for c in obs["combatants"]}
assert by_id["rogue"]["sneak_dice"] == 3
assert by_id["brute"]["sneak_dice"] == 0
assert by_id["rogue"]["engaged_in_melee"] is True
assert by_id["brute"]["engaged_in_melee"] is True
assert by_id["distant"]["engaged_in_melee"] is False
def test_condition_definitions_carry_miss_chance() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 3)],
["players", "monsters"],
[],
)
obs = _snapshot(eng, eng._states[0])
defs = dict(obs["condition_definitions"])
assert defs["blurred"]["miss_chance"] == 20
assert defs["displaced"]["miss_chance"] == 50
assert defs["shaken"]["miss_chance"] == 0