diff --git a/docs/PHASE4_COMBAT_IMPLEMENTATION.md b/docs/PHASE4_COMBAT_IMPLEMENTATION.md index 5dc24b2..3872ff0 100644 --- a/docs/PHASE4_COMBAT_IMPLEMENTATION.md +++ b/docs/PHASE4_COMBAT_IMPLEMENTATION.md @@ -92,910 +92,66 @@ This phase implements the core combat and progression systems for Code of Conque ### Week 1: Combat Backend & Data Models ✅ COMPLETE -#### Task 1.1: Verify Combat Data Models (2 hours) ✅ COMPLETE +#### Task 1.1: Verify Combat Data Models ✅ COMPLETE -**Objective:** Ensure all combat-related dataclasses are complete and correct +**Files:** `/api/app/models/combat.py`, `effects.py`, `abilities.py`, `stats.py` -**Files to Review:** -- `/api/app/models/combat.py` - Combatant, CombatEncounter -- `/api/app/models/effects.py` - Effect, all effect types -- `/api/app/models/abilities.py` - Ability, AbilityLoader -- `/api/app/models/stats.py` - Stats with computed properties - -**Verification Checklist:** -- [x] `Combatant` dataclass has all required fields - - `combatant_id`, `name`, `stats`, `current_hp`, `current_mp` - - `active_effects`, `cooldowns`, `is_player` -- [x] `CombatEncounter` dataclass complete - - `encounter_id`, `combatants`, `turn_order`, `current_turn_index` - - `combat_log`, `round_number`, `status` -- [x] Effect types implemented: BUFF, DEBUFF, DOT, HOT, STUN, SHIELD -- [x] Effect stacking logic correct (max_stacks, duration refresh) -- [x] Ability loading from YAML works -- [x] All dataclasses have `to_dict()` and `from_dict()` methods - -**Acceptance Criteria:** ✅ MET -- All combat models serialize/deserialize correctly -- Unit tests pass for combat models -- No missing fields or methods +Verified: Combatant, CombatEncounter dataclasses, effect types (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD), stacking logic, YAML ability loading, serialization methods. --- -#### Task 1.2: Implement Combat Service (1 day / 8 hours) ✅ COMPLETE - -**Objective:** Create service layer for combat management +#### Task 1.2: Implement Combat Service ✅ COMPLETE **File:** `/api/app/services/combat_service.py` -**Implementation:** - -```python -""" -Combat Service - -Manages combat encounters, turn order, and combat state. -""" - -from typing import Optional, List, Dict, Any -from dataclasses import asdict -import uuid - -from app.models.combat import Combatant, CombatEncounter, CombatStatus -from app.models.character import Character -from app.models.effects import Effect -from app.models.abilities import Ability, AbilityLoader -from app.services.appwrite_service import AppwriteService -from app.utils.logging import get_logger - -logger = get_logger(__file__) - - -class CombatNotFound(Exception): - """Raised when combat encounter is not found.""" - pass - - -class InvalidCombatAction(Exception): - """Raised when combat action is invalid.""" - pass - - -class CombatService: - """Service for managing combat encounters.""" - - def __init__(self, appwrite_service: AppwriteService): - self.appwrite = appwrite_service - self.ability_loader = AbilityLoader() - self.collection_id = "combat_encounters" - - def initiate_combat( - self, - session_id: str, - character: Character, - enemies: List[Dict[str, Any]] - ) -> CombatEncounter: - """ - Initiate a new combat encounter. - - Args: - session_id: Game session ID - character: Player character - enemies: List of enemy data dicts - - Returns: - CombatEncounter instance - """ - combat_id = str(uuid.uuid4()) - - # Create player combatant - player_combatant = Combatant.from_character(character) - - # Create enemy combatants - enemy_combatants = [] - for i, enemy_data in enumerate(enemies): - enemy_combatant = Combatant.from_enemy_data( - enemy_id=f"{combat_id}_enemy_{i}", - enemy_data=enemy_data - ) - enemy_combatants.append(enemy_combatant) - - # Combine all combatants - all_combatants = [player_combatant] + enemy_combatants - - # Roll initiative and create turn order - turn_order = self._roll_initiative(all_combatants) - - # Create combat encounter - encounter = CombatEncounter( - combat_id=combat_id, - session_id=session_id, - combatants=all_combatants, - turn_order=turn_order, - current_turn_index=0, - combat_log=[], - round_number=1, - status=CombatStatus.IN_PROGRESS - ) - - # Save to database - self._save_encounter(encounter) - - logger.info(f"Combat initiated: {combat_id}", extra={ - "combat_id": combat_id, - "session_id": session_id, - "num_enemies": len(enemies) - }) - - return encounter - - def _roll_initiative(self, combatants: List[Combatant]) -> List[str]: - """ - Roll initiative for all combatants and return turn order. - - Args: - combatants: List of combatants - - Returns: - List of combatant IDs in turn order (highest initiative first) - """ - import random - - initiative_rolls = [] - for combatant in combatants: - roll = random.randint(1, 20) + combatant.stats.speed - initiative_rolls.append((combatant.combatant_id, roll)) - - # Sort by initiative (highest first) - initiative_rolls.sort(key=lambda x: x[1], reverse=True) - - return [combatant_id for combatant_id, _ in initiative_rolls] - - def get_encounter(self, combat_id: str) -> CombatEncounter: - """ - Load combat encounter from database. - - Args: - combat_id: Combat encounter ID - - Returns: - CombatEncounter instance - - Raises: - CombatNotFound: If combat not found - """ - try: - doc = self.appwrite.get_document(self.collection_id, combat_id) - return CombatEncounter.from_dict(doc) - except Exception as e: - raise CombatNotFound(f"Combat {combat_id} not found") from e - - def process_action( - self, - combat_id: str, - action_type: str, - ability_id: Optional[str] = None, - target_id: Optional[str] = None, - item_id: Optional[str] = None - ) -> Dict[str, Any]: - """ - Process a combat action. - - Args: - combat_id: Combat encounter ID - action_type: "attack", "spell", "item", "defend" - ability_id: Ability ID (for attack/spell) - target_id: Target combatant ID - item_id: Item ID (for item use) - - Returns: - Action result dict with damage, effects, etc. - - Raises: - InvalidCombatAction: If action is invalid - """ - encounter = self.get_encounter(combat_id) - - # Get current combatant - current_combatant_id = encounter.turn_order[encounter.current_turn_index] - current_combatant = encounter.get_combatant(current_combatant_id) - - # Process effect ticks at start of turn - self._process_turn_start(encounter, current_combatant) - - # Check if stunned - if current_combatant.is_stunned(): - result = { - "action": "stunned", - "message": f"{current_combatant.name} is stunned and cannot act!" - } - encounter.combat_log.append(result["message"]) - self._advance_turn(encounter) - self._save_encounter(encounter) - return result - - # Execute action based on type - if action_type == "attack": - result = self._execute_attack(encounter, current_combatant, ability_id, target_id) - elif action_type == "spell": - result = self._execute_spell(encounter, current_combatant, ability_id, target_id) - elif action_type == "item": - result = self._execute_item(encounter, current_combatant, item_id, target_id) - elif action_type == "defend": - result = self._execute_defend(encounter, current_combatant) - else: - raise InvalidCombatAction(f"Invalid action type: {action_type}") - - # Log action - encounter.combat_log.append(result["message"]) - - # Check for deaths - self._check_deaths(encounter) - - # Check for combat end - if self._check_combat_end(encounter): - encounter.status = CombatStatus.VICTORY if self._player_won(encounter) else CombatStatus.DEFEAT - - # Advance turn - self._advance_turn(encounter) - - # Save encounter - self._save_encounter(encounter) - - return result - - def _process_turn_start(self, encounter: CombatEncounter, combatant: Combatant) -> None: - """Process effects at start of combatant's turn.""" - for effect in combatant.active_effects: - effect.tick(combatant) - - # Remove expired effects - combatant.active_effects = [e for e in combatant.active_effects if not e.is_expired()] - - # Reduce cooldowns - combatant.reduce_cooldowns() - - def _advance_turn(self, encounter: CombatEncounter) -> None: - """Advance to next turn.""" - encounter.current_turn_index += 1 - - # If back to first combatant, increment round - if encounter.current_turn_index >= len(encounter.turn_order): - encounter.current_turn_index = 0 - encounter.round_number += 1 - - def _check_deaths(self, encounter: CombatEncounter) -> None: - """Check for dead combatants and remove from turn order.""" - for combatant in encounter.combatants: - if combatant.current_hp <= 0 and combatant.combatant_id in encounter.turn_order: - encounter.turn_order.remove(combatant.combatant_id) - encounter.combat_log.append(f"{combatant.name} has been defeated!") - - def _check_combat_end(self, encounter: CombatEncounter) -> bool: - """Check if combat has ended.""" - players_alive = any(c.is_player and c.current_hp > 0 for c in encounter.combatants) - enemies_alive = any(not c.is_player and c.current_hp > 0 for c in encounter.combatants) - - return not (players_alive and enemies_alive) - - def _player_won(self, encounter: CombatEncounter) -> bool: - """Check if player won the combat.""" - return any(c.is_player and c.current_hp > 0 for c in encounter.combatants) - - def _save_encounter(self, encounter: CombatEncounter) -> None: - """Save encounter to database.""" - doc_data = encounter.to_dict() - try: - self.appwrite.update_document(self.collection_id, encounter.combat_id, doc_data) - except: - self.appwrite.create_document(self.collection_id, encounter.combat_id, doc_data) - - # TODO: Implement _execute_attack, _execute_spell, _execute_item, _execute_defend - # These will be implemented in Task 1.3 (Damage Calculator) -``` - -**Acceptance Criteria:** ✅ MET -- Combat can be initiated with player + enemies -- Initiative rolls correctly -- Turn order maintained -- Combat state persists to GameSession -- Combat can be loaded from session +Implemented: `CombatService` class with `initiate_combat()`, `process_action()`, initiative rolling, turn management, death checking, combat end detection. --- -#### Task 1.3: Implement Damage Calculator (4 hours) ✅ COMPLETE - -**Objective:** Calculate damage for physical/magical attacks with critical hits +#### Task 1.3: Implement Damage Calculator ✅ COMPLETE **File:** `/api/app/services/damage_calculator.py` -**Implementation:** - -```python -""" -Damage Calculator - -Calculates damage for attacks and spells, including critical hits. -""" - -import random -from typing import Dict, Any - -from app.models.stats import Stats -from app.models.abilities import Ability -from app.models.items import Item -from app.utils.logging import get_logger - -logger = get_logger(__file__) - - -class DamageCalculator: - """Calculate damage for combat actions.""" - - @staticmethod - def calculate_physical_damage( - attacker_stats: Stats, - defender_stats: Stats, - weapon: Item, - ability: Ability = None - ) -> Dict[str, Any]: - """ - Calculate physical damage. - - Formula: weapon.damage + (strength / 2) - target.defense - Min damage: 1 - - Args: - attacker_stats: Attacker's effective stats - defender_stats: Defender's effective stats - weapon: Equipped weapon - ability: Optional ability (for skills like Power Strike) - - Returns: - Dict with damage, is_crit, message - """ - # Base damage from weapon - base_damage = weapon.damage if weapon else 1 - - # Add ability power if using skill - if ability: - base_damage += ability.calculate_power(attacker_stats) - - # Add strength scaling - base_damage += attacker_stats.strength // 2 - - # Subtract defense - damage = base_damage - defender_stats.defense - - # Min damage is 1 - damage = max(1, damage) - - # Check for critical hit - crit_chance = weapon.crit_chance if weapon else 0.05 - is_crit = random.random() < crit_chance - - if is_crit: - crit_mult = weapon.crit_multiplier if weapon else 2.0 - damage = int(damage * crit_mult) - - return { - "damage": damage, - "is_crit": is_crit, - "damage_type": "physical" - } - - @staticmethod - def calculate_magical_damage( - attacker_stats: Stats, - defender_stats: Stats, - ability: Ability - ) -> Dict[str, Any]: - """ - Calculate magical damage. - - Formula: spell.damage + (magic_power / 2) - target.resistance - Min damage: 1 - - Args: - attacker_stats: Attacker's effective stats - defender_stats: Defender's effective stats - ability: Spell ability - - Returns: - Dict with damage, is_crit, message - """ - # Base damage from spell - base_damage = ability.calculate_power(attacker_stats) - - # Subtract magic resistance - damage = base_damage - defender_stats.resistance - - # Min damage is 1 - damage = max(1, damage) - - # Spells don't crit by default (can be added per-spell) - is_crit = False - - return { - "damage": damage, - "is_crit": is_crit, - "damage_type": "magical" - } - - @staticmethod - def apply_damage(combatant, damage: int) -> int: - """ - Apply damage to combatant, considering shields. - - Args: - combatant: Target combatant - damage: Damage amount - - Returns: - Actual damage dealt to HP - """ - # Check for shield effects - shield_power = combatant.get_shield_power() - - if shield_power > 0: - if damage <= shield_power: - # Shield absorbs all damage - combatant.reduce_shield(damage) - return 0 - else: - # Shield absorbs partial damage - remaining_damage = damage - shield_power - combatant.reduce_shield(shield_power) - combatant.current_hp -= remaining_damage - return remaining_damage - else: - # No shield, apply damage directly - combatant.current_hp -= damage - return damage -``` - -**Acceptance Criteria:** ✅ MET -- Physical damage formula correct (39 unit tests) -- Magical damage formula correct -- Critical hits work (LUK-based chance, configurable multiplier) -- Shield absorption works (partial and full) -- Minimum damage is always 1 +Implemented: `calculate_physical_damage()`, `calculate_magical_damage()`, `apply_damage()` with shield absorption. Physical formula: `weapon.damage + (STR/2) - defense`. 39 unit tests. --- -#### Task 1.4: Implement Effect Processor (4 hours) ✅ COMPLETE +#### Task 1.4: Implement Effect Processor ✅ COMPLETE -**Objective:** Process effects (DOT, HOT, buffs, debuffs, stun, shield) at turn start +**File:** `/api/app/models/effects.py` -**Implementation:** Extend `Effect` class in `/api/app/models/effects.py` - -**Add Methods:** - -```python -# In Effect class - -def tick(self, combatant) -> None: - """ - Process this effect for one turn. - - Args: - combatant: Combatant affected by this effect - """ - if self.effect_type == EffectType.DOT: - damage = self.power * self.stacks - combatant.current_hp -= damage - logger.info(f"{combatant.name} takes {damage} damage from {self.name}") - - elif self.effect_type == EffectType.HOT: - healing = self.power * self.stacks - combatant.current_hp = min(combatant.current_hp + healing, combatant.stats.max_hp) - logger.info(f"{combatant.name} heals {healing} HP from {self.name}") - - elif self.effect_type == EffectType.STUN: - # Stun doesn't tick damage, just prevents action - pass - - elif self.effect_type == EffectType.SHIELD: - # Shield doesn't tick, it absorbs damage - pass - - elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: - # Stat modifiers are applied via get_effective_stats() - pass - - # Reduce duration - self.duration -= 1 -``` - -**Acceptance Criteria:** ✅ MET -- DOT deals damage each turn -- HOT heals each turn (capped at max HP) -- Buffs/debuffs modify stats via `get_effective_stats()` -- Shields absorb damage before HP -- Stun prevents actions -- Effects expire when duration reaches 0 +Implemented: `tick()` method for DOT/HOT damage/healing, duration tracking, stat modifiers via `get_effective_stats()`. --- -#### Task 1.5: Implement Combat Actions (1 day / 8 hours) ✅ COMPLETE +#### Task 1.5: Implement Combat Actions ✅ COMPLETE -**Objective:** Implement `_execute_attack`, `_execute_spell`, `_execute_item`, `_execute_defend` in CombatService +**File:** `/api/app/services/combat_service.py` -**Add to `/api/app/services/combat_service.py`:** - -```python -def _execute_attack( - self, - encounter: CombatEncounter, - attacker: Combatant, - ability_id: str, - target_id: str -) -> Dict[str, Any]: - """Execute physical attack.""" - target = encounter.get_combatant(target_id) - ability = self.ability_loader.get_ability(ability_id) if ability_id else None - - # Check mana cost - if ability and ability.mana_cost > attacker.current_mp: - raise InvalidCombatAction("Not enough mana") - - # Check cooldown - if ability and not attacker.can_use_ability(ability.ability_id): - raise InvalidCombatAction("Ability is on cooldown") - - # Calculate damage - from app.services.damage_calculator import DamageCalculator - weapon = attacker.equipped_weapon # Assume Combatant has this field - dmg_result = DamageCalculator.calculate_physical_damage( - attacker.stats, - target.stats, - weapon, - ability - ) - - # Apply damage - actual_damage = DamageCalculator.apply_damage(target, dmg_result["damage"]) - - # Apply effects from ability - if ability and ability.effects_applied: - for effect_data in ability.effects_applied: - effect = Effect.from_dict(effect_data) - target.apply_effect(effect) - - # Consume mana - if ability: - attacker.current_mp -= ability.mana_cost - attacker.set_cooldown(ability.ability_id, ability.cooldown) - - # Build message - crit_msg = " (CRITICAL HIT!)" if dmg_result["is_crit"] else "" - message = f"{attacker.name} attacks {target.name} for {actual_damage} damage{crit_msg}" - - return { - "action": "attack", - "damage": actual_damage, - "is_crit": dmg_result["is_crit"], - "target": target.name, - "message": message - } - -def _execute_spell( - self, - encounter: CombatEncounter, - caster: Combatant, - ability_id: str, - target_id: str -) -> Dict[str, Any]: - """Execute spell.""" - # Similar to _execute_attack but uses calculate_magical_damage - # Implementation left as exercise - pass - -def _execute_item( - self, - encounter: CombatEncounter, - user: Combatant, - item_id: str, - target_id: str -) -> Dict[str, Any]: - """Use item in combat.""" - # Load item, apply effects (healing, buffs, etc.) - # Remove item from inventory - pass - -def _execute_defend( - self, - encounter: CombatEncounter, - defender: Combatant -) -> Dict[str, Any]: - """Enter defensive stance.""" - # Apply temporary defense buff - from app.models.effects import Effect, EffectType - - defense_buff = Effect( - effect_id="defend_buff", - name="Defending", - effect_type=EffectType.BUFF, - duration=1, - power=5, - stat_type="defense", - stacks=1, - max_stacks=1 - ) - - defender.apply_effect(defense_buff) - - return { - "action": "defend", - "message": f"{defender.name} takes a defensive stance (+5 defense)" - } -``` - -**Acceptance Criteria:** ✅ MET -- Attack action works (physical damage via DamageCalculator) -- Ability action works (magical damage, mana cost) -- Defend action applies temporary defense buff -- Flee action with DEX-based success chance -- All actions log messages to combat log +Implemented: `_execute_attack()`, `_execute_spell()`, `_execute_item()`, `_execute_defend()` with mana costs, cooldowns, effect application. --- -#### Task 1.6: Combat API Endpoints (1 day / 8 hours) ✅ COMPLETE - -**Objective:** Create REST API for combat +#### Task 1.6: Combat API Endpoints ✅ COMPLETE **File:** `/api/app/api/combat.py` **Endpoints:** +- `POST /api/v1/combat/start` - Initiate combat +- `POST /api/v1/combat//action` - Take action +- `GET /api/v1/combat//state` - Get state +- `POST /api/v1/combat//flee` - Attempt flee +- `POST /api/v1/combat//enemy-turn` - Enemy AI +- `GET /api/v1/combat/enemies` - List templates (public) +- `GET /api/v1/combat/enemies/` - Enemy details (public) -```python -""" -Combat API Blueprint - -Endpoints: -- POST /api/v1/combat/start - Initiate combat -- POST /api/v1/combat//action - Take combat action -- GET /api/v1/combat//state - Get combat state -- GET /api/v1/combat//results - Get results after victory -""" - -from flask import Blueprint, request, g - -from app.services.combat_service import CombatService, CombatNotFound, InvalidCombatAction -from app.services.character_service import get_character_service -from app.services.appwrite_service import get_appwrite_service -from app.utils.response import success_response, created_response, error_response, not_found_response -from app.utils.auth import require_auth -from app.utils.logging import get_logger - -logger = get_logger(__file__) - -combat_bp = Blueprint('combat', __name__) - - -@combat_bp.route('/start', methods=['POST']) -@require_auth -def start_combat(): - """ - Initiate a new combat encounter. - - Request JSON: - { - "session_id": "session_123", - "character_id": "char_abc", - "enemies": [ - { - "name": "Goblin", - "level": 2, - "stats": {...} - } - ] - } - - Returns: - 201 Created with combat encounter data - """ - data = request.get_json() - - # Validate request - session_id = data.get('session_id') - character_id = data.get('character_id') - enemies = data.get('enemies', []) - - if not session_id or not character_id: - return error_response("session_id and character_id required", 400) - - # Load character - char_service = get_character_service() - character = char_service.get_character(character_id, g.user_id) - - # Initiate combat - combat_service = CombatService(get_appwrite_service()) - encounter = combat_service.initiate_combat(session_id, character, enemies) - - return created_response({ - "combat_id": encounter.combat_id, - "turn_order": encounter.turn_order, - "current_turn": encounter.turn_order[0], - "round": encounter.round_number - }) - - -@combat_bp.route('//action', methods=['POST']) -@require_auth -def combat_action(combat_id: str): - """ - Take a combat action. - - Request JSON: - { - "action_type": "attack", // "attack", "spell", "item", "defend" - "ability_id": "basic_attack", - "target_id": "enemy_1", - "item_id": null // for item use - } - - Returns: - 200 OK with action result - """ - data = request.get_json() - - action_type = data.get('action_type') - ability_id = data.get('ability_id') - target_id = data.get('target_id') - item_id = data.get('item_id') - - try: - combat_service = CombatService(get_appwrite_service()) - result = combat_service.process_action( - combat_id, - action_type, - ability_id, - target_id, - item_id - ) - - # Get updated encounter state - encounter = combat_service.get_encounter(combat_id) - - return success_response({ - "action_result": result, - "combat_state": { - "current_turn": encounter.turn_order[encounter.current_turn_index] if encounter.turn_order else None, - "round": encounter.round_number, - "status": encounter.status.value, - "combatants": [c.to_dict() for c in encounter.combatants] - } - }) - - except CombatNotFound: - return not_found_response("Combat not found") - except InvalidCombatAction as e: - return error_response(str(e), 400) - - -@combat_bp.route('//state', methods=['GET']) -@require_auth -def get_combat_state(combat_id: str): - """Get current combat state.""" - try: - combat_service = CombatService(get_appwrite_service()) - encounter = combat_service.get_encounter(combat_id) - - return success_response({ - "combat_id": encounter.combat_id, - "status": encounter.status.value, - "round": encounter.round_number, - "turn_order": encounter.turn_order, - "current_turn_index": encounter.current_turn_index, - "combatants": [c.to_dict() for c in encounter.combatants], - "combat_log": encounter.combat_log[-10:] # Last 10 messages - }) - - except CombatNotFound: - return not_found_response("Combat not found") - - -@combat_bp.route('//results', methods=['GET']) -@require_auth -def get_combat_results(combat_id: str): - """Get combat results (loot, XP, etc.).""" - try: - combat_service = CombatService(get_appwrite_service()) - encounter = combat_service.get_encounter(combat_id) - - if encounter.status not in [CombatStatus.VICTORY, CombatStatus.DEFEAT]: - return error_response("Combat is still in progress", 400) - - # Calculate rewards (TODO: implement loot/XP system) - results = { - "victory": encounter.status == CombatStatus.VICTORY, - "xp_gained": 100, # Placeholder - "gold_gained": 50, # Placeholder - "loot": [] # Placeholder - } - - return success_response(results) - - except CombatNotFound: - return not_found_response("Combat not found") -``` - -**Don't forget to register blueprint in `/api/app/__init__.py`:** - -```python -from app.api.combat import combat_bp -app.register_blueprint(combat_bp, url_prefix='/api/v1/combat') -``` - -**Acceptance Criteria:** ✅ MET -- `POST /api/v1/combat/start` creates combat encounter -- `POST /api/v1/combat//action` processes actions -- `GET /api/v1/combat//state` returns current state -- `POST /api/v1/combat//flee` attempts to flee -- `POST /api/v1/combat//enemy-turn` executes enemy AI -- `GET /api/v1/combat/enemies` lists enemy templates (public) -- `GET /api/v1/combat/enemies/` gets enemy details (public) -- All combat endpoints require authentication (except enemy listing) -- 19 integration tests passing +19 integration tests passing. --- -#### Task 1.7: Manual API Testing (4 hours) ⏭️ SKIPPED +#### Task 1.7: Manual API Testing ⏭️ SKIPPED -**Objective:** Test combat flow end-to-end via API - -**Test Cases:** - -1. **Start Combat** - ```bash - curl -X POST http://localhost:5000/api/v1/combat/start \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "session_id": "session_123", - "character_id": "char_abc", - "enemies": [ - { - "name": "Goblin", - "level": 2, - "stats": { - "strength": 8, - "defense": 5, - "max_hp": 30 - } - } - ] - }' - ``` - -2. **Take Attack Action** - ```bash - curl -X POST http://localhost:5000/api/v1/combat//action \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "action_type": "attack", - "ability_id": "basic_attack", - "target_id": "enemy_0" - }' - ``` - -3. **Get Combat State** - ```bash - curl -X GET http://localhost:5000/api/v1/combat//state \ - -H "Authorization: Bearer " - ``` - -**Document in `/api/docs/API_TESTING.md`** - -**Acceptance Criteria:** ⏭️ SKIPPED (covered by automated tests) -- All endpoints return correct responses - ✅ via test_combat_api.py -- Combat flows from start to victory/defeat - ✅ via test_combat_service.py -- Damage calculations verified - ✅ via test_damage_calculator.py -- Effects process correctly - ✅ via test_combat_service.py -- Turn order maintained - ✅ via test_combat_service.py - -> **Note:** Manual testing skipped in favor of 108 comprehensive automated tests. +Covered by 108 comprehensive automated tests. --- @@ -1003,824 +159,95 @@ app.register_blueprint(combat_bp, url_prefix='/api/v1/combat') #### Task 2.1: Item Data Models ✅ COMPLETE -**Objective:** Implement Diablo-style item generation with affixes +**Files:** `/api/app/models/items.py`, `affixes.py`, `enums.py` -**Files Implemented:** -- `/api/app/models/items.py` - Item dataclass with affix support -- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses -- `/api/app/models/enums.py` - ItemRarity, AffixType, AffixTier enums - -**Item Model - New Fields for Generated Items:** - -```python -@dataclass -class Item: - # ... existing fields ... - - # Affix tracking (for generated items) - applied_affixes: List[str] = field(default_factory=list) - base_template_id: Optional[str] = None - generated_name: Optional[str] = None - is_generated: bool = False - - def get_display_name(self) -> str: - """Return generated name if available, otherwise base name.""" - return self.generated_name if self.generated_name else self.name -``` - -**Affix Model:** - -```python -@dataclass -class Affix: - affix_id: str - name: str # Display name ("Flaming", "of Strength") - affix_type: AffixType # PREFIX or SUFFIX - tier: AffixTier # MINOR, MAJOR, LEGENDARY - stat_bonuses: Dict[str, int] # {"strength": 3, "dexterity": 2} - damage_bonus: int = 0 - defense_bonus: int = 0 - damage_type: Optional[DamageType] = None # For elemental prefixes - elemental_ratio: float = 0.0 - allowed_item_types: List[str] = field(default_factory=list) -``` - -**BaseItemTemplate Model:** - -```python -@dataclass -class BaseItemTemplate: - template_id: str - name: str # "Dagger", "Longsword" - item_type: str # "weapon" or "armor" - base_damage: int = 0 - base_defense: int = 0 - base_value: int = 0 - required_level: int = 1 - min_rarity: str = "common" # Minimum rarity this can generate as - drop_weight: int = 100 # Relative drop chance -``` - -**Acceptance Criteria:** ✅ MET -- Item model supports both static and generated items -- Affix system with PREFIX/SUFFIX types -- Three affix tiers (MINOR, MAJOR, LEGENDARY) -- BaseItemTemplate for procedural generation foundation -- All models have to_dict()/from_dict() serialization +Implemented: `Item` dataclass with affix support (`applied_affixes`, `base_template_id`, `generated_name`, `is_generated`), `Affix` model (PREFIX/SUFFIX types, MINOR/MAJOR/LEGENDARY tiers), `BaseItemTemplate` for procedural generation. 24 tests. --- #### Task 2.2: Item Data Files ✅ COMPLETE -**Objective:** Create YAML data files for item generation system +**Directory:** `/api/app/data/` -**Directory Structure:** - -``` -/api/app/data/ -├── items/ # Static items (consumables, quest items) -│ └── consumables/ -│ └── potions.yaml -├── base_items/ # Base templates for generation -│ ├── weapons.yaml # 13 weapon templates -│ └── armor.yaml # 12 armor templates -└── affixes/ # Prefix/suffix definitions - ├── prefixes.yaml # 18 prefixes - └── suffixes.yaml # 11 suffixes -``` - -**Example Base Weapon Template (`/api/app/data/base_items/weapons.yaml`):** - -```yaml -dagger: - template_id: "dagger" - name: "Dagger" - item_type: "weapon" - base_damage: 6 - damage_type: "physical" - crit_chance: 0.08 - crit_multiplier: 2.0 - base_value: 15 - required_level: 1 - drop_weight: 100 -``` - -**Example Prefix Affix (`/api/app/data/affixes/prefixes.yaml`):** - -```yaml -flaming: - affix_id: "flaming" - name: "Flaming" - affix_type: "prefix" - tier: "minor" - damage_type: "fire" - elemental_ratio: 0.25 - damage_bonus: 3 - allowed_item_types: ["weapon"] -``` - -**Example Suffix Affix (`/api/app/data/affixes/suffixes.yaml`):** - -```yaml -of_strength: - affix_id: "of_strength" - name: "of Strength" - affix_type: "suffix" - tier: "minor" - stat_bonuses: - strength: 3 -``` - -**Items Created:** -- **Base Templates:** 25 total (13 weapons, 12 armor across cloth/leather/chain/plate) -- **Prefixes:** 18 total (elemental, material, quality, defensive, legendary) -- **Suffixes:** 11 total (stat bonuses, animal totems, defensive, legendary) -- **Static Consumables:** Health/mana potions (small/medium/large) - -**Acceptance Criteria:** ✅ MET -- Base templates cover all weapon/armor categories -- Affixes balanced across tiers -- YAML files valid and loadable +Created: +- `base_items/weapons.yaml` - 13 weapon templates +- `base_items/armor.yaml` - 12 armor templates (cloth/leather/chain/plate) +- `affixes/prefixes.yaml` - 18 prefixes (elemental, material, quality, legendary) +- `affixes/suffixes.yaml` - 11 suffixes (stat bonuses, animal totems, legendary) +- `items/consumables/potions.yaml` - Health/mana potions (small/medium/large) --- #### Task 2.2.1: Item Generator Service ✅ COMPLETE -**Objective:** Implement procedural item generation with Diablo-style naming +**Files:** `/api/app/services/item_generator.py`, `affix_loader.py`, `base_item_loader.py` -**Files Implemented:** -- `/api/app/services/item_generator.py` - Main generation service (535 lines) -- `/api/app/services/affix_loader.py` - Loads affixes from YAML (316 lines) -- `/api/app/services/base_item_loader.py` - Loads base templates from YAML (274 lines) +Implemented Diablo-style procedural generation: +- Affix distribution: COMMON/UNCOMMON (0), RARE (1), EPIC (2), LEGENDARY (3) +- Name generation: "Flaming Dagger of Strength" +- Tier weights by rarity (RARE: 80% MINOR, EPIC: 70% MAJOR, LEGENDARY: 50% LEGENDARY) +- Luck-influenced rarity rolling -**ItemGenerator Usage:** - -```python -from app.services.item_generator import get_item_generator -from app.models.enums import ItemRarity - -generator = get_item_generator() - -# Generate specific item -item = generator.generate_item( - item_type="weapon", - rarity=ItemRarity.EPIC, - character_level=5 -) -# Result: "Flaming Longsword of Strength" (1 prefix + 1 suffix) - -# Generate random loot drop with luck influence -item = generator.generate_loot_drop( - character_level=10, - luck_stat=12 # Higher luck = better rarity chance -) -``` - -**Affix Distribution by Rarity:** - -| Rarity | Affix Count | Distribution | -|--------|-------------|--------------| -| COMMON | 0 | Plain item | -| UNCOMMON | 0 | Plain item | -| RARE | 1 | 50% prefix OR 50% suffix | -| EPIC | 2 | 1 prefix AND 1 suffix | -| LEGENDARY | 3 | Mix (2+1 or 1+2) | - -**Name Generation Examples:** -- COMMON: "Dagger" -- RARE: "Flaming Dagger" or "Dagger of Strength" -- EPIC: "Flaming Dagger of Strength" -- LEGENDARY: "Blazing Glacial Dagger of the Titan" - -**Tier Weights by Rarity:** - -| Rarity | MINOR | MAJOR | LEGENDARY | -|--------|-------|-------|-----------| -| RARE | 80% | 20% | 0% | -| EPIC | 30% | 70% | 0% | -| LEGENDARY | 10% | 40% | 50% | - -**Rarity Rolling (with Luck):** - -Base chances at luck 8: -- COMMON: 50% -- UNCOMMON: 30% -- RARE: 15% -- EPIC: 4% -- LEGENDARY: 1% - -Luck bonus: `(luck - 8) * 0.005` per threshold - -**Tests:** `/api/tests/test_item_generator.py` (528 lines, comprehensive coverage) - -**Acceptance Criteria:** ✅ MET -- Procedural generation works for all rarities -- Affix selection respects tier weights -- Generated names follow Diablo naming convention -- Luck stat influences rarity rolls -- Stats properly combined from template + affixes +35 tests. --- -#### Task 2.3: Implement Inventory Service (1 day / 8 hours) ✅ COMPLETE - -**Objective:** Service layer for inventory management +#### Task 2.3: Implement Inventory Service ✅ COMPLETE **File:** `/api/app/services/inventory_service.py` -**Actual Implementation:** - -The InventoryService was implemented as an orchestration layer on top of the existing Character model inventory methods. Key design decisions: - -1. **Full Object Storage (Not IDs)**: The Character model already stores `List[Item]` for inventory and `Dict[str, Item]` for equipped items. This approach works better for generated items which have unique IDs. - -2. **Validation Layer**: Added comprehensive validation for: - - Equipment slot validation (weapon, off_hand, helmet, chest, gloves, boots, accessory_1, accessory_2) - - Level and class requirements - - Item type to slot mapping - -3. **Consumable Effects**: Supports instant healing (HOT effects) and duration-based buffs for combat integration. - -4. **Tests**: 51 unit tests covering all functionality. - -**Implementation:** - -```python -""" -Inventory Service - -Manages character inventory, equipment, and item usage. -""" - -from typing import List, Optional -from app.models.items import Item, ItemType -from app.models.character import Character -from app.services.item_loader import ItemLoader -from app.services.appwrite_service import AppwriteService -from app.utils.logging import get_logger - -logger = get_logger(__file__) - - -class InventoryError(Exception): - """Base exception for inventory errors.""" - pass - - -class ItemNotFound(InventoryError): - """Raised when item is not found in inventory.""" - pass - - -class CannotEquipItem(InventoryError): - """Raised when item cannot be equipped.""" - pass - - -class InventoryService: - """Service for managing character inventory.""" - - def __init__(self, appwrite_service: AppwriteService): - self.appwrite = appwrite_service - self.item_loader = ItemLoader() - - def get_inventory(self, character: Character) -> List[Item]: - """ - Get character's inventory. - - Args: - character: Character instance - - Returns: - List of Item instances - """ - return [self.item_loader.get_item(item_id) for item_id in character.inventory_item_ids] - - def add_item(self, character: Character, item_id: str) -> None: - """ - Add item to character inventory. - - Args: - character: Character instance - item_id: Item ID to add - """ - if item_id not in character.inventory_item_ids: - character.inventory_item_ids.append(item_id) - logger.info(f"Added item {item_id} to character {character.character_id}") - - def remove_item(self, character: Character, item_id: str) -> None: - """ - Remove item from inventory. - - Args: - character: Character instance - item_id: Item ID to remove - - Raises: - ItemNotFound: If item not in inventory - """ - if item_id not in character.inventory_item_ids: - raise ItemNotFound(f"Item {item_id} not in inventory") - - character.inventory_item_ids.remove(item_id) - logger.info(f"Removed item {item_id} from character {character.character_id}") - - def equip_item(self, character: Character, item_id: str, slot: str) -> None: - """ - Equip item to character. - - Args: - character: Character instance - item_id: Item ID to equip - slot: Equipment slot (weapon, helmet, chest, boots, etc.) - - Raises: - ItemNotFound: If item not in inventory - CannotEquipItem: If item cannot be equipped - """ - if item_id not in character.inventory_item_ids: - raise ItemNotFound(f"Item {item_id} not in inventory") - - item = self.item_loader.get_item(item_id) - - # Validate item type matches slot - if item.item_type == ItemType.WEAPON and slot != "weapon": - raise CannotEquipItem("Weapon can only be equipped in weapon slot") - - if item.item_type == ItemType.ARMOR: - # Armor has sub-types (helmet, chest, boots) - # Add validation based on item.armor_slot field - pass - - if item.item_type == ItemType.CONSUMABLE: - raise CannotEquipItem("Consumables cannot be equipped") - - # Check level requirement - if character.level < item.required_level: - raise CannotEquipItem(f"Requires level {item.required_level}") - - # Unequip current item in slot (if any) - current_item_id = character.equipped.get(slot) - if current_item_id: - # Current item returns to inventory (already there) - pass - - # Equip new item - character.equipped[slot] = item_id - - logger.info(f"Equipped {item_id} to {slot} for character {character.character_id}") - - def unequip_item(self, character: Character, slot: str) -> None: - """ - Unequip item from slot. - - Args: - character: Character instance - slot: Equipment slot - """ - if slot not in character.equipped: - return - - item_id = character.equipped[slot] - del character.equipped[slot] - - logger.info(f"Unequipped {item_id} from {slot} for character {character.character_id}") - - def use_consumable(self, character: Character, item_id: str) -> dict: - """ - Use consumable item. - - Args: - character: Character instance - item_id: Consumable item ID - - Returns: - Dict with effects applied - - Raises: - ItemNotFound: If item not in inventory - CannotEquipItem: If item is not consumable - """ - if item_id not in character.inventory_item_ids: - raise ItemNotFound(f"Item {item_id} not in inventory") - - item = self.item_loader.get_item(item_id) - - if item.item_type != ItemType.CONSUMABLE: - raise CannotEquipItem("Only consumables can be used") - - # Apply effects (healing, mana, buffs) - effects_applied = [] - - if hasattr(item, 'hp_restore') and item.hp_restore > 0: - old_hp = character.current_hp - character.current_hp = min(character.current_hp + item.hp_restore, character.stats.max_hp) - actual_healing = character.current_hp - old_hp - effects_applied.append(f"Restored {actual_healing} HP") - - if hasattr(item, 'mp_restore') and item.mp_restore > 0: - old_mp = character.current_mp - character.current_mp = min(character.current_mp + item.mp_restore, character.stats.max_mp) - actual_restore = character.current_mp - old_mp - effects_applied.append(f"Restored {actual_restore} MP") - - # Remove item from inventory (consumables are single-use) - self.remove_item(character, item_id) - - logger.info(f"Used consumable {item_id} for character {character.character_id}") - - return { - "item_used": item.name, - "effects": effects_applied - } -``` - -**Also create `/api/app/services/item_loader.py`:** - -```python -""" -Item Loader - -Loads items from YAML data files. -""" - -import os -import yaml -from typing import Dict, Optional -from app.models.items import Item -from app.utils.logging import get_logger - -logger = get_logger(__file__) - - -class ItemLoader: - """Loads and caches items from YAML files.""" - - def __init__(self): - self.items: Dict[str, Item] = {} - self._load_all_items() - - def _load_all_items(self) -> None: - """Load all items from YAML files.""" - base_dir = "app/data/items" - categories = ["weapons", "armor", "consumables"] - - for category in categories: - category_dir = os.path.join(base_dir, category) - if not os.path.exists(category_dir): - continue - - for yaml_file in os.listdir(category_dir): - if not yaml_file.endswith('.yaml'): - continue - - filepath = os.path.join(category_dir, yaml_file) - self._load_items_from_file(filepath) - - logger.info(f"Loaded {len(self.items)} items from YAML files") - - def _load_items_from_file(self, filepath: str) -> None: - """Load items from a single YAML file.""" - with open(filepath, 'r') as f: - items_data = yaml.safe_load(f) - - for item_data in items_data: - item = Item.from_dict(item_data) - self.items[item.item_id] = item - - def get_item(self, item_id: str) -> Optional[Item]: - """Get item by ID.""" - return self.items.get(item_id) - - def get_all_items(self) -> Dict[str, Item]: - """Get all loaded items.""" - return self.items -``` - -**Note on Generated Items:** - -The inventory service must handle both static items (loaded by ID) and generated items -(stored as full objects). Generated items have unique IDs (`gen_`) and cannot be -looked up from YAML - they must be stored/retrieved as complete Item objects. - -```python -# For static items (consumables, quest items) -item = item_loader.get_item("health_potion_small") - -# For generated items - store full object -generated_item = generator.generate_loot_drop(level, luck) -character.inventory.append(generated_item.to_dict()) # Store full item data -``` - -**Acceptance Criteria:** ✅ MET -- [x] Inventory service can add/remove items - `add_item()`, `remove_item()`, `drop_item()` -- [x] Equip/unequip works with validation - `equip_item()`, `unequip_item()` with slot/level/type checks -- [x] Consumables can be used (healing, mana restore) - `use_consumable()`, `use_consumable_in_combat()` -- [x] Character's equipped items tracked - via `get_equipped_items()`, `get_equipped_item()` -- [x] **Generated items stored as full objects (not just IDs)** - Character model uses `List[Item]` -- [x] Bulk operations - `add_items()`, `get_items_by_type()`, `get_equippable_items()` - -**Tests:** `/api/tests/test_inventory_service.py` - 51 tests +Implemented: `add_item()`, `remove_item()`, `equip_item()`, `unequip_item()`, `use_consumable()`, `use_consumable_in_combat()`. Full object storage for generated items. Validation for slots, levels, item types. 24 tests. --- -#### Task 2.4: Inventory API Endpoints (1 day / 8 hours) ✅ COMPLETE +#### Task 2.4: Inventory API Endpoints ✅ COMPLETE -**Objective:** REST API for inventory management +**File:** `/api/app/api/inventory.py` -**Files Implemented:** -- `/api/app/api/inventory.py` - API blueprint (530 lines) -- `/api/tests/test_inventory_api.py` - Integration tests (25 tests) +**Endpoints:** +- `GET /api/v1/characters//inventory` - Get inventory + equipped +- `POST /api/v1/characters//inventory/equip` - Equip item +- `POST /api/v1/characters//inventory/unequip` - Unequip item +- `POST /api/v1/characters//inventory/use` - Use consumable +- `DELETE /api/v1/characters//inventory/` - Drop item -**Endpoints Implemented:** - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/v1/characters//inventory` | Get inventory + equipped items | -| POST | `/api/v1/characters//inventory/equip` | Equip item to slot | -| POST | `/api/v1/characters//inventory/unequip` | Unequip from slot | -| POST | `/api/v1/characters//inventory/use` | Use consumable item | -| DELETE | `/api/v1/characters//inventory/` | Drop/remove item | - -**Exception Handling:** -- `CharacterNotFound` → 404 Not Found -- `ItemNotFoundError` → 404 Not Found -- `InvalidSlotError` → 422 Validation Error -- `CannotEquipError` → 400 Bad Request -- `CannotUseItemError` → 400 Bad Request -- `InventoryFullError` → 400 Bad Request - -**Response Examples:** - -```json -// GET /api/v1/characters/{id}/inventory -{ - "result": { - "inventory": [{"item_id": "...", "name": "...", ...}], - "equipped": { - "weapon": {...}, - "helmet": null, - ... - }, - "inventory_count": 5, - "max_inventory": 100 - } -} - -// POST /api/v1/characters/{id}/inventory/equip -{ - "result": { - "message": "Equipped Flaming Dagger to weapon slot", - "equipped": {...}, - "unequipped_item": null - } -} -``` - -**Blueprint registered in `/api/app/__init__.py`** - -**Tests:** 25 passing (`/api/tests/test_inventory_api.py`) - -**Acceptance Criteria:** ✅ MET -- [x] All inventory endpoints functional -- [x] Authentication required on all endpoints -- [x] Ownership validation enforced -- [x] Errors handled gracefully with proper HTTP status codes +25 tests. --- -#### Task 2.5: Update Character Stats Calculation (4 hours) ✅ COMPLETE +#### Task 2.5: Update Character Stats Calculation ✅ COMPLETE -**Objective:** Ensure `get_effective_stats()` includes equipped items' combat bonuses +**Files:** `/api/app/models/stats.py`, `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 - -**Implementation Summary:** - -The Stats model now has three equipment bonus fields that are populated by `get_effective_stats()`: - -```python -# Stats model additions -damage_bonus: int = 0 # From weapons -defense_bonus: int = 0 # From armor -resistance_bonus: int = 0 # From armor - -# Updated computed properties -@property -def damage(self) -> int: - return (self.strength // 2) + self.damage_bonus - -@property -def defense(self) -> int: - return (self.constitution // 2) + self.defense_bonus - -@property -def resistance(self) -> int: - return (self.wisdom // 2) + self.resistance_bonus -``` - -The `get_effective_stats()` method now applies: -1. `stat_bonuses` dict from all equipped items (as before) -2. Weapon `damage` → `damage_bonus` -3. Armor `defense` → `defense_bonus` -4. Armor `resistance` → `resistance_bonus` - -**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 +Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields to Stats. Updated `get_effective_stats()` to populate from equipped weapon/armor. 17 tests. --- -#### Task 2.6: Equipment-Combat Integration (4 hours) ✅ COMPLETE +#### Task 2.6: Equipment-Combat Integration ✅ COMPLETE -**Objective:** Fully integrate equipment stats into combat damage calculations, replacing hardcoded weapon damage values with effective_stats properties. +**Files:** `stats.py`, `items.py`, `character.py`, `combat.py`, `combat_service.py`, `damage_calculator.py` -**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 +Key changes: +- Damage scaling: `int(STR * 0.75) + damage_bonus` (was `STR // 2`) +- Added `spell_power` system for magical weapons +- Combatant weapon properties (crit_chance, crit_multiplier, elemental support) +- DamageCalculator uses `stats.damage` directly (removed `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 +140 tests. --- -### Task 2.7: Combat Loot Integration ✅ COMPLETE +#### Task 2.7: Combat Loot Integration ✅ COMPLETE -**Status:** Complete +**Files:** `combat_loot_service.py`, `static_item_loader.py`, `app/models/enemy.py` -Integrated the ItemGenerator with combat loot drops via a hybrid loot system supporting both static and procedural drops. +Implemented hybrid loot system: +- Static drops (consumables, materials) via `StaticItemLoader` +- Procedural drops (equipment) via `ItemGenerator` +- Difficulty bonuses: EASY +0%, MEDIUM +5%, HARD +15%, BOSS +30% +- Enemy variants: goblin_scout, goblin_warrior, goblin_chieftain -**Implementation Summary:** - -**1. Extended LootEntry Model** (`app/models/enemy.py`): -```yaml -# New hybrid loot table format -loot_table: - - loot_type: "static" - item_id: "health_potion_small" - drop_chance: 0.5 - - loot_type: "procedural" - item_type: "weapon" - rarity_bonus: 0.10 - drop_chance: 0.1 -``` - -**2. Created CombatLootService** (`app/services/combat_loot_service.py`): -- Orchestrates loot generation from combat encounters -- Combines StaticItemLoader (consumables) + ItemGenerator (equipment) -- Full rarity formula: `effective_luck = base_luck + (entry_bonus + difficulty_bonus + loot_bonus) * 20` - -**3. Created StaticItemLoader** (`app/services/static_item_loader.py`): -- Loads predefined items from `app/data/static_items/` YAML files -- Supports consumables, materials, and quest items - -**4. Integrated with CombatService._calculate_rewards()**: -- Builds `LootContext` from encounter (party level, luck, difficulty) -- Calls `CombatLootService.generate_loot_from_enemy()` for each defeated enemy -- Boss enemies get guaranteed equipment drops via `generate_boss_loot()` - -**5. Difficulty Rarity Bonuses:** -- EASY: +0% | MEDIUM: +5% | HARD: +15% | BOSS: +30% - -**6. Enemy Variants Created** (proof-of-concept): -- `goblin_scout.yaml` (Easy) - static drops only -- `goblin_warrior.yaml` (Medium) - static + 8% procedural weapon -- `goblin_chieftain.yaml` (Hard) - static + 25% weapon, 15% armor - -**Files Created:** -- `app/services/combat_loot_service.py` -- `app/services/static_item_loader.py` -- `app/data/static_items/consumables.yaml` -- `app/data/static_items/materials.yaml` -- `app/data/enemies/goblin_scout.yaml` -- `app/data/enemies/goblin_warrior.yaml` -- `app/data/enemies/goblin_chieftain.yaml` -- `tests/test_loot_entry.py` (16 tests) -- `tests/test_static_item_loader.py` (19 tests) -- `tests/test_combat_loot_service.py` (24 tests) - -**Checklist:** -- [x] LootType enum and extended LootEntry (backward compatible) -- [x] StaticItemLoader service for consumables/materials -- [x] CombatLootService with full rarity formula -- [x] CombatService integration with `_build_loot_context()` -- [x] Static items YAML files (consumables, materials) -- [x] Goblin variant YAML files (scout, warrior, chieftain) -- [x] Unit tests (59 new tests passing) +59 tests. ---