diff --git a/README.md b/README.md index 9921b10..4932665 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,8 @@ Règles modélisées : `ok=False, reason="already_delayed"` ; préparation valide → `ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False, reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False, - reason="already_prone"`. + reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → + `ok=False, reason="weapon_unavailable"`. - 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 @@ -375,6 +376,16 @@ Règles modélisées : attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1) ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque à outrance ne sont pas modélisées. + Choix de l'arme : `Action(kind="attack", weapon_id=...)` et + `Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée + (l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au + lieu de l'arme auto-sélectionnée par `weapon_for` (première arme utilisable + capable de toucher la cible à cette distance avec ligne d'effet). + L'`id` doit désigner une arme utilisable (non brisée, non volée) pouvant + atteindre la cible ; sinon l'action est rejetée (`weapon_unavailable`) et + aucun jet n'est effectué. Sans `weapon_id`, la sélection automatique est + inchangée. `default_policy` ne choisit pas d'arme (disponible via une + politique personnalisée). - Mêlée : allonge effective = max(allonge naturelle du gabarit (Small/Medium 5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft), `reach_ft` de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index fa3098a..0520d43 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -543,7 +543,11 @@ class Action: until the combatant stands up. ``move`` and ``5foot_step`` accept an explicit ``destination`` cell ``(row, col)``: with one they walk to that cell instead of auto-pathing toward ``target_id`` (the 5-foot step's - destination must be exactly one cell away). + destination must be exactly one cell away). ``attack`` and ``full_attack`` + accept ``weapon_id`` to strike with THAT weapon instead of the + auto-selected one; a choice that is unknown, broken, stolen, or unable to + hit the target (range / line of effect) is rejected + (``weapon_unavailable``). """ kind: Literal[ @@ -564,7 +568,8 @@ class Action: channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use direction: tuple[int, int] | None = None # steering for directional maneuvers trick_condition: TrickCondition | None = None # dirty trick condition choice - weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon + # weapon id for attack / full_attack / retrieve_weapon / repair_weapon + weapon_id: str | None = None ready_trigger: ReadyTrigger | None = None # ready action trigger event destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step @@ -576,12 +581,15 @@ class Action: # already_delayed for the delay action. U4 adds ready for the ready action. # U5b adds not_prone / already_prone for the prone actions. U5c adds # 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. ACTION_RESULT_REASONS: frozenset[str] = frozenset({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "no_line_of_effect", "no_slot", "insufficient_resource", "downgraded:staggered", "downgraded:nauseated", "delayed", "already_delayed", "ready", "not_prone", "already_prone", "unreachable_destination", + "weapon_unavailable", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" @@ -595,6 +603,7 @@ _REASON_READY = "ready" _REASON_NOT_PRONE = "not_prone" _REASON_ALREADY_PRONE = "already_prone" _REASON_UNREACHABLE_DESTINATION = "unreachable_destination" +_REASON_WEAPON_UNAVAILABLE = "weapon_unavailable" # Action kinds that are declared but have no engine dispatch (U1 surfaces # them as explicit rejections instead of silent no-ops). @@ -1336,31 +1345,43 @@ class CombatEngine: def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None: """First weapon usable against the target at this range and with clear LoE.""" + for weapon in attacker.usable_attacks(): + if self._weapon_usable_against(attacker, target, weapon): + return weapon + return None + + def _weapon_usable_against( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + ) -> bool: + """True if ``weapon`` can strike ``target`` at this range with clear LoE. + + The per-weapon half of ``weapon_for``'s auto-selection, exposed for + the explicit weapon choice of ``attack`` / ``full_attack`` (U5d): + melee/touch reach (with the reach dead zone) or ranged/touch range + within the max increments, behind a line-of-effect gate. + """ if not has_line_of_effect_cells( self._grid, attacker.occupied_cells(), target.occupied_cells() ): - return None + return False dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT - for weapon in attacker.usable_attacks(): - if weapon.kind in ("melee", "touch"): - effective_reach_ft = _effective_reach_ft(attacker, weapon) - if weapon.reach_ft > _SQUARE_FT: - if _SQUARE_FT < dist_ft <= effective_reach_ft: - return weapon - elif dist_ft <= effective_reach_ft: - return weapon - max_increments = ( - _MAX_TOUCH_RANGE_INCREMENTS - if weapon.kind == "touch" - else _MAX_RANGE_INCREMENTS - ) - if ( - weapon.kind in ("ranged", "touch") - and weapon.range_increment_ft is not None - and dist_ft <= weapon.range_increment_ft * max_increments - ): - return weapon - return None + if weapon.kind in ("melee", "touch"): + effective_reach_ft = _effective_reach_ft(attacker, weapon) + if weapon.reach_ft > _SQUARE_FT: + if _SQUARE_FT < dist_ft <= effective_reach_ft: + return True + elif dist_ft <= effective_reach_ft: + return True + max_increments = ( + _MAX_TOUCH_RANGE_INCREMENTS + if weapon.kind == "touch" + else _MAX_RANGE_INCREMENTS + ) + return ( + weapon.kind in ("ranged", "touch") + and weapon.range_increment_ft is not None + and dist_ft <= weapon.range_increment_ft * max_increments + ) def _is_ranged_attack(self, weapon: AttackSpec) -> bool: """Ranged-style attack: projectile, or touch thrown/fired from a distance. @@ -2625,8 +2646,8 @@ class CombatEngine: """Route a validated action to its execution helper. Returns an explicit rejection when a destination-bearing movement - cannot be honoured (U5c); None means the helper ran and the caller - reports ok. + cannot be honoured (U5c) or an attack's chosen weapon is unavailable + (U5d); None means the helper ran and the caller reports ok. """ kind = action.kind if kind == "cast_spell": @@ -2647,7 +2668,7 @@ class CombatEngine: elif kind == "withdraw": self._withdraw(state, target) elif kind in ("attack", "full_attack"): - self._execute_attack(state, target, kind) + return self._execute_attack(state, target, kind, action.weapon_id) elif kind == "immediate": self._execute_immediate(state, target, action) else: @@ -3543,20 +3564,65 @@ class CombatEngine: f"inspire courage +1 morale attack/damage to {len(affected)} allies" ) def _execute_attack( - self, state: CombatantState, target: CombatantState | None, kind: str - ) -> None: + self, + state: CombatantState, + target: CombatantState | None, + kind: str, + weapon_id: str | None = None, + ) -> ActionResult | None: + """Execute an attack / full_attack (U5d: optional explicit weapon). + + With ``weapon_id`` the attack is made with THAT weapon; a choice that + is unknown, broken, stolen, or unable to strike the target is logged + and rejected with ``weapon_unavailable`` (no attack is made). Without + it the ``weapon_for`` auto-selection is unchanged. Returns an explicit + rejection, or None when the helper ran (or silently no-oped) and the + caller reports ok. + """ if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") - return + return None if target is None: - return + return None + chosen: AttackSpec | None = None + if weapon_id is not None: + chosen = self._chosen_weapon(state, target, weapon_id) + if chosen is None: + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"{kind} rejected ({_REASON_WEAPON_UNAVAILABLE})" + ) + return ActionResult( + kind=kind, ok=False, reason=_REASON_WEAPON_UNAVAILABLE + ) if kind == "attack": - weapon = self.weapon_for(state, target) + weapon = chosen if chosen is not None else self.weapon_for(state, target) if weapon is None: - return + return None self._single_attack(state, target, weapon) elif kind == "full_attack": - self._full_attack(state, target) + if chosen is not None: + self._full_attack_weapon(state, target, chosen) + else: + self._full_attack(state, target) + return None + + def _chosen_weapon( + self, attacker: CombatantState, target: CombatantState, weapon_id: str + ) -> AttackSpec | None: + """The usable weapon ``weapon_id``, if it can strike ``target`` (U5d). + + None when the id is unknown or the weapon is broken/stolen (not in + ``usable_attacks()``), or when it cannot reach the target / lacks + line of effect. + """ + for weapon in attacker.usable_attacks(): + if weapon.id == weapon_id: + if self._weapon_usable_against(attacker, target, weapon): + return weapon + return None + return None + def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: if target is None or not target.active: return diff --git a/tests/test_weapon_selection.py b/tests/test_weapon_selection.py new file mode 100644 index 0000000..bcb92e4 --- /dev/null +++ b/tests/test_weapon_selection.py @@ -0,0 +1,276 @@ +"""Explicit attacking-weapon selection (Package U5d). + +``attack`` and ``full_attack`` accept ``Action.weapon_id`` — the id of one of +the attacker's weapons. With it the engine strikes with THAT weapon instead of +the ``weapon_for`` auto-selection; a choice that is unknown, broken, stolen, +or unable to strike the target (out of reach / no line of effect) is rejected +with ``weapon_unavailable`` and no attack is made. Without it, the +auto-selection and the full-attack mechanics are byte-identical to before. +""" +from __future__ import annotations + +from collections.abc import Callable + +from pf1e_simulator.combat import ( + ACTION_RESULT_REASONS, + 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 make_grid, make_state + +PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] + +_WAIT: tuple[Action, ...] = (Action(kind="wait"),) + + +def _sword( + id_: str, name: str, attack_bonus: int, *, reach_ft: int = 5 +) -> AttackSpec: + return AttackSpec( + id=id_, + name=name, + kind="melee", + attack_bonus=attack_bonus, + reach_ft=reach_ft, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + ) + + +def _combatant(id_: str, attacks: list[AttackSpec], *, initiative_mod: int = 0) -> Combatant: + return Combatant( + id=id_, + name=id_, + level=1, + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + ac=ACProfile(total=10, touch=10, flat_footed=10), + bab=1, + initiative_mod=initiative_mod, + speed_land_ft=30, + hp_max=100, + saves=Saves(fort=0, ref=0, will=0), + attacks=attacks, + ) + + +# Hero's auto-selected weapon is "longsword" (first usable); "shortsword" is +# the explicit alternative with a lower bonus — the transcript distinguishes +# them by name and by the attack bonus. +def _two_swords() -> list[AttackSpec]: + return [_sword("hero-long", "longsword", 5), _sword("hero-short", "shortsword", 2)] + + +def _duel( + rng: list[int], + *, + hero_attacks: list[AttackSpec] | None = None, + foe_pos: tuple[int, int] = (1, 2), + grid: Grid | None = None, +) -> tuple[CombatEngine, CombatantState, CombatantState]: + """hero at (1, 1) vs foe; scripted rolls only — no run(), no initiative.""" + hero = make_state( + _combatant("hero", hero_attacks or _two_swords()), "players", (1, 1) + ) + foe = make_state( + _combatant("foe", [_sword("foe-w", "short sword", 5)]), "monsters", foe_pos + ) + engine = CombatEngine(ScriptedRng(rng), grid or make_grid(), [hero, foe]) + return engine, hero, foe + + +# ── (a) the chosen weapon is the one that swings ────────────────────────────── + + +def test_attack_uses_the_explicitly_chosen_weapon() -> None: + # d20 10 + 2 (shortsword) = 12 vs AC 10 -> HIT for 3; the auto-selected + # longsword (+5) must NOT be the one swinging. + eng, hero, foe = _duel([10, 3]) + result = eng._execute( + hero, Action(kind="attack", target_id="foe", weapon_id="hero-short") + ) + assert result == ActionResult(kind="attack", ok=True, reason="ok") + line = eng._transcript[-1] + assert "shortsword vs foe d20=10+2=12 AC 10 -> HIT 3 damage (100->97)" in line + assert "longsword" not in line + assert foe.hp == 97 + + +def test_full_attack_uses_only_the_chosen_weapon() -> None: + # Auto full_attack swings BOTH melee weapons (multi-weapon melee); with an + # explicit choice only the shortsword swings. + eng, hero, _foe = _duel([10, 3]) + result = eng._execute( + hero, Action(kind="full_attack", target_id="foe", weapon_id="hero-short") + ) + assert result == ActionResult(kind="full_attack", ok=True, reason="ok") + joined = "\n".join(eng._transcript) + assert "shortsword vs foe" in joined + assert "longsword" not in joined + + +def test_full_attack_without_choice_still_swings_all_melee_weapons() -> None: + eng, hero, _foe = _duel([10, 3, 10, 3]) + result = eng._execute(hero, Action(kind="full_attack", target_id="foe")) + assert result == ActionResult(kind="full_attack", ok=True, reason="ok") + joined = "\n".join(eng._transcript) + assert "longsword vs foe d20=10+5=15" in joined + assert "shortsword vs foe d20=10+2=12" in joined + + +# ── (b) broken weapon → weapon_unavailable ─────────────────────────────────── + + +def test_attack_with_broken_weapon_is_rejected() -> None: + eng, hero, foe = _duel([]) # empty script: any roll would raise + hero.weapon_broken.add("hero-short") + result = eng._execute( + hero, Action(kind="attack", target_id="foe", weapon_id="hero-short") + ) + assert result == ActionResult( + kind="attack", ok=False, reason="weapon_unavailable" + ) + assert any("attack rejected (weapon_unavailable)" in line for line in eng._transcript) + assert foe.hp == 100 + + +def test_full_attack_with_broken_weapon_is_rejected() -> None: + eng, hero, foe = _duel([]) + hero.weapon_broken.add("hero-short") + result = eng._execute( + hero, Action(kind="full_attack", target_id="foe", weapon_id="hero-short") + ) + assert result == ActionResult( + kind="full_attack", ok=False, reason="weapon_unavailable" + ) + assert foe.hp == 100 + + +# ── (c) stolen weapon → weapon_unavailable ─────────────────────────────────── + + +def test_attack_with_stolen_weapon_is_rejected() -> None: + eng, hero, foe = _duel([]) + hero.weapon_stolen.add("hero-long") + result = eng._execute( + hero, Action(kind="attack", target_id="foe", weapon_id="hero-long") + ) + assert result == ActionResult( + kind="attack", ok=False, reason="weapon_unavailable" + ) + assert foe.hp == 100 + + +# ── (d) unknown weapon id → weapon_unavailable ─────────────────────────────── + + +def test_attack_with_unknown_weapon_id_is_rejected() -> None: + eng, hero, foe = _duel([]) + result = eng._execute( + hero, Action(kind="attack", target_id="foe", weapon_id="ghost-blade") + ) + assert result == ActionResult( + kind="attack", ok=False, reason="weapon_unavailable" + ) + assert any("attack rejected (weapon_unavailable)" in line for line in eng._transcript) + assert foe.hp == 100 + + +def test_weapon_unavailable_is_in_the_reason_vocabulary() -> None: + assert "weapon_unavailable" in ACTION_RESULT_REASONS + + +# ── (e) chosen weapon cannot strike the target → weapon_unavailable ────────── + + +def test_attack_with_out_of_reach_weapon_is_rejected() -> None: + # Foe 4 cells away: the chosen melee weapon cannot reach, even though + # some other weapon might (here none can — but the choice is what fails). + eng, hero, foe = _duel([], foe_pos=(1, 5)) + result = eng._execute( + hero, Action(kind="attack", target_id="foe", weapon_id="hero-long") + ) + assert result == ActionResult( + kind="attack", ok=False, reason="weapon_unavailable" + ) + assert foe.hp == 100 + + +def test_attack_with_no_line_of_effect_weapon_is_rejected() -> None: + # Wall at (1,1) between hero (1,0) and foe (1,2): the longspear (10 ft + # reach) is IN range but has no line of effect -> weapon_unavailable. + rows = ["." * 8, ".#." + "." * 5] + ["." * 8] * 6 + legend = { + ".": TerrainType(type="floor", move_cost=1), + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + } + grid = Grid.from_spec(MapSpec(name="wall", terrain=tuple(rows), legend=legend)) + hero_spear = [_sword("hero-pike", "longspear", 5, reach_ft=10)] + eng, hero, foe = _duel( + [], hero_attacks=hero_spear, foe_pos=(1, 2), grid=grid + ) + hero.pos = (1, 0) + result = eng._execute( + hero, Action(kind="attack", target_id="foe", weapon_id="hero-pike") + ) + assert result == ActionResult( + kind="attack", ok=False, reason="weapon_unavailable" + ) + assert foe.hp == 100 + + +# ── (f) no weapon_id → identical to today's auto-selection ─────────────────── + + +def test_explicit_auto_equivalent_weapon_matches_no_choice() -> None: + # Same scripted scenario with weapon_id=None and with the weapon the + # auto-selection would pick anyway: transcripts must be byte-identical. + eng_a, hero_a, _foe_a = _duel([10, 3, 10, 3]) + res_a = eng_a._execute(hero_a, Action(kind="attack", target_id="foe")) + eng_b, hero_b, _foe_b = _duel([10, 3, 10, 3]) + res_b = eng_b._execute( + hero_b, Action(kind="attack", target_id="foe", weapon_id="hero-long") + ) + assert res_a == res_b == ActionResult(kind="attack", ok=True, reason="ok") + assert eng_a._transcript == eng_b._transcript + assert "longsword vs foe d20=10+5=15" in eng_a._transcript[-1] + + +def test_no_choice_full_attack_transcript_is_unchanged() -> None: + # Policy-driven full run: the auto full_attack transcript keeps its exact + # shape (both melee weapons, in usable_attacks() order). + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="full_attack", target_id="foe"),) + return _WAIT + + hero = make_state(_combatant("hero", _two_swords(), initiative_mod=2), "players", (1, 1)) + foe = make_state( + _combatant("foe", [_sword("foe-w", "short sword", 5)]), "monsters", (1, 2) + ) + # initiative: hero d20 10 (+2), foe d20 5; hero full_attack: hit+damage, hit+damage. + eng = CombatEngine( + ScriptedRng([10, 5, 10, 3, 10, 3]), make_grid(), [hero, foe], + policy=policy, round_cap=1, + ) + t = eng.run().transcript + assert "round 1 hero: longsword vs foe d20=10+5=15 AC 10 -> HIT 3 damage (100->97)" in t + assert "round 1 hero: shortsword vs foe d20=10+2=12 AC 10 -> HIT 3 damage (97->94)" in t + assert hero.last_action_results == [ + ActionResult(kind="full_attack", ok=True, reason="ok") + ]