Files
pf1e-simulator/tests/test_aoo_budget.py
T

165 lines
5.8 KiB
Python

"""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