# Phase 4: Combat & Progression Systems - Implementation Plan **Status:** In Progress - Week 2 In Progress **Timeline:** 4-5 weeks **Last Updated:** November 26, 2025 **Document Version:** 1.1 --- ## Completion Summary ### Week 1: Combat Backend - COMPLETE | Task | Description | Status | Tests | |------|-------------|--------|-------| | 1.1 | Verify Combat Data Models | ✅ Complete | - | | 1.2 | Implement Combat Service | ✅ Complete | 25 tests | | 1.3 | Implement Damage Calculator | ✅ Complete | 39 tests | | 1.4 | Implement Effect Processor | ✅ Complete | - | | 1.5 | Implement Combat Actions | ✅ Complete | - | | 1.6 | Combat API Endpoints | ✅ Complete | 19 tests | | 1.7 | Manual API Testing | ⏭️ Skipped | - | **Files Created:** - `/api/app/models/enemy.py` - EnemyTemplate, LootEntry dataclasses - `/api/app/services/enemy_loader.py` - YAML-based enemy loading - `/api/app/services/combat_service.py` - Combat orchestration service - `/api/app/services/damage_calculator.py` - Damage formula calculations - `/api/app/api/combat.py` - REST API endpoints - `/api/app/data/enemies/*.yaml` - 6 sample enemy definitions - `/api/tests/test_damage_calculator.py` - 39 tests - `/api/tests/test_enemy_loader.py` - 25 tests - `/api/tests/test_combat_service.py` - 25 tests - `/api/tests/test_combat_api.py` - 19 tests **Total Tests:** 108 passing --- ## Overview This phase implements the core combat and progression systems for Code of Conquest, enabling turn-based tactical combat, inventory management, equipment, skill trees, and the NPC shop. This is a prerequisite for the story progression and quest systems. **Key Deliverables:** - Turn-based combat system (API + UI) - Inventory & equipment management - Skill tree visualization and unlocking - XP and leveling system - NPC shop --- ## Phase Structure | Sub-Phase | Duration | Focus | |-----------|----------|-------| | **Phase 4A** | 2-3 weeks | Combat Foundation | | **Phase 4B** | 1-2 weeks | Skill Trees & Leveling | | **Phase 4C** | 3-4 days | NPC Shop | **Total Estimated Time:** 4-5 weeks (~140-175 hours) --- ## Phase 4A: Combat Foundation (Weeks 1-3) ### Week 1: Combat Backend & Data Models ✅ COMPLETE #### Task 1.1: Verify Combat Data Models (2 hours) ✅ COMPLETE **Objective:** Ensure all combat-related dataclasses are complete and correct **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 --- #### Task 1.2: Implement Combat Service (1 day / 8 hours) ✅ COMPLETE **Objective:** Create service layer for combat management **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 --- #### Task 1.3: Implement Damage Calculator (4 hours) ✅ COMPLETE **Objective:** Calculate damage for physical/magical attacks with critical hits **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 --- #### Task 1.4: Implement Effect Processor (4 hours) ✅ COMPLETE **Objective:** Process effects (DOT, HOT, buffs, debuffs, stun, shield) at turn start **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 --- #### Task 1.5: Implement Combat Actions (1 day / 8 hours) ✅ COMPLETE **Objective:** Implement `_execute_attack`, `_execute_spell`, `_execute_item`, `_execute_defend` in CombatService **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 --- #### Task 1.6: Combat API Endpoints (1 day / 8 hours) ✅ COMPLETE **Objective:** Create REST API for combat **File:** `/api/app/api/combat.py` **Endpoints:** ```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 --- #### Task 1.7: Manual API Testing (4 hours) ⏭️ 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. --- ### Week 2: Inventory & Equipment System ⏳ IN PROGRESS #### Task 2.1: Item Data Models ✅ COMPLETE **Objective:** Implement Diablo-style item generation with affixes **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 --- #### Task 2.2: Item Data Files ✅ COMPLETE **Objective:** Create YAML data files for item generation system **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 --- #### Task 2.2.1: Item Generator Service ✅ COMPLETE **Objective:** Implement procedural item generation with Diablo-style naming **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) **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 --- #### Task 2.3: Implement Inventory Service (1 day / 8 hours) ✅ COMPLETE **Objective:** Service layer for inventory management **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 --- #### Task 2.4: Inventory API Endpoints (1 day / 8 hours) ✅ COMPLETE **Objective:** REST API for inventory management **Files Implemented:** - `/api/app/api/inventory.py` - API blueprint (530 lines) - `/api/tests/test_inventory_api.py` - Integration tests (25 tests) **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 --- #### Task 2.5: Update Character Stats Calculation (4 hours) **Objective:** Ensure `get_effective_stats()` includes equipped items **File:** `/api/app/models/character.py` **Update Method:** ```python def get_effective_stats(self) -> Stats: """ Calculate effective stats including base, equipment, skills, and effects. Returns: Stats instance with all modifiers applied """ # Start with base stats 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 from app.services.item_loader import ItemLoader item_loader = ItemLoader() for slot, item_id in self.equipped.items(): item = item_loader.get_item(item_id) if not item: 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:** - Equipped weapons add damage - Equipped armor adds defense/resistance - Stat bonuses from items apply correctly - Skills still apply bonuses - Effects still modify stats --- ### Future Work: Combat Loot Integration **Status:** Planned for future phase The ItemGenerator is ready for integration with combat loot drops. Future implementation will: **1. Update Enemy Loot Tables** - Add procedural generation options: ```yaml # Example enhanced enemy loot entry loot_table: - type: "static" item_id: "health_potion_small" drop_chance: 0.5 - type: "generated" item_type: "weapon" rarity_range: ["rare", "epic"] drop_chance: 0.1 ``` **2. Integrate with CombatService._calculate_rewards()** - Use ItemGenerator for loot rolls **3. Boss Guaranteed Drops** - Higher-tier enemies guarantee better rarity **4. Luck Stat Integration** - Player luck affects all loot rolls **Implementation Notes:** - Current enemy loot tables use `item_id` references (static items only) - ItemGenerator provides `generate_loot_drop(character_level, luck_stat)` method - Generated items must be stored as full objects (not IDs) in character inventory - Consider adding `LootService` wrapper for consistent loot generation across all sources --- ### Week 3: Combat UI #### Task 3.1: Create Combat Template (1 day / 8 hours) **Objective:** Build HTMX-powered combat interface **File:** `/public_web/templates/game/combat.html` **Layout:** ``` ┌─────────────────────────────────────────────────────────────┐ │ COMBAT ENCOUNTER │ ├───────────────┬─────────────────────────┬───────────────────┤ │ │ │ │ │ YOUR │ COMBAT LOG │ TURN ORDER │ │ CHARACTER │ │ ─────────── │ │ ───────── │ Goblin attacks you │ 1. Aragorn ✓ │ │ HP: ████ 80 │ for 12 damage! │ 2. Goblin │ │ MP: ███ 60 │ │ 3. Orc │ │ │ You attack Goblin │ │ │ ENEMY │ for 18 damage! │ ACTIVE EFFECTS │ │ ───────── │ CRITICAL HIT! │ ─────────── │ │ Goblin │ │ 🛡️ Defending │ │ HP: ██ 12 │ Goblin is stunned! │ (1 turn) │ │ │ │ │ │ │ ───────────────── │ │ │ │ ACTION BUTTONS │ │ │ │ ───────────────── │ │ │ │ [Attack] [Spell] │ │ │ │ [Item] [Defend] │ │ │ │ │ │ └───────────────┴─────────────────────────┴───────────────────┘ ``` **Implementation:** ```html {% extends "base.html" %} {% block title %}Combat - Code of Conquest{% endblock %} {% block extra_head %} {% endblock %} {% block content %}

