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
+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))