From 389e1606277d7b3be2a6917ee5c7cfcf0153f217 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 14:04:34 +0200 Subject: [PATCH] fix(rules): cones require line of effect from their origin (SRD aiming-a-spell.md: a cone 'affects only an area, creature, or object to which it has line of effect from its origin') - cone_cells drops geometrically-inside cells without corner-to-corner LoE from the caster square; line/cone barrier-cell semantics aligned (both templates include the barrier square the effect strikes, exclude everything behind it) --- src/pf1e_simulator/areas.py | 26 +++++++++++++++++---- tests/test_area_shapes.py | 46 +++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/pf1e_simulator/areas.py b/src/pf1e_simulator/areas.py index 6501a68..79cf419 100644 --- a/src/pf1e_simulator/areas.py +++ b/src/pf1e_simulator/areas.py @@ -18,8 +18,15 @@ PF1e (CRB, Magic — Aiming a Spell): fill it). The line also stops at the first square that blocks line of effect (CRB, Magic - Aiming a Spell, Line: it "extends to the limit of its range or until it strikes a barrier that blocks line of effect"): the - barrier square itself and everything beyond it are outside the template, - so a bolt never reaches creatures behind a solid wall. + barrier square is the last square of the template and everything beyond + it is outside, so a bolt never reaches creatures behind a solid wall. +- Cones require line of effect from their origin (CRB, Magic - Line of + Effect): "A burst, cone, cylinder, or emanation spell affects only an + area, creature, or object to which it has line of effect from its origin + (a spherical burst's center point, a cone-shaped burst's starting + point, ...)". ``cone_cells`` therefore drops every geometric cell that + has no corner-to-corner line of effect from the origin square (a solid + wall between them shields everything behind it). Bursts need no template here: they are circles around a center cell and are resolved by ``CombatEngine._creatures_in_range`` (unchanged). @@ -29,6 +36,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +from pf1e_simulator.los import has_line_of_effect + if TYPE_CHECKING: from pf1e_simulator.grid import Grid from pf1e_simulator.map import Pos @@ -44,7 +53,11 @@ def cone_cells( from the origin is within ``radius_cells`` (5-10-5 grid distance), points strictly into the cone's forward half-plane, and stays within ±45° of the aim bearing. The origin cell itself is never affected (CRB: the cone - "shoots away from you"). A degenerate aim (origin == aim) affects nothing. + "shoots away from you"). A cell with no line of effect from the origin + is dropped (CRB, Line of Effect: a cone "affects only an area, creature, + or object to which it has line of effect from its origin" - a solid wall + stops the cone, it does not pass through). A degenerate aim + (origin == aim) affects nothing. The ±45° test is exact integer arithmetic: with v the cell offset and d the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to @@ -70,6 +83,8 @@ def cone_cells( continue if grid.distance(origin, cell) > radius_cells: continue + if not has_line_of_effect(grid, origin, cell): + continue cells.add(cell) return frozenset(cells) @@ -87,7 +102,8 @@ def line_cells( the ray. Out-of-bounds cells simply drop out of the template (creatures only exist in bounds). The ray stops at the first cell that blocks line of effect (CRB, Line: "or until it strikes a barrier that blocks line of - effect") - that barrier cell and everything beyond it are excluded. A + effect") - the barrier cell itself is the last cell of the template (the + line strikes it) and everything beyond it is excluded. A degenerate aim (origin == aim) affects nothing. """ if length_cells <= 0: @@ -109,9 +125,9 @@ def line_cells( for cell in ray[1 : length_cells + 1]: if not grid.in_bounds(cell): continue + cells.append(cell) if grid.terrain_at(cell).blocks_los: break - cells.append(cell) return frozenset(cells) diff --git a/tests/test_area_shapes.py b/tests/test_area_shapes.py index 6ac6f2e..addd778 100644 --- a/tests/test_area_shapes.py +++ b/tests/test_area_shapes.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING import pytest -from pf1e_simulator.areas import line_cells +from pf1e_simulator.areas import cone_cells, line_cells from pf1e_simulator.combat import Action, CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid @@ -185,6 +185,48 @@ def test_cone_covers_the_full_90_degree_arc() -> None: assert foe_outside.hp == 100 +def test_cone_cells_require_line_of_effect_from_the_origin() -> None: + """Given: a wall column at col 4 (rows 4-8) between origin (6,2) and the + cell (6,5) that sits on the cone axis at distance 3 + When: cone_cells builds a 3-cell east cone + Then: (6,5) is dropped - geometrically inside but without line of effect + from the origin (CRB, Line of Effect: a cone "affects only an area, + creature, or object to which it has line of effect from its origin"); + on the same geometry without walls it stays in the template.""" + walls = frozenset({(4, 4), (5, 4), (6, 4), (7, 4), (8, 4)}) + grid = _walled_grid(walls) + cells = cone_cells(grid, (6, 2), (6, 3), 3) + assert (6, 3) in cells + assert (6, 4) in cells # the cone reaches the barrier square itself + assert (6, 5) not in cells + open_cells = cone_cells(_open_grid(), (6, 2), (6, 3), 3) + assert (6, 5) in open_cells # control: nothing else filters it out + + +def test_cone_behind_a_solid_wall_does_not_hit_sheltered_creatures() -> None: + """Given: caster (6,2) aiming the 15-ft cone east at foe_front (6,3); a + wall column at col 4 (rows 4-8); foe_behind (6,5) sits on the cone axis + behind that wall + When: the cone resolves + Then: only foe_front is caught (one 1d6 consumed after the adjacent + foe_front's scripted -4 AoO d20 misses); foe_behind is untouched - the + cone stops at the solid barrier, it does not pass through it.""" + caster = _caster() + walls = frozenset({(4, 4), (5, 4), (6, 4), (7, 4), (8, 4)}) + foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 3)) + foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 5)) + eng = CombatEngine( + ScriptedRng([4, 4]), _walled_grid(walls), [caster, foe_front, foe_behind], + spell_registry=_REGISTRY, + ) + result = eng._execute( + caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Cone Sparks") + ) + assert result.ok + assert foe_front.hp == 100 - 4 + assert foe_behind.hp == 100 + + def test_cone_directed_by_target_pos_point_cast() -> None: """Given: the same scene, no target_id — the cone is aimed at an explicit cell (6,5) via target_pos (U7b point cast) @@ -256,7 +298,7 @@ def test_line_cells_stop_at_a_line_of_effect_barrier() -> None: grid = _walled_grid(frozenset({(6, 6)})) cells = line_cells(grid, (6, 2), (6, 8), 10) assert (6, 5) in cells - assert (6, 6) not in cells + assert (6, 6) in cells # the bolt strikes the barrier square itself assert (6, 7) not in cells assert (6, 8) not in cells