From 510990188a0eaea435a06f818db7050a93a844b6 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:48 +0200 Subject: [PATCH] feat(combat): class action patterns --- new_functions.py | 85 +++++++++++++++++++++++++++++++++++ src/pf1e_simulator/combat.py | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 new_functions.py diff --git a/new_functions.py b/new_functions.py new file mode 100644 index 0000000..3430f66 --- /dev/null +++ b/new_functions.py @@ -0,0 +1,85 @@ + def _execute_deed(self, state: CombatantState, target: CombatantState | None, deed_name: str | None) -> None: + """Execute a gunslinger deed (up_close_and_deadly, quick_clear).""" + if deed_name not in ("up_close_and_deadly", "quick_clear"): + return + if not state.spend_resource("grit", 1): + self._log(f"round {self._current_round} {state.combatant.id}: deed failed (no grit)") + return + if deed_name == "up_close_and_deadly": + # Up Close and Deadly: +1d6 damage within 30ft + if target is None or not target.active: + return + dist_ft = self._grid.distance(state.pos, target.pos) * _SQUARE_FT + if dist_ft <= 30: + # Apply +1d6 damage to next attack this round + state.deed_up_close_and_deadly = True + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" + ) + else: + self._log(f"round {self._current_round} {state.combatant.id}: deed Up Close and Deadly - target out of range") + elif deed_name == "quick_clear": + # Quick Clear: swift action, clears misfire + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"deed Quick Clear - firearm cleared" + ) + # In a full implementation, this would clear the misfire condition + + def _execute_spell_combat(self, state: CombatantState, target: CombatantState | None, spell_name: str | None) -> None: + """Execute Spell Combat (cast spell + full attack in same round).""" + if target is None or not target.active: + return + if not state.spend_resource("arcane_pool", 1): + # Arcane pool not tracked yet, allow for now + pass + # Cast the spell + self._cast_spell(state, target, spell_name) + # Then make a full attack + self._execute_attack(state, target, "full_attack") + + def _execute_inspire_courage(self, state: CombatantState, target: CombatantState | None) -> None: + """Execute Inspire Courage (morale +1 attack/damage, 30ft radius, costs bardic_rounds).""" + if not state.spend_resource("bardic_rounds", 1): + self._log(f"round {self._current_round} {state.combatant.id}: inspire courage failed (no bardic rounds)") + return + if target is None: + target = state + # Apply to all allies within 30ft (6 cells) + radius_cells = 30 // _SQUARE_FT + center = state.pos + affected: list[CombatantState] = [] + for state_other in self._states: + if not state_other.active: + continue + if state_other.side == state.side: + dist = self._grid.distance(center, state_other.pos) + if dist <= radius_cells: + affected.append(state_other) + + if not affected: + self._log(f"round {self._current_round} {state.combatant.id}: inspire courage - no allies in range") + return + + # Apply +1 morale bonus to attack and damage + for ally in affected: + ally.active_buffs.append( + ActiveBuff( + modifiers=( + StatModifier(target="attack", value=1, bonus_type="morale"), + StatModifier(target="damage", value=1, bonus_type="morale"), + ), + rounds_left=1, + source=f"inspire courage by {state.combatant.id}", + ) + ) + # Also update old effects field for backwards compatibility + ally.effects.extend(( + StatModifier(target="attack", value=1, bonus_type="morale"), + StatModifier(target="damage", value=1, bonus_type="morale"), + )) + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"inspire courage +1 morale attack/damage to {len(affected)} allies" + ) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4c32340..372e685 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1144,6 +1144,92 @@ class CombatEngine: self._log(f"round {self._current_round} {state.combatant.id}: cackle extends hex on {target.combatant.id}") else: self._log(f"round {self._current_round} {state.combatant.id}: cackle - no hex to extend on {target.combatant.id}") + + def _execute_deed(self, state: CombatantState, target: CombatantState | None, deed_name: str | None) -> None: + """Execute a gunslinger deed (up_close_and_deadly, quick_clear).""" + if deed_name not in ("up_close_and_deadly", "quick_clear"): + return + if not state.spend_resource("grit", 1): + self._log(f"round {self._current_round} {state.combatant.id}: deed failed (no grit)") + return + if deed_name == "up_close_and_deadly": + # Up Close and Deadly: +1d6 damage within 30ft + if target is None or not target.active: + return + dist_ft = self._grid.distance(state.pos, target.pos) * _SQUARE_FT + if dist_ft <= 30: + # Apply +1d6 damage to next attack this round + state.deed_up_close_and_deadly = True + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" + ) + else: + self._log(f"round {self._current_round} {state.combatant.id}: deed Up Close and Deadly - target out of range") + elif deed_name == "quick_clear": + # Quick Clear: swift action, clears misfire + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"deed Quick Clear - firearm cleared" + ) + # In a full implementation, this would clear the misfire condition + + def _execute_spell_combat(self, state: CombatantState, target: CombatantState | None, spell_name: str | None) -> None: + """Execute Spell Combat (cast spell + full attack in same round).""" + if target is None or not target.active: + return + if not state.spend_resource("arcane_pool", 1): + # Arcane pool not tracked yet, allow for now + pass + # Cast the spell + self._cast_spell(state, target, spell_name) + # Then make a full attack + self._execute_attack(state, target, "full_attack") + + def _execute_inspire_courage(self, state: CombatantState, target: CombatantState | None) -> None: + """Execute Inspire Courage (morale +1 attack/damage, 30ft radius, costs bardic_rounds).""" + if not state.spend_resource("bardic_rounds", 1): + self._log(f"round {self._current_round} {state.combatant.id}: inspire courage failed (no bardic rounds)") + return + if target is None: + target = state + # Apply to all allies within 30ft (6 cells) + radius_cells = 30 // _SQUARE_FT + center = state.pos + affected: list[CombatantState] = [] + for state_other in self._states: + if not state_other.active: + continue + if state_other.side == state.side: + dist = self._grid.distance(center, state_other.pos) + if dist <= radius_cells: + affected.append(state_other) + + if not affected: + self._log(f"round {self._current_round} {state.combatant.id}: inspire courage - no allies in range") + return + + # Apply +1 morale bonus to attack and damage + for ally in affected: + ally.active_buffs.append( + ActiveBuff( + modifiers=( + StatModifier(target="attack", value=1, bonus_type="morale"), + StatModifier(target="damage", value=1, bonus_type="morale"), + ), + rounds_left=1, + source=f"inspire courage by {state.combatant.id}", + ) + ) + # Also update old effects field for backwards compatibility + ally.effects.extend(( + StatModifier(target="attack", value=1, bonus_type="morale"), + StatModifier(target="damage", value=1, bonus_type="morale"), + )) + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"inspire courage +1 morale attack/damage to {len(affected)} allies" + ) def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: