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
+30 -5
View File
@@ -28,6 +28,9 @@ Phase 0 documented deviations from PF1e (conventions):
ranged reuse the same corner rule. Soft cover: active creatures between
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
melee attacks ignore creatures.
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
threatens the target from the opposite border or corner. Ranged attacks do
not benefit from flanking; allies without melee weapons do not threaten.
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished.
@@ -56,6 +59,7 @@ _NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
_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
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
_STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1),
@@ -112,6 +116,7 @@ class AttackResult:
total: int
ac: int
penalty: int = 0
flank_bonus: int = 0
@dataclass(frozen=True)
@@ -243,6 +248,22 @@ class CombatEngine:
increments += 1
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
def _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int:
"""+2 if an active ally threatens the target from the opposite side (CRB flanking)."""
ar, ac = attacker.pos
tr, tc = target.pos
opposite = (2 * tr - ar, 2 * tc - ac)
for ally in self._states:
if ally is attacker or ally is target:
continue
if not ally.active or ally.side != attacker.side:
continue
if ally.pos != opposite:
continue
if any(w.kind == "melee" for w in ally.combatant.attacks):
return _FLANK_BONUS
return 0
def resolve_attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> AttackResult:
@@ -250,7 +271,8 @@ class CombatEngine:
roll = self._rng.d20()
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
total = roll + weapon.attack_bonus + penalty
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
total = roll + weapon.attack_bonus + penalty + flank
ac = target.combatant.ac.total
occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target
@@ -269,14 +291,16 @@ class CombatEngine:
if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus + penalty >= ac
confirm_total = confirm + weapon.attack_bonus + penalty + flank
crit = confirm != _NATURAL_ONE and confirm_total >= ac
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
if crit:
damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target)
target.hp -= damage
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, penalty=penalty
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank,
)
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
@@ -346,10 +370,11 @@ class CombatEngine:
target_live.damage_taken += result.damage
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 ""
bonus = (
f"{weapon.attack_bonus}{result.penalty:+d}"
f"{weapon.attack_bonus}{result.penalty:+d}{flank_str}"
if result.penalty
else f"{weapon.attack_bonus}"
else f"{weapon.attack_bonus}{flank_str}"
)
line = (
f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} "