"""Tests for the combat engine: attacks, crits, DR, hp, initiative, rounds. The scripted transcripts pin the exact deterministic behavior of the engine end to end: dice order, log lines, winner, and per-combatant stats. """ from __future__ import annotations from typing import Literal from pf1e_simulator.combat import ( Action, CombatantState, CombatantStats, CombatEngine, CombatResult, default_policy, ) 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, DamageReduction, Saves, ) from pf1e_simulator.rng import ScriptedRng, SeededRng def make_combatant( cid: str, *, hp: int = 6, ac: int = 13, attack_bonus: int = 2, damage: str = "1d4", damage_bonus: int = 0, crit_range: int = 20, crit_mult: int = 2, initiative_mod: int = 0, con: int = 12, speed: int = 30, weapon_name: str | None = None, dr: DamageReduction | None = None, kind: Literal["melee", "ranged", "touch"] = "melee", range_increment_ft: int | None = None, count: int = 1, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name=weapon_name or ("short bow" if kind == "ranged" else "short sword"), kind=kind, attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])], damage_bonus=damage_bonus, crit_range=crit_range, crit_mult=crit_mult, range_increment_ft=range_increment_ft, count=count, ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=con, int_score=10, wis_score=10, cha_score=10, ), hp_max=hp, ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=attack_bonus, initiative_mod=initiative_mod, speed_land_ft=speed, saves=Saves(fort=0, ref=0, will=0), attacks=[attack], dr=dr, ) def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) def make_grid() -> Grid: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) return Grid.from_spec(spec) def make_engine( queue: list[int], states: list[CombatantState], *, round_cap: int = 100 ) -> CombatEngine: return CombatEngine(ScriptedRng(queue), make_grid(), states, round_cap=round_cap) def test_attack_hit_deals_damage() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([12, 3], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.hit is True assert result.crit is False assert result.damage == 3 assert b.hp == 3 def test_attack_miss_leaves_target_untouched() -> None: a_spec = make_combatant("a") b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([9], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.hit is False assert result.damage == 0 assert b.hp == 6 def test_natural_1_always_misses() -> None: a_spec = make_combatant("a", attack_bonus=20) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([1], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.hit is False assert b.hp == 6 def test_natural_20_threatens_and_crits() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([20, 12, 4], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.crit is True assert result.damage == 8 assert b.hp == -2 def test_crit_range_19_threatens_on_19() -> None: a_spec = make_combatant("a", attack_bonus=2, crit_range=19) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([19, 11, 2], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.crit is True assert result.damage == 4 assert b.hp == 2 def test_confirm_fail_is_normal_hit() -> None: a_spec = make_combatant("a", attack_bonus=2, crit_range=19) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([19, 8, 2], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.hit is True assert result.crit is False assert result.damage == 2 assert b.hp == 4 def test_no_threat_outside_crit_range() -> None: a_spec = make_combatant("a", attack_bonus=2, crit_range=19) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) # Only 2 rolls consumed: no confirm roll happens outside the threat range. engine = make_engine([18, 3], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.crit is False assert result.damage == 3 assert b.hp == 3 def test_dr_reduces_damage() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b", dr=DamageReduction(amount=5)) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([12, 7], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.damage == 2 assert b.hp == 4 def test_dr_floors_damage_at_zero() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b", dr=DamageReduction(amount=5)) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([12, 3], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.damage == 0 assert b.hp == 6 def test_dr_bypass_ignores_reduction() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b", dr=DamageReduction(amount=5, bypass=frozenset({"slashing"}))) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([12, 7], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.damage == 7 assert b.hp == -1 def test_dr_applies_after_crit_multiplier() -> None: a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b", dr=DamageReduction(amount=5)) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([20, 11, 6], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) assert result.crit is True assert result.damage == 7 assert b.hp == -1 def test_count_two_resolves_two_swings() -> None: a_spec = make_combatant("a", attack_bonus=2, count=2) b_spec = make_combatant("b", hp=12) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine( [10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 3], [a, b] ) result = engine.run() assert result.winner == "players" assert result.rounds == 3 assert result.transcript == ( "initiative: a d20=10+0=10", "initiative: b d20=9+0=9", "round 1 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (12->9)", "round 1 a: short sword #2 vs b d20=10+2=12 AC 13 -> MISS", "round 1 b: short sword vs a d20=7+2=9 AC 13 -> MISS", "round 2 a: short sword #1 vs b d20=13+2=15 AC 13 -> HIT 4 damage (9->5)", "round 2 a: short sword #2 vs b d20=11+2=13 AC 13 -> HIT 2 damage (5->3)", "round 2 b: short sword vs a d20=8+2=10 AC 13 -> MISS", "round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (3->0)", "round 3 a: b down", "battle over: players win in 3 rounds", ) assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=12, damage_taken=0) assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=12) def test_count_swings_stop_when_target_drops() -> None: a_spec = make_combatant("a", attack_bonus=2, damage="1d6", count=2) b_spec = make_combatant("b", hp=4) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) # First swing downs b; the second swing must never consume a roll. engine = make_engine([10, 9, 12, 6], [a, b]) result = engine.run() assert result.winner == "players" assert result.rounds == 1 assert result.transcript == ( "initiative: a d20=10+0=10", "initiative: b d20=9+0=9", "round 1 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 6 damage (4->-2)", "round 1 a: b down", "battle over: players win in 1 rounds", ) assert result.stats["a"] == CombatantStats(hits=1, crits=0, damage_dealt=6, damage_taken=0) assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=6) def test_count_swings_resolve_independently() -> None: a_spec = make_combatant("a", attack_bonus=2, count=2) b_spec = make_combatant("b", hp=12) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([10, 9, 20, 11, 3, 5, 8], [a, b], round_cap=1) result = engine.run() assert result.winner is None assert result.transcript == ( "initiative: a d20=10+0=10", "initiative: b d20=9+0=9", "round 1 a: short sword #1 vs b d20=20+2=22 AC 13 -> CRIT 6 damage (12->6)", "round 1 a: short sword #2 vs b d20=5+2=7 AC 13 -> MISS", "round 1 b: short sword vs a d20=8+2=10 AC 13 -> MISS", "battle over: draw after 1 rounds", ) assert result.stats["a"] == CombatantStats(hits=1, crits=1, damage_dealt=6, damage_taken=0) assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=6) def test_hp_states_active_down_dead() -> None: c = make_combatant("c", con=12) st = make_state(c, "players", (0, 0)) st.hp = 1 assert st.active is True assert st.dead is False st.hp = 0 assert st.active is False assert st.dead is False st.hp = -11 assert st.active is False assert st.dead is False st.hp = -12 assert st.active is False assert st.dead is False # dead requires hp < -12, not <= st.hp = -13 assert st.active is False assert st.dead is True def test_scripted_2v2_transcript() -> None: gob_1 = make_combatant( "gob-1", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6 ) gob_2 = make_combatant( "gob-2", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6 ) orc_1 = make_combatant( "orc-1", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18, weapon_name="falchion", ) orc_2 = make_combatant( "orc-2", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18, weapon_name="falchion", ) states = [ make_state(gob_1, "players", (2, 2)), make_state(gob_2, "players", (2, 5)), make_state(orc_1, "monsters", (3, 3)), make_state(orc_2, "monsters", (3, 4)), ] queue = [ 15, 5, 14, 7, # initiative: gob-1, gob-2, orc-1, orc-2 13, 3, # R1 gob-1 hits orc-1 for 3 10, # R1 orc-1 misses gob-1 14, 1, # R1 gob-2 hits orc-2 for 1 17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9 19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4 15, 1, 4, # R2 orc-2 hits gob-1 for 1+4+4=9 ] engine = make_engine(queue, states) result = engine.run() assert result.winner == "monsters" assert result.rounds == 2 assert result.transcript == ( "initiative: gob-1 d20=15+6=21", "initiative: orc-1 d20=14+0=14", "initiative: gob-2 d20=5+6=11", "initiative: orc-2 d20=7+0=7", "round 1 gob-1: short sword vs orc-1 d20=13+2=15 AC 13 -> HIT 3 damage (6->3)", "round 1 orc-1: falchion vs gob-1 d20=10+5=15 AC 16 -> MISS", "round 1 gob-2: short sword vs orc-2 d20=14+2=16 AC 13 -> HIT 1 damage (6->5)", "round 1 orc-2: falchion vs gob-2 d20=17+5=22 AC 16 -> HIT 9 damage (6->-3)", "round 1 orc-2: gob-2 down", "round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)", "round 2 gob-1: orc-1 down", "round 2 orc-2: charge (3,4)->(2,4)->(1,3)", "round 2 orc-2: charge vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)", "round 2 orc-2: gob-1 down", "battle over: monsters win in 2 rounds", ) assert result.stats["gob-1"] == CombatantStats(hits=2, crits=1, damage_dealt=7, damage_taken=9) assert result.stats["gob-2"] == CombatantStats(hits=1, crits=0, damage_dealt=1, damage_taken=9) assert result.stats["orc-1"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=7) assert result.stats["orc-2"] == CombatantStats(hits=2, crits=0, damage_dealt=18, damage_taken=1) def test_same_seed_replay_is_identical() -> None: def run_battle() -> CombatResult: gob = make_combatant( "gob", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6 ) orc = make_combatant( "orc", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18 ) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))] return CombatEngine(SeededRng(42), make_grid(), states).run() first = run_battle() second = run_battle() assert first.transcript == second.transcript assert first.stats == second.stats assert first.winner == second.winner assert first.rounds == second.rounds def test_default_policy_charges_toward_enemy() -> None: gob = make_combatant("gob", speed=30) orc = make_combatant("orc", speed=0) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))] engine = make_engine([10, 9, 12, 3, 11, 2, 10, 1], states, round_cap=2) result = engine.run() assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", "round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)", "round 1 gob: charge vs orc d20=12+4=16 AC 13 -> HIT 3 damage (6->3)", "round 1 orc: short sword vs gob d20=11+2=13 AC 11 -> HIT 2 damage (6->4)", "round 2 gob: short sword vs orc d20=10+2=12 AC 13 -> MISS", "round 2 orc: short sword vs gob d20=1+2=3 AC 13 -> MISS", "battle over: draw after 2 rounds", ) def test_wait_when_enemy_out_of_reach_and_speed_zero() -> None: gob = make_combatant("gob", speed=0) orc = make_combatant("orc", speed=0) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 3))] engine = make_engine([10, 9], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", "round 1 gob: wait", "round 1 orc: wait", "battle over: draw after 1 rounds", ) def test_nearest_enemy_targeting_and_full_rounds() -> None: gob = make_combatant("gob", hp=6, ac=13, attack_bonus=2, damage="1d4") orc_1 = make_combatant("orc-1", hp=6, ac=13, attack_bonus=2, damage="1d4") orc_2 = make_combatant("orc-2", hp=6, ac=13, attack_bonus=2, damage="1d4") states = [ make_state(gob, "players", (1, 1)), make_state(orc_1, "monsters", (1, 2)), make_state(orc_2, "monsters", (2, 1)), ] queue = [ 15, 10, 5, # initiative: gob, orc-1, orc-2 12, 4, # R1 gob hits orc-1 for 4 7, # R1 orc-1 misses 8, # R1 orc-2 misses 12, 6, # R2 gob downs orc-1 9, # R2 orc-2 misses 11, 3, # R3 gob hits orc-2 for 3 12, 4, # R3 orc-2 hits gob for 4 12, 6, # R4 gob downs orc-2 ] engine = make_engine(queue, states) result = engine.run() assert result.winner == "players" assert result.rounds == 4 assert result.transcript == ( "initiative: gob d20=15+0=15", "initiative: orc-1 d20=10+0=10", "initiative: orc-2 d20=5+0=5", "round 1 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 4 damage (6->2)", "round 1 orc-1: short sword vs gob d20=7+2=9 AC 13 -> MISS", "round 1 orc-2: short sword vs gob d20=8+2=10 AC 13 -> MISS", "round 2 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 6 damage (2->-4)", "round 2 gob: orc-1 down", "round 2 orc-2: short sword vs gob d20=9+2=11 AC 13 -> MISS", "round 3 gob: short sword vs orc-2 d20=11+2=13 AC 13 -> HIT 3 damage (6->3)", "round 3 orc-2: short sword vs gob d20=12+2=14 AC 13 -> HIT 4 damage (6->2)", "round 4 gob: short sword vs orc-2 d20=12+2=14 AC 13 -> HIT 6 damage (3->-3)", "round 4 gob: orc-2 down", "battle over: players win in 4 rounds", ) def test_melee_routes_around_wall() -> None: """Melee unit follows the true shortest path instead of oscillating at a wall.""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), } spec = MapSpec( name="wall-test", terrain=("....#...", "....#...", "........"), legend=legend, ) grid = Grid.from_spec(spec) mover = make_combatant("mover", hp=20, speed=30) target = make_combatant("target", speed=0) states = [make_state(mover, "players", (1, 1)), make_state(target, "monsters", (1, 5))] engine = CombatEngine(SeededRng(42), grid, states) result = engine.run() assert result.winner == "players" assert result.stats["mover"].hits > 0 def test_ranged_attack_gets_cover_bonus() -> None: """A pillar between shooter and target grants partial cover (+4 AC).""" legend = { ".": TerrainType(type="floor", move_cost=1), "C": TerrainType(type="pillar", move_cost=None, cover=True), } spec = MapSpec(name="cover-test", terrain=(".C...",), legend=legend) grid = Grid.from_spec(spec) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))] engine = CombatEngine(ScriptedRng([12, 3]), grid, states) result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.ac == 17 # 13 + 4 cover bonus assert result.hit is False # 12 + 4 = 16 < 17 def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None: """Diagonal melee across a wall corner grants cover (+4 AC).""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), } spec = MapSpec(name="corner-test", terrain=(".#.", "#.."), legend=legend) grid = Grid.from_spec(spec) attacker = make_state(make_combatant("a", attack_bonus=2), "players", (0, 0)) target = make_state(make_combatant("b", ac=13), "monsters", (1, 1)) engine = CombatEngine(ScriptedRng([12, 3]), grid, [attacker, target]) result = engine.resolve_attack(attacker, target, attacker.combatant.attacks[0]) assert result.ac == 17 assert result.hit is False def test_ranged_no_line_of_effect_moves_around_wall() -> None: """An archer without line of effect 5ft-steps around the wall, then shoots from safety.""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), } spec = MapSpec(name="wall-block", terrain=("..#..", "....."), legend=legend) grid = Grid.from_spec(spec) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1]) engine = CombatEngine(rng, grid, states, round_cap=4) result = engine.run() assert result.winner == "players" assert result.transcript == ( "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", "round 1 archer: 5ft step (0,1)->(1,2)", "round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS", "round 1 target: wait", "round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)", "round 2 target: wait", "round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 3 damage (3->0)", "round 3 archer: target down", "battle over: players win in 3 rounds", ) def test_ranged_no_line_of_effect_unreachable_target_waits() -> None: """A target on an island (no LoE, no path) leaves the archer waiting.""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), } spec = MapSpec(name="walled-off", terrain=("..#..", "..#.."), legend=legend) grid = Grid.from_spec(spec) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1) result = engine.run() assert result.transcript[2] == "round 1 archer: wait" def test_ranged_penalty_zero_within_first_increment() -> None: """Within the first range increment (5 ft of 10), no penalty applies.""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 2))] engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states) result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.penalty == 0 assert result.total == 16 # 12 + 4 assert result.hit is True def test_ranged_penalty_beyond_first_increment() -> None: """At 15 ft (2nd full increment of 10 ft), the attack suffers -2.""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 4))] engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states) result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.penalty == -2 assert result.total == 14 # 12 + 4 - 2 assert result.hit is True def test_ranged_penalty_scales_with_more_increments() -> None: """At 25 ft (3rd full increment of 10 ft), the penalty reaches -4.""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 6))] engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states) result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.penalty == -4 assert result.total == 12 # 12 + 4 - 4: now a MISS vs AC 13 assert result.hit is False def test_ranged_penalty_shown_in_transcript() -> None: """The log line renders the penalty between bonus and total.""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 4))] engine = CombatEngine(ScriptedRng([10, 9, 12, 3]), make_grid(), states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", "round 1 archer: short bow vs target d20=12+4-2=14 AC 13 -> HIT 3 damage (6->3)", "round 1 target: wait", "battle over: draw after 1 rounds", ) def test_ranged_penalty_applies_to_crit_confirm() -> None: """The -2 range penalty also applies to the crit confirmation roll.""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", ac=15) states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 4))] # Natural 20 threatens; confirm 12 would beat AC 15 without the penalty # (12 + 4 = 16) but fails with it (12 + 4 - 2 = 14). engine = CombatEngine(ScriptedRng([20, 12, 3]), make_grid(), states) result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.hit is True assert result.crit is False assert result.damage == 3 # not doubled def test_ranged_weapon_usable_at_maximum_range() -> None: """At exactly 10 range increments the weapon is still usable, with -18.""" legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="range-max", terrain=("." * 24,), legend=legend) grid = Grid.from_spec(spec) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", ac=13) states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 20))] engine = CombatEngine(ScriptedRng([20, 3, 3]), grid, states) assert engine.weapon_for(states[0], states[1]) is not None result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) assert result.penalty == -18 assert result.hit is True # natural 20 def test_ranged_weapon_unusable_beyond_maximum_range() -> None: """Beyond 10 range increments the weapon is dropped and the policy 5ft-steps into range.""" legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend) grid = Grid.from_spec(spec) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 21))] engine = CombatEngine(ScriptedRng([10, 9, 8]), grid, states, round_cap=1) assert engine.weapon_for(states[0], states[1]) is None result = engine.run() assert result.transcript == ( "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", "round 1 archer: 5ft step (0,0)->(0,1)", "round 1 archer: short bow vs target d20=8+4-18=-6 AC 13 -> MISS", "round 1 target: wait", "battle over: draw after 1 rounds", ) def test_ranged_soft_cover_from_creature_between() -> None: """A creature between shooter and target grants soft cover (+4 AC).""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) blocker = make_combatant("blocker", speed=0) target = make_combatant("target", ac=13) states = [ make_state(archer, "players", (1, 1)), make_state(blocker, "players", (1, 2)), make_state(target, "monsters", (1, 4)), ] engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states) result = engine.resolve_attack(states[0], states[2], archer.attacks[0]) assert result.ac == 17 # 13 + 4 soft cover assert result.hit is False # 12 + 4 = 16 < 17 def test_melee_soft_cover_ignored_in_resolution() -> None: """Melee attacks ignore creatures for soft cover (CRB: ranged only).""" attacker = make_combatant("attacker", attack_bonus=2) blocker = make_combatant("blocker", speed=0) target = make_combatant("target", ac=13) states = [ make_state(attacker, "players", (1, 1)), make_state(blocker, "players", (1, 2)), make_state(target, "monsters", (1, 4)), ] engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states) result = engine.resolve_attack(states[0], states[2], attacker.attacks[0]) assert result.ac == 13 # melee ignores soft cover assert result.hit is True # 12 + 2 = 14 >= 13 def test_ranged_soft_cover_shown_in_transcript() -> None: """Soft cover raises the AC displayed in the attack log line.""" archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) blocker = make_combatant("blocker", speed=0) target = make_combatant("target", speed=0) states = [ make_state(archer, "players", (0, 0)), make_state(blocker, "players", (0, 2)), make_state(target, "monsters", (0, 4)), ] engine = CombatEngine(ScriptedRng([10, 9, 8, 12]), make_grid(), states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: archer d20=10+0=10", "initiative: blocker d20=9+0=9", "initiative: target d20=8+0=8", "round 1 archer: short bow vs target d20=12+4=16 AC 17 -> MISS", "round 1 blocker: wait", "round 1 target: wait", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # Flanking (CRB: +2 melee attack when ally threatens opposite side) # # --------------------------------------------------------------------------- # def test_flanking_grants_plus_two_melee() -> None: """An active ally on the opposite side grants +2 to melee attack rolls.""" atk = make_combatant("atk", attack_bonus=2, speed=0) aly = make_combatant("aly", attack_bonus=0, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) states = [ make_state(atk, "players", (3, 4)), make_state(aly, "players", (5, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 9, 8, 12, 3, 10, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: atk d20=10+0=10", "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 atk: short sword vs tgt d20=12+2+2(flank)=16 AC 15 -> HIT 3 damage (10->7)", "round 1 aly: short sword vs tgt d20=10+0+2(flank)=12 AC 15 -> MISS", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_no_flanking_without_opposite_ally() -> None: """Without an ally on the opposite side, the same roll misses (14 < 15).""" atk = make_combatant("atk", attack_bonus=2, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) states = [ make_state(atk, "players", (3, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 8, 12, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: atk d20=10+0=10", "initiative: tgt d20=8+0=8", "round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_ranged_attack_ignores_flanking() -> None: """Ranged attacks get no flanking bonus; ranged in melee provokes AoO.""" arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0) aly = make_combatant("aly", attack_bonus=0, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) states = [ make_state(arc, "players", (3, 4)), make_state(aly, "players", (5, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 9, 8, 15, 3, 12, 10, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: arc d20=10+0=10", "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)", "round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_diagonal_flanking() -> None: """Flanking works on the diagonal (opposite corner).""" atk = make_combatant("atk", attack_bonus=2, speed=0) aly = make_combatant("aly", attack_bonus=0, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) states = [ make_state(atk, "players", (3, 3)), make_state(aly, "players", (5, 5)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 9, 8, 12, 3, 10, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: atk d20=10+0=10", "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 atk: short sword vs tgt d20=12+2+2(flank)=16 AC 15 -> HIT 3 damage (10->7)", "round 1 aly: short sword vs tgt d20=10+0+2(flank)=12 AC 15 -> MISS", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_flanking_requires_active_ally() -> None: """A downed ally on the opposite side does not grant flanking.""" atk = make_combatant("atk", attack_bonus=2, speed=0) aly = make_combatant("aly", attack_bonus=0, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) aly_state = make_state(aly, "players", (5, 4)) aly_state.hp = 0 states = [ make_state(atk, "players", (3, 4)), aly_state, make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 9, 8, 12, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: atk d20=10+0=10", "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_flanking_requires_ally_on_same_side() -> None: """An enemy on the opposite side does not grant flanking.""" atk = make_combatant("atk", attack_bonus=2, speed=0) en2 = make_combatant("en2", hp=10, ac=20, attack_bonus=0, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) states = [ make_state(atk, "players", (3, 4)), make_state(en2, "monsters", (5, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 9, 8, 12, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: atk d20=10+0=10", "initiative: en2 d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 en2: wait", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # Full attack (CRB: BAB iteratives at BAB, BAB-5, BAB-10, BAB-15) # # --------------------------------------------------------------------------- # def test_full_attack_bab6_two_iteratives() -> None: """BAB +6 gives two attacks: at +6 and +1.""" war = make_combatant("war", attack_bonus=6, hp=10, speed=0) tgt = make_combatant("tgt", hp=20, ac=15, attack_bonus=0, speed=0) states = [ make_state(war, "players", (3, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 8, 12, 3, 5, 2], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: war d20=10+0=10", "initiative: tgt d20=8+0=8", "round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 3 damage (20->17)", "round 1 war: short sword #2 vs tgt d20=5+1=6 AC 15 -> MISS", "round 1 tgt: short sword vs war d20=2+0=2 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_full_attack_bab11_three_iteratives() -> None: """BAB +11 gives three attacks: at +11, +6, +1.""" war = make_combatant("war", attack_bonus=11, hp=10, speed=0) tgt = make_combatant("tgt", hp=30, ac=15, attack_bonus=0, speed=0) states = [ make_state(war, "players", (3, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 8, 12, 3, 10, 2, 5, 2], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: war d20=10+0=10", "initiative: tgt d20=8+0=8", "round 1 war: short sword #1 vs tgt d20=12+11=23 AC 15 -> HIT 3 damage (30->27)", "round 1 war: short sword #2 vs tgt d20=10+6=16 AC 15 -> HIT 2 damage (27->25)", "round 1 war: short sword #3 vs tgt d20=5+1=6 AC 15 -> MISS", "round 1 tgt: short sword vs war d20=2+0=2 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_full_attack_stops_on_target_down() -> None: """Remaining iteratives are lost when the target drops mid-full-attack.""" war = make_combatant("war", attack_bonus=6, hp=10, speed=0) tgt = make_combatant("tgt", hp=5, ac=15, attack_bonus=0, speed=0) states = [ make_state(war, "players", (3, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 8, 12, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: war d20=10+0=10", "initiative: tgt d20=8+0=8", "round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 5 damage (5->0)", "round 1 war: tgt down", "battle over: players win in 1 rounds", ) def test_full_attack_low_bab_single_swing() -> None: """BAB < 6 gives one attack — same as a regular attack, no '#1' label.""" war = make_combatant("war", attack_bonus=3, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=0, speed=0) states = [ make_state(war, "players", (3, 4)), make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 8, 12, 2, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: war d20=10+0=10", "initiative: tgt d20=8+0=8", "round 1 war: short sword vs tgt d20=12+3=15 AC 15 -> HIT 2 damage (10->8)", "round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # Attacks of opportunity (CRB: movement + ranged-in-melee, 1/round) # # --------------------------------------------------------------------------- # def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: """Test policy: always move toward the 'dest' combatant.""" return (Action(kind="move", target_id="dest"),) def test_movement_provokes_aoo() -> None: """Moving through a threatened square provokes an AoO; movement continues after.""" runner = make_combatant("runner", hp=10, ac=12, speed=30) guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0) dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0) states = [ make_state(runner, "players", (4, 1)), make_state(guard, "monsters", (3, 1)), make_state(dest, "monsters", (4, 5)), ] engine = CombatEngine( ScriptedRng([10, 9, 8, 15, 3, 5]), make_grid(), states, round_cap=1, policy=_move_to_dest, ) result = engine.run() assert result.transcript == ( "initiative: runner d20=10+0=10", "initiative: guard d20=9+0=9", "initiative: dest d20=8+0=8", "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (10->7)", "round 1 runner: move (4,1)->(3,2)->(3,3)->(3,4)", "round 1 guard: wait", "round 1 dest: wait", "battle over: draw after 1 rounds", ) def test_aoo_drops_mover() -> None: """If an AoO drops the mover, movement stops and the mover's side loses.""" runner = make_combatant("runner", hp=3, ac=12, speed=30) guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0) dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0) states = [ make_state(runner, "players", (4, 1)), make_state(guard, "monsters", (3, 1)), make_state(dest, "monsters", (4, 5)), ] engine = CombatEngine( ScriptedRng([10, 9, 8, 15, 3]), make_grid(), states, round_cap=1, policy=_move_to_dest, ) result = engine.run() assert result.winner == "monsters" assert result.transcript == ( "initiative: runner d20=10+0=10", "initiative: guard d20=9+0=9", "initiative: dest d20=8+0=8", "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)", "round 1 guard: runner down", "battle over: monsters win in 1 rounds", ) def test_aoo_limit_one_per_round() -> None: """A combatant can only make one AoO per round, even if multiple squares are threatened.""" runner = make_combatant("runner", hp=20, ac=12, speed=30) guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5, speed=0) dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) states = [ make_state(runner, "players", (0, 0)), make_state(guard, "monsters", (2, 2)), make_state(dest, "monsters", (4, 4)), ] engine = CombatEngine( ScriptedRng([10, 9, 8, 15, 3]), make_grid(), states, round_cap=1, policy=_move_to_dest, ) result = engine.run() assert result.transcript == ( "initiative: runner d20=10+0=10", "initiative: guard d20=9+0=9", "initiative: dest d20=8+0=8", "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", "round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)", "round 1 guard: wait", "round 1 dest: wait", "battle over: draw after 1 rounds", ) def test_two_guards_each_one_aoo() -> None: """Two enemies threatening the same square each get their own AoO (1/round each).""" runner = make_combatant("runner", hp=20, ac=12, speed=30) g1 = make_combatant("g1", hp=20, ac=15, attack_bonus=5, speed=0) g2 = make_combatant("g2", hp=20, ac=15, attack_bonus=5, speed=0) dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) states = [ make_state(runner, "players", (4, 1)), make_state(g1, "monsters", (3, 1)), make_state(g2, "monsters", (3, 2)), make_state(dest, "monsters", (4, 6)), ] engine = CombatEngine( ScriptedRng([10, 9, 8, 7, 15, 3, 15, 3]), make_grid(), states, round_cap=1, policy=_move_to_dest, ) result = engine.run() assert result.transcript == ( "initiative: runner d20=10+0=10", "initiative: g1 d20=9+0=9", "initiative: g2 d20=8+0=8", "initiative: dest d20=7+0=7", "round 1 g1: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", "round 1 g2: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (17->14)", "round 1 runner: move (4,1)->(4,2)->(3,3)->(3,4)->(3,5)", "round 1 g1: wait", "round 1 g2: wait", "round 1 dest: wait", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # Charge (CRB: straight-line 2x speed, +2 attack, -2 AC, single melee attack) # # --------------------------------------------------------------------------- # def _gob_charges_orc_attacks( engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "gob": return (Action(kind="charge", target_id="orc"),) return (Action(kind="attack", target_id="gob"),) def test_charge_straight_line_and_bonus() -> None: """Charge moves in a straight line and attacks at +2; enemy retaliates.""" gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30) orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))] engine = CombatEngine( ScriptedRng([10, 9, 12, 3, 11, 2]), make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks, ) result = engine.run() assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", "round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)", "round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (10->7)", "round 1 orc: short sword vs gob d20=11+0=11 AC 13 -> MISS", "battle over: draw after 1 rounds", ) def test_charge_ac_penalty_applies() -> None: """The -2 AC penalty from charging applies to the enemy's attack the same round.""" gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30) orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 4))] engine = CombatEngine( ScriptedRng([10, 9, 12, 3, 11, 2]), make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks, ) result = engine.run() assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", "round 1 gob: charge (1,1)->(1,2)->(0,3)", "round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (20->17)", "round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (20->18)", "battle over: draw after 1 rounds", ) def test_charge_too_close_full_attacks_instead() -> None: """When adjacent (cannot charge minimum 2 squares), default_policy full-attacks.""" gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30) orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0) states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))] engine = make_engine([10, 9, 12, 3], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: gob d20=10+0=10", "initiative: orc d20=9+0=9", "round 1 gob: short sword vs orc d20=12+2=14 AC 15 -> MISS", "round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # Withdraw (CRB: double move, first square protected, subsequent provoke) # # --------------------------------------------------------------------------- # def _withdraw_from_foe( engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: return (Action(kind="withdraw", target_id="foe"),) def test_withdraw_protects_first_square() -> None: """Withdraw moves 2x speed away; the starting square does not provoke AoO.""" flee = make_combatant("flee", hp=20, ac=15, attack_bonus=0, speed=30) foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0) states = [make_state(flee, "players", (4, 1)), make_state(foe, "monsters", (3, 1))] engine = CombatEngine( ScriptedRng([10, 9, 15, 3]), make_grid(), states, round_cap=1, policy=_withdraw_from_foe, ) result = engine.run() assert result.transcript == ( "initiative: flee d20=10+0=10", "initiative: foe d20=9+0=9", "round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,0)", "round 1 foe: wait", "battle over: draw after 1 rounds", ) def test_withdraw_provokes_after_first_square() -> None: """Subsequent squares during withdraw provoke AoOs from other enemies.""" flee = make_combatant("flee", hp=20, ac=12, attack_bonus=0, speed=30) foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0) foe2 = make_combatant("foe2", hp=20, ac=15, attack_bonus=5, speed=0) states = [ make_state(flee, "players", (4, 1)), make_state(foe, "monsters", (3, 1)), make_state(foe2, "monsters", (6, 1)), ] engine = CombatEngine( ScriptedRng([10, 9, 8, 15, 3, 5]), make_grid(), states, round_cap=1, policy=_withdraw_from_foe, ) result = engine.run() assert result.transcript == ( "initiative: flee d20=10+0=10", "initiative: foe d20=9+0=9", "initiative: foe2 d20=8+0=8", "round 1 foe2: AoO vs flee d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", "round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,1)", "round 1 foe: wait", "round 1 foe2: wait", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # Standard attack (single swing, no iteratives — PF1e standard action) # # --------------------------------------------------------------------------- # def _move_then_attack( engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: return ( Action(kind="move", target_id="orc"), Action(kind="attack", target_id="orc"), ) def test_move_attack_single_swing() -> None: """move+attack gives ONE attack at highest BAB; move+full_attack gives iteratives.""" gob = make_combatant("gob", hp=20, ac=15, attack_bonus=6, speed=30, initiative_mod=10) orc = make_combatant("orc", hp=20, ac=15, attack_bonus=1, speed=0) states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (3, 0))] engine = CombatEngine( ScriptedRng([20, 1, 10, 15, 3]), make_grid(), states, round_cap=1, policy=_move_then_attack, ) result = engine.run() assert result.transcript == ( "initiative: gob d20=20+10=30", "initiative: orc d20=1+0=1", "round 1 gob: move (0,0)->(1,0)->(2,0)", "round 1 gob: short sword vs orc d20=10+6=16 AC 15 -> HIT 15 damage (20->5)", "round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS", "battle over: draw after 1 rounds", ) # --------------------------------------------------------------------------- # # 5-foot step (free action, no AoO, mutually exclusive with move) # # --------------------------------------------------------------------------- # def _step5_orc2(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "gob": return ( Action(kind="5foot_step", target_id="orc2"), Action(kind="full_attack", target_id="orc2"), ) return default_policy(engine, state) def test_5ft_step_no_aoo() -> None: """A 5-foot step leaves a threatened square without provoking any AoO.""" gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10) orc1 = make_combatant("orc1", hp=20, ac=15, attack_bonus=2, speed=0) orc2 = make_combatant("orc2", hp=20, ac=15, attack_bonus=2, speed=0) states = [ make_state(gob, "players", (1, 0)), make_state(orc1, "monsters", (0, 0)), make_state(orc2, "monsters", (1, 2)), ] engine = CombatEngine( ScriptedRng([20, 1, 1, 10, 3, 15, 2]), make_grid(), states, round_cap=1, policy=_step5_orc2, ) result = engine.run() assert result.transcript == ( "initiative: gob d20=20+10=30", "initiative: orc2 d20=1+0=1", "initiative: orc1 d20=1+0=1", "round 1 gob: 5ft step (1,0)->(0,1)", "round 1 gob: short sword vs orc2 d20=10+2=12 AC 15 -> MISS", "round 1 orc2: short sword vs gob d20=3+2=5 AC 15 -> MISS", "round 1 orc1: short sword vs gob d20=15+2=17 AC 15 -> HIT 2 damage (20->18)", "battle over: draw after 1 rounds", ) def _move_then_step5(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "gob": return ( Action(kind="move", target_id="orc"), Action(kind="5foot_step", target_id="orc"), ) return default_policy(engine, state) def test_move_blocks_5ft_step() -> None: """A move action sets moved_this_turn, blocking any 5-foot step afterward.""" gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10) orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0) states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (0, 6))] engine = CombatEngine( ScriptedRng([20, 1, 10, 3]), make_grid(), states, round_cap=1, policy=_move_then_step5, ) result = engine.run() assert result.transcript == ( "initiative: gob d20=20+10=30", "initiative: orc d20=1+0=1", "round 1 gob: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)", "round 1 orc: short sword vs gob d20=10+2=12 AC 15 -> MISS", "battle over: draw after 1 rounds", ) def _step5_then_move(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "gob": return ( Action(kind="5foot_step", target_id="orc"), Action(kind="move", target_id="orc"), ) return default_policy(engine, state) def test_5ft_step_blocks_move() -> None: """A 5-foot step sets moved_this_turn, blocking any move action afterward.""" gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10) orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0) states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (0, 6))] engine = CombatEngine( ScriptedRng([20, 1]), make_grid(), states, round_cap=1, policy=_step5_then_move, ) result = engine.run() assert result.transcript == ( "initiative: gob d20=20+10=30", "initiative: orc d20=1+0=1", "round 1 gob: 5ft step (0,0)->(0,1)", "round 1 orc: wait", "battle over: draw after 1 rounds", )