fix(combat): summoned creatures act and expire

This commit is contained in:
2026-09-04 22:55:55 +02:00
parent 2e4d192522
commit 735642a607
2 changed files with 270 additions and 11 deletions
+77 -11
View File
@@ -339,6 +339,8 @@ class CombatEngine:
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0
self._aoo_used: set[str] = set()
self._initiative_keys: dict[str, tuple[int, int, int]] = {}
self._pending_summons: list[CombatantState] = []
@property
def states(self) -> list[CombatantState]:
@@ -410,17 +412,13 @@ class CombatEngine:
def run(self) -> CombatResult:
"""Run the battle and return the final result and transcript."""
order = self._roll_initiative()
self._roll_initiative()
round_no = 1
while round_no <= self._round_cap:
self._current_round = round_no
self._aoo_used.clear()
for state in order:
if not state.active:
continue
if self._take_turn(state):
break
if self._winner_side() is not None:
self._expire_summons()
if self._run_round():
break
round_no += 1
winner = self._winner_side()
@@ -436,6 +434,31 @@ class CombatEngine:
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:
"""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]
@@ -658,13 +681,17 @@ class CombatEngine:
for state in self._states:
roll = self._rng.d20()
rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod)
order = sorted(
self._states,
key=lambda s: (
self._initiative_keys = {
s.combatant.id: (
rolls[s.combatant.id][1],
s.combatant.initiative_mod,
self._states.index(s),
),
)
for s in self._states
}
order = sorted(
self._states,
key=lambda s: self._initiative_keys[s.combatant.id],
reverse=True,
)
for state in order:
@@ -675,6 +702,42 @@ class CombatEngine:
)
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:
active_sides = {s.side for s in self._states if s.active}
if len(active_sides) == 1:
@@ -1860,6 +1923,9 @@ class CombatEngine:
summoned_state.summon_duration = effect.duration_rounds
summoned_state.summon_caster_id = state.combatant.id
self._stats[summoned_state.combatant.id] = _LiveStats()
self._pending_summons.append(summoned_state)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)"