feat(combat): add attacks of opportunity (movement + ranged-in-melee triggers)
This commit is contained in:
@@ -273,8 +273,7 @@ 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 (AoO, flanquement, attaque à outrance, charge, retraite) ne sont
|
spéciales (charge, retraite) ne sont pas encore modélisées.
|
||||||
pas encore modélisées.
|
|
||||||
- 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
|
||||||
@@ -298,6 +297,15 @@ Règles modélisées :
|
|||||||
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à
|
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à
|
||||||
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
|
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
|
||||||
pas pour le flanquement.
|
pas pour le flanquement.
|
||||||
|
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
|
||||||
|
les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une
|
||||||
|
case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace
|
||||||
|
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer
|
||||||
|
avec une arme à distance depuis une case menacée — chaque ennemi menaçant
|
||||||
|
porte une AoO avant chaque tir. Une AoO par combattant par round (piste
|
||||||
|
`_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.
|
||||||
- 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
|
||||||
@@ -307,7 +315,7 @@ Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non
|
|||||||
appliquées dans la résolution) :
|
appliquées dans la résolution) :
|
||||||
|
|
||||||
- Sorts, jets de sauvegarde, conditions et états.
|
- Sorts, jets de sauvegarde, conditions et états.
|
||||||
- Attaques d'opportunité, 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.
|
(charge, pas de placement, retraite) et actions immédiates hors-tour.
|
||||||
@@ -344,7 +352,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 # 206 tests
|
uv run pytest -q # 210 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
|
||||||
```
|
```
|
||||||
@@ -358,7 +366,8 @@ uv run basedpyright src # mode strict
|
|||||||
## Feuille de route
|
## Feuille de route
|
||||||
|
|
||||||
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
|
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
|
||||||
effets paramétrés, conditions, flanquement, attaques d'opportunité.
|
effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à
|
||||||
|
outrance et attaques d'opportunité sont déjà modélisés.
|
||||||
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
||||||
en politiques, balayage de matrices de positionnement.
|
en politiques, balayage de matrices de positionnement.
|
||||||
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ allowed-confusables = ["σ"]
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
|
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
|
||||||
# parametrized golden tests legitimately take many arguments
|
# parametrized golden tests legitimately take many arguments
|
||||||
"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917"]
|
"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917", "ARG001"]
|
||||||
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
|
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
|
||||||
"src/pf1e_simulator/rng.py" = ["S311"]
|
"src/pf1e_simulator/rng.py" = ["S311"]
|
||||||
# cli.py: stdout printing is the point of a CLI
|
# cli.py: stdout printing is the point of a CLI
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ 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
|
||||||
(AoO, flanking, full attack, charge, withdraw) are deferred.
|
(charge, withdraw) are deferred; flanking and full attack are modeled;
|
||||||
|
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
|
||||||
(down or dead) mid-routine. BAB iterative attacks (full-round action
|
(down or dead) mid-routine. BAB iterative attacks (full-round action
|
||||||
@@ -35,6 +36,15 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
|
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
|
||||||
threatens the target from the opposite border or corner. Ranged attacks do
|
threatens the target from the opposite border or corner. Ranged attacks do
|
||||||
not benefit from flanking; allies without melee weapons do not threaten.
|
not benefit from flanking; allies without melee weapons do not threaten.
|
||||||
|
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
|
||||||
|
active and wielding a melee weapon. Two triggers are modeled: (1) moving
|
||||||
|
out of a threatened square — movement is resolved step-by-step, and each
|
||||||
|
enemy that threatens the current square gets one AoO before the mover
|
||||||
|
leaves it; (2) making a ranged attack while in a threatened square — each
|
||||||
|
threatening enemy gets one AoO before each ranged swing. One AoO per
|
||||||
|
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.
|
||||||
- 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.
|
||||||
@@ -183,6 +193,7 @@ class CombatEngine:
|
|||||||
self._transcript: list[str] = []
|
self._transcript: list[str] = []
|
||||||
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
||||||
self._current_round = 0
|
self._current_round = 0
|
||||||
|
self._aoo_used: set[str] = set()
|
||||||
|
|
||||||
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."""
|
||||||
@@ -204,6 +215,7 @@ class CombatEngine:
|
|||||||
round_no = 1
|
round_no = 1
|
||||||
while round_no <= self._round_cap:
|
while round_no <= self._round_cap:
|
||||||
self._current_round = round_no
|
self._current_round = round_no
|
||||||
|
self._aoo_used.clear()
|
||||||
for state in order:
|
for state in order:
|
||||||
if not state.active:
|
if not state.active:
|
||||||
continue
|
continue
|
||||||
@@ -358,6 +370,55 @@ class CombatEngine:
|
|||||||
return active_sides.pop()
|
return active_sides.pop()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
||||||
|
"""Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach)."""
|
||||||
|
if not state.active:
|
||||||
|
return frozenset()
|
||||||
|
if not any(w.kind == "melee" for w in state.combatant.attacks):
|
||||||
|
return frozenset()
|
||||||
|
r, c = state.pos
|
||||||
|
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
|
||||||
|
|
||||||
|
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
||||||
|
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
||||||
|
return [
|
||||||
|
s
|
||||||
|
for s in self._states
|
||||||
|
if s is not state
|
||||||
|
and s.active
|
||||||
|
and s.side != state.side
|
||||||
|
and pos in self._threatened_squares(s)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
||||||
|
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
||||||
|
|
||||||
|
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
|
||||||
|
Returns True if the target dropped.
|
||||||
|
"""
|
||||||
|
self._aoo_used.add(attacker.combatant.id)
|
||||||
|
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
|
||||||
|
if weapon is None:
|
||||||
|
return False
|
||||||
|
return self._resolve_swing(
|
||||||
|
attacker, target, weapon, label="AoO"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
||||||
|
"""Check for ranged-AoO: firing a ranged weapon in a threatened square provokes.
|
||||||
|
|
||||||
|
Returns True if the attacker dropped (caller must stop attacking).
|
||||||
|
"""
|
||||||
|
if weapon.kind != "ranged":
|
||||||
|
return False
|
||||||
|
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:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
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"):
|
||||||
@@ -429,6 +490,10 @@ class CombatEngine:
|
|||||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||||
) -> None:
|
) -> None:
|
||||||
for swing in range(1, weapon.count + 1):
|
for swing in range(1, weapon.count + 1):
|
||||||
|
if not attacker.active:
|
||||||
|
return
|
||||||
|
if self._check_provoked_aoo(attacker, weapon):
|
||||||
|
return
|
||||||
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
|
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
|
||||||
if self._resolve_swing(attacker, target, weapon, label=label):
|
if self._resolve_swing(attacker, target, weapon, label=label):
|
||||||
return
|
return
|
||||||
@@ -444,6 +509,10 @@ class CombatEngine:
|
|||||||
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
|
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
|
||||||
)
|
)
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
|
if not attacker.active:
|
||||||
|
return
|
||||||
|
if self._check_provoked_aoo(attacker, weapon):
|
||||||
|
return
|
||||||
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
|
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
|
||||||
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
|
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
|
||||||
if self._resolve_swing(
|
if self._resolve_swing(
|
||||||
@@ -455,9 +524,22 @@ class CombatEngine:
|
|||||||
path = self._move_path(state, target)
|
path = self._move_path(state, target)
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path))
|
start = state.pos
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
|
actual_path: list[Pos] = []
|
||||||
state.pos = path[-1]
|
for step in path:
|
||||||
|
for enemy in self._enemies_threatening(state, state.pos):
|
||||||
|
if enemy.combatant.id in self._aoo_used:
|
||||||
|
continue
|
||||||
|
self._resolve_aoo(enemy, state)
|
||||||
|
if not state.active:
|
||||||
|
break
|
||||||
|
if not state.active:
|
||||||
|
break
|
||||||
|
state.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} {state.combatant.id}: move {coords}")
|
||||||
|
|
||||||
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
|
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
|
||||||
"""Full movement path toward the target, up to the combatant's speed."""
|
"""Full movement path toward the target, up to the combatant's speed."""
|
||||||
|
|||||||
+130
-3
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pf1e_simulator.combat import (
|
from pf1e_simulator.combat import (
|
||||||
|
Action,
|
||||||
CombatantState,
|
CombatantState,
|
||||||
CombatantStats,
|
CombatantStats,
|
||||||
CombatEngine,
|
CombatEngine,
|
||||||
@@ -536,7 +537,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
|||||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||||
target = make_combatant("target", speed=0)
|
target = make_combatant("target", speed=0)
|
||||||
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||||
rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1])
|
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1])
|
||||||
engine = CombatEngine(rng, grid, states, round_cap=4)
|
engine = CombatEngine(rng, grid, states, round_cap=4)
|
||||||
result = engine.run()
|
result = engine.run()
|
||||||
assert result.winner == "players"
|
assert result.winner == "players"
|
||||||
@@ -544,10 +545,13 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
|||||||
"initiative: archer d20=10+0=10",
|
"initiative: archer d20=10+0=10",
|
||||||
"initiative: target d20=9+0=9",
|
"initiative: target d20=9+0=9",
|
||||||
"round 1 archer: move (0,1)->(1,2)->(0,3)",
|
"round 1 archer: move (0,1)->(1,2)->(0,3)",
|
||||||
|
"round 1 target: AoO vs archer d20=5+2=7 AC 13 -> MISS",
|
||||||
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
||||||
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
|
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
|
||||||
|
"round 2 target: AoO vs archer d20=3+2=5 AC 13 -> MISS",
|
||||||
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
|
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
|
||||||
"round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
|
"round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
|
||||||
|
"round 3 target: AoO vs archer d20=2+2=4 AC 13 -> MISS",
|
||||||
"round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
|
"round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
|
||||||
"round 3 archer: target down",
|
"round 3 archer: target down",
|
||||||
"battle over: players win in 3 rounds",
|
"battle over: players win in 3 rounds",
|
||||||
@@ -775,7 +779,7 @@ def test_no_flanking_without_opposite_ally() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ranged_attack_ignores_flanking() -> None:
|
def test_ranged_attack_ignores_flanking() -> None:
|
||||||
"""Ranged attacks get no flanking bonus, and ranged allies don't threaten."""
|
"""Ranged attacks get no flanking bonus; ranged in melee provokes AoO."""
|
||||||
arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0)
|
arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0)
|
||||||
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
||||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||||
@@ -784,12 +788,13 @@ def test_ranged_attack_ignores_flanking() -> None:
|
|||||||
make_state(aly, "players", (5, 4)),
|
make_state(aly, "players", (5, 4)),
|
||||||
make_state(tgt, "monsters", (4, 4)),
|
make_state(tgt, "monsters", (4, 4)),
|
||||||
]
|
]
|
||||||
engine = make_engine([10, 9, 8, 12, 10, 5], states, round_cap=1)
|
engine = make_engine([10, 9, 8, 15, 3, 12, 10, 5], states, round_cap=1)
|
||||||
result = engine.run()
|
result = engine.run()
|
||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: arc d20=10+0=10",
|
"initiative: arc d20=10+0=10",
|
||||||
"initiative: aly d20=9+0=9",
|
"initiative: aly d20=9+0=9",
|
||||||
"initiative: tgt d20=8+0=8",
|
"initiative: tgt d20=8+0=8",
|
||||||
|
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
|
||||||
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS",
|
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS",
|
||||||
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
|
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
|
||||||
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
|
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
|
||||||
@@ -949,3 +954,125 @@ def test_full_attack_low_bab_single_swing() -> None:
|
|||||||
"round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS",
|
"round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS",
|
||||||
"battle over: draw after 1 rounds",
|
"battle over: draw after 1 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Attacks of opportunity (CRB: movement + ranged-in-melee, 1/round) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
"""Test policy: always move toward the 'dest' combatant."""
|
||||||
|
return (Action(kind="move", target_id="dest"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_movement_provokes_aoo() -> None:
|
||||||
|
"""Moving through a threatened square provokes an AoO; movement continues after."""
|
||||||
|
runner = make_combatant("runner", hp=10, ac=12, speed=30)
|
||||||
|
guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0)
|
||||||
|
dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0)
|
||||||
|
states = [
|
||||||
|
make_state(runner, "players", (4, 1)),
|
||||||
|
make_state(guard, "monsters", (3, 1)),
|
||||||
|
make_state(dest, "monsters", (4, 5)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: runner d20=10+0=10",
|
||||||
|
"initiative: guard d20=9+0=9",
|
||||||
|
"initiative: dest d20=8+0=8",
|
||||||
|
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (10->7)",
|
||||||
|
"round 1 runner: move (4,1)->(3,2)->(3,3)->(3,4)",
|
||||||
|
"round 1 guard: wait",
|
||||||
|
"round 1 dest: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aoo_drops_mover() -> None:
|
||||||
|
"""If an AoO drops the mover, movement stops and the mover's side loses."""
|
||||||
|
runner = make_combatant("runner", hp=3, ac=12, speed=30)
|
||||||
|
guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0)
|
||||||
|
dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0)
|
||||||
|
states = [
|
||||||
|
make_state(runner, "players", (4, 1)),
|
||||||
|
make_state(guard, "monsters", (3, 1)),
|
||||||
|
make_state(dest, "monsters", (4, 5)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 8, 15, 3]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.winner == "monsters"
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: runner d20=10+0=10",
|
||||||
|
"initiative: guard d20=9+0=9",
|
||||||
|
"initiative: dest d20=8+0=8",
|
||||||
|
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)",
|
||||||
|
"round 1 guard: runner down",
|
||||||
|
"battle over: monsters win in 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aoo_limit_one_per_round() -> None:
|
||||||
|
"""A combatant can only make one AoO per round, even if multiple squares are threatened."""
|
||||||
|
runner = make_combatant("runner", hp=20, ac=12, speed=30)
|
||||||
|
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||||
|
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
|
||||||
|
states = [
|
||||||
|
make_state(runner, "players", (0, 0)),
|
||||||
|
make_state(guard, "monsters", (2, 2)),
|
||||||
|
make_state(dest, "monsters", (4, 4)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 8, 15, 3]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: runner d20=10+0=10",
|
||||||
|
"initiative: guard d20=9+0=9",
|
||||||
|
"initiative: dest d20=8+0=8",
|
||||||
|
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
||||||
|
"round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)",
|
||||||
|
"round 1 guard: wait",
|
||||||
|
"round 1 dest: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_guards_each_one_aoo() -> None:
|
||||||
|
"""Two enemies threatening the same square each get their own AoO (1/round each)."""
|
||||||
|
runner = make_combatant("runner", hp=20, ac=12, speed=30)
|
||||||
|
g1 = make_combatant("g1", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||||
|
g2 = make_combatant("g2", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||||
|
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
|
||||||
|
states = [
|
||||||
|
make_state(runner, "players", (4, 1)),
|
||||||
|
make_state(g1, "monsters", (3, 1)),
|
||||||
|
make_state(g2, "monsters", (3, 2)),
|
||||||
|
make_state(dest, "monsters", (4, 6)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 8, 7, 15, 3, 15, 3]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: runner d20=10+0=10",
|
||||||
|
"initiative: g1 d20=9+0=9",
|
||||||
|
"initiative: g2 d20=8+0=8",
|
||||||
|
"initiative: dest d20=7+0=7",
|
||||||
|
"round 1 g1: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
||||||
|
"round 1 g2: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (17->14)",
|
||||||
|
"round 1 runner: move (4,1)->(4,2)->(3,3)->(3,4)->(3,5)",
|
||||||
|
"round 1 g1: wait",
|
||||||
|
"round 1 g2: wait",
|
||||||
|
"round 1 dest: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user