feat(combat): add concealment miss chance, sneak attack, and Precise Shot
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
"""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_concealment_denies_sneak_attack() -> None:
|
||||
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, "blurred")
|
||||
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_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_minus2_when_target_10ft_away() -> None:
|
||||
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 == 15
|
||||
assert result.melee_penalty == -2
|
||||
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
|
||||
Reference in New Issue
Block a user