feat(combat): grant soft cover (+4 AC) to ranged attacks through creatures

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent f67cd017e4
commit bb53d049ee
5 changed files with 129 additions and 19 deletions
+9 -8
View File
@@ -72,10 +72,10 @@ players : 3 combatants
monsters : 2 combatants monsters : 2 combatants
1000 combats simulés (seed 1, plafond 100 rounds) 1000 combats simulés (seed 1, plafond 100 rounds)
Victoires players : 98.1% (981) — bande 3σ : [96.8%, 99.4%] Victoires players : 97.8% (978) — bande 3σ : [96.4%, 99.2%]
Victoires monsters : 1.9% (19) — bande 3σ : [0.6%, 3.2%] Victoires monsters : 2.2% (22) — bande 3σ : [0.8%, 3.6%]
Nuls : 0.0% (0) Nuls : 0.0% (0)
Rounds moyens : 6.6 Rounds moyens : 6.9
``` ```
## Utilisation de la CLI ## Utilisation de la CLI
@@ -276,8 +276,9 @@ Règles modélisées :
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
visée. visée.
- Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la - Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la
confirmation de critique ; le couvert octroyé par les créatures n'est pas confirmation de critique ; couvert mou : une créature active entre
modélisé. l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
attaques de mêlée ignorent les créatures).
- Distance : pénalité cumulative de 2 par incrément de portée complet au-delà - 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 ; 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 l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
@@ -288,7 +289,6 @@ appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états. - Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, flanquement, manœuvres de combat. - Attaques d'opportunité, flanquement, manœuvres de combat.
- Couvert mou des créatures.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action - Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action
complète (charge, pas de placement…). complète (charge, pas de placement…).
@@ -305,7 +305,8 @@ appliquées dans la résolution) :
- `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins - `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins
(`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget. (`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget.
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne - `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
d'effet dans `weapon_for`, bonus de couvert +4 CA dans `resolve_attack`. 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 - `combat.py``CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet, pénalités de portée), états de vie, des attaques (couvert, ligne d'effet, pénalités de portée), états de vie,
transcripts, politique par défaut. transcripts, politique par défaut.
@@ -324,7 +325,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 189 tests uv run pytest -q # 196 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
+14 -4
View File
@@ -15,11 +15,12 @@ Phase 0 documented deviations from PF1e (conventions):
- Line of effect gates all attacks: a target fully behind blocking terrain - Line of effect gates all attacks: a target fully behind blocking terrain
cannot be attacked, and the policy moves to gain sight instead. cannot be attacked, and the policy moves to gain sight instead.
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and - Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
ranged reuse the same corner rule. ranged reuse the same corner rule. Soft cover: active creatures between
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
melee attacks ignore creatures.
- Ranged: cumulative -2 per full range increment beyond the first, up to 10 - Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls. range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished; soft cover from Thrown weapons (5 increments max) are not distinguished.
creatures is not modeled.
""" """
from __future__ import annotations from __future__ import annotations
@@ -221,7 +222,16 @@ class CombatEngine:
penalty = self.range_penalty(weapon, dist_ft) penalty = self.range_penalty(weapon, dist_ft)
total = roll + weapon.attack_bonus + penalty total = roll + weapon.attack_bonus + penalty
ac = target.combatant.ac.total ac = target.combatant.ac.total
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"): occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target
)
if has_cover(
self._grid,
attacker.pos,
target.pos,
ranged=weapon.kind == "ranged",
occupied=occupied,
):
ac += _COVER_AC_BONUS ac += _COVER_AC_BONUS
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
crit = False crit = False
+23 -7
View File
@@ -75,10 +75,12 @@ class _LineGrid:
a: Pos, a: Pos,
b: Pos, b: Pos,
blocking: Callable[[TerrainType], bool], blocking: Callable[[TerrainType], bool],
occupied: frozenset[Pos] = frozenset(),
) -> None: ) -> None:
self._grid = grid self._grid = grid
self._exclude = frozenset({a, b}) self._exclude = frozenset({a, b})
self._blocking = blocking self._blocking = blocking
self._occupied = occupied
def blocked(self, p1: Pt, p2: Pt) -> bool: def blocked(self, p1: Pt, p2: Pt) -> bool:
min_x = min(p1[0], p2[0]) min_x = min(p1[0], p2[0])
@@ -90,9 +92,9 @@ class _LineGrid:
pos = (row, col) pos = (row, col)
if pos in self._exclude or not self._grid.in_bounds(pos): if pos in self._exclude or not self._grid.in_bounds(pos):
continue continue
if self._blocking(self._grid.terrain_at(pos)) and _segment_hits_cell( if (
p1, p2, row, col pos in self._occupied or self._blocking(self._grid.terrain_at(pos))
): ) and _segment_hits_cell(p1, p2, row, col):
return True return True
return False return False
@@ -113,16 +115,30 @@ def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
) )
def has_cover(grid: Grid, attacker: Pos, target: Pos, *, ranged: bool) -> bool: def has_cover(
grid: Grid,
attacker: Pos,
target: Pos,
*,
ranged: bool,
occupied: frozenset[Pos] = frozenset(),
) -> bool:
"""Cover iff every attacker corner has at least one blocked target corner. """Cover iff every attacker corner has at least one blocked target corner.
The attacker picks their single best corner (PF1e ranged cover rule); The attacker picks their single best corner (PF1e ranged cover rule);
Phase 0 documented deviation: melee reuses the same corner rule. Phase 0 documented deviation: melee reuses the same corner rule. For
ranged attacks, squares occupied by other creatures also block the
lines (soft cover, CRB); melee ignores creatures.
""" """
if attacker == target: if attacker == target:
return False return False
_ = ranged lines = _LineGrid(
lines = _LineGrid(grid, attacker, target, _grants_cover) grid,
attacker,
target,
_grants_cover,
occupied if ranged else frozenset(),
)
return not any( return not any(
all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target)) all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target))
for corner_a in _corners(attacker) for corner_a in _corners(attacker)
+55
View File
@@ -664,3 +664,58 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
assert engine.weapon_for(states[0], states[1]) is None assert engine.weapon_for(states[0], states[1]) is None
result = engine.run() result = engine.run()
assert result.transcript[2] == "round 1 archer: move (0,0)->(0,1)" assert result.transcript[2] == "round 1 archer: move (0,0)->(0,1)"
def test_ranged_soft_cover_from_creature_between() -> None:
"""A creature between shooter and target grants soft cover (+4 AC)."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", ac=13)
states = [
make_state(archer, "players", (1, 1)),
make_state(blocker, "players", (1, 2)),
make_state(target, "monsters", (1, 4)),
]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[2], archer.attacks[0])
assert result.ac == 17 # 13 + 4 soft cover
assert result.hit is False # 12 + 4 = 16 < 17
def test_melee_soft_cover_ignored_in_resolution() -> None:
"""Melee attacks ignore creatures for soft cover (CRB: ranged only)."""
attacker = make_combatant("attacker", attack_bonus=2)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", ac=13)
states = [
make_state(attacker, "players", (1, 1)),
make_state(blocker, "players", (1, 2)),
make_state(target, "monsters", (1, 4)),
]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[2], attacker.attacks[0])
assert result.ac == 13 # melee ignores soft cover
assert result.hit is True # 12 + 2 = 14 >= 13
def test_ranged_soft_cover_shown_in_transcript() -> None:
"""Soft cover raises the AC displayed in the attack log line."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", speed=0)
states = [
make_state(archer, "players", (0, 0)),
make_state(blocker, "players", (0, 2)),
make_state(target, "monsters", (0, 4)),
]
engine = CombatEngine(ScriptedRng([10, 9, 8, 12]), make_grid(), states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: archer d20=10+0=10",
"initiative: blocker d20=9+0=9",
"initiative: target d20=8+0=8",
"round 1 archer: short bow vs target d20=12+4=16 AC 17 -> MISS",
"round 1 blocker: wait",
"round 1 target: wait",
"battle over: draw after 1 rounds",
)
+28
View File
@@ -88,3 +88,31 @@ def test_cover_melee_uses_same_corner_rule_in_phase0() -> None:
def test_cover_same_square_is_false() -> None: def test_cover_same_square_is_false() -> None:
grid = make_grid(["."]) grid = make_grid(["."])
assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False
def test_soft_cover_creature_between_grants_cover() -> None:
"""A creature between shooter and target grants cover on ranged attacks."""
grid = make_grid([".....", ".....", "....."])
occupied = frozenset({(1, 2)})
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is True
def test_soft_cover_melee_ignores_creatures() -> None:
"""Melee attacks ignore creatures for soft cover (CRB: ranged only)."""
grid = make_grid([".....", ".....", "....."])
occupied = frozenset({(1, 2)})
assert has_cover(grid, (1, 0), (1, 4), ranged=False, occupied=occupied) is False
def test_soft_cover_creature_behind_target_ignored() -> None:
"""A creature behind the target does not grant cover."""
grid = make_grid(["......", "......"])
occupied = frozenset({(1, 5)})
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is False
def test_soft_cover_creature_off_axis_ignored() -> None:
"""A creature off the attack line does not grant cover."""
grid = make_grid([".....", "....."])
occupied = frozenset({(0, 2)})
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is False