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
+74 -1
View File
@@ -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