fix(combat): separate standard attack (single swing) from full attack (iteratives)
This commit is contained in:
@@ -368,7 +368,7 @@ appliquées dans la résolution) :
|
|||||||
La gate de validation complète (tests + lint + types) :
|
La gate de validation complète (tests + lint + types) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -q # 215 tests
|
uv run pytest -q # 216 tests
|
||||||
uv run ruff check src tests
|
uv run ruff check src tests
|
||||||
uv run basedpyright src # mode strict
|
uv run basedpyright src # mode strict
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -183,11 +183,12 @@ class Action:
|
|||||||
"""What a policy wants a combatant to do this turn.
|
"""What a policy wants a combatant to do this turn.
|
||||||
|
|
||||||
Kinds map to PF1e action types: ``attack`` is a standard action
|
Kinds map to PF1e action types: ``attack`` is a standard action
|
||||||
(single attack), ``move`` is a move action, ``full_attack`` is a full-round
|
(single attack, highest BAB only — no iteratives), ``move`` is a move
|
||||||
action (BAB iteratives), ``charge`` is a special full-round action
|
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
|
||||||
(2x speed, straight line, +2 attack, -2 AC), ``swift`` and ``free`` are
|
is a special full-round action (2x speed, straight line, +2 attack, -2
|
||||||
minor actions, ``immediate`` is an off-turn reaction (deferred). ``wait``
|
AC), ``swift`` and ``free`` are minor actions, ``immediate`` is an
|
||||||
is a no-op. The engine executes a policy-returned sequence per turn.
|
off-turn reaction (deferred). ``wait`` is a no-op. The engine executes a
|
||||||
|
policy-returned sequence per turn.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: Literal[
|
kind: Literal[
|
||||||
@@ -625,7 +626,9 @@ class CombatEngine:
|
|||||||
weapon = self.weapon_for(state, target)
|
weapon = self.weapon_for(state, target)
|
||||||
if weapon is None:
|
if weapon is None:
|
||||||
return
|
return
|
||||||
if kind == "full_attack":
|
if kind == "attack":
|
||||||
|
self._single_attack(state, target, weapon)
|
||||||
|
elif kind == "full_attack":
|
||||||
self._full_attack(state, target, weapon)
|
self._full_attack(state, target, weapon)
|
||||||
else:
|
else:
|
||||||
self._attack(state, target, weapon)
|
self._attack(state, target, weapon)
|
||||||
@@ -698,6 +701,15 @@ class CombatEngine:
|
|||||||
if self._resolve_swing(attacker, target, weapon, label=label):
|
if self._resolve_swing(attacker, target, weapon, label=label):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _single_attack(
|
||||||
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||||
|
) -> None:
|
||||||
|
if not attacker.active:
|
||||||
|
return
|
||||||
|
if self._check_provoked_aoo(attacker, weapon):
|
||||||
|
return
|
||||||
|
self._resolve_swing(attacker, target, weapon, label=weapon.name)
|
||||||
|
|
||||||
def _full_attack(
|
def _full_attack(
|
||||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -870,12 +882,12 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|||||||
|
|
||||||
|
|
||||||
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
"""Full-attack the nearest enemy in range; charge if reachable; otherwise approach.
|
"""Standard-attack the nearest enemy in range; charge if reachable; otherwise approach.
|
||||||
|
|
||||||
Decision order:
|
Decision order:
|
||||||
1. Full-attack if a weapon is usable against the nearest enemy this turn.
|
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
||||||
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
||||||
3. Move toward the target then full-attack (standard + move economy).
|
3. Move toward the target then single attack (standard + move economy).
|
||||||
4. Wait if nothing is possible.
|
4. Wait if nothing is possible.
|
||||||
"""
|
"""
|
||||||
target = engine.nearest_enemy(state)
|
target = engine.nearest_enemy(state)
|
||||||
@@ -888,5 +900,5 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
|
|||||||
return (Action(kind="charge", target_id=target.combatant.id),)
|
return (Action(kind="charge", target_id=target.combatant.id),)
|
||||||
return (
|
return (
|
||||||
Action(kind="move", target_id=target.combatant.id),
|
Action(kind="move", target_id=target.combatant.id),
|
||||||
Action(kind="full_attack", target_id=target.combatant.id),
|
Action(kind="attack", target_id=target.combatant.id),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1202,3 +1202,37 @@ def test_withdraw_provokes_after_first_square() -> None:
|
|||||||
"round 1 foe2: wait",
|
"round 1 foe2: wait",
|
||||||
"battle over: draw after 1 rounds",
|
"battle over: draw after 1 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Standard attack (single swing, no iteratives — PF1e standard action) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _move_then_attack(
|
||||||
|
engine: CombatEngine, state: CombatantState
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
|
return (
|
||||||
|
Action(kind="move", target_id="orc"),
|
||||||
|
Action(kind="attack", target_id="orc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_attack_single_swing() -> None:
|
||||||
|
"""move+attack gives ONE attack at highest BAB; move+full_attack gives iteratives."""
|
||||||
|
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=6, speed=30, initiative_mod=10)
|
||||||
|
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=1, speed=0)
|
||||||
|
states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (3, 0))]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([20, 1, 10, 15, 3]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_move_then_attack,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=20+10=30",
|
||||||
|
"initiative: orc d20=1+0=1",
|
||||||
|
"round 1 gob: move (0,0)->(1,0)->(2,0)",
|
||||||
|
"round 1 gob: short sword vs orc d20=10+6=16 AC 15 -> HIT 15 damage (20->5)",
|
||||||
|
"round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user