feat(combat): implement overrun, dirty trick, steal, reposition and drag maneuvers

This commit is contained in:
2026-09-05 12:37:06 +02:00
parent 2f2acd992f
commit cd75a4f2bf
3 changed files with 297 additions and 12 deletions
+151 -4
View File
@@ -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: