feat(combat): allow selecting the attacking weapon
This commit is contained in:
@@ -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 à
|
||||
|
||||
@@ -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
|
||||
return True
|
||||
elif dist_ft <= effective_reach_ft:
|
||||
return weapon
|
||||
return True
|
||||
max_increments = (
|
||||
_MAX_TOUCH_RANGE_INCREMENTS
|
||||
if weapon.kind == "touch"
|
||||
else _MAX_RANGE_INCREMENTS
|
||||
)
|
||||
if (
|
||||
return (
|
||||
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
|
||||
)
|
||||
|
||||
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":
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
Reference in New Issue
Block a user