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.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 950b53c54e
commit b748980423
3 changed files with 105 additions and 21 deletions
+7 -3
View File
@@ -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
```
+27 -18
View File
@@ -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)
+71
View File
@@ -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))