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
+23 -11
View File
@@ -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,
+6 -4
View File
@@ -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",
+45 -10
View File
@@ -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):
+4 -1
View File
@@ -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):
+1 -1
View File
@@ -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",
+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