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
1000 combats simulés (seed 1, plafond 100 rounds)
Victoires players : 98.1% (981) — bande 3σ : [96.8%, 99.4%]
Victoires monsters : 1.9% (19) — bande 3σ : [0.6%, 3.2%]
Victoires players : 97.8% (978) — bande 3σ : [96.4%, 99.2%]
Victoires monsters : 2.2% (22) — bande 3σ : [0.8%, 3.6%]
Nuls : 0.0% (0)
Rounds moyens : 6.6
Rounds moyens : 6.9
```
## 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
visée.
- 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
modélisé.
confirmation de critique ; couvert mou : une créature active entre
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à
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
@@ -288,7 +289,6 @@ appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, flanquement, manœuvres de combat.
- Couvert mou des créatures.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action
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
(`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget.
- `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
des attaques (couvert, ligne d'effet, pénalités de portée), états de vie,
transcripts, politique par défaut.
@@ -324,7 +325,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 189 tests
uv run pytest -q # 196 tests
uv run ruff check src tests
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
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
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
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished; soft cover from
creatures is not modeled.
Thrown weapons (5 increments max) are not distinguished.
"""
from __future__ import annotations
@@ -221,7 +222,16 @@ class CombatEngine:
penalty = self.range_penalty(weapon, dist_ft)
total = roll + weapon.attack_bonus + penalty
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
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
crit = False
+23 -7
View File
@@ -75,10 +75,12 @@ class _LineGrid:
a: Pos,
b: Pos,
blocking: Callable[[TerrainType], bool],
occupied: frozenset[Pos] = frozenset(),
) -> None:
self._grid = grid
self._exclude = frozenset({a, b})
self._blocking = blocking
self._occupied = occupied
def blocked(self, p1: Pt, p2: Pt) -> bool:
min_x = min(p1[0], p2[0])
@@ -90,9 +92,9 @@ class _LineGrid:
pos = (row, col)
if pos in self._exclude or not self._grid.in_bounds(pos):
continue
if self._blocking(self._grid.terrain_at(pos)) and _segment_hits_cell(
p1, p2, row, col
):
if (
pos in self._occupied or self._blocking(self._grid.terrain_at(pos))
) and _segment_hits_cell(p1, p2, row, col):
return True
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.
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:
return False
_ = ranged
lines = _LineGrid(grid, attacker, target, _grants_cover)
lines = _LineGrid(
grid,
attacker,
target,
_grants_cover,
occupied if ranged else frozenset(),
)
return not any(
all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target))
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
result = engine.run()
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:
grid = make_grid(["."])
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