feat(combat): add charge action (straight-line 2x speed, +2 attack, -2 AC)
This commit is contained in:
@@ -273,7 +273,9 @@ Règles modélisées :
|
|||||||
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra,
|
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra,
|
||||||
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions
|
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions
|
||||||
immédiates (hors-tour, consomment le prochain swift) et les actions
|
immédiates (hors-tour, consomment le prochain swift) et les actions
|
||||||
spéciales (charge, retraite) ne sont pas encore modélisées.
|
spéciales (retraite) ne sont pas encore modélisées. `default_policy`
|
||||||
|
choisit la charge quand l'ennemi est hors de portée mais joignable par
|
||||||
|
une ligne droite (voir ci-dessous).
|
||||||
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
|
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
|
||||||
résout `count` balayages indépendants dans la même action ; les balayages
|
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
|
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
|
||||||
@@ -306,6 +308,13 @@ Règles modélisées :
|
|||||||
`_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées
|
`_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées
|
||||||
(PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de
|
(PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de
|
||||||
placement (5 ft) et la retraite (à venir) évitent les AoO de mouvement.
|
placement (5 ft) et la retraite (à venir) évitent les AoO de mouvement.
|
||||||
|
- Charge : action à round complet. Le combattant se déplace en ligne droite
|
||||||
|
(Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut
|
||||||
|
frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La
|
||||||
|
charge impose −2 CA à l'attaquant jusqu'au début de son prochain tour. La
|
||||||
|
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
|
||||||
|
créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la
|
||||||
|
charge quand l'ennemi est hors de portée mais joignable en ligne droite.
|
||||||
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
|
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
|
||||||
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
|
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
|
||||||
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
|
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
|
||||||
@@ -318,7 +327,7 @@ appliquées dans la résolution) :
|
|||||||
- Manœuvres de combat.
|
- Manœuvres de combat.
|
||||||
- Effets mécaniques de hauteur/élévation.
|
- Effets mécaniques de hauteur/élévation.
|
||||||
- Tailles Large+ (2×2), allonge > 5 ft, actions spéciales
|
- Tailles Large+ (2×2), allonge > 5 ft, actions spéciales
|
||||||
(charge, pas de placement, retraite) et actions immédiates hors-tour.
|
(pas de placement, retraite) et actions immédiates hors-tour.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -352,7 +361,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 # 210 tests
|
uv run pytest -q # 213 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
|
||||||
```
|
```
|
||||||
|
|||||||
+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
|
`(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.
|
action (e.g. a double move) but the default policy does not need this.
|
||||||
Immediate actions (off-turn, consume next swift) and special actions
|
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).
|
attacks of opportunity are resolved inline (see below).
|
||||||
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
||||||
in one attack action; remaining swings are lost once the target drops
|
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);
|
combatant per round (tracked in ``_aoo_used``, cleared at round start);
|
||||||
AoOs always strike (PF1e allows declining, the simulator does not). A
|
AoOs always strike (PF1e allows declining, the simulator does not). A
|
||||||
5-foot step and the withdraw action (deferred) avoid AoOs from movement.
|
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
|
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
|
||||||
range increments; the penalty applies to attack and crit-confirm rolls.
|
range increments; the penalty applies to attack and crit-confirm rolls.
|
||||||
Thrown weapons (5 increments max) are not distinguished.
|
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
|
_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
|
_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)
|
_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, ...] = (
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||||
(-1, -1),
|
(-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)
|
@dataclass(frozen=True)
|
||||||
class CombatantStats:
|
class CombatantStats:
|
||||||
"""Aggregated outcomes for one combatant over a battle."""
|
"""Aggregated outcomes for one combatant over a battle."""
|
||||||
@@ -107,6 +141,7 @@ class CombatantState:
|
|||||||
side: str
|
side: str
|
||||||
pos: Pos
|
pos: Pos
|
||||||
hp: int
|
hp: int
|
||||||
|
ac_penalty: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
@@ -140,17 +175,17 @@ class AttackResult:
|
|||||||
class Action:
|
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 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
|
(single attack), ``move`` is a move action, ``full_attack`` is a full-round
|
||||||
action (BAB iteratives), ``full_round`` is a generic full-round action
|
action (BAB iteratives), ``charge`` is a special full-round action
|
||||||
(charge, withdraw — deferred), ``swift`` and ``free`` are minor actions,
|
(2x speed, straight line, +2 attack, -2 AC), ``swift`` and ``free`` are
|
||||||
``immediate`` is an off-turn reaction (deferred). ``wait`` is a no-op.
|
minor actions, ``immediate`` is an off-turn reaction (deferred). ``wait``
|
||||||
The engine executes a policy-returned sequence per turn.
|
is a no-op. The engine executes a policy-returned sequence per turn.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: Literal[
|
kind: Literal[
|
||||||
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
"full_attack",
|
"full_attack", "charge",
|
||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
|
|
||||||
@@ -197,6 +232,7 @@ class CombatEngine:
|
|||||||
|
|
||||||
def _take_turn(self, state: CombatantState) -> bool:
|
def _take_turn(self, state: CombatantState) -> bool:
|
||||||
"""Execute one combatant's full turn; return True if the battle is over."""
|
"""Execute one combatant's full turn; return True if the battle is over."""
|
||||||
|
state.ac_penalty = 0
|
||||||
actions = self._policy(self, state)
|
actions = self._policy(self, state)
|
||||||
before = len(self._transcript)
|
before = len(self._transcript)
|
||||||
for action in actions:
|
for action in actions:
|
||||||
@@ -303,7 +339,7 @@ class CombatEngine:
|
|||||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
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
|
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||||
total = roll + base_bonus + penalty + flank
|
total = roll + base_bonus + penalty + flank
|
||||||
ac = target.combatant.ac.total
|
ac = target.combatant.ac.total - target.ac_penalty
|
||||||
occupied = frozenset(
|
occupied = frozenset(
|
||||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
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 True
|
||||||
return False
|
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:
|
def _execute(self, state: CombatantState, action: Action) -> None:
|
||||||
target = next((s for s in self._states if s.combatant.id == action.target_id), 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 action.kind in ("attack", "full_attack"):
|
||||||
if target is None or not target.active:
|
self._execute_attack(state, target, action.kind)
|
||||||
return
|
elif action.kind == "charge":
|
||||||
weapon = self.weapon_for(state, target)
|
self._execute_charge(state, target)
|
||||||
if weapon is None:
|
|
||||||
return
|
|
||||||
if action.kind == "full_attack":
|
|
||||||
self._full_attack(state, target, weapon)
|
|
||||||
else:
|
|
||||||
self._attack(state, target, weapon)
|
|
||||||
elif action.kind == "move":
|
elif action.kind == "move":
|
||||||
if target is None:
|
if target is None:
|
||||||
return
|
return
|
||||||
@@ -438,6 +608,27 @@ class CombatEngine:
|
|||||||
elif action.kind == "wait":
|
elif action.kind == "wait":
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: 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(
|
def _resolve_swing(
|
||||||
self,
|
self,
|
||||||
attacker: CombatantState,
|
attacker: CombatantState,
|
||||||
@@ -585,12 +776,22 @@ 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; 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)
|
target = engine.nearest_enemy(state)
|
||||||
if target is None:
|
if target is None:
|
||||||
return (Action(kind="wait"),)
|
return (Action(kind="wait"),)
|
||||||
if engine.weapon_for(state, target) is not None:
|
if engine.weapon_for(state, target) is not None:
|
||||||
return (Action(kind="full_attack", target_id=target.combatant.id),)
|
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 (
|
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="full_attack", target_id=target.combatant.id),
|
||||||
|
|||||||
+75
-6
@@ -367,8 +367,8 @@ def test_scripted_2v2_transcript() -> None:
|
|||||||
"round 1 orc-2: gob-2 down",
|
"round 1 orc-2: gob-2 down",
|
||||||
"round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)",
|
"round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)",
|
||||||
"round 2 gob-1: orc-1 down",
|
"round 2 gob-1: orc-1 down",
|
||||||
"round 2 orc-2: move (3,4)->(2,3)",
|
"round 2 orc-2: charge (3,4)->(2,4)->(1,3)",
|
||||||
"round 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
|
"round 2 orc-2: charge vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)",
|
||||||
"round 2 orc-2: gob-1 down",
|
"round 2 orc-2: gob-1 down",
|
||||||
"battle over: monsters win in 2 rounds",
|
"battle over: monsters win in 2 rounds",
|
||||||
)
|
)
|
||||||
@@ -397,7 +397,7 @@ def test_same_seed_replay_is_identical() -> None:
|
|||||||
assert first.rounds == second.rounds
|
assert first.rounds == second.rounds
|
||||||
|
|
||||||
|
|
||||||
def test_default_policy_moves_toward_enemy() -> None:
|
def test_default_policy_charges_toward_enemy() -> None:
|
||||||
gob = make_combatant("gob", speed=30)
|
gob = make_combatant("gob", speed=30)
|
||||||
orc = make_combatant("orc", speed=0)
|
orc = make_combatant("orc", speed=0)
|
||||||
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
|
||||||
@@ -406,9 +406,9 @@ def test_default_policy_moves_toward_enemy() -> None:
|
|||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: gob d20=10+0=10",
|
"initiative: gob d20=10+0=10",
|
||||||
"initiative: orc d20=9+0=9",
|
"initiative: orc d20=9+0=9",
|
||||||
"round 1 gob: move (1,1)->(0,2)->(0,3)->(0,4)",
|
"round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)",
|
||||||
"round 1 gob: short sword vs orc d20=12+2=14 AC 13 -> HIT 3 damage (6->3)",
|
"round 1 gob: charge vs orc d20=12+4=16 AC 13 -> HIT 3 damage (6->3)",
|
||||||
"round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (6->4)",
|
"round 1 orc: short sword vs gob d20=11+2=13 AC 11 -> HIT 2 damage (6->4)",
|
||||||
"round 2 gob: short sword vs orc d20=10+2=12 AC 13 -> MISS",
|
"round 2 gob: short sword vs orc d20=10+2=12 AC 13 -> MISS",
|
||||||
"round 2 orc: short sword vs gob d20=1+2=3 AC 13 -> MISS",
|
"round 2 orc: short sword vs gob d20=1+2=3 AC 13 -> MISS",
|
||||||
"battle over: draw after 2 rounds",
|
"battle over: draw after 2 rounds",
|
||||||
@@ -1076,3 +1076,72 @@ def test_two_guards_each_one_aoo() -> None:
|
|||||||
"round 1 dest: wait",
|
"round 1 dest: wait",
|
||||||
"battle over: draw after 1 rounds",
|
"battle over: draw after 1 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Charge (CRB: straight-line 2x speed, +2 attack, -2 AC, single melee attack) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _gob_charges_orc_attacks(
|
||||||
|
engine: CombatEngine, state: CombatantState
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "gob":
|
||||||
|
return (Action(kind="charge", target_id="orc"),)
|
||||||
|
return (Action(kind="attack", target_id="gob"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_straight_line_and_bonus() -> None:
|
||||||
|
"""Charge moves in a straight line and attacks at +2; enemy retaliates."""
|
||||||
|
gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30)
|
||||||
|
orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0)
|
||||||
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 12, 3, 11, 2]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=10+0=10",
|
||||||
|
"initiative: orc d20=9+0=9",
|
||||||
|
"round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)",
|
||||||
|
"round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (10->7)",
|
||||||
|
"round 1 orc: short sword vs gob d20=11+0=11 AC 13 -> MISS",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_ac_penalty_applies() -> None:
|
||||||
|
"""The -2 AC penalty from charging applies to the enemy's attack the same round."""
|
||||||
|
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30)
|
||||||
|
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0)
|
||||||
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 4))]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 12, 3, 11, 2]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_gob_charges_orc_attacks,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=10+0=10",
|
||||||
|
"initiative: orc d20=9+0=9",
|
||||||
|
"round 1 gob: charge (1,1)->(1,2)->(0,3)",
|
||||||
|
"round 1 gob: charge vs orc d20=12+4=16 AC 15 -> HIT 3 damage (20->17)",
|
||||||
|
"round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (20->18)",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_too_close_full_attacks_instead() -> None:
|
||||||
|
"""When adjacent (cannot charge minimum 2 squares), default_policy full-attacks."""
|
||||||
|
gob = make_combatant("gob", hp=10, ac=15, attack_bonus=2, speed=30)
|
||||||
|
orc = make_combatant("orc", hp=10, ac=15, attack_bonus=0, speed=0)
|
||||||
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))]
|
||||||
|
engine = make_engine([10, 9, 12, 3], states, round_cap=1)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=10+0=10",
|
||||||
|
"initiative: orc d20=9+0=9",
|
||||||
|
"round 1 gob: short sword vs orc d20=12+2=14 AC 15 -> MISS",
|
||||||
|
"round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user