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.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent b20f845606
commit ac18435695
3 changed files with 205 additions and 59 deletions
+7 -5
View File
@@ -278,7 +278,10 @@ Règles modélisées :
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages 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 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 - 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 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 à 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. - Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, manœuvres de combat. - Attaques d'opportunité, manœuvres de combat.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, actions spéciales - Tailles Large+ (2×2), allonge > 5 ft, actions spéciales
(attaque à outrance, charge, pas de placement, retraite) et actions (charge, pas de placement, retraite) et actions immédiates hors-tour.
immédiates hors-tour.
## Architecture ## Architecture
@@ -342,7 +344,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 202 tests uv run pytest -q # 206 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
+99 -39
View File
@@ -13,7 +13,11 @@ Phase 0 documented deviations from PF1e (conventions):
(AoO, flanking, full attack, charge, withdraw) are deferred. (AoO, flanking, full attack, charge, withdraw) are deferred.
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops 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. - 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. - A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; any bypassing type defeats DR. - 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 _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 _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side _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, ...] = ( _STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
@@ -117,6 +123,7 @@ class AttackResult:
ac: int ac: int
penalty: int = 0 penalty: int = 0
flank_bonus: int = 0 flank_bonus: int = 0
base_bonus: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -124,14 +131,17 @@ class Action:
"""What a policy wants a combatant to do this turn. """What a policy wants a combatant to do this turn.
Kinds map to the six PF1e action types: ``attack`` is a standard action Kinds map to the six PF1e action types: ``attack`` is a standard action
(single attack), ``move`` is a move action, ``full_round`` consumes the (single attack), ``move`` is a move action, ``full_attack`` is a full-round
whole turn (full attack, charge, withdraw — deferred), ``swift`` and action (BAB iteratives), ``full_round`` is a generic full-round action
``free`` are minor actions, ``immediate`` is an off-turn reaction (charge, withdraw — deferred), ``swift`` and ``free`` are minor actions,
(deferred). ``wait`` is a no-op. The engine executes a policy-returned ``immediate`` is an off-turn reaction (deferred). ``wait`` is a no-op.
sequence per turn; the default policy currently emits one action. 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 target_id: str | None = None
@@ -172,19 +182,20 @@ class CombatEngine:
self._policy = policy if policy is not None else default_policy self._policy = policy if policy is not None else default_policy
self._transcript: list[str] = [] self._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} 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.""" """Execute one combatant's full turn; return True if the battle is over."""
actions = self._policy(self, state) actions = self._policy(self, state)
before = len(self._transcript) before = len(self._transcript)
for action in actions: for action in actions:
if not state.active: if not state.active:
break break
self._execute(state, action, round_no) self._execute(state, action)
if self._winner_side() is not None: if self._winner_side() is not None:
break break
if state.active and len(self._transcript) == before: 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 return self._winner_side() is not None
def run(self) -> CombatResult: def run(self) -> CombatResult:
@@ -192,10 +203,11 @@ class CombatEngine:
order = self._roll_initiative() order = self._roll_initiative()
round_no = 1 round_no = 1
while round_no <= self._round_cap: while round_no <= self._round_cap:
self._current_round = round_no
for state in order: for state in order:
if not state.active: if not state.active:
continue continue
if self._take_turn(state, round_no): if self._take_turn(state):
break break
if self._winner_side() is not None: if self._winner_side() is not None:
break break
@@ -265,14 +277,20 @@ class CombatEngine:
return 0 return 0
def resolve_attack( def resolve_attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
*,
bonus_override: int | None = None,
) -> AttackResult: ) -> AttackResult:
"""Roll one attack (with crit confirm and damage) and apply it.""" """Roll one attack (with crit confirm and damage) and apply it."""
roll = self._rng.d20() roll = self._rng.d20()
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft) penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 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 ac = target.combatant.ac.total
occupied = frozenset( occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target 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 hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range: if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20() 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 crit = confirm != _NATURAL_ONE and confirm_total >= ac
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
if crit: if crit:
@@ -300,7 +318,7 @@ class CombatEngine:
target.hp -= damage target.hp -= damage
return AttackResult( return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, 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: def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
@@ -340,63 +358,105 @@ class CombatEngine:
return active_sides.pop() return active_sides.pop()
return None 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) 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: if target is None or not target.active:
return return
weapon = self.weapon_for(state, target) weapon = self.weapon_for(state, target)
if weapon is None: if weapon is None:
return 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": elif action.kind == "move":
if target is None: if target is None:
return return
self._move(state, target, round_no) self._move(state, target)
elif action.kind == "wait": 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( def _resolve_swing(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int self,
) -> None: 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] live = self._stats[attacker.combatant.id]
target_live = self._stats[target.combatant.id] target_live = self._stats[target.combatant.id]
for swing in range(1, weapon.count + 1):
hp_before = target.hp hp_before = target.hp
result = self.resolve_attack(attacker, target, weapon) result = self.resolve_attack(attacker, target, weapon, bonus_override=bonus_override)
live.hits += int(result.hit) live.hits += int(result.hit)
live.crits += int(result.crit) live.crits += int(result.crit)
live.damage_dealt += result.damage live.damage_dealt += result.damage
target_live.damage_taken += result.damage target_live.damage_taken += result.damage
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" 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 "" flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else ""
bonus = ( bonus_str = (
f"{weapon.attack_bonus}{result.penalty:+d}{flank_str}" f"{result.base_bonus}{result.penalty:+d}{flank_str}"
if result.penalty if result.penalty
else f"{weapon.attack_bonus}{flank_str}" else f"{result.base_bonus}{flank_str}"
) )
line = ( line = (
f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} " f"round {self._current_round} {attacker.combatant.id}: {label} "
f"d20={result.roll}+{bonus}={result.total} " f"vs {target.combatant.id} "
f"d20={result.roll}+{bonus_str}={result.total} "
f"AC {result.ac} -> {outcome}" f"AC {result.ac} -> {outcome}"
) )
if result.hit: if result.hit:
line += f" {result.damage} damage ({hp_before}->{target.hp})" line += f" {result.damage} damage ({hp_before}->{target.hp})"
self._log(line) self._log(line)
if target.dead: if target.dead:
self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} dead") self._log(
return f"round {self._current_round} {attacker.combatant.id}: "
f"{target.combatant.id} dead"
)
return True
if not target.active: if not target.active:
self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down") 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):
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
if self._resolve_swing(attacker, target, weapon, label=label):
return 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) path = self._move_path(state, target)
if not path: if not path:
return return
coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path)) 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] state.pos = path[-1]
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: 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, ...]: 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) target = engine.nearest_enemy(state)
if target is None: if target is None:
return (Action(kind="wait"),) return (Action(kind="wait"),)
if engine.weapon_for(state, target) is not None: 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 ( return (
Action(kind="move", target_id=target.combatant.id), 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),
) )
+84
View File
@@ -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", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds", "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",
)