diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 94fd67b..e6e6b9d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -251,9 +251,11 @@ class Action: kind: Literal[ "attack", "move", "wait", "swift", "free", "immediate", "full_round", "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", + "maneuver", ] target_id: str | None = None spell_name: str | None = None + maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder" @dataclass(frozen=True) @@ -894,7 +896,8 @@ class CombatEngine: self._log(f"round {self._current_round} {state.combatant.id}: wait") elif action.kind == "cast_spell": self._execute_cast_spell(state, target, action.spell_name) - + elif action.kind == "maneuver": + self._execute_maneuver(state, target, action.maneuver) def _execute_cast_spell( self, state: CombatantState, @@ -905,6 +908,72 @@ class CombatEngine: return self._cast_spell(state, target, spell_name) + def _execute_maneuver(self, state: CombatantState, target: CombatantState | None, maneuver: str | None) -> None: + """Execute a combat maneuver (trip, disarm, bull_rush, sunder).""" + if target is None or not target.active: + return + if self.has_condition_flag(state, "cant_attack"): + self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") + return + if maneuver not in ("trip", "disarm", "bull_rush", "sunder"): + return + + # Calculate CMB and CMD + attacker_cmb = state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)) + target_cmd = target.combatant.cmd or (10 + (target.combatant.cmb or (target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target))) + target.combatant.abilities.dex_mod) + + roll = self._rng.d20() + total = roll + attacker_cmb + success = roll != 1 and total >= target_cmd + + if maneuver == "trip": + if success: + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["prone"], + rounds_left=1, + source=f"maneuver: trip", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["prone"]) + self._log(f"round {self._current_round} {state.combatant.id}: trip vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (prone)") + else: + self._log(f"round {self._current_round} {state.combatant.id}: trip vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") + elif maneuver == "disarm": + if success: + # Disarm: target's weapon is dropped (mark as unusable) + # For simplicity, apply a condition that prevents weapon use + target.active_conditions.append( + ActiveCondition( + condition=Condition( + name="disarmed", + modifiers=(StatModifier(target="attack", value=-4),), + ), + rounds_left=1, + source=f"maneuver: disarm", + ) + ) + self._log(f"round {self._current_round} {state.combatant.id}: disarm vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (disarmed)") + else: + self._log(f"round {self._current_round} {state.combatant.id}: disarm vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") + elif maneuver == "bull_rush": + if success: + # Push target back 5ft (1 cell) away from attacker + pushed = self._bull_rush_push(state, target) + if pushed: + self._log(f"round {self._current_round} {state.combatant.id}: bull rush vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (pushed)") + else: + self._log(f"round {self._current_round} {state.combatant.id}: bull rush vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS (no space)") + else: + self._log(f"round {self._current_round} {state.combatant.id}: bull rush vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") + elif maneuver == "sunder": + if success: + # Sunder: deal damage to weapon (simplified: deal damage to target) + dmg = state.combatant.abilities.str_mod + 2 # simplified + target.hp -= dmg + self._log(f"round {self._current_round} {state.combatant.id}: sunder vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> SUCCESS ({dmg} damage to weapon)") + else: + self._log(f"round {self._current_round} {state.combatant.id}: sunder vs {target.combatant.id} d20={roll}+{attacker_cmb}={total} CMD {target_cmd} -> FAILED") def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: @@ -1484,7 +1553,50 @@ class CombatEngine: self._transcript.append(line) -Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] + def _size_mod(self, state: CombatantState) -> int: + """Size modifier for CMB/CMD: Medium=0, Small=-1, Large=1, etc.""" + size = state.combatant.size.lower() + if size == "fine": + return -8 + elif size == "diminutive": + return -4 + elif size == "tiny": + return -2 + elif size == "small": + return -1 + elif size == "medium": + return 0 + elif size == "large": + return 1 + elif size == "huge": + return 2 + elif size == "gargantuan": + return 4 + elif size == "colossal": + return 8 + return 0 + + def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool: + """Push target 5ft away from attacker. Returns True if push succeeded.""" + # Calculate direction away from attacker + ar, ac = attacker.pos + tr, tc = target.pos + dr = tr - ar + dc = tc - ac + # Normalize to single step + if dr != 0: + dr = 1 if dr > 0 else -1 + if dc != 0: + dc = 1 if dc > 0 else -1 + new_pos = (tr + dr, tc + dc) + # Check if destination is valid (in bounds, not blocked) + if not (0 <= new_pos[0] < self._grid.height and 0 <= new_pos[1] < self._grid.width): + return False + # Check if occupied + if any(s.pos == new_pos for s in self._states if s.active and s is not target): + return False + target.pos = new_pos + return True 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/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 3d266a8..96361a7 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -272,8 +272,10 @@ class Combatant(_FrozenModel): source: str = "" notes: str = "" features: list[AbilitySpec] = [] - spells: list[str] = [] + skills: dict[str, SkillRank] = Field(default_factory=dict) + cmb: int | None = None # Combat Maneuver Bonus + cmd: int | None = None # Combat Maneuver Defense casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell - skills: dict[str, SkillRank] = Field(default_factory=dict) + spells: list[str] = Field(default_factory=list)