"""A grazed caster cell must not shorten a line spell (SRD). PF1e SRD "Aiming a Spell" (aiming-a-spell.md): "A line-shaped spell shoots away from you in a line in the direction you designate. It starts from any corner of your square and extends to the limit of its range." The line's length is its RANGE measured from the caster's space: the caster's own squares are origin space the line starts past, so a square of a multi-cell caster's footprint that the ray grazes must NOT consume one of the ``length_cells`` squares. SRD Lightning Bolt: "The bolt begins at your fingertips" — a 120-ft bolt is 24 squares of effect beyond the caster, not 24 minus its footprint. The engine used to take a fixed ``ray[1 : length_cells + 1]`` window and drop caster cells inside it, so a 2x2 caster's line came out one square shorter per grazed footprint cell. ``line_cells`` now skips caster cells WITHOUT spending a length slot (the ray is extended to cover them), so a multi-cell caster's line runs its full ``length_cells`` squares starting just past the footprint. For a 1x1 caster the only skipped cell is the origin (``ray[0]``, already outside the window), so the template is bit-identical to the old behaviour. """ from __future__ import annotations from pf1e_simulator.areas import line_cells from pf1e_simulator.combat import Action, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec from tests.test_observation_enriched import make_combatant, make_state _FLOOR = TerrainType(type="floor", move_cost=1) # A Large caster anchored at (1,1) occupies this 2x2 block; aiming east the # ray grazes the footprint cell (1,2) before leaving the caster's space. _FOOTPRINT = frozenset({(1, 1), (1, 2), (2, 1), (2, 2)}) _LINE_SPARKS = SpellSpec( name="Line Sparks", level=2, school="evocation", range="long", save="none", base_dc=10, effects=( AreaEffect( shape="line", radius_ft=30, effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),), save_for_half=False, ), ), ) _REGISTRY = {"Line Sparks": _LINE_SPARKS} def _open_grid(side: int = 12) -> Grid: return Grid.from_spec( MapSpec(name="line-length", terrain=tuple(["." * side] * side), legend={".": _FLOOR}) ) def test_grazed_caster_cell_does_not_shorten_the_line() -> None: """A 2x2 caster aiming east grazes footprint cell (1,2); the line must still be 6 squares long starting past the footprint, so the far square (1,8) is inside the template (the old fixed window dropped it).""" cells = line_cells(_open_grid(), (1, 1), (1, 6), 6, caster_cells=_FOOTPRINT) assert (1, 2) not in cells # the caster's own space is never affected assert len(cells) == 6 # full length, not shortened by the grazed cell assert cells == {(1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8)} def test_single_cell_line_is_bit_identical() -> None: """1x1 regression: the only skipped cell is the origin (ray[0]), already outside the counted window, so the template is exactly the length_cells squares beyond the origin — unchanged from the pre-fix behaviour.""" grid = _open_grid() cells = line_cells(grid, (1, 1), (1, 6), 6, caster_cells=frozenset({(1, 1)})) assert cells == {(1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7)} # Identical to the no-caster-cells call (the origin is dropped either way). assert cells == line_cells(grid, (1, 1), (1, 6), 6) def test_large_caster_line_reaches_a_foe_the_short_line_missed() -> None: """Engine level: a Large caster at (1,1) (footprint rows 1-2, cols 1-2) casts a 30-ft line (6 cells) east at a foe on (1,8). The grazed footprint cell (1,2) used to shorten the line to 5 cells (reaching only (1,7)), so the foe at (1,8) escaped; with the length-slot fix the line runs its full 6 squares past the footprint and catches the foe.""" caster = make_state( make_combatant("ogre", size="Large", hp=100, spells=["Line Sparks"], spell_slots={2: 9}), "players", (1, 1), ) foe = make_state(make_combatant("gob", hp=100), "monsters", (1, 8)) engine = CombatEngine(ScriptedRng([4]), _open_grid(), [caster, foe], spell_registry=_REGISTRY) result = engine._execute( caster, Action(kind="cast_spell", target_id="gob", spell_name="Line Sparks") ) assert result.ok assert foe.hp == 100 - 4 # one 1d6 (d20=4) consumed: the line reached (1,8)