first commit
This commit is contained in:
414
api/app/models/combat.py
Normal file
414
api/app/models/combat.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
Combat system data models.
|
||||
|
||||
This module defines the combat-related dataclasses including Combatant (a wrapper
|
||||
for characters/enemies in combat) and CombatEncounter (the combat state manager).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional
|
||||
import random
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.effects import Effect
|
||||
from app.models.abilities import Ability
|
||||
from app.models.enums import CombatStatus, EffectType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Combatant:
|
||||
"""
|
||||
Represents a character or enemy in combat.
|
||||
|
||||
This wraps either a player Character or an NPC/enemy for combat purposes,
|
||||
tracking combat-specific state like current HP/MP, active effects, and cooldowns.
|
||||
|
||||
Attributes:
|
||||
combatant_id: Unique identifier (character_id or enemy_id)
|
||||
name: Display name
|
||||
is_player: True if player character, False if NPC/enemy
|
||||
current_hp: Current hit points
|
||||
max_hp: Maximum hit points
|
||||
current_mp: Current mana points
|
||||
max_mp: Maximum mana points
|
||||
stats: Current combat stats (use get_effective_stats() from Character)
|
||||
active_effects: Effects currently applied to this combatant
|
||||
abilities: Available abilities for this combatant
|
||||
cooldowns: Map of ability_id to turns remaining
|
||||
initiative: Turn order value (rolled at combat start)
|
||||
"""
|
||||
|
||||
combatant_id: str
|
||||
name: str
|
||||
is_player: bool
|
||||
current_hp: int
|
||||
max_hp: int
|
||||
current_mp: int
|
||||
max_mp: int
|
||||
stats: Stats
|
||||
active_effects: List[Effect] = field(default_factory=list)
|
||||
abilities: List[str] = field(default_factory=list) # ability_ids
|
||||
cooldowns: Dict[str, int] = field(default_factory=dict)
|
||||
initiative: int = 0
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if combatant is still alive."""
|
||||
return self.current_hp > 0
|
||||
|
||||
def is_dead(self) -> bool:
|
||||
"""Check if combatant is dead."""
|
||||
return self.current_hp <= 0
|
||||
|
||||
def is_stunned(self) -> bool:
|
||||
"""Check if combatant is stunned and cannot act."""
|
||||
return any(e.effect_type == EffectType.STUN for e in self.active_effects)
|
||||
|
||||
def take_damage(self, damage: int) -> int:
|
||||
"""
|
||||
Apply damage to this combatant.
|
||||
|
||||
Damage is reduced by shields first, then HP.
|
||||
|
||||
Args:
|
||||
damage: Amount of damage to apply
|
||||
|
||||
Returns:
|
||||
Actual damage dealt to HP (after shields)
|
||||
"""
|
||||
remaining_damage = damage
|
||||
|
||||
# Apply shield absorption
|
||||
for effect in self.active_effects:
|
||||
if effect.effect_type == EffectType.SHIELD and remaining_damage > 0:
|
||||
remaining_damage = effect.reduce_shield(remaining_damage)
|
||||
|
||||
# Apply remaining damage to HP
|
||||
hp_damage = min(remaining_damage, self.current_hp)
|
||||
self.current_hp -= hp_damage
|
||||
|
||||
return hp_damage
|
||||
|
||||
def heal(self, amount: int) -> int:
|
||||
"""
|
||||
Heal this combatant.
|
||||
|
||||
Args:
|
||||
amount: Amount to heal
|
||||
|
||||
Returns:
|
||||
Actual amount healed (capped at max_hp)
|
||||
"""
|
||||
old_hp = self.current_hp
|
||||
self.current_hp = min(self.max_hp, self.current_hp + amount)
|
||||
return self.current_hp - old_hp
|
||||
|
||||
def restore_mana(self, amount: int) -> int:
|
||||
"""
|
||||
Restore mana to this combatant.
|
||||
|
||||
Args:
|
||||
amount: Amount to restore
|
||||
|
||||
Returns:
|
||||
Actual amount restored (capped at max_mp)
|
||||
"""
|
||||
old_mp = self.current_mp
|
||||
self.current_mp = min(self.max_mp, self.current_mp + amount)
|
||||
return self.current_mp - old_mp
|
||||
|
||||
def can_use_ability(self, ability_id: str, ability: Ability) -> bool:
|
||||
"""
|
||||
Check if ability can be used right now.
|
||||
|
||||
Args:
|
||||
ability_id: Ability identifier
|
||||
ability: Ability instance
|
||||
|
||||
Returns:
|
||||
True if ability can be used, False otherwise
|
||||
"""
|
||||
# Check if ability is available to this combatant
|
||||
if ability_id not in self.abilities:
|
||||
return False
|
||||
|
||||
# Check mana cost
|
||||
if self.current_mp < ability.mana_cost:
|
||||
return False
|
||||
|
||||
# Check cooldown
|
||||
if ability_id in self.cooldowns and self.cooldowns[ability_id] > 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def use_ability_cost(self, ability: Ability, ability_id: str) -> None:
|
||||
"""
|
||||
Apply the costs of using an ability (mana, cooldown).
|
||||
|
||||
Args:
|
||||
ability: Ability being used
|
||||
ability_id: Ability identifier
|
||||
"""
|
||||
# Consume mana
|
||||
self.current_mp -= ability.mana_cost
|
||||
|
||||
# Set cooldown
|
||||
if ability.cooldown > 0:
|
||||
self.cooldowns[ability_id] = ability.cooldown
|
||||
|
||||
def tick_effects(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process all active effects for this turn.
|
||||
|
||||
Returns:
|
||||
List of effect tick results
|
||||
"""
|
||||
results = []
|
||||
expired_effects = []
|
||||
|
||||
for effect in self.active_effects:
|
||||
result = effect.tick()
|
||||
|
||||
# Apply effect results
|
||||
if effect.effect_type == EffectType.DOT:
|
||||
self.take_damage(result["value"])
|
||||
elif effect.effect_type == EffectType.HOT:
|
||||
self.heal(result["value"])
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Mark expired effects for removal
|
||||
if result.get("expired", False):
|
||||
expired_effects.append(effect)
|
||||
|
||||
# Remove expired effects
|
||||
for effect in expired_effects:
|
||||
self.active_effects.remove(effect)
|
||||
|
||||
return results
|
||||
|
||||
def tick_cooldowns(self) -> None:
|
||||
"""Reduce all ability cooldowns by 1 turn."""
|
||||
for ability_id in list(self.cooldowns.keys()):
|
||||
self.cooldowns[ability_id] -= 1
|
||||
if self.cooldowns[ability_id] <= 0:
|
||||
del self.cooldowns[ability_id]
|
||||
|
||||
def add_effect(self, effect: Effect) -> None:
|
||||
"""
|
||||
Add an effect to this combatant.
|
||||
|
||||
If the same effect already exists, stack it instead.
|
||||
|
||||
Args:
|
||||
effect: Effect to add
|
||||
"""
|
||||
# Check if effect already exists
|
||||
for existing in self.active_effects:
|
||||
if existing.name == effect.name and existing.effect_type == effect.effect_type:
|
||||
# Stack the effect
|
||||
existing.apply_stack(effect.duration)
|
||||
return
|
||||
|
||||
# New effect, add it
|
||||
self.active_effects.append(effect)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize combatant to dictionary."""
|
||||
return {
|
||||
"combatant_id": self.combatant_id,
|
||||
"name": self.name,
|
||||
"is_player": self.is_player,
|
||||
"current_hp": self.current_hp,
|
||||
"max_hp": self.max_hp,
|
||||
"current_mp": self.current_mp,
|
||||
"max_mp": self.max_mp,
|
||||
"stats": self.stats.to_dict(),
|
||||
"active_effects": [e.to_dict() for e in self.active_effects],
|
||||
"abilities": self.abilities,
|
||||
"cooldowns": self.cooldowns,
|
||||
"initiative": self.initiative,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Combatant':
|
||||
"""Deserialize combatant from dictionary."""
|
||||
stats = Stats.from_dict(data["stats"])
|
||||
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
||||
|
||||
return cls(
|
||||
combatant_id=data["combatant_id"],
|
||||
name=data["name"],
|
||||
is_player=data["is_player"],
|
||||
current_hp=data["current_hp"],
|
||||
max_hp=data["max_hp"],
|
||||
current_mp=data["current_mp"],
|
||||
max_mp=data["max_mp"],
|
||||
stats=stats,
|
||||
active_effects=active_effects,
|
||||
abilities=data.get("abilities", []),
|
||||
cooldowns=data.get("cooldowns", {}),
|
||||
initiative=data.get("initiative", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombatEncounter:
|
||||
"""
|
||||
Represents a combat encounter state.
|
||||
|
||||
Manages turn order, combatants, combat log, and victory/defeat conditions.
|
||||
|
||||
Attributes:
|
||||
encounter_id: Unique identifier
|
||||
combatants: All fighters in this combat
|
||||
turn_order: Combatant IDs sorted by initiative (highest first)
|
||||
current_turn_index: Index in turn_order for current turn
|
||||
round_number: Current round (increments each full turn cycle)
|
||||
combat_log: History of all actions taken
|
||||
status: Current combat status (active, victory, defeat, fled)
|
||||
"""
|
||||
|
||||
encounter_id: str
|
||||
combatants: List[Combatant] = field(default_factory=list)
|
||||
turn_order: List[str] = field(default_factory=list)
|
||||
current_turn_index: int = 0
|
||||
round_number: int = 1
|
||||
combat_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
status: CombatStatus = CombatStatus.ACTIVE
|
||||
|
||||
def initialize_combat(self) -> None:
|
||||
"""
|
||||
Initialize combat by rolling initiative and setting turn order.
|
||||
|
||||
Initiative: d20 + dexterity bonus
|
||||
"""
|
||||
# Roll initiative for all combatants
|
||||
for combatant in self.combatants:
|
||||
# d20 + dexterity bonus
|
||||
roll = random.randint(1, 20)
|
||||
dex_bonus = combatant.stats.dexterity // 2
|
||||
combatant.initiative = roll + dex_bonus
|
||||
|
||||
# Sort combatants by initiative (highest first)
|
||||
sorted_combatants = sorted(self.combatants, key=lambda c: c.initiative, reverse=True)
|
||||
self.turn_order = [c.combatant_id for c in sorted_combatants]
|
||||
|
||||
self.log_action("combat_start", None, f"Combat begins! Round {self.round_number}")
|
||||
|
||||
def get_current_combatant(self) -> Optional[Combatant]:
|
||||
"""Get the combatant whose turn it currently is."""
|
||||
if not self.turn_order:
|
||||
return None
|
||||
|
||||
current_id = self.turn_order[self.current_turn_index]
|
||||
return self.get_combatant(current_id)
|
||||
|
||||
def get_combatant(self, combatant_id: str) -> Optional[Combatant]:
|
||||
"""Get a combatant by ID."""
|
||||
for combatant in self.combatants:
|
||||
if combatant.combatant_id == combatant_id:
|
||||
return combatant
|
||||
return None
|
||||
|
||||
def advance_turn(self) -> None:
|
||||
"""Advance to the next combatant's turn."""
|
||||
self.current_turn_index += 1
|
||||
|
||||
# If we've cycled through all combatants, start a new round
|
||||
if self.current_turn_index >= len(self.turn_order):
|
||||
self.current_turn_index = 0
|
||||
self.round_number += 1
|
||||
self.log_action("round_start", None, f"Round {self.round_number} begins")
|
||||
|
||||
def start_turn(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process the start of a turn.
|
||||
|
||||
- Tick all effects on current combatant
|
||||
- Tick cooldowns
|
||||
- Check for stun
|
||||
|
||||
Returns:
|
||||
List of effect tick results
|
||||
"""
|
||||
combatant = self.get_current_combatant()
|
||||
if not combatant:
|
||||
return []
|
||||
|
||||
# Process effects
|
||||
effect_results = combatant.tick_effects()
|
||||
|
||||
# Reduce cooldowns
|
||||
combatant.tick_cooldowns()
|
||||
|
||||
return effect_results
|
||||
|
||||
def check_end_condition(self) -> CombatStatus:
|
||||
"""
|
||||
Check if combat should end.
|
||||
|
||||
Victory: All enemy combatants dead
|
||||
Defeat: All player combatants dead
|
||||
|
||||
Returns:
|
||||
Updated combat status
|
||||
"""
|
||||
players_alive = any(c.is_alive() and c.is_player for c in self.combatants)
|
||||
enemies_alive = any(c.is_alive() and not c.is_player for c in self.combatants)
|
||||
|
||||
if not enemies_alive and players_alive:
|
||||
self.status = CombatStatus.VICTORY
|
||||
self.log_action("combat_end", None, "Victory! All enemies defeated!")
|
||||
elif not players_alive:
|
||||
self.status = CombatStatus.DEFEAT
|
||||
self.log_action("combat_end", None, "Defeat! All players have fallen!")
|
||||
|
||||
return self.status
|
||||
|
||||
def log_action(self, action_type: str, combatant_id: Optional[str], message: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""
|
||||
Log a combat action.
|
||||
|
||||
Args:
|
||||
action_type: Type of action (attack, spell, item_use, etc.)
|
||||
combatant_id: ID of acting combatant (or None for system messages)
|
||||
message: Human-readable message
|
||||
details: Additional action details
|
||||
"""
|
||||
entry = {
|
||||
"round": self.round_number,
|
||||
"action_type": action_type,
|
||||
"combatant_id": combatant_id,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
}
|
||||
self.combat_log.append(entry)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize combat encounter to dictionary."""
|
||||
return {
|
||||
"encounter_id": self.encounter_id,
|
||||
"combatants": [c.to_dict() for c in self.combatants],
|
||||
"turn_order": self.turn_order,
|
||||
"current_turn_index": self.current_turn_index,
|
||||
"round_number": self.round_number,
|
||||
"combat_log": self.combat_log,
|
||||
"status": self.status.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'CombatEncounter':
|
||||
"""Deserialize combat encounter from dictionary."""
|
||||
combatants = [Combatant.from_dict(c) for c in data.get("combatants", [])]
|
||||
status = CombatStatus(data.get("status", "active"))
|
||||
|
||||
return cls(
|
||||
encounter_id=data["encounter_id"],
|
||||
combatants=combatants,
|
||||
turn_order=data.get("turn_order", []),
|
||||
current_turn_index=data.get("current_turn_index", 0),
|
||||
round_number=data.get("round_number", 1),
|
||||
combat_log=data.get("combat_log", []),
|
||||
status=status,
|
||||
)
|
||||
Reference in New Issue
Block a user