feat(combat): add 5-foot step (free action, no AoO, mutual exclusion with move)
This commit is contained in:
@@ -269,12 +269,10 @@ Règles modélisées :
|
|||||||
- Économie d'action : un tour normal donne une action standard + une action de
|
- Économie d'action : un tour normal donne une action standard + une action de
|
||||||
mouvement (ou une action à round complet), plus swift/free/immediate. La
|
mouvement (ou une action à round complet), plus swift/free/immediate. La
|
||||||
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
|
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
|
||||||
`default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est
|
`default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)`
|
||||||
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra,
|
si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas
|
||||||
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions
|
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
|
||||||
immédiates (hors-tour, consomment le prochain swift) ne sont pas encore
|
(hors-tour, consomment le prochain swift) ne sont pas encore modélisées.
|
||||||
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,7 +304,7 @@ 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, à venir) évite les AoO de mouvement ; la retraite ne
|
placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne
|
||||||
protège que la case de départ (voir ci-dessous).
|
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
|
||||||
@@ -322,6 +320,12 @@ Règles modélisées :
|
|||||||
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
|
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
|
||||||
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
|
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
|
||||||
une politique personnalisée).
|
une politique personnalisée).
|
||||||
|
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
|
||||||
|
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
|
||||||
|
avec tout autre mouvement (move, charge, retraite) dans le même tour —
|
||||||
|
tracked via `moved_this_turn`, réinitialisé au début du tour. `default_policy`
|
||||||
|
choisit `(5foot_step, full_attack)` quand un seul pas suffit à entrer en
|
||||||
|
porté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
|
||||||
@@ -333,7 +337,7 @@ 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, pas de placement (5 ft)
|
- Tailles Large+ (2×2), allonge > 5 ft
|
||||||
et actions immédiates hors-tour.
|
et actions immédiates hors-tour.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -368,7 +372,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 # 216 tests
|
uv run pytest -q # 219 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
|
||||||
```
|
```
|
||||||
@@ -383,7 +387,8 @@ 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, attaques d'opportunité, charge et retraite sont déjà modélisés.
|
outrance, attaques d'opportunité, charge, retraite et pas de placement 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
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
(or one full-round action), plus swift/free/immediate. The policy returns an
|
(or one full-round action), plus swift/free/immediate. The policy returns an
|
||||||
ordered sequence of `Action` per turn; the engine executes them in order,
|
ordered sequence of `Action` per turn; the engine executes them in order,
|
||||||
stopping early if the actor drops or one side is wiped. `default_policy`
|
stopping early if the actor drops or one side is wiped. `default_policy`
|
||||||
returns `(move, attack)` when the nearest enemy is out of reach (move at
|
returns `(full_attack,)` when already in range, `(charge,)` when a
|
||||||
full speed along the Dijkstra path, then attack if a weapon is usable) and
|
straight-line charge path exists, `(5foot_step, full_attack)` when a single
|
||||||
`(attack,)` when already in range. The move action may replace the standard
|
step brings the target into range, or `(move, attack)` otherwise (move at
|
||||||
action (e.g. a double move) but the default policy does not need this.
|
full speed along the Dijkstra path, then single attack). Immediate actions
|
||||||
Immediate actions (off-turn, consume next swift) are deferred; flanking,
|
(off-turn, consume next swift) are deferred; flanking, full attack, charge,
|
||||||
full attack, charge, and withdraw are modeled; attacks of opportunity are
|
withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
|
||||||
resolved inline (see below).
|
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,8 +44,10 @@ 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 (deferred) avoids AoOs from movement; the withdraw action
|
5-foot step never provokes AoOs (it is not a move action); the withdraw
|
||||||
protects only the starting square (see below).
|
action protects only the starting square (see below). A 5-foot step and
|
||||||
|
any other movement (move, charge, withdraw) are mutually exclusive within
|
||||||
|
the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
|
||||||
- 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
|
||||||
@@ -60,6 +62,12 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
move). The path is a greedy ascent of the Dijkstra cost field from the
|
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
|
threat. ``default_policy`` does not choose withdraw (it is only available
|
||||||
via a custom policy).
|
via a custom policy).
|
||||||
|
- 5-foot step: a free action that moves one square toward the nearest enemy
|
||||||
|
without provoking AoOs. Mutually exclusive with any other movement (move,
|
||||||
|
charge, withdraw) in the same turn — tracked via ``moved_this_turn``,
|
||||||
|
cleared at the start of each turn in ``_take_turn``. ``default_policy``
|
||||||
|
uses ``(5foot_step, full_attack)`` when a single step brings the target
|
||||||
|
into weapon range.
|
||||||
- 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.
|
||||||
@@ -149,6 +157,7 @@ class CombatantState:
|
|||||||
pos: Pos
|
pos: Pos
|
||||||
hp: int
|
hp: int
|
||||||
ac_penalty: int = 0
|
ac_penalty: int = 0
|
||||||
|
moved_this_turn: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
@@ -186,14 +195,15 @@ class Action:
|
|||||||
(single attack, highest BAB only — no iteratives), ``move`` is a move
|
(single attack, highest BAB only — no iteratives), ``move`` is a move
|
||||||
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
|
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
|
||||||
is a special full-round action (2x speed, straight line, +2 attack, -2
|
is a special full-round action (2x speed, straight line, +2 attack, -2
|
||||||
AC), ``swift`` and ``free`` are minor actions, ``immediate`` is an
|
AC), ``5foot_step`` is a free action (one square, no AoO, mutually
|
||||||
off-turn reaction (deferred). ``wait`` is a no-op. The engine executes a
|
exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor
|
||||||
policy-returned sequence per turn.
|
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` 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", "charge", "withdraw",
|
"full_attack", "charge", "withdraw", "5foot_step",
|
||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
|
|
||||||
@@ -241,6 +251,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
|
state.ac_penalty = 0
|
||||||
|
state.moved_this_turn = False
|
||||||
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:
|
||||||
@@ -463,6 +474,17 @@ class CombatEngine:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool:
|
||||||
|
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
|
||||||
|
path = self._move_path(state, target)
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
orig = state.pos
|
||||||
|
state.pos = path[0]
|
||||||
|
weapon = self.weapon_for(state, target)
|
||||||
|
state.pos = orig
|
||||||
|
return weapon is not None
|
||||||
|
|
||||||
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
|
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
|
||||||
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
|
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
|
||||||
|
|
||||||
@@ -591,6 +613,7 @@ class CombatEngine:
|
|||||||
attacker.pos = step
|
attacker.pos = step
|
||||||
actual_path.append(step)
|
actual_path.append(step)
|
||||||
if actual_path:
|
if actual_path:
|
||||||
|
attacker.moved_this_turn = True
|
||||||
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
|
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
|
||||||
@@ -611,6 +634,10 @@ class CombatEngine:
|
|||||||
self._execute_charge(state, target)
|
self._execute_charge(state, target)
|
||||||
elif action.kind == "withdraw":
|
elif action.kind == "withdraw":
|
||||||
self._withdraw(state, target)
|
self._withdraw(state, target)
|
||||||
|
elif action.kind == "5foot_step":
|
||||||
|
if target is None:
|
||||||
|
return
|
||||||
|
self._step5(state, target)
|
||||||
elif action.kind == "move":
|
elif action.kind == "move":
|
||||||
if target is None:
|
if target is None:
|
||||||
return
|
return
|
||||||
@@ -732,7 +759,23 @@ class CombatEngine:
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _step5(self, state: CombatantState, target: CombatantState) -> None:
|
||||||
|
if state.moved_this_turn:
|
||||||
|
return
|
||||||
|
path = self._move_path(state, target)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
start = state.pos
|
||||||
|
state.pos = path[0]
|
||||||
|
state.moved_this_turn = True
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
|
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
|
||||||
|
)
|
||||||
|
|
||||||
def _move(self, state: CombatantState, target: CombatantState) -> None:
|
def _move(self, state: CombatantState, target: CombatantState) -> None:
|
||||||
|
if state.moved_this_turn:
|
||||||
|
return
|
||||||
path = self._move_path(state, target)
|
path = self._move_path(state, target)
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
@@ -750,6 +793,7 @@ class CombatEngine:
|
|||||||
state.pos = step
|
state.pos = step
|
||||||
actual_path.append(step)
|
actual_path.append(step)
|
||||||
if actual_path:
|
if actual_path:
|
||||||
|
state.moved_this_turn = True
|
||||||
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *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}")
|
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
|
||||||
|
|
||||||
@@ -869,6 +913,7 @@ class CombatEngine:
|
|||||||
state.pos = step
|
state.pos = step
|
||||||
actual_path.append(step)
|
actual_path.append(step)
|
||||||
if actual_path:
|
if actual_path:
|
||||||
|
state.moved_this_turn = True
|
||||||
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
|
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
|
||||||
@@ -882,13 +927,14 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|||||||
|
|
||||||
|
|
||||||
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
"""Standard-attack the nearest enemy in range; charge if reachable; otherwise approach.
|
"""Full-attack the nearest enemy; charge, 5ft-step, or approach if out of range.
|
||||||
|
|
||||||
Decision order:
|
Decision order:
|
||||||
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
||||||
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
||||||
3. Move toward the target then single attack (standard + move economy).
|
3. 5-foot step then full-attack if a single step brings the target into weapon range.
|
||||||
4. Wait if nothing is possible.
|
4. Move toward the target then single attack (standard + move economy).
|
||||||
|
5. Wait if nothing is possible.
|
||||||
"""
|
"""
|
||||||
target = engine.nearest_enemy(state)
|
target = engine.nearest_enemy(state)
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -898,6 +944,11 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
|
|||||||
charge_weapon = engine.can_charge(state, target)
|
charge_weapon = engine.can_charge(state, target)
|
||||||
if charge_weapon is not None:
|
if charge_weapon is not None:
|
||||||
return (Action(kind="charge", target_id=target.combatant.id),)
|
return (Action(kind="charge", target_id=target.combatant.id),)
|
||||||
|
if engine.can_step5_to_attack(state, target):
|
||||||
|
return (
|
||||||
|
Action(kind="5foot_step", target_id=target.combatant.id),
|
||||||
|
Action(kind="full_attack", 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="attack", target_id=target.combatant.id),
|
Action(kind="attack", target_id=target.combatant.id),
|
||||||
|
|||||||
+108
-13
@@ -14,6 +14,7 @@ from pf1e_simulator.combat import (
|
|||||||
CombatantStats,
|
CombatantStats,
|
||||||
CombatEngine,
|
CombatEngine,
|
||||||
CombatResult,
|
CombatResult,
|
||||||
|
default_policy,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.dice import parse_dice
|
from pf1e_simulator.dice import parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
@@ -527,7 +528,7 @@ def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||||
"""An archer without line of effect moves around the wall at full speed, then shoots."""
|
"""An archer without line of effect 5ft-steps around the wall, then shoots from safety."""
|
||||||
legend = {
|
legend = {
|
||||||
".": TerrainType(type="floor", move_cost=1),
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
@@ -544,15 +545,12 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
|||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"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: 5ft step (0,1)->(1,2)",
|
||||||
"round 1 target: AoO vs archer d20=5+2=7 AC 13 -> MISS",
|
"round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
|
||||||
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
"round 1 target: wait",
|
||||||
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
|
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
||||||
"round 2 target: AoO vs archer d20=3+2=5 AC 13 -> MISS",
|
"round 2 target: wait",
|
||||||
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
|
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 3 damage (3->0)",
|
||||||
"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: target down",
|
"round 3 archer: target down",
|
||||||
"battle over: players win in 3 rounds",
|
"battle over: players win in 3 rounds",
|
||||||
)
|
)
|
||||||
@@ -656,7 +654,7 @@ def test_ranged_weapon_usable_at_maximum_range() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
|
def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
|
||||||
"""Beyond 10 range increments the weapon is dropped and the policy moves."""
|
"""Beyond 10 range increments the weapon is dropped and the policy 5ft-steps into range."""
|
||||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend)
|
spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend)
|
||||||
grid = Grid.from_spec(spec)
|
grid = Grid.from_spec(spec)
|
||||||
@@ -669,8 +667,8 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
|
|||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"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,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)->(0,6)",
|
"round 1 archer: 5ft step (0,0)->(0,1)",
|
||||||
"round 1 archer: short bow vs target d20=8+4-14=-2 AC 13 -> MISS",
|
"round 1 archer: short bow vs target d20=8+4-18=-6 AC 13 -> MISS",
|
||||||
"round 1 target: wait",
|
"round 1 target: wait",
|
||||||
"battle over: draw after 1 rounds",
|
"battle over: draw after 1 rounds",
|
||||||
)
|
)
|
||||||
@@ -1236,3 +1234,100 @@ def test_move_attack_single_swing() -> None:
|
|||||||
"round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS",
|
"round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS",
|
||||||
"battle over: draw after 1 rounds",
|
"battle over: draw after 1 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 5-foot step (free action, no AoO, mutually exclusive with move) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _step5_orc2(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "gob":
|
||||||
|
return (
|
||||||
|
Action(kind="5foot_step", target_id="orc2"),
|
||||||
|
Action(kind="full_attack", target_id="orc2"),
|
||||||
|
)
|
||||||
|
return default_policy(engine, state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_5ft_step_no_aoo() -> None:
|
||||||
|
"""A 5-foot step leaves a threatened square without provoking any AoO."""
|
||||||
|
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10)
|
||||||
|
orc1 = make_combatant("orc1", hp=20, ac=15, attack_bonus=2, speed=0)
|
||||||
|
orc2 = make_combatant("orc2", hp=20, ac=15, attack_bonus=2, speed=0)
|
||||||
|
states = [
|
||||||
|
make_state(gob, "players", (1, 0)),
|
||||||
|
make_state(orc1, "monsters", (0, 0)),
|
||||||
|
make_state(orc2, "monsters", (1, 2)),
|
||||||
|
]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([20, 1, 1, 10, 3, 15, 2]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_step5_orc2,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=20+10=30",
|
||||||
|
"initiative: orc2 d20=1+0=1",
|
||||||
|
"initiative: orc1 d20=1+0=1",
|
||||||
|
"round 1 gob: 5ft step (1,0)->(0,1)",
|
||||||
|
"round 1 gob: short sword vs orc2 d20=10+2=12 AC 15 -> MISS",
|
||||||
|
"round 1 orc2: short sword vs gob d20=3+2=5 AC 15 -> MISS",
|
||||||
|
"round 1 orc1: short sword vs gob d20=15+2=17 AC 15 -> HIT 2 damage (20->18)",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _move_then_step5(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "gob":
|
||||||
|
return (
|
||||||
|
Action(kind="move", target_id="orc"),
|
||||||
|
Action(kind="5foot_step", target_id="orc"),
|
||||||
|
)
|
||||||
|
return default_policy(engine, state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_blocks_5ft_step() -> None:
|
||||||
|
"""A move action sets moved_this_turn, blocking any 5-foot step afterward."""
|
||||||
|
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10)
|
||||||
|
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0)
|
||||||
|
states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (0, 6))]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([20, 1, 10, 3]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_move_then_step5,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=20+10=30",
|
||||||
|
"initiative: orc d20=1+0=1",
|
||||||
|
"round 1 gob: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)",
|
||||||
|
"round 1 orc: short sword vs gob d20=10+2=12 AC 15 -> MISS",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _step5_then_move(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "gob":
|
||||||
|
return (
|
||||||
|
Action(kind="5foot_step", target_id="orc"),
|
||||||
|
Action(kind="move", target_id="orc"),
|
||||||
|
)
|
||||||
|
return default_policy(engine, state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_5ft_step_blocks_move() -> None:
|
||||||
|
"""A 5-foot step sets moved_this_turn, blocking any move action afterward."""
|
||||||
|
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10)
|
||||||
|
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0)
|
||||||
|
states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (0, 6))]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([20, 1]),
|
||||||
|
make_grid(), states, round_cap=1, policy=_step5_then_move,
|
||||||
|
)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: gob d20=20+10=30",
|
||||||
|
"initiative: orc d20=1+0=1",
|
||||||
|
"round 1 gob: 5ft step (0,0)->(0,1)",
|
||||||
|
"round 1 orc: wait",
|
||||||
|
"battle over: draw after 1 rounds",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user