fix(runner): aggregation creates per-id accumulators on demand so summoned creature ids in result.stats no longer KeyError run_encounter (totals were seeded from the deployed roster only); duplicate summons of one creature id share a stats slot - noted as a limitation, pinned by a summon-encounter report test

This commit is contained in:
2026-09-19 16:56:48 +02:00
parent 2e2873f331
commit 7fefce4653
2 changed files with 85 additions and 3 deletions
+11 -3
View File
@@ -190,11 +190,16 @@ def run_encounter(
states = build_states(spec) states = build_states(spec)
if spell_registry is None: if spell_registry is None:
spell_registry = load_spell_registry(SPELLS_DIR) spell_registry = load_spell_registry(SPELLS_DIR)
ids = [s.combatant.id for s in states]
wins = {side.name: 0 for side in spec.sides} wins = {side.name: 0 for side in spec.sides}
draws = 0 draws = 0
total_rounds = 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 transcript: tuple[str, ...] | None = None
for index in range(runs): for index in range(runs):
if index > 0: if index > 0:
@@ -217,7 +222,10 @@ def run_encounter(
wins[result.winner] = wins.get(result.winner, 0) + 1 wins[result.winner] = wins.get(result.winner, 0) + 1
total_rounds += result.rounds total_rounds += result.rounds
for cid, stats in result.stats.items(): 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( totals[cid] = CombatantStats(
hits=current.hits + stats.hits, hits=current.hits + stats.hits,
crits=current.crits + stats.crits, crits=current.crits + stats.crits,
+74
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest import pytest
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma 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"): with pytest.raises(ValueError, match="has 2 cells"):
build_states(spec) 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