"""U6c: per-combatant ``aoo_remaining`` in the Observation snapshot. The combatant dict now exposes how many attacks of opportunity the combatant still has this round: ``max(0, _aoo_allowance - _aoo_used[id])``. The allowance is 1 by default, ``1 + DEX mod`` (floor 1) with Combat Reflexes; ``_aoo_used`` is cleared at the start of every round, so the budget is round-scoped. Pure read — no existing key changes, no RNG consumed. Covers: - (a) fresh combatant -> 1; - (b) Combat Reflexes with DEX mod +2 -> 3; a negative-DEX Combat Reflexes holder still gets the floor (1); - (c) recording one AoO used -> the snapshot decrements accordingly; - (d) never negative (floor 0) even when used exceeds the allowance; - (e) ``to_dict()`` stays JSON-serializable; - (f) resetting ``_aoo_used`` (new round) restores the full allowance; - the key is present for every combatant, not just the actor. """ from __future__ import annotations import json from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.rng import ScriptedRng from tests.test_observation_enriched import ( _snapshot, make_combatant, make_grid, make_state, ) _COMBAT_REFLEXES = AbilitySpec(name="Combat Reflexes", category="feat", source_detail="feat lv1") def _engine(states: list[CombatantState]) -> CombatEngine: return CombatEngine(ScriptedRng([]), make_grid(), states) def _aoo(obs: dict, cid: str) -> int: for combatant in obs["combatants"]: if combatant["id"] == cid: value: int = combatant["aoo_remaining"] return value msg = f"combatant {cid} missing from snapshot" raise AssertionError(msg) # (a) fresh combatant -> 1 def test_aoo_remaining_fresh_combatant_is_one() -> None: """Given: two combatants, none has spent an AoO When: the Observation is built Then: every combatant exposes aoo_remaining 1.""" hero = make_state(make_combatant("hero"), "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) obs = _snapshot(engine, hero) assert _aoo(obs, "hero") == 1 assert _aoo(obs, "foe") == 1 # (b) Combat Reflexes + DEX mod def test_aoo_remaining_combat_reflexes_dex_plus_two() -> None: """Given: a combatant with Combat Reflexes and DEX 14 (mod +2) When: the Observation is built Then: aoo_remaining is 1 + 2 = 3.""" cr = make_combatant("reflex", features=[_COMBAT_REFLEXES]) cr = cr.model_copy(update={"abilities": cr.abilities.model_copy(update={"dex_score": 14})}) hero = make_state(cr, "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) obs = _snapshot(engine, hero) assert _aoo(obs, "reflex") == 3 assert _aoo(obs, "foe") == 1 def test_aoo_remaining_combat_reflexes_negative_dex_floors_to_one() -> None: """Given: a Combat Reflexes holder with DEX 6 (mod -2) When: the Observation is built Then: the allowance floor keeps aoo_remaining at 1.""" cr = make_combatant("clumsy", features=[_COMBAT_REFLEXES]) cr = cr.model_copy(update={"abilities": cr.abilities.model_copy(update={"dex_score": 6})}) hero = make_state(cr, "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) obs = _snapshot(engine, hero) assert _aoo(obs, "clumsy") == 1 # (c) spent AoO decrements the budget def test_aoo_remaining_decrements_after_spending() -> None: """Given: a Combat Reflexes holder (allowance 3) that already spent 1 AoO When: the Observation is built Then: aoo_remaining is 2 for the spender and unchanged for the foe.""" cr = make_combatant("reflex", features=[_COMBAT_REFLEXES]) cr = cr.model_copy(update={"abilities": cr.abilities.model_copy(update={"dex_score": 14})}) hero = make_state(cr, "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) engine._aoo_used["reflex"] = 1 obs = _snapshot(engine, hero) assert _aoo(obs, "reflex") == 2 assert _aoo(obs, "foe") == 1 # (d) never negative def test_aoo_remaining_never_negative() -> None: """Given: a combatant whose recorded AoO usage exceeds its allowance When: the Observation is built Then: aoo_remaining floors at 0.""" hero = make_state(make_combatant("hero"), "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) engine._aoo_used["hero"] = 5 obs = _snapshot(engine, hero) assert _aoo(obs, "hero") == 0 # (e) JSON serializability def test_aoo_remaining_snapshot_is_json_serializable() -> None: """Given: a snapshot whose combatants carry aoo_remaining When: to_dict() is dumped to JSON Then: serialization round-trips and keeps the new key.""" hero = make_state(make_combatant("hero"), "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) engine._aoo_used["foe"] = 1 data = json.loads(json.dumps(_snapshot(engine, hero))) assert _aoo(data, "hero") == 1 assert _aoo(data, "foe") == 0 # (f) round-scoped: clearing _aoo_used (new round) restores the budget def test_aoo_remaining_resets_on_new_round() -> None: """Given: a combatant that spent its only AoO this round When: the engine clears _aoo_used (as it does at the start of each round) Then: the snapshot shows the full allowance again.""" hero = make_state(make_combatant("hero"), "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = _engine([hero, foe]) engine._aoo_used["hero"] = 1 assert _aoo(_snapshot(engine, hero), "hero") == 0 engine._aoo_used.clear() assert _aoo(_snapshot(engine, hero), "hero") == 1