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