feat(combat): class action patterns
This commit is contained in:
@@ -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"
|
||||||
|
)
|
||||||
@@ -1144,6 +1144,92 @@ class CombatEngine:
|
|||||||
self._log(f"round {self._current_round} {state.combatant.id}: cackle extends hex on {target.combatant.id}")
|
self._log(f"round {self._current_round} {state.combatant.id}: cackle extends hex on {target.combatant.id}")
|
||||||
else:
|
else:
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: cackle - no hex to extend on {target.combatant.id}")
|
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(
|
def _execute_attack(
|
||||||
self, state: CombatantState, target: CombatantState | None, kind: str
|
self, state: CombatantState, target: CombatantState | None, kind: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user