diff --git a/README.md b/README.md index 30b65a2..eb2c508 100644 --- a/README.md +++ b/README.md @@ -534,7 +534,9 @@ Règles modélisées : dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3), - bab (E1), initiative_mod (E2), casting_ability ou None pour non-jeteur + bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet + d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation + fraîche), casting_ability ou None pour non-jeteur (E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills {nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium), metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants}, @@ -563,6 +565,9 @@ Règles modélisées : flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. - Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, threatened (grille bool des cases menacées par les ennemis), + initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round + courant — le premier est le prochain à agir ; les invocations héritent de la + case de leur conjurateur), terrain_semantics (un dict par type de terrain : type, move_cost, blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom, {mods, flags, speed_mult, miss_chance}) — définitions complètes des 23 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index fda0bc5..f946a73 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -448,7 +448,9 @@ class Observation: on the map (sorted by type for determinism), carrying ``{type, move_cost, blocks_los, cover}`` — derived from ``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose - ``MapSpec.legend``). + ``MapSpec.legend``). ``initiative_order`` lists the combatant ids in the + current round's initiative order (``_build_round_order()``, read-only, + no RNG): the first entry acts next this round. """ round: int @@ -460,6 +462,7 @@ class Observation: threatened: tuple[tuple[bool, ...], ...] = () terrain_semantics: tuple[dict[str, object], ...] = () condition_definitions: tuple[tuple[str, dict[str, object]], ...] = () + initiative_order: tuple[str, ...] = () def to_dict(self) -> dict[str, object]: """Return a JSON-serializable dict with the pinned top-level keys.""" @@ -473,6 +476,7 @@ class Observation: "threatened": self.threatened, "terrain_semantics": self.terrain_semantics, "condition_definitions": self.condition_definitions, + "initiative_order": self.initiative_order, } @@ -710,6 +714,9 @@ class CombatEngine: (name, _condition_definition_dict(cond)) for name, cond in sorted(CONDITIONS_BY_NAME.items()) ) + initiative_order = tuple( + s.combatant.id for s in self._build_round_order() + ) return Observation( round=self._current_round, acting_combatant=acting_index, @@ -720,6 +727,7 @@ class CombatEngine: threatened=threatened, terrain_semantics=terrain_semantics, condition_definitions=condition_definitions, + initiative_order=initiative_order, ) def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]: @@ -809,6 +817,9 @@ class CombatEngine: "creature_type": state.combatant.creature_type, "bab": state.combatant.bab, "initiative_mod": state.combatant.initiative_mod, + "initiative_total": self._initiative_keys.get( + state.combatant.id, (0, 0, -999) + )[0], "casting_ability": ( state.combatant.casting_ability if state.combatant.spells else None ), diff --git a/tests/test_initiative_observation.py b/tests/test_initiative_observation.py new file mode 100644 index 0000000..f7f7944 --- /dev/null +++ b/tests/test_initiative_observation.py @@ -0,0 +1,151 @@ +"""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"] diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 7da0338..1b8bd59 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -1057,6 +1057,7 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None: "threatened", "terrain_semantics", "condition_definitions", + "initiative_order", } # combatants / pairs untouched by E5 assert len(obs["combatants"]) == 2