diff --git a/src/pf1e_simulator/areas.py b/src/pf1e_simulator/areas.py new file mode 100644 index 0000000..a735c1a --- /dev/null +++ b/src/pf1e_simulator/areas.py @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c09c65b..bafdb62 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index f5664f3..e686aab 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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)), diff --git a/tests/test_area_shapes.py b/tests/test_area_shapes.py new file mode 100644 index 0000000..5c1fb9b --- /dev/null +++ b/tests/test_area_shapes.py @@ -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)