From bb53d049eec7c62de113fbbe70d54e424999c369 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): grant soft cover (+4 AC) to ranged attacks through creatures --- README.md | 17 +++++------ src/pf1e_simulator/combat.py | 18 +++++++++--- src/pf1e_simulator/los.py | 30 +++++++++++++++----- tests/test_combat.py | 55 ++++++++++++++++++++++++++++++++++++ tests/test_los.py | 28 ++++++++++++++++++ 5 files changed, 129 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 07dc60d..df87a22 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 28dcd19..25ac2b5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/los.py b/src/pf1e_simulator/los.py index fbbc4e2..3df1d3e 100644 --- a/src/pf1e_simulator/los.py +++ b/src/pf1e_simulator/los.py @@ -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) diff --git a/tests/test_combat.py b/tests/test_combat.py index d16e75e..603a715 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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", + ) diff --git a/tests/test_los.py b/tests/test_los.py index 3b61095..1d811bf 100644 --- a/tests/test_los.py +++ b/tests/test_los.py @@ -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