diff --git a/README.md b/README.md index 09df59b..47ace63 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index c5154be..2da8855 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -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", diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 5bf0203..b2f96cf 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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.touch else: - base_ac = target.combatant.ac.total + 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): diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index c5438ed..783b4b0 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -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): diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index 4e0f2cb..b018496 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -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", diff --git a/tests/test_touch_ac.py b/tests/test_touch_ac.py new file mode 100644 index 0000000..a3bbcaf --- /dev/null +++ b/tests/test_touch_ac.py @@ -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