diff --git a/README.md b/README.md index 9568843..eb4db35 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c64dfaf..42e0baf 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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} " diff --git a/tests/test_combat.py b/tests/test_combat.py index 7a965a8..8cd2d14 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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", + )