d152d42af9
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>
462 lines
14 KiB
Python
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
|