"""Tests for touch attacks and firearms (B4). Touch weapons (kind "touch") resolve against the target's touch AC, dropping the Dexterity bonus when the target denies Dexterity to AC. Touch weapons with a range increment are usable up to 5 increments (like thrown weapons) and suffer the standard -2 range penalty; a firearm marker additionally resolves against the full AC beyond its first range increment. Ranged-style touch attacks provoke opportunity attacks, are penalized by prone, and benefit from soft cover, while melee touch attacks (no increment) do none of those. """ from __future__ import annotations from pathlib import Path from typing import Literal from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.conditions import FLAT_FOOTED, PRONE from pf1e_simulator.dice import parse_dice from pf1e_simulator.effects import ActiveCondition from pf1e_simulator.grid import Grid from pf1e_simulator.loaders.combatant import load_combatant from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng def make_combatant( cid: str, *, hp: int = 20, ac_total: int = 20, ac_touch: int = 12, ac_flat_footed: int = 18, dex: int = 10, attack_bonus: int = 5, damage: str = "1d6", kind: Literal["melee", "ranged", "touch"] = "melee", range_increment_ft: int | None = None, firearm: bool = False, speed: int = 30, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="test weapon", kind=kind, attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])], range_increment_ft=range_increment_ft, firearm=firearm, ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=dex, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=hp, ac=ACProfile(total=ac_total, touch=ac_touch, flat_footed=ac_flat_footed), bab=attack_bonus, initiative_mod=0, speed_land_ft=speed, saves=Saves(fort=0, ref=0, will=0), attacks=[attack], ) def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) def make_grid(width: int = 8) -> Grid: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * width] * 8), legend=legend) return Grid.from_spec(spec) def make_engine( queue: list[int], states: list[CombatantState], *, width: int = 8 ) -> CombatEngine: return CombatEngine(ScriptedRng(queue), make_grid(width), states) def test_melee_touch_attack_resolves_against_touch_ac() -> None: """Melee touch (lich-style) hits against touch AC, not full AC.""" lichy = make_combatant("lichy", kind="touch", attack_bonus=10, damage="1d8", hp=30) victim = make_combatant("victim", hp=20) a = make_state(lichy, "monsters", (1, 1)) t = make_state(victim, "players", (1, 2)) engine = make_engine([13, 3], [a, t]) result = engine.resolve_attack(a, t, lichy.attacks[0]) # 13 + 10 = 23 >= touch AC 12 (and >= total AC 20) assert result.hit is True assert result.ac == 12 assert result.total == 23 assert t.hp == 17 # 20 - 1d8(3) def test_touch_attack_flat_footed_drops_dex_bonus() -> None: """Flat-footed touch target: touch AC minus its Dexterity bonus.""" attacker = make_combatant("attacker", kind="touch", attack_bonus=5) target = make_combatant("target", dex=16, hp=10) # dex mod +3 a = make_state(attacker, "players", (1, 1)) t = make_state(target, "monsters", (1, 2)) t.active_conditions.append( ActiveCondition(condition=FLAT_FOOTED, rounds_left=5, source="test") ) engine = make_engine([6, 2], [a, t]) result = engine.resolve_attack(a, t, attacker.attacks[0]) # touch 12 - max(0, 3) = 9; 6 + 5 = 11 >= 9 assert result.hit is True assert result.ac == 9 assert t.hp == 8 # 10 - 1d6(2) def test_touch_weapon_with_increment_selectable_at_range() -> None: """A touch weapon with a range increment is selectable at range, with -2/increment.""" thrower = make_combatant( "thrower", kind="touch", range_increment_ft=10, attack_bonus=5 ) target = make_combatant("target", speed=0) a = make_state(thrower, "players", (0, 0)) t = make_state(target, "monsters", (0, 3)) # 15 ft -> 2 increments engine = make_engine([12, 2], [a, t]) picked = engine.weapon_for(a, t) assert picked is thrower.attacks[0] result = engine.resolve_attack(a, t, thrower.attacks[0]) # 12 + 5 - 2 = 15 >= touch AC 12 assert result.penalty == -2 assert result.hit is True assert result.ac == 12 assert t.hp == 18 # 20 - 1d6(2) def test_touch_weapon_usable_at_exactly_five_increments() -> None: """Touch weapons (thrown-like) are usable up to 5 increments, at -8.""" thrower = make_combatant( "thrower", kind="touch", range_increment_ft=10, attack_bonus=5 ) target = make_combatant("target", speed=0) a = make_state(thrower, "players", (0, 0)) t = make_state(target, "monsters", (0, 10)) # 50 ft -> exactly 5 increments engine = make_engine([8, 7], [a, t], width=24) picked = engine.weapon_for(a, t) assert picked is thrower.attacks[0] result = engine.resolve_attack(a, t, thrower.attacks[0]) assert result.penalty == -8 # -2 * (5 - 1) def test_touch_weapon_unusable_beyond_five_increments() -> None: """At 6 increments the touch weapon is dropped: max 5 increments.""" thrower = make_combatant( "thrower", kind="touch", range_increment_ft=10, attack_bonus=5 ) target = make_combatant("target", speed=0) a = make_state(thrower, "players", (0, 0)) t = make_state(target, "monsters", (0, 11)) # 55 ft -> 6 increments engine = make_engine([8, 7], [a, t], width=24) assert engine.weapon_for(a, t) is None def test_firearm_within_first_increment_resolves_touch_ac() -> None: """Firearm at <= its first range increment: touch AC, no range penalty.""" gunner = make_combatant( "gunner", kind="touch", range_increment_ft=20, firearm=True, attack_bonus=5 ) target = make_combatant("target", speed=0) a = make_state(gunner, "players", (0, 0)) t = make_state(target, "monsters", (0, 3)) # 15 ft <= first 20 ft increment engine = make_engine([13, 2], [a, t]) result = engine.resolve_attack(a, t, gunner.attacks[0]) # 13 + 5 = 18 >= touch AC 12 but < total AC 20 assert result.hit is True assert result.ac == 12 assert t.hp == 18 # 20 - 1d6(2) def test_firearm_beyond_first_increment_resolves_full_ac() -> None: """Firearm beyond the first range increment: full AC plus range penalty.""" gunner = make_combatant( "gunner", kind="touch", range_increment_ft=20, firearm=True, attack_bonus=5 ) target = make_combatant("target", speed=0) a = make_state(gunner, "players", (0, 0)) t = make_state(target, "monsters", (0, 9)) # 45 ft -> 3 increments engine = make_engine([15, 3], [a, t], width=24) result = engine.resolve_attack(a, t, gunner.attacks[0]) # 15 + 5 - 4 = 16: >= touch AC 12 but < full AC 20 -> misses on full AC assert result.penalty == -4 # -2 * (3 - 1) assert result.hit is False assert result.ac == 20 # full AC, not touch def test_touch_ranged_attack_provokes_aoo() -> None: """Firing a ranged-style touch weapon from a threatened square provokes an AoO.""" thrower = make_combatant( "thrower", kind="touch", range_increment_ft=10, attack_bonus=5 ) foe = make_combatant("foe", attack_bonus=2, damage="1d4", hp=10) # melee, threatens a = make_state(thrower, "players", (1, 1)) f = make_state(foe, "monsters", (1, 2)) engine = make_engine([4, 3], [a, f]) # AoO roll 4 + 2 = 6 < 20 -> miss assert engine._check_provoked_aoo(a, thrower.attacks[0]) is False # survives assert any("AoO vs thrower" in line for line in engine._transcript) def test_melee_touch_attack_does_not_provoke_aoo() -> None: """Melee touch (no increment) never provokes an AoO.""" lichy = make_combatant("lichy", kind="touch", attack_bonus=10, hp=30) foe = make_combatant("foe", hp=10) a = make_state(lichy, "monsters", (1, 1)) f = make_state(foe, "players", (1, 2)) engine = make_engine([4, 3], [a, f]) assert engine._check_provoked_aoo(a, lichy.attacks[0]) is False assert not any("AoO vs lichy" in line for line in engine._transcript) def test_prone_touch_ranged_attack_penalty_minus4() -> None: """A prone attacker takes -4 on ranged-style touch attacks.""" hero = make_combatant( "hero", kind="touch", range_increment_ft=10, attack_bonus=5 ) foe = make_combatant("foe") a = make_state(hero, "players", (1, 1)) f = make_state(foe, "monsters", (1, 2)) a.active_conditions.append( ActiveCondition(condition=PRONE, rounds_left=5, source="test") ) engine = make_engine([15, 4], [a, f]) result = engine.resolve_attack(a, f, hero.attacks[0]) # 15 + 5 - 4 (prone) = 16, no range penalty at 5 ft assert result.total == 16 def test_touch_ranged_attack_gets_soft_cover() -> None: """Ranged-style touch attacks are blocked by soft cover (+4 AC).""" thrower = make_combatant( "thrower", kind="touch", range_increment_ft=10, attack_bonus=5 ) blocker = make_combatant("blocker", speed=0) target = make_combatant("target", speed=0) a = make_state(thrower, "players", (1, 1)) b = make_state(blocker, "players", (1, 2)) t = make_state(target, "monsters", (1, 4)) engine = make_engine([12, 3], [a, b, t]) result = engine.resolve_attack(a, t, thrower.attacks[0]) # touch 12 + 4 soft cover = 16; 12 + 5 - 2 = 15 < 16 assert result.ac == 16 assert result.hit is False def test_tammara_pistols_are_firearm_touch_attacks() -> None: """Tammara's pistols are touch attacks with the firearm marker; the AF is not.""" tammara = load_combatant( Path(__file__).resolve().parents[1] / "data" / "pcs" / "tammara.json" ) pistols = [w for w in tammara.attacks if "pistolet" in w.name.lower()] assert len(pistols) >= 2 for weapon in pistols: assert weapon.kind == "touch" assert weapon.firearm is True assert weapon.range_increment_ft == 20 alchemist_fire = next(w for w in tammara.attacks if w.name.startswith("Alchemist")) assert alchemist_fire.kind == "touch" assert alchemist_fire.firearm is False