feat(observation): expose remaining attacks of opportunity

This commit is contained in:
2026-09-10 14:08:25 +02:00
parent 6abd29cbc5
commit 61c9b7fa47
3 changed files with 175 additions and 2 deletions
+7 -2
View File
@@ -448,7 +448,9 @@ Règles modélisées :
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer 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 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 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 (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 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). 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 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 (+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 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, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
+4
View File
@@ -883,6 +883,10 @@ class CombatEngine:
"power_attack": state.power_attack, "power_attack": state.power_attack,
"defensive_ac_bonus": state.defensive_ac_bonus, "defensive_ac_bonus": state.defensive_ac_bonus,
"fight_defensively": state.fight_defensively, "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, "name": state.combatant.name,
"level": state.combatant.level, "level": state.combatant.level,
"size": state.combatant.size, "size": state.combatant.size,
+164
View File
@@ -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