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:
2026-08-17 22:49:50 +02:00
parent 3c31b88b13
commit b20f845606
3 changed files with 176 additions and 7 deletions
+140
View File
@@ -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",
)