"""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_denies_sneak_attack() -> None: """(A7) a blurred (20%) target CANNOT be sneak attacked — SRD rogue text is "A rogue cannot sneak attack while striking a creature with concealment": ANY concealment denies the precision damage (the rogue must see the target well enough to pick out a vital spot).""" sneak = _combatant("atk", level=3, features=("Sneak Attack",)) eng = _engine( [sneak, _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 90, 6], ) 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; sneak denied by the 20% concealment, # so only the 1d6 weapon die (6) lands. assert result.damage == 6 assert t.hp == 94 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_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