Files
Code_of_Conquest/docs/PHASE4_COMBAT_IMPLEMENTATION.md
Phillip Tarrant 76f67c4a22 feat(api): implement inventory service with equipment system
Add InventoryService for managing character inventory, equipment, and
consumable usage. Key features:

- Add/remove items with inventory capacity checks
- Equipment slot validation (weapon, off_hand, helmet, chest, gloves,
  boots, accessory_1, accessory_2)
- Level and class requirement validation for equipment
- Consumable usage with instant and duration-based effects
- Combat-specific consumable method returning effects for combat system
- Bulk operations (add_items, get_items_by_type, get_equippable_items)

Design decision: Uses full Item object storage (not IDs) to support
procedurally generated items with unique identifiers.

Files added:
- /api/app/services/inventory_service.py (560 lines)
- /api/tests/test_inventory_service.py (51 tests passing)

Task 2.3 of Phase 4 Combat Implementation complete.
2025-11-26 18:38:39 -06:00

100 KiB
Raw Blame History

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:

  • Combatant dataclass has all required fields
    • combatant_id, name, stats, current_hp, current_mp
    • active_effects, cooldowns, is_player
  • CombatEncounter dataclass complete
    • encounter_id, combatants, turn_order, current_turn_index
    • combat_log, round_number, status
  • Effect types implemented: BUFF, DEBUFF, DOT, HOT, STUN, SHIELD
  • Effect stacking logic correct (max_stacks, duration refresh)
  • Ability loading from YAML works
  • 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:

"""
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:

"""
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:

# 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:

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:

"""
Combat API Blueprint

Endpoints:
- POST /api/v1/combat/start - Initiate combat
- POST /api/v1/combat/<combat_id>/action - Take combat action
- GET /api/v1/combat/<combat_id>/state - Get combat state
- GET /api/v1/combat/<combat_id>/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('/<combat_id>/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('/<combat_id>/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('/<combat_id>/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:

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/<session_id>/action processes actions
  • GET /api/v1/combat/<session_id>/state returns current state
  • POST /api/v1/combat/<session_id>/flee attempts to flee
  • POST /api/v1/combat/<session_id>/enemy-turn executes enemy AI
  • GET /api/v1/combat/enemies lists enemy templates (public)
  • GET /api/v1/combat/enemies/<id> 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

    curl -X POST http://localhost:5000/api/v1/combat/start \
      -H "Authorization: Bearer <token>" \
      -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

    curl -X POST http://localhost:5000/api/v1/combat/<combat_id>/action \
      -H "Authorization: Bearer <token>" \
      -H "Content-Type: application/json" \
      -d '{
        "action_type": "attack",
        "ability_id": "basic_attack",
        "target_id": "enemy_0"
      }'
    
  3. Get Combat State

    curl -X GET http://localhost:5000/api/v1/combat/<combat_id>/state \
      -H "Authorization: Bearer <token>"
    

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:

@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:

@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:

@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):

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):

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):

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:

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:

"""
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:

"""
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_<uuid>) and cannot be looked up from YAML - they must be stored/retrieved as complete Item objects.

# 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

  • Inventory service can add/remove items - add_item(), remove_item(), drop_item()
  • Equip/unequip works with validation - equip_item(), unequip_item() with slot/level/type checks
  • Consumables can be used (healing, mana restore) - use_consumable(), use_consumable_in_combat()
  • Character's equipped items tracked - via get_equipped_items(), get_equipped_item()
  • Generated items stored as full objects (not just IDs) - Character model uses List[Item]
  • 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)

Objective: REST API for inventory management

File: /api/app/api/inventory.py

Endpoints:

"""
Inventory API Blueprint

