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