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
+222 -45
View File
@@ -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(