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:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user