"""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, destroyed, stolen, dropped, or unable to strike the target (out of reach / no line of effect) is rejected with ``weapon_unavailable`` and no attack is made (a BROKEN melee weapon stays selectable — CRB "Broken" makes it usable at -2). 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 / destroyed weapon selection ────────────────────────────────── def test_attack_with_broken_weapon_is_usable() -> None: # CRB "Broken": a broken weapon is still usable — at -2 on attack and # damage. The explicit choice is accepted: d20 10 + 2 - 2 = 10 vs AC 10 # -> HIT for 3 - 2 = 1 damage. eng, hero, foe = _duel([10, 3]) 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=True, reason="ok") assert "shortsword vs foe d20=10+2=10 AC 10 -> HIT 1 damage" in eng._transcript[-1] assert foe.hp == 99 def test_attack_with_destroyed_weapon_is_rejected() -> None: # A destroyed weapon (hp <= 0, CRB "Smashing an Object": "ruined") is # unusable: the explicit choice is rejected, no roll consumed. eng, hero, foe = _duel([]) # empty script: any roll would raise hero.weapon_destroyed.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_destroyed_weapon_is_rejected() -> None: eng, hero, foe = _duel([]) hero.weapon_destroyed.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") ]