diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e9b9394..a701a52 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index 7b3d87c..a978d60 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -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): diff --git a/tests/test_runner.py b/tests/test_runner.py index b943898..47dafcd 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -269,3 +269,30 @@ def test_run_encounter_with_summon_aggregates_summoned_ids() -> None: wolf = report.attrition["celestial_wolf"] assert wolf.damage_dealt >= 0.0 assert report.wins["players"] + report.wins["monsters"] + report.draws == 5 + + +def test_run_encounter_splits_duplicate_summons() -> None: + """Two summons of the same creature no longer share one stats slot: the + engine disambiguates ids the way build_states does, and the aggregation + reports each summoned creature separately. + + The summoning policy casts every round while slots remain, so a level-3 + caster (1 round/level duration) raises at least two concurrent wolves; the + second is celestial_wolf-2. Before the fix both merged into one slot. + """ + spec = EncounterSpec( + map=make_map(), + sides=( + Side(name="players", combatants=(_make_runner_caster(),), zone="A"), + Side( + name="monsters", + combatants=(make_combatant("gob", hp=30, speed=0),), + zone="B", + ), + ), + round_cap=10, + ) + report = run_encounter(spec, runs=1, seed=11, policy=_summoning_policy) + assert "celestial_wolf" in report.attrition + assert "celestial_wolf-2" in report.attrition + assert report.attrition["celestial_wolf-2"].damage_dealt >= 0.0 diff --git a/tests/test_summoning.py b/tests/test_summoning.py index 4b5e6d8..64dace5 100644 --- a/tests/test_summoning.py +++ b/tests/test_summoning.py @@ -19,7 +19,7 @@ from pf1e_simulator.models import ( DamageComponent, Saves, ) -from pf1e_simulator.rng import SeededRng +from pf1e_simulator.rng import ScriptedRng, SeededRng from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, SummonEffect, load_spell @@ -337,6 +337,79 @@ class TestSummonDurationScalesWithCasterLevel: assert effect.duration_rounds_per_level == 1 +class TestDuplicateSummonIds: + """Given: a caster with two slots casting the same summon twice + When: both summons resolve + Then: the second creature gets a build_states-style disambiguated id + (celestial_wolf, celestial_wolf-2) and its own stats slot, so attrition + never merges (or resets) between two summons of the same creature.""" + + def _engine_with_two_wolves(self) -> tuple[CombatEngine, list[CombatantState]]: + 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) + reg = {"Summon Monster II": _make_summon_spell(duration_rounds=5)} + engine = CombatEngine( + SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg + ) + engine._cast_spell(c, e, "Summon Monster II") + engine._cast_spell(c, e, "Summon Monster II") + wolves = [ + s for s in engine._states if s.combatant.id.startswith("celestial_wolf") + ] + return engine, wolves + + def test_second_summon_id_is_disambiguated(self) -> None: + # Given/When: the same creature is summoned twice + # Then: ids follow the build_states convention (bare, then -2). + _, wolves = self._engine_with_two_wolves() + # Each new summon is inserted right after the caster, so the roster + # order is newest-first; compare ids as a set. + assert {s.combatant.id for s in wolves} == {"celestial_wolf", "celestial_wolf-2"} + assert wolves[0] is not wolves[1] + + def test_each_summon_owns_a_distinct_stats_slot(self) -> None: + # Given: two summons of the same creature + # Then: each owns its own _LiveStats slot (the pre-fix engine reset + # the shared slot on the second summon, wiping the first one's rows). + engine, _ = self._engine_with_two_wolves() + assert engine._stats["celestial_wolf"] is not engine._stats["celestial_wolf-2"] + + def test_damage_to_the_second_summon_does_not_merge(self) -> None: + # Given: two summoned wolves + # When: a scripted hit lands on the second wolf only + # Then: the damage is booked to celestial_wolf-2's slot, leaving the + # first wolf's counters untouched (no shared-slot merge). + 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) + reg = {"Summon Monster II": _make_summon_spell(duration_rounds=5)} + engine = CombatEngine( + SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg + ) + engine._cast_spell(c, e, "Summon Monster II") + engine._cast_spell(c, e, "Summon Monster II") + wolves = [ + s for s in engine._states if s.combatant.id.startswith("celestial_wolf") + ] + second = next(s for s in wolves if s.combatant.id == "celestial_wolf-2") + hp_before = second.hp + # d20=15 + attack_bonus 1 = 16 >= AC 14 -> hit; 1d6=6 - DR 5/magic = 1. + engine._rng = ScriptedRng([15, 6]) + # _resolve_swing is the combat path that books the stats slots. + engine._resolve_swing(e, second, enemy.attacks[0], label="club") + dealt = hp_before - second.hp + assert dealt > 0 + assert engine._stats["celestial_wolf-2"].damage_taken == dealt + assert engine._stats["celestial_wolf"].damage_taken == 0 + + class TestExtendSpellOnSummon: """SRD feats/extend-spell.md: "An extended spell lasts twice as long as normal. A spell with a duration of concentration, instantaneous, or