From eb666993e74f32566cd2388a4d45d7ad3ba404cc Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 30 Aug 2026 09:04:21 +0200 Subject: [PATCH] feat(combat): summoning --- data/monsters/celestial_wolf.json | 53 ++++++++++ data/spells/summon_monster_ii.json | 16 +++ src/pf1e_simulator/combat.py | 162 +++++++++++++++++++++++++---- src/pf1e_simulator/spells.py | 17 ++- tests/test_spells.py | 30 +++++- 5 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 data/monsters/celestial_wolf.json create mode 100644 data/spells/summon_monster_ii.json diff --git a/data/monsters/celestial_wolf.json b/data/monsters/celestial_wolf.json new file mode 100644 index 0000000..cdc75d4 --- /dev/null +++ b/data/monsters/celestial_wolf.json @@ -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": {} +} diff --git a/data/spells/summon_monster_ii.json b/data/spells/summon_monster_ii.json new file mode 100644 index 0000000..f96ec70 --- /dev/null +++ b/data/spells/summon_monster_ii.json @@ -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." +} diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e6e6b9d..19b7894 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 337bdb4..91a88f7 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -68,8 +68,18 @@ class AreaEffect: effects: tuple[SpellEffect, ...] = () 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) @@ -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}") diff --git a/tests/test_spells.py b/tests/test_spells.py index 54de81e..67cdbc3 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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)