feat(combat): add aid another and run actions

This commit is contained in:
2026-09-10 16:56:38 +02:00
parent 06555f7b49
commit f637f87a8c
3 changed files with 743 additions and 23 deletions
+39 -2
View File
@@ -355,6 +355,22 @@ Règles modélisées :
présente, l'action est rejetée (`already_prone`). Le kind ne requiert pas de présente, l'action est rejetée (`already_prone`). Le kind ne requiert pas de
cible. `default_policy` ne la choisit pas (disponible via une politique cible. `default_policy` ne la choisit pas (disponible via une politique
personnalisée). personnalisée).
- Aider autrui (`Action(kind="aid_another", aid_mode=...)`, action standard,
U5e) : l'aidant **doit avoir une arme de mêlée utilisable** ; il fait un jet
d'attaque `d20 + bonus d'attaque de sa première arme de mêlée utilisable`
contre le CA 10 (20 naturel réussit toujours, 1 naturel échoue toujours —
convention des jets d'attaque du moteur). Sur succès, la cible empile **+2**
soit à son prochain jet d'attaque (`aid_mode="attack"` ou absent — consommé
one-shot par `resolve_attack`, attaque et confirmation de critique), soit à
sa CA contre la prochaine attaque la ciblant (`aid_mode="ac"`). Plusieurs
aides s'additionnent (le bonus s'ajoute, ne se remplace pas) ; tout bonus
inutilisé expire au début du prochain tour du bénéficiaire. Simplifications
documentées : pas de vérification d'adjacence/ligne d'effet vers l'allié,
toute cible résolue peut être aidée, et la progression de compétence d'aide
(+5 avec 5 rangs, +8 avec 10) n'est pas modélisée. Sans cible →
`missing_target` ; sans arme de mêlée utilisable → `no_weapon`.
`default_policy` ne la choisit pas (disponible via une politique
personnalisée).
- Retour d'exécution des actions : chaque action produit un `ActionResult` - Retour d'exécution des actions : chaque action produit un `ActionResult`
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results` explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
au tour suivant). Les actions invalides sont rejetées et journalisées au lieu au tour suivant). Les actions invalides sont rejetées et journalisées au lieu
@@ -370,7 +386,10 @@ Règles modélisées :
reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable →
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos` `ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"` hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"`
(U7b). (U7b) ; course ou aide sans cible **et** sans destination →
`missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ;
course vers une `destination` hors du budget 4× →
`unreachable_destination` (U5e).
- 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
@@ -470,6 +489,18 @@ 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).
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en
réutilisant exactement la mécanique de `move` (descente du champ de coût
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des
AoO). À l'achèvement, le combattant est `running` jusqu'au début de son
prochain tour : il **perd son bonus de Dextérité à la CA** (résolu par le
même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
`destination` hors budget 4× (ou invalide) est rejetée
(`unreachable_destination`) ; sans cible ni destination, rejet
(`missing_target`). `default_policy` ne choisit pas la course (disponible
via une politique personnalisée).
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une - 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 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 — avec tout autre mouvement (move, charge, retraite) dans le même tour —
@@ -673,7 +704,13 @@ Règles modélisées :
jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining
(int — attaques d'opportunité restantes ce round : `_aoo_allowance` (int — attaques d'opportunité restantes ce round : `_aoo_allowance`
(1 par défaut, 1 + mod de Dex avec Combat Reflexes, plancher 1) moins les (1 par défaut, 1 + mod de Dex avec Combat Reflexes, plancher 1) moins les
AoO déjà consommées, réinitialisé à chaque nouveau round, jamais négatif). AoO déjà consommées, réinitialisé à chaque nouveau round, jamais négatif),
aid_attack_bonus (int — bonus d'aide autrui (+2 par aide réussie, cumulatif)
ajouté au prochain jet d'attaque du combattant puis consommé, expiré au
début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide
autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de
son tour), running (bool — course en cours jusqu'au prochain tour du
combattant : il perd son bonus de Dextérité à la CA).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
+187 -21
View File
@@ -156,6 +156,9 @@ _DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total
_TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC _TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC
_FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC _FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms _FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x land speed
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease", "fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting", "mind_affecting",
@@ -396,6 +399,14 @@ class CombatantState:
# opponents' turns in between. # opponents' turns in between.
defensive_ac_bonus: int = 0 defensive_ac_bonus: int = 0
fight_defensively: bool = False fight_defensively: bool = False
# Aid another & run (not in the frozen data model): aid bonuses granted to
# this combatant by an ally's aid another — aid_attack_bonus is consumed by
# its next attack roll, aid_ac_bonus applies against the next attack that
# targets it. Both (and the running flag, which denies the Dex bonus to AC
# until the runner's next turn) reset at the start of the combatant's turn.
aid_attack_bonus: int = 0
aid_ac_bonus: int = 0
running: bool = False
def usable_attacks(self) -> list[AttackSpec]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -580,7 +591,15 @@ class Action:
outside the map or without line of effect from the caster is rejected outside the map or without line of effect from the caster is rejected
(``invalid_destination``); with ``target_pos`` and no resolved target the (``invalid_destination``); with ``target_pos`` and no resolved target the
spell is cast on the point and every creature in the area rolls its own spell is cast on the point and every creature in the area rolls its own
save. save. ``aid_another`` is a standard action helping a resolved ally: the
aider makes an attack roll (d20 + its first usable melee weapon's bonus)
against AC 10; on success the ally gains +2 on its next attack roll
(``aid_mode`` "attack" or None) or +2 AC against the next attack
targeting it (``aid_mode`` "ac"). ``run`` is a full-round action moving
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
reusing the move machinery (step-by-step, AoO-provoking); it sets
``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC.
""" """
kind: Literal[ kind: Literal[
@@ -590,6 +609,7 @@ class Action:
"inspire_courage", "retrieve_weapon", "repair_weapon", "inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack", "delay", "ready", "activate_power_attack", "delay", "ready",
"total_defense", "fight_defensively", "stand_up", "drop_prone", "total_defense", "fight_defensively", "stand_up", "drop_prone",
"aid_another", "run",
] ]
target_id: str | None = None target_id: str | None = None
spell_name: str | None = None spell_name: str | None = None
@@ -606,6 +626,7 @@ class Action:
ready_trigger: ReadyTrigger | None = None # ready action trigger event ready_trigger: ReadyTrigger | None = None # ready action trigger event
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
@@ -618,7 +639,9 @@ class Action:
# unreachable_destination for move / 5foot_step with an explicit destination. # unreachable_destination for move / 5foot_step with an explicit destination.
# U5d adds weapon_unavailable for attack / full_attack with an explicit # U5d adds weapon_unavailable for attack / full_attack with an explicit
# weapon_id that cannot be honoured. U7b adds invalid_destination for # weapon_id that cannot be honoured. U7b adds invalid_destination for
# cast_spell with an explicit target_pos that cannot be honoured. # cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
# no_weapon (aid another without a usable melee weapon) and
# unreachable_destination (run to an unhonourable destination).
ACTION_RESULT_REASONS: frozenset[str] = frozenset({ ACTION_RESULT_REASONS: frozenset[str] = frozenset({
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
"no_line_of_effect", "no_slot", "insufficient_resource", "no_line_of_effect", "no_slot", "insufficient_resource",
@@ -650,10 +673,11 @@ _REASON_NO_LINE_OF_EFFECT = "no_line_of_effect"
_UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"}) _UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
# Action kinds whose helper is a silent no-op (or worse) without a resolved # Action kinds whose helper is a silent no-op (or worse) without a resolved
# target; _execute rejects them up front with missing_target instead. # target; _execute rejects them up front with missing_target instead.
# move / 5foot_step are exempt when an explicit destination is provided (U5c). # move / 5foot_step / run are exempt when an explicit destination is provided
# (U5c / U5e).
_TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({ _TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({
"attack", "full_attack", "charge", "withdraw", "move", "5foot_step", "attack", "full_attack", "charge", "withdraw", "move", "5foot_step",
"cast_spell", "maneuver", "cast_spell", "maneuver", "aid_another", "run",
}) })
@@ -919,6 +943,9 @@ class CombatEngine:
"power_attack": state.power_attack, "power_attack": state.power_attack,
"defensive_ac_bonus": state.defensive_ac_bonus, "defensive_ac_bonus": state.defensive_ac_bonus,
"fight_defensively": state.fight_defensively, "fight_defensively": state.fight_defensively,
"aid_attack_bonus": state.aid_attack_bonus,
"aid_ac_bonus": state.aid_ac_bonus,
"running": state.running,
"aoo_remaining": max( "aoo_remaining": max(
0, 0,
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0), self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
@@ -1145,6 +1172,9 @@ class CombatEngine:
state.power_attack = False state.power_attack = False
state.defensive_ac_bonus = 0 state.defensive_ac_bonus = 0
state.fight_defensively = False state.fight_defensively = False
state.aid_attack_bonus = 0
state.aid_ac_bonus = 0
state.running = False
original = self._policy(self, state) original = self._policy(self, state)
actions = original actions = original
results: list[ActionResult] = [] results: list[ActionResult] = []
@@ -1689,10 +1719,11 @@ class CombatEngine:
) )
total = ( total = (
roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
+ fim_penalty + fd_penalty + fim_penalty + fd_penalty + attacker.aid_attack_bonus
) )
# Check if target denies DEX to AC (flat-footed) # Check if target denies DEX to AC (flat-footed); a running target
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") # loses its DEX bonus too (U5e), reusing the same AC path.
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") or target.running
full_ac_base = ( full_ac_base = (
target.combatant.ac.flat_footed target.combatant.ac.flat_footed
if denies_dex if denies_dex
@@ -1716,6 +1747,7 @@ class CombatEngine:
base_ac = full_ac_base base_ac = full_ac_base
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac")) ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
ac += target.defensive_ac_bonus ac += target.defensive_ac_bonus
ac += target.aid_ac_bonus
occupied = frozenset( occupied = frozenset(
cell cell
for s in self._states for s in self._states
@@ -1745,6 +1777,7 @@ class CombatEngine:
damage = self._consume_up_close_and_deadly( damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit attacker, target, weapon, dist_ft, damage, hit=hit
) )
attacker.aid_attack_bonus = 0
return AttackResult( return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
@@ -1786,7 +1819,7 @@ class CombatEngine:
confirm = self._rng.d20() confirm = self._rng.d20()
confirm_total = ( confirm_total = (
confirm + base_bonus + penalty + flank + high_ground confirm + base_bonus + penalty + flank + high_ground
+ attack_mod - pa_amt + fim_penalty + attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0) + (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
) )
crit = confirm != _NATURAL_ONE and confirm_total >= ac crit = confirm != _NATURAL_ONE and confirm_total >= ac
@@ -2552,16 +2585,36 @@ class CombatEngine:
rejection = self._target_rejection(state, kind, target, action) rejection = self._target_rejection(state, kind, target, action)
if rejection is not None: if rejection is not None:
return rejection return rejection
direct = self._execute_direct(state, target, action)
if direct is not None:
return direct
override = self._dispatch_action(state, target, action)
return override if override is not None else ActionResult(
kind=kind, ok=True, reason=_REASON_OK
)
def _execute_direct(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult | None:
"""Execute the kinds with a dedicated (non-target-dispatch) helper.
delay/ready (U3/U4), the self-affecting defensive and prone actions
(U5a/U5b) and the aid another / run pair (U5e) each own an explicit
result; None means the action is not one of these kinds and the
caller must fall through to ``_dispatch_action``.
"""
kind = action.kind
if kind == "delay": if kind == "delay":
return self._execute_delay(state, target, action) return self._execute_delay(state, target, action)
if kind == "ready": if kind == "ready":
return self._execute_ready(state, action) return self._execute_ready(state, action)
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"): if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"):
return self._execute_self_action(state, kind) return self._execute_self_action(state, kind)
override = self._dispatch_action(state, target, action) if kind == "aid_another":
return override if override is not None else ActionResult( return self._execute_aid_another(state, target, action)
kind=kind, ok=True, reason=_REASON_OK if kind == "run":
) return self._execute_run(state, target, action)
return None
def _target_rejection( def _target_rejection(
self, self,
@@ -2570,11 +2623,11 @@ class CombatEngine:
target: CombatantState | None, target: CombatantState | None,
action: Action, action: Action,
) -> ActionResult | None: ) -> ActionResult | None:
"""U1 missing-target gate; move/5foot_step accept a destination instead (U5c); """U1 missing-target gate; move/5foot_step/run accept a destination instead
cast_spell accepts a target_pos instead (U7b).""" (U5c / U5e); cast_spell accepts a target_pos instead (U7b)."""
if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS: if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS:
return None return None
if kind in ("move", "5foot_step") and action.destination is not None: if kind in ("move", "5foot_step", "run") and action.destination is not None:
return None return None
if kind == "cast_spell" and action.target_pos is not None: if kind == "cast_spell" and action.target_pos is not None:
return None return None
@@ -2734,6 +2787,97 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: drops prone") self._log(f"round {self._current_round} {state.combatant.id}: drops prone")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _execute_aid_another(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult:
"""Execute the aid another action (PF1e, U5e simplification).
The aider needs a usable melee weapon and makes an attack roll
(d20 + the first usable melee weapon's bonus) against AC 10; natural
20 always succeeds and natural 1 always fails (attack-roll
convention). On success the target stacks +2 on its next attack roll
(``aid_mode`` "attack" or None) or +2 AC against the next attack
targeting it (``aid_mode`` "ac"); multiple aids add up. Documented
simplifications: no adjacency/line-of-effect check on the ally, and
any resolved target may be aided (the aider's side is not enforced).
"""
kind = "aid_another"
if target is None:
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
weapon = next((w for w in state.usable_attacks() if w.kind == "melee"), None)
if weapon is None:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_NO_WEAPON})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_NO_WEAPON)
mode = "ac" if action.aid_mode == "ac" else "attack"
roll = self._rng.d20()
total = roll + weapon.attack_bonus
success = (
roll == _NATURAL_TWENTY
or (roll != _NATURAL_ONE and total >= _AID_ANOTHER_DC)
)
if success:
if mode == "ac":
target.aid_ac_bonus += _AID_ANOTHER_BONUS
else:
target.aid_attack_bonus += _AID_ANOTHER_BONUS
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"aid another {target.combatant.id} ({mode}) "
f"d20={roll}+{weapon.attack_bonus}={total} vs AC {_AID_ANOTHER_DC} "
f"-> {'success' if success else 'fail'}"
)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _execute_run(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult:
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed.
Reuses the move machinery: the Dijkstra descent toward an explicit
destination (U5c) or the resolved target, walked step-by-step with
the usual AoO provocation, under a 4x speed budget. On completion the
combatant is ``running`` until the start of its next turn, which
denies it its Dexterity bonus to AC. An explicit destination that
cannot be honoured within the 4x budget is rejected with
``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``.
"""
kind = "run"
if state.moved_this_turn:
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if action.destination is not None:
reachable = self._destination_reachable(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
)
if not reachable:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
path = self._descend_path(
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
)
self._walk_steps(state, path)
state.moved_this_turn = True
elif target is not None:
path = self._descend_path(
state, target.pos, speed_factor=_RUN_SPEED_FACTOR
)
if self._walk_steps(state, path):
state.moved_this_turn = True
state.running = True
self._log(f"round {self._current_round} {state.combatant.id}: run")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _dispatch_action( def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult | None: ) -> ActionResult | None:
@@ -4569,10 +4713,22 @@ class CombatEngine:
) )
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _destination_reachable(self, state: CombatantState, destination: Pos) -> bool: def _destination_reachable(
"""True when destination is passable, unoccupied and within this speed.""" self,
state: CombatantState,
destination: Pos,
*,
speed_factor: int = 1,
) -> bool:
"""True when destination is passable, unoccupied and within this speed.
``speed_factor`` widens the budget (run: 4x speed, U5e).
"""
speed_cells = int( speed_cells = int(
state.combatant.speed_land_ft // _SQUARE_FT * self._get_speed_mult(state) state.combatant.speed_land_ft
// _SQUARE_FT
* self._get_speed_mult(state)
* speed_factor
) )
if speed_cells <= 0: if speed_cells <= 0:
return destination == state.pos return destination == state.pos
@@ -4606,10 +4762,20 @@ class CombatEngine:
""" """
return self._descend_path(state, target.pos) return self._descend_path(state, target.pos)
def _descend_path(self, state: CombatantState, anchor: Pos) -> list[Pos]: def _descend_path(
"""Greedy walk of the anchor over the cost field from ``anchor``.""" self, state: CombatantState, anchor: Pos, *, speed_factor: int = 1
) -> list[Pos]:
"""Greedy walk of the anchor over the cost field from ``anchor``.
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
"""
speed_mult = self._get_speed_mult(state) speed_mult = self._get_speed_mult(state)
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult) speed_cells = int(
state.combatant.speed_land_ft
// _SQUARE_FT
* speed_mult
* speed_factor
)
if speed_cells <= 0: if speed_cells <= 0:
return [] return []
blocked = self._blocked(state) blocked = self._blocked(state)
+517
View File
@@ -0,0 +1,517 @@
"""The PF1e aid another and run actions (optional Package U5e).
``aid_another`` is a standard action: the aider (which must have a usable
melee weapon) makes an attack roll — d20 + its first usable melee weapon's
bonus — against AC 10 (natural 20 always succeeds, natural 1 always fails);
on success the target stacks +2 on its next attack roll (``aid_mode``
"attack"/None, consumed one-shot by ``resolve_attack``) or +2 AC against the
next attack targeting it (``aid_mode`` "ac"). ``run`` is a full-round action
moving up to 4x speed toward ``target_id`` or an explicit ``destination``,
reusing the move machinery (Dijkstra descent, step-by-step AoO walk); it
sets ``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC (the flat-footed path). Both kinds are
exposed through the U1 feedback contract (``missing_target`` when neither a
target nor a destination is given) and the three runtime fields are additive
snapshot keys. The transcript of a combat that uses neither is unchanged.
"""
from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.combat import (
Action,
ActionResult,
CombatantState,
CombatEngine,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from tests.test_observation_enriched import _snapshot, make_grid, make_state
def _combatant(
id_: str,
*,
initiative_mod: int = 0,
ac: int = 10,
flat_ac: int | None = None,
dex: int = 10,
speed: int = 30,
attack_bonus: int = 5,
ranged: bool = False,
) -> Combatant:
weapon = AttackSpec(
id=f"{id_}-{'bow' if ranged else 'sword'}",
name="short bow" if ranged else "short sword",
kind="ranged" if ranged else "melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"]
if ranged else ["slashing"])],
range_increment_ft=60 if ranged else None,
)
return Combatant(
id=id_,
name=id_,
level=1,
abilities=AbilityScores(
str_score=10, dex_score=dex, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=ac, touch=ac, flat_footed=flat_ac if flat_ac is not None else ac),
bab=1,
initiative_mod=initiative_mod,
speed_land_ft=speed,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=[weapon],
)
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
def _trio(
policy: PolicyFn, rng: list[int], round_cap: int,
) -> CombatEngine:
"""hero (0,0) acts first, ally (3,3) second, foe (0,1) adjacent last."""
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
ally = make_state(_combatant("ally", initiative_mod=1), "players", (3, 3))
foe = make_state(_combatant("foe"), "monsters", (0, 1))
return CombatEngine(
ScriptedRng(rng), make_grid(), [hero, ally, foe],
policy=policy, round_cap=round_cap,
)
def _duo(
policy: PolicyFn, rng: list[int], round_cap: int, *,
hero: CombatantState | None = None,
foe: CombatantState | None = None,
grid: Grid | None = None,
) -> CombatEngine:
hero = hero or make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = foe or make_state(_combatant("foe"), "monsters", (0, 1))
return CombatEngine(
ScriptedRng(rng), grid or make_grid(), [hero, foe],
policy=policy, round_cap=round_cap,
)
def _open_grid(size: int = 16) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(
name="open", terrain=tuple(["." * size] * size), legend=legend,
)
return Grid.from_spec(spec)
# ── (a) aid (attack): +2 on the ally's next attack, consumed one-shot ────────
def test_aid_attack_bonus_grants_two_and_is_consumed() -> None:
calls: dict[str, int] = {}
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero":
if n == 0:
return (Action(
kind="ready", target_id="foe",
ready_trigger="enemy_attacks",
),)
return (Action(kind="attack", target_id="foe"),)
if cid == "ally":
if n == 0:
return (Action(
kind="aid_another", target_id="hero", aid_mode="attack",
),)
return _WAIT
if n == 0:
return (Action(kind="attack", target_id="hero"),)
return _WAIT
# Rolls: 3 initiatives; aid d20=10 (10+5=15 >= 10 success); foe attack
# d20=4 (9 vs AC 10, miss); readied hero attack d20=3 (3+5+2=10, hit) +
# d6=3; round-2 hero attack d20=3 (3+5=8, miss — bonus consumed); the
# ally and the foe both wait in round 2 (no rolls).
eng = _trio(policy, [10, 10, 10, 10, 4, 3, 3, 3], round_cap=2)
t = eng.run().transcript
assert (
"round 1 ally: aid another hero (attack) d20=10+5=15 vs AC 10 -> success"
in t
)
assert "round 1 hero: ready fires (enemy_attacks)" in t
# The readied attack that would miss by 2 (d20 3 + 5 = 8) hits with the +2.
assert (
"round 1 hero: short sword vs foe d20=3+5=10 AC 10 -> HIT 3 damage (100->97)"
in t
)
hero = eng._states[0]
assert hero.aid_attack_bonus == 0 # consumed by the readied attack
# The second attack (round 2, same scripted d20) does not get the bonus.
assert "round 2 hero: short sword vs foe d20=3+5=8 AC 10 -> MISS" in t
assert eng._states[2].hp == 97
assert hero.hp == 100
def test_aid_attack_control_without_aid_misses() -> None:
calls: dict[str, int] = {}
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero" and n == 0:
return (Action(
kind="ready", target_id="foe", ready_trigger="enemy_attacks",
),)
if cid == "foe" and n == 0:
return (Action(kind="attack", target_id="hero"),)
return _WAIT
# Same geometry, no aid: the readied d20=3+5=8 misses AC 10.
eng = _trio(policy, [10, 10, 10, 4, 3], round_cap=1)
t = eng.run().transcript
assert "round 1 hero: short sword vs foe d20=3+5=8 AC 10 -> MISS" in t
assert eng._states[0].aid_attack_bonus == 0
assert eng._states[2].hp == 100
# ── (b) aid (ac): +2 AC against the next attack targeting the ally ───────────
def test_aid_ac_bonus_raises_ally_ac() -> None:
calls: dict[str, int] = {}
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "ally" and n == 0:
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
if cid == "foe":
return (Action(kind="attack", target_id="hero"),)
return _WAIT
# Aid d20=10 success; foe attack d20=5+5=10 vs AC 10+2=12 -> MISS.
eng = _trio(policy, [10, 10, 10, 10, 5], round_cap=1)
t = eng.run().transcript
assert (
"round 1 ally: aid another hero (ac) d20=10+5=15 vs AC 10 -> success"
in t
)
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 12 -> MISS" in t
hero = eng._states[0]
assert hero.aid_ac_bonus == 2
assert hero.aid_attack_bonus == 0
assert hero.hp == 100
def test_aid_ac_control_without_aid_hits() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "foe":
return (Action(kind="attack", target_id="hero"),)
return _WAIT
eng = _trio(policy, [10, 10, 10, 5, 3], round_cap=1)
t = eng.run().transcript
assert (
"round 1 foe: short sword vs hero d20=5+5=10 AC 10 -> HIT 3 damage (100->97)"
in t
)
# ── (c) a failed aid roll grants nothing ─────────────────────────────────────
def test_failed_aid_grants_nothing() -> None:
calls: dict[str, int] = {}
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "ally" and n == 0:
return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),)
if cid == "foe":
return (Action(kind="attack", target_id="hero"),)
return _WAIT
# Aid d20=2 + 5 = 7 < 10 -> fail; the foe's d20=5+5=10 then hits plain AC 10.
eng = _trio(policy, [10, 10, 10, 2, 5, 3], round_cap=1)
t = eng.run().transcript
assert (
"round 1 ally: aid another hero (ac) d20=2+5=7 vs AC 10 -> fail" in t
)
hero = eng._states[0]
assert hero.aid_ac_bonus == 0
assert hero.aid_attack_bonus == 0
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 10 -> HIT 3 damage (100->97)" in t
assert hero.hp == 97
# ── (d) run moves up to 4x speed (destination and target), sets running ──────
def test_run_reaches_destination_beyond_normal_move() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(7, 7)),)
return _WAIT
# Speed 30 ft = 6 cells; run = 24 cells; (0,0)->(7,7) costs 7 diagonals.
# The foe sits at (7,0), far off the diagonal path — no AoO provokes.
foe = make_state(_combatant("foe"), "monsters", (7, 0))
eng = _duo(policy, [10, 5], round_cap=1, foe=foe)
t = eng.run().transcript
hero = eng._states[0]
assert hero.pos == (7, 7)
assert hero.running is True
assert "round 1 hero: run" in t
assert hero.last_action_results == [ActionResult(kind="run", ok=True, reason="ok")]
def test_normal_move_cannot_reach_the_same_destination() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=(7, 7)),)
return _WAIT
eng = _duo(policy, [10, 5], round_cap=1)
t = eng.run().transcript
hero = eng._states[0]
assert hero.pos == (0, 0) # 7 cells > the 6-cell speed budget
assert "round 1 hero: move rejected (unreachable_destination)" in t
assert hero.running is False
# The run got strictly farther than the normal move.
assert make_grid().distance((7, 7), (0, 0)) > make_grid().distance(hero.pos, (0, 0))
def test_run_toward_target_outreaches_normal_move() -> None:
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(
lambda _e, s: (
(Action(kind="run", target_id="foe"),)
if s.combatant.id == "hero" else _WAIT
),
[10, 5], round_cap=1, hero=hero, foe=foe, grid=grid,
)
t = eng.run().transcript
run_hero = eng._states[0]
assert run_hero.pos == (14, 14) # stopped adjacent to the foe
assert run_hero.running is True
assert "round 1 hero: run" in t
hero2 = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
foe2 = make_state(_combatant("foe"), "monsters", (15, 15))
eng2 = _duo(
lambda _e, s: (
(Action(kind="move", target_id="foe"),)
if s.combatant.id == "hero" else _WAIT
),
[10, 5], round_cap=1, hero=hero2, foe=foe2, grid=grid,
)
eng2.run()
move_hero = eng2._states[0]
assert move_hero.pos == (6, 6) # only 6 cells of the 15-cell path
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
# ── (e) a running combatant loses its Dex bonus to AC ────────────────────────
def test_running_combatant_is_denied_dex_to_ac() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(0, 1)),)
return (Action(kind="attack", target_id="hero"),)
# Hero: AC 14 (Dex +4) but flat-footed 10. Running denies the Dex bonus:
# the foe's d20=5+5=10 hits AC 10 (the flat-footed value).
hero = make_state(
_combatant("hero", initiative_mod=2, ac=14, flat_ac=10, dex=18),
"players", (0, 0),
)
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _duo(policy, [10, 10, 5, 3], round_cap=1, hero=hero, foe=foe)
t = eng.run().transcript
assert "round 1 hero: run" in t
assert (
"round 1 foe: short sword vs hero d20=5+5=10 AC 10 -> HIT 3 damage (100->97)"
in t
)
assert eng._states[0].hp == 97
def test_not_running_combatant_keeps_dex_to_ac() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", destination=(0, 1)),)
return (Action(kind="attack", target_id="hero"),)
# Same geometry, plain move (not running): the same roll misses AC 14.
hero = make_state(
_combatant("hero", initiative_mod=2, ac=14, flat_ac=10, dex=18),
"players", (0, 0),
)
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _duo(policy, [10, 10, 5], round_cap=1, hero=hero, foe=foe)
t = eng.run().transcript
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 14 -> MISS" in t
assert eng._states[0].hp == 100
# ── (f) the snapshot exposes the three keys ──────────────────────────────────
def test_snapshot_exposes_aid_and_run_keys() -> None:
hero_snaps: list[dict[str, object]] = []
first_hero_snap: dict[str, object] = {}
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
if cid == "hero":
obs = _snapshot(engine, state)
mine = next(c for c in obs["combatants"] if c["id"] == "hero")
first_hero_snap.update(mine)
return (Action(kind="run", destination=(1, 0)),)
if cid == "ally":
return (Action(kind="aid_another", target_id="hero"),)
obs = _snapshot(engine, state)
hero_snaps.append(
next(c for c in obs["combatants"] if c["id"] == "hero")
)
return _WAIT
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
ally = make_state(_combatant("ally", initiative_mod=1), "players", (3, 3))
foe = make_state(_combatant("foe"), "monsters", (0, 4))
eng = CombatEngine(
ScriptedRng([10, 10, 10, 10]), make_grid(), [hero, ally, foe],
policy=policy, round_cap=1,
)
eng.run()
# Before any aid/run: the three keys exist with neutral defaults.
assert first_hero_snap["aid_attack_bonus"] == 0
assert first_hero_snap["aid_ac_bonus"] == 0
assert first_hero_snap["running"] is False
# After hero ran and the ally aided (default mode = attack): live values.
assert len(hero_snaps) == 1
assert hero_snaps[0]["aid_attack_bonus"] == 2
assert hero_snaps[0]["aid_ac_bonus"] == 0
assert hero_snaps[0]["running"] is True
# A combatant that never used either keeps neutral values.
foe_snap = _snapshot(eng, eng._states[2])
foe_mine = next(c for c in foe_snap["combatants"] if c["id"] == "foe")
assert foe_mine["aid_attack_bonus"] == 0
assert foe_mine["aid_ac_bonus"] == 0
assert foe_mine["running"] is False
# ── (g) missing target/destination rejections (U1 path) ──────────────────────
def test_run_and_aid_without_target_are_rejected() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run"), Action(kind="aid_another"))
return _WAIT
eng = _duo(policy, [10, 5], round_cap=1)
t = eng.run().transcript
hero = eng._states[0]
assert hero.last_action_results == [
ActionResult(kind="run", ok=False, reason="missing_target"),
ActionResult(kind="aid_another", ok=False, reason="missing_target"),
]
assert "round 1 hero: run rejected (missing_target)" in t
assert "round 1 hero: aid_another rejected (missing_target)" in t
assert hero.pos == (0, 0)
assert hero.running is False
# ── (h) multiple aids stack (add, never replace) ─────────────────────────────
def test_multiple_aids_stack() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id in ("ally-1", "ally-2"):
return (Action(kind="aid_another", target_id="hero"),)
return _WAIT
hero = make_state(_combatant("hero", initiative_mod=3), "players", (0, 0))
ally1 = make_state(_combatant("ally-1", initiative_mod=2), "players", (3, 3))
ally2 = make_state(_combatant("ally-2", initiative_mod=1), "players", (4, 4))
foe = make_state(_combatant("foe"), "monsters", (0, 1))
eng = CombatEngine(
ScriptedRng([10, 10, 10, 10, 10, 10]), make_grid(),
[hero, ally1, ally2, foe], policy=policy, round_cap=1,
)
eng.run()
assert hero.aid_attack_bonus == 4 # 2 + 2, added not replaced
# ── (i) aid without a usable melee weapon is rejected ────────────────────────
def test_aid_without_melee_weapon_is_rejected() -> None:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "helper":
return (Action(kind="aid_another", target_id="hero"),)
return _WAIT
hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0))
helper = make_state(
_combatant("helper", ranged=True), "monsters", (3, 3),
)
# Only the two initiative rolls are consumed — the aid never reaches a d20.
eng = _duo(policy, [10, 5], round_cap=1, hero=hero, foe=helper)
t = eng.run().transcript
assert "round 1 helper: aid_another rejected (no_weapon)" in t
assert hero.aid_attack_bonus == 0
assert hero.last_action_results == [
ActionResult(kind="wait", ok=True, reason="ok")
]
assert helper.last_action_results == [
ActionResult(kind="aid_another", ok=False, reason="no_weapon")
]
# ── (j) combats that use neither action are byte-identical ──────────────────
def test_no_aid_or_run_transcript_is_pinned() -> None:
eng = _duo(lambda _e, _s: _WAIT, [10, 5], round_cap=2)
result = eng.run()
assert result.transcript == (
"initiative: hero d20=10+2=12",
"initiative: foe d20=5+0=5",
"round 1 hero: wait",
"round 1 foe: wait",
"round 2 hero: wait",
"round 2 foe: wait",
"battle over: draw after 2 rounds",
)
for state in eng._states:
assert state.aid_attack_bonus == 0
assert state.aid_ac_bonus == 0
assert state.running is False