feat(combat): apply flanking +2 bonus when ally threatens opposite side
- _flanking_bonus checks for an active same-side ally with a melee weapon at the position diametrically opposite the attacker through the target. - +2 applies to melee attack rolls and crit-confirm rolls; ranged attacks ignore flanking; allies without melee weapons don't threaten. - AttackResult gains flank_bonus field; transcript shows '+2(flank)' when active. - 6 new tests: opposite ally grants +2, no ally = no bonus, ranged ignores flanking, diagonal flanking, downed ally doesn't flank, enemy on opposite side doesn't flank. - README and module docstring updated; test count 196 -> 202.
This commit is contained in:
@@ -725,3 +725,143 @@ def test_ranged_soft_cover_shown_in_transcript() -> None:
|
||||
"round 1 target: wait",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Flanking (CRB: +2 melee attack when ally threatens opposite side) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_flanking_grants_plus_two_melee() -> None:
|
||||
"""An active ally on the opposite side grants +2 to melee attack rolls."""
|
||||
atk = make_combatant("atk", attack_bonus=2, speed=0)
|
||||
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
states = [
|
||||
make_state(atk, "players", (3, 4)),
|
||||
make_state(aly, "players", (5, 4)),
|
||||
make_state(tgt, "monsters", (4, 4)),
|
||||
]
|
||||
engine = make_engine([10, 9, 8, 12, 3, 10, 5], states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: atk d20=10+0=10",
|
||||
"initiative: aly d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 atk: short sword vs tgt d20=12+2+2(flank)=16 AC 15 -> HIT 3 damage (10->7)",
|
||||
"round 1 aly: short sword vs tgt d20=10+0+2(flank)=12 AC 15 -> MISS",
|
||||
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_no_flanking_without_opposite_ally() -> None:
|
||||
"""Without an ally on the opposite side, the same roll misses (14 < 15)."""
|
||||
atk = make_combatant("atk", attack_bonus=2, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
states = [
|
||||
make_state(atk, "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: atk d20=10+0=10",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS",
|
||||
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_ranged_attack_ignores_flanking() -> None:
|
||||
"""Ranged attacks get no flanking bonus, and ranged allies don't threaten."""
|
||||
arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0)
|
||||
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
states = [
|
||||
make_state(arc, "players", (3, 4)),
|
||||
make_state(aly, "players", (5, 4)),
|
||||
make_state(tgt, "monsters", (4, 4)),
|
||||
]
|
||||
engine = make_engine([10, 9, 8, 12, 10, 5], states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: arc d20=10+0=10",
|
||||
"initiative: aly d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS",
|
||||
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
|
||||
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_diagonal_flanking() -> None:
|
||||
"""Flanking works on the diagonal (opposite corner)."""
|
||||
atk = make_combatant("atk", attack_bonus=2, speed=0)
|
||||
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
states = [
|
||||
make_state(atk, "players", (3, 3)),
|
||||
make_state(aly, "players", (5, 5)),
|
||||
make_state(tgt, "monsters", (4, 4)),
|
||||
]
|
||||
engine = make_engine([10, 9, 8, 12, 3, 10, 5], states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: atk d20=10+0=10",
|
||||
"initiative: aly d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 atk: short sword vs tgt d20=12+2+2(flank)=16 AC 15 -> HIT 3 damage (10->7)",
|
||||
"round 1 aly: short sword vs tgt d20=10+0+2(flank)=12 AC 15 -> MISS",
|
||||
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_flanking_requires_active_ally() -> None:
|
||||
"""A downed ally on the opposite side does not grant flanking."""
|
||||
atk = make_combatant("atk", attack_bonus=2, speed=0)
|
||||
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
aly_state = make_state(aly, "players", (5, 4))
|
||||
aly_state.hp = 0
|
||||
states = [
|
||||
make_state(atk, "players", (3, 4)),
|
||||
aly_state,
|
||||
make_state(tgt, "monsters", (4, 4)),
|
||||
]
|
||||
engine = make_engine([10, 9, 8, 12, 5], states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: atk d20=10+0=10",
|
||||
"initiative: aly d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS",
|
||||
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_flanking_requires_ally_on_same_side() -> None:
|
||||
"""An enemy on the opposite side does not grant flanking."""
|
||||
atk = make_combatant("atk", attack_bonus=2, speed=0)
|
||||
en2 = make_combatant("en2", hp=10, ac=20, attack_bonus=0, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
states = [
|
||||
make_state(atk, "players", (3, 4)),
|
||||
make_state(en2, "monsters", (5, 4)),
|
||||
make_state(tgt, "monsters", (4, 4)),
|
||||
]
|
||||
engine = make_engine([10, 9, 8, 12, 5], states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: atk d20=10+0=10",
|
||||
"initiative: en2 d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS",
|
||||
"round 1 en2: wait",
|
||||
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user