"""Initiative exposure in the Observation (Package U2). Top-level ``initiative_order`` (ids in the current round's initiative order, from ``_build_round_order()``, read-only, no RNG) and per-combatant additive key ``initiative_total`` (the d20 roll + initiative_mod from ``_initiative_keys``, 0 when no key exists yet, e.g. fresh summons). """ from __future__ import annotations import json from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng from tests.test_observation_enriched import _snapshot, make_grid, make_state def _combatant(id_: str, *, initiative_mod: int = 0) -> Combatant: return Combatant( id=id_, name=id_, level=1, abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), ac=ACProfile(total=10, touch=10, flat_footed=10), bab=1, initiative_mod=initiative_mod, speed_land_ft=30, hp_max=100, saves=Saves(fort=0, ref=0, will=0), attacks=[ AttackSpec( id=f"{id_}-sword", name="short sword", kind="melee", attack_bonus=1, damage=[ DamageComponent(formula=parse_dice("1d6"), types=["slashing"]) ], ) ], ) def _engine(rng: list[int], states: list[CombatantState]) -> CombatEngine: return CombatEngine(ScriptedRng(rng), make_grid(), states) def _duel() -> CombatEngine: """hero (mod +5, d20=4 → total 9) vs foe (mod 0, d20=10 → total 10).""" hero = make_state(_combatant("hero", initiative_mod=5), "players", (0, 0)) foe = make_state(_combatant("foe"), "monsters", (0, 3)) return _engine([4, 10], [hero, foe]) # ── (a) initiative_order == _build_round_order ids, descending key ─────────── def test_initiative_order_matches_round_order() -> None: eng = _duel() eng._roll_initiative() obs = _snapshot(eng, eng._states[0]) order = obs["initiative_order"] assert isinstance(order, tuple) assert order == tuple(s.combatant.id for s in eng._build_round_order()) # foe total 10 > hero total 9 → foe acts first despite later deployment. assert order == ("foe", "hero") def test_initiative_order_sorts_descending_by_full_key() -> None: a = make_state(_combatant("a", initiative_mod=2), "players", (0, 0)) b = make_state(_combatant("b", initiative_mod=5), "players", (0, 1)) c = make_state(_combatant("c", initiative_mod=5), "monsters", (0, 2)) # keys (total, mod, index): a=(5,2,0), b=(10,5,1), c=(10,5,2) sorted # descending → full b/c tie breaks on the index component (c first). eng = _engine([3, 5, 5], [a, b, c]) eng._roll_initiative() obs = _snapshot(eng, a) assert obs["initiative_order"] == ("c", "b", "a") # ── (b) initiative_total == _initiative_keys total == logged d20+mod ───────── def test_initiative_total_matches_key_and_log() -> None: eng = _duel() eng._roll_initiative() obs = _snapshot(eng, eng._states[0]) totals = {c["id"]: c["initiative_total"] for c in obs["combatants"]} assert totals == {"hero": 9, "foe": 10} for cid, total in totals.items(): key_total, _mod, _index = eng._initiative_keys[cid] assert total == key_total # The snapshot agrees with the pinned initiative log lines. assert "initiative: hero d20=4+5=9" in eng._transcript assert "initiative: foe d20=10+0=10" in eng._transcript # initiative_mod stays as the combatant's static modifier. mods = {c["id"]: c["initiative_mod"] for c in obs["combatants"]} assert mods == {"hero": 5, "foe": 0} # ── (c) summons / states without an initiative key → total 0, no crash ────── def test_summon_without_key_has_zero_total_and_inherits_order() -> None: eng = _duel() eng._roll_initiative() summon = make_state(_combatant("serpent"), "players", (0, 2)) summon.summon_caster_id = "hero" summon.summon_duration = 5 eng._states.append(summon) obs = _snapshot(eng, eng._states[0]) totals = {c["id"]: c["initiative_total"] for c in obs["combatants"]} assert totals["serpent"] == 0 # no own key yet — neutral default # The summon inherits the caster's key → acts right after hero (total 9). assert obs["initiative_order"] == ("foe", "hero", "serpent") def test_snapshot_before_initiative_roll_is_safe() -> None: eng = _duel() # _roll_initiative never called: no keys at all obs = _snapshot(eng, eng._states[0]) for combatant in obs["combatants"]: assert combatant["initiative_total"] == 0 assert isinstance(obs["initiative_order"], tuple) json.dumps(obs) # must not raise # ── (d) to_dict JSON-serializable and includes initiative_order ────────────── def test_to_dict_includes_initiative_order_and_is_json_safe() -> None: eng = _duel() eng._roll_initiative() data = eng._build_observation(eng._states[0]).to_dict() assert "initiative_order" in data parsed = json.loads(json.dumps(data)) assert parsed["initiative_order"] == ["foe", "hero"]