Files

903 lines
36 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) 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.
"""
from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.abilities import AbilitySpec
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,
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'}",
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=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),
bab=1,
initiative_mod=initiative_mod,
speed_land_ft=speed,
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 []
),
)
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)
def _rubble_row_grid(size: int = 16, row: int = 5) -> Grid:
"""Open grid whose single ``row`` is rubble (difficult terrain, cost 2)."""
terrain = tuple("." * size if r != row else "~" * size for r in range(size))
legend = {
".": TerrainType(type="floor", move_cost=1),
"~": TerrainType(type="rubble", move_cost=2),
}
return Grid.from_spec(MapSpec(name="rubble", terrain=terrain, legend=legend))
def _walled_grid(size: int = 16) -> Grid:
"""Open grid with a wall block at rows 1-4, col 5 (a straight-line barrier).
A hero at (0, 0) running toward a foe at (2, 8) would have to turn the
corner around the wall (a Dijkstra ``move`` does); the straight-line run
must instead stop at the wall face.
"""
terrain = []
for r in range(size):
row = ["#" if (1 <= r <= 4 and c == 5) else "." for c in range(size)]
terrain.append("".join(row))
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
return Grid.from_spec(MapSpec(name="walled", terrain=tuple(terrain), legend=legend))
# ── (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
# ── (c2) the aid bonus expires at the AIDER's next turn, not the beneficiary's ─
def test_aid_bonus_survives_beneficiary_turn_and_expires_at_aider_turn() -> None:
# SRD "Aid Another": the +2 lasts "as long as that attack comes before the
# beginning of your next turn" — your = the AIDER's. The ally aids the
# hero (ac) in round 1; the hero's own round-2 turn start must NOT clear
# the bonus, the ally's round-2 turn start must.
seen: dict[str, int] = {}
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
if cid == "hero" and engine._current_round == 2:
seen["hero_turn"] = state.aid_ac_bonus
if cid == "foe" and engine._current_round == 2:
seen["foe_turn"] = engine._states[0].aid_ac_bonus
if cid == "ally" and engine._current_round == 1:
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
return _WAIT
# Rolls: 3 initiatives (10,10,10); aid d20=10+5=15 >= 10 success.
eng = _trio(policy, [10, 10, 10, 10], round_cap=2)
eng.run()
assert seen["hero_turn"] == 2 # survived the beneficiary's turn start
assert seen["foe_turn"] == 0 # expired at the aider's turn start
# ── (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]
# 5-10-5: 4 diagonal cells cost 1+2+1+2 = 6 = the speed budget; the old
# cell-count budget let a speed-30 walker cover 6 diagonal cells.
assert move_hero.pos == (4, 4) # only 4 diagonal cells of the 15-cell path
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
# ── (d2) R2: a run cannot cross difficult terrain ────────────────────────────
def test_run_stops_before_difficult_terrain_toward_target() -> None:
"""Given: hero at (0,0), a rubble row across row 3, foe beyond it at (6,0)
When: hero runs (4x speed = 24 cells, far beyond the 3-cell obstacle)
Then: hero stops in front of the rubble and never enters it (CRB: "You
can't run or charge across difficult terrain")."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (6, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
# The footprint never touches the rubble row, and the run stopped at it.
assert all(cell[0] != 3 for cell in eng._states[0].occupied_cells())
assert eng._states[0].pos == (2, 0)
def test_run_beyond_difficult_terrain_to_destination_is_rejected() -> None:
"""Given: the same rubble row
When: hero runs to an explicit destination ONLY reachable across it
Then: the destination is rejected (unreachable_destination) — the
reachability check uses the same avoid-difficult constraint as the
descent (CRB: "You can't run or charge across difficult terrain"), so
the verdict and the walked path agree instead of silently stopping short."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(6, 0)),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (0, 0)
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
def test_run_destination_in_front_of_difficult_terrain_is_reachable() -> None:
"""Given: the same rubble row
When: hero runs to an explicit destination that avoids the rubble
Then: the run honours it (the field routes around difficult cells, the
budget is generous — the verdict is positive and the path arrives)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(2, 0)),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
# The default foe at (0, 1) threatens the start: the run provokes one
# (missed) AoO on the first step — scripted as a natural 1.
eng = _duo(policy, [10, 5, 1], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (2, 0)
assert "round 1 hero: run" in result.transcript
def test_move_can_enter_difficult_terrain_but_run_cannot() -> None:
"""Contrast: the same 3-cell geometry with `move` (6-cell budget) pays the
2x cost and DOES enter the rubble row — only the run refuses it."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", target_id="foe"),)
return _WAIT
grid = _rubble_row_grid(size=8, row=3)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (6, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
# Move: (0,0)->(1,0)->(2,0) then the rubble step costs 2 of the 6-cell
# budget, so it crosses: the hero ends beyond row 3.
assert eng._states[0].pos[0] > 3
# ── (d3) S4a: a run is a straight line (SRD Full-Round Actions — Run) ─────────
def test_run_does_not_turn_a_corner_around_a_wall() -> None:
"""Given: hero at (0,0), a wall column at col 5 (rows 1-4), foe at (2,8)
When: hero runs toward the foe (a Dijkstra move would bend around the wall)
Then: the run follows the straight Bresenham line and stops at the wall
face — it does NOT turn the corner (SRD Run: "move up to four times your
speed in a straight line")."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _walled_grid(size=12)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (2, 8))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
hero_state = eng._states[0]
# The straight line (0,0)->(2,8) crosses col 5 at rows 1-2 (both wall), so
# the run halts before the wall face and never reaches or bends past it.
assert hero_state.pos[1] < 5
assert hero_state.running is True
# Contrast: a plain move DOES route around the wall (bends the corner).
hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe2 = make_state(_combatant("foe"), "monsters", (2, 8))
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()
assert eng2._states[0].pos[1] > 5 # the move got around to the far side
def test_clear_straight_run_still_works() -> None:
"""Given: an open grid, hero (0,0), foe far along a clear straight line
When: hero runs toward the foe
Then: the straight-line run reaches adjacent to the target (unchanged
behaviour on an unobstructed line)."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", target_id="foe"),)
return _WAIT
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 15))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=foe, grid=grid)
eng.run()
hero_state = eng._states[0]
# 4x speed = 24 cells; the 15-cell straight run reaches (0,14), adjacent.
assert hero_state.pos == (0, 14)
assert hero_state.running is True
def test_run_destination_around_a_corner_is_rejected() -> None:
"""Given: the wall column, hero (0,0)
When: hero runs to an explicit destination only reachable by turning the
corner (2,8)
Then: the straight line cannot honour it, so the destination is rejected
(unreachable_destination) rather than the run bending around the wall."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(2, 8)),)
return _WAIT
grid = _walled_grid(size=12)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, grid=grid)
result = eng.run()
assert eng._states[0].pos == (0, 0)
assert "round 1 hero: run rejected (unreachable_destination)" in result.transcript
# ── (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
# ── (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