feat(combat): add stand up and drop prone
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user