"""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) 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)) # ── (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) # ── (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_stops_before_difficult_terrain_to_destination() -> None: """Given: the same rubble row When: hero runs to an explicit destination beyond it Then: the descent stops in front of the rubble (the destination is reachable in budget, but the run cannot cross — it moves up to the obstacle and stops).""" 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)) # The default foe sits at (0, 1), adjacent to the start: the run walks # out of its threatened disk and provokes one (missed) AoO. eng = _duo(policy, [10, 5, 1, 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 # ── (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