277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""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")
|
|
]
|