From 577aec33c4aef3f21448a42392714db2c9df7bb6 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): deterministic engine with attacks, crits, DR, hp states, initiative, rounds, basic policies --- src/pf1e_simulator/combat.py | 333 +++++++++++++++++++++++++++++ tests/test_combat.py | 396 +++++++++++++++++++++++++++++++++++ 2 files changed, 729 insertions(+) create mode 100644 src/pf1e_simulator/combat.py create mode 100644 tests/test_combat.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py new file mode 100644 index 0000000..2127b5a --- /dev/null +++ b/src/pf1e_simulator/combat.py @@ -0,0 +1,333 @@ +"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds. + +Phase 0 documented deviations from PF1e (conventions): +- One action per turn: move OR attack (no move+attack, no AoO, no flanking). +- Natural 1 always misses; natural 20 always hits and threatens a crit. +- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult. +- DR applies once, after crit multiplication; any bypassing type defeats DR. +- Death when hp < min(-10, -CON); hp <= 0 cannot act. +- Initiative ties: higher initiative_mod first, then list order (no re-roll). +- Movement: greedy single step toward the nearest enemy minimizing + accumulated cost + remaining grid distance; ties keep delta order. +- Ranged attacks ignore cover and range penalties in Phase 0. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + + from pf1e_simulator.grid import Grid + from pf1e_simulator.map import Pos + from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction + from pf1e_simulator.rng import Rng + +_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares +_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower +_NATURAL_ONE = 1 # PF1e: natural 1 always misses +_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens + +_STEP_DELTAS: tuple[Pos, ...] = ( + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), +) + + +@dataclass(frozen=True) +class CombatantStats: + """Aggregated outcomes for one combatant over a battle.""" + + hits: int = 0 + crits: int = 0 + damage_dealt: int = 0 + damage_taken: int = 0 + + +@dataclass +class CombatantState: + """Mutable runtime state of one combatant on the grid.""" + + combatant: Combatant + side: str + pos: Pos + hp: int + + @property + def active(self) -> bool: + return self.hp > 0 + + @property + def dead(self) -> bool: + return self.hp < self.death_threshold + + @property + def death_threshold(self) -> int: + return min(_DEATH_FLOOR, -self.combatant.abilities.con_score) + + +@dataclass(frozen=True) +class AttackResult: + """Outcome of one attack roll, including the raw rolls for the log.""" + + hit: bool + crit: bool + damage: int + roll: int + total: int + ac: int + + +@dataclass(frozen=True) +class Action: + """What a policy wants a combatant to do this turn.""" + + kind: Literal["attack", "move", "wait"] + target_id: str | None = None + + +@dataclass(frozen=True) +class CombatResult: + """Outcome of a full battle: winner, rounds, log, and per-combatant stats.""" + + winner: str | None + rounds: int + transcript: tuple[str, ...] + stats: dict[str, CombatantStats] + + +@dataclass +class _LiveStats: + hits: int = 0 + crits: int = 0 + damage_dealt: int = 0 + damage_taken: int = 0 + + +class CombatEngine: + """Runs one battle to completion on a grid with a single RNG stream.""" + + def __init__( + self, + rng: Rng, + grid: Grid, + states: list[CombatantState], + *, + round_cap: int = 100, + policy: Policy | None = None, + ) -> None: + self._rng = rng + self._grid = grid + self._states = states + self._round_cap = round_cap + self._policy = policy if policy is not None else default_policy + self._transcript: list[str] = [] + self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} + + def run(self) -> CombatResult: + """Run the battle and return the final result and transcript.""" + order = self._roll_initiative() + round_no = 1 + while round_no <= self._round_cap: + for state in order: + if not state.active: + continue + action = self._policy(self, state) + self._execute(state, action, round_no) + if self._winner_side() is not None: + break + if self._winner_side() is not None: + break + round_no += 1 + winner = self._winner_side() + if winner is not None: + self._log(f"battle over: {winner} win in {round_no} rounds") + else: + self._log(f"battle over: draw after {self._round_cap} rounds") + stats = {cid: CombatantStats(**asdict(live)) for cid, live in self._stats.items()} + return CombatResult( + winner=winner, + rounds=round_no if winner is not None else self._round_cap, + transcript=tuple(self._transcript), + stats=stats, + ) + + def nearest_enemy(self, state: CombatantState) -> CombatantState | None: + """Closest active enemy by grid distance; ties keep list order.""" + enemies = [s for s in self._states if s.side != state.side and s.active] + if not enemies: + return None + return min( + enemies, + key=lambda s: (self._grid.distance(state.pos, s.pos), self._states.index(s)), + ) + + def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None: + """First weapon of the attacker usable against the target at this range.""" + dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT + for weapon in attacker.combatant.attacks: + if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft: + return weapon + if ( + weapon.kind == "ranged" + and weapon.range_increment_ft is not None + and dist_ft <= weapon.range_increment_ft + ): + return weapon + return None + + def resolve_attack( + self, target: CombatantState, weapon: AttackSpec + ) -> AttackResult: + """Roll one attack (with crit confirm and damage) and apply it.""" + roll = self._rng.d20() + total = roll + weapon.attack_bonus + ac = target.combatant.ac.total + hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) + crit = False + damage = 0 + if hit: + if roll != _NATURAL_ONE and roll >= weapon.crit_range: + confirm = self._rng.d20() + crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus >= ac + damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus + if crit: + damage *= weapon.crit_mult + damage = self._apply_dr(damage, weapon, target) + target.hp -= damage + return AttackResult(hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac) + + def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int: + dr: DamageReduction | None = target.combatant.dr + if dr is None: + return damage + types = {t for component in weapon.damage for t in component.types} + if types & dr.bypass: + return damage + return max(0, damage - dr.amount) + + def _roll_initiative(self) -> list[CombatantState]: + rolls: dict[str, tuple[int, int]] = {} + for state in self._states: + roll = self._rng.d20() + rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod) + order = sorted( + self._states, + key=lambda s: ( + rolls[s.combatant.id][1], + s.combatant.initiative_mod, + self._states.index(s), + ), + reverse=True, + ) + for state in order: + roll, total = rolls[state.combatant.id] + self._log( + f"initiative: {state.combatant.id} " + f"d20={roll}+{state.combatant.initiative_mod}={total}" + ) + return order + + def _winner_side(self) -> str | None: + active_sides = {s.side for s in self._states if s.active} + if len(active_sides) == 1: + return active_sides.pop() + return None + + def _execute(self, state: CombatantState, action: Action, round_no: int) -> None: + target = next((s for s in self._states if s.combatant.id == action.target_id), None) + if action.kind == "attack": + if target is None or not target.active: + self._log(f"round {round_no} {state.combatant.id}: wait") + return + weapon = self.weapon_for(state, target) + if weapon is None: + self._log(f"round {round_no} {state.combatant.id}: wait") + return + self._attack(state, target, weapon, round_no) + elif action.kind == "move": + if target is None: + self._log(f"round {round_no} {state.combatant.id}: wait") + return + self._move(state, target, round_no) + else: + self._log(f"round {round_no} {state.combatant.id}: wait") + + def _attack( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int + ) -> None: + hp_before = target.hp + result = self.resolve_attack(target, weapon) + live = self._stats[attacker.combatant.id] + live.hits += int(result.hit) + live.crits += int(result.crit) + live.damage_dealt += result.damage + self._stats[target.combatant.id].damage_taken += result.damage + outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" + line = ( + f"round {round_no} {attacker.combatant.id}: {weapon.name} vs {target.combatant.id} " + f"d20={result.roll}+{weapon.attack_bonus}={result.total} AC {result.ac} -> {outcome}" + ) + if result.hit: + line += f" {result.damage} damage ({hp_before}->{target.hp})" + self._log(line) + if target.dead: + self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} dead") + elif not target.active: + self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down") + + def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None: + step = self._step_toward(state, target) + if step is None: + self._log(f"round {round_no} {state.combatant.id}: wait") + return + self._log( + f"round {round_no} {state.combatant.id}: move " + f"({state.pos[0]},{state.pos[1]})->({step[0]},{step[1]})" + ) + state.pos = step + + def _step_toward(self, state: CombatantState, target: CombatantState) -> Pos | None: + speed_cells = state.combatant.speed_land_ft // _SQUARE_FT + if speed_cells <= 0: + return None + blocked = frozenset(s.pos for s in self._states if s is not state and s.active) + costs = self._grid.reachable(state.pos, speed_cells, blocked) + best: tuple[int, Pos] | None = None + row, col = state.pos + for d_row, d_col in _STEP_DELTAS: + nxt = (row + d_row, col + d_col) + cost = costs.get(nxt) + if cost is None: + continue + score = cost + self._grid.distance(nxt, target.pos) + if best is None or score < best[0]: + best = (score, nxt) + if best is None: + return None + return best[1] + + def _log(self, line: str) -> None: + self._transcript.append(line) + + +Policy = Callable[[CombatEngine, CombatantState], Action] + + +def default_policy(engine: CombatEngine, state: CombatantState) -> Action: + """Attack the nearest active enemy when a weapon is in range, else approach.""" + target = engine.nearest_enemy(state) + if target is None: + return Action(kind="wait") + if engine.weapon_for(state, target) is not None: + return Action(kind="attack", target_id=target.combatant.id) + return Action(kind="move", target_id=target.combatant.id) diff --git a/tests/test_combat.py b/tests/test_combat.py new file mode 100644 index 0000000..1bdb44f --- /dev/null +++ b/tests/test_combat.py @@ -0,0 +1,396 @@ +"""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", + )