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 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.dice import dice_to_str
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
@@ -5888,16 +5889,21 @@ class CombatEngine:
center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> 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
targeting), otherwise on the resolved target's position.
PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on
``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)
if center is None:
return "no targets in area"
affected = self._creatures_in_range(center, radius_cells)
affected = self._area_creatures(effect, caster, center)
if not affected:
return "no targets in area"
@@ -5933,6 +5939,31 @@ class CombatEngine:
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(
self, center: tuple[int, int], radius_cells: int
) -> list[CombatantState]:
@@ -6010,6 +6041,7 @@ class CombatEngine:
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
if isinstance(effect, AreaEffect):
return AreaEffect(
shape=effect.shape,
radius_ft=effect.radius_ft,
effects=tuple(
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"]
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).
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"]
@@ -66,14 +70,21 @@ class BuffEffect:
@dataclass(frozen=True)
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)
- radius_ft: radius in feet (e.g., 20 for fireball)
PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on a chosen
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
- save_for_half: if True, successful save halves damage
"""
shape: Literal["burst"] = "burst"
shape: AreaShape = "burst"
radius_ft: int = 20
effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True
@@ -195,6 +206,10 @@ def _is_save_type(value: str) -> TypeGuard[SaveType]:
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]:
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)),
)
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(
shape=shape_raw,
radius_ft=_as_int(effect.get("radius_ft", 20)),
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(effect.get("save_for_half", True)),