From f637f87a8c7d9a709018cd8100343f021ea5c096 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 16:56:38 +0200 Subject: [PATCH] feat(combat): add aid another and run actions --- README.md | 41 ++- src/pf1e_simulator/combat.py | 208 ++++++++++++-- tests/test_aid_and_run.py | 517 +++++++++++++++++++++++++++++++++++ 3 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 tests/test_aid_and_run.py diff --git a/README.md b/README.md index f626dbf..3978302 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,22 @@ Règles modélisées : 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 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` 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 @@ -370,7 +386,10 @@ Règles modélisées : reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → `ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos` 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 ») 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 @@ -470,6 +489,18 @@ Règles modélisées : ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour le mouvement). `default_policy` ne choisit pas la retraite (disponible via une politique personnalisée). +- 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 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 — @@ -673,7 +704,13 @@ Règles modélisées : jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining (int — attaques d'opportunité restantes ce round : `_aoo_allowance` (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, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b6970a5..b639bef 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 _FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC _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({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -396,6 +399,14 @@ class CombatantState: # opponents' turns in between. defensive_ac_bonus: int = 0 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]: return [ @@ -580,7 +591,15 @@ class Action: outside the map or without line of effect from the caster is rejected (``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 - 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[ @@ -590,6 +609,7 @@ class Action: "inspire_courage", "retrieve_weapon", "repair_weapon", "activate_power_attack", "delay", "ready", "total_defense", "fight_defensively", "stand_up", "drop_prone", + "aid_another", "run", ] target_id: str | None = None spell_name: str | None = None @@ -606,6 +626,7 @@ class Action: ready_trigger: ReadyTrigger | None = None # ready action trigger event 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 + aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack) # 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. # U5d adds weapon_unavailable for attack / full_attack with an explicit # 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({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "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"}) # Action kinds whose helper is a silent no-op (or worse) without a resolved # 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({ "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, "defensive_ac_bonus": state.defensive_ac_bonus, "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( 0, self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0), @@ -1145,6 +1172,9 @@ class CombatEngine: state.power_attack = False state.defensive_ac_bonus = 0 state.fight_defensively = False + state.aid_attack_bonus = 0 + state.aid_ac_bonus = 0 + state.running = False original = self._policy(self, state) actions = original results: list[ActionResult] = [] @@ -1689,10 +1719,11 @@ class CombatEngine: ) total = ( 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) - denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") + # Check if target denies DEX to AC (flat-footed); a running target + # 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 = ( target.combatant.ac.flat_footed if denies_dex @@ -1716,6 +1747,7 @@ class CombatEngine: base_ac = full_ac_base ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac")) ac += target.defensive_ac_bonus + ac += target.aid_ac_bonus occupied = frozenset( cell for s in self._states @@ -1745,6 +1777,7 @@ class CombatEngine: damage = self._consume_up_close_and_deadly( attacker, target, weapon, dist_ft, damage, hit=hit ) + attacker.aid_attack_bonus = 0 return AttackResult( hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, @@ -1786,7 +1819,7 @@ class CombatEngine: confirm = self._rng.d20() confirm_total = ( 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) ) crit = confirm != _NATURAL_ONE and confirm_total >= ac @@ -2552,16 +2585,36 @@ class CombatEngine: rejection = self._target_rejection(state, kind, target, action) if rejection is not None: 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": return self._execute_delay(state, target, action) if kind == "ready": return self._execute_ready(state, action) if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"): return self._execute_self_action(state, kind) - override = self._dispatch_action(state, target, action) - return override if override is not None else ActionResult( - kind=kind, ok=True, reason=_REASON_OK - ) + if kind == "aid_another": + return self._execute_aid_another(state, target, action) + if kind == "run": + return self._execute_run(state, target, action) + return None def _target_rejection( self, @@ -2570,11 +2623,11 @@ class CombatEngine: target: CombatantState | None, action: Action, ) -> ActionResult | None: - """U1 missing-target gate; move/5foot_step accept a destination instead (U5c); - cast_spell accepts a target_pos instead (U7b).""" + """U1 missing-target gate; move/5foot_step/run accept a destination instead + (U5c / U5e); cast_spell accepts a target_pos instead (U7b).""" if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS: 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 if kind == "cast_spell" and action.target_pos is not None: return None @@ -2734,6 +2787,97 @@ class CombatEngine: self._log(f"round {self._current_round} {state.combatant.id}: drops prone") 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( self, state: CombatantState, target: CombatantState | None, action: Action ) -> ActionResult | None: @@ -4569,10 +4713,22 @@ class CombatEngine: ) return ActionResult(kind=kind, ok=True, reason=_REASON_OK) - def _destination_reachable(self, state: CombatantState, destination: Pos) -> bool: - """True when destination is passable, unoccupied and within this speed.""" + def _destination_reachable( + 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( - 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: return destination == state.pos @@ -4606,10 +4762,20 @@ class CombatEngine: """ return self._descend_path(state, target.pos) - def _descend_path(self, state: CombatantState, anchor: Pos) -> list[Pos]: - """Greedy walk of the anchor over the cost field from ``anchor``.""" + def _descend_path( + 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_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: return [] blocked = self._blocked(state) diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py new file mode 100644 index 0000000..a5d9779 --- /dev/null +++ b/tests/test_aid_and_run.py @@ -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