feat(combat): add aid another and run actions
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user