feat(combat): allow selecting the attacking weapon
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user