"""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 pf1e_simulator.combat import ( CombatantState, CombatantStats, CombatEngine, CombatResult, ) 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 = "short sword", dr: DamageReduction | None = None, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name=weapon_name, kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])], damage_bonus=damage_bonus, crit_range=crit_range, crit_mult=crit_mult, ) 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(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(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(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(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(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(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(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(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(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(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(b, a_spec.attacks[0]) assert result.crit is True assert result.damage == 7 assert b.hp == -1 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 7, # R3 gob-1 misses orc-2 15, 1, 4, # R3 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 == 3 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: move (3,4)->(2,3)", "round 3 gob-1: short sword vs orc-2 d20=7+2=9 AC 13 -> MISS", "round 3 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)", "round 3 orc-2: gob-1 down", "battle over: monsters win in 3 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_moves_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], 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: move (1,1)->(0,2)", "round 1 orc: wait", "round 2 gob: move (0,2)->(0,3)", "round 2 orc: wait", "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