312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""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([1, 10, 4, 3]), make_grid(), [hero, foe],
|
|
policy=lambda _e, _s: _WAIT, round_cap=1,
|
|
)
|
|
eng._current_round = 1
|
|
# The prone hero threatens, so the trip provokes its AoO first (d20 1 ->
|
|
# natural 1 miss); then the 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 == []
|