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