"""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.abilities import AbilitySpec 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, 4], [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 4 damage (3->-1)", "round 3 a: b down", "battle over: players win in 3 rounds", ) assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=13, damage_taken=0) assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=13) 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 # B11 conformance: exactly 0 hp is DISABLED — active (single action round). assert st.active is True assert st.dead is False st.hp = -1 assert st.active is False # dying assert st.dead is False st.hp = -11 assert st.active is False assert st.dead is False # B12 conformance (CRB): death when negative hp reaches CON (hp <= -12). st.hp = -12 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 14, # R2 orc-1 dying check: 14+1-1=14 >= 10 -> stabilized 5, # R2 gob-2 dying check: 5+1-3=3 < 10 -> fails, bleeds to -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-1: stabilized (d20(14) + CON(1) - 1 = 14 vs DC 10)", ( "round 2 gob-2: dying — Constitution check d20(5) + CON(1) - 3 = 3 vs DC 10 " "failed, loses 1 hp (hp -3 -> -4)" ), "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 3, # R2 orc-1 dying check: 3+1-4=0 < 10 -> fails, bleeds to -5 9, # R2 orc-2 misses 11, 3, # R3 gob hits orc-2 for 3 4, # R3 orc-1 dying check: 4+1-5=0 < 10 -> fails, bleeds to -6 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-1: dying — Constitution check d20(3) + CON(1) - 4 = 0 vs DC 10 " "failed, loses 1 hp (hp -4 -> -5)" ), "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-1: dying — Constitution check d20(4) + CON(1) - 5 = 0 vs DC 10 " "failed, loses 1 hp (hp -5 -> -6)" ), "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, 13, 6, 15, 4]) engine = CombatEngine(rng, grid, states, round_cap=4) result = engine.run() assert result.winner == "players" # SRD Measuring Distance corner rule: the (0,1)->(1,2) diagonal is flank- # blocked by the wall at (0,2), so the auto step goes to (1,1) instead — # and the wall keeps granting cover (+4 AC) from that square. assert result.transcript == ( "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", "round 1 archer: 5ft step (0,1)->(1,1)", "round 1 archer: short bow vs target d20=5+4=9 AC 17 -> MISS", "round 1 target: wait", "round 2 archer: short bow vs target d20=13+4=17 AC 17 -> HIT 6 damage (6->0)", "round 2 target: wait", "round 3 archer: short bow vs target d20=15+4=19 AC 17 -> HIT 4 damage (0->-4)", "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-2(melee)=12 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 (below 0 hp, dying) on the opposite side does not flank. B11 conformance: an ally at exactly 0 hp is disabled but active — it would flank; only hp < 0 removes it from the fight.""" 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 = -1 states = [ make_state(atk, "players", (3, 4)), aly_state, make_state(tgt, "monsters", (4, 4)), ] engine = make_engine([10, 9, 8, 12, 5, 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 aly: dying — Constitution check d20(5) + CON(1) - 1 = 5 " "vs DC 10 failed, loses 1 hp (hp -1 -> -2)" ), "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. The scripted blow takes tgt below 0 (hp 4 - 5 = -1): a target at exactly 0 hp is disabled but still ACTIVE (B11 conformance) and would not drop. """ war = make_combatant("war", attack_bonus=6, hp=10, speed=0) tgt = make_combatant("tgt", hp=4, 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 (4->-1)", "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, 4]), 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 4 damage (3->-1)", "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", ) def _make_guard_cr(dex: int, *, combat_reflexes: bool) -> Combatant: """Guard with given DEX and optional Combat Reflexes feat.""" return Combatant( id="guard", name="guard", level=3, size="Medium", abilities=AbilityScores( str_score=14, dex_score=dex, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=20, ac=ACProfile(total=15, touch=15, flat_footed=15), bab=3, initiative_mod=(dex - 10) // 2, speed_land_ft=0, saves=Saves(fort=4, ref=3, will=1), attacks=[ AttackSpec( id="guard-w", name="short sword", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])], crit_range=20, crit_mult=2, ) ], features=[AbilitySpec(name="Combat Reflexes", category="feat")] if combat_reflexes else [], ) def test_combat_reflexes_multiple_aoos() -> None: """Guard with Combat Reflexes + DEX 16 takes multiple AoOs when enemies move. A1 conformance: each moving opponent counts as its own opportunity, so three runners crossing the guard's threatened zone each provoke once (a single runner leaving two threatened squares would NOT add a second opportunity for the guard). """ runner1 = make_combatant("runner1", hp=20, ac=12, speed=30) runner2 = make_combatant("runner2", hp=20, ac=12, speed=30) runner3 = make_combatant("runner3", hp=20, ac=12, speed=30) guard = _make_guard_cr(dex=16, combat_reflexes=True) dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) states = [ make_state(runner1, "players", (2, 3)), make_state(runner2, "players", (4, 3)), make_state(runner3, "players", (2, 4)), make_state(guard, "monsters", (3, 5)), make_state(dest, "monsters", (6, 5)), ] engine = CombatEngine( ScriptedRng([15, 14, 13, 5, 4, 15, 3, 14, 2, 13, 4, 12, 1]), make_grid(), states, round_cap=1, policy=_move_to_dest, ) result = engine.run() aoo_lines = [line for line in result.transcript if "AoO" in line] assert len(aoo_lines) >= 3 def test_no_combat_reflexes_single_aoo() -> None: """Without Combat Reflexes, the guard still takes exactly1 AoO per round.""" runner1 = make_combatant("runner1", hp=20, ac=12, speed=30) runner2 = make_combatant("runner2", hp=20, ac=12, speed=30) runner3 = make_combatant("runner3", hp=20, ac=12, speed=30) guard = _make_guard_cr(dex=16, combat_reflexes=False) dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) states = [ make_state(runner1, "players", (2, 3)), make_state(runner2, "players", (4, 3)), make_state(runner3, "players", (3, 2)), make_state(guard, "monsters", (3, 5)), make_state(dest, "monsters", (6, 5)), ] engine = CombatEngine( ScriptedRng([15, 14, 13, 5, 4, 15, 3]), make_grid(), states, round_cap=1, policy=_move_to_dest, ) result = engine.run() aoo_lines = [line for line in result.transcript if "AoO" in line] assert len(aoo_lines) == 1 # ── A1: one movement opportunity per opponent per round (CRB AoO) ─────────── # def test_movement_two_squares_same_guard_one_opportunity() -> None: """Leaving two squares threatened by the same opponent counts as one opportunity. PF1e (CRB "Attacks of Opportunity"): "Moving out of more than one square threatened by the same opponent in the same round doesn't count as more than one opportunity for that opponent." The guard holds Combat Reflexes with DEX 16 (allowance 4), so the per-round budget alone would NOT stop the second strike — only the dedupe does. """ runner = make_combatant("runner", hp=20, ac=12, speed=30) guard = _make_guard_cr(dex=16, combat_reflexes=True) states = [make_state(runner, "players", (4, 1)), make_state(guard, "monsters", (3, 1))] engine = CombatEngine(ScriptedRng([15, 3, 15, 3]), make_grid(), states) runner_s, _guard_s = engine._states # Step 1: leaving (4,1) — threatened by the guard — provokes. assert engine._check_move_aoo(runner_s) is False runner_s.pos = (4, 2) # Step 2: leaving (4,2) — still threatened by the SAME guard — is the # same opponent, so no second movement opportunity. assert engine._check_move_aoo(runner_s) is False assert engine._aoo_used.get("guard", 0) == 1 aoo_lines = [line for line in engine._transcript if "AoO" in line] assert len(aoo_lines) == 1 def test_movement_dedupe_exempts_stand_up_trigger() -> None: """A movement opportunity already taken does not absorb the stand-up trigger. The dedupe covers ONLY the "leaving a threatened square" trigger; standing up is a separate trigger (``movement=False``) and still provokes. """ runner = make_combatant("runner", hp=20, ac=12, speed=30) guard = _make_guard_cr(dex=16, combat_reflexes=True) states = [make_state(runner, "players", (4, 1)), make_state(guard, "monsters", (3, 1))] engine = CombatEngine(ScriptedRng([15, 3, 15, 3]), make_grid(), states) runner_s, _guard_s = engine._states assert engine._check_move_aoo(runner_s) is False # movement: provokes assert engine._check_move_aoo(runner_s) is False # movement again: deduped assert engine._check_move_aoo(runner_s, movement=False) is False # stand-up: provokes assert engine._aoo_used.get("guard", 0) == 2 aoo_lines = [line for line in engine._transcript if "AoO" in line] assert len(aoo_lines) == 2 def test_two_guards_each_one_movement_opportunity() -> None: """Two different opponents each keep their own movement opportunity.""" 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) states = [ make_state(runner, "players", (4, 1)), make_state(g1, "monsters", (3, 1)), make_state(g2, "monsters", (3, 2)), ] engine = CombatEngine(ScriptedRng([15, 3, 15, 3, 15, 3, 15, 3]), make_grid(), states) runner_s, _g1, _g2 = engine._states assert engine._check_move_aoo(runner_s) is False # both guards strike runner_s.pos = (4, 2) assert engine._check_move_aoo(runner_s) is False # same squares, deduped assert engine._aoo_used.get("g1", 0) == 1 assert engine._aoo_used.get("g2", 0) == 1 aoo_lines = [line for line in engine._transcript if "AoO" in line] assert len(aoo_lines) == 2