280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""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
|