fix(rules): duplicate summons get disambiguated ids - the engine now counts id occurrences (seeded from the deployed roster) and gives each new summon of the same creature a build_states-style suffix (celestial_wolf, celestial_wolf-2, ...), so two summons no longer share one _LiveStats slot (the second summon used to reset the first one's counters and their attrition merged); run_encounter aggregation verified to report each slot separately; runner 'known limitation' comment updated; +4 tests (id split, distinct slots, per-slot attrition, run_encounter aggregate)

This commit is contained in:
2026-09-19 23:13:06 +02:00
parent 4f8a131ba0
commit 5e7436b210
4 changed files with 129 additions and 4 deletions
+24
View File
@@ -1316,6 +1316,14 @@ class CombatEngine:
self._move_aoo_provoked: dict[str, set[str]] = {}
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
self._pending_summons: list[CombatantState] = []
# Occurrence counts per combatant id, seeded from the deployed roster:
# summons disambiguate duplicate creature ids exactly the way
# build_states does (first keeps the bare id, later ones get -2, -3...),
# so each summoned creature owns a distinct stats slot.
self._id_counts: dict[str, int] = {}
for st in states:
base = st.combatant.id
self._id_counts[base] = self._id_counts.get(base, 0) + 1
# True while the surprise round runs (SRD "Surprise"): the turn filter
# then degrades every aware combatant's sequence to a single standard
# OR move action plus free actions.
@@ -7733,6 +7741,21 @@ class CombatEngine:
return effect
def _disambiguated_summon_id(self, combatant: Combatant) -> Combatant:
"""Give a summoned creature a unique id, mirroring build_states.
The first occurrence of a creature id keeps the bare name; later ones
get -2, -3... suffixes. Without this, two summons of the same creature
share one _stats slot and their attrition merges (and the second
summon resets the first one's counters).
"""
base = combatant.id
count = self._id_counts.get(base, 0) + 1
self._id_counts[base] = count
if count == 1:
return combatant
return combatant.model_copy(update={"id": f"{base}-{count}"})
def _apply_summon_effect(
self,
effect: SummonEffect,
@@ -7755,6 +7778,7 @@ class CombatEngine:
summoned_combatant = load_combatant(Path(effect.creature))
except CombatantLoadError:
return f"summon failed: could not load {effect.creature}"
summoned_combatant = self._disambiguated_summon_id(summoned_combatant)
# Find a valid adjacent position for the summoned creature
center = state.pos
+4 -3
View File
@@ -196,9 +196,10 @@ def run_encounter(
# Accumulate whatever ids the engine reports: summoned creatures act in the
# battle and appear in result.stats, so seeding from the initial states
# only would KeyError on them. Ids seen for the first time get a fresh
# accumulator. Known limitation: two summons of the same creature id share
# one engine stats slot (the engine keys _LiveStats by id), so their
# attrition is merged, not split.
# accumulator. Duplicate summons of the same creature are disambiguated by
# the engine the same way build_states does (celestial_wolf,
# celestial_wolf-2, ...), so each summoned creature owns its own slot and
# their attrition is split, not merged.
totals: dict[str, CombatantStats] = {}
transcript: tuple[str, ...] | None = None
for index in range(runs):