diff --git a/README.md b/README.md index 7af0ac5..e1549fd 100644 --- a/README.md +++ b/README.md @@ -847,9 +847,18 @@ Règles modélisées : (conformance A5 — CRB « Big Creatures and Cover » : « when making a melee attack against such a creature, you can pick any of the squares it occupies to determine if it has cover against you ») — le score est donc le minimum - sur les cases de la cible en mêlée, le maximum à distance. En mêlée, la - règle du pire coin attaquant est conservée (« any line from ANY corner of - your square »). + sur les cases de la cible en mêlée, le maximum à distance. **Mêlée + adjacente** (conformance — SRD « Cover » : « When making a melee attack + against an adjacent target, your target has cover if any line from any + corner of your square to the target's square goes through a wall ») : la + règle est celle de **toute ligne** — une seule ligne coin-à-coin bloquée + suffit à donner le couvert, l'attaquant ne choisit plus son meilleur coin + (score : maximum sur les seize lignes de la paire de cases, minimum sur + les paires de cases adjacentes). **Mêlée non adjacente** (arme à allonge, + distance > 1) : le SRD renvoie à la règle à distance (« use the rules for + determining cover from ranged attacks ») — l'attaquant garde son **meilleur + coin** (minimum sur ses coins du maximum des lignes), avec le choix de la + case de cible multi-cases. - 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 diff --git a/src/pf1e_simulator/los.py b/src/pf1e_simulator/los.py index acea226..66b424a 100644 --- a/src/pf1e_simulator/los.py +++ b/src/pf1e_simulator/los.py @@ -29,6 +29,22 @@ def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]: return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1)) +def _adjacent(a: Pos, b: Pos) -> bool: + """True if the two squares touch (share an edge or a corner).""" + return max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1 + + +def _melee_is_adjacent( + attacker_cells: frozenset[Pos], target_cells: frozenset[Pos] +) -> bool: + """True if some attacker square touches some target square.""" + return any( + _adjacent(cell_a, cell_t) + for cell_a in attacker_cells + for cell_t in target_cells + ) + + def _norm(num: int, den: int) -> _Rat: return (num, den) if den > 0 else (-num, -den) @@ -176,12 +192,13 @@ def has_cover( ranged: bool, occupied: frozenset[Pos] = frozenset(), ) -> bool: - """Cover iff every attacker corner has at least one blocked target corner. + """Cover with the SRD corner rule (see has_cover_cells). - The attacker picks their single best corner (PF1e ranged cover 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. + Ranged (and non-adjacent melee): the attacker picks their single best + corner (PF1e ranged cover rule). Adjacent melee: the SRD any-line rule + (cover.md: "any line from any corner of your square to the target's + square"). For ranged attacks, squares occupied by other creatures also + block the lines (soft cover, CRB); melee ignores creatures. """ return has_cover_cells( grid, @@ -200,15 +217,22 @@ def has_cover_cells( ranged: bool, occupied: frozenset[Pos] = frozenset(), ) -> bool: - """Cover iff every attacker corner has at least one blocked target corner. + """Cover for an attack between two sets of cells, per the SRD corner rules. - The attacker picks their single best cell (and best corner within it). - Target side: ranged attacks check ANY line to ANY corner of ANY target - cell (union over the target's cells); melee attacks may pick any single - square of a multi-square target (CRB "Big Creatures and Cover"), so - cover exists only when no attacker corner sees every corner of SOME - target cell unblocked. For 1x1 creatures both sides are the single-cell - corner rule. + Ranged (and non-adjacent melee, which cover.md sends to the ranged + rule): the attacker picks their single best cell (and best corner + within it), so cover exists only when every attacker corner has at + least one blocked target corner. Adjacent melee (some attacker square + touching some target square): cover.md grants cover if ANY line from + ANY corner of the attacker's square to the target's square goes + through a wall, so cover exists as soon as one corner line is blocked. + Target side: ranged attacks check ANY line to ANY corner of ANY + target cell (union over the target's cells); melee attacks may pick + any single square of a multi-square target (CRB "Big Creatures and + Cover"), so cover exists only when no attacker corner sees every + corner of SOME target cell unblocked. For 1x1 creatures both sides + are the single-cell corner rule. For ranged attacks, squares occupied + by other creatures block the lines (soft cover); melee ignores them. """ if attacker_cells & target_cells: return False @@ -228,6 +252,27 @@ def has_cover_cells( for cell_a in attacker_cells for corner_a in _corners(cell_a) ) + if _melee_is_adjacent(attacker_cells, target_cells): + # SRD cover.md, adjacent melee: "your target has cover if any line + # from any corner of your square to the target's square goes + # through a wall". One blocked corner line is enough. With a + # multi-square attacker the defender still picks the pair it + # attacks through (CRB "Big Creatures and Cover" applies to both + # sides), so cover exists only when EVERY touching attacker / + # target square pair has at least one blocked corner line. + return all( + any( + lines.blocked(corner_a, corner_t) + for corner_a in _corners(cell_a) + for corner_t in _corners(cell_t) + ) + for cell_a in attacker_cells + for cell_t in target_cells + if _adjacent(cell_a, cell_t) + ) + # Non-adjacent melee (reach weapons): cover.md sends these to the + # ranged rule, but the attacker may still pick any single square of a + # multi-square target (CRB "Big Creatures and Cover"). return not any( all( not lines.blocked(corner_a, corner_t) @@ -268,15 +313,15 @@ 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 cover level of the attacker's BEST occupied cell: each - attacker cell is scored by the per-cell corner rule, and the attacker - picks the lowest-scoring (clearest) cell. Target side: ranged attacks are - scored by the maximum over ALL the target's cells; melee attacks may pick - any single square of the target (CRB "Big Creatures and Cover"), so each - attacker cell is scored by the MINIMUM over the target's cells. Interposed - creatures grant soft cover (+4) on ranged attacks only, exactly as in - has_cover_cells. For 1x1 attacker and target this is exactly the - single-cell value. + otherwise the cover level scored under the same rule the gate uses: + ranged and non-adjacent melee score each attacker corner by the + maximum over the target-corner lines and the attacker keeps its BEST + corner (and best own cell); adjacent melee scores each touching + attacker/target square pair by the maximum over ALL its corner lines + (any blocked line grants cover) and the attacker keeps its best pair + of cells. Interposed creatures grant soft cover (+4) on ranged + attacks only, exactly as in has_cover_cells. For 1x1 attacker and + target this is exactly the single-cell value. """ if not attacker_cells or not target_cells: return 0 @@ -291,36 +336,56 @@ def cover_bonus_cells( occupied if ranged else frozenset(), bonus_of=terrain_cover_bonus, ) - # 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. Ranged: - # "choose a corner of your square. If any line from this corner to any - # corner of the target's square passes through cover, the target has - # cover" — each attacker corner is scored by the MAXIMUM over the target's - # corner lines (union over all target cells), and the attacker picks its - # BEST corner: MIN over attacker corners. Melee: the attacker may also - # pick any single square of a multi-square target (CRB "Big Creatures and - # Cover"), so the target side is a MIN over cells; the attacker side - # stays the worst-corner rule ("any line from ANY corner of your - # square"). - per_cell_best = ( - min( + # PF1e CRB "Cover", rule of corners. Ranged: "choose a corner of your + # square. If any line from this corner to any corner of the target's + # square passes through cover, the target has cover" - each attacker + # corner is scored by the MAXIMUM over the target's corner lines + # (union over all target cells), and the attacker picks its BEST + # corner: MIN over attacker corners, then MIN over its own cells + # (B20). Adjacent melee: "any line from ANY corner of your square to + # the target's square" - within a chosen square pair the level is the + # MAXIMUM over all sixteen corner lines, and the attacker picks its + # best pair: MIN over cells on both sides. Non-adjacent melee + # (reach): cover.md sends the attack to the ranged rule, but the + # attacker may pick any single square of a multi-square target (CRB + # "Big Creatures and Cover"), so the target side is a MIN over cells + # while the attacker side stays the best-corner MIN. + adjacent_melee = not ranged and _melee_is_adjacent(attacker_cells, target_cells) + if adjacent_melee: + return min( max( lines.bonus(corner_a, corner_t) for corner_a in _corners(cell_a) for corner_t in _corners(cell_t) ) + for cell_a in attacker_cells for cell_t in target_cells + if _adjacent(cell_a, cell_t) ) - if not ranged - else min( - max( - lines.bonus(corner_a, corner_t) - for cell_t in target_cells - for corner_t in _corners(cell_t) + if ranged: + per_cell_best = ( + min( + max( + lines.bonus(corner_a, corner_t) + for cell_t in target_cells + for corner_t in _corners(cell_t) + ) + for corner_a in _corners(cell_a) ) - for corner_a in _corners(cell_a) + for cell_a in attacker_cells + ) + else: + per_cell_best = ( + min( + min( + max( + lines.bonus(corner_a, corner_t) + for corner_t in _corners(cell_t) + ) + for corner_a in _corners(cell_a) + ) + for cell_t in target_cells + ) + for cell_a in attacker_cells ) - for cell_a in attacker_cells - ) return min(per_cell_best) diff --git a/tests/test_conformance_cover.py b/tests/test_conformance_cover.py index 73a3a31..a8a7b80 100644 --- a/tests/test_conformance_cover.py +++ b/tests/test_conformance_cover.py @@ -177,3 +177,31 @@ def test_melee_vs_large_target_picks_best_target_square() -> None: assert cover_bonus_cells(grid, attacker, frozenset({(0, 3)}), ranged=True) == 4 assert cover_bonus_cells(grid, attacker, frozenset({(1, 3)}), ranged=False) == 0 assert cover_bonus_cells(grid, attacker, frozenset({(1, 3)}), ranged=True) == 0 + + +# --------------------------------------------------------------------------- +# (g) adjacent melee uses the SRD any-line cover rule (cover.md), not the +# ranged best-corner rule - engine AC effect +# --------------------------------------------------------------------------- + + +def test_adjacent_melee_grazing_wall_grants_cover_to_ac() -> None: + """Wall (0,1) grazes one corner line of the adjacent pair (1,0)-(1,1). + + SRD cover.md: "When making a melee attack against an adjacent target, + your target has cover if any line from any corner of your square to + the target's square goes through a wall." The ranged rule would find + the attacker's top corner with all lines clear (no cover); the melee + rule grants +4, and the engine's resolve_attack must apply it. + """ + grid = _grid((".#", "..")) + attacker = make_combatant("gnoll", attack_bonus=4) + target = make_combatant("warden", ac=13) + states = [ + make_state(attacker, "players", (1, 0)), + make_state(target, "monsters", (1, 1)), + ] + engine = CombatEngine(ScriptedRng([12, 3, 12, 3]), grid, states) + result = engine.resolve_attack(states[0], states[1], attacker.attacks[0]) + assert result.ac == 17 # 13 + 4 melee-adjacent cover + assert result.hit is False # 12 + 4 = 16 < 17 diff --git a/tests/test_los.py b/tests/test_los.py index 1d811bf..d90de5c 100644 --- a/tests/test_los.py +++ b/tests/test_los.py @@ -8,7 +8,12 @@ on a blocking square's vertex do not count as passing through it. from __future__ import annotations from pf1e_simulator.grid import Grid -from pf1e_simulator.los import has_cover, has_line_of_effect +from pf1e_simulator.los import ( + cover_bonus, + has_cover, + has_cover_cells, + has_line_of_effect, +) from pf1e_simulator.map import MapSpec, TerrainType @@ -78,7 +83,7 @@ def test_cover_diagonal_past_wall_vertex() -> None: assert has_cover(grid, (0, 0), (1, 1), ranged=True) is True -def test_cover_melee_uses_same_corner_rule_in_phase0() -> None: +def test_cover_melee_adjacent_wall_vertex_grant_cover() -> None: grid = make_grid([".#.", "#.."]) assert has_cover(grid, (0, 0), (1, 1), ranged=False) is True open_grid = make_grid(["...", "..."]) @@ -90,6 +95,74 @@ def test_cover_same_square_is_false() -> None: assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False +# --------------------------------------------------------------------------- +# Adjacent melee uses the SRD any-line rule (cover.md), not the ranged +# best-corner rule: "When making a melee attack against an adjacent target, +# your target has cover if any line from any corner of your square to the +# target's square goes through a wall." +# --------------------------------------------------------------------------- + + +def test_adjacent_melee_any_blocked_line_grants_cover() -> None: + """Wall (0,1) blocks ONE corner line (0,1)->(2,1) of adjacent (1,0)->(1,1). + + The ranged best-corner rule finds attacker corner (1,2) with all four + lines clear (no cover); the melee-adjacent any-line rule grants cover + from the single grazing line. This is the divergence the fix closes. + """ + grid = make_grid([".#", ".."]) + assert has_cover(grid, (1, 0), (1, 1), ranged=True) is False + assert has_cover(grid, (1, 0), (1, 1), ranged=False) is True + + +def test_adjacent_melee_cover_bonus_grants_four() -> None: + grid = make_grid([".#", ".."]) + assert cover_bonus(grid, (1, 0), (1, 1), ranged=False) == 4 + assert cover_bonus(grid, (1, 0), (1, 1), ranged=True) == 0 + + +def test_adjacent_melee_worst_line_sets_level() -> None: + """Pillar (1,1) grazes ONE line (1,2)->(2,2) of adjacent (2,0)->(2,1): + the any-line rule takes the MAX over the sixteen corner lines, so the + target keeps the +4 even though the attacker's best corner (0,3) sees + every line clear (ranged level 0).""" + grid = make_grid(["..", ".C", ".."]) + assert cover_bonus(grid, (2, 0), (2, 1), ranged=False) == 4 + assert cover_bonus(grid, (2, 0), (2, 1), ranged=True) == 0 + + +def test_adjacent_melee_open_field_no_cover() -> None: + grid = make_grid(["..", ".."]) + assert has_cover(grid, (0, 0), (0, 1), ranged=False) is False + assert cover_bonus(grid, (0, 0), (0, 1), ranged=False) == 0 + + +def test_nonadjacent_melee_keeps_ranged_best_corner_rule() -> None: + """Reach attack at distance 2 (wall (0,1) off-axis): cover.md sends it + to the ranged rule. Line (0,1)->(2,1) grazes the wall's border (the + adjacent-melee rule would grant cover) but the attacker's top corner + (0,2) sees every target corner clear -> no cover, exactly as ranged.""" + grid = make_grid([".#", ".."]) + assert has_cover(grid, (1, 0), (1, 2), ranged=True) is False + assert has_cover(grid, (1, 0), (1, 2), ranged=False) is False + + +def test_adjacent_melee_multi_cell_attacker_picks_clear_pair() -> None: + """2x1 attacker {(1,0),(1,1)} vs target (0,1): both squares touch it. + Wall (0,2) blocks a line of the (1,1) pair (vertical x=2 along the + wall's border) but the (1,0) pair sees every line clear - the attacker + attacks through its clear square -> no cover (CRB "Big Creatures and + Cover": the big creature picks its own square). Without the wall the + same pair scan gives no cover either.""" + grid = make_grid(["..#", "..."]) + attacker = frozenset({(1, 0), (1, 1)}) + target = frozenset({(0, 1)}) + assert has_cover_cells(grid, attacker, target, ranged=False) is False + assert has_cover_cells(grid, attacker, target, ranged=True) is False + open_grid = make_grid(["...", "..."]) + assert has_cover_cells(open_grid, attacker, target, ranged=False) 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([".....", ".....", "....."])