feat(rules): support cone and line spell areas

AreaEffect gains shape: burst|cone|line (CRB Magic - Aiming a Spell). Cones (90 deg, length = radius_ft) and lines (5 ft wide, Bresenham ray) originate at the caster's cell and are aimed at the resolved target or the U7b point-cast center; bursts are unchanged. Shared per-creature save + SR application; the observation area dict already exposes the shape.

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 10:32:37 +02:00
parent 734cfa4e55
commit 4659fae9f6
4 changed files with 462 additions and 9 deletions
+115
View File
@@ -0,0 +1,115 @@
"""PF1e area-shape geometry: cone and line templates on the 5-ft square grid.
PF1e (CRB, Magic — Aiming a Spell):
- "A cone-shaped spell shoots away from you in a quarter-circle in the
direction you designate. It starts from any corner of your square and
widens out as it goes." — modeled here as every cell whose center lies
within the cone's length of the origin and within ±45° of the aim
direction (a 90°-wide quarter circle), excluding the origin cell.
- "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 [...]. A line-shaped spell affects all creatures in
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
line's length (the origin cell counts as the first square: the bolt
"begins at your fingertips", SRD Lightning Bolt).
Bursts need no template here: they are circles around a center cell and are
resolved by ``CombatEngine._creatures_in_range`` (unchanged).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos
def cone_cells(
grid: Grid, origin: Pos, aim: Pos, radius_cells: int
) -> frozenset[Pos]:
"""Cells of a 90° cone of ``radius_cells`` squares starting at ``origin``.
The cone opens toward ``aim`` (any cell in the aim direction, usually the
resolved target or point-cast center). A cell is affected when its offset
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.
The ±45° test is exact integer arithmetic: with v the cell offset and d
the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to
``2 * (v·d)^2 >= |v|^2 * |d|^2`` together with ``v·d > 0``.
"""
d_row = aim[0] - origin[0]
d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0:
return frozenset()
aim_sq = d_row * d_row + d_col * d_col
cells: set[Pos] = set()
for row in range(origin[0] - radius_cells, origin[0] + radius_cells + 1):
for col in range(origin[1] - radius_cells, origin[1] + radius_cells + 1):
cell = (row, col)
if cell == origin or not grid.in_bounds(cell):
continue
v_row = row - origin[0]
v_col = col - origin[1]
dot = v_row * d_row + v_col * d_col
if dot <= 0:
continue
if 2 * dot * dot < (v_row * v_row + v_col * v_col) * aim_sq:
continue
if grid.distance(origin, cell) > radius_cells:
continue
cells.add(cell)
return frozenset(cells)
def line_cells(
grid: Grid, origin: Pos, aim: Pos, length_cells: int
) -> frozenset[Pos]:
"""Cells of a 5-ft-wide line of ``length_cells`` squares from ``origin``.
The line runs along the Bresenham ray from the origin toward ``aim``,
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
cells simply drop out of the template (creatures only exist in bounds).
A degenerate aim (origin == aim) affects only the origin cell.
"""
if length_cells <= 0:
return frozenset()
d_row = aim[0] - origin[0]
d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0:
return frozenset({origin}) if grid.in_bounds(origin) else frozenset()
# Extend the aim vector so the ray spans `length_cells` squares (the
# dominant axis step count of a Bresenham line is max(|d_row|, |d_col|)).
scale = length_cells / max(abs(d_row), abs(d_col))
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 first of them (120 ft / 5 ft = 24 squares for Lightning Bolt).
return frozenset(_bresenham(origin, end)[:length_cells])
def _bresenham(start: Pos, end: Pos) -> list[Pos]:
"""Inclusive Bresenham cell list from ``start`` to ``end`` (both ends kept)."""
row, col = start
end_row, end_col = end
d_row = abs(end_row - row)
d_col = abs(end_col - col)
step_row = 1 if row < end_row else -1
step_col = 1 if col < end_col else -1
err = d_row - d_col
cells: list[Pos] = [(row, col)]
while (row, col) != (end_row, end_col):
e2 = 2 * err
if e2 > -d_col:
err -= d_col
row += step_row
if e2 < d_row:
err += d_row
col += step_col
cells.append((row, col))
return cells
+37 -5
View File
@@ -121,6 +121,7 @@ from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from pf1e_simulator.areas import cone_cells, line_cells
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
from pf1e_simulator.dice import dice_to_str from pf1e_simulator.dice import dice_to_str
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
@@ -5888,16 +5889,21 @@ class CombatEngine:
center_pos: tuple[int, int] | None = None, center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none", save_type: SaveType = "none",
) -> str: ) -> str:
"""Apply an area effect to all creatures within radius. """Apply an area effect to all creatures within its template.
The burst is centered on ``center_pos`` when given (U7b point PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on
targeting), otherwise on the resolved target's position. ``center_pos`` when given (U7b point targeting), otherwise on the
resolved target's position; a cone or line starts at the CASTER's
square and is aimed at that same resolved cell (the cone "shoots
away from you in a quarter-circle in the direction you designate",
the line "shoots away from you in a line in the direction you
designate"). Every creature whose occupied cells intersect the
template is subject to the effect.
""" """
radius_cells = effect.radius_ft // _SQUARE_FT
center = _burst_center(target, center_pos) center = _burst_center(target, center_pos)
if center is None: if center is None:
return "no targets in area" return "no targets in area"
affected = self._creatures_in_range(center, radius_cells) affected = self._area_creatures(effect, caster, center)
if not affected: if not affected:
return "no targets in area" return "no targets in area"
@@ -5933,6 +5939,31 @@ class CombatEngine:
f"{total_conditions} conditions, {total_buffs} buffs" f"{total_conditions} conditions, {total_buffs} buffs"
) )
def _area_creatures(
self, effect: AreaEffect, caster: CombatantState, aim: tuple[int, int]
) -> list[CombatantState]:
"""Creatures intersecting the template of an area effect.
Burst: circle of ``radius_ft`` around the resolved center ``aim``
(unchanged behaviour). Cone/line: the template originates at the
caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a
Spell" — see ``areas.py``); a creature is affected when ANY of its
occupied cells lies inside the template.
"""
span_cells = effect.radius_ft // _SQUARE_FT
if effect.shape == "burst":
return self._creatures_in_range(aim, span_cells)
origin = caster.pos
if effect.shape == "cone":
cells = cone_cells(self._grid, origin, aim, span_cells)
else:
cells = line_cells(self._grid, origin, aim, span_cells)
return [
state
for state in self._states
if state.active and state.occupied_cells() & cells
]
def _creatures_in_range( def _creatures_in_range(
self, center: tuple[int, int], radius_cells: int self, center: tuple[int, int], radius_cells: int
) -> list[CombatantState]: ) -> list[CombatantState]:
@@ -6010,6 +6041,7 @@ class CombatEngine:
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration) return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
if isinstance(effect, AreaEffect): if isinstance(effect, AreaEffect):
return AreaEffect( return AreaEffect(
shape=effect.shape,
radius_ft=effect.radius_ft, radius_ft=effect.radius_ft,
effects=tuple( effects=tuple(
self._metamagic_transform_sub(sub, has_extend=has_extend) self._metamagic_transform_sub(sub, has_extend=has_extend)
+24 -4
View File
@@ -29,6 +29,10 @@ SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
SpellRange = Literal["personal", "touch", "close", "medium", "long"] SpellRange = Literal["personal", "touch", "close", "medium", "long"]
SaveType = Literal["fort", "ref", "will", "none"] SaveType = Literal["fort", "ref", "will", "none"]
# PF1e area templates (CRB, Magic - Aiming a Spell): burst (circle around a
# point), cone (90° quarter circle from the caster), line (5-ft ray from the
# caster to its length).
AreaShape = Literal["burst", "cone", "line"]
# PF1e casting times, normalized to budget codes (see combat._cast_time_slot). # PF1e casting times, normalized to budget codes (see combat._cast_time_slot).
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"] CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"]
@@ -66,14 +70,21 @@ class BuffEffect:
@dataclass(frozen=True) @dataclass(frozen=True)
class AreaEffect: class AreaEffect:
"""Area of effect spell (burst only for Phase 0). """Area of effect spell (burst / cone / line).
- shape: only "burst" supported (circle around target point) PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on a chosen
- radius_ft: radius in feet (e.g., 20 for fireball) point; a cone "shoots away from you in a quarter-circle in the direction
you designate"; a line "shoots away from you in a line in the direction
you designate" and "affects all creatures in squares through which the
line passes". Cones and lines therefore originate at the CASTER and are
aimed at the resolved target cell (or the U7b point-cast center).
- shape: "burst" (circle), "cone" (90° quarter circle) or "line" (5 ft wide)
- radius_ft: burst radius / cone length / line length, in feet
- effects: the effects applied to each creature in the area - effects: the effects applied to each creature in the area
- save_for_half: if True, successful save halves damage - save_for_half: if True, successful save halves damage
""" """
shape: Literal["burst"] = "burst" shape: AreaShape = "burst"
radius_ft: int = 20 radius_ft: int = 20
effects: tuple[SpellEffect, ...] = () effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True save_for_half: bool = True
@@ -195,6 +206,10 @@ def _is_save_type(value: str) -> TypeGuard[SaveType]:
return value in ("fort", "ref", "will", "none") return value in ("fort", "ref", "will", "none")
def _is_area_shape(value: str) -> TypeGuard[AreaShape]:
return value in get_args(AreaShape)
def _is_cast_time(value: str) -> TypeGuard[CastTime]: def _is_cast_time(value: str) -> TypeGuard[CastTime]:
return value in get_args(CastTime) return value in get_args(CastTime)
@@ -281,7 +296,12 @@ def _parse_effect(data: Json) -> SpellEffect:
duration_rounds=_as_int(effect.get("duration_rounds", 0)), duration_rounds=_as_int(effect.get("duration_rounds", 0)),
) )
if effect_type == "area": if effect_type == "area":
shape_raw = _as_str(effect.get("shape", "burst"))
if not _is_area_shape(shape_raw):
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
raise ValueError(msg)
return AreaEffect( return AreaEffect(
shape=shape_raw,
radius_ft=_as_int(effect.get("radius_ft", 20)), radius_ft=_as_int(effect.get("radius_ft", 20)),
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])), effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(effect.get("save_for_half", True)), save_for_half=bool(effect.get("save_for_half", True)),
+286
View File
@@ -0,0 +1,286 @@
"""Cone and line spell areas (Package S1a).
PF1e (CRB, Magic - Aiming a Spell): a cone "shoots away from you in a
quarter-circle in the direction you designate"; a line "shoots away from you
in a line in the direction you designate" and "affects all creatures in
squares through which the line passes". Both originate at the CASTER and are
aimed via ``cast_spell``'s existing ``target_id`` / ``target_pos``.
Factories are reused from ``tests/test_observation_enriched.py``; every roll
is scripted. The cone spell is save-less (save="none", save_for_half=False)
so each affected creature consumes exactly its damage dice — the scripted
queues also pin the EXACT set of creatures caught in each template.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pf1e_simulator.combat import Action, CombatantState, 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,
load_spell,
)
from tests.test_observation_enriched import make_combatant, make_state
if TYPE_CHECKING:
from pathlib import Path
_CONE_SPARKS = SpellSpec(
name="Cone Sparks",
level=1,
school="evocation",
range="close",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="cone",
radius_ft=15,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
),
save_for_half=False,
),
),
)
_LINE_SPARKS = SpellSpec(
name="Line Sparks",
level=2,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="line",
radius_ft=120,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),
),
save_for_half=False,
),
),
)
_BURST_SPARKS = SpellSpec(
name="Burst Sparks",
level=3,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
radius_ft=20,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
),
save_for_half=False,
),
),
)
_REGISTRY = {
"Cone Sparks": _CONE_SPARKS,
"Line Sparks": _LINE_SPARKS,
"Burst Sparks": _BURST_SPARKS,
}
def _open_grid(side: int = 12) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="open", terrain=tuple(["." * side] * side), legend=legend)
return Grid.from_spec(spec)
def _engine(states: list[CombatantState], rolls: list[int]) -> CombatEngine:
return CombatEngine(
ScriptedRng(rolls), _open_grid(), states, spell_registry=_REGISTRY
)
def _caster(hp: int = 100) -> CombatantState:
return make_state(
make_combatant(
"caster",
hp=hp,
spells=["Cone Sparks", "Line Sparks", "Burst Sparks"],
spell_slots={1: 9, 2: 9, 3: 9},
),
"players",
(6, 2),
)
# ---------------------------------------------------------------------------
# cone: 15 ft in front of the caster, ±45° wide, origin cell excluded
# ---------------------------------------------------------------------------
def test_cone_hits_cells_in_front_and_not_behind() -> None:
"""Given: caster (6,2); foe_front (6,4) east, foe_behind (6,0) west,
foe_side (2,2) north, foe_far (6,7) east beyond the 15-ft reach
When: cast a 15-ft cone aimed at foe_front (target_id)
Then: only foe_front (in front, within 3 cells) is hit; the behind/side
foes fall outside the ±45° quarter circle and foe_far exceeds the length.
The caster's own cell (the origin) is excluded by the cone template."""
caster = _caster()
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 4))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 0))
foe_side = make_state(make_combatant("foe_side", hp=100), "monsters", (2, 2))
foe_far = make_state(make_combatant("foe_far", hp=100), "monsters", (6, 7))
eng = _engine([caster, foe_front, foe_behind, foe_side, foe_far], [4])
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
assert foe_side.hp == 100
assert foe_far.hp == 100
assert caster.hp == 100 # origin cell excluded — the caster is never caught
def test_cone_covers_the_full_90_degree_arc() -> None:
"""Given: caster (6,2) aiming east; foes on the straight axis (6,4), the
45° edge (4,4) and just off the edge (3,4)
When: the 15-ft cone resolves
Then: the axis and the 45° edge cell are inside the quarter circle; the
off-edge cell (beyond ±45°) is untouched."""
caster = _caster()
foe_axis = make_state(make_combatant("foe_axis", hp=100), "monsters", (6, 4))
foe_edge = make_state(make_combatant("foe_edge", hp=100), "monsters", (4, 4))
foe_outside = make_state(make_combatant("foe_outside", hp=100), "monsters", (3, 4))
eng = _engine([caster, foe_axis, foe_edge, foe_outside], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_axis", spell_name="Cone Sparks")
)
assert foe_axis.hp == 100 - 4
assert foe_edge.hp == 100 - 4
assert foe_outside.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)
When: the point cast resolves
Then: the direction is caster -> point; the foe at (6,4) on that bearing
is caught, the north foe is not."""
caster = _caster()
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (6, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 2))
eng = _engine([caster, foe_in, foe_out], [4])
result = eng._execute(
caster, Action(kind="cast_spell", spell_name="Cone Sparks", target_pos=(6, 5))
)
assert result.ok
assert foe_in.hp == 100 - 4
assert foe_out.hp == 100
# ---------------------------------------------------------------------------
# line: 120 ft straight ray, 5 ft wide — only the cells it passes through
# ---------------------------------------------------------------------------
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
row away, foe_west (6,0) is behind the caster
When: cast the 120-ft line aimed at foe_ray
Then: every creature ON the ray (the caster's own cell — the line starts
at your fingertips — and foe_ray) takes damage; the off-row and the
behind-caster foes are outside the 5-ft-wide template."""
caster = _caster()
foe_ray = make_state(make_combatant("foe_ray", hp=100), "monsters", (6, 5))
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (5, 5))
foe_west = make_state(make_combatant("foe_west", hp=100), "monsters", (6, 0))
eng = _engine([caster, foe_ray, foe_off, foe_west], [4, 4])
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_ray", spell_name="Line Sparks")
)
assert result.ok
assert foe_ray.hp == 100 - 4
assert caster.hp == 100 - 4 # the line's first square is the caster's own
assert foe_off.hp == 100
assert foe_west.hp == 100
def test_line_follows_a_diagonal_bresenham_ray() -> None:
"""Given: caster (6,2) aiming diagonally at foe_diag (2,6)
When: the line resolves
Then: the diagonal ray cells are affected (foe_diag is hit) while a cell
beside the ray (foe_off at (2,5)) is not."""
caster = _caster()
foe_diag = make_state(make_combatant("foe_diag", hp=100), "monsters", (2, 6))
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (2, 5))
eng = _engine([caster, foe_diag, foe_off], [4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_diag", spell_name="Line Sparks")
)
assert foe_diag.hp == 100 - 4
assert foe_off.hp == 100
# ---------------------------------------------------------------------------
# burst: unchanged circle around the resolved center
# ---------------------------------------------------------------------------
def test_burst_still_centers_on_the_target_and_ignores_the_caster_side() -> None:
"""Given: caster (6,2); foe_in (4,4) and foe_out (2,0) around a burst
centered on target_id=foe_in (radius 20 ft = 4 cells)
When: the burst cast resolves
Then: the circle around the TARGET cell decides — foe_in (distance 0)
and the caster (distance 3) are inside, foe_out (distance 5) is not."""
caster = _caster()
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (4, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 0))
eng = _engine([caster, foe_in, foe_out], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_in", spell_name="Burst Sparks")
)
assert foe_in.hp == 100 - 4
assert caster.hp == 100 - 4
assert foe_out.hp == 100
# ---------------------------------------------------------------------------
# loader: shape parsing
# ---------------------------------------------------------------------------
def test_loader_parses_cone_and_line_shapes(tmp_path: Path) -> None:
"""Given: area effects declaring "shape": "cone" / "line" / (absent)
When: load_spell parses them
Then: the shapes are carried on the AreaEffect; an absent shape defaults
to "burst"; an unknown shape is a data error."""
spell_file = tmp_path / "shapes.json"
spell_file.write_text(
'{"name": "Shapes", "level": 1, "school": "evocation",'
' "range": "close", "save": "ref", "effects": ['
' {"type": "area", "shape": "cone", "radius_ft": 15, "effects": []},'
' {"type": "area", "shape": "line", "radius_ft": 120, "effects": []},'
' {"type": "area", "radius_ft": 20, "effects": []}]}'
)
spell = load_spell(spell_file)
areas = [e for e in spell.effects if isinstance(e, AreaEffect)]
assert [a.shape for a in areas] == ["cone", "line", "burst"]
bad_file = tmp_path / "bad_shape.json"
bad_file.write_text(
'{"name": "Bad", "level": 1, "school": "evocation",'
' "range": "close", "save": "none", "effects": ['
' {"type": "area", "shape": "spiral", "radius_ft": 10, "effects": []}]}'
)
with pytest.raises(ValueError, match="unknown area shape"):
load_spell(bad_file)