fix(combat): resolve touch attacks against touch AC and firearms

This commit is contained in:
2026-09-05 11:28:46 +02:00
parent 8e2753e67a
commit 31d970dd5f
6 changed files with 358 additions and 27 deletions
+279
View File
@@ -0,0 +1,279 @@
"""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