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
+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)),