diff --git a/README.md b/README.md index f94d412..a065e76 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,23 @@ Règles modélisées : Simplification documentée : la progression d'Acrobatie (−2 / +3 avec 3 rangs) n'est pas modélisée. `default_policy` ne le choisit pas (disponible via une politique personnalisée). +- Se relever (`Action(kind="stand_up")`, action de mouvement) : le combattant à + plat ventre (renversé par trip/overrun ou après s'être jeté à terre) retire la + condition `prone` des **deux** représentations (`active_conditions` durée, + `conditions` sans durée) et redevient valide. Le relèvement **provoque une + attaque d'opportunité** de chaque ennemi menaçant (résolue via la mécanique de + mouvement `_check_move_aoo`, pendant qu'il est encore à plat ventre, CA −4). + Sans condition `prone`, l'action est rejetée (`not_prone`). Le kind ne requiert + pas de cible. `default_policy` ne la choisit pas (disponible via une politique + personnalisée). +- Se jeter à terre (`Action(kind="drop_prone")`, action libre) : le combattant + ajoute `prone` à la liste `conditions` **sans durée**, donc la condition + persiste jusqu'à `stand_up` (contrairement à trip/overrun qui posent + `active_conditions` avec 1 round). Les modificateurs de `prone` (−4 CA, −4 aux + attaques à distance) s'appliquent immédiatement. Si la condition est déjà + présente, l'action est rejetée (`already_prone`). Le kind ne requiert pas de + cible. `default_policy` ne la choisit pas (disponible via une politique + personnalisée). - Retour d'exécution des actions : chaque action produit un `ActionResult` explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results` au tour suivant). Les actions invalides sont rejetées et journalisées au lieu @@ -348,7 +365,9 @@ Règles modélisées : `downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ; retard valide → `ok=True, reason="delayed"`, second retard du même round → `ok=False, reason="already_delayed"` ; préparation valide → - `ok=True, reason="ready"`. + `ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False, + reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False, + reason="already_prone"`. - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») résout `count` balayages indépendants dans la même action ; les balayages restants sont perdus si la cible tombe (inconsciente ou morte) en cours de diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 010587f..6fdf029 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -537,7 +537,10 @@ class Action: ``ReactionEvent``; the readied action is then consumed. ``total_defense`` and ``fight_defensively`` are standard defensive actions granting a temporary dodge AC bonus until the combatant's next turn; - ``fight_defensively`` also takes -4 on its attacks. + ``fight_defensively`` also takes -4 on its attacks. ``stand_up`` is a move + action ending the prone condition (it provokes an AoO from each enemy + threatening the combatant); ``drop_prone`` is a free action going prone + until the combatant stands up. """ kind: Literal[ @@ -546,7 +549,7 @@ class Action: "maneuver", "channel_energy", "hex", "deed", "spell_combat", "inspire_courage", "retrieve_weapon", "repair_weapon", "activate_power_attack", "delay", "ready", - "total_defense", "fight_defensively", + "total_defense", "fight_defensively", "stand_up", "drop_prone", ] target_id: str | None = None spell_name: str | None = None @@ -567,11 +570,13 @@ class Action: # no_line_of_effect, no_slot and insufficient_resource are declared here for # the deeper execution-feedback package (U6b). U3 adds delayed / # already_delayed for the delay action. U4 adds ready for the ready action. +# U5b adds not_prone / already_prone for the prone actions. ACTION_RESULT_REASONS: frozenset[str] = frozenset({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "no_line_of_effect", "no_slot", "insufficient_resource", "downgraded:staggered", "downgraded:nauseated", "delayed", "already_delayed", "ready", + "not_prone", "already_prone", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" @@ -582,6 +587,8 @@ _REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift" _REASON_DELAYED = "delayed" _REASON_ALREADY_DELAYED = "already_delayed" _REASON_READY = "ready" +_REASON_NOT_PRONE = "not_prone" +_REASON_ALREADY_PRONE = "already_prone" # Action kinds that are declared but have no engine dispatch (U1 surfaces # them as explicit rejections instead of silent no-ops). @@ -2433,8 +2440,8 @@ class CombatEngine: return self._execute_delay(state, target, action) if kind == "ready": return self._execute_ready(state, action) - if kind in ("total_defense", "fight_defensively"): - return self._execute_defensive(state, kind) + if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"): + return self._execute_self_action(state, kind) self._dispatch_action(state, target, action) return ActionResult(kind=kind, ok=True, reason=_REASON_OK) @@ -2506,11 +2513,15 @@ class CombatEngine: ) return ActionResult(kind=kind, ok=True, reason=_REASON_READY) - def _execute_defensive(self, state: CombatantState, kind: str) -> ActionResult: - """Dispatch a target-free defensive standard action to its helper.""" + def _execute_self_action(self, state: CombatantState, kind: str) -> ActionResult: + """Dispatch a target-free self-affecting action to its helper.""" if kind == "total_defense": return self._execute_total_defense(state) - return self._execute_fight_defensively(state) + if kind == "fight_defensively": + return self._execute_fight_defensively(state) + if kind == "stand_up": + return self._execute_stand_up(state) + return self._execute_drop_prone(state) def _execute_total_defense(self, state: CombatantState) -> ActionResult: """Execute the total defense action (PF1e): standard action granting a @@ -2537,6 +2548,53 @@ class CombatEngine: ) return ActionResult(kind="fight_defensively", ok=True, reason=_REASON_OK) + def _execute_stand_up(self, state: CombatantState) -> ActionResult: + """Execute the stand up action (PF1e): move action that ends prone. + + Standing up provokes an AoO from each enemy threatening the + combatant's squares (``_check_move_aoo``, the movement machinery); + the AoOs resolve while the combatant is still prone (-4 AC). The + prone condition is then removed from both representations: the + duration-tracked ``active_conditions`` (trip/overrun) and the + duration-less ``conditions`` list (drop_prone). If a provoked AoO + takes the combatant out of the fight, the removal is a formality — + inactive combatants do not act. + """ + kind = "stand_up" + if not self._has_condition_named(state, "prone"): + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"{kind} rejected ({_REASON_NOT_PRONE})" + ) + return ActionResult(kind=kind, ok=False, reason=_REASON_NOT_PRONE) + self._check_move_aoo(state) + state.active_conditions = [ + ac for ac in state.active_conditions if ac.condition.name != "prone" + ] + state.conditions = [c for c in state.conditions if c.name != "prone"] + self._log(f"round {self._current_round} {state.combatant.id}: stands up") + return ActionResult(kind=kind, ok=True, reason=_REASON_OK) + + def _execute_drop_prone(self, state: CombatantState) -> ActionResult: + """Execute the drop prone action (PF1e): free action that goes prone. + + The prone condition is appended to the duration-less ``conditions`` + list (trip/overrun use the duration-tracked ``active_conditions`` + instead), so it persists until the combatant stands up. Both lists + are read by ``_has_condition_named``, so the prone modifiers + (-4 AC, -4 ranged attacks) apply immediately. + """ + kind = "drop_prone" + if self._has_condition_named(state, "prone"): + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"{kind} rejected ({_REASON_ALREADY_PRONE})" + ) + return ActionResult(kind=kind, ok=False, reason=_REASON_ALREADY_PRONE) + state.conditions.append(CONDITIONS_BY_NAME["prone"]) + self._log(f"round {self._current_round} {state.combatant.id}: drops prone") + return ActionResult(kind=kind, ok=True, reason=_REASON_OK) + def _dispatch_action( self, state: CombatantState, target: CombatantState | None, action: Action ) -> None: diff --git a/tests/test_prone_actions.py b/tests/test_prone_actions.py new file mode 100644 index 0000000..27710af --- /dev/null +++ b/tests/test_prone_actions.py @@ -0,0 +1,307 @@ +"""The PF1e prone actions (Package U5b). + +``stand_up`` is a move action that ends prone: it provokes an AoO from each +threatening enemy (resolved while still prone, via ``_check_move_aoo``) and +removes the ``prone`` condition from BOTH representations — the duration-tracked +``active_conditions`` (how trip/overrun apply it) and the duration-less +``conditions`` list (how ``drop_prone`` applies it). ``drop_prone`` is a free +action that goes prone until the combatant stands up. Rejections: standing up +while not prone → ``not_prone``; dropping while already prone → +``already_prone``. The engine's existing prone behaviour (flat -4 AC in +``_stat_modifiers``, -4 ranged attacks) is unchanged. +""" +from __future__ import annotations + +from collections.abc import Callable + +from pf1e_simulator.combat import ( + ACTION_RESULT_REASONS, + Action, + ActionResult, + CombatantState, + CombatEngine, +) +from pf1e_simulator.conditions import CONDITIONS_BY_NAME +from pf1e_simulator.dice import parse_dice +from pf1e_simulator.effects import ActiveCondition +from pf1e_simulator.models import ( + AbilityScores, + ACProfile, + AttackSpec, + Combatant, + DamageComponent, + Saves, +) +from pf1e_simulator.rng import ScriptedRng +from tests.test_observation_enriched import _snapshot, make_grid, make_state + + +def _combatant(id_: str, *, initiative_mod: int = 0) -> Combatant: + return Combatant( + id=id_, + name=id_, + level=1, + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + ac=ACProfile(total=10, touch=10, flat_footed=10), + bab=1, + initiative_mod=initiative_mod, + speed_land_ft=30, + hp_max=100, + saves=Saves(fort=0, ref=0, will=0), + attacks=[ + AttackSpec( + id=f"{id_}-sword", + name="short sword", + kind="melee", + attack_bonus=5, + damage=[ + DamageComponent(formula=parse_dice("1d6"), types=["slashing"]) + ], + ) + ], + ) + + +PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] + +_WAIT: tuple[Action, ...] = (Action(kind="wait"),) + + +def _duel(policy: PolicyFn, rng: list[int], round_cap: int) -> CombatEngine: + """hero (state order first, ties act first) adjacent to foe.""" + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + foe = make_state(_combatant("foe"), "monsters", (0, 1)) + return CombatEngine( + ScriptedRng(rng), make_grid(), [hero, foe], + policy=policy, round_cap=round_cap, + ) + + +def _prone_hero_state(*, rounds_left: int = 2) -> CombatantState: + """A hero carrying trip-style prone (duration-tracked active_conditions).""" + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + hero.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["prone"], + rounds_left=rounds_left, + source="maneuver: trip", + ) + ) + return hero + + +# ── (a) drop_prone applies prone and attacks reflect the -4 AC ──────────────── + + +def test_drop_prone_applies_prone_and_attacks_see_ac_penalty() -> None: + # Control: hero waits — d20 4 + 5 = 9 misses AC 10. + def waiting_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return _WAIT + return (Action(kind="attack", target_id="hero"),) + + plain = _duel(waiting_policy, [10, 5, 4], round_cap=1).run().transcript + assert "round 1 foe: short sword vs hero d20=4+5=9 AC 10 -> MISS" in plain + + # Same scripted roll against a hero who dropped prone: AC 6, hit. + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="drop_prone"),) + return (Action(kind="attack", target_id="hero"),) + + eng = _duel(policy, [10, 5, 4, 3], round_cap=1) + t = eng.run().transcript + assert "round 1 hero: drops prone" in t + assert ( + "round 1 foe: short sword vs hero d20=4+5=9 AC 6 -> HIT 3 damage (100->97)" + in t + ) + hero = eng._states[0] + # drop_prone uses the duration-less list; active_conditions stays empty. + assert [c.name for c in hero.conditions] == ["prone"] + assert hero.active_conditions == [] + assert hero.last_action_results == [ + ActionResult(kind="drop_prone", ok=True, reason="ok") + ] + + +def test_snapshot_exposes_dropped_prone() -> None: + hero_snaps: list[dict[str, object]] = [] + + def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "foe": + obs = _snapshot(engine, state) + hero_snaps.append( + next(c for c in obs["combatants"] if c["id"] == "hero") + ) + return _WAIT + if engine._current_round == 1: + return (Action(kind="drop_prone"),) + return _WAIT + + _duel(policy, [10, 5], round_cap=2).run() + + assert len(hero_snaps) == 2 + # rounds_left 0: the duration-less representation, persists until stand up. + assert ("prone", 0) in hero_snaps[0]["conditions"] + assert ("prone", 0) in hero_snaps[1]["conditions"] + assert hero_snaps[0]["last_turn_results"] == ( + {"kind": "drop_prone", "ok": True, "reason": "ok"}, + ) + + +# ── (b) stand_up clears prone ───────────────────────────────────────────────── + + +def test_stand_up_clears_prone() -> None: + # Foe out of reach: no provoked AoO, a pure clear test. + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + foe = make_state(_combatant("foe"), "monsters", (0, 5)) + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id != "hero": + return _WAIT + if _engine._current_round == 1: + return (Action(kind="drop_prone"),) + return (Action(kind="stand_up"),) + + eng = CombatEngine( + ScriptedRng([10, 5]), make_grid(), [hero, foe], + policy=policy, round_cap=2, + ) + t = eng.run().transcript + assert "round 1 hero: drops prone" in t + assert "round 2 hero: stands up" in t + hero_state = eng._states[0] + assert hero_state.conditions == [] + assert hero_state.active_conditions == [] + assert hero_state.last_action_results == [ + ActionResult(kind="stand_up", ok=True, reason="ok") + ] + + +# ── (c) stand_up on a non-prone combatant → not_prone ───────────────────────── + + +def test_stand_up_without_prone_is_rejected() -> None: + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="stand_up"),) + return _WAIT + + eng = _duel(policy, [10, 5], round_cap=1) + t = eng.run().transcript + assert "round 1 hero: stand_up rejected (not_prone)" in t + assert "stands up" not in "\n".join(t) + assert eng._states[0].last_action_results == [ + ActionResult(kind="stand_up", ok=False, reason="not_prone") + ] + assert "not_prone" in ACTION_RESULT_REASONS + + +# ── (d) drop_prone on an already-prone combatant → already_prone ────────────── + + +def test_drop_prone_when_already_prone_is_rejected() -> None: + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="drop_prone"),) + return _WAIT + + eng = _duel(policy, [10, 5], round_cap=2) + t = eng.run().transcript + assert "round 1 hero: drops prone" in t + assert "round 2 hero: drop_prone rejected (already_prone)" in t + hero = eng._states[0] + # Still exactly one prone entry — the rejection does not stack or clear. + assert [c.name for c in hero.conditions] == ["prone"] + assert hero.last_action_results == [ + ActionResult(kind="drop_prone", ok=False, reason="already_prone") + ] + assert "already_prone" in ACTION_RESULT_REASONS + + +# ── (e) a trip-prone combatant can stand up (same representation) ───────────── + + +def test_stand_up_removes_trip_applied_prone() -> None: + hero = make_state(_combatant("hero"), "players", (0, 0)) + foe = make_state(_combatant("foe"), "monsters", (0, 1)) + eng = CombatEngine( + ScriptedRng([10, 4, 3]), make_grid(), [hero, foe], + policy=lambda _e, _s: _WAIT, round_cap=1, + ) + eng._current_round = 1 + # Real trip handler: d20 10 + CMB 1 = 11 vs CMD 11 -> prone (active_conditions). + eng._execute_maneuver(foe, hero, "trip") + assert "trip vs hero d20=10+1=11 CMD 11 -> SUCCESS (prone)" in "\n".join( + eng._transcript + ) + assert [ + (ac.condition.name, ac.rounds_left) for ac in hero.active_conditions + ] == [("prone", 1)] + + # Stand up: the foe threatens, so an AoO strikes first (d20 4 + 5 = 9 vs + # prone AC 6 -> HIT, d6 3), then prone is removed from active_conditions. + result = eng._execute_stand_up(hero) + assert result == ActionResult(kind="stand_up", ok=True, reason="ok") + assert hero.active_conditions == [] + assert hero.conditions == [] + assert hero.hp == 97 + lines = "\n".join(eng._transcript) + assert "round 1 foe: AoO vs hero d20=4+5=9 AC 6 -> HIT 3 damage (100->97)" in lines + assert lines.index("AoO vs hero") < lines.index("round 1 hero: stands up") + + +# ── (f) standing up provokes an AoO while still prone (full turn flow) ──────── + + +def test_stand_up_provoke_opportunity_attack() -> None: + hero = _prone_hero_state() + foe = make_state(_combatant("foe"), "monsters", (0, 1)) + + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="stand_up"),) + return _WAIT + + # init hero d20 10+2=12, foe d20 5; hero's turn: AoO d20 4 + 5 = 9 vs + # AC 6 (still prone) -> HIT, d6 3. + eng = CombatEngine( + ScriptedRng([10, 5, 4, 3]), make_grid(), [hero, foe], + policy=policy, round_cap=1, + ) + t = eng.run().transcript + assert "round 1 foe: AoO vs hero d20=4+5=9 AC 6 -> HIT 3 damage (100->97)" in t + assert "round 1 hero: stands up" in t + assert t.index( + "round 1 foe: AoO vs hero d20=4+5=9 AC 6 -> HIT 3 damage (100->97)" + ) < t.index("round 1 hero: stands up") + assert eng._states[0].active_conditions == [] + assert eng._states[0].hp == 97 + assert eng._aoo_used == {"foe": 1} + + +# ── (g) combatants that never use them are unchanged ────────────────────────── + + +def test_no_prone_actions_transcript_is_pinned() -> None: + eng = _duel(lambda _e, _s: _WAIT, [10, 5], round_cap=2) + result = eng.run() + + assert result.transcript == ( + "initiative: hero d20=10+2=12", + "initiative: foe d20=5+0=5", + "round 1 hero: wait", + "round 1 foe: wait", + "round 2 hero: wait", + "round 2 foe: wait", + "battle over: draw after 2 rounds", + ) + assert result.winner is None + for state in eng._states: + assert state.conditions == [] + assert state.active_conditions == []