feat(combat): add charge action (straight-line 2x speed, +2 attack, -2 AC)
This commit is contained in:
+219
-18
@@ -10,7 +10,7 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
`(attack,)` when already in range. The move action may replace the standard
|
||||
action (e.g. a double move) but the default policy does not need this.
|
||||
Immediate actions (off-turn, consume next swift) and special actions
|
||||
(charge, withdraw) are deferred; flanking and full attack are modeled;
|
||||
(withdraw) are deferred; flanking, full attack, and charge are modeled;
|
||||
attacks of opportunity are resolved inline (see below).
|
||||
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
||||
in one attack action; remaining swings are lost once the target drops
|
||||
@@ -45,6 +45,14 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
combatant per round (tracked in ``_aoo_used``, cleared at round start);
|
||||
AoOs always strike (PF1e allows declining, the simulator does not). A
|
||||
5-foot step and the withdraw action (deferred) avoid AoOs from movement.
|
||||
- Charge: a full-round action that moves in a straight line (Bresenham) up to
|
||||
2x speed toward the closest square from which the charger can melee the
|
||||
target, then makes a single melee attack at +2. The charger takes -2 AC
|
||||
until the start of their next turn (tracked in ``ac_penalty``, cleared in
|
||||
``_take_turn``). The straight line must be clear of difficult terrain,
|
||||
obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy``
|
||||
chooses a charge when the enemy is out of reach but a valid charge path
|
||||
exists.
|
||||
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
|
||||
range increments; the penalty applies to attack and crit-confirm rolls.
|
||||
Thrown weapons (5 increments max) are not distinguished.
|
||||
@@ -76,6 +84,9 @@ _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
|
||||
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
|
||||
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
|
||||
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
|
||||
_CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
|
||||
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
|
||||
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
|
||||
|
||||
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||
(-1, -1),
|
||||
@@ -89,6 +100,29 @@ _STEP_DELTAS: tuple[Pos, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
||||
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
||||
r0, c0 = start
|
||||
r1, c1 = end
|
||||
dr = abs(r1 - r0)
|
||||
dc = abs(c1 - c0)
|
||||
sr = 1 if r1 > r0 else -1
|
||||
sc = 1 if c1 > c0 else -1
|
||||
err = dr - dc
|
||||
path: list[Pos] = []
|
||||
r, c = r0, c0
|
||||
while (r, c) != (r1, c1):
|
||||
e2 = 2 * err
|
||||
if e2 > -dc:
|
||||
err -= dc
|
||||
r += sr
|
||||
if e2 < dr:
|
||||
err += dr
|
||||
c += sc
|
||||
path.append((r, c))
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CombatantStats:
|
||||
"""Aggregated outcomes for one combatant over a battle."""
|
||||
@@ -107,6 +141,7 @@ class CombatantState:
|
||||
side: str
|
||||
pos: Pos
|
||||
hp: int
|
||||
ac_penalty: int = 0
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -140,17 +175,17 @@ class AttackResult:
|
||||
class Action:
|
||||
"""What a policy wants a combatant to do this turn.
|
||||
|
||||
Kinds map to the six 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
|
||||
action (BAB iteratives), ``full_round`` is a generic full-round action
|
||||
(charge, withdraw — deferred), ``swift`` and ``free`` are minor actions,
|
||||
``immediate`` is an off-turn reaction (deferred). ``wait`` is a no-op.
|
||||
The engine executes a policy-returned sequence per turn.
|
||||
action (BAB iteratives), ``charge`` is a special full-round action
|
||||
(2x speed, straight line, +2 attack, -2 AC), ``swift`` and ``free`` are
|
||||
minor actions, ``immediate`` is an off-turn reaction (deferred). ``wait``
|
||||
is a no-op. The engine executes a policy-returned sequence per turn.
|
||||
"""
|
||||
|
||||
kind: Literal[
|
||||
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||
"full_attack",
|
||||
"full_attack", "charge",
|
||||
]
|
||||
target_id: str | None = None
|
||||
|
||||
@@ -197,6 +232,7 @@ class CombatEngine:
|
||||
|
||||
def _take_turn(self, state: CombatantState) -> bool:
|
||||
"""Execute one combatant's full turn; return True if the battle is over."""
|
||||
state.ac_penalty = 0
|
||||
actions = self._policy(self, state)
|
||||
before = len(self._transcript)
|
||||
for action in actions:
|
||||
@@ -303,7 +339,7 @@ class CombatEngine:
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
total = roll + base_bonus + penalty + flank
|
||||
ac = target.combatant.ac.total
|
||||
ac = target.combatant.ac.total - target.ac_penalty
|
||||
occupied = frozenset(
|
||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
||||
)
|
||||
@@ -419,18 +455,152 @@ class CombatEngine:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
|
||||
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
|
||||
|
||||
PF1e charge requirements (CRB):
|
||||
- Must have line of sight to the target at the start of the turn.
|
||||
- Must move at least 10 ft (2 squares) and at most double speed.
|
||||
- Must move in a straight line (Bresenham) to the closest attackable square.
|
||||
- The path must not pass through blocking terrain, difficult terrain, or creatures.
|
||||
- Must end adjacent to the target (within melee reach).
|
||||
- Only a single melee attack is allowed.
|
||||
"""
|
||||
if not has_line_of_effect(self._grid, state.pos, target.pos):
|
||||
return None
|
||||
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
||||
max_cells = 2 * speed_cells
|
||||
blocked = frozenset(
|
||||
s.pos for s in self._states if s is not state and s is not target and s.active
|
||||
)
|
||||
for weapon in state.combatant.attacks:
|
||||
if weapon.kind != "melee":
|
||||
continue
|
||||
reach_cells = weapon.reach_ft // _SQUARE_FT
|
||||
end = self._charge_end(state, target, reach_cells, max_cells, blocked)
|
||||
if end is not None:
|
||||
return weapon
|
||||
return None
|
||||
|
||||
def _charge_end(
|
||||
self,
|
||||
state: CombatantState,
|
||||
target: CombatantState,
|
||||
reach_cells: int,
|
||||
max_cells: int,
|
||||
blocked: frozenset[Pos],
|
||||
) -> Pos | None:
|
||||
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
|
||||
|
||||
Scans squares within reach of the target, picks the one whose Bresenham
|
||||
line from ``state.pos`` is the shortest valid charge path (>=2 cells,
|
||||
<=max_cells, no blocking terrain or creatures, all passable).
|
||||
Ties break toward lower distance from state.
|
||||
"""
|
||||
candidates: list[tuple[int, Pos]] = []
|
||||
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
|
||||
path = _bresenham_line(state.pos, end)
|
||||
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
|
||||
continue
|
||||
if not self._charge_path_clear(path, blocked):
|
||||
continue
|
||||
candidates.append((len(path), end))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda x: (x[0], self._grid.distance(state.pos, x[1])))
|
||||
return candidates[0][1]
|
||||
|
||||
def _charge_candidates(
|
||||
self,
|
||||
target: CombatantState,
|
||||
reach_cells: int,
|
||||
blocked: frozenset[Pos],
|
||||
start: Pos,
|
||||
) -> list[Pos]:
|
||||
"""Squares within reach of target, passable, unoccupied, not the start."""
|
||||
tr, tc = target.pos
|
||||
result: list[Pos] = []
|
||||
for dr in range(-reach_cells, reach_cells + 1):
|
||||
for dc in range(-reach_cells, reach_cells + 1):
|
||||
if abs(dr) > reach_cells or abs(dc) > reach_cells:
|
||||
continue
|
||||
if dr == 0 and dc == 0:
|
||||
continue
|
||||
end = (tr + dr, tc + dc)
|
||||
if end == start:
|
||||
continue
|
||||
if not self._grid.in_bounds(end) or not self._grid.passable(end):
|
||||
continue
|
||||
if end in blocked:
|
||||
continue
|
||||
if self._grid.distance(end, target.pos) > reach_cells:
|
||||
continue
|
||||
result.append(end)
|
||||
return result
|
||||
|
||||
def _charge_path_clear(self, path: list[Pos], blocked: frozenset[Pos]) -> bool:
|
||||
"""True if every square on the charge path is passable and clear."""
|
||||
for pos in path:
|
||||
if not self._grid.in_bounds(pos) or not self._grid.passable(pos):
|
||||
return False
|
||||
if pos in blocked:
|
||||
return False
|
||||
terrain = self._grid.terrain_at(pos)
|
||||
if terrain.move_cost is not None and terrain.move_cost > 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _charge(
|
||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||
) -> None:
|
||||
"""Execute a charge: move in a straight line, then make a single melee attack at +2.
|
||||
|
||||
Sets a -2 AC penalty on the attacker that lasts until the start of their next turn
|
||||
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
|
||||
as for any move action.
|
||||
"""
|
||||
reach_cells = weapon.reach_ft // _SQUARE_FT
|
||||
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
|
||||
max_cells = 2 * speed_cells
|
||||
blocked = frozenset(
|
||||
s.pos for s in self._states if s is not attacker and s is not target and s.active
|
||||
)
|
||||
end = self._charge_end(attacker, target, reach_cells, max_cells, blocked)
|
||||
if end is None:
|
||||
return
|
||||
path = _bresenham_line(attacker.pos, end)
|
||||
start = attacker.pos
|
||||
actual_path: list[Pos] = []
|
||||
for step in path:
|
||||
for enemy in self._enemies_threatening(attacker, attacker.pos):
|
||||
if enemy.combatant.id in self._aoo_used:
|
||||
continue
|
||||
self._resolve_aoo(enemy, attacker)
|
||||
if not attacker.active:
|
||||
break
|
||||
if not attacker.active:
|
||||
break
|
||||
attacker.pos = step
|
||||
actual_path.append(step)
|
||||
if actual_path:
|
||||
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
||||
self._log(
|
||||
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
|
||||
)
|
||||
if not attacker.active:
|
||||
return
|
||||
attacker.ac_penalty = _CHARGE_AC_PENALTY
|
||||
bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS
|
||||
self._resolve_swing(
|
||||
attacker, target, weapon, label="charge", bonus_override=bonus
|
||||
)
|
||||
|
||||
def _execute(self, state: CombatantState, action: Action) -> None:
|
||||
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
||||
if action.kind in ("attack", "full_attack"):
|
||||
if target is None or not target.active:
|
||||
return
|
||||
weapon = self.weapon_for(state, target)
|
||||
if weapon is None:
|
||||
return
|
||||
if action.kind == "full_attack":
|
||||
self._full_attack(state, target, weapon)
|
||||
else:
|
||||
self._attack(state, target, weapon)
|
||||
self._execute_attack(state, target, action.kind)
|
||||
elif action.kind == "charge":
|
||||
self._execute_charge(state, target)
|
||||
elif action.kind == "move":
|
||||
if target is None:
|
||||
return
|
||||
@@ -438,6 +608,27 @@ class CombatEngine:
|
||||
elif action.kind == "wait":
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
||||
|
||||
def _execute_attack(
|
||||
self, state: CombatantState, target: CombatantState | None, kind: str
|
||||
) -> None:
|
||||
if target is None or not target.active:
|
||||
return
|
||||
weapon = self.weapon_for(state, target)
|
||||
if weapon is None:
|
||||
return
|
||||
if kind == "full_attack":
|
||||
self._full_attack(state, target, weapon)
|
||||
else:
|
||||
self._attack(state, target, weapon)
|
||||
|
||||
def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None:
|
||||
if target is None or not target.active:
|
||||
return
|
||||
weapon = self.can_charge(state, target)
|
||||
if weapon is None:
|
||||
return
|
||||
self._charge(state, target, weapon)
|
||||
|
||||
def _resolve_swing(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
@@ -585,12 +776,22 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||
|
||||
|
||||
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
"""Full-attack the nearest enemy in range; otherwise approach then full-attack."""
|
||||
"""Full-attack the nearest enemy in range; charge if reachable; otherwise approach.
|
||||
|
||||
Decision order:
|
||||
1. Full-attack if a weapon is usable against the nearest enemy this turn.
|
||||
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).
|
||||
4. Wait if nothing is possible.
|
||||
"""
|
||||
target = engine.nearest_enemy(state)
|
||||
if target is None:
|
||||
return (Action(kind="wait"),)
|
||||
if engine.weapon_for(state, target) is not None:
|
||||
return (Action(kind="full_attack", target_id=target.combatant.id),)
|
||||
charge_weapon = engine.can_charge(state, target)
|
||||
if charge_weapon is not None:
|
||||
return (Action(kind="charge", target_id=target.combatant.id),)
|
||||
return (
|
||||
Action(kind="move", target_id=target.combatant.id),
|
||||
Action(kind="full_attack", target_id=target.combatant.id),
|
||||
|
||||
Reference in New Issue
Block a user