From 26f86f416d094dcbd2329f1aca0136e6286a4d31 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 15:19:36 +0200 Subject: [PATCH] feat(combat): allow point-targeted spell areas --- README.md | 17 +- src/pf1e_simulator/combat.py | 267 ++++++++++++++++++++----- tests/test_point_targeting.py | 362 ++++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+), 47 deletions(-) create mode 100644 tests/test_point_targeting.py diff --git a/README.md b/README.md index 0317755..977814e 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,9 @@ Règles modélisées : `ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False, reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False, reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → - `ok=False, reason="weapon_unavailable"`. + `ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos` + hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"` + (U7b). - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») résout `count` balayages indépendants dans la même action ; les balayages restants sont perdus si la cible tombe (inconsciente ou morte) en cours de @@ -515,7 +517,18 @@ Règles modélisées : sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` - pour les explosions). `default_policy` choisit soin / offensif / buff via + pour les explosions). Ciblage par point (U7b) : `Action(kind="cast_spell", + target_pos=(r, c))` centre l'`AreaEffect` de niveau supérieur du sort sur la + case `(r, c)` désignée au lieu de la position de la cible créature — poser un + Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte + et avoir une ligne d'effet depuis le jeteur (sinon rejet `invalid_destination`, + aucun sort lancé). Avec `target_pos` et pas de cible résolue, l'incantation se + fait sur le point : la sauvegarde primaire est sautée et chaque créature de la + zone effectue sa propre sauvegarde (SR incluse). `target_pos` n'a de sens que + pour un sort à zone : pour un sort ciblé sans `AreaEffect`, il est ignoré et + la cible résolue est utilisée normalement. Sans `target_pos`, le comportement + centré sur la créature est inchangé. `default_policy` ne choisit pas de point + (disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via `_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`. - Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e34feaf..b6970a5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -201,6 +201,17 @@ def _save_label(result: SaveResult | None) -> str: return "SAVED" if result.success else "FAILED" +def _burst_center( + target: CombatantState | None, center_pos: tuple[int, int] | None +) -> tuple[int, int] | None: + """AoE center: the U7b point-targeting override wins over the target cell.""" + if center_pos is not None: + return center_pos + if target is not None: + return target.pos + return None + + def _spell_effect_summary(effect: SpellEffect) -> str: """Short serializable summary of one spell effect (never the description).""" if isinstance(effect, DamageEffect): @@ -561,6 +572,15 @@ class Action: auto-selected one; a choice that is unknown, broken, stolen, or unable to hit the target (range / line of effect) is rejected (``weapon_unavailable``). + ``cast_spell`` accepts ``target_pos``: an explicit ``(row, col)`` cell to + center the spell's ``AreaEffect`` on instead of the target creature's + position (a point-targeted Fireball). It is meaningful only for spells + with a top-level ``AreaEffect`` — for every other effect the resolved + target is used exactly as before, and ``target_pos`` is ignored. A point + outside the map or without line of effect from the caster is rejected + (``invalid_destination``); with ``target_pos`` and no resolved target the + spell is cast on the point and every creature in the area rolls its own + save. """ kind: Literal[ @@ -585,6 +605,7 @@ class Action: weapon_id: str | None = None ready_trigger: ReadyTrigger | None = None # ready action trigger event destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step + target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, @@ -596,14 +617,15 @@ class Action: # U5b adds not_prone / already_prone for the prone actions. U5c adds # unreachable_destination for move / 5foot_step with an explicit destination. # U5d adds weapon_unavailable for attack / full_attack with an explicit -# weapon_id that cannot be honoured. +# weapon_id that cannot be honoured. U7b adds invalid_destination for +# cast_spell with an explicit target_pos that cannot be honoured. ACTION_RESULT_REASONS: frozenset[str] = frozenset({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "no_line_of_effect", "no_slot", "insufficient_resource", "downgraded:staggered", "downgraded:nauseated", "delayed", "already_delayed", "ready", "not_prone", "already_prone", "unreachable_destination", - "weapon_unavailable", + "weapon_unavailable", "invalid_destination", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" @@ -618,6 +640,7 @@ _REASON_NOT_PRONE = "not_prone" _REASON_ALREADY_PRONE = "already_prone" _REASON_UNREACHABLE_DESTINATION = "unreachable_destination" _REASON_WEAPON_UNAVAILABLE = "weapon_unavailable" +_REASON_INVALID_DESTINATION = "invalid_destination" _REASON_NO_WEAPON = "no_weapon" _REASON_OUT_OF_RANGE = "out_of_range" _REASON_NO_LINE_OF_EFFECT = "no_line_of_effect" @@ -2547,11 +2570,14 @@ class CombatEngine: target: CombatantState | None, action: Action, ) -> ActionResult | None: - """U1 missing-target gate; move/5foot_step accept a destination instead (U5c).""" + """U1 missing-target gate; move/5foot_step accept a destination instead (U5c); + cast_spell accepts a target_pos instead (U7b).""" if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS: return None if kind in ("move", "5foot_step") and action.destination is not None: return None + if kind == "cast_spell" and action.target_pos is not None: + return None self._log( f"round {self._current_round} {state.combatant.id}: " f"{kind} rejected ({_REASON_MISSING_TARGET})" @@ -2714,13 +2740,16 @@ class CombatEngine: """Route a validated action to its execution helper. Returns an explicit rejection when a destination-bearing movement - cannot be honoured (U5c) or an attack's chosen weapon is unavailable - (U5d); None means the helper ran and the caller reports ok. + cannot be honoured (U5c), an attack's chosen weapon is unavailable + (U5d), or a spell's point target is off-map / without line of effect + (U7b); None means the helper ran and the caller reports ok. """ kind = action.kind if kind == "cast_spell": - self._execute_cast_spell(state, target, action.spell_name) - elif kind == "maneuver": + return self._execute_cast_spell( + state, target, action.spell_name, target_pos=action.target_pos + ) + if kind == "maneuver": self._execute_maneuver( state, target, action.maneuver, direction=action.direction, trick_condition=action.trick_condition, @@ -2801,7 +2830,9 @@ class CombatEngine: """ state.immediate_used = True if action.spell_name is not None: - self._execute_cast_spell(state, target, action.spell_name) + self._execute_cast_spell( + state, target, action.spell_name, target_pos=action.target_pos + ) elif action.maneuver is not None: self._execute_maneuver(state, target, action.maneuver) elif action.class_ability is not None: @@ -2915,10 +2946,36 @@ class CombatEngine: state: CombatantState, target: CombatantState | None, spell_name: str | None, - ) -> None: + *, + target_pos: tuple[int, int] | None = None, + ) -> ActionResult | None: + """Cast the carried spell; an explicit ``target_pos`` (U7b) is + validated first — an off-map point or one without line of effect + from the caster is rejected without casting.""" + if target_pos is not None: + rejection = self._point_target_rejection(state, target_pos) + if rejection is not None: + return rejection if spell_name is None: - return - self._cast_spell(state, target, spell_name) + return None + self._cast_spell(state, target, spell_name, center_pos=target_pos) + return None + + def _point_target_rejection( + self, state: CombatantState, target_pos: tuple[int, int] + ) -> ActionResult | None: + """U7b gate: a point-targeted area must be in bounds and reachable.""" + if self._grid.in_bounds(target_pos) and has_line_of_effect_cells( + self._grid, state.occupied_cells(), frozenset({target_pos}) + ): + return None + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"cast_spell rejected ({_REASON_INVALID_DESTINATION})" + ) + return ActionResult( + kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION + ) def _execute_maneuver( self, @@ -3740,8 +3797,22 @@ class CombatEngine: } def _cast_spell( - self, caster: CombatantState, target: CombatantState | None, spell_name: str + self, + caster: CombatantState, + target: CombatantState | None, + spell_name: str, + *, + center_pos: tuple[int, int] | None = None, ) -> None: + """Cast ``spell_name``. + + ``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell + instead of the target creature's position. It is honoured only for + spells carrying an area; every other effect still resolves on the + target. A cast with no resolved target requires ``center_pos`` and an + area effect — the primary save is skipped and each creature in the + area rolls its own. + """ spell = self._spell_registry.get(spell_name) if spell is None: self._log( @@ -3749,16 +3820,49 @@ class CombatEngine: f"cast {spell_name} -> UNKNOWN SPELL" ) return - if target is None or not target.active: + has_area = any(isinstance(e, AreaEffect) for e in spell.effects) + if self._spell_cast_rejected( + caster, target, spell, spell_name, center_pos, has_area=has_area + ): return + # Check for AoO from casting in threatened square (before resolving spell) + if self._check_casting_aoo(caster, spell.level): + return + caster.spend_spell_slot(spell.level) + if ( + target is not None + and not has_area + and not self._check_spell_resistance(caster, target) + ): + return + self._apply_and_log_cast(caster, target, spell, spell_name, center_pos) + + def _spell_cast_rejected( + self, + caster: CombatantState, + target: CombatantState | None, + spell: SpellSpec, + spell_name: str, + center_pos: tuple[int, int] | None, + *, + has_area: bool, + ) -> bool: + """Pre-cast gates: resolvable target (or U7b point cast of an area + spell), spell range and line of effect. True = not castable.""" + if target is None: + point_ok = self._point_cast_castable( + caster, spell, spell_name, center_pos, has_area=has_area + ) + return not point_ok + if not target.active: + return True dist_ft = self.creature_distance(caster, target) * _SQUARE_FT - max_range = spell_range_ft(spell, caster.combatant.level) - if dist_ft > max_range: + if dist_ft > spell_range_ft(spell, caster.combatant.level): self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> OUT OF RANGE" ) - return + return True if not has_line_of_effect_cells( self._grid, caster.occupied_cells(), target.occupied_cells() ): @@ -3766,48 +3870,87 @@ class CombatEngine: f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> NO LINE OF EFFECT" ) - return - # Check for AoO from casting in threatened square (before resolving spell) - if self._check_casting_aoo(caster, spell.level): - return - caster.spend_spell_slot(spell.level) - _has_area = any(isinstance(e, AreaEffect) for e in spell.effects) - if not _has_area and not self._check_spell_resistance(caster, target): - return + return True + return False + + def _point_cast_castable( + self, + caster: CombatantState, + spell: SpellSpec, + spell_name: str, + center_pos: tuple[int, int] | None, + *, + has_area: bool, + ) -> bool: + """U7b point cast (no resolved target): needs an area spell centered + on ``center_pos``, within the spell's range of the caster.""" + if center_pos is None or not has_area: + return False + point_dist = min( + self._grid.distance(cell, center_pos) + for cell in caster.occupied_cells() + ) * _SQUARE_FT + if point_dist > spell_range_ft(spell, caster.combatant.level): + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} -> OUT OF RANGE" + ) + return False + return True + + def _apply_and_log_cast( + self, + caster: CombatantState, + target: CombatantState | None, + spell: SpellSpec, + spell_name: str, + center_pos: tuple[int, int] | None, + ) -> None: + """Resolve save, apply effects and log a spell cast (post-gates).""" dc = self._spell_dc(caster, spell) save_result = ( self.resolve_save( target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) ) - if spell.save != "none" + if spell.save != "none" and target is not None else None ) caster_live = self._stats[caster.combatant.id] - target_live = self._stats[target.combatant.id] + target_live = ( + self._stats[target.combatant.id] if target is not None else caster_live + ) # Apply metamagic feats to spell effects metamagic_feats = caster.combatant.metamagic_feats metamagic_effects = self._apply_metamagic(spell, metamagic_feats) - hp_before = target.hp + hp_before = target.hp if target is not None else caster.hp parts = self._apply_spell_effects_from_list( metamagic_effects, target, save_result, caster_live, target_live, caster=caster, spell_name=spell.name, empower="Empower Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic_feats, - dc=dc, + dc=dc, center_pos=center_pos, ) save_str = _save_label(save_result) - self._log( - f"round {self._current_round} {caster.combatant.id}: " - f"cast {spell_name} vs {target.combatant.id} " - f"DC{dc} {save_str} " - f"-> {'; '.join(parts)} " - f"({hp_before}->{target.hp})" - ) - if target.dead: + if target is None and center_pos is not None: self._log( f"round {self._current_round} {caster.combatant.id}: " - f"{target.combatant.id} dead" + f"cast {spell_name} at ({center_pos[0]},{center_pos[1]}) " + f"DC{dc} {save_str} " + f"-> {'; '.join(parts)}" ) + elif target is not None: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} vs {target.combatant.id} " + f"DC{dc} {save_str} " + f"-> {'; '.join(parts)} " + f"({hp_before}->{target.hp})" + ) + if target.dead: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"{target.combatant.id} dead" + ) self._check_reactions( ReactionEvent(kind="spell_cast", actor=caster, target=target) ) @@ -3829,7 +3972,7 @@ class CombatEngine: def _apply_spell_effects_from_list( self, effects: tuple[SpellEffect, ...], - target: CombatantState, + target: CombatantState | None, save_result: SaveResult | None, caster_live: _LiveStats, target_live: _LiveStats, @@ -3839,6 +3982,7 @@ class CombatEngine: empower: bool = False, maximize: bool = False, dc: int = 15, + center_pos: tuple[int, int] | None = None, ) -> list[str]: saved = save_result is not None and save_result.success return [ @@ -3846,7 +3990,7 @@ class CombatEngine: effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, empower=empower, maximize=maximize, saved=saved, - dc=dc, + dc=dc, center_pos=center_pos, ) for effect in effects ] @@ -3854,7 +3998,7 @@ class CombatEngine: def _apply_single_spell_effect( self, effect: SpellEffect, - target: CombatantState, + target: CombatantState | None, caster_live: _LiveStats, target_live: _LiveStats, caster: CombatantState, @@ -3864,15 +4008,38 @@ class CombatEngine: maximize: bool = False, saved: bool, dc: int = 15, + center_pos: tuple[int, int] | None = None, ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, saved=saved, empower=empower, maximize=maximize, dc=dc, + center_pos=center_pos, ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) + if target is None: + return "" + return self._apply_targeted_spell_effect( + effect, target, caster_live, target_live, + caster=caster, saved=saved, empower=empower, maximize=maximize, + spell_name=spell_name, + ) + + def _apply_targeted_spell_effect( + self, + effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect, + target: CombatantState, + caster_live: _LiveStats, + target_live: _LiveStats, + *, + caster: CombatantState, + saved: bool, + empower: bool, + maximize: bool, + spell_name: str, + ) -> str: if isinstance(effect, DamageEffect): return self._apply_damage_effect( effect, target, caster_live, target_live, @@ -3973,7 +4140,7 @@ class CombatEngine: def _apply_area_effect( self, effect: AreaEffect, - target: CombatantState, + target: CombatantState | None, caster_live: _LiveStats, target_live: _LiveStats, *, @@ -3983,13 +4150,17 @@ class CombatEngine: empower: bool = False, maximize: bool = False, dc: int = 15, + center_pos: tuple[int, int] | None = None, ) -> str: """Apply an area effect to all creatures within radius. - The target parameter is used as the center point for the burst. + The burst is centered on ``center_pos`` when given (U7b point + targeting), otherwise on the resolved target's position. """ radius_cells = effect.radius_ft // _SQUARE_FT - center = target.pos + center = _burst_center(target, center_pos) + if center is None: + return "no targets in area" affected = self._creatures_in_range(center, radius_cells) if not affected: @@ -4043,13 +4214,18 @@ class CombatEngine: def _area_save( self, affected_state: CombatantState, - target: CombatantState, + target: CombatantState | None, effect: AreaEffect, *, saved: bool, dc: int = 15, ) -> bool: - """Save result for one creature in an area effect.""" + """Save result for one creature in an area effect. + + The resolved primary target re-uses its already-rolled save; every + other creature in the area — and every creature of a point-targeted + burst (no primary target, U7b) — rolls its own. + """ if not effect.save_for_half: return False if affected_state is target: @@ -4753,6 +4929,7 @@ def _ready_payload(action: Action) -> Action: kind="cast_spell", target_id=action.target_id, spell_name=action.spell_name, + target_pos=action.target_pos, ) if action.maneuver is not None: return Action( diff --git a/tests/test_point_targeting.py b/tests/test_point_targeting.py new file mode 100644 index 0000000..1729dfb --- /dev/null +++ b/tests/test_point_targeting.py @@ -0,0 +1,362 @@ +"""Point-targeted spell areas (Package U7b). + +``Action(kind="cast_spell", target_pos=(row, col))`` centers a spell's +top-level ``AreaEffect`` on that cell instead of on the target creature's +position. Factories are reused from ``tests/test_observation_enriched.py`` +(``make_combatant``/``make_state``/``make_grid`` — an open 8x8 floor grid); +spell registries are hand-built like ``tests/test_spell_resistance.py``. +Casts go through ``engine._execute`` (the full action path: missing-target +gate, point validation, dispatch) and every roll is scripted — initiative is +never rolled because ``run`` is never called. A queue that is too short +raises ``RngExhaustedError``, so the scripted sequences also pin the EXACT +number of rolls (no primary save on point casts, per-creature saves only). + +Roll vocabulary for the Fireball-like spell (8d6 fire, ref DC 13 — caster +level 1, wis 10): one d20 per saving throw, then one d6 per damage die. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import ACTION_RESULT_REASONS, 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 +from tests.test_observation_enriched import make_combatant, make_grid, make_state + +_FIREBALL = SpellSpec( + name="Fireball", + level=3, + school="evocation", + range="long", + save="ref", + base_dc=10, + effects=( + AreaEffect( + radius_ft=20, + effects=( + DamageEffect( + formula=parse_dice("8d6"), + types=("fire",), + half_on_save=True, + ), + ), + save_for_half=True, + ), + ), +) + +_SPARK_BURST = SpellSpec( + name="Spark Burst", + level=2, + school="evocation", + range="long", + save="none", + base_dc=10, + effects=( + AreaEffect( + radius_ft=20, + effects=( + DamageEffect( + formula=parse_dice("8d6"), + types=("fire",), + half_on_save=False, + ), + ), + save_for_half=False, + ), + ), +) + +_MAGIC_MISSILE = SpellSpec( + name="Magic Missile", + level=1, + school="evocation", + range="medium", + save="none", + base_dc=10, + effects=( + DamageEffect( + formula=parse_dice("1d4+1"), + types=("force",), + ), + ), +) + +_REGISTRY = { + "Fireball": _FIREBALL, + "Spark Burst": _SPARK_BURST, + "Magic Missile": _MAGIC_MISSILE, +} + + +def _engine( + states: list[CombatantState], rolls: list[int], grid: Grid | None = None +) -> CombatEngine: + return CombatEngine( + ScriptedRng(rolls), grid or make_grid(), states, spell_registry=_REGISTRY + ) + + +def _cast_scene( + rolls: list[int], +) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]: + """Caster at (7,0); foe_in at (0,4) and foe_out at (4,7). + + A burst centered on (0,4) (radius 20 ft = 4 cells) covers foe_in + (distance 0) but not foe_out (distance 5) nor the caster (distance 9). + """ + caster = make_state( + make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0) + ) + foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (0, 4)) + foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (4, 7)) + return _engine([caster, foe_in, foe_out], rolls), caster, foe_in, foe_out + + +def _walled_grid() -> Grid: + """Open 8x8 floor with a blocking wall column at col 4.""" + legend = { + ".": TerrainType(type="floor", move_cost=1), + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + } + spec = MapSpec(name="walled", terrain=tuple(["....#..."] * 8), legend=legend) + return Grid.from_spec(spec) + + +# --------------------------------------------------------------------------- +# (a) a Fireball-like area spell cast at a point hits exactly the area +# --------------------------------------------------------------------------- + + +def test_point_cast_hits_only_creatures_in_radius() -> None: + """Given: caster (7,0), foe_in (0,4), foe_out (4,7), 9 scripted rolls + When: cast_spell with target_pos=(0,4) and NO target_id (point cast) + Then: ok; only foe_in takes damage — its own save fails (d20=2) and 8d6 + all 3s deal 24; nobody else is touched and no roll is wasted.""" + eng, caster, foe_in, foe_out = _cast_scene([2] + [3] * 8) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 4)) + ) + assert result.kind == "cast_spell" + assert result.ok + assert result.reason == "ok" + assert foe_in.hp == 100 - 24 + assert foe_out.hp == 100 + assert caster.hp == 100 + assert any("cast Fireball at (0,4)" in line for line in eng._transcript) + + +def test_point_cast_successful_save_halves_damage() -> None: + """Given: the same scene, scripted natural 20 for the in-area creature + When: the same point cast + Then: 8d6 all 3s = 24 halved to 12 on the saved creature.""" + eng, caster, foe_in, foe_out = _cast_scene([20] + [3] * 8) + eng._execute( + caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 4)) + ) + assert foe_in.hp == 100 - 12 + assert foe_out.hp == 100 + + +# --------------------------------------------------------------------------- +# (b) invalid destinations are rejected without casting +# --------------------------------------------------------------------------- + + +def test_point_cast_out_of_bounds_rejected_no_rolls_no_damage() -> None: + """Given: an empty roll queue and target_pos=(9,9) on the 8x8 map + When: cast_spell at that point + Then: invalid_destination rejection, nothing cast, NO die rolled + (any roll would raise RngExhaustedError) and no hp moved.""" + eng, caster, foe_in, foe_out = _cast_scene([]) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(9, 9)) + ) + assert result.kind == "cast_spell" + assert not result.ok + assert result.reason == "invalid_destination" + assert foe_in.hp == 100 + assert foe_out.hp == 100 + assert any("cast_spell rejected (invalid_destination)" in line for line in eng._transcript) + + +def test_point_cast_without_line_of_effect_rejected() -> None: + """Given: a wall column between caster (0,0) and the point (0,6) + When: cast_spell at that unseen point + Then: invalid_destination rejection without casting.""" + caster = make_state( + make_combatant("caster", hp=100, spells=["Fireball"]), "players", (0, 0) + ) + foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 6)) + eng = _engine([caster, foe], [], grid=_walled_grid()) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 6)) + ) + assert not result.ok + assert result.reason == "invalid_destination" + assert foe.hp == 100 + + +def test_invalid_destination_is_in_reason_vocabulary() -> None: + """Given: the normalized ActionResult reason vocabulary + When: checked for the U7b reason + Then: invalid_destination is declared.""" + assert "invalid_destination" in ACTION_RESULT_REASONS + + +# --------------------------------------------------------------------------- +# (c) point cast with no primary target: no crash, per-creature saves +# --------------------------------------------------------------------------- + + +def test_point_cast_rolls_a_save_per_creature_in_area() -> None: + """Given: two foes inside the burst (0,4) and (0,5), 18 scripted rolls + When: a target-less point cast at (0,4) + Then: no primary save is rolled (queue would shift otherwise) and EACH + creature rolls its own: foe_a fails (2) full 24, foe_b saves (20) half 12.""" + caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + foe_a = make_state(make_combatant("foe_a", hp=100), "monsters", (0, 4)) + foe_b = make_state(make_combatant("foe_b", hp=100), "monsters", (0, 5)) + eng = _engine([caster, foe_a, foe_b], [2] + [3] * 8 + [20] + [3] * 8) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Fireball", target_pos=(0, 4)) + ) + assert result.ok + assert foe_a.hp == 100 - 24 + assert foe_b.hp == 100 - 12 + + +def test_point_cast_no_save_area_spell_rolls_damage_dice_only() -> None: + """Given: a no-save area spell (save_for_half=False) and 8 scripted d6 + When: a target-less point cast + Then: no saving throw is rolled at all — only the 8 damage dice.""" + caster = make_state( + make_combatant("caster", hp=100, spells=["Spark Burst"]), "players", (7, 0) + ) + foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) + eng = _engine([caster, foe], [3] * 8) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Spark Burst", target_pos=(0, 4)) + ) + assert result.ok + assert foe.hp == 100 - 24 + + +# --------------------------------------------------------------------------- +# (d) target_pos=None pins the creature-centered behaviour +# --------------------------------------------------------------------------- + + +def test_creature_centered_cast_transcript_pinned() -> None: + """Given: caster (7,0) and foe (0,4), primary save scripted to fail + When: cast_spell with target_id and NO target_pos (the legacy path) + Then: the transcript is the pinned creature-centered line — primary save + rolled once, reused for the target in the area, 8d6 all 3s.""" + caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) + eng = _engine([caster, foe], [2] + [3] * 8) + result = eng._execute( + caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball") + ) + assert result.ok + assert result.reason == "ok" + assert eng._transcript == [ + ( + "round 0 caster: cast Fireball vs foe DC13 FAILED " + "-> area: 1 targets, 24 total damage, 0 conditions, 0 buffs (100->76)" + ) + ] + assert foe.hp == 76 + + +def test_target_pos_at_target_cell_matches_creature_centered() -> None: + """Given: the same scene, twice — once creature-centered, once with + target_pos set to the target's own cell (target_id kept) + When: both casts resolve with identical scripted rolls + Then: transcripts and hp outcomes are byte-identical.""" + caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) + plain = _engine([caster, foe], [2] + [3] * 8) + plain._execute( + caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball") + ) + caster2 = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + foe2 = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) + pointed = _engine([caster2, foe2], [2] + [3] * 8) + pointed._execute( + caster2, + Action(kind="cast_spell", target_id="foe", spell_name="Fireball", target_pos=(0, 4)), + ) + assert pointed._transcript == plain._transcript + assert foe2.hp == foe.hp + + +# --------------------------------------------------------------------------- +# (e) non-area spells ignore target_pos and resolve on their target +# --------------------------------------------------------------------------- + + +def test_non_area_spell_with_target_pos_resolves_on_target() -> None: + """Given: Magic Missile (no AreaEffect) cast with target_id AND target_pos + When: the cast resolves + Then: target_pos is ignored — the missile hits its creature (1d4+1 = 4) + and the transcript is the ordinary creature-centered line.""" + caster = make_state( + make_combatant("caster", hp=100, spells=["Magic Missile"]), "players", (0, 0) + ) + foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2)) + eng = _engine([caster, foe], [3]) + result = eng._execute( + caster, + Action(kind="cast_spell", target_id="foe", spell_name="Magic Missile", target_pos=(1, 1)), + ) + assert result.ok + assert result.reason == "ok" + assert foe.hp == 96 + assert eng._transcript == [ + "round 0 caster: cast Magic Missile vs foe DC11 NO SAVE -> 4 damage (100->96)" + ] + + +def test_non_area_point_cast_without_target_is_harmless_no_op() -> None: + """Given: Magic Missile (no area) with target_pos but NO target_id + When: the action is dispatched with an empty roll queue + Then: nothing crashes, nothing is cast, no die is rolled.""" + caster = make_state( + make_combatant("caster", hp=100, spells=["Magic Missile"]), "players", (0, 0) + ) + foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2)) + eng = _engine([caster, foe], []) + result = eng._execute( + caster, Action(kind="cast_spell", spell_name="Magic Missile", target_pos=(1, 1)) + ) + assert result.kind == "cast_spell" + assert foe.hp == 100 + + +# --------------------------------------------------------------------------- +# (f) the missing-target relaxation covers cast_spell only +# --------------------------------------------------------------------------- + + +def test_cast_spell_without_target_or_pos_still_missing_target() -> None: + """Given: cast_spell with neither target_id nor target_pos + When: executed with an empty roll queue + Then: the U1 missing_target rejection still fires.""" + eng, caster, foe_in, _ = _cast_scene([]) + result = eng._execute(caster, Action(kind="cast_spell", spell_name="Fireball")) + assert not result.ok + assert result.reason == "missing_target" + assert foe_in.hp == 100 + + +def test_other_kinds_not_relaxed_by_target_pos() -> None: + """Given: an attack carrying target_pos but no target_id + When: executed + Then: missing_target still fires — only cast_spell is relaxed.""" + eng, caster, _, _ = _cast_scene([]) + result = eng._execute(caster, Action(kind="attack", target_pos=(0, 4))) + assert not result.ok + assert result.reason == "missing_target"