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