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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user