fix(rules): apply cover by attacker's best corner for multi-square attackers
This commit is contained in:
@@ -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
|
||||
- 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).
|
||||
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).
|
||||
|
||||
@@ -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(
|
||||
# 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 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)
|
||||
)
|
||||
for cell_a in attacker_cells
|
||||
)
|
||||
return min(per_cell_best)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user