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
+16 -8
View File
@@ -377,14 +377,20 @@ Règles modélisées :
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance.
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple et escape sont
modélisées, chacune comme une tentative unique (1d20 + CMB contre CMD) :
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) :
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2`
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) →
`disarmed` ; une arme brisée est exclue de `usable_attacks()`. `default_policy` ne
`disarmed` ; une arme brisée est exclue de `usable_attacks()`. overrun →
`prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick →
`dazzled` 1 round ; steal → l'arme de mêlée de la cible est volée (exclue de
`usable_attacks()`) ; reposition → déplacement de 5 ft de la cible vers le
premier voisin libre ; drag → traction de 5 ft de la cible, l'attaquant
occupant sa case d'origine. `default_policy` ne
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
politique personnalisée).
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
@@ -420,9 +426,11 @@ dans la résolution) :
appliquées au déplacement, à la portée ou à la ligne d'effet).
- Tailles Large+ (2×2), allonge > 5 ft (la mêlée reste limitée à une case) et
actions immédiates hors-tour (consommeraient le prochain swift).
- Parmi les manœuvres, seules trip / disarm / bull_rush / sunder / grapple / escape
sont câblées ; les autres (overrun, dirty trick, steal, reposition, drag) ne le
sont pas. Une arme brisée ne peut pas être réparée en cours de combat.
- Manœuvres : dirty trick applique toujours `dazzled` (le choix
aveuglé/assourdi/entravé/malade n'est pas modélisé) ; reposition et drag
utilisent une direction déterministe (premier voisin libre / au-delà de la
cible) ; un objet volé n'est pas récupérable en cours de combat. Une arme
brisée ne peut pas être réparée en cours de combat.
- La distribution des dégâts par type sur un sort n'envoie que des dégâts simples
(sans plage « half on save » pour les effets ciblés).
@@ -522,8 +530,8 @@ uv run basedpyright src # mode strict
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
hex, deeds, inspire courage / spell combat) sont en place et documentés dans
« Règles implémentées ». Restent hors du socle (voir « Non modélisé ») : les
hauteurs/élévations, tailles Large+, allonges > 5 ft, actions immédiates
hors-tour, et le reste des manœuvres.
hauteurs/élévations, tailles Large+, allonges > 5 ft et actions immédiates
hors-tour.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. (Non implémentée.)
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+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:
+130
View File
@@ -116,3 +116,133 @@ def test_escape_from_grapple_consumes_single_d20() -> None:
assert not engine._rng._queue
assert a.grappled_with is None
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
def test_overrun_knocks_prone_and_moves_through() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1
assert prone.source == "maneuver: overrun"
assert a.pos == (1, 3)
def test_overrun_prone_but_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1)
assert "SUCCESS (prone)" in engine._transcript[-1]
assert "moved through" not in engine._transcript[-1]
def test_dirty_trick_dazzles() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
assert dazzled.rounds_left == 1
assert dazzled.source == "maneuver: dirty trick"
def test_dirty_trick_fails_on_natural_1() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert not any(c.condition.name == "dazzled" for c in b.active_conditions)
assert not any(c.condition.name == "prone" for c in b.active_conditions)
assert engine._transcript[-1].endswith("-> FAILED")
def test_steal_takes_melee_weapon() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None
assert b.hp == hp_before
def test_steal_nothing_to_steal() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b").model_copy(update={"attacks": []})
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert not b.weapon_stolen
assert "nothing to steal" in engine._transcript[-1]
def test_reposition_moves_to_first_free_neighbor() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (0, 1)
assert c.pos == (1, 3)
def test_reposition_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
blocker_positions = [(0, 0), (0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 2)]
blockers = [
make_state(make_combatant(f"c{idx}"), "monsters", pos)
for idx, pos in enumerate(blocker_positions)
]
a = make_state(a_spec, "players", (2, 1))
b = make_state(b_spec, "monsters", (1, 1))
engine = make_engine([12], [a, b, *blockers])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (1, 1)
assert "no space" in engine._transcript[-1]
def test_drag_moves_pair_together() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 3)
assert a.pos == (1, 2)
def test_drag_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 2)
assert a.pos == (1, 1)
assert "no space" in engine._transcript[-1]