fix(combat): summoned creatures act and expire
This commit is contained in:
@@ -339,6 +339,8 @@ class CombatEngine:
|
|||||||
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
||||||
self._current_round = 0
|
self._current_round = 0
|
||||||
self._aoo_used: set[str] = set()
|
self._aoo_used: set[str] = set()
|
||||||
|
self._initiative_keys: dict[str, tuple[int, int, int]] = {}
|
||||||
|
self._pending_summons: list[CombatantState] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def states(self) -> list[CombatantState]:
|
def states(self) -> list[CombatantState]:
|
||||||
@@ -410,17 +412,13 @@ class CombatEngine:
|
|||||||
|
|
||||||
def run(self) -> CombatResult:
|
def run(self) -> CombatResult:
|
||||||
"""Run the battle and return the final result and transcript."""
|
"""Run the battle and return the final result and transcript."""
|
||||||
order = self._roll_initiative()
|
self._roll_initiative()
|
||||||
round_no = 1
|
round_no = 1
|
||||||
while round_no <= self._round_cap:
|
while round_no <= self._round_cap:
|
||||||
self._current_round = round_no
|
self._current_round = round_no
|
||||||
self._aoo_used.clear()
|
self._aoo_used.clear()
|
||||||
for state in order:
|
self._expire_summons()
|
||||||
if not state.active:
|
if self._run_round():
|
||||||
continue
|
|
||||||
if self._take_turn(state):
|
|
||||||
break
|
|
||||||
if self._winner_side() is not None:
|
|
||||||
break
|
break
|
||||||
round_no += 1
|
round_no += 1
|
||||||
winner = self._winner_side()
|
winner = self._winner_side()
|
||||||
@@ -436,6 +434,31 @@ class CombatEngine:
|
|||||||
stats=stats,
|
stats=stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _run_round(self) -> bool:
|
||||||
|
"""Execute one round; return True if the battle ended."""
|
||||||
|
order = self._build_round_order()
|
||||||
|
for state in order:
|
||||||
|
if not state.active:
|
||||||
|
continue
|
||||||
|
if self._take_turn(state):
|
||||||
|
return True
|
||||||
|
if self._drive_pending_summons():
|
||||||
|
return True
|
||||||
|
self._pending_summons.clear()
|
||||||
|
return self._winner_side() is not None
|
||||||
|
|
||||||
|
def _drive_pending_summons(self) -> bool:
|
||||||
|
"""Give summoned creatures their turn immediately; return True if battle ended."""
|
||||||
|
while self._pending_summons:
|
||||||
|
summoned = self._pending_summons.pop(0)
|
||||||
|
if (
|
||||||
|
summoned in self._states
|
||||||
|
and summoned.active
|
||||||
|
and self._take_turn(summoned)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
|
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
|
||||||
"""Closest active enemy by grid distance; ties keep list order."""
|
"""Closest active enemy by grid distance; ties keep list order."""
|
||||||
enemies = [s for s in self._states if s.side != state.side and s.active]
|
enemies = [s for s in self._states if s.side != state.side and s.active]
|
||||||
@@ -658,13 +681,17 @@ class CombatEngine:
|
|||||||
for state in self._states:
|
for state in self._states:
|
||||||
roll = self._rng.d20()
|
roll = self._rng.d20()
|
||||||
rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod)
|
rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod)
|
||||||
order = sorted(
|
self._initiative_keys = {
|
||||||
self._states,
|
s.combatant.id: (
|
||||||
key=lambda s: (
|
|
||||||
rolls[s.combatant.id][1],
|
rolls[s.combatant.id][1],
|
||||||
s.combatant.initiative_mod,
|
s.combatant.initiative_mod,
|
||||||
self._states.index(s),
|
self._states.index(s),
|
||||||
),
|
)
|
||||||
|
for s in self._states
|
||||||
|
}
|
||||||
|
order = sorted(
|
||||||
|
self._states,
|
||||||
|
key=lambda s: self._initiative_keys[s.combatant.id],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
for state in order:
|
for state in order:
|
||||||
@@ -675,6 +702,42 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
def _build_round_order(self) -> list[CombatantState]:
|
||||||
|
"""Build initiative order for this round from current ``_states``.
|
||||||
|
|
||||||
|
Summoned creatures (not present at the start of combat) inherit their
|
||||||
|
caster's initiative key so they act immediately after the caster via
|
||||||
|
stable sort.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def sort_key(s: CombatantState) -> tuple[int, int, int]:
|
||||||
|
key = self._initiative_keys.get(s.combatant.id)
|
||||||
|
if key is not None:
|
||||||
|
return key
|
||||||
|
caster_id = s.summon_caster_id
|
||||||
|
if caster_id and caster_id in self._initiative_keys:
|
||||||
|
ck = self._initiative_keys[caster_id]
|
||||||
|
return (ck[0], ck[1], ck[2])
|
||||||
|
return (0, 0, -999)
|
||||||
|
|
||||||
|
return sorted(self._states, key=sort_key, reverse=True)
|
||||||
|
|
||||||
|
def _expire_summons(self) -> None:
|
||||||
|
"""Decrement summon durations and remove expired summoned creatures."""
|
||||||
|
expired: list[CombatantState] = []
|
||||||
|
for state in list(self._states):
|
||||||
|
if state.summon_duration > 0:
|
||||||
|
state.summon_duration -= 1
|
||||||
|
if state.summon_duration <= 0:
|
||||||
|
expired.append(state)
|
||||||
|
for state in expired:
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: summon expired"
|
||||||
|
)
|
||||||
|
self._states.remove(state)
|
||||||
|
if state in self._pending_summons:
|
||||||
|
self._pending_summons.remove(state)
|
||||||
|
|
||||||
def _winner_side(self) -> str | None:
|
def _winner_side(self) -> str | None:
|
||||||
active_sides = {s.side for s in self._states if s.active}
|
active_sides = {s.side for s in self._states if s.active}
|
||||||
if len(active_sides) == 1:
|
if len(active_sides) == 1:
|
||||||
@@ -1860,6 +1923,9 @@ class CombatEngine:
|
|||||||
summoned_state.summon_duration = effect.duration_rounds
|
summoned_state.summon_duration = effect.duration_rounds
|
||||||
summoned_state.summon_caster_id = state.combatant.id
|
summoned_state.summon_caster_id = state.combatant.id
|
||||||
|
|
||||||
|
self._stats[summoned_state.combatant.id] = _LiveStats()
|
||||||
|
self._pending_summons.append(summoned_state)
|
||||||
|
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)"
|
f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)"
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Tests for summoning: summoned creatures act in combat and expire after duration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
Action,
|
||||||
|
CombatantState,
|
||||||
|
CombatEngine,
|
||||||
|
default_policy,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import SeededRng
|
||||||
|
from pf1e_simulator.spells import SpellSpec, SummonEffect
|
||||||
|
|
||||||
|
|
||||||
|
def _make_caster() -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id="caster-w",
|
||||||
|
name="dagger",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=2,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id="caster",
|
||||||
|
name="caster",
|
||||||
|
level=5,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=14, con_score=12,
|
||||||
|
int_score=18, wis_score=12, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=50,
|
||||||
|
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
||||||
|
bab=2,
|
||||||
|
initiative_mod=2,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=3, ref=4, will=4),
|
||||||
|
attacks=[attack],
|
||||||
|
casting_ability="int",
|
||||||
|
spells=["Summon Monster II"],
|
||||||
|
spell_slots={1: 3, 2: 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_enemy() -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id="enemy-w",
|
||||||
|
name="club",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=1,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id="enemy",
|
||||||
|
name="enemy",
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=200,
|
||||||
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=2, ref=2, will=2),
|
||||||
|
attacks=[attack],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_summon_spell(duration_rounds: int = 2) -> SpellSpec:
|
||||||
|
return SpellSpec(
|
||||||
|
name="Summon Monster II",
|
||||||
|
level=2,
|
||||||
|
school="conjuration",
|
||||||
|
range="close",
|
||||||
|
save="none",
|
||||||
|
base_dc=10,
|
||||||
|
effects=(
|
||||||
|
SummonEffect(
|
||||||
|
creature="data/monsters/celestial_wolf.json",
|
||||||
|
duration_rounds=duration_rounds,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summon_policy(
|
||||||
|
engine: CombatEngine, state: CombatantState
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
|
"""Force the caster to summon on first turn with slots available."""
|
||||||
|
if (
|
||||||
|
state.combatant.id == "caster"
|
||||||
|
and state.combatant.spells
|
||||||
|
and state.spell_slots_left.get(2, 0) > 0
|
||||||
|
):
|
||||||
|
target = engine.nearest_enemy(state)
|
||||||
|
if target is not None:
|
||||||
|
return (
|
||||||
|
Action(
|
||||||
|
kind="cast_spell",
|
||||||
|
target_id=target.combatant.id,
|
||||||
|
spell_name="Summon Monster II",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return default_policy(engine, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_engine(seed: int, duration_rounds: int = 2) -> CombatEngine:
|
||||||
|
caster = _make_caster()
|
||||||
|
enemy = _make_enemy()
|
||||||
|
c = CombatantState(
|
||||||
|
combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max,
|
||||||
|
)
|
||||||
|
e = CombatantState(
|
||||||
|
combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max,
|
||||||
|
)
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
|
||||||
|
return CombatEngine(
|
||||||
|
SeededRng(seed), grid, [c, e],
|
||||||
|
policy=_summon_policy,
|
||||||
|
spell_registry=reg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummonedCreatureActs:
|
||||||
|
"""Given: a caster who summons a celestial wolf
|
||||||
|
When: combat runs for several rounds
|
||||||
|
Then: the summoned creature takes at least one turn (acts)."""
|
||||||
|
|
||||||
|
def test_summoned_creature_acts_in_combat(self) -> None:
|
||||||
|
engine = _build_engine(seed=42)
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
wolf_lines = [
|
||||||
|
line for line in result.transcript if "celestial_wolf" in line
|
||||||
|
]
|
||||||
|
action_keywords = (
|
||||||
|
"charge", "attack", "move", "full_attack", "bite",
|
||||||
|
"5-foot", "wait",
|
||||||
|
)
|
||||||
|
action_lines = [
|
||||||
|
line for line in wolf_lines
|
||||||
|
if any(kw in line for kw in action_keywords)
|
||||||
|
]
|
||||||
|
assert len(action_lines) >= 1, (
|
||||||
|
f"Summoned creature never acted. All wolf lines: {wolf_lines}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummonedCreatureExpires:
|
||||||
|
"""Given: a caster who summons with duration 2
|
||||||
|
When: enough rounds pass
|
||||||
|
Then: the summoned creature expires and the transcript logs it."""
|
||||||
|
|
||||||
|
def test_summoned_creature_expires_after_duration(self) -> None:
|
||||||
|
engine = _build_engine(seed=42, duration_rounds=2)
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
expire_lines = [
|
||||||
|
line for line in result.transcript
|
||||||
|
if "summon expired" in line.lower()
|
||||||
|
]
|
||||||
|
summon_lines = [
|
||||||
|
ln for ln in result.transcript
|
||||||
|
if "celestial" in ln.lower() or "summon" in ln.lower()
|
||||||
|
]
|
||||||
|
assert len(expire_lines) >= 1, (
|
||||||
|
f"Summoned creature never expired. "
|
||||||
|
f"Transcript excerpt: {summon_lines}"
|
||||||
|
)
|
||||||
|
wolf_states = [
|
||||||
|
s for s in engine._states if s.combatant.id == "celestial_wolf"
|
||||||
|
]
|
||||||
|
if wolf_states:
|
||||||
|
assert not wolf_states[0].active, (
|
||||||
|
"Wolf should be inactive after expiration"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user