feat(combat): summoning

This commit is contained in:
ctan
2026-08-30 09:04:21 +02:00
parent e158ea7e11
commit eb666993e7
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."
}
+140 -16
View File
@@ -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,
)
@@ -932,7 +932,7 @@ class CombatEngine:
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
+16 -1
View File
@@ -68,8 +68,18 @@ class AreaEffect:
effects: tuple[SpellEffect, ...] = ()
save_for_half: bool = True
@dataclass(frozen=True)
class SummonEffect:
"""Summoning spell effect.
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect
- 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 | SummonEffect
@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]
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}")
+27 -3
View File
@@ -108,13 +108,13 @@ def _make_engine(
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
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:
reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 10
assert len(reg) == 11
def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
@@ -122,6 +122,7 @@ class TestSpellLoading:
"Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt",
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow",
"Summon Monster II",
}
assert set(reg) == expected
@@ -411,3 +412,26 @@ class TestSpellDamageWithDr:
engine._cast_spell(c, t, "Fireball")
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage
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)