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,
|
||||
puis attaque si une arme est utilisable) et `(attack,)` sinon. 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 »)
|
||||
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
|
||||
@@ -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
|
||||
(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.
|
||||
- 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à
|
||||
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
|
||||
@@ -318,7 +327,7 @@ appliquées dans la résolution) :
|
||||
- Manœuvres de combat.
|
||||
- Effets mécaniques de hauteur/élévation.
|
||||
- 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
|
||||
|
||||
@@ -352,7 +361,7 @@ appliquées dans la résolution) :
|
||||
La gate de validation complète (tests + lint + types) :
|
||||
|
||||
```bash
|
||||
uv run pytest -q # 210 tests
|
||||
uv run pytest -q # 213 tests
|
||||
uv run ruff check src tests
|
||||
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
|
||||
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),
|
||||
|
||||
+75
-6
@@ -367,8 +367,8 @@ def test_scripted_2v2_transcript() -> None:
|
||||
"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: orc-1 down",
|
||||
"round 2 orc-2: move (3,4)->(2,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 (3,4)->(2,4)->(1,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",
|
||||
"battle over: monsters win in 2 rounds",
|
||||
)
|
||||
@@ -397,7 +397,7 @@ def test_same_seed_replay_is_identical() -> None:
|
||||
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)
|
||||
orc = make_combatant("orc", speed=0)
|
||||
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 == (
|
||||
"initiative: gob d20=10+0=10",
|
||||
"initiative: orc d20=9+0=9",
|
||||
"round 1 gob: move (1,1)->(0,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 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (6->4)",
|
||||
"round 1 gob: charge (1,1)->(1,2)->(0,3)->(0,4)",
|
||||
"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 11 -> HIT 2 damage (6->4)",
|
||||
"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",
|
||||
"battle over: draw after 2 rounds",
|
||||
@@ -1076,3 +1076,72 @@ def test_two_guards_each_one_aoo() -> None:
|
||||
"round 1 dest: wait",
|
||||
"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