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:
@@ -291,6 +291,10 @@ Règles modélisées :
|
||||
confirmation de critique ; couvert mou : une créature active entre
|
||||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||||
attaques de mêlée ignorent les créatures).
|
||||
- Flanquement : +2 au jet d'attaque de mêlée si un allié actif (doté d'une arme
|
||||
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à
|
||||
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
|
||||
pas pour le flanquement.
|
||||
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
|
||||
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
|
||||
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
|
||||
@@ -300,7 +304,7 @@ Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non
|
||||
appliquées dans la résolution) :
|
||||
|
||||
- Sorts, jets de sauvegarde, conditions et états.
|
||||
- Attaques d'opportunité, flanquement, manœuvres de combat.
|
||||
- Attaques d'opportunité, manœuvres de combat.
|
||||
- Effets mécaniques de hauteur/élévation.
|
||||
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, actions spéciales
|
||||
(attaque à outrance, charge, pas de placement, retraite) et actions
|
||||
@@ -338,7 +342,7 @@ appliquées dans la résolution) :
|
||||
La gate de validation complète (tests + lint + types) :
|
||||
|
||||
```bash
|
||||
uv run pytest -q # 196 tests
|
||||
uv run pytest -q # 202 tests
|
||||
uv run ruff check src tests
|
||||
uv run basedpyright src # mode strict
|
||||
```
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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