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._move_aoo_provoked: dict[str, set[str]] = {}
|
||||||
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
|
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
|
||||||
self._pending_summons: list[CombatantState] = []
|
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
|
# True while the surprise round runs (SRD "Surprise"): the turn filter
|
||||||
# then degrades every aware combatant's sequence to a single standard
|
# then degrades every aware combatant's sequence to a single standard
|
||||||
# OR move action plus free actions.
|
# OR move action plus free actions.
|
||||||
@@ -7733,6 +7741,21 @@ class CombatEngine:
|
|||||||
return effect
|
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(
|
def _apply_summon_effect(
|
||||||
self,
|
self,
|
||||||
effect: SummonEffect,
|
effect: SummonEffect,
|
||||||
@@ -7755,6 +7778,7 @@ class CombatEngine:
|
|||||||
summoned_combatant = load_combatant(Path(effect.creature))
|
summoned_combatant = load_combatant(Path(effect.creature))
|
||||||
except CombatantLoadError:
|
except CombatantLoadError:
|
||||||
return f"summon failed: could not load {effect.creature}"
|
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
|
# Find a valid adjacent position for the summoned creature
|
||||||
center = state.pos
|
center = state.pos
|
||||||
|
|||||||
@@ -196,9 +196,10 @@ def run_encounter(
|
|||||||
# Accumulate whatever ids the engine reports: summoned creatures act in the
|
# Accumulate whatever ids the engine reports: summoned creatures act in the
|
||||||
# battle and appear in result.stats, so seeding from the initial states
|
# 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
|
# 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
|
# accumulator. Duplicate summons of the same creature are disambiguated by
|
||||||
# one engine stats slot (the engine keys _LiveStats by id), so their
|
# the engine the same way build_states does (celestial_wolf,
|
||||||
# attrition is merged, not split.
|
# celestial_wolf-2, ...), so each summoned creature owns its own slot and
|
||||||
|
# their attrition is split, not merged.
|
||||||
totals: dict[str, CombatantStats] = {}
|
totals: dict[str, CombatantStats] = {}
|
||||||
transcript: tuple[str, ...] | None = None
|
transcript: tuple[str, ...] | None = None
|
||||||
for index in range(runs):
|
for index in range(runs):
|
||||||
|
|||||||
@@ -269,3 +269,30 @@ def test_run_encounter_with_summon_aggregates_summoned_ids() -> None:
|
|||||||
wolf = report.attrition["celestial_wolf"]
|
wolf = report.attrition["celestial_wolf"]
|
||||||
assert wolf.damage_dealt >= 0.0
|
assert wolf.damage_dealt >= 0.0
|
||||||
assert report.wins["players"] + report.wins["monsters"] + report.draws == 5
|
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,
|
DamageComponent,
|
||||||
Saves,
|
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
|
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, SummonEffect, load_spell
|
||||||
|
|
||||||
|
|
||||||
@@ -337,6 +337,79 @@ class TestSummonDurationScalesWithCasterLevel:
|
|||||||
assert effect.duration_rounds_per_level == 1
|
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:
|
class TestExtendSpellOnSummon:
|
||||||
"""SRD feats/extend-spell.md: "An extended spell lasts twice as long as
|
"""SRD feats/extend-spell.md: "An extended spell lasts twice as long as
|
||||||
normal. A spell with a duration of concentration, instantaneous, or
|
normal. A spell with a duration of concentration, instantaneous, or
|
||||||
|
|||||||
Reference in New Issue
Block a user