fix(combat): resolve touch attacks against touch AC and firearms
This commit is contained in:
@@ -232,10 +232,11 @@ ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA.
|
||||
}
|
||||
```
|
||||
|
||||
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) :
|
||||
incrément de portée en pieds. La pénalité de portée vaut −2 par incrément
|
||||
complet au-delà du premier ; l'arme reste utilisable jusqu'à 10 incréments,
|
||||
au-delà elle n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
|
||||
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou
|
||||
`kind: touch`) : incrément de portée en pieds. La pénalité de portée vaut −2
|
||||
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
|
||||
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
|
||||
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
|
||||
Deux monstres d'exemple sont fournis : gobelin et orc.
|
||||
|
||||
### Fiches de personnages (PJ)
|
||||
@@ -328,8 +329,18 @@ Règles modélisées :
|
||||
portée.
|
||||
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
|
||||
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
|
||||
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
|
||||
(5 incréments max) ne sont pas distinguées des armes à projectiles.
|
||||
les armes ranged restent utilisables jusqu'à 10 incréments, les armes touch
|
||||
avec incrément (armes de jet, firearms) jusqu'à 5.
|
||||
- Touch AC et armes à feu : les attaques `kind: touch` résolvent leur touche
|
||||
contre la CA de contact de la cible (`ac.touch`) ; si la cible perd son bonus
|
||||
de Dextérité à la CA (flat-footed, `denies_dex_to_ac`), la CA de contact est
|
||||
amputée du modificateur de Dextérité. Les armes marquées `firearm: true`
|
||||
(pistolets) résolvent contre la CA de contact uniquement dans leur premier
|
||||
incrément de portée — au-delà, elles frappent la CA totale. Une attaque touch
|
||||
avec incrément est une attaque à distance : elle provoque des attaques
|
||||
d'opportunité depuis une case menacée, subit −4 si l'attaquant est à plat
|
||||
ventre et bénéficie du couvert mou ; un contact de mêlée (sans incrément,
|
||||
ex. le contact paralysant du liche) n'en fait rien.
|
||||
- Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will.
|
||||
1 naturel = échec automatique, 20 naturel = succès automatique. Les
|
||||
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont
|
||||
@@ -415,7 +426,8 @@ dans la résolution) :
|
||||
- `rng.py` — `SeededRng` (flux reproductible par graine) et `ScriptedRng`
|
||||
(séquence de dés scriptée pour les tests de transcripts).
|
||||
- `models.py` — schémas Pydantic v2 : `Combatant`, `AbilityScores`,
|
||||
`ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction`.
|
||||
`ACProfile`, `AttackSpec` (avec `firearm` pour les armes touch à feu),
|
||||
`DamageComponent`, `Saves`, `DamageReduction`.
|
||||
- `map.py` — `MapSpec`/`TerrainType`, chargement YAML et validation
|
||||
(dimensions, caractères connus, zones, déploiement, markers).
|
||||
- `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins
|
||||
@@ -424,10 +436,10 @@ dans la résolution) :
|
||||
d'effet dans `weapon_for`, bonus de couvert +4 CA (terrain et couvert mou
|
||||
des créatures) dans `resolve_attack`.
|
||||
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
|
||||
des attaques (couvert, ligne d'effet, pénalités de portée), états de vie,
|
||||
incantation (emplacements, concentration par situation, AoO d'incantation),
|
||||
invocation, manœuvres, capacités de classe, transcripts, politique par
|
||||
défaut.
|
||||
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
|
||||
pour les attaques touch et firearms), états de vie, incantation
|
||||
(emplacements, concentration par situation, AoO d'incantation), invocation,
|
||||
manœuvres, capacités de classe, transcripts, politique par défaut.
|
||||
- `effects.py` — système d'effets : `StatModifier` (modificateur de
|
||||
caractéristique avec type de bonus) et `resolve_modifiers` (application des
|
||||
règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait,
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Braises d'Automne (pistolet ?)",
|
||||
"kind": "ranged",
|
||||
"kind": "touch",
|
||||
"attack_bonus": 9,
|
||||
"damage": [
|
||||
{
|
||||
@@ -68,11 +68,12 @@
|
||||
"crit_mult": 4,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 20
|
||||
"range_increment_ft": 20,
|
||||
"firearm": true
|
||||
},
|
||||
{
|
||||
"name": "Adieux du Printemps (pistolet, masterwork)",
|
||||
"kind": "ranged",
|
||||
"kind": "touch",
|
||||
"attack_bonus": 10,
|
||||
"damage": [
|
||||
{
|
||||
@@ -87,7 +88,8 @@
|
||||
"crit_mult": 4,
|
||||
"reach_ft": 5,
|
||||
"count": 1,
|
||||
"range_increment_ft": 20
|
||||
"range_increment_ft": 20,
|
||||
"firearm": true
|
||||
},
|
||||
{
|
||||
"name": "Alchemist's Fire",
|
||||
|
||||
@@ -114,6 +114,7 @@ _NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
||||
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
|
||||
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
|
||||
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
|
||||
_MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments
|
||||
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
|
||||
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
|
||||
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
|
||||
@@ -510,17 +511,31 @@ class CombatEngine:
|
||||
for weapon in attacker.usable_attacks():
|
||||
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
||||
return weapon
|
||||
max_increments = (
|
||||
_MAX_TOUCH_RANGE_INCREMENTS
|
||||
if weapon.kind == "touch"
|
||||
else _MAX_RANGE_INCREMENTS
|
||||
)
|
||||
if (
|
||||
weapon.kind == "ranged"
|
||||
weapon.kind in ("ranged", "touch")
|
||||
and weapon.range_increment_ft is not None
|
||||
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
|
||||
and dist_ft <= weapon.range_increment_ft * max_increments
|
||||
):
|
||||
return weapon
|
||||
return None
|
||||
|
||||
def _is_ranged_attack(self, weapon: AttackSpec) -> bool:
|
||||
"""Ranged-style attack: projectile, or touch thrown/fired from a distance.
|
||||
|
||||
Melee touch attacks (no range increment) are not ranged-style.
|
||||
"""
|
||||
return weapon.kind == "ranged" or (
|
||||
weapon.kind == "touch" and weapon.range_increment_ft is not None
|
||||
)
|
||||
|
||||
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
|
||||
"""PF1e: -2 per full range increment beyond the first, zero within it."""
|
||||
if weapon.kind != "ranged" or weapon.range_increment_ft is None:
|
||||
if weapon.kind not in ("ranged", "touch") or weapon.range_increment_ft is None:
|
||||
return 0
|
||||
increments = dist_ft // weapon.range_increment_ft
|
||||
if dist_ft % weapon.range_increment_ft != 0:
|
||||
@@ -679,16 +694,34 @@ class CombatEngine:
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
||||
if weapon.kind == "ranged" and self._has_condition_named(attacker, "prone"):
|
||||
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
|
||||
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
||||
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt
|
||||
# Check if target denies DEX to AC (flat-footed)
|
||||
if self.has_condition_flag(target, "denies_dex_to_ac"):
|
||||
base_ac = target.combatant.ac.flat_footed
|
||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||
full_ac_base = (
|
||||
target.combatant.ac.flat_footed
|
||||
if denies_dex
|
||||
else target.combatant.ac.total
|
||||
)
|
||||
# Touch resolves vs touch AC; firearms beyond their first increment use full AC.
|
||||
if weapon.kind == "touch":
|
||||
if (
|
||||
weapon.firearm
|
||||
and weapon.range_increment_ft is not None
|
||||
and dist_ft > weapon.range_increment_ft
|
||||
):
|
||||
base_ac = full_ac_base
|
||||
elif denies_dex:
|
||||
base_ac = (
|
||||
target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||
)
|
||||
else:
|
||||
base_ac = target.combatant.ac.total
|
||||
base_ac = target.combatant.ac.touch
|
||||
else:
|
||||
base_ac = full_ac_base
|
||||
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
occupied = frozenset(
|
||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
||||
@@ -697,7 +730,7 @@ class CombatEngine:
|
||||
self._grid,
|
||||
attacker.pos,
|
||||
target.pos,
|
||||
ranged=weapon.kind == "ranged",
|
||||
ranged=self._is_ranged_attack(weapon),
|
||||
occupied=occupied,
|
||||
):
|
||||
ac += _COVER_AC_BONUS
|
||||
@@ -900,11 +933,13 @@ class CombatEngine:
|
||||
)
|
||||
|
||||
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
||||
"""Check for ranged-AoO: firing a ranged weapon in a threatened square provokes.
|
||||
"""Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes.
|
||||
|
||||
Ranged-style means projectile weapons and touch thrown/fired weapons
|
||||
(touch with a range increment); melee touch (no increment) does not provoke.
|
||||
Returns True if the attacker dropped (caller must stop attacking).
|
||||
"""
|
||||
if weapon.kind != "ranged":
|
||||
if not self._is_ranged_attack(weapon):
|
||||
return False
|
||||
for enemy in self._enemies_threatening(attacker, attacker.pos):
|
||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||
|
||||
@@ -237,7 +237,9 @@ class AttackSpec(_FrozenModel):
|
||||
|
||||
`count` captures repeated identical swings ("2x Talons") without listing
|
||||
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
||||
`range_increment_ft` to ranged/touch kinds.
|
||||
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
|
||||
weapon (gun) whose shots resolve against touch AC within the first range
|
||||
increment and against full AC beyond it.
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -251,6 +253,7 @@ class AttackSpec(_FrozenModel):
|
||||
reach_ft: int = 5
|
||||
range_increment_ft: int | None = None
|
||||
count: int = Field(default=1, ge=1)
|
||||
firearm: bool = False
|
||||
|
||||
|
||||
class DamageReduction(_FrozenModel):
|
||||
|
||||
@@ -115,7 +115,7 @@ class TestGoldenCombats:
|
||||
"(20->20)"),
|
||||
"round 6 lich: fear aura on tammara -> SAVED",
|
||||
"round 6 lich: move (4,4)->(3,3)->(2,2)->(1,1)",
|
||||
("round 6 lich: Paralyzing Touch vs tammara d20=10+10=20 AC 20 -> HIT 25 damage "
|
||||
("round 6 lich: Paralyzing Touch vs tammara d20=10+10=20 AC 16 -> HIT 25 damage "
|
||||
"(20->-5) paralyzed (11r)"),
|
||||
"round 6 lich: tammara down",
|
||||
"battle over: monsters win in 6 rounds",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user