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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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