fix(rules): run is bounded by Constitution endurance and the Run feat - after a number of consecutive run rounds equal to the CON score each further round requires a Constitution check (DC 10 +1 per prior check, no auto nat-1/20, undead/constructs exempt with no CON score); failure stops the run and forces the SRD 1-minute (10-round) rest; the Run feat grants +4 on the check and keeps the Dex bonus to AC while running (SRD Full-Round Actions - Run); heavy-armor x3 speed clause documented out-of-scope (no armor field); +5 tests

This commit is contained in:
2026-09-19 22:02:36 +02:00
parent 28bc624d1e
commit ea835293a0
3 changed files with 363 additions and 34 deletions
+30 -2
View File
@@ -951,7 +951,31 @@ Règles modélisées :
terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions),
**sauf s'il a le don Run** (SRD « Run » : « You lose any Dexterity bonus to
AC unless you have the Run feat » — `_running_denies_dex`). **Endurance
(SRD « Full-Round Actions — Run »)** : « You can run for a number of rounds
equal to your Constitution score, but after that you must make a DC 10
Constitution check to continue running. You must check again each round in
which you continue to run, and the DC of this check increases by 1 for each
check you have made. When you fail this check, you must stop running. A
character who has run to his limit must rest for 1 minute (10 rounds) before
running again. » Le moteur compte les rounds de course **consécutifs** par
combattant (`run_rounds`, la série se brise quand un tour passe sans
courir) ; au-delà du score de Constitution, chaque nouvelle round exige un
jet de Constitution DD `10 + jets déjà faits` (`run_checks`), **sans**
réussite/échec automatique sur 20/1 naturel (un test de caractéristique
n'est pas un jet d'attaque) ; le don **Run** donne **+4** à ce jet. Un échec
stoppe la course (tour perdu, aucun mouvement, journalisé `run stopped`) et
pose `run_rest_rounds = 10` : pendant le repos, toute action `run` est
refusée (journalisé `wait (cannot run, resting …)`) — le round de l'échec
compte comme le premier des dix. Les morts-vivants et les constructeurs n'ont
pas de score de Constitution et sont exemptés. Simplification assumée : la
règle du repos borne la course consécutive par un compteur de tours au sens
du moteur (le « repos » n'exige pas l'inactivité totale, seule la course est
interdite). **Hors périmètre (documenté, non approximé)** : la clause
d'armure lourde « or three times your speed if you're in heavy armor » — le
modèle n'a aucun champ armure (la CA des JSON est une valeur finale). Une
`destination` que la ligne droite ne peut pas honorer dans le budget 4×
(hors portée, bloquée par un obstacle/une créature, ou joignable uniquement
en tournant un coin ou en traversant le terrain difficile) est **rejetée**
@@ -1660,7 +1684,11 @@ Règles modélisées :
début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide
autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de
son tour), running (bool — course en cours jusqu'au prochain tour du
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
combattant : il perd son bonus de Dextérité à la CA, sauf don Run),
run_rounds (int — rounds de course consécutifs déjà courus, série brisée
quand un tour passe sans courir) et run_rest_rounds (int — rounds de repos
restants après un échec du jet d'endurance, course interdite tant que > 0)
— voir « Course », bleed_damage (int —
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`),
recurring_damage (S2c — liste des sources de dégâts récurrents de sort
+171 -30
View File
@@ -275,6 +275,15 @@ _PRECISION_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"})
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed
# PF1e (SRD "Full-Round Actions — Run"): after running a number of rounds equal
# to your Constitution score, each further round of running requires a DC 10
# Constitution check whose DC rises by 1 per check made; the Run feat grants
# +4 on this check (and lets the runner keep its Dexterity bonus to AC). A
# failed check stops the run and forces a 1-minute (10-round) rest.
_RUN_FEAT = "Run"
_RUN_CHECK_BASE_DC = 10
_RUN_FEAT_CHECK_BONUS = 4
_RUN_REST_ROUNDS = 10
_OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin
_BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD
_DIRTY_TRICK_EXTRA_ROUND_MARGIN = 5 # PF1e APG: +1 round per 5 points above CMD
@@ -791,6 +800,15 @@ class CombatantState:
aid_ac_bonus: int = 0
aid_grants: list[tuple[str, str]] = field(default_factory=list)
running: bool = False
# Run endurance (SRD "Full-Round Actions — Run", not in the frozen data
# model): run_rounds counts the CONSECUTIVE rounds this combatant has run;
# once it reaches the combatant's Constitution score, each further round
# requires a Constitution check (DC 10 + run_checks, +4 with the Run feat);
# a failure stops the run and sets run_rest_rounds (10-round rest, no
# running). Undead/constructs have no Constitution score: exempt.
run_rounds: int = 0
run_checks: int = 0
run_rest_rounds: int = 0
# Bleeding (V5b, not in the frozen data model): damage taken at the start
# of each turn; any effect that restores hit points clears it.
bleed_damage: int = 0
@@ -1045,7 +1063,9 @@ class Action:
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
reusing the move machinery (step-by-step, AoO-provoking); it sets
``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC.
runner its Dexterity bonus to AC (unless it has the Run feat). Running
beyond a number of consecutive rounds equal to the runner's Constitution
score requires a DC 10 Constitution check each round (Run feat: +4).
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
listed here AND known by the caster (in ``Combatant.metamagic_feats``)
@@ -1514,6 +1534,8 @@ class CombatEngine:
"aid_attack_bonus": state.aid_attack_bonus,
"aid_ac_bonus": state.aid_ac_bonus,
"running": state.running,
"run_rounds": state.run_rounds,
"run_rest_rounds": state.run_rest_rounds,
"bleed_damage": state.bleed_damage,
"recurring_damage": [
{
@@ -1886,7 +1908,7 @@ class CombatEngine:
state.fight_defensively = False
state.total_defense = False
self._expire_aid_grants(state)
state.running = False
self._tick_run_endurance(state)
state.swift_used_this_turn = False
original = self._policy(self, state)
results: list[ActionResult] = []
@@ -2905,11 +2927,13 @@ class CombatEngine:
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
)
# Check if target denies DEX to AC (flat-footed); a running target
# loses its DEX bonus too (U5e), reusing the same AC path. An
# invisible attacker also denies the target's DEX bonus to AC.
# loses its DEX bonus too (U5e), reusing the same AC path — unless it
# has the Run feat (SRD Run: "You lose any Dexterity bonus to AC
# unless you have the Run feat"). An invisible attacker also denies
# the target's DEX bonus to AC.
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._running_denies_dex(target)
or self._has_condition_named(attacker, "invisible")
)
full_ac_base = (
@@ -4419,6 +4443,21 @@ class CombatEngine:
break
aider.aid_grants = []
def _tick_run_endurance(self, state: CombatantState) -> None:
"""Per-round run-endurance bookkeeping at the start of the turn.
PF1e (SRD "Full-Round Actions — Run"): the consecutive-run streak
breaks when a round passes without running; the 10-round rest forced by
a failed endurance check ticks once per round (the failing round itself
counts as the first). Then the running flag lapses with the turn.
"""
if not state.running:
state.run_rounds = 0
state.run_checks = 0
if state.run_rest_rounds > 0:
state.run_rest_rounds -= 1
state.running = False
def _execute_run(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult:
@@ -4440,11 +4479,23 @@ class CombatEngine:
ends adjacent to the target. Friendly squares are still crossed, never
ended on (SRD Movement, conformance L5). On completion the combatant is
``running`` until the start of its next turn, which denies it its
Dexterity bonus to AC. An explicit destination that the straight line
cannot honour within the 4x budget (blocked by terrain, difficult
terrain, a creature, or out of range) is rejected with
``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``.
Dexterity bonus to AC unless it has the Run feat (SRD Run: "You lose
any Dexterity bonus to AC unless you have the Run feat"). PF1e bounds
the run by endurance: "You can run for a number of rounds equal to
your Constitution score, but after that you must make a DC 10
Constitution check to continue running. You must check again each
round in which you continue to run, and the DC of this check increases
by 1 for each check you have made. When you fail this check, you must
stop running. A character who has run to his limit must rest for 1
minute (10 rounds) before running again." The Run feat grants +4 on
the check. Out of scope (documented, not approximated): the heavy-
armor clause "or three times your speed if you're in heavy armor" —
the model carries no armor field (AC is a final sheet value). An
explicit destination that the straight line cannot honour within the
4x budget (blocked by terrain, difficult terrain, a creature, or out
of range) is rejected with ``unreachable_destination``; with neither
target nor destination the U1 gate rejects the action with
``missing_target``.
"""
kind = "run"
if state.moved_this_turn:
@@ -4466,30 +4517,120 @@ class CombatEngine:
):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
# SRD Run endurance: the past-the-limit Constitution check and the
# 10-round rest that follows a failure gate the run (the turn is
# forfeited either way, with the verdict logged by the gate).
if not self._run_endurance_gate(state):
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if action.destination is not None:
if action.destination == state.pos:
path: list[Pos] = [] # already there: a valid zero-length run
else:
path = self._run_line_path(state, action.destination)
if not path or path[-1] != action.destination:
path = [] # the straight line could not honour it
if not path and action.destination != state.pos:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
self._walk_steps(state, path)
state.moved_this_turn = True
elif target is not None:
return self._run_to_destination(state, action.destination)
if target is not None:
path = self._run_line_path(state, target.pos)
if self._walk_steps(state, path):
state.moved_this_turn = True
return self._finish_run(state)
def _run_to_destination(self, state: CombatantState, destination: Pos) -> ActionResult:
"""Run to an explicit destination (U5c): the straight line must honour it.
A destination equal to the current position is a valid zero-length run;
a straight line that cannot reach the destination within the 4x budget
is rejected with ``unreachable_destination`` (no silent short-stop).
"""
kind = "run"
if destination == state.pos:
path: list[Pos] = [] # already there: a valid zero-length run
else:
path = self._run_line_path(state, destination)
if not path or path[-1] != destination:
path = [] # the straight line could not honour it
if not path and destination != state.pos:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
self._walk_steps(state, path)
state.moved_this_turn = True
return self._finish_run(state)
def _finish_run(self, state: CombatantState) -> ActionResult:
"""Complete a run round: extend the streak and set the running flag."""
state.run_rounds += 1
state.running = True
self._log(f"round {self._current_round} {state.combatant.id}: run")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
return ActionResult(kind="run", ok=True, reason=_REASON_OK)
def _running_denies_dex(self, state: CombatantState) -> bool:
"""True when the running state denies the Dex bonus to AC.
PF1e (SRD "Full-Round Actions — Run"): "You lose any Dexterity bonus
to AC unless you have the Run feat."
"""
return state.running and not self._has_feat(state, _RUN_FEAT)
def _run_endurance_gate(self, state: CombatantState) -> bool:
"""Endurance gate for one more run round (SRD "Full-Round Actions — Run").
During the post-failure rest the character "can move no faster than a
normal move action", so the run is forfeited. Past a number of
consecutive rounds equal to the Constitution score, each further round
requires the escalating Constitution check (see
``_run_endurance_check``). Returns True when the run may proceed.
"""
if state.run_rest_rounds > 0:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"wait (cannot run, resting {state.run_rest_rounds} rounds)"
)
return False
return self._run_endurance_check(state)
def _run_endurance_check(self, state: CombatantState) -> bool:
"""Constitution check to keep running past the runner's CON score.
PF1e (SRD "Full-Round Actions — Run"): "You can run for a number of
rounds equal to your Constitution score, but after that you must make
a DC 10 Constitution check to continue running. You must check again
each round in which you continue to run, and the DC of this check
increases by 1 for each check you have made. When you fail this check,
you must stop running. A character who has run to his limit must rest
for 1 minute (10 rounds) before running again." The Run feat grants
"+4 bonus on this check" (SRD Feats — Run). Undead and constructs have
no Constitution score and cannot be fatigued, so they are exempt. An
ability check takes no automatic success/failure on a natural roll
(CRB "Using Ability Scores"). Returns True when the run may proceed.
"""
if state.combatant.creature_type != "living":
return True
if state.run_rounds < state.combatant.abilities.con_score:
return True
dc = _RUN_CHECK_BASE_DC + state.run_checks
con_mod = (state.combatant.abilities.con_score - 10) // 2
bonus = _RUN_FEAT_CHECK_BONUS if self._has_feat(state, _RUN_FEAT) else 0
roll = self._rng.d20()
total = roll + con_mod + bonus
state.run_checks += 1
check = f"d20({roll}) + CON({con_mod})"
if bonus:
check += f" + Run({bonus})"
check += f" = {total} vs DC {dc}"
if total >= dc:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"keeps running ({check})"
)
return True
state.run_rounds = 0
state.run_checks = 0
state.run_rest_rounds = _RUN_REST_ROUNDS
self._log(
f"round {self._current_round} {state.combatant.id}: run stopped "
f"({check} failed, rests {_RUN_REST_ROUNDS} rounds)"
)
return False
def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action
@@ -6402,7 +6543,7 @@ class CombatEngine:
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible")
)
if denies_dex:
@@ -6444,7 +6585,7 @@ class CombatEngine:
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible")
)
if denies_dex:
+162 -2
View File
@@ -9,7 +9,11 @@ next attack targeting it (``aid_mode`` "ac"). ``run`` is a full-round action
moving up to 4x speed toward ``target_id`` or an explicit ``destination``,
reusing the move machinery (Dijkstra descent, step-by-step AoO walk); it
sets ``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC (the flat-footed path). Both kinds are
runner its Dexterity bonus to AC (the flat-footed path) unless it has the Run
feat. Running is bounded by endurance (SRD "Full-Round Actions — Run"): a
number of consecutive rounds equal to the runner's Constitution score, then a
DC 10 Constitution check each round (+1 DC per prior check, +4 with the Run
feat); a failure stops the run and forces a 10-round rest. Both kinds are
exposed through the U1 feedback contract (``missing_target`` when neither a
target nor a destination is given) and the three runtime fields are additive
snapshot keys. The transcript of a combat that uses neither is unchanged.
@@ -18,6 +22,7 @@ from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import (
Action,
ActionResult,
@@ -46,9 +51,12 @@ def _combatant(
ac: int = 10,
flat_ac: int | None = None,
dex: int = 10,
con: int = 10,
speed: int = 30,
attack_bonus: int = 5,
ranged: bool = False,
run_feat: bool = False,
creature_type: str = "living",
) -> Combatant:
weapon = AttackSpec(
id=f"{id_}-{'bow' if ranged else 'sword'}",
@@ -64,7 +72,7 @@ def _combatant(
name=id_,
level=1,
abilities=AbilityScores(
str_score=10, dex_score=dex, con_score=10,
str_score=10, dex_score=dex, con_score=con,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=ac, touch=ac, flat_footed=flat_ac if flat_ac is not None else ac),
@@ -74,6 +82,12 @@ def _combatant(
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=[weapon],
creature_type=creature_type,
features=(
[AbilitySpec(name="Run", category="feat", source_detail="test")]
if run_feat
else []
),
)
@@ -740,3 +754,149 @@ def test_no_aid_or_run_transcript_is_pinned() -> None:
assert state.aid_attack_bonus == 0
assert state.aid_ac_bonus == 0
assert state.running is False
# ── (k) run endurance: CON-based rounds, escalating DC 10 checks, Run feat ───
def _run_east_each_round(
calls: dict[str, int],
) -> Callable[[CombatEngine, CombatantState], tuple[Action, ...]]:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero":
return (Action(kind="run", destination=(n + 1, 0)),)
return _WAIT
return policy
def test_run_past_constitution_rounds_requires_escalating_checks() -> None:
"""Given: a CON 2 hero (2 free run rounds) running every round
When: rounds 3 and 4 each demand the past-the-limit Constitution check
Then: round 3 passes DC 10 (d20 14 - 4 = 10), round 4 fails the escalated
DC 11 ("the DC of this check increases by 1 for each check you have
made"), the run stops with no movement, and the 10-round rest forbids
running while it lasts (SRD Full-Round Actions — Run)."""
calls: dict[str, int] = {}
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2, con=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (15, 15))
# Rolls: 2 initiatives (10, 5); round-3 check d20=14; round-4 check d20=14.
# Round 5 consumes nothing (rest rejection) — the queue pins RNG use.
eng = _duo(
_run_east_each_round(calls), [10, 5, 14, 14], round_cap=5,
hero=hero, foe=foe, grid=grid,
)
result = eng.run()
t = result.transcript
hero_state = eng._states[0]
assert "round 3 hero: keeps running (d20(14) + CON(-4) = 10 vs DC 10)" in t
assert (
"round 4 hero: run stopped (d20(14) + CON(-4) = 10 vs DC 11 failed, "
"rests 10 rounds)" in t
)
assert "round 5 hero: wait (cannot run, resting 9 rounds)" in t
# Rounds 1-3 moved one cell each; rounds 4-5 did not move.
assert hero_state.pos == (3, 0)
assert hero_state.running is False
assert hero_state.run_rest_rounds == 9
def test_run_streak_breaks_when_a_round_passes_without_running() -> None:
"""Given: a CON 2 hero that runs rounds 1-2 then waits round 3
When: it runs again in round 4
Then: no Constitution check is consumed (the consecutive-run streak reset
with the idle round) — the two initiative dice are the whole queue, so any
extra roll raises RngExhaustedError."""
calls: dict[str, int] = {}
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero":
if n == 2:
return _WAIT
return (Action(kind="run", destination=(n + 1, 0)),)
return _WAIT
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2, con=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(policy, [10, 5], round_cap=4, hero=hero, foe=foe, grid=grid)
result = eng.run()
assert not any("vs DC" in line for line in result.transcript)
assert eng._states[0].pos == (4, 0) # rounds 1, 2 and 4 moved (round 4 to (4,0))
def test_undead_are_exempt_from_run_endurance_checks() -> None:
"""Given: an undead CON 2 combatant (no Constitution score, SRD Undead)
When: it runs for 5 consecutive rounds
Then: no Constitution check is ever consumed — the queue holds only the
two initiative dice."""
calls: dict[str, int] = {}
grid = _open_grid(16)
hero = make_state(
_combatant("hero", initiative_mod=2, con=2, creature_type="undead"),
"players", (0, 0),
)
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(
_run_east_each_round(calls), [10, 5], round_cap=5,
hero=hero, foe=foe, grid=grid,
)
result = eng.run()
assert not any("vs DC" in line for line in result.transcript)
assert eng._states[0].pos == (5, 0)
def test_run_feat_grants_four_on_the_endurance_check() -> None:
"""Given: a CON 2 hero WITH the Run feat running to round 3
When: the past-the-limit Constitution check is made at DC 10
Then: the feat's +4 turns a plain failure (10 - 4 = 6) into a success
(SRD Feats — Run: "+4 bonus on this check")."""
calls: dict[str, int] = {}
grid = _open_grid(16)
hero = make_state(
_combatant("hero", initiative_mod=2, con=2, run_feat=True), "players", (0, 0)
)
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(
_run_east_each_round(calls), [10, 5, 10], round_cap=3,
hero=hero, foe=foe, grid=grid,
)
result = eng.run()
assert (
"round 3 hero: keeps running (d20(10) + CON(-4) + Run(4) = 10 vs DC 10)"
in result.transcript
)
assert eng._states[0].pos == (3, 0)
def test_run_feat_keeps_dex_bonus_to_ac_while_running() -> None:
"""Given: a Dex-18 (AC 14, flat-footed 10) hero WITH the Run feat
When: it runs and the foe attacks it the same round
Then: the attack resolves against AC 14 — the Run feat keeps the
Dexterity bonus (SRD Run: "You lose any Dexterity bonus to AC unless you
have the Run feat"); the feat-less control test above hits AC 10."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(0, 1)),)
return (Action(kind="attack", target_id="hero"),)
hero = make_state(
_combatant(
"hero", initiative_mod=2, ac=14, flat_ac=10, dex=18, run_feat=True
),
"players", (0, 0),
)
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _duo(policy, [10, 10, 5], round_cap=1, hero=hero, foe=foe)
t = eng.run().transcript
assert "round 1 hero: run" in t
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 14 -> MISS" in t
assert eng._states[0].hp == 100