feat(combat): summoning

This commit is contained in:
2026-09-04 22:55:44 +02:00
parent 03e0d6ecb6
commit b5c15ae60b
5 changed files with 255 additions and 23 deletions
+53
View File
@@ -0,0 +1,53 @@
{
"name": "Celestial Wolf",
"level": 2,
"size": "Medium",
"abilities": {
"str_score": 13,
"dex_score": 15,
"con_score": 12,
"int_score": 2,
"wis_score": 12,
"cha_score": 6
},
"hp_max": 15,
"ac": {
"total": 14,
"touch": 13,
"flat_footed": 12
},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 50,
"saves": {
"fort": 4,
"ref": 5,
"will": 2
},
"attacks": [
{
"id": "celestial_wolf:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d6+1",
"types": ["piercing"]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
],
"source": "summon monster II",
"features": [],
"spells": [],
"casting_ability": "wis",
"resources": {},
"metamagic_feats": [],
"skills": {}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Summon Monster II",
"level": 2,
"school": "conjuration",
"range": "close",
"save": "none",
"base_dc": 10,
"effects": [
{
"type": "summon",
"creature": "data/monsters/celestial_wolf.json",
"duration_rounds": 5
}
],
"description": "Summons a celestial wolf to fight for you."
}
+143 -19
View File
@@ -75,7 +75,6 @@ Phase 0 documented deviations from PF1e (conventions):
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -89,6 +88,7 @@ from pf1e_simulator.spells import (
DamageEffect, DamageEffect,
HealEffect, HealEffect,
SpellEffect, SpellEffect,
SummonEffect,
spell_range_ft, spell_range_ft,
) )
@@ -917,22 +917,22 @@ class CombatEngine:
return return
if maneuver not in ("trip", "disarm", "bull_rush", "sunder"): if maneuver not in ("trip", "disarm", "bull_rush", "sunder"):
return return
# Calculate CMB and CMD # Calculate CMB and CMD
attacker_cmb = state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)) 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) 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() roll = self._rng.d20()
total = roll + attacker_cmb total = roll + attacker_cmb
success = roll != 1 and total >= target_cmd success = roll != 1 and total >= target_cmd
if maneuver == "trip": if maneuver == "trip":
if success: if success:
target.active_conditions.append( target.active_conditions.append(
ActiveCondition( ActiveCondition(
condition=CONDITIONS_BY_NAME["prone"], condition=CONDITIONS_BY_NAME["prone"],
rounds_left=1, rounds_left=1,
source=f"maneuver: trip", source="maneuver: trip",
) )
) )
target.conditions.append(CONDITIONS_BY_NAME["prone"]) target.conditions.append(CONDITIONS_BY_NAME["prone"])
@@ -950,7 +950,7 @@ class CombatEngine:
modifiers=(StatModifier(target="attack", value=-4),), modifiers=(StatModifier(target="attack", value=-4),),
), ),
rounds_left=1, 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)") 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 hp_before = target.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,
spell_name=spell.name, 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,
) )
@@ -1073,7 +1073,7 @@ class CombatEngine:
"""Legacy wrapper for compatibility.""" """Legacy wrapper for compatibility."""
return self._apply_spell_effects_from_list( return self._apply_spell_effects_from_list(
spell.effects, target, save_result, caster_live, target_live, 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( def _apply_spell_effects_from_list(
@@ -1083,6 +1083,7 @@ class CombatEngine:
save_result: SaveResult | None, save_result: SaveResult | None,
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
caster: CombatantState,
*, *,
spell_name: str, spell_name: str,
empower: bool = False, empower: bool = False,
@@ -1091,7 +1092,7 @@ class CombatEngine:
saved = save_result is not None and save_result.success saved = save_result is not None and save_result.success
return [ return [
self._apply_single_spell_effect( 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, spell_name=spell_name, empower=empower, maximize=maximize,
saved=saved saved=saved
) )
@@ -1104,6 +1105,7 @@ class CombatEngine:
target: CombatantState, target: CombatantState,
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
caster: CombatantState,
*, *,
spell_name: str, spell_name: str,
empower: bool = False, empower: bool = False,
@@ -1111,7 +1113,9 @@ class CombatEngine:
saved: bool, saved: bool,
) -> str: ) -> str:
if isinstance(effect, AreaEffect): 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): if isinstance(effect, DamageEffect):
raw = effect.formula.roll(self._rng) raw = effect.formula.roll(self._rng)
if maximize: if maximize:
@@ -1168,6 +1172,7 @@ class CombatEngine:
target: CombatantState, target: CombatantState,
caster_live: _LiveStats, caster_live: _LiveStats,
target_live: _LiveStats, target_live: _LiveStats,
caster: CombatantState,
spell_name: str, spell_name: str,
saved: bool, saved: bool,
*, *,
@@ -1214,7 +1219,7 @@ class CombatEngine:
for sub_effect in effect.effects: for sub_effect in effect.effects:
result = self._apply_single_spell_effect( result = self._apply_single_spell_effect(
sub_effect, affected_state, caster_live, self._stats[affected_state.combatant.id], 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: if "damage" in result:
total_damage += int(result.split()[0]) total_damage += int(result.split()[0])
@@ -1294,6 +1299,125 @@ class CombatEngine:
) )
return tuple(new_effects) 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( def _resolve_swing(
self, self,
attacker: CombatantState, attacker: CombatantState,
@@ -1558,21 +1682,21 @@ class CombatEngine:
size = state.combatant.size.lower() size = state.combatant.size.lower()
if size == "fine": if size == "fine":
return -8 return -8
elif size == "diminutive": if size == "diminutive":
return -4 return -4
elif size == "tiny": if size == "tiny":
return -2 return -2
elif size == "small": if size == "small":
return -1 return -1
elif size == "medium": if size == "medium":
return 0 return 0
elif size == "large": if size == "large":
return 1 return 1
elif size == "huge": if size == "huge":
return 2 return 2
elif size == "gargantuan": if size == "gargantuan":
return 4 return 4
elif size == "colossal": if size == "colossal":
return 8 return 8
return 0 return 0
+16 -1
View File
@@ -68,8 +68,18 @@ class AreaEffect:
effects: tuple[SpellEffect, ...] = () effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True save_for_half: bool = True
@dataclass(frozen=True)
class SummonEffect:
"""Summoning spell effect.
- creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json")
- duration_rounds: how long the summoned creature stays
"""
creature: str
duration_rounds: int
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -134,6 +144,11 @@ def _parse_effect(data: dict[str, Json]) -> SpellEffect:
effects=tuple(_parse_effect(e) for e in data["effects"]), # type: ignore[index] effects=tuple(_parse_effect(e) for e in data["effects"]), # type: ignore[index]
save_for_half=bool(data.get("save_for_half", True)), save_for_half=bool(data.get("save_for_half", True)),
) )
if effect_type == "summon":
return SummonEffect(
creature=str(data["creature"]), # type: ignore[index]
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type]
)
raise ValueError(f"Unknown effect type: {effect_type}") raise ValueError(f"Unknown effect type: {effect_type}")
+27 -3
View File
@@ -108,13 +108,13 @@ def _make_engine(
class TestSpellLoading: class TestSpellLoading:
"""Given: data/spells/ directory with 10 JSON spell files """Given: data/spells/ directory with 11 JSON spell files
When: load_spell_registry is called When: load_spell_registry is called
Then: all 10 spells are loaded with correct fields.""" Then: all 11 spells are loaded with correct fields."""
def test_loads_all_10_spells(self) -> None: def test_loads_all_10_spells(self) -> None:
reg = load_spell_registry(_SPELLS_DIR) reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 10 assert len(reg) == 11
def test_spell_names_present(self) -> None: def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR) reg = load_spell_registry(_SPELLS_DIR)
@@ -122,6 +122,7 @@ class TestSpellLoading:
"Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt", "Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt",
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person", "Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow", "Fear", "Bull's Strength", "Acid Arrow",
"Summon Monster II",
} }
assert set(reg) == expected assert set(reg) == expected
@@ -411,3 +412,26 @@ class TestSpellDamageWithDr:
engine._cast_spell(c, t, "Fireball") engine._cast_spell(c, t, "Fireball")
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage # Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage
assert t.hp == hp_before - 13 assert t.hp == hp_before - 13
class TestSummonMonster:
"""Given: a caster casting Summon Monster II
When: _cast_spell is called
Then: a celestial wolf is summoned adjacent to the caster."""
def test_summon_creature(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = len(engine._states)
engine._cast_spell(c, t, "Summon Monster II")
# Caster + target + summoned = 3 combatants
assert len(engine._states) == hp_before + 1
# Find the summoned creature
summoned = [s for s in engine._states if s.combatant.id == "celestial_wolf"]
assert len(summoned) == 1
assert summoned[0].side == "players"
# Check it has correct HP
assert summoned[0].hp == 15
# Check transcript mentions summon
assert any("summon" in line.lower() for line in engine._transcript)