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
+12 -3
View File
@@ -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
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 `(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
View File
@@ -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",
)