feat(combat): add withdraw action (double move with AoO protection on first square)
This commit is contained in:
@@ -272,10 +272,9 @@ Règles modélisées :
|
|||||||
`default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est
|
`default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est
|
||||||
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) ne sont pas encore
|
||||||
spéciales (retraite) ne sont pas encore modélisées. `default_policy`
|
modélisées. `default_policy` choisit la charge quand l'ennemi est hors de
|
||||||
choisit la charge quand l'ennemi est hors de portée mais joignable par
|
portée mais joignable par une ligne droite (voir ci-dessous).
|
||||||
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
|
||||||
@@ -307,7 +306,8 @@ Règles modélisées :
|
|||||||
porte une AoO avant chaque tir. Une AoO par combattant par round (piste
|
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
|
`_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, à venir) évite les AoO de mouvement ; la retraite ne
|
||||||
|
protège que la case de départ (voir ci-dessous).
|
||||||
- Charge : action à round complet. Le combattant se déplace en ligne droite
|
- 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
|
(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
|
frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La
|
||||||
@@ -315,6 +315,13 @@ Règles modélisées :
|
|||||||
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
|
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
|
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.
|
charge quand l'ennemi est hors de portée mais joignable en ligne droite.
|
||||||
|
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
|
||||||
|
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
|
||||||
|
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
|
||||||
|
considérée comme menacée — aucune AoO en la quittant. Les cases
|
||||||
|
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
|
||||||
|
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
|
||||||
|
une politique personnalisée).
|
||||||
- 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
|
||||||
@@ -326,8 +333,8 @@ appliquées dans la résolution) :
|
|||||||
- Sorts, jets de sauvegarde, conditions et états.
|
- Sorts, jets de sauvegarde, conditions et états.
|
||||||
- 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, pas de placement (5 ft)
|
||||||
(pas de placement, retraite) et actions immédiates hors-tour.
|
et actions immédiates hors-tour.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -361,7 +368,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 # 213 tests
|
uv run pytest -q # 215 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
|
||||||
```
|
```
|
||||||
@@ -376,7 +383,7 @@ uv run basedpyright src # mode strict
|
|||||||
|
|
||||||
- **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, manœuvres de combat. Flanquement, attaques à
|
effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à
|
||||||
outrance et attaques d'opportunité sont déjà modélisés.
|
outrance, attaques d'opportunité, charge et retraite 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
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
full speed along the Dijkstra path, then attack if a weapon is usable) and
|
full speed along the Dijkstra path, then attack if a weapon is usable) and
|
||||||
`(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) are deferred; flanking,
|
||||||
(withdraw) are deferred; flanking, full attack, and charge are modeled;
|
full attack, charge, and withdraw are modeled; attacks of opportunity are
|
||||||
attacks of opportunity are resolved inline (see below).
|
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
|
||||||
@@ -44,7 +44,8 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
threatening enemy gets one AoO before each ranged swing. One AoO per
|
threatening enemy gets one AoO before each ranged swing. One AoO per
|
||||||
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 (deferred) avoids AoOs from movement; the withdraw action
|
||||||
|
protects only the starting square (see below).
|
||||||
- Charge: a full-round action that moves in a straight line (Bresenham) up to
|
- 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
|
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
|
target, then makes a single melee attack at +2. The charger takes -2 AC
|
||||||
@@ -53,6 +54,12 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy``
|
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
|
chooses a charge when the enemy is out of reach but a valid charge path
|
||||||
exists.
|
exists.
|
||||||
|
- Withdraw: a full-round action that moves up to 2x speed away from the
|
||||||
|
nearest enemy. The starting square is not threatened — no AoO when leaving
|
||||||
|
it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for
|
||||||
|
move). The path is a greedy ascent of the Dijkstra cost field from the
|
||||||
|
threat. ``default_policy`` does not choose withdraw (it is only available
|
||||||
|
via a custom policy).
|
||||||
- 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.
|
||||||
@@ -185,7 +192,7 @@ class Action:
|
|||||||
|
|
||||||
kind: Literal[
|
kind: Literal[
|
||||||
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
"full_attack", "charge",
|
"full_attack", "charge", "withdraw",
|
||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
|
|
||||||
@@ -601,6 +608,8 @@ class CombatEngine:
|
|||||||
self._execute_attack(state, target, action.kind)
|
self._execute_attack(state, target, action.kind)
|
||||||
elif action.kind == "charge":
|
elif action.kind == "charge":
|
||||||
self._execute_charge(state, target)
|
self._execute_charge(state, target)
|
||||||
|
elif action.kind == "withdraw":
|
||||||
|
self._withdraw(state, target)
|
||||||
elif action.kind == "move":
|
elif action.kind == "move":
|
||||||
if target is None:
|
if target is None:
|
||||||
return
|
return
|
||||||
@@ -768,6 +777,91 @@ class CombatEngine:
|
|||||||
budget -= best[1]
|
budget -= best[1]
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
||||||
|
"""Movement path away from ``threat``, up to double speed.
|
||||||
|
|
||||||
|
Uses the Dijkstra cost field from the threat and greedily picks the
|
||||||
|
step that maximizes distance (cost) from the threat each cell.
|
||||||
|
"""
|
||||||
|
max_cells = 2 * (state.combatant.speed_land_ft // _SQUARE_FT)
|
||||||
|
if max_cells <= 0:
|
||||||
|
return []
|
||||||
|
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
|
||||||
|
from_threat = self._grid.reachable(threat.pos, None, blocked)
|
||||||
|
if state.pos not in from_threat:
|
||||||
|
return []
|
||||||
|
path: list[Pos] = []
|
||||||
|
current = state.pos
|
||||||
|
budget = max_cells
|
||||||
|
while budget > 0:
|
||||||
|
best = self._best_flee_step(current, from_threat, blocked, budget)
|
||||||
|
if best is None:
|
||||||
|
break
|
||||||
|
_, step_cost, nxt = best
|
||||||
|
current = nxt
|
||||||
|
path.append(current)
|
||||||
|
budget -= step_cost
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _best_flee_step(
|
||||||
|
self,
|
||||||
|
current: Pos,
|
||||||
|
from_threat: dict[Pos, int],
|
||||||
|
blocked: frozenset[Pos],
|
||||||
|
budget: int,
|
||||||
|
) -> tuple[int, int, Pos] | None:
|
||||||
|
"""Pick the adjacent square that maximizes distance from the threat."""
|
||||||
|
current_cost = from_threat[current]
|
||||||
|
best: tuple[int, int, Pos] | None = None
|
||||||
|
row, col = current
|
||||||
|
for d_row, d_col in _STEP_DELTAS:
|
||||||
|
nxt = (row + d_row, col + d_col)
|
||||||
|
if nxt in blocked or not self._grid.passable(nxt):
|
||||||
|
continue
|
||||||
|
if not self._grid.diagonal_allowed(current, nxt):
|
||||||
|
continue
|
||||||
|
nxt_cost = from_threat.get(nxt)
|
||||||
|
if nxt_cost is None or nxt_cost <= current_cost:
|
||||||
|
continue
|
||||||
|
step = self._grid.step_cost(current, nxt, 0)
|
||||||
|
if step > budget:
|
||||||
|
continue
|
||||||
|
score = -(nxt_cost * 100) + step
|
||||||
|
if best is None or score < best[0]:
|
||||||
|
best = (score, step, nxt)
|
||||||
|
return best
|
||||||
|
|
||||||
|
def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None:
|
||||||
|
"""Full-round action: move up to 2x speed away from the nearest enemy.
|
||||||
|
|
||||||
|
The starting square is not threatened — no AoO when leaving it.
|
||||||
|
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
||||||
|
"""
|
||||||
|
if threat is None:
|
||||||
|
return
|
||||||
|
path = self._flee_path(state, threat)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
start = state.pos
|
||||||
|
actual_path: list[Pos] = []
|
||||||
|
for i, step in enumerate(path):
|
||||||
|
if i > 0:
|
||||||
|
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}: withdraw {coords}"
|
||||||
|
)
|
||||||
|
|
||||||
def _log(self, line: str) -> None:
|
def _log(self, line: str) -> None:
|
||||||
self._transcript.append(line)
|
self._transcript.append(line)
|
||||||
|
|
||||||
|
|||||||
@@ -1145,3 +1145,60 @@ def test_charge_too_close_full_attacks_instead() -> None:
|
|||||||
"round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS",
|
"round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS",
|
||||||
"battle over: draw after 1 rounds",
|
"battle over: draw after 1 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Withdraw (CRB: double move, first square protected, subsequent provoke) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _withdraw_from_foe(
|
||||||
|
engine: CombatEngine, state: CombatantState
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
|
return (Action(kind="withdraw", target_id="foe"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_protects_first_square() -> None:
|
||||||
|
"""Withdraw moves 2x speed away; the starting square does not provoke AoO."""
|
||||||
|
flee = make_combatant("flee", hp=20, ac=15, attack_bonus=0, speed=30)
|
||||||
|
foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||||
|
states = [make_state(flee, "players", (4, 1)), make_state(foe, "monsters", (3, 1))]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 15, 3]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_withdraw_from_foe,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: flee d20=10+0=10",
|
||||||
|
"initiative: foe d20=9+0=9",
|
||||||
|
"round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,0)",
|
||||||
|
"round 1 foe: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_provokes_after_first_square() -> None:
|
||||||
|
"""Subsequent squares during withdraw provoke AoOs from other enemies."""
|
||||||
|
flee = make_combatant("flee", hp=20, ac=12, attack_bonus=0, speed=30)
|
||||||
|
foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||||
|
foe2 = make_combatant("foe2", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||||
|
states = [
|
||||||
|
make_state(flee, "players", (4, 1)),
|
||||||
|
make_state(foe, "monsters", (3, 1)),
|
||||||
|
make_state(foe2, "monsters", (6, 1)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_withdraw_from_foe,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: flee d20=10+0=10",
|
||||||
|
"initiative: foe d20=9+0=9",
|
||||||
|
"initiative: foe2 d20=8+0=8",
|
||||||
|
"round 1 foe2: AoO vs flee d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
||||||
|
"round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,1)",
|
||||||
|
"round 1 foe: wait",
|
||||||
|
"round 1 foe2: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user