From 61c9b7fa474fdf71f385577cde6375182f927fd6 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 14:08:25 +0200 Subject: [PATCH] feat(observation): expose remaining attacks of opportunity --- README.md | 9 +- src/pf1e_simulator/combat.py | 4 + tests/test_aoo_budget.py | 164 +++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/test_aoo_budget.py diff --git a/README.md b/README.md index 615e947..ca2c557 100644 --- a/README.md +++ b/README.md @@ -448,7 +448,9 @@ Règles modélisées : la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer avec une arme à distance depuis une case menacée — chaque ennemi menaçant porte une AoO avant chaque tir. Une AoO par combattant par round (piste - `_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées + `_aoo_used`, réinitialisée au début du round) ; le budget restant est exposé + dans l'Observation via la clé `aoo_remaining` par combattant ; les AoO sont + toujours portées (PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne protège que la case de départ (voir ci-dessous). @@ -653,7 +655,10 @@ Règles modélisées : defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale (+4) ou du combat défensif (+2), réinitialisé au début du tour du combattant), fight_defensively (bool — combat défensif actif : −4 aux - jets d'attaque et de confirmation jusqu'au prochain tour). + jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining + (int — attaques d'opportunité restantes ce round : `_aoo_allowance` + (1 par défaut, 1 + mod de Dex avec Combat Reflexes, plancher 1) moins les + AoO déjà consommées, réinitialisé à chaque nouveau round, jamais négatif). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c0c24e6..25cc0bf 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -883,6 +883,10 @@ class CombatEngine: "power_attack": state.power_attack, "defensive_ac_bonus": state.defensive_ac_bonus, "fight_defensively": state.fight_defensively, + "aoo_remaining": max( + 0, + self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0), + ), "name": state.combatant.name, "level": state.combatant.level, "size": state.combatant.size, diff --git a/tests/test_aoo_budget.py b/tests/test_aoo_budget.py new file mode 100644 index 0000000..4846f95 --- /dev/null +++ b/tests/test_aoo_budget.py @@ -0,0 +1,164 @@ +"""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