feat(combat): implement overrun, dirty trick, steal, reposition and drag maneuvers
This commit is contained in:
@@ -138,6 +138,11 @@ _SIZE_MODIFIERS: dict[str, int] = {
|
||||
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
||||
}
|
||||
|
||||
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
||||
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
||||
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
||||
)
|
||||
|
||||
|
||||
def _save_label(result: SaveResult | None) -> str:
|
||||
if result is None:
|
||||
@@ -216,9 +221,15 @@ class CombatantState:
|
||||
# sunder damage per weapon id, and the set of weapon ids rendered unusable.
|
||||
weapon_damage: dict[str, int] = field(default_factory=dict)
|
||||
weapon_broken: set[str] = field(default_factory=set)
|
||||
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
|
||||
# steal maneuvers.
|
||||
weapon_stolen: set[str] = field(default_factory=set)
|
||||
|
||||
def usable_attacks(self) -> list[AttackSpec]:
|
||||
return [w for w in self.combatant.attacks if w.id not in self.weapon_broken]
|
||||
return [
|
||||
w for w in self.combatant.attacks
|
||||
if w.id not in self.weapon_broken and w.id not in self.weapon_stolen
|
||||
]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.spell_slots_left:
|
||||
@@ -300,7 +311,8 @@ class Action:
|
||||
]
|
||||
target_id: str | None = None
|
||||
spell_name: str | None = None
|
||||
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape"
|
||||
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder",
|
||||
# "grapple", "escape", "overrun", "dirty_trick", "steal", "reposition", "drag"
|
||||
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
||||
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
||||
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
||||
@@ -1287,7 +1299,8 @@ class CombatEngine:
|
||||
def _execute_maneuver(
|
||||
self, state: CombatantState, target: CombatantState | None, maneuver: str | None
|
||||
) -> None:
|
||||
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder)."""
|
||||
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
|
||||
escape, overrun, dirty_trick, steal, reposition, drag)."""
|
||||
if target is None or not target.active:
|
||||
return
|
||||
if self.has_condition_flag(state, "cant_attack"):
|
||||
@@ -1296,7 +1309,10 @@ class CombatEngine:
|
||||
if maneuver == "escape":
|
||||
self._maneuver_escape(state, target)
|
||||
return
|
||||
if maneuver not in ("trip", "disarm", "bull_rush", "sunder", "grapple"):
|
||||
if maneuver not in (
|
||||
"trip", "disarm", "bull_rush", "sunder", "grapple",
|
||||
"overrun", "dirty_trick", "steal", "reposition", "drag",
|
||||
):
|
||||
return
|
||||
|
||||
attacker_cmb = self._maneuver_cmb(state)
|
||||
@@ -1314,6 +1330,11 @@ class CombatEngine:
|
||||
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m),
|
||||
"sunder": lambda: self._maneuver_sunder(state, target, m),
|
||||
"grapple": lambda: self._maneuver_grapple(state, target, m),
|
||||
"overrun": lambda: self._maneuver_overrun(state, target, m),
|
||||
"dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m),
|
||||
"steal": lambda: self._maneuver_steal(state, target, m),
|
||||
"reposition": lambda: self._maneuver_reposition(state, target, m),
|
||||
"drag": lambda: self._maneuver_drag(state, target, m),
|
||||
}
|
||||
handlers[maneuver]()
|
||||
|
||||
@@ -1453,6 +1474,118 @@ class CombatEngine:
|
||||
f"CMD {m.target_cmd} -> FAILED"
|
||||
)
|
||||
|
||||
def _maneuver_overrun(
|
||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "overrun", m)
|
||||
return
|
||||
target.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["prone"],
|
||||
rounds_left=1,
|
||||
source="maneuver: overrun",
|
||||
)
|
||||
)
|
||||
beyond = self._beyond_cell(state.pos, target.pos)
|
||||
if self._cell_free(beyond, state):
|
||||
state.pos = beyond
|
||||
outcome = "SUCCESS (prone, moved through)"
|
||||
else:
|
||||
outcome = "SUCCESS (prone)"
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: overrun vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> {outcome}"
|
||||
)
|
||||
|
||||
def _maneuver_dirty_trick(
|
||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "dirty_trick", m)
|
||||
return
|
||||
target.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["dazzled"],
|
||||
rounds_left=1,
|
||||
source="maneuver: dirty trick",
|
||||
)
|
||||
)
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: dirty trick vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> SUCCESS (dazzled)"
|
||||
)
|
||||
|
||||
def _maneuver_steal(
|
||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "steal", m)
|
||||
return
|
||||
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
||||
if weapon is None:
|
||||
outcome = "SUCCESS (nothing to steal)"
|
||||
else:
|
||||
target.weapon_stolen.add(weapon.id)
|
||||
outcome = f"SUCCESS (stole {weapon.id})"
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: steal vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> {outcome}"
|
||||
)
|
||||
|
||||
def _maneuver_reposition(
|
||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "reposition", m)
|
||||
return
|
||||
new_pos: Pos | None = None
|
||||
for dr, dc in _NEIGHBOR_OFFSETS:
|
||||
pos = (target.pos[0] + dr, target.pos[1] + dc)
|
||||
if pos == state.pos:
|
||||
continue
|
||||
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
||||
continue
|
||||
if self._cell_free(pos, target):
|
||||
new_pos = pos
|
||||
break
|
||||
if new_pos is not None:
|
||||
target.pos = new_pos
|
||||
outcome = f"SUCCESS (moved to {new_pos})"
|
||||
else:
|
||||
outcome = "SUCCESS (no space)"
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: reposition vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> {outcome}"
|
||||
)
|
||||
|
||||
def _maneuver_drag(
|
||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "drag", m)
|
||||
return
|
||||
ar, ac = state.pos
|
||||
tr, tc = target.pos
|
||||
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
||||
beyond = self._beyond_cell(state.pos, target.pos)
|
||||
if adjacent and self._cell_free(beyond, target):
|
||||
target_old = target.pos
|
||||
target.pos = beyond
|
||||
state.pos = target_old
|
||||
outcome = "SUCCESS (dragged)"
|
||||
else:
|
||||
outcome = "SUCCESS (no space)"
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: drag vs "
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
f"CMD {m.target_cmd} -> {outcome}"
|
||||
)
|
||||
|
||||
def _maneuver_escape(
|
||||
self, state: CombatantState, target: CombatantState
|
||||
) -> None:
|
||||
@@ -2481,6 +2614,20 @@ class CombatEngine:
|
||||
return False
|
||||
target.pos = new_pos
|
||||
return True
|
||||
|
||||
def _beyond_cell(self, attacker_pos: Pos, target_pos: Pos) -> Pos:
|
||||
"""The 1-cell position on the far side of the target from the attacker."""
|
||||
ar, ac = attacker_pos
|
||||
tr, tc = target_pos
|
||||
dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0)
|
||||
dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0)
|
||||
return (tr + dr, tc + dc)
|
||||
|
||||
def _cell_free(self, pos: Pos, mover: CombatantState) -> bool:
|
||||
"""True if pos is in bounds and not occupied by any active state but mover."""
|
||||
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
||||
return False
|
||||
return not any(s.pos == pos for s in self._states if s.active and s is not mover)
|
||||
def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
|
||||
"""Find a heal spell in known spells."""
|
||||
for name in spells_known:
|
||||
|
||||
Reference in New Issue
Block a user