From cd75a4f2bf7549b569af558aafaf1c235b5a6c05 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 5 Sep 2026 12:37:06 +0200 Subject: [PATCH] feat(combat): implement overrun, dirty trick, steal, reposition and drag maneuvers --- README.md | 24 ++++-- src/pf1e_simulator/combat.py | 155 ++++++++++++++++++++++++++++++++++- tests/test_maneuvers.py | 130 +++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 13d5a45..ac36e0a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4667c4d..a102b85 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 01efaec..edea503 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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]