From ac18435695d9cbef2f48183880b7102abe2a914a Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): add full attack action with BAB iterative attacks - New Action kind 'full_attack': manufactured weapons (count=1) gain BAB iteratives at +6/+11/+16 (-5/-10/-15 from attack bonus, capped at 4); natural weapons (count>1) delegate to _attack (no iteratives). - Extracted _resolve_swing from _attack: one swing, log, stats, target-down check. Both _attack and _full_attack use it. AttackResult gains base_bonus field so the transcript shows the actual bonus used per iterative. - default_policy now returns full_attack instead of attack when in range. Non-breaking for BAB < 6 (single swing, identical transcript). - Refactored round_no to self._current_round instance variable, removing it from 6 method signatures and fixing PLR0913 on _resolve_swing. - 4 new tests: BAB+6 two iteratives, BAB+11 three iteratives, target drops mid-full-attack, BAB<6 single swing. README and docstring updated; test count 202 -> 206. --- README.md | 12 +-- src/pf1e_simulator/combat.py | 168 ++++++++++++++++++++++++----------- tests/test_combat.py | 84 ++++++++++++++++++ 3 files changed, 205 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index eb4db35..2c37ad0 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,10 @@ Règles modélisées : - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») résout `count` balayages indépendants dans la même action ; les balayages restants sont perdus si la cible tombe (inconsciente ou morte) en cours de - rafale. Les itératifs de BAB (2e attaque à BAB +6…) ne sont pas modélisés. + rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des + attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1) + ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque + à outrance ne sont pas modélisées. - Mêlée : allonge d'une case ; mouvement : un déplacement parcourt jusqu'à la vitesse du combattant en cases le long du plus court chemin réel (champ de coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à @@ -306,9 +309,8 @@ appliquées dans la résolution) : - Sorts, jets de sauvegarde, conditions et états. - Attaques d'opportunité, manœuvres de combat. - Effets mécaniques de hauteur/élévation. -- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, actions spéciales - (attaque à outrance, charge, pas de placement, retraite) et actions - immédiates hors-tour. +- Tailles Large+ (2×2), allonge > 5 ft, actions spéciales + (charge, pas de placement, retraite) et actions immédiates hors-tour. ## Architecture @@ -342,7 +344,7 @@ appliquées dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 202 tests +uv run pytest -q # 206 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 42e0baf..010f025 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -13,7 +13,11 @@ Phase 0 documented deviations from PF1e (conventions): (AoO, flanking, full attack, charge, withdraw) are deferred. - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks in one attack action; remaining swings are lost once the target drops - (down or dead) mid-routine. BAB iterative attacks are not modeled. + (down or dead) mid-routine. BAB iterative attacks (full-round action + ``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from + the weapon's attack bonus; iteratives are only computed for weapons with + ``count == 1`` (natural multi-attacks do not gain iteratives). Multiple + natural weapons in a single full attack are not modeled. - 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. @@ -60,6 +64,8 @@ _COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC _RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side +_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous +_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1) _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), @@ -117,6 +123,7 @@ class AttackResult: ac: int penalty: int = 0 flank_bonus: int = 0 + base_bonus: int = 0 @dataclass(frozen=True) @@ -124,14 +131,17 @@ class Action: """What a policy wants a combatant to do this turn. Kinds map to the six PF1e action types: ``attack`` is a standard action - (single attack), ``move`` is a move action, ``full_round`` consumes the - whole turn (full attack, charge, withdraw — deferred), ``swift`` and - ``free`` are minor actions, ``immediate`` is an off-turn reaction - (deferred). ``wait`` is a no-op. The engine executes a policy-returned - sequence per turn; the default policy currently emits one action. + (single attack), ``move`` is a move action, ``full_attack`` is a full-round + action (BAB iteratives), ``full_round`` is a generic full-round action + (charge, withdraw — deferred), ``swift`` and ``free`` are minor actions, + ``immediate`` is an off-turn reaction (deferred). ``wait`` is a no-op. + The engine executes a policy-returned sequence per turn. """ - kind: Literal["attack", "move", "wait", "swift", "free", "immediate", "full_round"] + kind: Literal[ + "attack", "move", "wait", "swift", "free", "immediate", "full_round", + "full_attack", + ] target_id: str | None = None @@ -172,19 +182,20 @@ class CombatEngine: 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} + self._current_round = 0 - def _take_turn(self, state: CombatantState, round_no: int) -> bool: + def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" actions = self._policy(self, state) before = len(self._transcript) for action in actions: if not state.active: break - self._execute(state, action, round_no) + self._execute(state, action) if self._winner_side() is not None: break if state.active and len(self._transcript) == before: - self._log(f"round {round_no} {state.combatant.id}: wait") + self._log(f"round {self._current_round} {state.combatant.id}: wait") return self._winner_side() is not None def run(self) -> CombatResult: @@ -192,10 +203,11 @@ class CombatEngine: order = self._roll_initiative() round_no = 1 while round_no <= self._round_cap: + self._current_round = round_no for state in order: if not state.active: continue - if self._take_turn(state, round_no): + if self._take_turn(state): break if self._winner_side() is not None: break @@ -265,14 +277,20 @@ class CombatEngine: return 0 def resolve_attack( - self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + self, + attacker: CombatantState, + target: CombatantState, + weapon: AttackSpec, + *, + bonus_override: int | None = None, ) -> AttackResult: """Roll one attack (with crit confirm and damage) and apply it.""" roll = self._rng.d20() dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT penalty = self.range_penalty(weapon, dist_ft) flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 - total = roll + weapon.attack_bonus + penalty + flank + base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus + total = roll + base_bonus + penalty + flank ac = target.combatant.ac.total occupied = frozenset( s.pos for s in self._states if s.active and s is not attacker and s is not target @@ -291,7 +309,7 @@ class CombatEngine: if hit: if roll != _NATURAL_ONE and roll >= weapon.crit_range: confirm = self._rng.d20() - confirm_total = confirm + weapon.attack_bonus + penalty + flank + confirm_total = confirm + base_bonus + penalty + flank crit = confirm != _NATURAL_ONE and confirm_total >= ac damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus if crit: @@ -300,7 +318,7 @@ class CombatEngine: target.hp -= damage return AttackResult( hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, - penalty=penalty, flank_bonus=flank, + penalty=penalty, flank_bonus=flank, base_bonus=base_bonus, ) def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int: @@ -340,63 +358,105 @@ class CombatEngine: return active_sides.pop() return None - def _execute(self, state: CombatantState, action: Action, round_no: int) -> None: + def _execute(self, state: CombatantState, action: Action) -> None: target = next((s for s in self._states if s.combatant.id == action.target_id), None) - if action.kind == "attack": + if action.kind in ("attack", "full_attack"): if target is None or not target.active: return weapon = self.weapon_for(state, target) if weapon is None: return - self._attack(state, target, weapon, round_no) + if action.kind == "full_attack": + self._full_attack(state, target, weapon) + else: + self._attack(state, target, weapon) elif action.kind == "move": if target is None: return - self._move(state, target, round_no) + self._move(state, target) elif action.kind == "wait": - self._log(f"round {round_no} {state.combatant.id}: wait") + self._log(f"round {self._current_round} {state.combatant.id}: wait") - def _attack( - self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int - ) -> None: + def _resolve_swing( + self, + attacker: CombatantState, + target: CombatantState, + weapon: AttackSpec, + *, + label: str, + bonus_override: int | None = None, + ) -> bool: + """Resolve one swing, log it, update stats. Return True if target dropped.""" live = self._stats[attacker.combatant.id] target_live = self._stats[target.combatant.id] + hp_before = target.hp + result = self.resolve_attack(attacker, target, weapon, bonus_override=bonus_override) + live.hits += int(result.hit) + live.crits += int(result.crit) + live.damage_dealt += result.damage + target_live.damage_taken += result.damage + outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" + flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else "" + bonus_str = ( + f"{result.base_bonus}{result.penalty:+d}{flank_str}" + if result.penalty + else f"{result.base_bonus}{flank_str}" + ) + line = ( + f"round {self._current_round} {attacker.combatant.id}: {label} " + f"vs {target.combatant.id} " + f"d20={result.roll}+{bonus_str}={result.total} " + f"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 {self._current_round} {attacker.combatant.id}: " + f"{target.combatant.id} dead" + ) + return True + if not target.active: + self._log( + f"round {self._current_round} {attacker.combatant.id}: " + f"{target.combatant.id} down" + ) + return True + return False + + def _attack( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + ) -> None: for swing in range(1, weapon.count + 1): - hp_before = target.hp - result = self.resolve_attack(attacker, target, weapon) - live.hits += int(result.hit) - live.crits += int(result.crit) - live.damage_dealt += result.damage - target_live.damage_taken += result.damage - outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}" - flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else "" - bonus = ( - f"{weapon.attack_bonus}{result.penalty:+d}{flank_str}" - if result.penalty - else f"{weapon.attack_bonus}{flank_str}" - ) - line = ( - f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} " - f"d20={result.roll}+{bonus}={result.total} " - f"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") - return - if not target.active: - self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down") + if self._resolve_swing(attacker, target, weapon, label=label): return - def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None: + def _full_attack( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + ) -> None: + if weapon.count > 1: + self._attack(attacker, target, weapon) + return + n = min( + _MAX_ITERATIVES, + 1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY), + ) + for i in range(n): + label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}" + bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i + if self._resolve_swing( + attacker, target, weapon, label=label, bonus_override=bonus + ): + return + + def _move(self, state: CombatantState, target: CombatantState) -> None: path = self._move_path(state, target) if not path: return coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path)) - self._log(f"round {round_no} {state.combatant.id}: move {coords}") + self._log(f"round {self._current_round} {state.combatant.id}: move {coords}") state.pos = path[-1] def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: @@ -443,13 +503,13 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: - """Attack the nearest enemy in range; otherwise approach at full speed then try to attack.""" + """Full-attack the nearest enemy in range; otherwise approach then full-attack.""" 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="full_attack", target_id=target.combatant.id),) return ( Action(kind="move", target_id=target.combatant.id), - Action(kind="attack", target_id=target.combatant.id), + Action(kind="full_attack", target_id=target.combatant.id), ) diff --git a/tests/test_combat.py b/tests/test_combat.py index 8cd2d14..0534f8d 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -865,3 +865,87 @@ def test_flanking_requires_ally_on_same_side() -> None: "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", ) + + +# --------------------------------------------------------------------------- # +# Full attack (CRB: BAB iteratives at BAB, BAB-5, BAB-10, BAB-15) # +# --------------------------------------------------------------------------- # + + +def test_full_attack_bab6_two_iteratives() -> None: + """BAB +6 gives two attacks: at +6 and +1.""" + war = make_combatant("war", attack_bonus=6, hp=10, speed=0) + tgt = make_combatant("tgt", hp=20, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(war, "players", (3, 4)), + make_state(tgt, "monsters", (4, 4)), + ] + engine = make_engine([10, 8, 12, 3, 5, 2], states, round_cap=1) + result = engine.run() + assert result.transcript == ( + "initiative: war d20=10+0=10", + "initiative: tgt d20=8+0=8", + "round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 3 damage (20->17)", + "round 1 war: short sword #2 vs tgt d20=5+1=6 AC 15 -> MISS", + "round 1 tgt: short sword vs war d20=2+0=2 AC 13 -> MISS", + "battle over: draw after 1 rounds", + ) + + +def test_full_attack_bab11_three_iteratives() -> None: + """BAB +11 gives three attacks: at +11, +6, +1.""" + war = make_combatant("war", attack_bonus=11, hp=10, speed=0) + tgt = make_combatant("tgt", hp=30, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(war, "players", (3, 4)), + make_state(tgt, "monsters", (4, 4)), + ] + engine = make_engine([10, 8, 12, 3, 10, 2, 5, 2], states, round_cap=1) + result = engine.run() + assert result.transcript == ( + "initiative: war d20=10+0=10", + "initiative: tgt d20=8+0=8", + "round 1 war: short sword #1 vs tgt d20=12+11=23 AC 15 -> HIT 3 damage (30->27)", + "round 1 war: short sword #2 vs tgt d20=10+6=16 AC 15 -> HIT 2 damage (27->25)", + "round 1 war: short sword #3 vs tgt d20=5+1=6 AC 15 -> MISS", + "round 1 tgt: short sword vs war d20=2+0=2 AC 13 -> MISS", + "battle over: draw after 1 rounds", + ) + + +def test_full_attack_stops_on_target_down() -> None: + """Remaining iteratives are lost when the target drops mid-full-attack.""" + war = make_combatant("war", attack_bonus=6, hp=10, speed=0) + tgt = make_combatant("tgt", hp=5, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(war, "players", (3, 4)), + make_state(tgt, "monsters", (4, 4)), + ] + engine = make_engine([10, 8, 12, 5], states, round_cap=1) + result = engine.run() + assert result.transcript == ( + "initiative: war d20=10+0=10", + "initiative: tgt d20=8+0=8", + "round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 5 damage (5->0)", + "round 1 war: tgt down", + "battle over: players win in 1 rounds", + ) + + +def test_full_attack_low_bab_single_swing() -> None: + """BAB < 6 gives one attack — same as a regular attack, no '#1' label.""" + war = make_combatant("war", attack_bonus=3, speed=0) + tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(war, "players", (3, 4)), + make_state(tgt, "monsters", (4, 4)), + ] + engine = make_engine([10, 8, 12, 2, 5], states, round_cap=1) + result = engine.run() + assert result.transcript == ( + "initiative: war d20=10+0=10", + "initiative: tgt d20=8+0=8", + "round 1 war: short sword vs tgt d20=12+3=15 AC 15 -> HIT 2 damage (10->8)", + "round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS", + "battle over: draw after 1 rounds", + )