feat(combat): allow point-targeted spell areas

This commit is contained in:
2026-09-10 15:19:36 +02:00
parent 7fafbe43cf
commit 26f86f416d
3 changed files with 599 additions and 47 deletions
+15 -2
View File
@@ -368,7 +368,9 @@ Règles modélisées :
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False, `ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False, reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False,
reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → 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 ») - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages 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 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 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 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` (`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`. `_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 - 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 avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
+222 -45
View File
@@ -201,6 +201,17 @@ def _save_label(result: SaveResult | None) -> str:
return "SAVED" if result.success else "FAILED" 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: def _spell_effect_summary(effect: SpellEffect) -> str:
"""Short serializable summary of one spell effect (never the description).""" """Short serializable summary of one spell effect (never the description)."""
if isinstance(effect, DamageEffect): if isinstance(effect, DamageEffect):
@@ -561,6 +572,15 @@ class Action:
auto-selected one; a choice that is unknown, broken, stolen, or unable to auto-selected one; a choice that is unknown, broken, stolen, or unable to
hit the target (range / line of effect) is rejected hit the target (range / line of effect) is rejected
(``weapon_unavailable``). (``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[ kind: Literal[
@@ -585,6 +605,7 @@ class Action:
weapon_id: str | None = None weapon_id: str | None = None
ready_trigger: ReadyTrigger | None = None # ready action trigger event ready_trigger: ReadyTrigger | None = None # ready action trigger event
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step 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, # 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 # U5b adds not_prone / already_prone for the prone actions. U5c adds
# unreachable_destination for move / 5foot_step with an explicit destination. # unreachable_destination for move / 5foot_step with an explicit destination.
# U5d adds weapon_unavailable for attack / full_attack with an explicit # 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({ ACTION_RESULT_REASONS: frozenset[str] = frozenset({
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
"no_line_of_effect", "no_slot", "insufficient_resource", "no_line_of_effect", "no_slot", "insufficient_resource",
"downgraded:staggered", "downgraded:nauseated", "downgraded:staggered", "downgraded:nauseated",
"delayed", "already_delayed", "ready", "delayed", "already_delayed", "ready",
"not_prone", "already_prone", "unreachable_destination", "not_prone", "already_prone", "unreachable_destination",
"weapon_unavailable", "weapon_unavailable", "invalid_destination",
}) })
_REASON_OK = "ok" _REASON_OK = "ok"
_REASON_UNSUPPORTED_KIND = "unsupported_kind" _REASON_UNSUPPORTED_KIND = "unsupported_kind"
@@ -618,6 +640,7 @@ _REASON_NOT_PRONE = "not_prone"
_REASON_ALREADY_PRONE = "already_prone" _REASON_ALREADY_PRONE = "already_prone"
_REASON_UNREACHABLE_DESTINATION = "unreachable_destination" _REASON_UNREACHABLE_DESTINATION = "unreachable_destination"
_REASON_WEAPON_UNAVAILABLE = "weapon_unavailable" _REASON_WEAPON_UNAVAILABLE = "weapon_unavailable"
_REASON_INVALID_DESTINATION = "invalid_destination"
_REASON_NO_WEAPON = "no_weapon" _REASON_NO_WEAPON = "no_weapon"
_REASON_OUT_OF_RANGE = "out_of_range" _REASON_OUT_OF_RANGE = "out_of_range"
_REASON_NO_LINE_OF_EFFECT = "no_line_of_effect" _REASON_NO_LINE_OF_EFFECT = "no_line_of_effect"
@@ -2547,11 +2570,14 @@ class CombatEngine:
target: CombatantState | None, target: CombatantState | None,
action: Action, action: Action,
) -> ActionResult | None: ) -> 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: if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS:
return None return None
if kind in ("move", "5foot_step") and action.destination is not None: if kind in ("move", "5foot_step") and action.destination is not None:
return None return None
if kind == "cast_spell" and action.target_pos is not None:
return None
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_MISSING_TARGET})" f"{kind} rejected ({_REASON_MISSING_TARGET})"
@@ -2714,13 +2740,16 @@ class CombatEngine:
"""Route a validated action to its execution helper. """Route a validated action to its execution helper.
Returns an explicit rejection when a destination-bearing movement Returns an explicit rejection when a destination-bearing movement
cannot be honoured (U5c) or an attack's chosen weapon is unavailable cannot be honoured (U5c), an attack's chosen weapon is unavailable
(U5d); None means the helper ran and the caller reports ok. (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 kind = action.kind
if kind == "cast_spell": if kind == "cast_spell":
self._execute_cast_spell(state, target, action.spell_name) return self._execute_cast_spell(
elif kind == "maneuver": state, target, action.spell_name, target_pos=action.target_pos
)
if kind == "maneuver":
self._execute_maneuver( self._execute_maneuver(
state, target, action.maneuver, state, target, action.maneuver,
direction=action.direction, trick_condition=action.trick_condition, direction=action.direction, trick_condition=action.trick_condition,
@@ -2801,7 +2830,9 @@ class CombatEngine:
""" """
state.immediate_used = True state.immediate_used = True
if action.spell_name is not None: 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: elif action.maneuver is not None:
self._execute_maneuver(state, target, action.maneuver) self._execute_maneuver(state, target, action.maneuver)
elif action.class_ability is not None: elif action.class_ability is not None:
@@ -2915,10 +2946,36 @@ class CombatEngine:
state: CombatantState, state: CombatantState,
target: CombatantState | None, target: CombatantState | None,
spell_name: str | 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: if spell_name is None:
return return None
self._cast_spell(state, target, spell_name) 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( def _execute_maneuver(
self, self,
@@ -3740,8 +3797,22 @@ class CombatEngine:
} }
def _cast_spell( 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: ) -> 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) spell = self._spell_registry.get(spell_name)
if spell is None: if spell is None:
self._log( self._log(
@@ -3749,16 +3820,49 @@ class CombatEngine:
f"cast {spell_name} -> UNKNOWN SPELL" f"cast {spell_name} -> UNKNOWN SPELL"
) )
return 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 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 dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
max_range = spell_range_ft(spell, caster.combatant.level) if dist_ft > spell_range_ft(spell, caster.combatant.level):
if dist_ft > max_range:
self._log( self._log(
f"round {self._current_round} {caster.combatant.id}: " f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> OUT OF RANGE" f"cast {spell_name} -> OUT OF RANGE"
) )
return return True
if not has_line_of_effect_cells( if not has_line_of_effect_cells(
self._grid, caster.occupied_cells(), target.occupied_cells() self._grid, caster.occupied_cells(), target.occupied_cells()
): ):
@@ -3766,48 +3870,87 @@ class CombatEngine:
f"round {self._current_round} {caster.combatant.id}: " f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> NO LINE OF EFFECT" f"cast {spell_name} -> NO LINE OF EFFECT"
) )
return return True
# Check for AoO from casting in threatened square (before resolving spell) return False
if self._check_casting_aoo(caster, spell.level):
return def _point_cast_castable(
caster.spend_spell_slot(spell.level) self,
_has_area = any(isinstance(e, AreaEffect) for e in spell.effects) caster: CombatantState,
if not _has_area and not self._check_spell_resistance(caster, target): spell: SpellSpec,
return 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) dc = self._spell_dc(caster, spell)
save_result = ( save_result = (
self.resolve_save( self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) 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 else None
) )
caster_live = self._stats[caster.combatant.id] 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 # Apply metamagic feats to spell effects
metamagic_feats = caster.combatant.metamagic_feats metamagic_feats = caster.combatant.metamagic_feats
metamagic_effects = self._apply_metamagic(spell, 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( parts = self._apply_spell_effects_from_list(
metamagic_effects, target, save_result, caster_live, target_live, metamagic_effects, target, save_result, caster_live, target_live,
caster=caster, spell_name=spell.name, caster=caster, spell_name=spell.name,
empower="Empower Spell" in metamagic_feats, empower="Empower Spell" in metamagic_feats,
maximize="Maximize Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic_feats,
dc=dc, dc=dc, center_pos=center_pos,
) )
save_str = _save_label(save_result) save_str = _save_label(save_result)
self._log( if target is None and center_pos is not None:
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( self._log(
f"round {self._current_round} {caster.combatant.id}: " 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( self._check_reactions(
ReactionEvent(kind="spell_cast", actor=caster, target=target) ReactionEvent(kind="spell_cast", actor=caster, target=target)
) )
@@ -3829,7 +3972,7 @@ class CombatEngine:
def _apply_spell_effects_from_list( def _apply_spell_effects_from_list(
self, self,
effects: tuple[SpellEffect, ...], effects: tuple[SpellEffect, ...],
target: CombatantState, target: CombatantState | None,
save_result: SaveResult | None, save_result: SaveResult | None,
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
@@ -3839,6 +3982,7 @@ class CombatEngine:
empower: bool = False, empower: bool = False,
maximize: bool = False, maximize: bool = False,
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None,
) -> list[str]: ) -> list[str]:
saved = save_result is not None and save_result.success saved = save_result is not None and save_result.success
return [ return [
@@ -3846,7 +3990,7 @@ class CombatEngine:
effect, target, caster_live, target_live, effect, target, caster_live, target_live,
caster=caster, spell_name=spell_name, caster=caster, spell_name=spell_name,
empower=empower, maximize=maximize, saved=saved, empower=empower, maximize=maximize, saved=saved,
dc=dc, dc=dc, center_pos=center_pos,
) )
for effect in effects for effect in effects
] ]
@@ -3854,7 +3998,7 @@ class CombatEngine:
def _apply_single_spell_effect( def _apply_single_spell_effect(
self, self,
effect: SpellEffect, effect: SpellEffect,
target: CombatantState, target: CombatantState | None,
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
caster: CombatantState, caster: CombatantState,
@@ -3864,15 +4008,38 @@ class CombatEngine:
maximize: bool = False, maximize: bool = False,
saved: bool, saved: bool,
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None,
) -> str: ) -> str:
if isinstance(effect, AreaEffect): if isinstance(effect, AreaEffect):
return self._apply_area_effect( return self._apply_area_effect(
effect, target, caster_live, target_live, effect, target, caster_live, target_live,
caster=caster, spell_name=spell_name, saved=saved, caster=caster, spell_name=spell_name, saved=saved,
empower=empower, maximize=maximize, dc=dc, empower=empower, maximize=maximize, dc=dc,
center_pos=center_pos,
) )
if isinstance(effect, SummonEffect): if isinstance(effect, SummonEffect):
return self._apply_summon_effect(effect, caster, caster_live, spell_name) 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): if isinstance(effect, DamageEffect):
return self._apply_damage_effect( return self._apply_damage_effect(
effect, target, caster_live, target_live, effect, target, caster_live, target_live,
@@ -3973,7 +4140,7 @@ class CombatEngine:
def _apply_area_effect( def _apply_area_effect(
self, self,
effect: AreaEffect, effect: AreaEffect,
target: CombatantState, target: CombatantState | None,
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
*, *,
@@ -3983,13 +4150,17 @@ class CombatEngine:
empower: bool = False, empower: bool = False,
maximize: bool = False, maximize: bool = False,
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None,
) -> str: ) -> str:
"""Apply an area effect to all creatures within radius. """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 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) affected = self._creatures_in_range(center, radius_cells)
if not affected: if not affected:
@@ -4043,13 +4214,18 @@ class CombatEngine:
def _area_save( def _area_save(
self, self,
affected_state: CombatantState, affected_state: CombatantState,
target: CombatantState, target: CombatantState | None,
effect: AreaEffect, effect: AreaEffect,
*, *,
saved: bool, saved: bool,
dc: int = 15, dc: int = 15,
) -> bool: ) -> 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: if not effect.save_for_half:
return False return False
if affected_state is target: if affected_state is target:
@@ -4753,6 +4929,7 @@ def _ready_payload(action: Action) -> Action:
kind="cast_spell", kind="cast_spell",
target_id=action.target_id, target_id=action.target_id,
spell_name=action.spell_name, spell_name=action.spell_name,
target_pos=action.target_pos,
) )
if action.maneuver is not None: if action.maneuver is not None:
return Action( return Action(
+362
View File
@@ -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"