Endpoints:
- GET /api/v1/characters/<id>/inventory - Get inventory
- POST /api/v1/characters/<id>/inventory/equip - Equip item
- POST /api/v1/characters/<id>/inventory/unequip - Unequip item
- POST /api/v1/characters/<id>/inventory/use - Use consumable
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop item
"""

from flask import Blueprint, request, g

from app.services.inventory_service import InventoryService, InventoryError
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, not_found_response
from app.utils.auth import require_auth
from app.utils.logging import get_logger

logger = get_logger(__file__)

inventory_bp = Blueprint('inventory', __name__)


@inventory_bp.route('/<character_id>/inventory', methods=['GET'])
@require_auth
def get_inventory(character_id: str):
    """Get character inventory."""
    char_service = get_character_service()
    character = char_service.get_character(character_id, g.user_id)

    inventory_service = InventoryService(get_appwrite_service())
    items = inventory_service.get_inventory(character)

    return success_response({
        "inventory": [item.to_dict() for item in items],
        "equipped": character.equipped
    })


@inventory_bp.route('/<character_id>/inventory/equip', methods=['POST'])
@require_auth
def equip_item(character_id: str):
    """
    Equip item.

    Request JSON:
    {
        "item_id": "iron_sword",
        "slot": "weapon"
    }
    """
    data = request.get_json()
    item_id = data.get('item_id')
    slot = data.get('slot')

    if not item_id or not slot:
        return error_response("item_id and slot required", 400)

    try:
        char_service = get_character_service()
        character = char_service.get_character(character_id, g.user_id)

        inventory_service = InventoryService(get_appwrite_service())
        inventory_service.equip_item(character, item_id, slot)

        # Save character
        char_service.update_character(character)

        return success_response({
            "equipped": character.equipped,
            "message": f"Equipped {item_id} to {slot}"
        })

    except InventoryError as e:
        return error_response(str(e), 400)


@inventory_bp.route('/<character_id>/inventory/unequip', methods=['POST'])
@require_auth
def unequip_item(character_id: str):
    """
    Unequip item.

    Request JSON:
    {
        "slot": "weapon"
    }
    """
    data = request.get_json()
    slot = data.get('slot')

    if not slot:
        return error_response("slot required", 400)

    char_service = get_character_service()
    character = char_service.get_character(character_id, g.user_id)

    inventory_service = InventoryService(get_appwrite_service())
    inventory_service.unequip_item(character, slot)

    # Save character
    char_service.update_character(character)

    return success_response({
        "equipped": character.equipped,
        "message": f"Unequipped item from {slot}"
    })


@inventory_bp.route('/<character_id>/inventory/use', methods=['POST'])
@require_auth
def use_item(character_id: str):
    """
    Use consumable item.

    Request JSON:
    {
        "item_id": "health_potion_small"
    }
    """
    data = request.get_json()
    item_id = data.get('item_id')

    if not item_id:
        return error_response("item_id required", 400)

    try:
        char_service = get_character_service()
        character = char_service.get_character(character_id, g.user_id)

        inventory_service = InventoryService(get_appwrite_service())
        result = inventory_service.use_consumable(character, item_id)

        # Save character
        char_service.update_character(character)

        return success_response(result)

    except InventoryError as e:
        return error_response(str(e), 400)

Register blueprint in /api/app/__init__.py

Acceptance Criteria:

  • All inventory endpoints functional
  • Authentication required
  • Ownership validation enforced
  • Errors handled gracefully

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:

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:

# 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:

{% extends "base.html" %}

{% block title %}Combat - Code of Conquest{% endblock %}

{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}

{% block content %}
<div class="combat-container">
    <h1 class="combat-title">⚔️ COMBAT ENCOUNTER</h1>

    <div class="combat-grid">
        {# Left Panel - Combatants #}
        <aside class="combat-panel combat-combatants">
            <div class="combatant-card player-card">
                <h3>{{ character.name }}</h3>
                <div class="hp-bar">
                    <div class="hp-fill" style="width: {{ (character.current_hp / character.stats.max_hp * 100)|int }}%"></div>
                    <span class="hp-text">HP: {{ character.current_hp }} / {{ character.stats.max_hp }}</span>
                </div>
                <div class="mp-bar">
                    <div class="mp-fill" style="width: {{ (character.current_mp / character.stats.max_mp * 100)|int }}%"></div>
                    <span class="mp-text">MP: {{ character.current_mp }} / {{ character.stats.max_mp }}</span>
                </div>
            </div>

            <div class="vs-divider">VS</div>

            {% for enemy in enemies %}
            <div class="combatant-card enemy-card" id="enemy-{{ loop.index0 }}">
                <h3>{{ enemy.name }}</h3>
                <div class="hp-bar">
                    <div class="hp-fill enemy" style="width: {{ (enemy.current_hp / enemy.stats.max_hp * 100)|int }}%"></div>
                    <span class="hp-text">HP: {{ enemy.current_hp }} / {{ enemy.stats.max_hp }}</span>
                </div>
                {% if enemy.current_hp > 0 %}
                <button class="btn btn-target" onclick="selectTarget('{{ enemy.combatant_id }}')">
                    Target
                </button>
                {% else %}
                <span class="defeated-badge">DEFEATED</span>
                {% endif %}
            </div>
            {% endfor %}
        </aside>

        {# Middle Panel - Combat Log & Actions #}
        <section class="combat-panel combat-main">
            <div class="combat-log" id="combat-log">
                <h3>Combat Log</h3>
                <div class="log-entries">
                    {% for entry in combat_log[-10:] %}
                    <div class="log-entry">{{ entry }}</div>
                    {% endfor %}
                </div>
            </div>

            <div class="combat-actions" id="combat-actions">
                <h3>Your Turn</h3>
                <div class="action-buttons">
                    <button class="btn btn-action btn-attack"
                            hx-post="/combat/{{ combat_id }}/action"
                            hx-vals='{"action_type": "attack", "ability_id": "basic_attack", "target_id": ""}'
                            hx-target="#combat-container"
                            hx-swap="outerHTML">
                        ⚔️ Attack
                    </button>

                    <button class="btn btn-action btn-spell"
                            onclick="openSpellMenu()">
                        ✨ Cast Spell
                    </button>

                    <button class="btn btn-action btn-item"
                            onclick="openItemMenu()">
                        🎒 Use Item
                    </button>

                    <button class="btn btn-action btn-defend"
                            hx-post="/combat/{{ combat_id }}/action"
                            hx-vals='{"action_type": "defend"}'
                            hx-target="#combat-container"
                            hx-swap="outerHTML">
                        🛡️ Defend
                    </button>
                </div>
            </div>
        </section>

        {# Right Panel - Turn Order & Effects #}
        <aside class="combat-panel combat-sidebar">
            <div class="turn-order">
                <h3>Turn Order</h3>
                <ol>
                    {% for combatant_id in turn_order %}
                    <li class="{% if loop.index0 == current_turn_index %}active-turn{% endif %}">
                        {{ get_combatant_name(combatant_id) }}
                        {% if loop.index0 == current_turn_index %}✓{% endif %}
                    </li>
                    {% endfor %}
                </ol>
            </div>

            <div class="active-effects">
                <h3>Active Effects</h3>
                {% for effect in character.active_effects %}
                <div class="effect-badge {{ effect.effect_type }}">
                    {{ effect.name }} ({{ effect.duration }})
                </div>
                {% endfor %}
            </div>
        </aside>
    </div>
</div>

{# Modal Container #}
<div id="modal-container"></div>
{% endblock %}

{% block scripts %}
<script>
let selectedTargetId = null;

function selectTarget(targetId) {
    selectedTargetId = targetId;

    // Update UI to show selected target
    document.querySelectorAll('.btn-target').forEach(btn => {
        btn.classList.remove('selected');
    });
    event.target.classList.add('selected');
}

function openSpellMenu() {
    // TODO: Open modal with spell selection
}

function openItemMenu() {
    // TODO: Open modal with item selection
}

// Auto-scroll combat log to bottom
const logDiv = document.querySelector('.log-entries');
if (logDiv) {
    logDiv.scrollTop = logDiv.scrollHeight;
}
</script>
{% 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:

"""
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('/<combat_id>')
@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('/<combat_id>/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('/<combat_id>/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:

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:

{# Existing character panel code #}

{# Add Inventory Accordion #}
<div class="panel-accordion" data-accordion="inventory">
    <button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
        <span>Inventory <span class="count">({{ character.inventory|length }}/{{ inventory_max }})</span></span>
        <span class="accordion-icon"></span>
    </button>
    <div class="panel-accordion-content">
        <div class="inventory-grid">
            {% for item in inventory %}
            <div class="inventory-item {{ item.rarity }}"
                 hx-get="/inventory/{{ character.character_id }}/item/{{ item.item_id }}"
                 hx-target="#modal-container"
                 hx-swap="innerHTML">
                <img src="{{ item.icon_url or '/static/img/items/default.png' }}" alt="{{ item.name }}">
                <span class="item-name">{{ item.name }}</span>
            </div>
            {% endfor %}
        </div>
    </div>
</div>

{# Equipment Section #}
<div class="panel-accordion" data-accordion="equipment">
    <button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
        <span>Equipment</span>
        <span class="accordion-icon"></span>
    </button>
    <div class="panel-accordion-content">
        <div class="equipment-slots">
            <div class="equipment-slot">
                <label>Weapon:</label>
                {% if character.equipped.weapon %}
                <span class="equipped-item">{{ get_item_name(character.equipped.weapon) }}</span>
                <button class="btn-small"
                        hx-post="/inventory/{{ character.character_id }}/unequip"
                        hx-vals='{"slot": "weapon"}'
                        hx-target="#character-panel"
                        hx-swap="outerHTML">
                    Unequip
                </button>
                {% else %}
                <span class="empty-slot">Empty</span>
                {% endif %}
            </div>

            <div class="equipment-slot">
                <label>Helmet:</label>
                {# Similar for helmet, chest, boots, etc. #}
            </div>
        </div>
    </div>
</div>

Create /public_web/templates/game/partials/item_modal.html:

<div class="modal-overlay" onclick="closeModal()">
    <div class="modal-content" onclick="event.stopPropagation()">
        <div class="modal-header">
            <h2 class="item-name {{ item.rarity }}">{{ item.name }}</h2>
            <button class="modal-close" onclick="closeModal()">×</button>
        </div>

        <div class="modal-body">
            <p class="item-description">{{ item.description }}</p>

            <div class="item-stats">
                {% if item.item_type == 'weapon' %}
                <p><strong>Damage:</strong> {{ item.damage }}</p>
                <p><strong>Crit Chance:</strong> {{ (item.crit_chance * 100)|int }}%</p>
                {% elif item.item_type == 'armor' %}
                <p><strong>Defense:</strong> {{ item.defense }}</p>
                <p><strong>Resistance:</strong> {{ item.resistance }}</p>
                {% elif item.item_type == 'consumable' %}
                <p><strong>HP Restore:</strong> {{ item.hp_restore }}</p>
                <p><strong>MP Restore:</strong> {{ item.mp_restore }}</p>
                {% endif %}
            </div>

            <p class="item-value">Value: {{ item.value }} gold</p>
        </div>

        <div class="modal-footer">
            {% if item.item_type == 'weapon' %}
            <button class="btn btn-primary"
                    hx-post="/inventory/{{ character_id }}/equip"
                    hx-vals='{"item_id": "{{ item.item_id }}", "slot": "weapon"}'
                    hx-target="#character-panel"
                    hx-swap="outerHTML">
                Equip Weapon
            </button>
            {% elif item.item_type == 'consumable' %}
            <button class="btn btn-primary"
                    hx-post="/inventory/{{ character_id }}/use"
                    hx-vals='{"item_id": "{{ item.item_id }}"}'
                    hx-target="#character-panel"
                    hx-swap="outerHTML">
                Use Item
            </button>
            {% endif %}

            <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
        </div>
    </div>
</div>

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:

{% extends "base.html" %}

{% block title %}Skill Trees - {{ character.name }}{% endblock %}

{% block content %}
<div class="skills-container">
    <div class="skills-header">
        <h1>{{ character.name }}'s Skill Trees</h1>
        <div class="skills-info">
            <span class="skill-points">Skill Points: <strong>{{ character.skill_points }}</strong></span>
            <button class="btn btn-warning btn-respec"
                    hx-post="/characters/{{ character.character_id }}/skills/respec"
                    hx-confirm="Respec costs {{ respec_cost }} gold. Continue?"
                    hx-target=".skills-container"
                    hx-swap="outerHTML">
                Respec ({{ respec_cost }} gold)
            </button>
        </div>
    </div>

    <div class="skill-trees-grid">
        {% for tree in character.skill_trees %}
        <div class="skill-tree">
            <h2 class="tree-name">{{ tree.name }}</h2>
            <p class="tree-description">{{ tree.description }}</p>

            <div class="tree-diagram">
                {% for tier in range(5, 0, -1) %}
                <div class="skill-tier" data-tier="{{ tier }}">
                    <span class="tier-label">Tier {{ tier }}</span>
                    <div class="skill-nodes">
                        {% for node in tree.get_nodes_by_tier(tier) %}
                        <div class="skill-node {{ get_node_status(node, character) }}"
                             data-skill-id="{{ node.skill_id }}"
                             hx-get="/skills/{{ node.skill_id }}/tooltip"
                             hx-target="#skill-tooltip"
                             hx-swap="innerHTML"
                             hx-trigger="mouseenter">

                            <div class="node-icon">
                                {% if node.skill_id in character.unlocked_skills %}
                                ✓
                                {% elif character.can_unlock(node.skill_id) %}
                                ⬡
                                {% else %}
                                ⬢
                                {% endif %}
                            </div>

                            <span class="node-name">{{ node.name }}</span>

                            {% if character.can_unlock(node.skill_id) and character.skill_points > 0 %}
                            <button class="btn-unlock"
                                    hx-post="/characters/{{ character.character_id }}/skills/unlock"
                                    hx-vals='{"skill_id": "{{ node.skill_id }}"}'
                                    hx-target=".skills-container"
                                    hx-swap="outerHTML">
                                Unlock
                            </button>
                            {% endif %}
                        </div>

                        {# Draw prerequisite lines #}
                        {% if node.prerequisite_skill_id %}
                        <div class="prerequisite-line"></div>
                        {% endif %}
                        {% endfor %}
                    </div>
                </div>
                {% endfor %}
            </div>
        </div>
        {% endfor %}
    </div>

    {# Skill Tooltip (populated via HTMX) #}
    <div id="skill-tooltip" class="skill-tooltip"></div>
</div>
{% endblock %}

Also create /public_web/templates/character/partials/skill_tooltip.html:

<div class="tooltip-content">
    <h3 class="skill-name">{{ skill.name }}</h3>
    <p class="skill-description">{{ skill.description }}</p>

    <div class="skill-bonuses">
        <strong>Bonuses:</strong>
        <ul>
            {% for stat, bonus in skill.stat_bonuses.items() %}
            <li>+{{ bonus }} {{ stat|title }}</li>
            {% endfor %}
        </ul>
    </div>

    {% if skill.prerequisite_skill_id %}
    <p class="prerequisite">
        <strong>Requires:</strong> {{ get_skill_name(skill.prerequisite_skill_id) }}
    </p>
    {% endif %}
</div>

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

"""
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('/<skill_id>/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/<character_id>/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/<character_id>/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)

@skills_bp.route('/characters/<character_id>/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

"""
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:

# In /api/app/api/combat.py

@combat_bp.route('/<combat_id>/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

<div class="modal-overlay">
    <div class="modal-content level-up-modal">
        <div class="modal-header">
            <h2>🎉 LEVEL UP! 🎉</h2>
        </div>

        <div class="modal-body">
            <p class="level-up-text">
                Congratulations! You've reached <strong>Level {{ new_level }}</strong>!
            </p>

            <div class="level-up-rewards">
                <p>You gained:</p>
                <ul>
                    <li>+1 Skill Point</li>
                    <li>+{{ stat_increases.vitality }} Vitality</li>
                    <li>+{{ stat_increases.spirit }} Spirit</li>
                </ul>
            </div>
        </div>

        <div class="modal-footer">
            <button class="btn btn-primary" onclick="closeModal()">Awesome!</button>
            <a href="/characters/{{ character_id }}/skills" class="btn btn-secondary">
                View Skill Trees
            </a>
        </div>
    </div>
</div>

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

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

"""
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:

"""
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

{% extends "base.html" %}

{% block title %}Shop - Code of Conquest{% endblock %}

{% block content %}
<div class="shop-container">
    <div class="shop-header">
        <h1>🏪 {{ shop_name }}</h1>
        <p class="shopkeeper">Shopkeeper: {{ shopkeeper_name }}</p>
        <p class="player-gold">Your Gold: <strong>{{ character.gold }}</strong></p>
    </div>

    <div class="shop-inventory">
        {% for item_entry in inventory %}
        <div class="shop-item-card {{ item_entry.item.rarity }}">
            <div class="item-header">
                <h3>{{ item_entry.item.name }}</h3>
                <span class="item-price">{{ item_entry.price }} gold</span>
            </div>

            <p class="item-description">{{ item_entry.item.description }}</p>

            <div class="item-stats">
                {% if item_entry.item.item_type == 'weapon' %}
                <span>⚔️ Damage: {{ item_entry.item.damage }}</span>
                {% elif item_entry.item.item_type == 'armor' %}
                <span>🛡️ Defense: {{ item_entry.item.defense }}</span>
                {% elif item_entry.item.item_type == 'consumable' %}
                <span>❤️ Restores: {{ item_entry.item.hp_restore }} HP</span>
                {% endif %}
            </div>

            <button class="btn btn-primary btn-purchase"
                    {% if character.gold < item_entry.price %}disabled{% endif %}
                    hx-post="/shop/purchase"
                    hx-vals='{"character_id": "{{ character.character_id }}", "item_id": "{{ item_entry.item.item_id }}"}'
                    hx-target=".shop-container"
                    hx-swap="outerHTML">
                {% if character.gold >= item_entry.price %}
                Purchase
                {% else %}
                Not Enough Gold
                {% endif %}
            </button>
        </div>
        {% endfor %}
    </div>
</div>
{% endblock %}

Create view in /public_web/app/views/shop.py:

"""
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

"""
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:

# 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! 🚀