feat(observation): expose remaining attacks of opportunity
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user