feat(combat): summoning
This commit is contained in:
+143
-19
@@ -75,7 +75,6 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -89,6 +88,7 @@ from pf1e_simulator.spells import (
|
||||
DamageEffect,
|
||||
HealEffect,
|
||||
SpellEffect,
|
||||
SummonEffect,
|
||||
spell_range_ft,
|
||||
)
|
||||
|
||||
@@ -917,22 +917,22 @@ class CombatEngine:
|
||||
return
|
||||
if maneuver not in ("trip", "disarm", "bull_rush", "sunder"):
|
||||
return
|
||||
|
||||
|
||||
# Calculate CMB and CMD
|
||||
attacker_cmb = state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state))
|
||||
target_cmd = target.combatant.cmd or (10 + (target.combatant.cmb or (target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target))) + target.combatant.abilities.dex_mod)
|
||||
|
||||
|
||||
roll = self._rng.d20()
|
||||
total = roll + attacker_cmb
|
||||
success = roll != 1 and total >= target_cmd
|
||||
|
||||
|
||||
if maneuver == "trip":
|
||||
if success:
|
||||
target.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["prone"],
|
||||
rounds_left=1,
|
||||
source=f"maneuver: trip",
|
||||
source="maneuver: trip",
|
||||
)
|
||||
)
|
||||
target.conditions.append(CONDITIONS_BY_NAME["prone"])
|
||||
@@ -950,7 +950,7 @@ class CombatEngine:
|
||||
modifiers=(StatModifier(target="attack", value=-4),),
|
||||
),
|
||||
rounds_left=1,
|
||||
source=f"maneuver: disarm",
|
||||
source="maneuver: disarm",
|
||||
)
|
||||
)
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: disarm vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (disarmed)")
|
||||
@@ -1045,7 +1045,7 @@ class CombatEngine:
|
||||
hp_before = target.hp
|
||||
parts = self._apply_spell_effects_from_list(
|
||||
metamagic_effects, target, save_result, caster_live, target_live,
|
||||
spell_name=spell.name,
|
||||
caster, spell_name=spell.name,
|
||||
empower="Empower Spell" in metamagic_feats,
|
||||
maximize="Maximize Spell" in metamagic_feats,
|
||||
)
|
||||
@@ -1073,7 +1073,7 @@ class CombatEngine:
|
||||
"""Legacy wrapper for compatibility."""
|
||||
return self._apply_spell_effects_from_list(
|
||||
spell.effects, target, save_result, caster_live, target_live,
|
||||
spell_name=spell.name
|
||||
caster=target, spell_name=spell.name
|
||||
)
|
||||
|
||||
def _apply_spell_effects_from_list(
|
||||
@@ -1083,6 +1083,7 @@ class CombatEngine:
|
||||
save_result: SaveResult | None,
|
||||
caster_live: _LiveStats,
|
||||
target_live: _LiveStats,
|
||||
caster: CombatantState,
|
||||
*,
|
||||
spell_name: str,
|
||||
empower: bool = False,
|
||||
@@ -1091,7 +1092,7 @@ class CombatEngine:
|
||||
saved = save_result is not None and save_result.success
|
||||
return [
|
||||
self._apply_single_spell_effect(
|
||||
effect, target, caster_live, target_live,
|
||||
effect, target, caster_live, target_live, caster,
|
||||
spell_name=spell_name, empower=empower, maximize=maximize,
|
||||
saved=saved
|
||||
)
|
||||
@@ -1104,6 +1105,7 @@ class CombatEngine:
|
||||
target: CombatantState,
|
||||
caster_live: _LiveStats,
|
||||
target_live: _LiveStats,
|
||||
caster: CombatantState,
|
||||
*,
|
||||
spell_name: str,
|
||||
empower: bool = False,
|
||||
@@ -1111,7 +1113,9 @@ class CombatEngine:
|
||||
saved: bool,
|
||||
) -> str:
|
||||
if isinstance(effect, AreaEffect):
|
||||
return self._apply_area_effect(effect, target, caster_live, target_live, spell_name, saved, empower=empower, maximize=maximize)
|
||||
return self._apply_area_effect(effect, target, caster_live, target_live, caster, spell_name, saved, empower=empower, maximize=maximize)
|
||||
if isinstance(effect, SummonEffect):
|
||||
return self._apply_summon_effect(effect, caster, caster_live, spell_name)
|
||||
if isinstance(effect, DamageEffect):
|
||||
raw = effect.formula.roll(self._rng)
|
||||
if maximize:
|
||||
@@ -1168,6 +1172,7 @@ class CombatEngine:
|
||||
target: CombatantState,
|
||||
caster_live: _LiveStats,
|
||||
target_live: _LiveStats,
|
||||
caster: CombatantState,
|
||||
spell_name: str,
|
||||
saved: bool,
|
||||
*,
|
||||
@@ -1214,7 +1219,7 @@ class CombatEngine:
|
||||
for sub_effect in effect.effects:
|
||||
result = self._apply_single_spell_effect(
|
||||
sub_effect, affected_state, caster_live, self._stats[affected_state.combatant.id],
|
||||
spell_name=spell_name, saved=creature_saved, empower=empower, maximize=maximize
|
||||
caster, spell_name=spell_name, saved=creature_saved, empower=empower, maximize=maximize
|
||||
)
|
||||
if "damage" in result:
|
||||
total_damage += int(result.split()[0])
|
||||
@@ -1294,6 +1299,125 @@ class CombatEngine:
|
||||
)
|
||||
return tuple(new_effects)
|
||||
|
||||
|
||||
def _apply_summon_effect(
|
||||
self,
|
||||
effect: SummonEffect,
|
||||
state: CombatantState,
|
||||
caster_live: _LiveStats,
|
||||
spell_name: str,
|
||||
) -> str:
|
||||
"""Apply a summoning effect - create a summoned creature."""
|
||||
# Load the summoned creature
|
||||
from pathlib import Path
|
||||
|
||||
from pf1e_simulator.loaders.combatant import load_combatant
|
||||
|
||||
try:
|
||||
summoned_combatant = load_combatant(Path(effect.creature))
|
||||
except Exception:
|
||||
return f"summon failed: could not load {effect.creature}"
|
||||
|
||||
# Find a valid adjacent position for the summoned creature
|
||||
center = state.pos
|
||||
spawn_pos = None
|
||||
for dr in (-1, 0, 1):
|
||||
for dc in (-1, 0, 1):
|
||||
if dr == 0 and dc == 0:
|
||||
continue
|
||||
pos = (center[0] + dr, center[1] + dc)
|
||||
if (0 <= pos[0] < self._grid.height and
|
||||
0 <= pos[1] < self._grid.width and
|
||||
not any(s.pos == pos for s in self._states if s.active)):
|
||||
spawn_pos = pos
|
||||
break
|
||||
if spawn_pos:
|
||||
break
|
||||
|
||||
if not spawn_pos:
|
||||
return "summon failed: no space"
|
||||
|
||||
# Create summoned creature state
|
||||
summoned_state = CombatantState(
|
||||
combatant=summoned_combatant,
|
||||
side=state.side,
|
||||
pos=spawn_pos,
|
||||
hp=summoned_combatant.hp_max,
|
||||
)
|
||||
|
||||
# Insert into initiative order right after caster
|
||||
caster_idx = next(i for i, s in enumerate(self._states) if s is state)
|
||||
self._states.insert(caster_idx + 1, summoned_state)
|
||||
|
||||
# Set expiration
|
||||
summoned_state.summon_duration = effect.duration_rounds
|
||||
summoned_state.summon_caster_id = state.combatant.id
|
||||
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)"
|
||||
)
|
||||
return f"summoned {summoned_combatant.id}"
|
||||
|
||||
for effect in spell.effects:
|
||||
if isinstance(effect, DamageEffect):
|
||||
# Empower: 1.5x damage (applied at roll time in _apply_spell_effects_from_list)
|
||||
# Maximize: max damage (applied at roll time)
|
||||
# We mark these via a custom attribute on the formula
|
||||
new_effects.append(effect)
|
||||
elif isinstance(effect, ConditionEffect):
|
||||
new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
||||
new_effects.append(
|
||||
ConditionEffect(
|
||||
condition_name=effect.condition_name,
|
||||
duration_rounds=new_duration,
|
||||
save_negates=effect.save_negates,
|
||||
)
|
||||
)
|
||||
elif isinstance(effect, HealEffect):
|
||||
# Empower/Maximize also apply to healing
|
||||
new_effects.append(effect)
|
||||
elif isinstance(effect, AreaEffect):
|
||||
new_sub_effects = []
|
||||
for sub in effect.effects:
|
||||
if isinstance(sub, DamageEffect):
|
||||
new_sub_effects.append(sub)
|
||||
elif isinstance(sub, ConditionEffect):
|
||||
new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds
|
||||
new_sub_effects.append(
|
||||
ConditionEffect(
|
||||
condition_name=sub.condition_name,
|
||||
duration_rounds=new_duration,
|
||||
save_negates=sub.save_negates,
|
||||
)
|
||||
)
|
||||
elif isinstance(sub, HealEffect):
|
||||
new_sub_effects.append(sub)
|
||||
else: # BuffEffect
|
||||
new_duration = sub.duration_rounds * 2 if has_extend else sub.duration_rounds
|
||||
new_sub_effects.append(
|
||||
BuffEffect(
|
||||
modifiers=sub.modifiers,
|
||||
duration_rounds=new_duration,
|
||||
)
|
||||
)
|
||||
new_effects.append(
|
||||
AreaEffect(
|
||||
radius_ft=effect.radius_ft,
|
||||
effects=tuple(new_sub_effects),
|
||||
save_for_half=effect.save_for_half,
|
||||
)
|
||||
)
|
||||
else: # BuffEffect
|
||||
new_duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
||||
new_effects.append(
|
||||
BuffEffect(
|
||||
modifiers=effect.modifiers,
|
||||
duration_rounds=new_duration,
|
||||
)
|
||||
)
|
||||
return tuple(new_effects)
|
||||
|
||||
def _resolve_swing(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
@@ -1558,21 +1682,21 @@ class CombatEngine:
|
||||
size = state.combatant.size.lower()
|
||||
if size == "fine":
|
||||
return -8
|
||||
elif size == "diminutive":
|
||||
if size == "diminutive":
|
||||
return -4
|
||||
elif size == "tiny":
|
||||
if size == "tiny":
|
||||
return -2
|
||||
elif size == "small":
|
||||
if size == "small":
|
||||
return -1
|
||||
elif size == "medium":
|
||||
if size == "medium":
|
||||
return 0
|
||||
elif size == "large":
|
||||
if size == "large":
|
||||
return 1
|
||||
elif size == "huge":
|
||||
if size == "huge":
|
||||
return 2
|
||||
elif size == "gargantuan":
|
||||
if size == "gargantuan":
|
||||
return 4
|
||||
elif size == "colossal":
|
||||
if size == "colossal":
|
||||
return 8
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user