feat(combat): add charge action (straight-line 2x speed, +2 attack, -2 AC)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 004d6f1c2b
commit 7543c55ed4
3 changed files with 306 additions and 27 deletions
+219 -18
View File
@@ -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),