feat(combat): summoning
This commit is contained in:
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -932,7 +932,7 @@ class CombatEngine:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
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)
|
@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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user