"""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 prone modifiers follow the CRB (B3 conformance): a prone attacker takes -4 on melee attacks (ranged weapons are treated as crossbow-exempt — usable at -4, the model cannot tell them apart); a prone defender takes -4 AC vs melee and gains +4 AC vs ranged. """ 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 == []