⚔️ COMBAT ENCOUNTER

{# Left Panel - Combatants #} {# Middle Panel - Combat Log & Actions #}

Combat Log

{% for entry in combat_log[-10:] %}
{{ entry }}
{% endfor %}

Your Turn

{# Right Panel - Turn Order & Effects #}
{# Modal Container #} {% endblock %} {% block scripts %} {% endblock %} ``` **Also create `/public_web/static/css/combat.css`** **Acceptance Criteria:** - 3-column layout works - Combat log displays messages - HP/MP bars update dynamically - Action buttons trigger HTMX requests - Turn order displays correctly - Active effects shown --- #### Task 3.2: Combat HTMX Integration (1 day / 8 hours) **Objective:** Wire combat UI to API via HTMX **File:** `/public_web/app/views/combat.py` **Implementation:** ```python """ Combat Views Routes for combat UI. """ from flask import Blueprint, render_template, request, g, redirect, url_for from app.services.api_client import APIClient, APIError 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('/') @require_auth def combat_view(combat_id: str): """Display combat interface.""" api_client = APIClient() try: # Get combat state response = api_client.get(f'/combat/{combat_id}/state') combat_state = response['result'] return render_template( 'game/combat.html', combat_id=combat_id, combat_state=combat_state, turn_order=combat_state['turn_order'], current_turn_index=combat_state['current_turn_index'], combat_log=combat_state['combat_log'], character=combat_state['combatants'][0], # Player is first enemies=combat_state['combatants'][1:] # Rest are enemies ) except APIError as e: logger.error(f"Failed to load combat {combat_id}: {e}") return redirect(url_for('game.play')) @combat_bp.route('//action', methods=['POST']) @require_auth def combat_action(combat_id: str): """Process combat action (HTMX endpoint).""" api_client = APIClient() action_data = { 'action_type': request.form.get('action_type'), 'ability_id': request.form.get('ability_id'), 'target_id': request.form.get('target_id'), 'item_id': request.form.get('item_id') } try: # Submit action to API response = api_client.post(f'/combat/{combat_id}/action', json=action_data) result = response['result'] # Check if combat ended if result['combat_state']['status'] in ['victory', 'defeat']: return redirect(url_for('combat.combat_results', combat_id=combat_id)) # Re-render combat view with updated state return render_template( 'game/combat.html', combat_id=combat_id, combat_state=result['combat_state'], turn_order=result['combat_state']['turn_order'], current_turn_index=result['combat_state']['current_turn_index'], combat_log=result['combat_state']['combat_log'], character=result['combat_state']['combatants'][0], enemies=result['combat_state']['combatants'][1:] ) except APIError as e: logger.error(f"Combat action failed: {e}") return render_template('partials/error.html', error=str(e)) @combat_bp.route('//results') @require_auth def combat_results(combat_id: str): """Display combat results (victory/defeat).""" api_client = APIClient() try: response = api_client.get(f'/combat/{combat_id}/results') results = response['result'] return render_template( 'game/combat_results.html', victory=results['victory'], xp_gained=results['xp_gained'], gold_gained=results['gold_gained'], loot=results['loot'] ) except APIError as e: logger.error(f"Failed to load combat results: {e}") return redirect(url_for('game.play')) ``` **Register blueprint in `/public_web/app/__init__.py`:** ```python from app.views.combat import combat_bp app.register_blueprint(combat_bp, url_prefix='/combat') ``` **Acceptance Criteria:** - Combat view loads from API - Action buttons submit to API - Combat state updates dynamically - Combat results shown at end - Errors handled gracefully --- #### Task 3.3: Inventory UI (1 day / 8 hours) **Objective:** Add inventory accordion to character panel **File:** `/public_web/templates/game/partials/character_panel.html` **Add Inventory Section:** ```html {# Existing character panel code #} {# Add Inventory Accordion #}
{% for item in inventory %}
{{ item.name }} {{ item.name }}
{% endfor %}
{# Equipment Section #}
{% if character.equipped.weapon %} {{ get_item_name(character.equipped.weapon) }} {% else %} Empty {% endif %}
{# Similar for helmet, chest, boots, etc. #}
``` **Create `/public_web/templates/game/partials/item_modal.html`:** ```html ``` **Acceptance Criteria:** - Inventory displays in character panel - Click item shows modal with details - Equip/unequip works via HTMX - Use consumable works - Equipment slots show equipped items --- #### Task 3.4: Combat Testing & Polish (1 day / 8 hours) **Objective:** Playtest combat and fix bugs **Testing Checklist:** - [ ] Start combat from story session - [ ] Turn order correct - [ ] Attack deals damage - [ ] Critical hits work - [ ] Spells consume mana - [ ] Effects apply and tick correctly - [ ] Items can be used in combat - [ ] Defend action works - [ ] Victory awards XP/gold/loot - [ ] Defeat handling works - [ ] Combat log readable - [ ] HP/MP bars update - [ ] Multiple enemies work - [ ] Combat state persists (refresh page) **Bug Fixes & Polish:** - Fix any calculation errors - Improve combat log messages - Add visual feedback (animations, highlights) - Improve mobile responsiveness - Add loading states **Acceptance Criteria:** - Combat flows smoothly start to finish - No critical bugs - UX feels responsive and clear - Ready for real gameplay --- ## Phase 4B: Skill Trees & Leveling (Week 4) ### Task 4.1: Verify Skill Tree Data (2 hours) **Objective:** Review skill system **Files to Review:** - `/api/app/models/skills.py` - SkillNode, SkillTree, PlayerClass - `/api/app/data/skills/` - Skill YAML files for all 8 classes **Verification Checklist:** - [ ] Skill trees loaded from YAML - [ ] Each class has 2 skill trees - [ ] Each tree has 5 tiers - [ ] Prerequisites work correctly - [ ] Stat bonuses apply correctly **Acceptance Criteria:** - All 8 classes have complete skill trees - Unlock logic works - Respec logic implemented --- ### Task 4.2: Create Skill Tree Template (2 days / 16 hours) **Objective:** Visual skill tree UI **File:** `/public_web/templates/character/skills.html` **Layout:** ``` ┌─────────────────────────────────────────────────────────────┐ │ CHARACTER SKILL TREES │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Skill Points Available: 5 [Respec] ($$$)│ │ │ │ ┌────────────────────────┐ ┌────────────────────────┐ │ │ │ TREE 1: Combat │ │ TREE 2: Utility │ │ │ ├────────────────────────┤ ├────────────────────────┤ │ │ │ │ │ │ │ │ │ Tier 5: [⬢] [⬢] │ │ Tier 5: [⬢] [⬢] │ │ │ │ │ │ │ │ │ │ │ │ │ │ Tier 4: [⬢] [⬢] │ │ Tier 4: [⬢] [⬢] │ │ │ │ │ │ │ │ │ │ │ │ │ │ Tier 3: [⬢] [⬢] │ │ Tier 3: [⬢] [⬢] │ │ │ │ │ │ │ │ │ │ │ │ │ │ Tier 2: [✓] [⬢] │ │ Tier 2: [⬢] [✓] │ │ │ │ │ │ │ │ │ │ │ │ │ │ Tier 1: [✓] [✓] │ │ Tier 1: [✓] [✓] │ │ │ │ │ │ │ │ │ └────────────────────────┘ └────────────────────────┘ │ │ │ │ Legend: [✓] Unlocked [⬡] Available [⬢] Locked │ │ │ └─────────────────────────────────────────────────────────────┘ ``` **Implementation:** ```html {% extends "base.html" %} {% block title %}Skill Trees - {{ character.name }}{% endblock %} {% block content %}

{{ character.name }}'s Skill Trees

Skill Points: {{ character.skill_points }}
{% for tree in character.skill_trees %}

{{ tree.name }}

{{ tree.description }}

{% for tier in range(5, 0, -1) %}
Tier {{ tier }}
{% for node in tree.get_nodes_by_tier(tier) %}
{% if node.skill_id in character.unlocked_skills %} ✓ {% elif character.can_unlock(node.skill_id) %} ⬡ {% else %} ⬢ {% endif %}
{{ node.name }} {% if character.can_unlock(node.skill_id) and character.skill_points > 0 %} {% endif %}
{# Draw prerequisite lines #} {% if node.prerequisite_skill_id %}
{% endif %} {% endfor %}
{% endfor %}
{% endfor %}
{# Skill Tooltip (populated via HTMX) #}
{% endblock %} ``` **Also create `/public_web/templates/character/partials/skill_tooltip.html`:** ```html

{{ skill.name }}

{{ skill.description }}

Bonuses:
    {% for stat, bonus in skill.stat_bonuses.items() %}
  • +{{ bonus }} {{ stat|title }}
  • {% endfor %}
{% if skill.prerequisite_skill_id %}

Requires: {{ get_skill_name(skill.prerequisite_skill_id) }}

{% endif %}
``` **Acceptance Criteria:** - Dual skill tree layout works - 5 tiers × 2 nodes per tree displayed - Locked/available/unlocked states visual - Prerequisite lines drawn - Hover shows tooltip - Mobile responsive --- ### Task 4.3: Skill Unlock HTMX (4 hours) **Objective:** Click to unlock skills **File:** `/public_web/app/views/skills.py` ```python """ Skill Views Routes for skill tree UI. """ from flask import Blueprint, render_template, request, g from app.services.api_client import APIClient, APIError from app.utils.auth import require_auth from app.utils.logging import get_logger logger = get_logger(__file__) skills_bp = Blueprint('skills', __name__) @skills_bp.route('//tooltip', methods=['GET']) @require_auth def skill_tooltip(skill_id: str): """Get skill tooltip (HTMX partial).""" # Load skill data # Return rendered tooltip pass @skills_bp.route('/characters//skills', methods=['GET']) @require_auth def character_skills(character_id: str): """Display character skill trees.""" api_client = APIClient() try: # Get character response = api_client.get(f'/characters/{character_id}') character = response['result'] # Calculate respec cost respec_cost = character['level'] * 100 return render_template( 'character/skills.html', character=character, respec_cost=respec_cost ) except APIError as e: logger.error(f"Failed to load skills: {e}") return render_template('partials/error.html', error=str(e)) @skills_bp.route('/characters//skills/unlock', methods=['POST']) @require_auth def unlock_skill(character_id: str): """Unlock skill (HTMX endpoint).""" api_client = APIClient() skill_id = request.form.get('skill_id') try: # Unlock skill via API response = api_client.post( f'/characters/{character_id}/skills/unlock', json={'skill_id': skill_id} ) # Re-render skill trees character = response['result']['character'] respec_cost = character['level'] * 100 return render_template( 'character/skills.html', character=character, respec_cost=respec_cost ) except APIError as e: logger.error(f"Failed to unlock skill: {e}") return render_template('partials/error.html', error=str(e)) ``` **Acceptance Criteria:** - Click available node unlocks skill - Skill points decrease - Stat bonuses apply immediately - Prerequisites enforced - UI updates without page reload --- ### Task 4.4: Respec Functionality (4 hours) **Objective:** Respec button with confirmation **Implementation:** (in `skills_bp`) ```python @skills_bp.route('/characters//skills/respec', methods=['POST']) @require_auth def respec_skills(character_id: str): """Respec all skills.""" api_client = APIClient() try: response = api_client.post(f'/characters/{character_id}/skills/respec') character = response['result']['character'] respec_cost = character['level'] * 100 return render_template( 'character/skills.html', character=character, respec_cost=respec_cost, message="Skills reset! All skill points refunded." ) except APIError as e: logger.error(f"Failed to respec: {e}") return render_template('partials/error.html', error=str(e)) ``` **Acceptance Criteria:** - Respec button costs gold - Confirmation modal shown - All skills reset - Skill points refunded - Gold deducted --- ### Task 4.5: XP & Leveling System (1 day / 8 hours) **Objective:** Award XP after combat, level up grants skill points **File:** `/api/app/services/leveling_service.py` ```python """ Leveling Service Manages XP gain and level ups. """ from app.models.character import Character from app.utils.logging import get_logger logger = get_logger(__file__) class LevelingService: """Service for XP and leveling.""" @staticmethod def xp_required_for_level(level: int) -> int: """ Calculate XP required for a given level. Formula: 100 * (level ^ 2) """ return 100 * (level ** 2) @staticmethod def award_xp(character: Character, xp_amount: int) -> dict: """ Award XP to character and check for level up. Args: character: Character instance xp_amount: XP to award Returns: Dict with leveled_up, new_level, skill_points_gained """ character.experience += xp_amount leveled_up = False levels_gained = 0 # Check for level ups (can level multiple times) while character.experience >= LevelingService.xp_required_for_level(character.level + 1): character.level += 1 character.skill_points += 1 levels_gained += 1 leveled_up = True logger.info(f"Character {character.character_id} leveled up to {character.level}") return { 'leveled_up': leveled_up, 'new_level': character.level if leveled_up else None, 'skill_points_gained': levels_gained, 'xp_gained': xp_amount } ``` **Update Combat Results Endpoint:** ```python # In /api/app/api/combat.py @combat_bp.route('//results', methods=['GET']) @require_auth def get_combat_results(combat_id: str): """Get combat results with XP/loot.""" combat_service = CombatService(get_appwrite_service()) encounter = combat_service.get_encounter(combat_id) if encounter.status != CombatStatus.VICTORY: return error_response("Combat not won", 400) # Calculate XP (based on enemy difficulty) xp_gained = sum(enemy.level * 50 for enemy in encounter.combatants if not enemy.is_player) # Award XP to character char_service = get_character_service() character = char_service.get_character(encounter.character_id, g.user_id) from app.services.leveling_service import LevelingService level_result = LevelingService.award_xp(character, xp_gained) # Award gold gold_gained = sum(enemy.level * 25 for enemy in encounter.combatants if not enemy.is_player) character.gold += gold_gained # Generate loot (TODO: implement loot tables) loot = [] # Save character char_service.update_character(character) return success_response({ 'victory': True, 'xp_gained': xp_gained, 'gold_gained': gold_gained, 'loot': loot, 'level_up': level_result }) ``` **Create Level Up Modal Template:** **File:** `/public_web/templates/game/partials/level_up_modal.html` ```html ``` **Acceptance Criteria:** - XP awarded after combat victory - Level up triggers at XP threshold - Skill points granted on level up - Level up modal shown - Character stats increase --- ## Phase 4C: NPC Shop (Days 15-18) ### Task 5.1: Define Shop Inventory (4 hours) **Objective:** Create YAML for shop items **File:** `/api/app/data/shop/general_store.yaml` ```yaml shop_id: "general_store" shop_name: "General Store" shop_description: "A well-stocked general store with essential supplies." shopkeeper_name: "Merchant Guildmaster" inventory: # Weapons - item_id: "iron_sword" stock: -1 # Unlimited stock (-1) price: 50 - item_id: "oak_bow" stock: -1 price: 45 # Armor - item_id: "leather_helmet" stock: -1 price: 30 - item_id: "leather_chest" stock: -1 price: 60 # Consumables - item_id: "health_potion_small" stock: -1 price: 10 - item_id: "health_potion_medium" stock: -1 price: 30 - item_id: "mana_potion_small" stock: -1 price: 15 - item_id: "antidote" stock: -1 price: 20 ``` **Acceptance Criteria:** - Shop inventory defined in YAML - Mix of weapons, armor, consumables - Reasonable pricing - Unlimited stock for basics --- ### Task 5.2: Shop API Endpoints (4 hours) **Objective:** Create shop endpoints **File:** `/api/app/api/shop.py` ```python """ Shop API Blueprint Endpoints: - GET /api/v1/shop/inventory - Browse shop items - POST /api/v1/shop/purchase - Purchase item """ from flask import Blueprint, request, g from app.services.shop_service import ShopService 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, error_response from app.utils.auth import require_auth from app.utils.logging import get_logger logger = get_logger(__file__) shop_bp = Blueprint('shop', __name__) @shop_bp.route('/inventory', methods=['GET']) @require_auth def get_shop_inventory(): """Get shop inventory.""" shop_service = ShopService() inventory = shop_service.get_shop_inventory("general_store") return success_response({ 'shop_name': "General Store", 'inventory': [ { 'item': item.to_dict(), 'price': price, 'in_stock': True } for item, price in inventory ] }) @shop_bp.route('/purchase', methods=['POST']) @require_auth def purchase_item(): """ Purchase item from shop. Request JSON: { "character_id": "char_abc", "item_id": "iron_sword", "quantity": 1 } """ data = request.get_json() character_id = data.get('character_id') item_id = data.get('item_id') quantity = data.get('quantity', 1) # Get character char_service = get_character_service() character = char_service.get_character(character_id, g.user_id) # Purchase item shop_service = ShopService() try: result = shop_service.purchase_item( character, "general_store", item_id, quantity ) # Save character char_service.update_character(character) return success_response(result) except Exception as e: return error_response(str(e), 400) ``` **Also create `/api/app/services/shop_service.py`:** ```python """ Shop Service Manages NPC shop inventory and purchases. """ import yaml from typing import List, Tuple from app.models.items import Item from app.models.character import Character from app.services.item_loader import ItemLoader from app.utils.logging import get_logger logger = get_logger(__file__) class ShopService: """Service for NPC shops.""" def __init__(self): self.item_loader = ItemLoader() self.shops = self._load_shops() def _load_shops(self) -> dict: """Load all shop data from YAML.""" shops = {} with open('app/data/shop/general_store.yaml', 'r') as f: shop_data = yaml.safe_load(f) shops[shop_data['shop_id']] = shop_data return shops def get_shop_inventory(self, shop_id: str) -> List[Tuple[Item, int]]: """ Get shop inventory. Returns: List of (Item, price) tuples """ shop = self.shops.get(shop_id) if not shop: return [] inventory = [] for item_data in shop['inventory']: item = self.item_loader.get_item(item_data['item_id']) price = item_data['price'] inventory.append((item, price)) return inventory def purchase_item( self, character: Character, shop_id: str, item_id: str, quantity: int = 1 ) -> dict: """ Purchase item from shop. Args: character: Character instance shop_id: Shop ID item_id: Item to purchase quantity: Quantity to buy Returns: Purchase result dict Raises: ValueError: If insufficient gold or item not found """ shop = self.shops.get(shop_id) if not shop: raise ValueError("Shop not found") # Find item in shop inventory item_data = next( (i for i in shop['inventory'] if i['item_id'] == item_id), None ) if not item_data: raise ValueError("Item not available in shop") price = item_data['price'] * quantity # Check if character has enough gold if character.gold < price: raise ValueError(f"Not enough gold. Need {price}, have {character.gold}") # Deduct gold character.gold -= price # Add items to inventory for _ in range(quantity): if item_id not in character.inventory_item_ids: character.inventory_item_ids.append(item_id) else: # Item already exists, increment stack (if stackable) # For now, just add multiple entries character.inventory_item_ids.append(item_id) logger.info(f"Character {character.character_id} purchased {quantity}x {item_id} for {price} gold") return { 'item_purchased': item_id, 'quantity': quantity, 'total_cost': price, 'gold_remaining': character.gold } ``` **Acceptance Criteria:** - Shop inventory endpoint works - Purchase endpoint validates gold - Items added to inventory - Gold deducted - Transactions logged --- ### Task 5.3: Shop UI (1 day / 8 hours) **Objective:** Shop browse and purchase interface **File:** `/public_web/templates/shop/index.html` ```html {% extends "base.html" %} {% block title %}Shop - Code of Conquest{% endblock %} {% block content %}

🏪 {{ shop_name }}

Shopkeeper: {{ shopkeeper_name }}

Your Gold: {{ character.gold }}

{% for item_entry in inventory %}

{{ item_entry.item.name }}

{{ item_entry.price }} gold

{{ item_entry.item.description }}

{% if item_entry.item.item_type == 'weapon' %} ⚔️ Damage: {{ item_entry.item.damage }} {% elif item_entry.item.item_type == 'armor' %} 🛡️ Defense: {{ item_entry.item.defense }} {% elif item_entry.item.item_type == 'consumable' %} ❤️ Restores: {{ item_entry.item.hp_restore }} HP {% endif %}
{% endfor %}
{% endblock %} ``` **Create view in `/public_web/app/views/shop.py`:** ```python """ Shop Views """ from flask import Blueprint, render_template, request, g from app.services.api_client import APIClient, APIError from app.utils.auth import require_auth from app.utils.logging import get_logger logger = get_logger(__file__) shop_bp = Blueprint('shop', __name__) @shop_bp.route('/') @require_auth def shop_index(): """Display shop.""" api_client = APIClient() try: # Get shop inventory shop_response = api_client.get('/shop/inventory') inventory = shop_response['result']['inventory'] # Get character (for gold display) char_response = api_client.get(f'/characters/{g.character_id}') character = char_response['result'] return render_template( 'shop/index.html', shop_name="General Store", shopkeeper_name="Merchant Guildmaster", inventory=inventory, character=character ) except APIError as e: logger.error(f"Failed to load shop: {e}") return render_template('partials/error.html', error=str(e)) @shop_bp.route('/purchase', methods=['POST']) @require_auth def purchase(): """Purchase item (HTMX endpoint).""" api_client = APIClient() purchase_data = { 'character_id': request.form.get('character_id'), 'item_id': request.form.get('item_id'), 'quantity': 1 } try: response = api_client.post('/shop/purchase', json=purchase_data) # Reload shop return shop_index() except APIError as e: logger.error(f"Purchase failed: {e}") return render_template('partials/error.html', error=str(e)) ``` **Acceptance Criteria:** - Shop displays all items - Item cards show stats and price - Purchase button disabled if not enough gold - Purchase adds item to inventory - Gold updates dynamically - UI refreshes after purchase --- ### Task 5.4: Transaction Logging (2 hours) **Objective:** Log all shop purchases **File:** `/api/app/models/transaction.py` ```python """ Transaction Model Tracks all gold transactions (shop, trades, etc.) """ from dataclasses import dataclass, field from datetime import datetime from typing import Dict, Any @dataclass class Transaction: """Represents a gold transaction.""" transaction_id: str transaction_type: str # "shop_purchase", "trade", "quest_reward", etc. character_id: str amount: int # Negative for expenses, positive for income description: str timestamp: datetime = field(default_factory=datetime.utcnow) metadata: Dict[str, Any] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: """Serialize to dict.""" return { "transaction_id": self.transaction_id, "transaction_type": self.transaction_type, "character_id": self.character_id, "amount": self.amount, "description": self.description, "timestamp": self.timestamp.isoformat(), "metadata": self.metadata } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Transaction': """Deserialize from dict.""" return cls( transaction_id=data["transaction_id"], transaction_type=data["transaction_type"], character_id=data["character_id"], amount=data["amount"], description=data["description"], timestamp=datetime.fromisoformat(data["timestamp"]), metadata=data.get("metadata", {}) ) ``` **Update `ShopService.purchase_item()` to log transaction:** ```python # In shop_service.py def purchase_item(...): # ... existing code ... # Log transaction from app.models.transaction import Transaction import uuid transaction = Transaction( transaction_id=str(uuid.uuid4()), transaction_type="shop_purchase", character_id=character.character_id, amount=-price, description=f"Purchased {quantity}x {item_id} from {shop_id}", metadata={ "shop_id": shop_id, "item_id": item_id, "quantity": quantity, "unit_price": item_data['price'] } ) # Save to database from app.services.appwrite_service import get_appwrite_service appwrite = get_appwrite_service() appwrite.create_document("transactions", transaction.transaction_id, transaction.to_dict()) # ... rest of code ... ``` **Acceptance Criteria:** - All purchases logged to database - Transaction records complete - Can query transaction history --- ## Success Criteria - Phase 4 Complete ### Combat System - [ ] Turn-based combat works end-to-end - [ ] Damage calculations correct (physical, magical, critical) - [ ] Effects process correctly (DOT, HOT, buffs, debuffs, shields, stun) - [ ] Combat UI functional and responsive - [ ] Victory awards XP, gold, loot - [ ] Combat state persists ### Inventory System - [ ] Inventory displays in UI - [ ] Equip/unequip items works - [ ] Consumables can be used - [ ] Equipment affects character stats - [ ] Item YAML data loaded correctly ### Skill Trees - [ ] Visual skill tree UI works - [ ] Prerequisites enforced - [ ] Unlock skills with skill points - [ ] Respec functionality works - [ ] Stat bonuses apply immediately ### Leveling - [ ] XP awarded after combat - [ ] Level up triggers at threshold - [ ] Skill points granted on level up - [ ] Level up modal shown - [ ] Character stats increase ### NPC Shop - [ ] Shop inventory displays - [ ] Purchase validation works - [ ] Items added to inventory - [ ] Gold deducted correctly - [ ] Transactions logged --- ## Next Steps After Phase 4 Once Phase 4 is complete, you'll have a fully playable combat game with progression. The next logical phases are: **Phase 5: Story Progression & Quests** (Original Phase 4 from roadmap) - AI-driven story progression - Action prompts (button-based gameplay) - Quest system (YAML-driven, context-aware) - Full gameplay loop: Explore → Combat → Quests → Level Up **Phase 6: Multiplayer Sessions** - Invite-based co-op - Time-limited sessions - AI-generated campaigns **Phase 7: Marketplace & Economy** - Player-to-player trading - Auction system - Economy balancing --- ## Appendix: Testing Strategy ### Manual Testing Checklist **Combat:** - [ ] Start combat from story - [ ] Turn order correct - [ ] Attack deals damage - [ ] Spells work - [ ] Items usable in combat - [ ] Defend action - [ ] Victory conditions - [ ] Defeat handling **Inventory:** - [ ] Add items - [ ] Remove items - [ ] Equip weapons - [ ] Equip armor - [ ] Use consumables - [ ] Inventory UI updates **Skills:** - [ ] View skill trees - [ ] Unlock skills - [ ] Prerequisites enforced - [ ] Stat bonuses apply - [ ] Respec works **Shop:** - [ ] Browse inventory - [ ] Purchase items - [ ] Insufficient gold handling - [ ] Transaction logging --- ## Document Maintenance **Update this document as you complete tasks:** - Mark tasks complete with ✅ - Add notes about implementation decisions - Update time estimates based on actual progress - Document any blockers or challenges **Good luck with Phase 4 implementation!** 🚀