diff --git a/README.md b/README.md index c81fa09..700b94e 100644 --- a/README.md +++ b/README.md @@ -491,11 +491,16 @@ Règles modélisées : - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne 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 le bonus de couvert du terrain - (+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la - 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). + - Couvert (règle des coins) : la cible gagne le bonus de couvert du terrain + (+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la + 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). Conformance B20 (CRB « Cover », + règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** — + une créature multi-cases (Large +) ne subit que le couvert de SA meilleure + case occupée (minimum sur ses cases), pas le pire. Le côté cible reste + évalué par la règle RAW : toute ligne vers tout coin des cases de la cible + compte (maximum sur les coins de la cible). - Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir « Touch AC ») si un allié actif menace réellement la cible depuis la bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une @@ -703,7 +708,10 @@ Règles modélisées : Les attaques de mêlée ne sont pas concernées. - Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le - couvert mou des créatures reste +4 (couvert standard). + couvert mou des créatures reste +4 (couvert standard). B20 (conformance) : + pour un attaquant multi-cases, le bonus effectif est le **meilleur** (plus + faible) couvert parmi ses cases occupées — l'attaquant choisit son meilleur + coin/case selon le CRB ; les créatures 1×1 sont inchangées. - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` (`_expire_summons` décrémente puis retire). diff --git a/src/pf1e_simulator/los.py b/src/pf1e_simulator/los.py index 59320e5..7595423 100644 --- a/src/pf1e_simulator/los.py +++ b/src/pf1e_simulator/los.py @@ -254,9 +254,12 @@ def cover_bonus_cells( """Effective cover bonus (0/2/4/8) for an attack between two sets of cells. 0 when there is no cover (see has_cover_cells for the corner rule); - otherwise the highest cover level among the cells blocking the - attacker-corner to target-corner lines. Interposed creatures grant soft - cover (+4) on ranged attacks only, exactly as in has_cover_cells. + otherwise the cover level of the attacker's BEST occupied cell: each + attacker cell is scored by the per-cell corner rule against all of the + target's cells, and the attacker picks the lowest-scoring (clearest) + cell. Interposed creatures grant soft cover (+4) on ranged attacks only, + exactly as in has_cover_cells. For a 1x1 attacker this is exactly the + single-cell value. """ if not attacker_cells or not target_cells: return 0 @@ -271,10 +274,18 @@ def cover_bonus_cells( occupied if ranged else frozenset(), bonus_of=terrain_cover_bonus, ) - return max( - lines.bonus(corner_a, corner_t) + # PF1e CRB "Cover", rule of corners: the attacker chooses the corner (and, + # for a multi-square creature, the occupied square) that gives it the best + # shot, so it suffers only the MINIMUM cover over its own cells. The + # target side is unchanged: RAW checks ANY line to ANY target corner, so + # each attacker cell is scored by the MAXIMUM over its corner lines. + per_cell_best = ( + max( + lines.bonus(corner_a, corner_t) + for corner_a in _corners(cell_a) + for cell_t in target_cells + for corner_t in _corners(cell_t) + ) for cell_a in attacker_cells - for corner_a in _corners(cell_a) - for cell_t in target_cells - for corner_t in _corners(cell_t) ) + return min(per_cell_best) diff --git a/tests/test_conformance_cover.py b/tests/test_conformance_cover.py new file mode 100644 index 0000000..076ddd3 --- /dev/null +++ b/tests/test_conformance_cover.py @@ -0,0 +1,152 @@ +"""Conformance B20: cover uses the ATTACKER's best corner/square. + +PF1e CRB "Cover" (rule of corners): the attacker chooses the corner of its +square (and, for a multi-square creature, the occupied square) that gives it +the best shot, so a multi-cell attacker suffers the MINIMUM cover over its +own cells, not the maximum. The target side is unchanged: RAW checks ANY +line to ANY target corner, so each attacker cell is scored by the maximum +over its corner lines. 1x1 attackers are bit-identical to the old behavior. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import CombatEngine +from pf1e_simulator.grid import Grid +from pf1e_simulator.los import cover_bonus_cells +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.rng import ScriptedRng +from tests.test_combat import make_combatant, make_state + +_FLOOR = TerrainType(type="floor", move_cost=1) +_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True) +_PARTIAL = TerrainType(type="ruin", move_cost=None, cover_bonus=2) + + +def _grid(rows: tuple[str, ...]) -> Grid: + legend = {".": _FLOOR, "#": _WALL, "W": _PARTIAL} + return Grid.from_spec(MapSpec(name="b20", terrain=rows, legend=legend)) + + +# Two-cell attacker {(0,0),(1,0)} shooting right at target (1,4): +# the +2 ruins at (0,2)/(1,2) sit on the top cell's lines, the +4 wall at +# (2,1) additionally sits on the bottom cell's low lines. +_CORNER_GRID = ("..W..", "..W..", ".#...", ".....") + + +# --------------------------------------------------------------------------- +# (a) multi-cell attacker picks its best square: minimum, not maximum +# --------------------------------------------------------------------------- + + +def test_two_cell_attacker_uses_best_cell_cover() -> None: + grid = _grid(_CORNER_GRID) + top = frozenset({(0, 0)}) + bottom = frozenset({(1, 0)}) + target = frozenset({(1, 4)}) + # Per-cell scores pin the geometry: the top cell only sees +2 ruins, + # the bottom cell's low lines also graze the +4 wall. + assert cover_bonus_cells(grid, top, target, ranged=True) == 2 + assert cover_bonus_cells(grid, bottom, target, ranged=True) == 4 + # B20 fix: the attacker picks its best square -> min(2, 4) = 2. + assert ( + cover_bonus_cells(grid, top | bottom, target, ranged=True) == 2 + ) + + +def test_two_cell_attacker_best_cell_alone_is_unchanged() -> None: + grid = _grid(_CORNER_GRID) + target = frozenset({(1, 4)}) + # A single-square attacker is the golden path: exactly its own score. + assert cover_bonus_cells(grid, frozenset({(0, 0)}), target, ranged=True) == 2 + assert cover_bonus_cells(grid, frozenset({(1, 0)}), target, ranged=True) == 4 + + +# --------------------------------------------------------------------------- +# (b) 1x1 attacker vs 2x2 target: target side keeps the max over corners +# --------------------------------------------------------------------------- + + +def test_single_attacker_vs_large_target_pins_cover_level() -> None: + grid = _grid(("......", "..W...", "......")) + attacker = frozenset({(1, 0)}) + large_target = frozenset({(0, 4), (0, 5), (1, 4), (1, 5)}) + # Cover is evaluated over ALL target corners: the big target keeps the + # +2 it gains from the ruin, exactly as before the B20 fix. + assert cover_bonus_cells(grid, attacker, large_target, ranged=True) == 2 + assert cover_bonus_cells(grid, attacker, frozenset({(1, 4)}), ranged=True) == 2 + + +# --------------------------------------------------------------------------- +# (c) regression: single-square attacker vs wall still gets exactly +4 +# --------------------------------------------------------------------------- + + +def test_single_square_attacker_wall_cover_unchanged() -> None: + grid = _grid((".....", "..#..", ".....")) + assert ( + cover_bonus_cells( + grid, frozenset({(1, 0)}), frozenset({(1, 4)}), ranged=True + ) + == 4 + ) + + +# --------------------------------------------------------------------------- +# (d) engine: Large attacker resolves with its best cell; snapshot agrees +# --------------------------------------------------------------------------- + + +def _large_archer_engine() -> CombatEngine: + grid = _grid(_CORNER_GRID) + archer = make_combatant( + "ogre-archer", attack_bonus=4, kind="ranged", range_increment_ft=60 + ) + large = archer.model_copy(update={"size": "Large"}) + target = make_combatant("target", ac=13) + states = [ + make_state(large, "players", (0, 0)), + make_state(target, "monsters", (1, 4)), + ] + return CombatEngine(ScriptedRng([12, 3, 2, 2]), grid, states) + + +def test_large_attacker_ac_uses_best_cell_cover() -> None: + engine = _large_archer_engine() + archer, target = engine._states[0], engine._states[1] + assert len(archer.occupied_cells()) == 4 + result = engine.resolve_attack(archer, target, archer.combatant.attacks[0]) + # 13 base + 2 (best cell of the footprint), NOT 13 + 4 (worst cell). + assert result.ac == 15 + assert result.hit is True # 12 + 4 = 16 >= 15 + + +def test_observation_pair_cover_bonus_matches_attack() -> None: + engine = _large_archer_engine() + archer, target = engine._states[0], engine._states[1] + obs = engine._build_observation(archer).to_dict() + pair = next(p for p in obs["pairs"] if p["target_index"] == 1) + result = engine.resolve_attack(archer, target, archer.combatant.attacks[0]) + assert pair["cover_bonus"] == 2 + assert result.ac - target.combatant.ac.total == pair["cover_bonus"] + + +# --------------------------------------------------------------------------- +# (e) false-positive lock: cover still applies to touch attacks (Oracle) +# --------------------------------------------------------------------------- + + +def test_ranged_touch_attack_still_gets_cover() -> None: + grid = _grid((".....", "..#..", ".....")) + caster = make_combatant( + "sorcerer", attack_bonus=4, kind="touch", range_increment_ft=30 + ) + target = make_combatant("target", ac=13) + states = [ + make_state(caster, "players", (1, 0)), + make_state(target, "monsters", (1, 4)), + ] + engine = CombatEngine(ScriptedRng([12, 3]), grid, states) + result = engine.resolve_attack(states[0], states[1], caster.attacks[0]) + # Touch AC 13 + 4 standard cover: cover is NOT removed for touch. + assert result.ac == 17 + assert result.hit is False # 12 + 4 = 16 < 17