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:
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user