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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user