From b74898042357ae9a916ef6cc1e9025aadee4ce09 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): honor AttackSpec.count for multi-swing attacks A weapon with count>1 ('2x Talons') now resolves one independent attack roll per swing in the same attack action (label '#N'), aggregating stats per swing. Remaining swings are lost if the target drops mid-routine. BAB iterative attacks remain out of Phase 0 scope. --- README.md | 10 +++-- src/pf1e_simulator/combat.py | 45 ++++++++++++++--------- tests/test_combat.py | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 39221d2..83422dd 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,10 @@ Règles modélisées : - Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. - Une action par tour : se déplacer OU attaquer. +- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») + résout `count` balayages indépendants dans la même action ; les balayages + restants sont perdus si la cible tombe (inconsciente ou morte) en cours de + rafale. Les itératifs de BAB (2e attaque à BAB +6…) ne sont pas modélisés. - Mêlée : allonge d'une case ; mouvement : un pas par action, le long du plus court chemin réel (champ de coût Dijkstra depuis la cible — les combattants contournent les murs au lieu d'osciller contre eux). @@ -280,8 +284,8 @@ appliquées dans la résolution) : - Attaques d'opportunité, flanquement, manœuvres de combat. - Pénalités de portée (distance) et couvert mou des créatures. - Effets mécaniques de hauteur/élévation. -- Tailles Large+ (2×2), allonge > 5 ft, attaques itératives (une attaque par - tour), économie d'action complète (charge, pas de placement…). +- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action + complète (charge, pas de placement…). ## Architecture @@ -314,7 +318,7 @@ appliquées dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 170 tests +uv run pytest -q # 182 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 b391540..cd2f0d4 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2,6 +2,9 @@ Phase 0 documented deviations from PF1e (conventions): - One action per turn: move OR attack (no move+attack, no AoO, no flanking). +- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks + in one attack action; remaining swings are lost once the target drops + (down or dead) mid-routine. BAB iterative attacks are not modeled. - Natural 1 always misses; natural 20 always hits and threatens a crit. - A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult. - DR applies once, after crit multiplication; any bypassing type defeats DR. @@ -277,25 +280,31 @@ class CombatEngine: def _attack( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int ) -> None: - hp_before = target.hp - result = self.resolve_attack(attacker, target, weapon) live = self._stats[attacker.combatant.id] - live.hits += int(result.hit) - live.crits += int(result.crit) - live.damage_dealt += result.damage - self._stats[target.combatant.id].damage_taken += result.damage - outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" - line = ( - f"round {round_no} {attacker.combatant.id}: {weapon.name} vs {target.combatant.id} " - f"d20={result.roll}+{weapon.attack_bonus}={result.total} AC {result.ac} -> {outcome}" - ) - if result.hit: - line += f" {result.damage} damage ({hp_before}->{target.hp})" - self._log(line) - if target.dead: - self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} dead") - elif not target.active: - self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down") + target_live = self._stats[target.combatant.id] + for swing in range(1, weapon.count + 1): + hp_before = target.hp + result = self.resolve_attack(attacker, target, weapon) + live.hits += int(result.hit) + live.crits += int(result.crit) + live.damage_dealt += result.damage + 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}" + line = ( + f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} " + f"d20={result.roll}+{weapon.attack_bonus}={result.total} " + f"AC {result.ac} -> {outcome}" + ) + if result.hit: + line += f" {result.damage} damage ({hp_before}->{target.hp})" + self._log(line) + if target.dead: + self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} dead") + return + if not target.active: + self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down") + return def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None: step = self._step_toward(state, target) diff --git a/tests/test_combat.py b/tests/test_combat.py index ee4cbef..80d3314 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -46,6 +46,7 @@ def make_combatant( dr: DamageReduction | None = None, kind: Literal["melee", "ranged", "touch"] = "melee", range_increment_ft: int | None = None, + count: int = 1, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", @@ -57,6 +58,7 @@ def make_combatant( crit_range=crit_range, crit_mult=crit_mult, range_increment_ft=range_increment_ft, + count=count, ) return Combatant( id=cid, @@ -229,6 +231,75 @@ def test_dr_applies_after_crit_multiplier() -> None: assert b.hp == -1 +def test_count_two_resolves_two_swings() -> None: + a_spec = make_combatant("a", attack_bonus=2, count=2) + b_spec = make_combatant("b", hp=12) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine( + [10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 3], [a, b] + ) + result = engine.run() + assert result.winner == "players" + assert result.rounds == 3 + assert result.transcript == ( + "initiative: a d20=10+0=10", + "initiative: b d20=9+0=9", + "round 1 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (12->9)", + "round 1 a: short sword #2 vs b d20=10+2=12 AC 13 -> MISS", + "round 1 b: short sword vs a d20=7+2=9 AC 13 -> MISS", + "round 2 a: short sword #1 vs b d20=13+2=15 AC 13 -> HIT 4 damage (9->5)", + "round 2 a: short sword #2 vs b d20=11+2=13 AC 13 -> HIT 2 damage (5->3)", + "round 2 b: short sword vs a d20=8+2=10 AC 13 -> MISS", + "round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (3->0)", + "round 3 a: b down", + "battle over: players win in 3 rounds", + ) + assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=12, damage_taken=0) + assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=12) + + +def test_count_swings_stop_when_target_drops() -> None: + a_spec = make_combatant("a", attack_bonus=2, damage="1d6", count=2) + b_spec = make_combatant("b", hp=4) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + # First swing downs b; the second swing must never consume a roll. + engine = make_engine([10, 9, 12, 6], [a, b]) + result = engine.run() + assert result.winner == "players" + assert result.rounds == 1 + assert result.transcript == ( + "initiative: a d20=10+0=10", + "initiative: b d20=9+0=9", + "round 1 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 6 damage (4->-2)", + "round 1 a: b down", + "battle over: players win in 1 rounds", + ) + assert result.stats["a"] == CombatantStats(hits=1, crits=0, damage_dealt=6, damage_taken=0) + assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=6) + + +def test_count_swings_resolve_independently() -> None: + a_spec = make_combatant("a", attack_bonus=2, count=2) + b_spec = make_combatant("b", hp=12) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([10, 9, 20, 11, 3, 5, 8], [a, b], round_cap=1) + result = engine.run() + assert result.winner is None + assert result.transcript == ( + "initiative: a d20=10+0=10", + "initiative: b d20=9+0=9", + "round 1 a: short sword #1 vs b d20=20+2=22 AC 13 -> CRIT 6 damage (12->6)", + "round 1 a: short sword #2 vs b d20=5+2=7 AC 13 -> MISS", + "round 1 b: short sword vs a d20=8+2=10 AC 13 -> MISS", + "battle over: draw after 1 rounds", + ) + assert result.stats["a"] == CombatantStats(hits=1, crits=1, damage_dealt=6, damage_taken=0) + assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=6) + + def test_hp_states_active_down_dead() -> None: c = make_combatant("c", con=12) st = make_state(c, "players", (0, 0))