feat(combat): allow point-targeted spell areas
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+213
-36
@@ -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,36 +3870,75 @@ 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)
|
||||||
|
if target is None and center_pos is not None:
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {caster.combatant.id}: "
|
||||||
|
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(
|
self._log(
|
||||||
f"round {self._current_round} {caster.combatant.id}: "
|
f"round {self._current_round} {caster.combatant.id}: "
|
||||||
f"cast {spell_name} vs {target.combatant.id} "
|
f"cast {spell_name} vs {target.combatant.id} "
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user