e44ec75f2b
PF1e CRB: running is a full-round action, not a move action. The budget filter now classifies run as full-round (consumes the standard AND the move slot), _FULL_ROUND_KINDS includes it for the staggered filter, and the nauseated filter drops it (run is no longer move-like). The moved_this_turn/running flags are unchanged; the _execute_run moved_this_turn early return stays as defense in depth. default_policy never emits run, so the golden transcripts are untouched. Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
523 lines
20 KiB
Python
523 lines
20 KiB
Python
"""The PF1e aid another and run actions (optional Package U5e).
|
|
|
|
``aid_another`` is a standard action: the aider (which must have a usable
|
|
melee weapon) makes an attack roll — d20 + its first usable melee weapon's
|
|
bonus — against AC 10 (natural 20 always succeeds, natural 1 always fails);
|
|
on success the target stacks +2 on its next attack roll (``aid_mode``
|
|
"attack"/None, consumed one-shot by ``resolve_attack``) or +2 AC against the
|
|
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
|
|
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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
|
|
from pf1e_simulator.combat import (
|
|
Action,
|
|
ActionResult,
|
|
CombatantState,
|
|
CombatEngine,
|
|
)
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
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,
|
|
ac: int = 10,
|
|
flat_ac: int | None = None,
|
|
dex: int = 10,
|
|
speed: int = 30,
|
|
attack_bonus: int = 5,
|
|
ranged: bool = False,
|
|
) -> Combatant:
|
|
weapon = AttackSpec(
|
|
id=f"{id_}-{'bow' if ranged else 'sword'}",
|
|
name="short bow" if ranged else "short sword",
|
|
kind="ranged" if ranged else "melee",
|
|
attack_bonus=attack_bonus,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"]
|
|
if ranged else ["slashing"])],
|
|
range_increment_ft=60 if ranged else None,
|
|
)
|
|
return Combatant(
|
|
id=id_,
|
|
name=id_,
|
|
level=1,
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=dex, con_score=10,
|
|
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),
|
|
bab=1,
|
|
initiative_mod=initiative_mod,
|
|
speed_land_ft=speed,
|
|
hp_max=100,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[weapon],
|
|
)
|
|
|
|
|
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|
|
|
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
|
|
|
|
|
def _trio(
|
|
policy: PolicyFn, rng: list[int], round_cap: int,
|
|
) -> CombatEngine:
|
|
"""hero (0,0) acts first, ally (3,3) second, foe (0,1) adjacent last."""
|
|
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
ally = make_state(_combatant("ally", initiative_mod=1), "players", (3, 3))
|
|
foe = make_state(_combatant("foe"), "monsters", (0, 1))
|
|
return CombatEngine(
|
|
ScriptedRng(rng), make_grid(), [hero, ally, foe],
|
|
policy=policy, round_cap=round_cap,
|
|
)
|
|
|
|
|
|
def _duo(
|
|
policy: PolicyFn, rng: list[int], round_cap: int, *,
|
|
hero: CombatantState | None = None,
|
|
foe: CombatantState | None = None,
|
|
grid: Grid | None = None,
|
|
) -> CombatEngine:
|
|
hero = hero or make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
foe = foe or make_state(_combatant("foe"), "monsters", (0, 1))
|
|
return CombatEngine(
|
|
ScriptedRng(rng), grid or make_grid(), [hero, foe],
|
|
policy=policy, round_cap=round_cap,
|
|
)
|
|
|
|
|
|
def _open_grid(size: int = 16) -> Grid:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(
|
|
name="open", terrain=tuple(["." * size] * size), legend=legend,
|
|
)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
# ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ────────
|
|
|
|
|
|
def test_aid_attack_bonus_grants_two_and_is_consumed() -> None:
|
|
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 == 0:
|
|
return (Action(
|
|
kind="ready", target_id="foe",
|
|
ready_trigger="enemy_attacks",
|
|
),)
|
|
return (Action(kind="attack", target_id="foe"),)
|
|
if cid == "ally":
|
|
if n == 0:
|
|
return (Action(
|
|
kind="aid_another", target_id="hero", aid_mode="attack",
|
|
),)
|
|
return _WAIT
|
|
if n == 0:
|
|
return (Action(kind="attack", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
# Rolls: 3 initiatives; aid d20=10 (10+5=15 >= 10 success); foe attack
|
|
# d20=4 (9 vs AC 10, miss); readied hero attack d20=3 (3+5+2=10, hit) +
|
|
# d6=3; round-2 hero attack d20=3 (3+5=8, miss — bonus consumed); the
|
|
# ally and the foe both wait in round 2 (no rolls).
|
|
eng = _trio(policy, [10, 10, 10, 10, 4, 3, 3, 3], round_cap=2)
|
|
t = eng.run().transcript
|
|
|
|
assert (
|
|
"round 1 ally: aid another hero (attack) d20=10+5=15 vs AC 10 -> success"
|
|
in t
|
|
)
|
|
assert "round 1 hero: ready fires (enemy_attacks)" in t
|
|
# The readied attack that would miss by 2 (d20 3 + 5 = 8) hits with the +2.
|
|
assert (
|
|
"round 1 hero: short sword vs foe d20=3+5=10 AC 10 -> HIT 3 damage (100->97)"
|
|
in t
|
|
)
|
|
hero = eng._states[0]
|
|
assert hero.aid_attack_bonus == 0 # consumed by the readied attack
|
|
# The second attack (round 2, same scripted d20) does not get the bonus.
|
|
assert "round 2 hero: short sword vs foe d20=3+5=8 AC 10 -> MISS" in t
|
|
assert eng._states[2].hp == 97
|
|
assert hero.hp == 100
|
|
|
|
|
|
def test_aid_attack_control_without_aid_misses() -> None:
|
|
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" and n == 0:
|
|
return (Action(
|
|
kind="ready", target_id="foe", ready_trigger="enemy_attacks",
|
|
),)
|
|
if cid == "foe" and n == 0:
|
|
return (Action(kind="attack", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
# Same geometry, no aid: the readied d20=3+5=8 misses AC 10.
|
|
eng = _trio(policy, [10, 10, 10, 4, 3], round_cap=1)
|
|
t = eng.run().transcript
|
|
assert "round 1 hero: short sword vs foe d20=3+5=8 AC 10 -> MISS" in t
|
|
assert eng._states[0].aid_attack_bonus == 0
|
|
assert eng._states[2].hp == 100
|
|
|
|
|
|
# ── (b) aid (ac): +2 AC against the next attack targeting the ally ───────────
|
|
|
|
|
|
def test_aid_ac_bonus_raises_ally_ac() -> None:
|
|
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 == "ally" and n == 0:
|
|
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
|
|
if cid == "foe":
|
|
return (Action(kind="attack", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
# Aid d20=10 success; foe attack d20=5+5=10 vs AC 10+2=12 -> MISS.
|
|
eng = _trio(policy, [10, 10, 10, 10, 5], round_cap=1)
|
|
t = eng.run().transcript
|
|
assert (
|
|
"round 1 ally: aid another hero (ac) d20=10+5=15 vs AC 10 -> success"
|
|
in t
|
|
)
|
|
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 12 -> MISS" in t
|
|
hero = eng._states[0]
|
|
assert hero.aid_ac_bonus == 2
|
|
assert hero.aid_attack_bonus == 0
|
|
assert hero.hp == 100
|
|
|
|
|
|
def test_aid_ac_control_without_aid_hits() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "foe":
|
|
return (Action(kind="attack", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
eng = _trio(policy, [10, 10, 10, 5, 3], round_cap=1)
|
|
t = eng.run().transcript
|
|
assert (
|
|
"round 1 foe: short sword vs hero d20=5+5=10 AC 10 -> HIT 3 damage (100->97)"
|
|
in t
|
|
)
|
|
|
|
|
|
# ── (c) a failed aid roll grants nothing ─────────────────────────────────────
|
|
|
|
|
|
def test_failed_aid_grants_nothing() -> None:
|
|
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 == "ally" and n == 0:
|
|
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
|
|
if cid == "foe":
|
|
return (Action(kind="attack", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
# Aid d20=2 + 5 = 7 < 10 -> fail; the foe's d20=5+5=10 then hits plain AC 10.
|
|
eng = _trio(policy, [10, 10, 10, 2, 5, 3], round_cap=1)
|
|
t = eng.run().transcript
|
|
assert (
|
|
"round 1 ally: aid another hero (ac) d20=2+5=7 vs AC 10 -> fail" in t
|
|
)
|
|
hero = eng._states[0]
|
|
assert hero.aid_ac_bonus == 0
|
|
assert hero.aid_attack_bonus == 0
|
|
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 10 -> HIT 3 damage (100->97)" in t
|
|
assert hero.hp == 97
|
|
|
|
|
|
# ── (d) run moves up to 4x speed (destination and target), sets running ──────
|
|
|
|
|
|
def test_run_reaches_destination_beyond_normal_move() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="run", destination=(7, 7)),)
|
|
return _WAIT
|
|
|
|
# Speed 30 ft = 6 cells; run = 24 cells; (0,0)->(7,7) costs 7 diagonals.
|
|
# The foe sits at (7,0), far off the diagonal path — no AoO provokes.
|
|
foe = make_state(_combatant("foe"), "monsters", (7, 0))
|
|
eng = _duo(policy, [10, 5], round_cap=1, foe=foe)
|
|
t = eng.run().transcript
|
|
hero = eng._states[0]
|
|
assert hero.pos == (7, 7)
|
|
assert hero.running is True
|
|
assert "round 1 hero: run" in t
|
|
assert hero.last_action_results == [ActionResult(kind="run", ok=True, reason="ok")]
|
|
|
|
|
|
def test_normal_move_cannot_reach_the_same_destination() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(7, 7)),)
|
|
return _WAIT
|
|
|
|
eng = _duo(policy, [10, 5], round_cap=1)
|
|
t = eng.run().transcript
|
|
hero = eng._states[0]
|
|
assert hero.pos == (0, 0) # 7 cells > the 6-cell speed budget
|
|
assert "round 1 hero: move rejected (unreachable_destination)" in t
|
|
assert hero.running is False
|
|
# The run got strictly farther than the normal move.
|
|
assert make_grid().distance((7, 7), (0, 0)) > make_grid().distance(hero.pos, (0, 0))
|
|
|
|
|
|
def test_run_toward_target_outreaches_normal_move() -> None:
|
|
grid = _open_grid(16)
|
|
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
foe = make_state(_combatant("foe"), "monsters", (15, 15))
|
|
eng = _duo(
|
|
lambda _e, s: (
|
|
(Action(kind="run", target_id="foe"),)
|
|
if s.combatant.id == "hero" else _WAIT
|
|
),
|
|
[10, 5], round_cap=1, hero=hero, foe=foe, grid=grid,
|
|
)
|
|
t = eng.run().transcript
|
|
run_hero = eng._states[0]
|
|
assert run_hero.pos == (14, 14) # stopped adjacent to the foe
|
|
assert run_hero.running is True
|
|
assert "round 1 hero: run" in t
|
|
|
|
hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
foe2 = make_state(_combatant("foe"), "monsters", (15, 15))
|
|
eng2 = _duo(
|
|
lambda _e, s: (
|
|
(Action(kind="move", target_id="foe"),)
|
|
if s.combatant.id == "hero" else _WAIT
|
|
),
|
|
[10, 5], round_cap=1, hero=hero2, foe=foe2, grid=grid,
|
|
)
|
|
eng2.run()
|
|
move_hero = eng2._states[0]
|
|
assert move_hero.pos == (6, 6) # only 6 cells of the 15-cell path
|
|
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
|
|
|
|
|
|
# ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
|
|
|
|
|
|
def test_running_combatant_is_denied_dex_to_ac() -> None:
|
|
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: AC 14 (Dex +4) but flat-footed 10. Running denies the Dex bonus:
|
|
# the foe's d20=5+5=10 hits AC 10 (the flat-footed value).
|
|
hero = make_state(
|
|
_combatant("hero", initiative_mod=2, ac=14, flat_ac=10, dex=18),
|
|
"players", (0, 0),
|
|
)
|
|
foe = make_state(_combatant("foe"), "monsters", (0, 2))
|
|
eng = _duo(policy, [10, 10, 5, 3], 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 10 -> HIT 3 damage (100->97)"
|
|
in t
|
|
)
|
|
assert eng._states[0].hp == 97
|
|
|
|
|
|
def test_not_running_combatant_keeps_dex_to_ac() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="move", destination=(0, 1)),)
|
|
return (Action(kind="attack", target_id="hero"),)
|
|
|
|
# Same geometry, plain move (not running): the same roll misses AC 14.
|
|
hero = make_state(
|
|
_combatant("hero", initiative_mod=2, ac=14, flat_ac=10, dex=18),
|
|
"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 foe: short sword vs hero d20=5+5=10 AC 14 -> MISS" in t
|
|
assert eng._states[0].hp == 100
|
|
|
|
|
|
# ── (f) the snapshot exposes the three keys ──────────────────────────────────
|
|
|
|
|
|
def test_snapshot_exposes_aid_and_run_keys() -> None:
|
|
hero_snaps: list[dict[str, object]] = []
|
|
first_hero_snap: dict[str, object] = {}
|
|
|
|
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
cid = state.combatant.id
|
|
if cid == "hero":
|
|
obs = _snapshot(engine, state)
|
|
mine = next(c for c in obs["combatants"] if c["id"] == "hero")
|
|
first_hero_snap.update(mine)
|
|
return (Action(kind="run", destination=(1, 0)),)
|
|
if cid == "ally":
|
|
return (Action(kind="aid_another", target_id="hero"),)
|
|
obs = _snapshot(engine, state)
|
|
hero_snaps.append(
|
|
next(c for c in obs["combatants"] if c["id"] == "hero")
|
|
)
|
|
return _WAIT
|
|
|
|
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
ally = make_state(_combatant("ally", initiative_mod=1), "players", (3, 3))
|
|
foe = make_state(_combatant("foe"), "monsters", (0, 4))
|
|
eng = CombatEngine(
|
|
ScriptedRng([10, 10, 10, 10]), make_grid(), [hero, ally, foe],
|
|
policy=policy, round_cap=1,
|
|
)
|
|
eng.run()
|
|
|
|
# Before any aid/run: the three keys exist with neutral defaults.
|
|
assert first_hero_snap["aid_attack_bonus"] == 0
|
|
assert first_hero_snap["aid_ac_bonus"] == 0
|
|
assert first_hero_snap["running"] is False
|
|
# After hero ran and the ally aided (default mode = attack): live values.
|
|
assert len(hero_snaps) == 1
|
|
assert hero_snaps[0]["aid_attack_bonus"] == 2
|
|
assert hero_snaps[0]["aid_ac_bonus"] == 0
|
|
assert hero_snaps[0]["running"] is True
|
|
# A combatant that never used either keeps neutral values.
|
|
foe_snap = _snapshot(eng, eng._states[2])
|
|
foe_mine = next(c for c in foe_snap["combatants"] if c["id"] == "foe")
|
|
assert foe_mine["aid_attack_bonus"] == 0
|
|
assert foe_mine["aid_ac_bonus"] == 0
|
|
assert foe_mine["running"] is False
|
|
|
|
|
|
# ── (g) missing target/destination rejections (U1 path) ──────────────────────
|
|
|
|
|
|
def test_run_and_aid_without_target_are_rejected() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "hero":
|
|
return (Action(kind="run"), Action(kind="aid_another"))
|
|
return _WAIT
|
|
|
|
eng = _duo(policy, [10, 5], round_cap=1)
|
|
t = eng.run().transcript
|
|
hero = eng._states[0]
|
|
# run is a full-round action: it consumes the standard AND the move slot,
|
|
# so the budget filter drops the following aid_another as a surplus
|
|
# standard before it can even reach the missing-target gate.
|
|
assert hero.last_action_results == [
|
|
ActionResult(
|
|
kind="aid_another", ok=False, reason="downgraded:action_economy"
|
|
),
|
|
ActionResult(kind="run", ok=False, reason="missing_target"),
|
|
]
|
|
assert "round 1 hero: run rejected (missing_target)" in t
|
|
assert "round 1 hero: aid_another rejected (missing_target)" not in t
|
|
assert hero.pos == (0, 0)
|
|
assert hero.running is False
|
|
|
|
|
|
# ── (h) multiple aids stack (add, never replace) ─────────────────────────────
|
|
|
|
|
|
def test_multiple_aids_stack() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id in ("ally-1", "ally-2"):
|
|
return (Action(kind="aid_another", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
hero = make_state(_combatant("hero", initiative_mod=3), "players", (0, 0))
|
|
ally1 = make_state(_combatant("ally-1", initiative_mod=2), "players", (3, 3))
|
|
ally2 = make_state(_combatant("ally-2", initiative_mod=1), "players", (4, 4))
|
|
foe = make_state(_combatant("foe"), "monsters", (0, 1))
|
|
eng = CombatEngine(
|
|
ScriptedRng([10, 10, 10, 10, 10, 10]), make_grid(),
|
|
[hero, ally1, ally2, foe], policy=policy, round_cap=1,
|
|
)
|
|
eng.run()
|
|
assert hero.aid_attack_bonus == 4 # 2 + 2, added not replaced
|
|
|
|
|
|
# ── (i) aid without a usable melee weapon is rejected ────────────────────────
|
|
|
|
|
|
def test_aid_without_melee_weapon_is_rejected() -> None:
|
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "helper":
|
|
return (Action(kind="aid_another", target_id="hero"),)
|
|
return _WAIT
|
|
|
|
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
|
|
helper = make_state(
|
|
_combatant("helper", ranged=True), "monsters", (3, 3),
|
|
)
|
|
# Only the two initiative rolls are consumed — the aid never reaches a d20.
|
|
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=helper)
|
|
t = eng.run().transcript
|
|
assert "round 1 helper: aid_another rejected (no_weapon)" in t
|
|
assert hero.aid_attack_bonus == 0
|
|
assert hero.last_action_results == [
|
|
ActionResult(kind="wait", ok=True, reason="ok")
|
|
]
|
|
assert helper.last_action_results == [
|
|
ActionResult(kind="aid_another", ok=False, reason="no_weapon")
|
|
]
|
|
|
|
|
|
# ── (j) combats that use neither action are byte-identical ──────────────────
|
|
|
|
|
|
def test_no_aid_or_run_transcript_is_pinned() -> None:
|
|
eng = _duo(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",
|
|
)
|
|
for state in eng._states:
|
|
assert state.aid_attack_bonus == 0
|
|
assert state.aid_ac_bonus == 0
|
|
assert state.running is False
|