feat(observation): expose initiative order and totals
This commit is contained in:
@@ -534,7 +534,9 @@ Règles modélisées :
|
|||||||
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
||||||
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
|
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
|
||||||
name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
|
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
|
(E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills
|
||||||
{nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium),
|
{nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium),
|
||||||
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
|
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.
|
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
||||||
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
|
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
|
||||||
threatened (grille bool des cases menacées par les ennemis),
|
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,
|
terrain_semantics (un dict par type de terrain : type, move_cost,
|
||||||
blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom,
|
blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom,
|
||||||
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 23
|
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 23
|
||||||
|
|||||||
@@ -448,7 +448,9 @@ class Observation:
|
|||||||
on the map (sorted by type for determinism), carrying
|
on the map (sorted by type for determinism), carrying
|
||||||
``{type, move_cost, blocks_los, cover}`` — derived from
|
``{type, move_cost, blocks_los, cover}`` — derived from
|
||||||
``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose
|
``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
|
round: int
|
||||||
@@ -460,6 +462,7 @@ class Observation:
|
|||||||
threatened: tuple[tuple[bool, ...], ...] = ()
|
threatened: tuple[tuple[bool, ...], ...] = ()
|
||||||
terrain_semantics: tuple[dict[str, object], ...] = ()
|
terrain_semantics: tuple[dict[str, object], ...] = ()
|
||||||
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
|
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
|
||||||
|
initiative_order: tuple[str, ...] = ()
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
||||||
@@ -473,6 +476,7 @@ class Observation:
|
|||||||
"threatened": self.threatened,
|
"threatened": self.threatened,
|
||||||
"terrain_semantics": self.terrain_semantics,
|
"terrain_semantics": self.terrain_semantics,
|
||||||
"condition_definitions": self.condition_definitions,
|
"condition_definitions": self.condition_definitions,
|
||||||
|
"initiative_order": self.initiative_order,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -710,6 +714,9 @@ class CombatEngine:
|
|||||||
(name, _condition_definition_dict(cond))
|
(name, _condition_definition_dict(cond))
|
||||||
for name, cond in sorted(CONDITIONS_BY_NAME.items())
|
for name, cond in sorted(CONDITIONS_BY_NAME.items())
|
||||||
)
|
)
|
||||||
|
initiative_order = tuple(
|
||||||
|
s.combatant.id for s in self._build_round_order()
|
||||||
|
)
|
||||||
return Observation(
|
return Observation(
|
||||||
round=self._current_round,
|
round=self._current_round,
|
||||||
acting_combatant=acting_index,
|
acting_combatant=acting_index,
|
||||||
@@ -720,6 +727,7 @@ class CombatEngine:
|
|||||||
threatened=threatened,
|
threatened=threatened,
|
||||||
terrain_semantics=terrain_semantics,
|
terrain_semantics=terrain_semantics,
|
||||||
condition_definitions=condition_definitions,
|
condition_definitions=condition_definitions,
|
||||||
|
initiative_order=initiative_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
|
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
|
||||||
@@ -809,6 +817,9 @@ class CombatEngine:
|
|||||||
"creature_type": state.combatant.creature_type,
|
"creature_type": state.combatant.creature_type,
|
||||||
"bab": state.combatant.bab,
|
"bab": state.combatant.bab,
|
||||||
"initiative_mod": state.combatant.initiative_mod,
|
"initiative_mod": state.combatant.initiative_mod,
|
||||||
|
"initiative_total": self._initiative_keys.get(
|
||||||
|
state.combatant.id, (0, 0, -999)
|
||||||
|
)[0],
|
||||||
"casting_ability": (
|
"casting_ability": (
|
||||||
state.combatant.casting_ability if state.combatant.spells else None
|
state.combatant.casting_ability if state.combatant.spells else None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1057,6 +1057,7 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
|
|||||||
"threatened",
|
"threatened",
|
||||||
"terrain_semantics",
|
"terrain_semantics",
|
||||||
"condition_definitions",
|
"condition_definitions",
|
||||||
|
"initiative_order",
|
||||||
}
|
}
|
||||||
# combatants / pairs untouched by E5
|
# combatants / pairs untouched by E5
|
||||||
assert len(obs["combatants"]) == 2
|
assert len(obs["combatants"]) == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user