feat(combat): allow point-targeted spell areas
This commit is contained in:
+222
-45
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user