From 642f449b9e4bccd38b54bafb0e13878fc0a641ec Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 11:30:30 +0200 Subject: [PATCH] fix(rules): exclude the caster from a line spell's area CRB Magic - Aiming a Spell: a line 'starts from any corner of your square', so the caster's own square is outside the template (SRD Lightning Bolt: 'The bolt begins at your fingertips' - it leaves the square, it does not fill it). line_cells now drops the origin and takes the length_cells squares beyond it; a degenerate aim affects nothing. Updated the Lightning Bolt and Line Sparks tests (caster no longer caught) and the README. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 9 ++++++--- src/pf1e_simulator/areas.py | 32 ++++++++++++++++++++------------ tests/test_area_shapes.py | 7 ++++--- tests/test_spells.py | 14 ++++++++------ 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index de50fdc..3af15c6 100644 --- a/README.md +++ b/README.md @@ -916,9 +916,12 @@ Règles modélisées : largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large, longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le - cône exclut la case d'origine, la ligne commence dans la case du jeteur - (« the bolt begins at your fingertips », SRD Lightning Bolt : le jeteur est - donc dans sa propre ligne). Toute créature dont une case occupée intersecte + cône exclut la case d'origine, la ligne part d'un **coin** de la case du + jeteur et n'affecte **pas** sa propre case (CRB « Aiming a Spell » : « It + starts from any corner of your square » — SRD Lightning Bolt : « The bolt + begins at your fingertips », le trait quitte la case du jeteur au lieu de la + remplir ; un viseur dégénéré origine==cible n'affecte personne). Toute + créature dont une case occupée intersecte le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un burst (application partagée, géométrie dans `areas.py`). Le snapshot d'observation expose `{shape, radius_ft}` via la clé `area`. diff --git a/src/pf1e_simulator/areas.py b/src/pf1e_simulator/areas.py index a735c1a..dfb95ed 100644 --- a/src/pf1e_simulator/areas.py +++ b/src/pf1e_simulator/areas.py @@ -11,8 +11,11 @@ PF1e (CRB, Magic — Aiming a Spell): 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). + line's length. The line "starts from any corner of your square", so the + CASTER's own square is NOT one of the affected squares: the affected + squares are the ones beyond the origin along the ray (SRD Lightning Bolt: + "The bolt begins at your fingertips" — it leaves your square, it does not + fill it). Bursts need no template here: they are circles around a center cell and are resolved by ``CombatEngine._creatures_in_range`` (unchanged). @@ -74,23 +77,28 @@ def line_cells( 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. + its range"). The line "starts from any corner of your square" (CRB, Magic + — Aiming a Spell), so the CASTER's own square is NOT affected: the + ``length_cells`` affected squares are the ones *beyond* the origin along + the ray. Out-of-bounds cells simply drop out of the template (creatures + only exist in bounds). A degenerate aim (origin == aim) affects nothing. """ 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)) + return frozenset() + # Extend the aim vector so the ray reaches `length_cells` squares BEYOND + # the origin (the dominant-axis step count of a Bresenham line is + # max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop. + scale = (length_cells + 1) / 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]) + # The line is `length_cells` squares LONG starting just past the caster: + # 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning + # Bolt: "The bolt begins at your fingertips" — it leaves your square). + ray = _bresenham(origin, end) + return frozenset(cell for cell in ray[1 : length_cells + 1] if grid.in_bounds(cell)) def _bresenham(start: Pos, end: Pos) -> list[Pos]: diff --git a/tests/test_area_shapes.py b/tests/test_area_shapes.py index 5c1fb9b..67d5330 100644 --- a/tests/test_area_shapes.py +++ b/tests/test_area_shapes.py @@ -197,8 +197,9 @@ 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 + Then: every creature ON the ray beyond the caster's own square (foe_ray) + takes damage; the caster's own cell is NOT part of the line (CRB: it + "starts from any corner of your square"), and 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)) @@ -210,7 +211,7 @@ def test_line_hits_the_straight_ray_only() -> None: ) 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 caster.hp == 100 # the caster's own square is outside the line assert foe_off.hp == 100 assert foe_west.hp == 100 diff --git a/tests/test_spells.py b/tests/test_spells.py index 483e257..ac40911 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -462,22 +462,24 @@ class TestLightningBoltSave: 1d6 per caster level max 10d6, ref save for half) cast by the level-5 fixture at a target straight ahead When: the primary target succeeds its save - Then: damage is halved — and the caster, whose own square the line - starts in ("the bolt begins at your fingertips"), is caught as well.""" + Then: damage is halved — and the caster is NOT caught by their own line + (CRB, Magic — Aiming a Spell: the line "starts from any corner of your + square", so the caster's own square is outside the template).""" def test_successful_save_still_halves(self) -> None: caster = _make_caster() target = _make_target(hp_max=200) - # primary save (target) + secondary save (caster, same line) + - # 5 damage dice each (1d6/level, level 5) + # primary save (target, SAVED on a natural 20) + 5 damage dice + # (1d6/level, level 5); the caster is no longer in the line so no + # secondary save and no caster damage dice are rolled. engine, c, t = _make_engine( - caster, target, [20, 20] + [3] * 10 + caster, target, [20] + [3] * 5 ) hp_before = t.hp engine._cast_spell(c, t, "Lightning Bolt") full = 3 + 3 + 3 + 3 + 3 assert t.hp == hp_before - (full // 2) - assert c.hp == 20 - (full // 2) + assert c.hp == 20 # the caster's own square is outside the line class TestDamageEffectHalfOnSaveDefault: