fix(rules): exclude the caster from a line spell's area

CRB Magic - Aiming a Spell: a line 'starts from any corner of your square', so the caster's own square is outside the template (SRD Lightning Bolt: 'The bolt begins at your fingertips' - it leaves the square, it does not fill it). line_cells now drops the origin and takes the length_cells squares beyond it; a degenerate aim affects nothing. Updated the Lightning Bolt and Line Sparks tests (caster no longer caught) and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-13 11:30:30 +02:00
parent ee8c8f80ff
commit 642f449b9e
4 changed files with 38 additions and 24 deletions
+6 -3
View File
@@ -916,9 +916,12 @@ Règles modélisées :
largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large, largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large,
longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés
vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le
cône exclut la case d'origine, la ligne commence dans la case du jeteur cône exclut la case d'origine, la ligne part d'un **coin** de la case du
(« the bolt begins at your fingertips », SRD Lightning Bolt : le jeteur est jeteur et n'affecte **pas** sa propre case (CRB « Aiming a Spell » : « It
donc dans sa propre ligne). Toute créature dont une case occupée intersecte starts from any corner of your square » — SRD Lightning Bolt : « The bolt
begins at your fingertips », le trait quitte la case du jeteur au lieu de la
remplir ; un viseur dégénéré origine==cible n'affecte personne). Toute
créature dont une case occupée intersecte
le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un
burst (application partagée, géométrie dans `areas.py`). Le snapshot burst (application partagée, géométrie dans `areas.py`). Le snapshot
d'observation expose `{shape, radius_ft}` via la clé `area`. d'observation expose `{shape, radius_ft}` via la clé `area`.
+20 -12
View File
@@ -11,8 +11,11 @@ PF1e (CRB, Magic — Aiming a Spell):
limit of its range [...]. A line-shaped spell affects all creatures in limit of its range [...]. A line-shaped spell affects all creatures in
squares through which the line passes." — modeled here as the 5-ft-wide squares through which the line passes." — modeled here as the 5-ft-wide
Bresenham ray from the origin through the aim cell, truncated to the Bresenham ray from the origin through the aim cell, truncated to the
line's length (the origin cell counts as the first square: the bolt line's length. The line "starts from any corner of your square", so the
"begins at your fingertips", SRD Lightning Bolt). CASTER's own square is NOT one of the affected squares: the affected
squares are the ones beyond the origin along the ray (SRD Lightning Bolt:
"The bolt begins at your fingertips" — it leaves your square, it does not
fill it).
Bursts need no template here: they are circles around a center cell and are Bursts need no template here: they are circles around a center cell and are
resolved by ``CombatEngine._creatures_in_range`` (unchanged). resolved by ``CombatEngine._creatures_in_range`` (unchanged).
@@ -74,23 +77,28 @@ def line_cells(
The line runs along the Bresenham ray from the origin toward ``aim``, The line runs along the Bresenham ray from the origin toward ``aim``,
extended to ``length_cells`` squares (CRB: it "extends to the limit of extended to ``length_cells`` squares (CRB: it "extends to the limit of
its range"). The origin cell is the first square of the line. Out-of-bounds its range"). The line "starts from any corner of your square" (CRB, Magic
cells simply drop out of the template (creatures only exist in bounds). — Aiming a Spell), so the CASTER's own square is NOT affected: the
A degenerate aim (origin == aim) affects only the origin cell. ``length_cells`` affected squares are the ones *beyond* the origin along
the ray. Out-of-bounds cells simply drop out of the template (creatures
only exist in bounds). A degenerate aim (origin == aim) affects nothing.
""" """
if length_cells <= 0: if length_cells <= 0:
return frozenset() return frozenset()
d_row = aim[0] - origin[0] d_row = aim[0] - origin[0]
d_col = aim[1] - origin[1] d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0: if d_row == 0 and d_col == 0:
return frozenset({origin}) if grid.in_bounds(origin) else frozenset() return frozenset()
# Extend the aim vector so the ray spans `length_cells` squares (the # Extend the aim vector so the ray reaches `length_cells` squares BEYOND
# dominant axis step count of a Bresenham line is max(|d_row|, |d_col|)). # the origin (the dominant-axis step count of a Bresenham line is
scale = length_cells / max(abs(d_row), abs(d_col)) # max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop.
scale = (length_cells + 1) / max(abs(d_row), abs(d_col))
end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale)) end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale))
# The line is `length_cells` squares LONG: the origin square counts as # The line is `length_cells` squares LONG starting just past the caster:
# the first of them (120 ft / 5 ft = 24 squares for Lightning Bolt). # 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning
return frozenset(_bresenham(origin, end)[:length_cells]) # Bolt: "The bolt begins at your fingertips" — it leaves your square).
ray = _bresenham(origin, end)
return frozenset(cell for cell in ray[1 : length_cells + 1] if grid.in_bounds(cell))
def _bresenham(start: Pos, end: Pos) -> list[Pos]: def _bresenham(start: Pos, end: Pos) -> list[Pos]:
+4 -3
View File
@@ -197,8 +197,9 @@ def test_line_hits_the_straight_ray_only() -> None:
"""Given: caster (6,2) aiming east at foe_ray (6,5); foe_off (5,5) sits a """Given: caster (6,2) aiming east at foe_ray (6,5); foe_off (5,5) sits a
row away, foe_west (6,0) is behind the caster row away, foe_west (6,0) is behind the caster
When: cast the 120-ft line aimed at foe_ray When: cast the 120-ft line aimed at foe_ray
Then: every creature ON the ray (the caster's own cell — the line starts Then: every creature ON the ray beyond the caster's own square (foe_ray)
at your fingertips — and foe_ray) takes damage; the off-row and the takes damage; the caster's own cell is NOT part of the line (CRB: it
"starts from any corner of your square"), and the off-row and the
behind-caster foes are outside the 5-ft-wide template.""" behind-caster foes are outside the 5-ft-wide template."""
caster = _caster() caster = _caster()
foe_ray = make_state(make_combatant("foe_ray", hp=100), "monsters", (6, 5)) foe_ray = make_state(make_combatant("foe_ray", hp=100), "monsters", (6, 5))
@@ -210,7 +211,7 @@ def test_line_hits_the_straight_ray_only() -> None:
) )
assert result.ok assert result.ok
assert foe_ray.hp == 100 - 4 assert foe_ray.hp == 100 - 4
assert caster.hp == 100 - 4 # the line's first square is the caster's own assert caster.hp == 100 # the caster's own square is outside the line
assert foe_off.hp == 100 assert foe_off.hp == 100
assert foe_west.hp == 100 assert foe_west.hp == 100
+8 -6
View File
@@ -462,22 +462,24 @@ class TestLightningBoltSave:
1d6 per caster level max 10d6, ref save for half) cast by the level-5 1d6 per caster level max 10d6, ref save for half) cast by the level-5
fixture at a target straight ahead fixture at a target straight ahead
When: the primary target succeeds its save When: the primary target succeeds its save
Then: damage is halved — and the caster, whose own square the line Then: damage is halved — and the caster is NOT caught by their own line
starts in ("the bolt begins at your fingertips"), is caught as well.""" (CRB, Magic — Aiming a Spell: the line "starts from any corner of your
square", so the caster's own square is outside the template)."""
def test_successful_save_still_halves(self) -> None: def test_successful_save_still_halves(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=200) target = _make_target(hp_max=200)
# primary save (target) + secondary save (caster, same line) + # primary save (target, SAVED on a natural 20) + 5 damage dice
# 5 damage dice each (1d6/level, level 5) # (1d6/level, level 5); the caster is no longer in the line so no
# secondary save and no caster damage dice are rolled.
engine, c, t = _make_engine( engine, c, t = _make_engine(
caster, target, [20, 20] + [3] * 10 caster, target, [20] + [3] * 5
) )
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt") engine._cast_spell(c, t, "Lightning Bolt")
full = 3 + 3 + 3 + 3 + 3 full = 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2) assert t.hp == hp_before - (full // 2)
assert c.hp == 20 - (full // 2) assert c.hp == 20 # the caster's own square is outside the line
class TestDamageEffectHalfOnSaveDefault: class TestDamageEffectHalfOnSaveDefault: