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