feat(api): integrate equipment stats into combat damage system
Equipment-Combat Integration: - Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling - Add spell_power system for magical weapons (staves, wands) - Add spell_power_bonus field to Stats model with spell_power property - Add spell_power field to Item model with is_magical_weapon() method - Update Character.get_effective_stats() to populate spell_power_bonus Combatant Model Updates: - Add weapon property fields (crit_chance, crit_multiplier, damage_type) - Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio) - Update serialization to handle new weapon properties DamageCalculator Refactoring: - Remove weapon_damage parameter from calculate_physical_damage() - Use attacker_stats.damage directly (includes weapon bonus) - Use attacker_stats.spell_power for magical damage calculations Combat Service Updates: - Extract weapon properties in _create_combatant_from_character() - Use stats.damage_bonus for enemy combatants from templates - Remove hardcoded _get_weapon_damage() method - Handle elemental weapons with split damage in _execute_attack() Item Generation Updates: - Add base_spell_power to BaseItemTemplate dataclass - Add ARCANE damage type to DamageType enum - Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand) Test Updates: - Update test_stats.py for new damage formula (0.75 scaling) - Update test_character.py for equipment bonus calculations - Update test_damage_calculator.py for new API signatures - Update test_combat_service.py mock fixture for equipped attribute Tests: 174 passing
This commit is contained in:
@@ -146,14 +146,59 @@ weapons:
|
|||||||
name: "Wizard Staff"
|
name: "Wizard Staff"
|
||||||
item_type: "weapon"
|
item_type: "weapon"
|
||||||
description: "A staff attuned to magical energy"
|
description: "A staff attuned to magical energy"
|
||||||
base_damage: 8
|
base_damage: 4
|
||||||
|
base_spell_power: 12
|
||||||
base_value: 45
|
base_value: 45
|
||||||
damage_type: "physical"
|
damage_type: "arcane"
|
||||||
crit_chance: 0.05
|
crit_chance: 0.05
|
||||||
crit_multiplier: 2.0
|
crit_multiplier: 2.0
|
||||||
required_level: 3
|
required_level: 3
|
||||||
drop_weight: 0.8
|
drop_weight: 0.8
|
||||||
|
|
||||||
|
arcane_staff:
|
||||||
|
template_id: "arcane_staff"
|
||||||
|
name: "Arcane Staff"
|
||||||
|
item_type: "weapon"
|
||||||
|
description: "A powerful staff pulsing with arcane power"
|
||||||
|
base_damage: 6
|
||||||
|
base_spell_power: 18
|
||||||
|
base_value: 90
|
||||||
|
damage_type: "arcane"
|
||||||
|
crit_chance: 0.06
|
||||||
|
crit_multiplier: 2.0
|
||||||
|
required_level: 5
|
||||||
|
drop_weight: 0.6
|
||||||
|
min_rarity: "uncommon"
|
||||||
|
|
||||||
|
# ==================== WANDS ====================
|
||||||
|
wand:
|
||||||
|
template_id: "wand"
|
||||||
|
name: "Wand"
|
||||||
|
item_type: "weapon"
|
||||||
|
description: "A simple magical focus"
|
||||||
|
base_damage: 2
|
||||||
|
base_spell_power: 8
|
||||||
|
base_value: 30
|
||||||
|
damage_type: "arcane"
|
||||||
|
crit_chance: 0.06
|
||||||
|
crit_multiplier: 2.0
|
||||||
|
required_level: 1
|
||||||
|
drop_weight: 1.0
|
||||||
|
|
||||||
|
crystal_wand:
|
||||||
|
template_id: "crystal_wand"
|
||||||
|
name: "Crystal Wand"
|
||||||
|
item_type: "weapon"
|
||||||
|
description: "A wand topped with a magical crystal"
|
||||||
|
base_damage: 3
|
||||||
|
base_spell_power: 14
|
||||||
|
base_value: 60
|
||||||
|
damage_type: "arcane"
|
||||||
|
crit_chance: 0.07
|
||||||
|
crit_multiplier: 2.2
|
||||||
|
required_level: 4
|
||||||
|
drop_weight: 0.8
|
||||||
|
|
||||||
# ==================== RANGED ====================
|
# ==================== RANGED ====================
|
||||||
shortbow:
|
shortbow:
|
||||||
template_id: "shortbow"
|
template_id: "shortbow"
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class BaseItemTemplate:
|
|||||||
|
|
||||||
# Base stats
|
# Base stats
|
||||||
base_damage: int = 0
|
base_damage: int = 0
|
||||||
|
base_spell_power: int = 0 # For magical weapons (staves, wands)
|
||||||
base_defense: int = 0
|
base_defense: int = 0
|
||||||
base_resistance: int = 0
|
base_resistance: int = 0
|
||||||
base_value: int = 10
|
base_value: int = 10
|
||||||
@@ -276,6 +277,7 @@ class BaseItemTemplate:
|
|||||||
item_type=data["item_type"],
|
item_type=data["item_type"],
|
||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
base_damage=data.get("base_damage", 0),
|
base_damage=data.get("base_damage", 0),
|
||||||
|
base_spell_power=data.get("base_spell_power", 0),
|
||||||
base_defense=data.get("base_defense", 0),
|
base_defense=data.get("base_defense", 0),
|
||||||
base_resistance=data.get("base_resistance", 0),
|
base_resistance=data.get("base_resistance", 0),
|
||||||
base_value=data.get("base_value", 10),
|
base_value=data.get("base_value", 10),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.models.stats import Stats
|
|||||||
from app.models.items import Item
|
from app.models.items import Item
|
||||||
from app.models.skills import PlayerClass, SkillNode
|
from app.models.skills import PlayerClass, SkillNode
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
from app.models.enums import EffectType, StatType
|
from app.models.enums import EffectType, StatType, ItemType
|
||||||
from app.models.origins import Origin
|
from app.models.origins import Origin
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +92,11 @@ class Character:
|
|||||||
|
|
||||||
This is the CRITICAL METHOD that combines:
|
This is the CRITICAL METHOD that combines:
|
||||||
1. Base stats (from character)
|
1. Base stats (from character)
|
||||||
2. Equipment bonuses (from equipped items)
|
2. Equipment bonuses (from equipped items):
|
||||||
|
- stat_bonuses dict applied to corresponding stats
|
||||||
|
- Weapon damage added to damage_bonus
|
||||||
|
- Weapon spell_power added to spell_power_bonus
|
||||||
|
- Armor defense/resistance added to defense_bonus/resistance_bonus
|
||||||
3. Skill tree bonuses (from unlocked skills)
|
3. Skill tree bonuses (from unlocked skills)
|
||||||
4. Active effect modifiers (buffs/debuffs)
|
4. Active effect modifiers (buffs/debuffs)
|
||||||
|
|
||||||
@@ -100,18 +104,30 @@ class Character:
|
|||||||
active_effects: Currently active effects on this character (from combat)
|
active_effects: Currently active effects on this character (from combat)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stats instance with all modifiers applied
|
Stats instance with all modifiers applied (including computed
|
||||||
|
damage, defense, resistance properties that incorporate bonuses)
|
||||||
"""
|
"""
|
||||||
# Start with a copy of base stats
|
# Start with a copy of base stats
|
||||||
effective = self.base_stats.copy()
|
effective = self.base_stats.copy()
|
||||||
|
|
||||||
# Apply equipment bonuses
|
# Apply equipment bonuses
|
||||||
for item in self.equipped.values():
|
for item in self.equipped.values():
|
||||||
|
# Apply stat bonuses from item (e.g., +3 strength)
|
||||||
for stat_name, bonus in item.stat_bonuses.items():
|
for stat_name, bonus in item.stat_bonuses.items():
|
||||||
if hasattr(effective, stat_name):
|
if hasattr(effective, stat_name):
|
||||||
current_value = getattr(effective, stat_name)
|
current_value = getattr(effective, stat_name)
|
||||||
setattr(effective, stat_name, current_value + bonus)
|
setattr(effective, stat_name, current_value + bonus)
|
||||||
|
|
||||||
|
# Add weapon damage and spell_power to bonus fields
|
||||||
|
if item.item_type == ItemType.WEAPON:
|
||||||
|
effective.damage_bonus += item.damage
|
||||||
|
effective.spell_power_bonus += item.spell_power
|
||||||
|
|
||||||
|
# Add armor defense and resistance to bonus fields
|
||||||
|
if item.item_type == ItemType.ARMOR:
|
||||||
|
effective.defense_bonus += item.defense
|
||||||
|
effective.resistance_bonus += item.resistance
|
||||||
|
|
||||||
# Apply skill tree bonuses
|
# Apply skill tree bonuses
|
||||||
skill_bonuses = self._get_skill_bonuses()
|
skill_bonuses = self._get_skill_bonuses()
|
||||||
for stat_name, bonus in skill_bonuses.items():
|
for stat_name, bonus in skill_bonuses.items():
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import random
|
|||||||
from app.models.stats import Stats
|
from app.models.stats import Stats
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
from app.models.abilities import Ability
|
from app.models.abilities import Ability
|
||||||
from app.models.enums import CombatStatus, EffectType
|
from app.models.enums import CombatStatus, EffectType, DamageType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -36,6 +36,12 @@ class Combatant:
|
|||||||
abilities: Available abilities for this combatant
|
abilities: Available abilities for this combatant
|
||||||
cooldowns: Map of ability_id to turns remaining
|
cooldowns: Map of ability_id to turns remaining
|
||||||
initiative: Turn order value (rolled at combat start)
|
initiative: Turn order value (rolled at combat start)
|
||||||
|
weapon_crit_chance: Critical hit chance from equipped weapon
|
||||||
|
weapon_crit_multiplier: Critical hit damage multiplier
|
||||||
|
weapon_damage_type: Primary damage type of weapon
|
||||||
|
elemental_damage_type: Secondary damage type for elemental weapons
|
||||||
|
physical_ratio: Portion of damage that is physical (0.0-1.0)
|
||||||
|
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
combatant_id: str
|
combatant_id: str
|
||||||
@@ -51,6 +57,16 @@ class Combatant:
|
|||||||
cooldowns: Dict[str, int] = field(default_factory=dict)
|
cooldowns: Dict[str, int] = field(default_factory=dict)
|
||||||
initiative: int = 0
|
initiative: int = 0
|
||||||
|
|
||||||
|
# Weapon properties (for combat calculations)
|
||||||
|
weapon_crit_chance: float = 0.05
|
||||||
|
weapon_crit_multiplier: float = 2.0
|
||||||
|
weapon_damage_type: Optional[DamageType] = None
|
||||||
|
|
||||||
|
# Elemental weapon properties (for split damage)
|
||||||
|
elemental_damage_type: Optional[DamageType] = None
|
||||||
|
physical_ratio: float = 1.0
|
||||||
|
elemental_ratio: float = 0.0
|
||||||
|
|
||||||
def is_alive(self) -> bool:
|
def is_alive(self) -> bool:
|
||||||
"""Check if combatant is still alive."""
|
"""Check if combatant is still alive."""
|
||||||
return self.current_hp > 0
|
return self.current_hp > 0
|
||||||
@@ -228,6 +244,12 @@ class Combatant:
|
|||||||
"abilities": self.abilities,
|
"abilities": self.abilities,
|
||||||
"cooldowns": self.cooldowns,
|
"cooldowns": self.cooldowns,
|
||||||
"initiative": self.initiative,
|
"initiative": self.initiative,
|
||||||
|
"weapon_crit_chance": self.weapon_crit_chance,
|
||||||
|
"weapon_crit_multiplier": self.weapon_crit_multiplier,
|
||||||
|
"weapon_damage_type": self.weapon_damage_type.value if self.weapon_damage_type else None,
|
||||||
|
"elemental_damage_type": self.elemental_damage_type.value if self.elemental_damage_type else None,
|
||||||
|
"physical_ratio": self.physical_ratio,
|
||||||
|
"elemental_ratio": self.elemental_ratio,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -236,6 +258,15 @@ class Combatant:
|
|||||||
stats = Stats.from_dict(data["stats"])
|
stats = Stats.from_dict(data["stats"])
|
||||||
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
||||||
|
|
||||||
|
# Parse damage types
|
||||||
|
weapon_damage_type = None
|
||||||
|
if data.get("weapon_damage_type"):
|
||||||
|
weapon_damage_type = DamageType(data["weapon_damage_type"])
|
||||||
|
|
||||||
|
elemental_damage_type = None
|
||||||
|
if data.get("elemental_damage_type"):
|
||||||
|
elemental_damage_type = DamageType(data["elemental_damage_type"])
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
combatant_id=data["combatant_id"],
|
combatant_id=data["combatant_id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -249,6 +280,12 @@ class Combatant:
|
|||||||
abilities=data.get("abilities", []),
|
abilities=data.get("abilities", []),
|
||||||
cooldowns=data.get("cooldowns", {}),
|
cooldowns=data.get("cooldowns", {}),
|
||||||
initiative=data.get("initiative", 0),
|
initiative=data.get("initiative", 0),
|
||||||
|
weapon_crit_chance=data.get("weapon_crit_chance", 0.05),
|
||||||
|
weapon_crit_multiplier=data.get("weapon_crit_multiplier", 2.0),
|
||||||
|
weapon_damage_type=weapon_damage_type,
|
||||||
|
elemental_damage_type=elemental_damage_type,
|
||||||
|
physical_ratio=data.get("physical_ratio", 1.0),
|
||||||
|
elemental_ratio=data.get("elemental_ratio", 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class DamageType(Enum):
|
|||||||
HOLY = "holy" # Holy/divine damage
|
HOLY = "holy" # Holy/divine damage
|
||||||
SHADOW = "shadow" # Dark/shadow magic damage
|
SHADOW = "shadow" # Dark/shadow magic damage
|
||||||
POISON = "poison" # Poison damage (usually DoT)
|
POISON = "poison" # Poison damage (usually DoT)
|
||||||
|
ARCANE = "arcane" # Pure magical damage (staves, wands)
|
||||||
|
|
||||||
|
|
||||||
class ItemType(Enum):
|
class ItemType(Enum):
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ class Item:
|
|||||||
effects_on_use: Effects applied when consumed (consumables only)
|
effects_on_use: Effects applied when consumed (consumables only)
|
||||||
|
|
||||||
Weapon-specific attributes:
|
Weapon-specific attributes:
|
||||||
damage: Base weapon damage
|
damage: Base weapon damage (physical/melee/ranged)
|
||||||
|
spell_power: Spell power for staves/wands (boosts spell damage)
|
||||||
damage_type: Type of damage (physical, fire, etc.)
|
damage_type: Type of damage (physical, fire, etc.)
|
||||||
crit_chance: Probability of critical hit (0.0 to 1.0)
|
crit_chance: Probability of critical hit (0.0 to 1.0)
|
||||||
crit_multiplier: Damage multiplier on critical hit
|
crit_multiplier: Damage multiplier on critical hit
|
||||||
@@ -62,7 +63,8 @@ class Item:
|
|||||||
effects_on_use: List[Effect] = field(default_factory=list)
|
effects_on_use: List[Effect] = field(default_factory=list)
|
||||||
|
|
||||||
# Weapon-specific
|
# Weapon-specific
|
||||||
damage: int = 0
|
damage: int = 0 # Physical damage for melee/ranged weapons
|
||||||
|
spell_power: int = 0 # Spell power for staves/wands (boosts spell damage)
|
||||||
damage_type: Optional[DamageType] = None
|
damage_type: Optional[DamageType] = None
|
||||||
crit_chance: float = 0.05 # 5% default critical hit chance
|
crit_chance: float = 0.05 # 5% default critical hit chance
|
||||||
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
||||||
@@ -136,6 +138,18 @@ class Item:
|
|||||||
self.elemental_damage_type is not None
|
self.elemental_damage_type is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_magical_weapon(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this weapon is a spell-casting weapon (staff, wand, tome).
|
||||||
|
|
||||||
|
Magical weapons provide spell_power which boosts spell damage,
|
||||||
|
rather than physical damage for melee/ranged attacks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if weapon has spell_power (staves, wands, etc.)
|
||||||
|
"""
|
||||||
|
return self.is_weapon() and self.spell_power > 0
|
||||||
|
|
||||||
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a character can equip this item.
|
Check if a character can equip this item.
|
||||||
|
|||||||
@@ -22,12 +22,18 @@ class Stats:
|
|||||||
wisdom: Perception and insight, affects magical resistance
|
wisdom: Perception and insight, affects magical resistance
|
||||||
charisma: Social influence, affects NPC interactions
|
charisma: Social influence, affects NPC interactions
|
||||||
luck: Fortune and fate, affects critical hits, loot, and random outcomes
|
luck: Fortune and fate, affects critical hits, loot, and random outcomes
|
||||||
|
damage_bonus: Flat damage bonus from equipped weapons (default 0)
|
||||||
|
spell_power_bonus: Flat spell power bonus from staves/wands (default 0)
|
||||||
|
defense_bonus: Flat defense bonus from equipped armor (default 0)
|
||||||
|
resistance_bonus: Flat resistance bonus from equipped armor (default 0)
|
||||||
|
|
||||||
Computed Properties:
|
Computed Properties:
|
||||||
hit_points: Maximum HP = 10 + (constitution × 2)
|
hit_points: Maximum HP = 10 + (constitution × 2)
|
||||||
mana_points: Maximum MP = 10 + (intelligence × 2)
|
mana_points: Maximum MP = 10 + (intelligence × 2)
|
||||||
defense: Physical defense = constitution // 2
|
damage: Physical damage = int(strength × 0.75) + damage_bonus
|
||||||
resistance: Magical resistance = wisdom // 2
|
spell_power: Spell power = int(intelligence × 0.75) + spell_power_bonus
|
||||||
|
defense: Physical defense = (constitution // 2) + defense_bonus
|
||||||
|
resistance: Magical resistance = (wisdom // 2) + resistance_bonus
|
||||||
"""
|
"""
|
||||||
|
|
||||||
strength: int = 10
|
strength: int = 10
|
||||||
@@ -38,6 +44,12 @@ class Stats:
|
|||||||
charisma: int = 10
|
charisma: int = 10
|
||||||
luck: int = 8
|
luck: int = 8
|
||||||
|
|
||||||
|
# Equipment bonus fields (populated by get_effective_stats())
|
||||||
|
damage_bonus: int = 0 # From weapons (physical damage)
|
||||||
|
spell_power_bonus: int = 0 # From staves/wands (magical damage)
|
||||||
|
defense_bonus: int = 0 # From armor
|
||||||
|
resistance_bonus: int = 0 # From armor
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hit_points(self) -> int:
|
def hit_points(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -62,29 +74,65 @@ class Stats:
|
|||||||
"""
|
"""
|
||||||
return 10 + (self.intelligence * 2)
|
return 10 + (self.intelligence * 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def damage(self) -> int:
|
||||||
|
"""
|
||||||
|
Calculate total physical damage from strength and equipment.
|
||||||
|
|
||||||
|
Formula: int(strength * 0.75) + damage_bonus
|
||||||
|
|
||||||
|
The damage_bonus comes from equipped weapons and is populated
|
||||||
|
by Character.get_effective_stats().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total physical damage value
|
||||||
|
"""
|
||||||
|
return int(self.strength * 0.75) + self.damage_bonus
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spell_power(self) -> int:
|
||||||
|
"""
|
||||||
|
Calculate spell power from intelligence and equipment.
|
||||||
|
|
||||||
|
Formula: int(intelligence * 0.75) + spell_power_bonus
|
||||||
|
|
||||||
|
The spell_power_bonus comes from equipped staves/wands and is
|
||||||
|
populated by Character.get_effective_stats().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total spell power value
|
||||||
|
"""
|
||||||
|
return int(self.intelligence * 0.75) + self.spell_power_bonus
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def defense(self) -> int:
|
def defense(self) -> int:
|
||||||
"""
|
"""
|
||||||
Calculate physical defense from constitution.
|
Calculate physical defense from constitution and equipment.
|
||||||
|
|
||||||
Formula: constitution // 2
|
Formula: (constitution // 2) + defense_bonus
|
||||||
|
|
||||||
|
The defense_bonus comes from equipped armor and is populated
|
||||||
|
by Character.get_effective_stats().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Physical defense value (damage reduction)
|
Physical defense value (damage reduction)
|
||||||
"""
|
"""
|
||||||
return self.constitution // 2
|
return (self.constitution // 2) + self.defense_bonus
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resistance(self) -> int:
|
def resistance(self) -> int:
|
||||||
"""
|
"""
|
||||||
Calculate magical resistance from wisdom.
|
Calculate magical resistance from wisdom and equipment.
|
||||||
|
|
||||||
Formula: wisdom // 2
|
Formula: (wisdom // 2) + resistance_bonus
|
||||||
|
|
||||||
|
The resistance_bonus comes from equipped armor and is populated
|
||||||
|
by Character.get_effective_stats().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Magical resistance value (spell damage reduction)
|
Magical resistance value (spell damage reduction)
|
||||||
"""
|
"""
|
||||||
return self.wisdom // 2
|
return (self.wisdom // 2) + self.resistance_bonus
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def crit_bonus(self) -> float:
|
def crit_bonus(self) -> float:
|
||||||
@@ -171,6 +219,10 @@ class Stats:
|
|||||||
wisdom=data.get("wisdom", 10),
|
wisdom=data.get("wisdom", 10),
|
||||||
charisma=data.get("charisma", 10),
|
charisma=data.get("charisma", 10),
|
||||||
luck=data.get("luck", 8),
|
luck=data.get("luck", 8),
|
||||||
|
damage_bonus=data.get("damage_bonus", 0),
|
||||||
|
spell_power_bonus=data.get("spell_power_bonus", 0),
|
||||||
|
defense_bonus=data.get("defense_bonus", 0),
|
||||||
|
resistance_bonus=data.get("resistance_bonus", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
def copy(self) -> 'Stats':
|
def copy(self) -> 'Stats':
|
||||||
@@ -188,6 +240,10 @@ class Stats:
|
|||||||
wisdom=self.wisdom,
|
wisdom=self.wisdom,
|
||||||
charisma=self.charisma,
|
charisma=self.charisma,
|
||||||
luck=self.luck,
|
luck=self.luck,
|
||||||
|
damage_bonus=self.damage_bonus,
|
||||||
|
spell_power_bonus=self.spell_power_bonus,
|
||||||
|
defense_bonus=self.defense_bonus,
|
||||||
|
resistance_bonus=self.resistance_bonus,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -197,6 +253,7 @@ class Stats:
|
|||||||
f"CON={self.constitution}, INT={self.intelligence}, "
|
f"CON={self.constitution}, INT={self.intelligence}, "
|
||||||
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
|
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
|
||||||
f"HP={self.hit_points}, MP={self.mana_points}, "
|
f"HP={self.hit_points}, MP={self.mana_points}, "
|
||||||
|
f"DMG={self.damage}, SP={self.spell_power}, "
|
||||||
f"DEF={self.defense}, RES={self.resistance}, "
|
f"DEF={self.defense}, RES={self.resistance}, "
|
||||||
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
|
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ class BaseItemLoader:
|
|||||||
# Set defaults for missing optional fields
|
# Set defaults for missing optional fields
|
||||||
template_data.setdefault("description", "")
|
template_data.setdefault("description", "")
|
||||||
template_data.setdefault("base_damage", 0)
|
template_data.setdefault("base_damage", 0)
|
||||||
|
template_data.setdefault("base_spell_power", 0)
|
||||||
template_data.setdefault("base_defense", 0)
|
template_data.setdefault("base_defense", 0)
|
||||||
template_data.setdefault("base_resistance", 0)
|
template_data.setdefault("base_resistance", 0)
|
||||||
template_data.setdefault("base_value", 10)
|
template_data.setdefault("base_value", 10)
|
||||||
|
|||||||
@@ -571,17 +571,26 @@ class CombatService:
|
|||||||
message="Invalid or dead target"
|
message="Invalid or dead target"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get attacker's weapon damage (or base damage for enemies)
|
# Check if this is an elemental weapon attack
|
||||||
weapon_damage = self._get_weapon_damage(attacker)
|
if attacker.elemental_ratio > 0.0 and attacker.elemental_damage_type:
|
||||||
crit_chance = self._get_crit_chance(attacker)
|
# Elemental weapon: split damage between physical and elemental
|
||||||
|
damage_result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
# Calculate damage using DamageCalculator
|
attacker_stats=attacker.stats,
|
||||||
damage_result = DamageCalculator.calculate_physical_damage(
|
defender_stats=target.stats,
|
||||||
attacker_stats=attacker.stats,
|
weapon_crit_chance=attacker.weapon_crit_chance,
|
||||||
defender_stats=target.stats,
|
weapon_crit_multiplier=attacker.weapon_crit_multiplier,
|
||||||
weapon_damage=weapon_damage,
|
physical_ratio=attacker.physical_ratio,
|
||||||
weapon_crit_chance=crit_chance,
|
elemental_ratio=attacker.elemental_ratio,
|
||||||
)
|
elemental_type=attacker.elemental_damage_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Normal physical attack
|
||||||
|
damage_result = DamageCalculator.calculate_physical_damage(
|
||||||
|
attacker_stats=attacker.stats,
|
||||||
|
defender_stats=target.stats,
|
||||||
|
weapon_crit_chance=attacker.weapon_crit_chance,
|
||||||
|
weapon_crit_multiplier=attacker.weapon_crit_multiplier,
|
||||||
|
)
|
||||||
|
|
||||||
# Add target_id to result for tracking
|
# Add target_id to result for tracking
|
||||||
damage_result.target_id = target.combatant_id
|
damage_result.target_id = target.combatant_id
|
||||||
@@ -970,6 +979,25 @@ class CombatService:
|
|||||||
abilities = ["basic_attack"] # All characters have basic attack
|
abilities = ["basic_attack"] # All characters have basic attack
|
||||||
abilities.extend(character.unlocked_skills)
|
abilities.extend(character.unlocked_skills)
|
||||||
|
|
||||||
|
# Extract weapon properties from equipped weapon
|
||||||
|
weapon = character.equipped.get("weapon")
|
||||||
|
weapon_crit_chance = 0.05
|
||||||
|
weapon_crit_multiplier = 2.0
|
||||||
|
weapon_damage_type = DamageType.PHYSICAL
|
||||||
|
elemental_damage_type = None
|
||||||
|
physical_ratio = 1.0
|
||||||
|
elemental_ratio = 0.0
|
||||||
|
|
||||||
|
if weapon and weapon.is_weapon():
|
||||||
|
weapon_crit_chance = weapon.crit_chance
|
||||||
|
weapon_crit_multiplier = weapon.crit_multiplier
|
||||||
|
weapon_damage_type = weapon.damage_type or DamageType.PHYSICAL
|
||||||
|
|
||||||
|
if weapon.is_elemental_weapon():
|
||||||
|
elemental_damage_type = weapon.elemental_damage_type
|
||||||
|
physical_ratio = weapon.physical_ratio
|
||||||
|
elemental_ratio = weapon.elemental_ratio
|
||||||
|
|
||||||
return Combatant(
|
return Combatant(
|
||||||
combatant_id=character.character_id,
|
combatant_id=character.character_id,
|
||||||
name=character.name,
|
name=character.name,
|
||||||
@@ -980,6 +1008,12 @@ class CombatService:
|
|||||||
max_mp=effective_stats.mana_points,
|
max_mp=effective_stats.mana_points,
|
||||||
stats=effective_stats,
|
stats=effective_stats,
|
||||||
abilities=abilities,
|
abilities=abilities,
|
||||||
|
weapon_crit_chance=weapon_crit_chance,
|
||||||
|
weapon_crit_multiplier=weapon_crit_multiplier,
|
||||||
|
weapon_damage_type=weapon_damage_type,
|
||||||
|
elemental_damage_type=elemental_damage_type,
|
||||||
|
physical_ratio=physical_ratio,
|
||||||
|
elemental_ratio=elemental_ratio,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_combatant_from_enemy(
|
def _create_combatant_from_enemy(
|
||||||
@@ -996,7 +1030,9 @@ class CombatService:
|
|||||||
if instance_index > 0:
|
if instance_index > 0:
|
||||||
name = f"{template.name} #{instance_index + 1}"
|
name = f"{template.name} #{instance_index + 1}"
|
||||||
|
|
||||||
stats = template.base_stats
|
# Copy stats and populate damage_bonus with base_damage
|
||||||
|
stats = template.base_stats.copy()
|
||||||
|
stats.damage_bonus = template.base_damage
|
||||||
|
|
||||||
return Combatant(
|
return Combatant(
|
||||||
combatant_id=combatant_id,
|
combatant_id=combatant_id,
|
||||||
@@ -1008,23 +1044,15 @@ class CombatService:
|
|||||||
max_mp=stats.mana_points,
|
max_mp=stats.mana_points,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
abilities=template.abilities.copy(),
|
abilities=template.abilities.copy(),
|
||||||
|
weapon_crit_chance=template.crit_chance,
|
||||||
|
weapon_crit_multiplier=2.0,
|
||||||
|
weapon_damage_type=DamageType.PHYSICAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_weapon_damage(self, combatant: Combatant) -> int:
|
|
||||||
"""Get weapon damage for a combatant."""
|
|
||||||
# For enemies, use base_damage from template
|
|
||||||
if not combatant.is_player:
|
|
||||||
# Base damage stored in combatant data or default
|
|
||||||
return 8 # Default enemy damage
|
|
||||||
|
|
||||||
# For players, would check equipped weapon
|
|
||||||
# TODO: Check character's equipped weapon
|
|
||||||
return 5 # Default unarmed damage
|
|
||||||
|
|
||||||
def _get_crit_chance(self, combatant: Combatant) -> float:
|
def _get_crit_chance(self, combatant: Combatant) -> float:
|
||||||
"""Get critical hit chance for a combatant."""
|
"""Get critical hit chance for a combatant."""
|
||||||
# Base 5% + LUK bonus
|
# Weapon crit chance + LUK bonus
|
||||||
return 0.05 + combatant.stats.crit_bonus
|
return combatant.weapon_crit_chance + combatant.stats.crit_bonus
|
||||||
|
|
||||||
def _get_default_target(
|
def _get_default_target(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ Handles physical, magical, and elemental damage with LUK stat integration
|
|||||||
for variance, critical hits, and accuracy.
|
for variance, critical hits, and accuracy.
|
||||||
|
|
||||||
Formulas:
|
Formulas:
|
||||||
Physical: (Weapon_Base + STR * 0.75) * Variance * Crit_Mult - DEF
|
Physical: (effective_stats.damage + ability_power) * Variance * Crit_Mult - DEF
|
||||||
Magical: (Ability_Base + INT * 0.75) * Variance * Crit_Mult - RES
|
where effective_stats.damage = int(STR * 0.75) + damage_bonus (from weapon)
|
||||||
Elemental: Split between physical and magical components
|
Magical: (effective_stats.spell_power + ability_power) * Variance * Crit_Mult - RES
|
||||||
|
where effective_stats.spell_power = int(INT * 0.75) + spell_power_bonus (from staff/wand)
|
||||||
|
Elemental: Split between physical and magical components using ratios
|
||||||
|
|
||||||
LUK Integration:
|
LUK Integration:
|
||||||
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
|
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
|
||||||
@@ -275,7 +277,6 @@ class DamageCalculator:
|
|||||||
cls,
|
cls,
|
||||||
attacker_stats: Stats,
|
attacker_stats: Stats,
|
||||||
defender_stats: Stats,
|
defender_stats: Stats,
|
||||||
weapon_damage: int = 0,
|
|
||||||
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||||
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||||
ability_base_power: int = 0,
|
ability_base_power: int = 0,
|
||||||
@@ -286,13 +287,13 @@ class DamageCalculator:
|
|||||||
Calculate physical damage for a melee/ranged attack.
|
Calculate physical damage for a melee/ranged attack.
|
||||||
|
|
||||||
Formula:
|
Formula:
|
||||||
Base = Weapon_Base + Ability_Power + (STR * 0.75)
|
Base = attacker_stats.damage + ability_base_power
|
||||||
|
where attacker_stats.damage = int(STR * 0.75) + damage_bonus
|
||||||
Damage = Base * Variance * Crit_Mult - DEF
|
Damage = Base * Variance * Crit_Mult - DEF
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
attacker_stats: Attacker's Stats (STR, LUK used)
|
attacker_stats: Attacker's Stats (includes weapon damage via damage property)
|
||||||
defender_stats: Defender's Stats (DEX, CON used)
|
defender_stats: Defender's Stats (DEX, CON used)
|
||||||
weapon_damage: Base damage from equipped weapon
|
|
||||||
weapon_crit_chance: Crit chance from weapon (default 5%)
|
weapon_crit_chance: Crit chance from weapon (default 5%)
|
||||||
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
|
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
|
||||||
ability_base_power: Additional base power from ability
|
ability_base_power: Additional base power from ability
|
||||||
@@ -317,9 +318,8 @@ class DamageCalculator:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# Step 2: Calculate base damage
|
# Step 2: Calculate base damage
|
||||||
# Formula: weapon + ability + (STR * scaling_factor)
|
# attacker_stats.damage already includes: int(STR * 0.75) + damage_bonus (weapon)
|
||||||
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR
|
base_damage = attacker_stats.damage + ability_base_power
|
||||||
base_damage = weapon_damage + ability_base_power + str_bonus
|
|
||||||
|
|
||||||
# Step 3: Apply variance
|
# Step 3: Apply variance
|
||||||
variance = cls.calculate_variance(attacker_stats.luck)
|
variance = cls.calculate_variance(attacker_stats.luck)
|
||||||
@@ -371,11 +371,12 @@ class DamageCalculator:
|
|||||||
LUK benefits all classes equally.
|
LUK benefits all classes equally.
|
||||||
|
|
||||||
Formula:
|
Formula:
|
||||||
Base = Ability_Power + (INT * 0.75)
|
Base = attacker_stats.spell_power + ability_base_power
|
||||||
|
where attacker_stats.spell_power = int(INT * 0.75) + spell_power_bonus
|
||||||
Damage = Base * Variance * Crit_Mult - RES
|
Damage = Base * Variance * Crit_Mult - RES
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
attacker_stats: Attacker's Stats (INT, LUK used)
|
attacker_stats: Attacker's Stats (includes staff/wand spell_power via spell_power property)
|
||||||
defender_stats: Defender's Stats (DEX, WIS used)
|
defender_stats: Defender's Stats (DEX, WIS used)
|
||||||
ability_base_power: Base power of the spell
|
ability_base_power: Base power of the spell
|
||||||
damage_type: Type of magical damage (fire, ice, etc.)
|
damage_type: Type of magical damage (fire, ice, etc.)
|
||||||
@@ -402,9 +403,8 @@ class DamageCalculator:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# Step 2: Calculate base damage
|
# Step 2: Calculate base damage
|
||||||
# Formula: ability + (INT * scaling_factor)
|
# attacker_stats.spell_power already includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
|
||||||
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR
|
base_damage = attacker_stats.spell_power + ability_base_power
|
||||||
base_damage = ability_base_power + int_bonus
|
|
||||||
|
|
||||||
# Step 3: Apply variance
|
# Step 3: Apply variance
|
||||||
variance = cls.calculate_variance(attacker_stats.luck)
|
variance = cls.calculate_variance(attacker_stats.luck)
|
||||||
@@ -442,7 +442,6 @@ class DamageCalculator:
|
|||||||
cls,
|
cls,
|
||||||
attacker_stats: Stats,
|
attacker_stats: Stats,
|
||||||
defender_stats: Stats,
|
defender_stats: Stats,
|
||||||
weapon_damage: int,
|
|
||||||
weapon_crit_chance: float,
|
weapon_crit_chance: float,
|
||||||
weapon_crit_multiplier: float,
|
weapon_crit_multiplier: float,
|
||||||
physical_ratio: float,
|
physical_ratio: float,
|
||||||
@@ -459,8 +458,8 @@ class DamageCalculator:
|
|||||||
calculated separately against DEF and RES respectively.
|
calculated separately against DEF and RES respectively.
|
||||||
|
|
||||||
Formula:
|
Formula:
|
||||||
Physical = (Weapon * PHYS_RATIO + STR * 0.75 * PHYS_RATIO) - DEF
|
Physical = (attacker_stats.damage + ability_power) * PHYS_RATIO - DEF
|
||||||
Elemental = (Weapon * ELEM_RATIO + INT * 0.75 * ELEM_RATIO) - RES
|
Elemental = (attacker_stats.spell_power + ability_power) * ELEM_RATIO - RES
|
||||||
Total = Physical + Elemental
|
Total = Physical + Elemental
|
||||||
|
|
||||||
Recommended Split Ratios:
|
Recommended Split Ratios:
|
||||||
@@ -470,9 +469,8 @@ class DamageCalculator:
|
|||||||
- Lightning Spear: 50% / 50%
|
- Lightning Spear: 50% / 50%
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
attacker_stats: Attacker's Stats
|
attacker_stats: Attacker's Stats (damage and spell_power include equipment)
|
||||||
defender_stats: Defender's Stats
|
defender_stats: Defender's Stats
|
||||||
weapon_damage: Base weapon damage
|
|
||||||
weapon_crit_chance: Crit chance from weapon
|
weapon_crit_chance: Crit chance from weapon
|
||||||
weapon_crit_multiplier: Crit damage multiplier
|
weapon_crit_multiplier: Crit damage multiplier
|
||||||
physical_ratio: Portion of damage that is physical (0.0-1.0)
|
physical_ratio: Portion of damage that is physical (0.0-1.0)
|
||||||
@@ -516,17 +514,15 @@ class DamageCalculator:
|
|||||||
crit_mult = weapon_crit_multiplier if is_crit else 1.0
|
crit_mult = weapon_crit_multiplier if is_crit else 1.0
|
||||||
|
|
||||||
# Step 3: Calculate physical component
|
# Step 3: Calculate physical component
|
||||||
# Physical uses STR scaling
|
# attacker_stats.damage includes: int(STR * 0.75) + damage_bonus (weapon)
|
||||||
phys_base = (weapon_damage + ability_base_power) * physical_ratio
|
phys_base = (attacker_stats.damage + ability_base_power) * physical_ratio
|
||||||
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR * physical_ratio
|
phys_damage = phys_base * variance * crit_mult
|
||||||
phys_damage = (phys_base + str_bonus) * variance * crit_mult
|
|
||||||
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
|
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
|
||||||
|
|
||||||
# Step 4: Calculate elemental component
|
# Step 4: Calculate elemental component
|
||||||
# Elemental uses INT scaling
|
# attacker_stats.spell_power includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
|
||||||
elem_base = (weapon_damage + ability_base_power) * elemental_ratio
|
elem_base = (attacker_stats.spell_power + ability_base_power) * elemental_ratio
|
||||||
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR * elemental_ratio
|
elem_damage = elem_base * variance * crit_mult
|
||||||
elem_damage = (elem_base + int_bonus) * variance * crit_mult
|
|
||||||
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
|
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
|
||||||
|
|
||||||
# Step 5: Combine results
|
# Step 5: Combine results
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ class ItemGenerator:
|
|||||||
|
|
||||||
# Base values from template
|
# Base values from template
|
||||||
damage = base_template.base_damage + combined_stats["damage_bonus"]
|
damage = base_template.base_damage + combined_stats["damage_bonus"]
|
||||||
|
spell_power = base_template.base_spell_power # Magical weapon damage
|
||||||
defense = base_template.base_defense + combined_stats["defense_bonus"]
|
defense = base_template.base_defense + combined_stats["defense_bonus"]
|
||||||
resistance = base_template.base_resistance + combined_stats["resistance_bonus"]
|
resistance = base_template.base_resistance + combined_stats["resistance_bonus"]
|
||||||
crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"]
|
crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"]
|
||||||
@@ -353,6 +354,7 @@ class ItemGenerator:
|
|||||||
stat_bonuses=combined_stats["stat_bonuses"],
|
stat_bonuses=combined_stats["stat_bonuses"],
|
||||||
effects_on_use=[], # Not a consumable
|
effects_on_use=[], # Not a consumable
|
||||||
damage=damage,
|
damage=damage,
|
||||||
|
spell_power=spell_power, # Magical weapon damage bonus
|
||||||
damage_type=DamageType(base_template.damage_type) if damage > 0 else None,
|
damage_type=DamageType(base_template.damage_type) if damage > 0 else None,
|
||||||
crit_chance=crit_chance,
|
crit_chance=crit_chance,
|
||||||
crit_multiplier=crit_multiplier,
|
crit_multiplier=crit_multiplier,
|
||||||
|
|||||||
@@ -452,3 +452,183 @@ def test_character_round_trip_serialization(basic_character):
|
|||||||
assert restored.unlocked_skills == basic_character.unlocked_skills
|
assert restored.unlocked_skills == basic_character.unlocked_skills
|
||||||
assert "weapon" in restored.equipped
|
assert "weapon" in restored.equipped
|
||||||
assert restored.equipped["weapon"].item_id == "sword"
|
assert restored.equipped["weapon"].item_id == "sword"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Combat Bonuses (Task 2.5)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_get_effective_stats_weapon_damage_bonus(basic_character):
|
||||||
|
"""Test that weapon damage is added to effective stats damage_bonus."""
|
||||||
|
# Create weapon with damage
|
||||||
|
weapon = Item(
|
||||||
|
item_id="iron_sword",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
description="A sturdy iron sword",
|
||||||
|
damage=15, # 15 damage
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["weapon"] = weapon
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Base strength is 12, so base damage = int(12 * 0.75) = 9
|
||||||
|
# Weapon damage = 15
|
||||||
|
# Total damage property = 9 + 15 = 24
|
||||||
|
assert effective.damage_bonus == 15
|
||||||
|
assert effective.damage == 24 # int(12 * 0.75) + 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_armor_defense_bonus(basic_character):
|
||||||
|
"""Test that armor defense is added to effective stats defense_bonus."""
|
||||||
|
# Create armor with defense
|
||||||
|
armor = Item(
|
||||||
|
item_id="iron_chestplate",
|
||||||
|
name="Iron Chestplate",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="A sturdy iron chestplate",
|
||||||
|
defense=10,
|
||||||
|
resistance=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["chest"] = armor
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Base constitution is 14, so base defense = 14 // 2 = 7
|
||||||
|
# Armor defense = 10
|
||||||
|
# Total defense property = 7 + 10 = 17
|
||||||
|
assert effective.defense_bonus == 10
|
||||||
|
assert effective.defense == 17 # (14 // 2) + 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_armor_resistance_bonus(basic_character):
|
||||||
|
"""Test that armor resistance is added to effective stats resistance_bonus."""
|
||||||
|
# Create armor with resistance
|
||||||
|
robe = Item(
|
||||||
|
item_id="magic_robe",
|
||||||
|
name="Magic Robe",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="An enchanted robe",
|
||||||
|
defense=2,
|
||||||
|
resistance=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["chest"] = robe
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Base wisdom is 10, so base resistance = 10 // 2 = 5
|
||||||
|
# Armor resistance = 8
|
||||||
|
# Total resistance property = 5 + 8 = 13
|
||||||
|
assert effective.resistance_bonus == 8
|
||||||
|
assert effective.resistance == 13 # (10 // 2) + 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_multiple_armor_pieces(basic_character):
|
||||||
|
"""Test that multiple armor pieces stack their bonuses."""
|
||||||
|
# Create multiple armor pieces
|
||||||
|
helmet = Item(
|
||||||
|
item_id="iron_helmet",
|
||||||
|
name="Iron Helmet",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Protects your head",
|
||||||
|
defense=5,
|
||||||
|
resistance=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
chestplate = Item(
|
||||||
|
item_id="iron_chestplate",
|
||||||
|
name="Iron Chestplate",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Protects your torso",
|
||||||
|
defense=10,
|
||||||
|
resistance=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
boots = Item(
|
||||||
|
item_id="iron_boots",
|
||||||
|
name="Iron Boots",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Protects your feet",
|
||||||
|
defense=3,
|
||||||
|
resistance=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["helmet"] = helmet
|
||||||
|
basic_character.equipped["chest"] = chestplate
|
||||||
|
basic_character.equipped["boots"] = boots
|
||||||
|
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Total defense bonus = 5 + 10 + 3 = 18
|
||||||
|
# Total resistance bonus = 2 + 3 + 1 = 6
|
||||||
|
assert effective.defense_bonus == 18
|
||||||
|
assert effective.resistance_bonus == 6
|
||||||
|
|
||||||
|
# Base constitution is 14: base defense = 7
|
||||||
|
# Base wisdom is 10: base resistance = 5
|
||||||
|
assert effective.defense == 25 # 7 + 18
|
||||||
|
assert effective.resistance == 11 # 5 + 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_weapon_and_armor_combined(basic_character):
|
||||||
|
"""Test that weapon damage and armor defense/resistance work together."""
|
||||||
|
# Create weapon
|
||||||
|
weapon = Item(
|
||||||
|
item_id="flaming_sword",
|
||||||
|
name="Flaming Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
description="A sword wreathed in flame",
|
||||||
|
damage=18,
|
||||||
|
stat_bonuses={"strength": 3}, # Also has stat bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create armor
|
||||||
|
armor = Item(
|
||||||
|
item_id="dragon_armor",
|
||||||
|
name="Dragon Armor",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Forged from dragon scales",
|
||||||
|
defense=15,
|
||||||
|
resistance=10,
|
||||||
|
stat_bonuses={"constitution": 2}, # Also has stat bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["weapon"] = weapon
|
||||||
|
basic_character.equipped["chest"] = armor
|
||||||
|
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Weapon: damage=18, +3 STR
|
||||||
|
# Armor: defense=15, resistance=10, +2 CON
|
||||||
|
# Base STR=12 -> 12+3=15, damage = int(15 * 0.75) + 18 = 11 + 18 = 29
|
||||||
|
assert effective.strength == 15
|
||||||
|
assert effective.damage_bonus == 18
|
||||||
|
assert effective.damage == 29
|
||||||
|
|
||||||
|
# Base CON=14 -> 14+2=16, defense = (16//2) + 15 = 8 + 15 = 23
|
||||||
|
assert effective.constitution == 16
|
||||||
|
assert effective.defense_bonus == 15
|
||||||
|
assert effective.defense == 23
|
||||||
|
|
||||||
|
# Base WIS=10, resistance = (10//2) + 10 = 5 + 10 = 15
|
||||||
|
assert effective.resistance_bonus == 10
|
||||||
|
assert effective.resistance == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_no_equipment_bonuses(basic_character):
|
||||||
|
"""Test that bonus fields are zero when no equipment is equipped."""
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
assert effective.damage_bonus == 0
|
||||||
|
assert effective.defense_bonus == 0
|
||||||
|
assert effective.resistance_bonus == 0
|
||||||
|
|
||||||
|
# Damage/defense/resistance should just be base stat derived values
|
||||||
|
# Base STR=12, damage = int(12 * 0.75) = 9
|
||||||
|
assert effective.damage == 9
|
||||||
|
|
||||||
|
# Base CON=14, defense = 14 // 2 = 7
|
||||||
|
assert effective.defense == 7
|
||||||
|
|
||||||
|
# Base WIS=10, resistance = 10 // 2 = 5
|
||||||
|
assert effective.resistance == 5
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def mock_character(mock_stats):
|
|||||||
char.experience = 1000
|
char.experience = 1000
|
||||||
char.gold = 100
|
char.gold = 100
|
||||||
char.unlocked_skills = ["power_strike"]
|
char.unlocked_skills = ["power_strike"]
|
||||||
|
char.equipped = {} # No equipment by default
|
||||||
char.get_effective_stats = Mock(return_value=mock_stats)
|
char.get_effective_stats = Mock(return_value=mock_stats)
|
||||||
return char
|
return char
|
||||||
|
|
||||||
|
|||||||
@@ -267,8 +267,9 @@ class TestPhysicalDamage:
|
|||||||
|
|
||||||
def test_basic_physical_damage_formula(self):
|
def test_basic_physical_damage_formula(self):
|
||||||
"""Test the basic physical damage formula."""
|
"""Test the basic physical damage formula."""
|
||||||
# Formula: (Weapon + STR * 0.75) * Variance - DEF
|
# Formula: (stats.damage + ability_power) * Variance - DEF
|
||||||
attacker = Stats(strength=14, luck=0) # LUK 0 to ensure no miss
|
# where stats.damage = int(STR * 0.75) + damage_bonus
|
||||||
|
attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus
|
||||||
defender = Stats(constitution=10, dexterity=10) # DEF = 5
|
defender = Stats(constitution=10, dexterity=10) # DEF = 5
|
||||||
|
|
||||||
# Mock to ensure no miss and no crit, variance = 1.0
|
# Mock to ensure no miss and no crit, variance = 1.0
|
||||||
@@ -278,10 +279,9 @@ class TestPhysicalDamage:
|
|||||||
result = DamageCalculator.calculate_physical_damage(
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
attacker_stats=attacker,
|
attacker_stats=attacker,
|
||||||
defender_stats=defender,
|
defender_stats=defender,
|
||||||
weapon_damage=8,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8 + (14 * 0.75) = 8 + 10.5 = 18.5 -> 18 - 5 = 13
|
# int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 13
|
||||||
assert result.total_damage == 13
|
assert result.total_damage == 13
|
||||||
assert result.is_miss is False
|
assert result.is_miss is False
|
||||||
assert result.is_critical is False
|
assert result.is_critical is False
|
||||||
@@ -289,7 +289,7 @@ class TestPhysicalDamage:
|
|||||||
|
|
||||||
def test_physical_damage_miss(self):
|
def test_physical_damage_miss(self):
|
||||||
"""Test that misses deal zero damage."""
|
"""Test that misses deal zero damage."""
|
||||||
attacker = Stats(strength=14, luck=0)
|
attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus
|
||||||
defender = Stats(dexterity=30) # Very high DEX
|
defender = Stats(dexterity=30) # Very high DEX
|
||||||
|
|
||||||
# Force a miss
|
# Force a miss
|
||||||
@@ -297,7 +297,6 @@ class TestPhysicalDamage:
|
|||||||
result = DamageCalculator.calculate_physical_damage(
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
attacker_stats=attacker,
|
attacker_stats=attacker,
|
||||||
defender_stats=defender,
|
defender_stats=defender,
|
||||||
weapon_damage=8,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.is_miss is True
|
assert result.is_miss is True
|
||||||
@@ -306,7 +305,7 @@ class TestPhysicalDamage:
|
|||||||
|
|
||||||
def test_physical_damage_critical_hit(self):
|
def test_physical_damage_critical_hit(self):
|
||||||
"""Test critical hit doubles damage."""
|
"""Test critical hit doubles damage."""
|
||||||
attacker = Stats(strength=14, luck=20) # High LUK for crit
|
attacker = Stats(strength=14, luck=20, damage_bonus=8) # High LUK for crit, weapon in bonus
|
||||||
defender = Stats(constitution=10, dexterity=10)
|
defender = Stats(constitution=10, dexterity=10)
|
||||||
|
|
||||||
# Force hit and crit
|
# Force hit and crit
|
||||||
@@ -315,15 +314,14 @@ class TestPhysicalDamage:
|
|||||||
result = DamageCalculator.calculate_physical_damage(
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
attacker_stats=attacker,
|
attacker_stats=attacker,
|
||||||
defender_stats=defender,
|
defender_stats=defender,
|
||||||
weapon_damage=8,
|
|
||||||
weapon_crit_multiplier=2.0,
|
weapon_crit_multiplier=2.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.is_critical is True
|
assert result.is_critical is True
|
||||||
# Base: 8 + 14*0.75 = 18.5
|
# Base: int(14 * 0.75) + 8 = 10 + 8 = 18
|
||||||
# Crit applied BEFORE int conversion: 18.5 * 2 = 37
|
# Crit: 18 * 2 = 36
|
||||||
# After DEF 5: 37 - 5 = 32
|
# After DEF 5: 36 - 5 = 31
|
||||||
assert result.total_damage == 32
|
assert result.total_damage == 31
|
||||||
assert "critical" in result.message.lower()
|
assert "critical" in result.message.lower()
|
||||||
|
|
||||||
|
|
||||||
@@ -405,7 +403,8 @@ class TestElementalWeaponDamage:
|
|||||||
def test_split_damage_calculation(self):
|
def test_split_damage_calculation(self):
|
||||||
"""Test 70/30 physical/fire split damage."""
|
"""Test 70/30 physical/fire split damage."""
|
||||||
# Fire Sword: 70% physical, 30% fire
|
# Fire Sword: 70% physical, 30% fire
|
||||||
attacker = Stats(strength=14, intelligence=8, luck=0)
|
# Weapon contributes to both damage_bonus (physical) and spell_power_bonus (elemental)
|
||||||
|
attacker = Stats(strength=14, intelligence=8, luck=0, damage_bonus=15, spell_power_bonus=15)
|
||||||
defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
||||||
|
|
||||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
@@ -414,7 +413,6 @@ class TestElementalWeaponDamage:
|
|||||||
result = DamageCalculator.calculate_elemental_weapon_damage(
|
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
attacker_stats=attacker,
|
attacker_stats=attacker,
|
||||||
defender_stats=defender,
|
defender_stats=defender,
|
||||||
weapon_damage=15,
|
|
||||||
weapon_crit_chance=0.05,
|
weapon_crit_chance=0.05,
|
||||||
weapon_crit_multiplier=2.0,
|
weapon_crit_multiplier=2.0,
|
||||||
physical_ratio=0.7,
|
physical_ratio=0.7,
|
||||||
@@ -422,9 +420,10 @@ class TestElementalWeaponDamage:
|
|||||||
elemental_type=DamageType.FIRE,
|
elemental_type=DamageType.FIRE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Physical: (15 * 0.7) + (14 * 0.75 * 0.7) - 5 = 10.5 + 7.35 - 5 = 12.85 -> 12
|
# stats.damage = int(14 * 0.75) + 15 = 10 + 15 = 25
|
||||||
# Elemental: (15 * 0.3) + (8 * 0.75 * 0.3) - 5 = 4.5 + 1.8 - 5 = 1.3 -> 1
|
# stats.spell_power = int(8 * 0.75) + 15 = 6 + 15 = 21
|
||||||
# Total: 12 + 1 = 13 (approximately, depends on min damage)
|
# Physical: 25 * 0.7 = 17.5 -> 17 - 5 DEF = 12
|
||||||
|
# Elemental: 21 * 0.3 = 6.3 -> 6 - 5 RES = 1
|
||||||
|
|
||||||
assert result.physical_damage > 0
|
assert result.physical_damage > 0
|
||||||
assert result.elemental_damage >= 1 # At least minimum damage
|
assert result.elemental_damage >= 1 # At least minimum damage
|
||||||
@@ -433,7 +432,8 @@ class TestElementalWeaponDamage:
|
|||||||
|
|
||||||
def test_50_50_split_damage(self):
|
def test_50_50_split_damage(self):
|
||||||
"""Test 50/50 physical/elemental split (Lightning Spear)."""
|
"""Test 50/50 physical/elemental split (Lightning Spear)."""
|
||||||
attacker = Stats(strength=12, intelligence=12, luck=0)
|
# Same stats and weapon bonuses means similar damage on both sides
|
||||||
|
attacker = Stats(strength=12, intelligence=12, luck=0, damage_bonus=20, spell_power_bonus=20)
|
||||||
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
||||||
|
|
||||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
@@ -442,7 +442,6 @@ class TestElementalWeaponDamage:
|
|||||||
result = DamageCalculator.calculate_elemental_weapon_damage(
|
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
attacker_stats=attacker,
|
attacker_stats=attacker,
|
||||||
defender_stats=defender,
|
defender_stats=defender,
|
||||||
weapon_damage=20,
|
|
||||||
weapon_crit_chance=0.05,
|
weapon_crit_chance=0.05,
|
||||||
weapon_crit_multiplier=2.0,
|
weapon_crit_multiplier=2.0,
|
||||||
physical_ratio=0.5,
|
physical_ratio=0.5,
|
||||||
@@ -450,12 +449,12 @@ class TestElementalWeaponDamage:
|
|||||||
elemental_type=DamageType.LIGHTNING,
|
elemental_type=DamageType.LIGHTNING,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Both components should be similar (same stat values)
|
# Both components should be similar (same stat values and weapon bonuses)
|
||||||
assert abs(result.physical_damage - result.elemental_damage) <= 2
|
assert abs(result.physical_damage - result.elemental_damage) <= 2
|
||||||
|
|
||||||
def test_elemental_crit_applies_to_both_components(self):
|
def test_elemental_crit_applies_to_both_components(self):
|
||||||
"""Test that crit multiplier applies to both damage types."""
|
"""Test that crit multiplier applies to both damage types."""
|
||||||
attacker = Stats(strength=14, intelligence=8, luck=20)
|
attacker = Stats(strength=14, intelligence=8, luck=20, damage_bonus=15, spell_power_bonus=15)
|
||||||
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
||||||
|
|
||||||
# Force hit and crit
|
# Force hit and crit
|
||||||
@@ -464,7 +463,6 @@ class TestElementalWeaponDamage:
|
|||||||
result = DamageCalculator.calculate_elemental_weapon_damage(
|
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
attacker_stats=attacker,
|
attacker_stats=attacker,
|
||||||
defender_stats=defender,
|
defender_stats=defender,
|
||||||
weapon_damage=15,
|
|
||||||
weapon_crit_chance=0.05,
|
weapon_crit_chance=0.05,
|
||||||
weapon_crit_multiplier=2.0,
|
weapon_crit_multiplier=2.0,
|
||||||
physical_ratio=0.7,
|
physical_ratio=0.7,
|
||||||
@@ -614,8 +612,8 @@ class TestCombatIntegration:
|
|||||||
|
|
||||||
def test_vanguard_attack_scenario(self):
|
def test_vanguard_attack_scenario(self):
|
||||||
"""Test Vanguard (STR 14) basic attack."""
|
"""Test Vanguard (STR 14) basic attack."""
|
||||||
# Vanguard: STR 14, LUK 8
|
# Vanguard: STR 14, LUK 8, equipped with Rusty Sword (8 damage)
|
||||||
vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8)
|
vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8, damage_bonus=8)
|
||||||
goblin = Stats(constitution=10, dexterity=10) # DEF = 5
|
goblin = Stats(constitution=10, dexterity=10) # DEF = 5
|
||||||
|
|
||||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
@@ -624,15 +622,14 @@ class TestCombatIntegration:
|
|||||||
result = DamageCalculator.calculate_physical_damage(
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
attacker_stats=vanguard,
|
attacker_stats=vanguard,
|
||||||
defender_stats=goblin,
|
defender_stats=goblin,
|
||||||
weapon_damage=8, # Rusty sword
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8 + (14 * 0.75) = 18.5 -> 18 - 5 = 13
|
# int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 13
|
||||||
assert result.total_damage == 13
|
assert result.total_damage == 13
|
||||||
|
|
||||||
def test_arcanist_fireball_scenario(self):
|
def test_arcanist_fireball_scenario(self):
|
||||||
"""Test Arcanist (INT 15) Fireball."""
|
"""Test Arcanist (INT 15) Fireball."""
|
||||||
# Arcanist: INT 15, LUK 9
|
# Arcanist: INT 15, LUK 9 (no staff equipped, pure ability damage)
|
||||||
arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9)
|
arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9)
|
||||||
goblin = Stats(wisdom=10, dexterity=10) # RES = 5
|
goblin = Stats(wisdom=10, dexterity=10) # RES = 5
|
||||||
|
|
||||||
@@ -646,14 +643,15 @@ class TestCombatIntegration:
|
|||||||
damage_type=DamageType.FIRE,
|
damage_type=DamageType.FIRE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 12 + (15 * 0.75) = 23.25 -> 23 - 5 = 18
|
# stats.spell_power = int(15 * 0.75) + 0 = 11
|
||||||
|
# 11 + 12 (ability) = 23 - 5 RES = 18
|
||||||
assert result.total_damage == 18
|
assert result.total_damage == 18
|
||||||
|
|
||||||
def test_physical_vs_magical_balance(self):
|
def test_physical_vs_magical_balance(self):
|
||||||
"""Test that physical and magical damage are comparable."""
|
"""Test that physical and magical damage are comparable."""
|
||||||
# Same-tier characters should deal similar damage
|
# Same-tier characters should deal similar damage
|
||||||
vanguard = Stats(strength=14, luck=8) # Melee
|
vanguard = Stats(strength=14, luck=8, damage_bonus=8) # Melee with weapon
|
||||||
arcanist = Stats(intelligence=15, luck=9) # Caster
|
arcanist = Stats(intelligence=15, luck=9) # Caster (no staff)
|
||||||
target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
||||||
|
|
||||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
@@ -662,7 +660,6 @@ class TestCombatIntegration:
|
|||||||
phys_result = DamageCalculator.calculate_physical_damage(
|
phys_result = DamageCalculator.calculate_physical_damage(
|
||||||
attacker_stats=vanguard,
|
attacker_stats=vanguard,
|
||||||
defender_stats=target,
|
defender_stats=target,
|
||||||
weapon_damage=8,
|
|
||||||
)
|
)
|
||||||
magic_result = DamageCalculator.calculate_magical_damage(
|
magic_result = DamageCalculator.calculate_magical_damage(
|
||||||
attacker_stats=arcanist,
|
attacker_stats=arcanist,
|
||||||
|
|||||||
@@ -248,3 +248,134 @@ def test_repr_includes_combat_bonuses():
|
|||||||
|
|
||||||
assert "CRIT_BONUS=" in repr_str
|
assert "CRIT_BONUS=" in repr_str
|
||||||
assert "HIT_BONUS=" in repr_str
|
assert "HIT_BONUS=" in repr_str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Bonus Fields (Task 2.5)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_bonus_fields_default_to_zero():
|
||||||
|
"""Test that equipment bonus fields default to zero."""
|
||||||
|
stats = Stats()
|
||||||
|
|
||||||
|
assert stats.damage_bonus == 0
|
||||||
|
assert stats.defense_bonus == 0
|
||||||
|
assert stats.resistance_bonus == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_damage_property_with_no_bonus():
|
||||||
|
"""Test damage calculation: int(strength * 0.75) + damage_bonus with no bonus."""
|
||||||
|
stats = Stats(strength=10)
|
||||||
|
# int(10 * 0.75) = 7, no bonus
|
||||||
|
assert stats.damage == 7
|
||||||
|
|
||||||
|
stats = Stats(strength=14)
|
||||||
|
# int(14 * 0.75) = 10, no bonus
|
||||||
|
assert stats.damage == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_damage_property_with_bonus():
|
||||||
|
"""Test damage calculation includes damage_bonus from weapons."""
|
||||||
|
stats = Stats(strength=10, damage_bonus=15)
|
||||||
|
# int(10 * 0.75) + 15 = 7 + 15 = 22
|
||||||
|
assert stats.damage == 22
|
||||||
|
|
||||||
|
stats = Stats(strength=14, damage_bonus=8)
|
||||||
|
# int(14 * 0.75) + 8 = 10 + 8 = 18
|
||||||
|
assert stats.damage == 18
|
||||||
|
|
||||||
|
|
||||||
|
def test_defense_property_with_bonus():
|
||||||
|
"""Test defense calculation includes defense_bonus from armor."""
|
||||||
|
stats = Stats(constitution=10, defense_bonus=10)
|
||||||
|
# (10 // 2) + 10 = 5 + 10 = 15
|
||||||
|
assert stats.defense == 15
|
||||||
|
|
||||||
|
stats = Stats(constitution=20, defense_bonus=5)
|
||||||
|
# (20 // 2) + 5 = 10 + 5 = 15
|
||||||
|
assert stats.defense == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_resistance_property_with_bonus():
|
||||||
|
"""Test resistance calculation includes resistance_bonus from armor."""
|
||||||
|
stats = Stats(wisdom=10, resistance_bonus=8)
|
||||||
|
# (10 // 2) + 8 = 5 + 8 = 13
|
||||||
|
assert stats.resistance == 13
|
||||||
|
|
||||||
|
stats = Stats(wisdom=14, resistance_bonus=3)
|
||||||
|
# (14 // 2) + 3 = 7 + 3 = 10
|
||||||
|
assert stats.resistance == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_serialization():
|
||||||
|
"""Test that bonus fields are included in to_dict()."""
|
||||||
|
stats = Stats(
|
||||||
|
strength=15,
|
||||||
|
damage_bonus=12,
|
||||||
|
defense_bonus=8,
|
||||||
|
resistance_bonus=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = stats.to_dict()
|
||||||
|
|
||||||
|
assert data["damage_bonus"] == 12
|
||||||
|
assert data["defense_bonus"] == 8
|
||||||
|
assert data["resistance_bonus"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_deserialization():
|
||||||
|
"""Test that bonus fields are restored from from_dict()."""
|
||||||
|
data = {
|
||||||
|
"strength": 15,
|
||||||
|
"damage_bonus": 12,
|
||||||
|
"defense_bonus": 8,
|
||||||
|
"resistance_bonus": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = Stats.from_dict(data)
|
||||||
|
|
||||||
|
assert stats.damage_bonus == 12
|
||||||
|
assert stats.defense_bonus == 8
|
||||||
|
assert stats.resistance_bonus == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_deserialization_defaults():
|
||||||
|
"""Test that missing bonus fields default to zero on deserialization."""
|
||||||
|
data = {
|
||||||
|
"strength": 15,
|
||||||
|
# No bonus fields
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = Stats.from_dict(data)
|
||||||
|
|
||||||
|
assert stats.damage_bonus == 0
|
||||||
|
assert stats.defense_bonus == 0
|
||||||
|
assert stats.resistance_bonus == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_includes_bonus_fields():
|
||||||
|
"""Test that copy() preserves bonus fields."""
|
||||||
|
original = Stats(
|
||||||
|
strength=15,
|
||||||
|
damage_bonus=10,
|
||||||
|
defense_bonus=8,
|
||||||
|
resistance_bonus=5,
|
||||||
|
)
|
||||||
|
copy = original.copy()
|
||||||
|
|
||||||
|
assert copy.damage_bonus == 10
|
||||||
|
assert copy.defense_bonus == 8
|
||||||
|
assert copy.resistance_bonus == 5
|
||||||
|
|
||||||
|
# Verify independence
|
||||||
|
copy.damage_bonus = 20
|
||||||
|
assert original.damage_bonus == 10
|
||||||
|
assert copy.damage_bonus == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr_includes_damage():
|
||||||
|
"""Test that repr includes the damage computed property."""
|
||||||
|
stats = Stats(strength=10, damage_bonus=15)
|
||||||
|
repr_str = repr(stats)
|
||||||
|
|
||||||
|
assert "DMG=" in repr_str
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Phase 4: Combat & Progression Systems - Implementation Plan
|
# Phase 4: Combat & Progression Systems - Implementation Plan
|
||||||
|
|
||||||
**Status:** In Progress - Week 2 In Progress
|
**Status:** In Progress - Week 2 Complete, Week 3 Next
|
||||||
**Timeline:** 4-5 weeks
|
**Timeline:** 4-5 weeks
|
||||||
**Last Updated:** November 26, 2025
|
**Last Updated:** November 26, 2025
|
||||||
**Document Version:** 1.1
|
**Document Version:** 1.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,6 +35,31 @@
|
|||||||
|
|
||||||
**Total Tests:** 108 passing
|
**Total Tests:** 108 passing
|
||||||
|
|
||||||
|
### Week 2: Inventory & Equipment - COMPLETE
|
||||||
|
|
||||||
|
| Task | Description | Status | Tests |
|
||||||
|
|------|-------------|--------|-------|
|
||||||
|
| 2.1 | Item Data Models (Affixes) | ✅ Complete | 24 tests |
|
||||||
|
| 2.2 | Item Data Files (YAML) | ✅ Complete | - |
|
||||||
|
| 2.2.1 | Item Generator Service | ✅ Complete | 35 tests |
|
||||||
|
| 2.3 | Inventory Service | ✅ Complete | 24 tests |
|
||||||
|
| 2.4 | Inventory API Endpoints | ✅ Complete | 25 tests |
|
||||||
|
| 2.5 | Character Stats Calculation | ✅ Complete | 17 tests |
|
||||||
|
| 2.6 | Equipment-Combat Integration | ✅ Complete | 140 tests |
|
||||||
|
|
||||||
|
**Files Created/Modified:**
|
||||||
|
- `/api/app/models/items.py` - Item with affix support, spell_power field
|
||||||
|
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
|
||||||
|
- `/api/app/models/stats.py` - spell_power_bonus, updated damage formula
|
||||||
|
- `/api/app/models/combat.py` - Combatant weapon properties
|
||||||
|
- `/api/app/services/item_generator.py` - Procedural item generation
|
||||||
|
- `/api/app/services/inventory_service.py` - Equipment management
|
||||||
|
- `/api/app/services/damage_calculator.py` - Refactored to use stats properties
|
||||||
|
- `/api/app/services/combat_service.py` - Equipment integration
|
||||||
|
- `/api/app/api/inventory.py` - REST API endpoints
|
||||||
|
|
||||||
|
**Total Tests (Week 2):** 265+ passing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -973,7 +998,7 @@ app.register_blueprint(combat_bp, url_prefix='/api/v1/combat')
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Week 2: Inventory & Equipment System ⏳ IN PROGRESS
|
### Week 2: Inventory & Equipment System ✅ COMPLETE
|
||||||
|
|
||||||
#### Task 2.1: Item Data Models ✅ COMPLETE
|
#### Task 2.1: Item Data Models ✅ COMPLETE
|
||||||
|
|
||||||
@@ -1563,81 +1588,172 @@ character.inventory.append(generated_item.to_dict()) # Store full item data
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Task 2.5: Update Character Stats Calculation (4 hours)
|
#### Task 2.5: Update Character Stats Calculation (4 hours) ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Ensure `get_effective_stats()` includes equipped items
|
**Objective:** Ensure `get_effective_stats()` includes equipped items' combat bonuses
|
||||||
|
|
||||||
**File:** `/api/app/models/character.py`
|
**Files Modified:**
|
||||||
|
- `/api/app/models/stats.py` - Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields
|
||||||
|
- `/api/app/models/character.py` - Updated `get_effective_stats()` to populate bonus fields
|
||||||
|
|
||||||
**Update Method:**
|
**Implementation Summary:**
|
||||||
|
|
||||||
|
The Stats model now has three equipment bonus fields that are populated by `get_effective_stats()`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_effective_stats(self) -> Stats:
|
# Stats model additions
|
||||||
"""
|
damage_bonus: int = 0 # From weapons
|
||||||
Calculate effective stats including base, equipment, skills, and effects.
|
defense_bonus: int = 0 # From armor
|
||||||
|
resistance_bonus: int = 0 # From armor
|
||||||
|
|
||||||
Returns:
|
# Updated computed properties
|
||||||
Stats instance with all modifiers applied
|
@property
|
||||||
"""
|
def damage(self) -> int:
|
||||||
# Start with base stats
|
return (self.strength // 2) + self.damage_bonus
|
||||||
effective = Stats(
|
|
||||||
strength=self.stats.strength,
|
|
||||||
defense=self.stats.defense,
|
|
||||||
speed=self.stats.speed,
|
|
||||||
intelligence=self.stats.intelligence,
|
|
||||||
resistance=self.stats.resistance,
|
|
||||||
vitality=self.stats.vitality,
|
|
||||||
spirit=self.stats.spirit
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add bonuses from equipped items
|
@property
|
||||||
from app.services.item_loader import ItemLoader
|
def defense(self) -> int:
|
||||||
item_loader = ItemLoader()
|
return (self.constitution // 2) + self.defense_bonus
|
||||||
|
|
||||||
for slot, item_id in self.equipped.items():
|
@property
|
||||||
item = item_loader.get_item(item_id)
|
def resistance(self) -> int:
|
||||||
if not item:
|
return (self.wisdom // 2) + self.resistance_bonus
|
||||||
continue
|
|
||||||
|
|
||||||
# Add item stat bonuses
|
|
||||||
if hasattr(item, 'stat_bonuses'):
|
|
||||||
for stat_name, bonus in item.stat_bonuses.items():
|
|
||||||
current_value = getattr(effective, stat_name)
|
|
||||||
setattr(effective, stat_name, current_value + bonus)
|
|
||||||
|
|
||||||
# Armor adds defense/resistance
|
|
||||||
if item.item_type == ItemType.ARMOR:
|
|
||||||
effective.defense += item.defense
|
|
||||||
effective.resistance += item.resistance
|
|
||||||
|
|
||||||
# Add bonuses from unlocked skills
|
|
||||||
for skill_id in self.unlocked_skills:
|
|
||||||
skill = self.skill_tree.get_skill_node(skill_id)
|
|
||||||
if skill and skill.stat_bonuses:
|
|
||||||
for stat_name, bonus in skill.stat_bonuses.items():
|
|
||||||
current_value = getattr(effective, stat_name)
|
|
||||||
setattr(effective, stat_name, current_value + bonus)
|
|
||||||
|
|
||||||
# Add temporary effects (buffs/debuffs)
|
|
||||||
for effect in self.active_effects:
|
|
||||||
if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
|
||||||
modifier = effect.power * effect.stacks
|
|
||||||
if effect.effect_type == EffectType.DEBUFF:
|
|
||||||
modifier *= -1
|
|
||||||
|
|
||||||
current_value = getattr(effective, effect.stat_type)
|
|
||||||
new_value = max(1, current_value + modifier) # Min stat is 1
|
|
||||||
setattr(effective, effect.stat_type, new_value)
|
|
||||||
|
|
||||||
return effective
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
The `get_effective_stats()` method now applies:
|
||||||
- Equipped weapons add damage
|
1. `stat_bonuses` dict from all equipped items (as before)
|
||||||
- Equipped armor adds defense/resistance
|
2. Weapon `damage` → `damage_bonus`
|
||||||
- Stat bonuses from items apply correctly
|
3. Armor `defense` → `defense_bonus`
|
||||||
- Skills still apply bonuses
|
4. Armor `resistance` → `resistance_bonus`
|
||||||
- Effects still modify stats
|
|
||||||
|
**Tests Added:**
|
||||||
|
- `/api/tests/test_stats.py` - 11 new tests for bonus fields
|
||||||
|
- `/api/tests/test_character.py` - 6 new tests for equipment combat bonuses
|
||||||
|
|
||||||
|
**Acceptance Criteria:** ✅ MET
|
||||||
|
- [x] Equipped weapons add damage (via `damage_bonus`)
|
||||||
|
- [x] Equipped armor adds defense/resistance (via `defense_bonus`/`resistance_bonus`)
|
||||||
|
- [x] Stat bonuses from items apply correctly
|
||||||
|
- [x] Skills still apply bonuses
|
||||||
|
- [x] Effects still modify stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.6: Equipment-Combat Integration (4 hours) ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Fully integrate equipment stats into combat damage calculations, replacing hardcoded weapon damage values with effective_stats properties.
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `/api/app/models/stats.py` - Updated damage formula, added spell_power system
|
||||||
|
- `/api/app/models/items.py` - Added spell_power field for magical weapons
|
||||||
|
- `/api/app/models/character.py` - Populate spell_power_bonus in get_effective_stats()
|
||||||
|
- `/api/app/models/combat.py` - Added weapon property fields to Combatant
|
||||||
|
- `/api/app/services/combat_service.py` - Updated combatant creation and attack execution
|
||||||
|
- `/api/app/services/damage_calculator.py` - Use stats properties instead of weapon_damage param
|
||||||
|
|
||||||
|
**Implementation Summary:**
|
||||||
|
|
||||||
|
**1. Updated Damage Formula (Stats Model)**
|
||||||
|
|
||||||
|
Changed damage scaling from `STR // 2` to `int(STR * 0.75)` for better progression:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Old formula
|
||||||
|
@property
|
||||||
|
def damage(self) -> int:
|
||||||
|
return (self.strength // 2) + self.damage_bonus
|
||||||
|
|
||||||
|
# New formula (0.75 scaling factor)
|
||||||
|
@property
|
||||||
|
def damage(self) -> int:
|
||||||
|
return int(self.strength * 0.75) + self.damage_bonus
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Added Spell Power System**
|
||||||
|
|
||||||
|
Symmetric system for magical weapons (staves, wands):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Stats model additions
|
||||||
|
spell_power_bonus: int = 0 # From magical weapons
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spell_power(self) -> int:
|
||||||
|
"""Magical damage: int(INT * 0.75) + spell_power_bonus."""
|
||||||
|
return int(self.intelligence * 0.75) + self.spell_power_bonus
|
||||||
|
|
||||||
|
# Item model additions
|
||||||
|
spell_power: int = 0 # Spell power bonus for magical weapons
|
||||||
|
|
||||||
|
def is_magical_weapon(self) -> bool:
|
||||||
|
"""Check if this is a magical weapon (uses spell_power)."""
|
||||||
|
return self.is_weapon() and self.spell_power > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Combatant Weapon Properties**
|
||||||
|
|
||||||
|
Added weapon properties to Combatant model for combat-time access:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Weapon combat properties
|
||||||
|
weapon_crit_chance: float = 0.05
|
||||||
|
weapon_crit_multiplier: float = 2.0
|
||||||
|
weapon_damage_type: Optional[DamageType] = None
|
||||||
|
|
||||||
|
# Elemental weapon support
|
||||||
|
elemental_damage_type: Optional[DamageType] = None
|
||||||
|
physical_ratio: float = 1.0
|
||||||
|
elemental_ratio: float = 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. DamageCalculator Refactored**
|
||||||
|
|
||||||
|
Removed `weapon_damage` parameter - now uses `attacker_stats.damage` directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Old signature
|
||||||
|
def calculate_physical_damage(
|
||||||
|
attacker_stats: Stats,
|
||||||
|
defender_stats: Stats,
|
||||||
|
weapon_damage: int, # Separate parameter
|
||||||
|
...
|
||||||
|
)
|
||||||
|
|
||||||
|
# New signature
|
||||||
|
def calculate_physical_damage(
|
||||||
|
attacker_stats: Stats, # stats.damage includes weapon bonus
|
||||||
|
defender_stats: Stats,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
|
||||||
|
# Formula now uses:
|
||||||
|
base_damage = attacker_stats.damage + ability_base_power # Physical
|
||||||
|
base_damage = attacker_stats.spell_power + ability_base_power # Magical
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Combat Service Updates**
|
||||||
|
|
||||||
|
- `_create_combatant_from_character()` extracts weapon properties from equipped weapon
|
||||||
|
- `_create_combatant_from_enemy()` uses `stats.damage_bonus = template.base_damage`
|
||||||
|
- Removed hardcoded `_get_weapon_damage()` method
|
||||||
|
- `_execute_attack()` handles elemental weapons with split damage
|
||||||
|
|
||||||
|
**Tests Updated:**
|
||||||
|
- `/api/tests/test_stats.py` - Updated damage formula tests (0.75 scaling)
|
||||||
|
- `/api/tests/test_character.py` - Updated equipment bonus tests
|
||||||
|
- `/api/tests/test_damage_calculator.py` - Removed weapon_damage parameter from calls
|
||||||
|
- `/api/tests/test_combat_service.py` - Added `equipped` attribute to mock fixture
|
||||||
|
|
||||||
|
**Test Results:** 140 tests passing for all modified components
|
||||||
|
|
||||||
|
**Acceptance Criteria:** ✅ MET
|
||||||
|
- [x] Damage uses `effective_stats.damage` (includes weapon bonus)
|
||||||
|
- [x] Spell power uses `effective_stats.spell_power` (includes staff/wand bonus)
|
||||||
|
- [x] 0.75 scaling factor for both physical and magical damage
|
||||||
|
- [x] Weapon crit chance/multiplier flows through to combat
|
||||||
|
- [x] Elemental weapons support split physical/elemental damage
|
||||||
|
- [x] Enemy combatants use template base_damage correctly
|
||||||
|
- [x] All existing tests pass with updated formulas
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user