diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index d0f442b..7b3d87c 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -190,11 +190,16 @@ def run_encounter( states = build_states(spec) if spell_registry is None: spell_registry = load_spell_registry(SPELLS_DIR) - ids = [s.combatant.id for s in states] wins = {side.name: 0 for side in spec.sides} draws = 0 total_rounds = 0 - totals = {cid: CombatantStats() for cid in ids} + # 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. + totals: dict[str, CombatantStats] = {} transcript: tuple[str, ...] | None = None for index in range(runs): if index > 0: @@ -217,7 +222,10 @@ def run_encounter( wins[result.winner] = wins.get(result.winner, 0) + 1 total_rounds += result.rounds for cid, stats in result.stats.items(): - current = totals[cid] + current = totals.get(cid) + if current is None: + totals[cid] = stats + continue totals[cid] = CombatantStats( hits=current.hits + stats.hits, crits=current.crits + stats.crits, diff --git a/tests/test_runner.py b/tests/test_runner.py index 0742119..b943898 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -10,6 +10,7 @@ from __future__ import annotations import pytest +from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy from pf1e_simulator.dice import parse_dice from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma @@ -195,3 +196,76 @@ def test_build_states_rejects_too_many_combatants_for_zone() -> None: ) with pytest.raises(ValueError, match="has 2 cells"): build_states(spec) + + +def _make_runner_caster() -> Combatant: + attack = AttackSpec( + id="caster-w", + name="dagger", + kind="melee", + attack_bonus=2, + damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])], + ) + return Combatant( + id="caster", + name="caster", + level=3, + size="Medium", + abilities=AbilityScores( + str_score=10, dex_score=12, con_score=14, + int_score=16, wis_score=12, cha_score=10, + ), + hp_max=40, + ac=ACProfile(total=14, touch=13, flat_footed=12), + bab=2, + initiative_mod=2, + speed_land_ft=30, + saves=Saves(fort=2, ref=3, will=3), + attacks=[attack], + casting_ability="int", + spells=["Summon Monster I"], + spell_slots={1: 3}, + ) + + +def _summoning_policy( + engine: CombatEngine, state: CombatantState +) -> tuple[Action, ...]: + caster_id = state.combatant.id + if caster_id.startswith("caster") and state.spell_slots_left.get(1, 0) > 0: + target = engine.nearest_enemy(state) + if target is not None: + return ( + Action( + kind="cast_spell", + target_id=target.combatant.id, + spell_name="Summon Monster I", + ), + ) + return default_policy(engine, state) + + +def test_run_encounter_with_summon_aggregates_summoned_ids() -> None: + """Regression: summoned creatures act in the battle and appear in + result.stats with ids absent from the deployed roster; the aggregation + must create their accumulators on demand (it KeyError-crashed before).""" + 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=5, seed=11, policy=_summoning_policy + ) + assert "celestial_wolf" in report.attrition + assert {"caster", "gob"} <= set(report.attrition) + wolf = report.attrition["celestial_wolf"] + assert wolf.damage_dealt >= 0.0 + assert report.wins["players"] + report.wins["monsters"] + report.draws == 5