From 03ab783eeb598826c2cad7840a27ff874c2b9f75 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 26 Nov 2025 15:43:20 -0600 Subject: [PATCH] Combat Backend & Data Models - Implement Combat Service - Implement Damage Calculator - Implement Effect Processor - Implement Combat Actions - Created Combat API Endpoints --- api/app/__init__.py | 8 +- api/app/api/combat.py | 729 +++++ api/app/data/enemies/bandit.yaml | 55 + api/app/data/enemies/dire_wolf.yaml | 52 + api/app/data/enemies/goblin.yaml | 45 + api/app/data/enemies/goblin_shaman.yaml | 52 + api/app/data/enemies/orc_berserker.yaml | 58 + api/app/data/enemies/skeleton_warrior.yaml | 52 + api/app/models/enemy.py | 217 ++ api/app/models/items.py | 43 + api/app/models/stats.py | 60 +- api/app/services/combat_service.py | 1068 +++++++ api/app/services/damage_calculator.py | 594 ++++ api/app/services/enemy_loader.py | 260 ++ api/tests/test_combat_api.py | 376 +++ api/tests/test_combat_service.py | 648 ++++ api/tests/test_damage_calculator.py | 677 +++++ api/tests/test_enemy_loader.py | 399 +++ api/tests/test_session_service.py | 6 +- api/tests/test_stats.py | 52 + docs/PHASE4_COMBAT_IMPLEMENTATION.md | 3164 ++++++++++++++++++++ docs/VECTOR_DATABASE_STRATEGY.md | 481 +++ 22 files changed, 9091 insertions(+), 5 deletions(-) create mode 100644 api/app/api/combat.py create mode 100644 api/app/data/enemies/bandit.yaml create mode 100644 api/app/data/enemies/dire_wolf.yaml create mode 100644 api/app/data/enemies/goblin.yaml create mode 100644 api/app/data/enemies/goblin_shaman.yaml create mode 100644 api/app/data/enemies/orc_berserker.yaml create mode 100644 api/app/data/enemies/skeleton_warrior.yaml create mode 100644 api/app/models/enemy.py create mode 100644 api/app/services/combat_service.py create mode 100644 api/app/services/damage_calculator.py create mode 100644 api/app/services/enemy_loader.py create mode 100644 api/tests/test_combat_api.py create mode 100644 api/tests/test_combat_service.py create mode 100644 api/tests/test_damage_calculator.py create mode 100644 api/tests/test_enemy_loader.py create mode 100644 docs/PHASE4_COMBAT_IMPLEMENTATION.md create mode 100644 docs/VECTOR_DATABASE_STRATEGY.md diff --git a/api/app/__init__.py b/api/app/__init__.py index 94cff75..c40359e 100644 --- a/api/app/__init__.py +++ b/api/app/__init__.py @@ -169,8 +169,12 @@ def register_blueprints(app: Flask) -> None: app.register_blueprint(chat_bp) logger.info("Chat API blueprint registered") + # Import and register Combat API blueprint + from app.api.combat import combat_bp + app.register_blueprint(combat_bp) + logger.info("Combat API blueprint registered") + # TODO: Register additional blueprints as they are created - # from app.api import combat, marketplace, shop - # app.register_blueprint(combat.bp, url_prefix='/api/v1/combat') + # from app.api import marketplace, shop # app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace') # app.register_blueprint(shop.bp, url_prefix='/api/v1/shop') diff --git a/api/app/api/combat.py b/api/app/api/combat.py new file mode 100644 index 0000000..7ae197c --- /dev/null +++ b/api/app/api/combat.py @@ -0,0 +1,729 @@ +""" +Combat API Blueprint + +This module provides API endpoints for turn-based combat: +- Starting combat encounters +- Executing combat actions (attack, ability, defend, flee) +- Getting combat state +- Processing enemy turns +""" + +from flask import Blueprint, request + +from app.services.combat_service import ( + get_combat_service, + CombatAction, + CombatError, + NotInCombatError, + AlreadyInCombatError, + InvalidActionError, + InsufficientResourceError, +) +from app.models.enums import CombatStatus +from app.utils.response import ( + success_response, + error_response, + not_found_response, + validation_error_response +) +from app.utils.auth import require_auth, get_current_user +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +combat_bp = Blueprint('combat', __name__, url_prefix='/api/v1/combat') + + +# ============================================================================= +# Combat Lifecycle Endpoints +# ============================================================================= + +@combat_bp.route('/start', methods=['POST']) +@require_auth +def start_combat(): + """ + Start a new combat encounter. + + Creates a combat encounter with the session's character(s) vs specified enemies. + Rolls initiative and sets up turn order. + + Request JSON: + { + "session_id": "sess_123", + "enemy_ids": ["goblin", "goblin", "goblin_shaman"] + } + + Returns: + { + "encounter_id": "enc_abc123", + "combatants": [...], + "turn_order": [...], + "current_turn": "char_456", + "round_number": 1, + "status": "active" + } + + Errors: + 400: Missing required fields + 400: Already in combat + 404: Enemy template not found + """ + user = get_current_user() + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"field": "body", "issue": "Missing JSON body"} + ) + + # Validate required fields + session_id = data.get("session_id") + enemy_ids = data.get("enemy_ids", []) + + if not session_id: + return validation_error_response( + message="session_id is required", + details={"field": "session_id", "issue": "Missing required field"} + ) + + if not enemy_ids: + return validation_error_response( + message="enemy_ids is required and must not be empty", + details={"field": "enemy_ids", "issue": "Missing or empty list"} + ) + + try: + combat_service = get_combat_service() + encounter = combat_service.start_combat( + session_id=session_id, + user_id=user["user_id"], + enemy_ids=enemy_ids, + ) + + # Format response + current = encounter.get_current_combatant() + response_data = { + "encounter_id": encounter.encounter_id, + "combatants": [ + { + "combatant_id": c.combatant_id, + "name": c.name, + "is_player": c.is_player, + "current_hp": c.current_hp, + "max_hp": c.max_hp, + "current_mp": c.current_mp, + "max_mp": c.max_mp, + "initiative": c.initiative, + "abilities": c.abilities, + } + for c in encounter.combatants + ], + "turn_order": encounter.turn_order, + "current_turn": current.combatant_id if current else None, + "round_number": encounter.round_number, + "status": encounter.status.value, + } + + logger.info("Combat started via API", + session_id=session_id, + encounter_id=encounter.encounter_id, + enemy_count=len(enemy_ids)) + + return success_response(response_data) + + except AlreadyInCombatError as e: + logger.warning("Attempt to start combat while already in combat", + session_id=session_id) + return error_response( + status_code=400, + message=str(e), + error_code="ALREADY_IN_COMBAT" + ) + except ValueError as e: + logger.warning("Invalid enemy ID", + session_id=session_id, + error=str(e)) + return not_found_response(message=str(e)) + except Exception as e: + logger.error("Failed to start combat", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to start combat", + error_code="COMBAT_START_ERROR" + ) + + +@combat_bp.route('//state', methods=['GET']) +@require_auth +def get_combat_state(session_id: str): + """ + Get current combat state for a session. + + Returns the full combat encounter state including all combatants, + turn order, combat log, and current status. + + Path Parameters: + session_id: Game session ID + + Returns: + { + "in_combat": true, + "encounter": { + "encounter_id": "...", + "combatants": [...], + "turn_order": [...], + "current_turn": "...", + "round_number": 1, + "status": "active", + "combat_log": [...] + } + } + + or if not in combat: + { + "in_combat": false, + "encounter": null + } + """ + user = get_current_user() + + try: + combat_service = get_combat_service() + encounter = combat_service.get_combat_state(session_id, user["user_id"]) + + if not encounter: + return success_response({ + "in_combat": False, + "encounter": None + }) + + current = encounter.get_current_combatant() + response_data = { + "in_combat": True, + "encounter": { + "encounter_id": encounter.encounter_id, + "combatants": [ + { + "combatant_id": c.combatant_id, + "name": c.name, + "is_player": c.is_player, + "current_hp": c.current_hp, + "max_hp": c.max_hp, + "current_mp": c.current_mp, + "max_mp": c.max_mp, + "is_alive": c.is_alive(), + "is_stunned": c.is_stunned(), + "active_effects": [ + {"name": e.name, "duration": e.duration} + for e in c.active_effects + ], + "abilities": c.abilities, + "cooldowns": c.cooldowns, + } + for c in encounter.combatants + ], + "turn_order": encounter.turn_order, + "current_turn": current.combatant_id if current else None, + "round_number": encounter.round_number, + "status": encounter.status.value, + "combat_log": encounter.combat_log[-10:], # Last 10 entries + } + } + + return success_response(response_data) + + except Exception as e: + logger.error("Failed to get combat state", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to get combat state", + error_code="COMBAT_STATE_ERROR" + ) + + +# ============================================================================= +# Action Execution Endpoints +# ============================================================================= + +@combat_bp.route('//action', methods=['POST']) +@require_auth +def execute_action(session_id: str): + """ + Execute a combat action for a combatant. + + Processes the specified action (attack, ability, defend, flee, item) + for the given combatant. Must be that combatant's turn. + + Path Parameters: + session_id: Game session ID + + Request JSON: + { + "combatant_id": "char_456", + "action_type": "attack" | "ability" | "defend" | "flee" | "item", + "target_ids": ["enemy_1"], // Optional, auto-targets if omitted + "ability_id": "fireball", // Required for ability actions + "item_id": "health_potion" // Required for item actions + } + + Returns: + { + "success": true, + "message": "Attack hits for 15 damage!", + "damage_results": [...], + "effects_applied": [...], + "combat_ended": false, + "combat_status": null, + "next_combatant_id": "goblin_0" + } + + Errors: + 400: Missing required fields + 400: Not this combatant's turn + 400: Invalid action + 404: Session not in combat + """ + user = get_current_user() + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"field": "body", "issue": "Missing JSON body"} + ) + + # Validate required fields + combatant_id = data.get("combatant_id") + action_type = data.get("action_type") + + if not combatant_id: + return validation_error_response( + message="combatant_id is required", + details={"field": "combatant_id", "issue": "Missing required field"} + ) + + if not action_type: + return validation_error_response( + message="action_type is required", + details={"field": "action_type", "issue": "Missing required field"} + ) + + valid_actions = ["attack", "ability", "defend", "flee", "item"] + if action_type not in valid_actions: + return validation_error_response( + message=f"Invalid action_type. Must be one of: {valid_actions}", + details={"field": "action_type", "issue": "Invalid value"} + ) + + # Validate ability_id for ability actions + if action_type == "ability" and not data.get("ability_id"): + return validation_error_response( + message="ability_id is required for ability actions", + details={"field": "ability_id", "issue": "Missing required field"} + ) + + try: + combat_service = get_combat_service() + + action = CombatAction( + action_type=action_type, + target_ids=data.get("target_ids", []), + ability_id=data.get("ability_id"), + item_id=data.get("item_id"), + ) + + result = combat_service.execute_action( + session_id=session_id, + user_id=user["user_id"], + combatant_id=combatant_id, + action=action, + ) + + logger.info("Combat action executed", + session_id=session_id, + combatant_id=combatant_id, + action_type=action_type, + success=result.success) + + return success_response(result.to_dict()) + + except NotInCombatError as e: + logger.warning("Action attempted while not in combat", + session_id=session_id) + return not_found_response(message="Session is not in combat") + except InvalidActionError as e: + logger.warning("Invalid combat action", + session_id=session_id, + combatant_id=combatant_id, + error=str(e)) + return error_response( + status_code=400, + message=str(e), + error_code="INVALID_ACTION" + ) + except InsufficientResourceError as e: + logger.warning("Insufficient resources for action", + session_id=session_id, + combatant_id=combatant_id, + error=str(e)) + return error_response( + status_code=400, + message=str(e), + error_code="INSUFFICIENT_RESOURCES" + ) + except Exception as e: + logger.error("Failed to execute combat action", + session_id=session_id, + combatant_id=combatant_id, + action_type=action_type, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to execute action", + error_code="ACTION_EXECUTION_ERROR" + ) + + +@combat_bp.route('//enemy-turn', methods=['POST']) +@require_auth +def execute_enemy_turn(session_id: str): + """ + Execute the current enemy's turn using AI logic. + + Called when it's an enemy combatant's turn. The enemy AI will + automatically choose and execute an appropriate action. + + Path Parameters: + session_id: Game session ID + + Returns: + { + "success": true, + "message": "Goblin attacks Hero for 8 damage!", + "damage_results": [...], + "effects_applied": [...], + "combat_ended": false, + "combat_status": null, + "next_combatant_id": "char_456" + } + + Errors: + 400: Current combatant is not an enemy + 404: Session not in combat + """ + user = get_current_user() + + try: + combat_service = get_combat_service() + result = combat_service.execute_enemy_turn( + session_id=session_id, + user_id=user["user_id"], + ) + + logger.info("Enemy turn executed", + session_id=session_id, + success=result.success) + + return success_response(result.to_dict()) + + except NotInCombatError as e: + return not_found_response(message="Session is not in combat") + except InvalidActionError as e: + return error_response( + status_code=400, + message=str(e), + error_code="INVALID_ACTION" + ) + except Exception as e: + logger.error("Failed to execute enemy turn", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to execute enemy turn", + error_code="ENEMY_TURN_ERROR" + ) + + +@combat_bp.route('//flee', methods=['POST']) +@require_auth +def attempt_flee(session_id: str): + """ + Attempt to flee from combat. + + The current combatant attempts to flee. Success is based on + DEX comparison with enemies. Failed flee attempts consume the turn. + + Path Parameters: + session_id: Game session ID + + Request JSON: + { + "combatant_id": "char_456" + } + + Returns: + { + "success": true, + "message": "Successfully fled from combat!", + "combat_ended": true, + "combat_status": "fled" + } + + or on failure: + { + "success": false, + "message": "Failed to flee! (Roll: 0.35, Needed: 0.50)", + "combat_ended": false + } + """ + user = get_current_user() + data = request.get_json() or {} + + combatant_id = data.get("combatant_id") + + try: + combat_service = get_combat_service() + + action = CombatAction( + action_type="flee", + target_ids=[], + ) + + result = combat_service.execute_action( + session_id=session_id, + user_id=user["user_id"], + combatant_id=combatant_id, + action=action, + ) + + return success_response(result.to_dict()) + + except NotInCombatError: + return not_found_response(message="Session is not in combat") + except InvalidActionError as e: + return error_response( + status_code=400, + message=str(e), + error_code="INVALID_ACTION" + ) + except Exception as e: + logger.error("Failed flee attempt", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to attempt flee", + error_code="FLEE_ERROR" + ) + + +@combat_bp.route('//end', methods=['POST']) +@require_auth +def end_combat(session_id: str): + """ + Force end the current combat (debug/admin endpoint). + + Ends combat with the specified outcome. Should normally only be used + for debugging or admin purposes - combat usually ends automatically. + + Path Parameters: + session_id: Game session ID + + Request JSON: + { + "outcome": "victory" | "defeat" | "fled" + } + + Returns: + { + "outcome": "victory", + "rewards": { + "experience": 100, + "gold": 50, + "items": [...], + "level_ups": [] + } + } + """ + user = get_current_user() + data = request.get_json() or {} + + outcome_str = data.get("outcome", "fled") + + # Parse outcome + try: + outcome = CombatStatus(outcome_str) + except ValueError: + return validation_error_response( + message="Invalid outcome. Must be: victory, defeat, or fled", + details={"field": "outcome", "issue": "Invalid value"} + ) + + try: + combat_service = get_combat_service() + rewards = combat_service.end_combat( + session_id=session_id, + user_id=user["user_id"], + outcome=outcome, + ) + + logger.info("Combat force-ended", + session_id=session_id, + outcome=outcome_str) + + return success_response({ + "outcome": outcome_str, + "rewards": rewards.to_dict() if outcome == CombatStatus.VICTORY else None, + }) + + except NotInCombatError: + return not_found_response(message="Session is not in combat") + except Exception as e: + logger.error("Failed to end combat", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to end combat", + error_code="COMBAT_END_ERROR" + ) + + +# ============================================================================= +# Utility Endpoints +# ============================================================================= + +@combat_bp.route('/enemies', methods=['GET']) +def list_enemies(): + """ + List all available enemy templates. + + Returns a list of all enemy templates that can be used in combat. + Useful for encounter building and testing. + + Query Parameters: + difficulty: Filter by difficulty (easy, medium, hard, boss) + tag: Filter by tag (undead, beast, humanoid, etc.) + + Returns: + { + "enemies": [ + { + "enemy_id": "goblin", + "name": "Goblin Scout", + "difficulty": "easy", + "tags": ["humanoid", "goblinoid"], + "experience_reward": 15 + }, + ... + ] + } + """ + from app.services.enemy_loader import get_enemy_loader + from app.models.enemy import EnemyDifficulty + + difficulty = request.args.get("difficulty") + tag = request.args.get("tag") + + try: + enemy_loader = get_enemy_loader() + enemy_loader.load_all_enemies() + + if difficulty: + try: + diff = EnemyDifficulty(difficulty) + enemies = enemy_loader.get_enemies_by_difficulty(diff) + except ValueError: + return validation_error_response( + message="Invalid difficulty", + details={"field": "difficulty", "issue": "Must be: easy, medium, hard, boss"} + ) + elif tag: + enemies = enemy_loader.get_enemies_by_tag(tag) + else: + enemies = list(enemy_loader.get_all_cached().values()) + + response_data = { + "enemies": [ + { + "enemy_id": e.enemy_id, + "name": e.name, + "description": e.description[:100] + "..." if len(e.description) > 100 else e.description, + "difficulty": e.difficulty.value, + "tags": e.tags, + "experience_reward": e.experience_reward, + "gold_reward_range": [e.gold_reward_min, e.gold_reward_max], + } + for e in enemies + ] + } + + return success_response(response_data) + + except Exception as e: + logger.error("Failed to list enemies", + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to list enemies", + error_code="ENEMY_LIST_ERROR" + ) + + +@combat_bp.route('/enemies/', methods=['GET']) +def get_enemy_details(enemy_id: str): + """ + Get detailed information about a specific enemy template. + + Path Parameters: + enemy_id: Enemy template ID + + Returns: + { + "enemy_id": "goblin", + "name": "Goblin Scout", + "description": "...", + "base_stats": {...}, + "abilities": [...], + "loot_table": [...], + "difficulty": "easy", + ... + } + """ + from app.services.enemy_loader import get_enemy_loader + + try: + enemy_loader = get_enemy_loader() + enemy = enemy_loader.load_enemy(enemy_id) + + if not enemy: + return not_found_response(message=f"Enemy not found: {enemy_id}") + + return success_response(enemy.to_dict()) + + except Exception as e: + logger.error("Failed to get enemy details", + enemy_id=enemy_id, + error=str(e), + exc_info=True) + return error_response( + status_code=500, + message="Failed to get enemy details", + error_code="ENEMY_DETAILS_ERROR" + ) diff --git a/api/app/data/enemies/bandit.yaml b/api/app/data/enemies/bandit.yaml new file mode 100644 index 0000000..7976de8 --- /dev/null +++ b/api/app/data/enemies/bandit.yaml @@ -0,0 +1,55 @@ +# Bandit - Medium humanoid with weapon +# A highway robber armed with sword and dagger + +enemy_id: bandit +name: Bandit Rogue +description: > + A rough-looking human in worn leather armor, their face partially hidden + by a tattered hood. They fight with a chipped sword and keep a dagger + ready for backstabs. Desperation has made them dangerous. + +base_stats: + strength: 12 + dexterity: 14 + constitution: 10 + intelligence: 10 + wisdom: 8 + charisma: 8 + luck: 10 + +abilities: + - basic_attack + - quick_strike + - dirty_trick + +loot_table: + - item_id: bandit_sword + drop_chance: 0.20 + quantity_min: 1 + quantity_max: 1 + - item_id: leather_armor + drop_chance: 0.15 + quantity_min: 1 + quantity_max: 1 + - item_id: lockpick + drop_chance: 0.25 + quantity_min: 1 + quantity_max: 3 + - item_id: gold_coin + drop_chance: 0.80 + quantity_min: 5 + quantity_max: 20 + +experience_reward: 35 +gold_reward_min: 10 +gold_reward_max: 30 +difficulty: medium + +tags: + - humanoid + - rogue + - armed + +base_damage: 8 +crit_chance: 0.12 +flee_chance: 0.45 diff --git a/api/app/data/enemies/dire_wolf.yaml b/api/app/data/enemies/dire_wolf.yaml new file mode 100644 index 0000000..155c9fe --- /dev/null +++ b/api/app/data/enemies/dire_wolf.yaml @@ -0,0 +1,52 @@ +# Dire Wolf - Medium beast enemy +# A large, ferocious predator + +enemy_id: dire_wolf +name: Dire Wolf +description: > + A massive wolf the size of a horse, with matted black fur and eyes + that glow with predatory intelligence. Its fangs are as long as daggers, + and its growl rumbles like distant thunder. + +base_stats: + strength: 14 + dexterity: 14 + constitution: 12 + intelligence: 4 + wisdom: 10 + charisma: 6 + luck: 8 + +abilities: + - basic_attack + - savage_bite + - pack_howl + +loot_table: + - item_id: wolf_pelt + drop_chance: 0.60 + quantity_min: 1 + quantity_max: 1 + - item_id: wolf_fang + drop_chance: 0.40 + quantity_min: 1 + quantity_max: 2 + - item_id: beast_meat + drop_chance: 0.70 + quantity_min: 1 + quantity_max: 3 + +experience_reward: 40 +gold_reward_min: 0 +gold_reward_max: 5 +difficulty: medium + +tags: + - beast + - wolf + - large + - pack + +base_damage: 10 +crit_chance: 0.10 +flee_chance: 0.40 diff --git a/api/app/data/enemies/goblin.yaml b/api/app/data/enemies/goblin.yaml new file mode 100644 index 0000000..ffcf17d --- /dev/null +++ b/api/app/data/enemies/goblin.yaml @@ -0,0 +1,45 @@ +# Goblin - Easy melee enemy (STR-focused) +# A small, cunning creature that attacks in groups + +enemy_id: goblin +name: Goblin Scout +description: > + A small, green-skinned creature with pointed ears and sharp teeth. + Goblins are cowardly alone but dangerous in groups, using crude weapons + and dirty tactics to overwhelm their prey. + +base_stats: + strength: 8 + dexterity: 12 + constitution: 6 + intelligence: 6 + wisdom: 6 + charisma: 4 + luck: 8 + +abilities: + - basic_attack + +loot_table: + - item_id: rusty_dagger + drop_chance: 0.15 + quantity_min: 1 + quantity_max: 1 + - item_id: gold_coin + drop_chance: 0.50 + quantity_min: 1 + quantity_max: 3 + +experience_reward: 15 +gold_reward_min: 2 +gold_reward_max: 8 +difficulty: easy + +tags: + - humanoid + - goblinoid + - small + +base_damage: 4 +crit_chance: 0.05 +flee_chance: 0.60 diff --git a/api/app/data/enemies/goblin_shaman.yaml b/api/app/data/enemies/goblin_shaman.yaml new file mode 100644 index 0000000..5320cb5 --- /dev/null +++ b/api/app/data/enemies/goblin_shaman.yaml @@ -0,0 +1,52 @@ +# Goblin Shaman - Easy caster enemy (INT-focused) +# A goblin spellcaster that provides magical support + +enemy_id: goblin_shaman +name: Goblin Shaman +description: > + A hunched goblin wrapped in tattered robes, clutching a staff adorned + with bones and feathers. It mutters dark incantations and hurls bolts + of sickly green fire at its enemies. + +base_stats: + strength: 4 + dexterity: 10 + constitution: 6 + intelligence: 12 + wisdom: 10 + charisma: 6 + luck: 10 + +abilities: + - basic_attack + - fire_bolt + - minor_heal + +loot_table: + - item_id: shaman_staff + drop_chance: 0.10 + quantity_min: 1 + quantity_max: 1 + - item_id: mana_potion_small + drop_chance: 0.20 + quantity_min: 1 + quantity_max: 1 + - item_id: gold_coin + drop_chance: 0.60 + quantity_min: 3 + quantity_max: 8 + +experience_reward: 25 +gold_reward_min: 5 +gold_reward_max: 15 +difficulty: easy + +tags: + - humanoid + - goblinoid + - caster + - small + +base_damage: 3 +crit_chance: 0.08 +flee_chance: 0.55 diff --git a/api/app/data/enemies/orc_berserker.yaml b/api/app/data/enemies/orc_berserker.yaml new file mode 100644 index 0000000..5f1225b --- /dev/null +++ b/api/app/data/enemies/orc_berserker.yaml @@ -0,0 +1,58 @@ +# Orc Berserker - Hard heavy hitter +# A fearsome orc warrior in a battle rage + +enemy_id: orc_berserker +name: Orc Berserker +description: > + A towering mass of green muscle and fury, covered in tribal war paint + and scars from countless battles. Foam flecks at the corners of its + mouth as it swings a massive greataxe with terrifying speed. In its + battle rage, it feels no pain and shows no mercy. + +base_stats: + strength: 18 + dexterity: 10 + constitution: 16 + intelligence: 6 + wisdom: 6 + charisma: 4 + luck: 8 + +abilities: + - basic_attack + - cleave + - berserker_rage + - intimidating_shout + +loot_table: + - item_id: orc_greataxe + drop_chance: 0.20 + quantity_min: 1 + quantity_max: 1 + - item_id: orc_war_paint + drop_chance: 0.35 + quantity_min: 1 + quantity_max: 2 + - item_id: beast_hide_armor + drop_chance: 0.15 + quantity_min: 1 + quantity_max: 1 + - item_id: gold_coin + drop_chance: 0.70 + quantity_min: 15 + quantity_max: 40 + +experience_reward: 80 +gold_reward_min: 20 +gold_reward_max: 50 +difficulty: hard + +tags: + - humanoid + - orc + - berserker + - large + +base_damage: 15 +crit_chance: 0.15 +flee_chance: 0.30 diff --git a/api/app/data/enemies/skeleton_warrior.yaml b/api/app/data/enemies/skeleton_warrior.yaml new file mode 100644 index 0000000..e3f1eba --- /dev/null +++ b/api/app/data/enemies/skeleton_warrior.yaml @@ -0,0 +1,52 @@ +# Skeleton Warrior - Medium undead melee +# An animated skeleton wielding ancient weapons + +enemy_id: skeleton_warrior +name: Skeleton Warrior +description: > + The animated remains of a long-dead soldier, held together by dark magic. + Its empty eye sockets glow with pale blue fire, and it wields a rusted + but deadly sword with unnatural precision. It knows no fear and feels no pain. + +base_stats: + strength: 12 + dexterity: 10 + constitution: 10 + intelligence: 4 + wisdom: 6 + charisma: 2 + luck: 6 + +abilities: + - basic_attack + - shield_bash + - bone_rattle + +loot_table: + - item_id: ancient_sword + drop_chance: 0.15 + quantity_min: 1 + quantity_max: 1 + - item_id: bone_fragment + drop_chance: 0.80 + quantity_min: 2 + quantity_max: 5 + - item_id: soul_essence + drop_chance: 0.10 + quantity_min: 1 + quantity_max: 1 + +experience_reward: 45 +gold_reward_min: 0 +gold_reward_max: 10 +difficulty: medium + +tags: + - undead + - skeleton + - armed + - fearless + +base_damage: 9 +crit_chance: 0.08 +flee_chance: 0.50 diff --git a/api/app/models/enemy.py b/api/app/models/enemy.py new file mode 100644 index 0000000..900d56c --- /dev/null +++ b/api/app/models/enemy.py @@ -0,0 +1,217 @@ +""" +Enemy data models for combat encounters. + +This module defines the EnemyTemplate dataclass representing enemies/monsters +that can be encountered in combat. Enemy definitions are loaded from YAML files +for data-driven game design. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional, Tuple +from enum import Enum + +from app.models.stats import Stats + + +class EnemyDifficulty(Enum): + """Enemy difficulty levels for scaling and encounter building.""" + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + BOSS = "boss" + + +@dataclass +class LootEntry: + """ + Single entry in an enemy's loot table. + + Attributes: + item_id: Reference to item definition + drop_chance: Probability of dropping (0.0 to 1.0) + quantity_min: Minimum quantity if dropped + quantity_max: Maximum quantity if dropped + """ + + item_id: str + drop_chance: float = 0.1 + quantity_min: int = 1 + quantity_max: int = 1 + + def to_dict(self) -> Dict[str, Any]: + """Serialize loot entry to dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry': + """Deserialize loot entry from dictionary.""" + return cls( + item_id=data["item_id"], + drop_chance=data.get("drop_chance", 0.1), + quantity_min=data.get("quantity_min", 1), + quantity_max=data.get("quantity_max", 1), + ) + + +@dataclass +class EnemyTemplate: + """ + Template definition for an enemy type. + + EnemyTemplates define the base characteristics of enemy types. When combat + starts, instances are created from templates with randomized variations. + + Attributes: + enemy_id: Unique identifier (e.g., "goblin", "dire_wolf") + name: Display name (e.g., "Goblin Scout") + description: Flavor text about the enemy + base_stats: Base stat block for this enemy + abilities: List of ability_ids this enemy can use + loot_table: Potential drops on defeat + experience_reward: Base XP granted on defeat + gold_reward_min: Minimum gold dropped + gold_reward_max: Maximum gold dropped + difficulty: Difficulty classification for encounter building + tags: Classification tags (e.g., ["humanoid", "goblinoid"]) + image_url: Optional image reference for UI + + Combat-specific attributes: + base_damage: Base damage for basic attack (no weapon) + crit_chance: Critical hit chance (0.0 to 1.0) + flee_chance: Chance to successfully flee from this enemy + """ + + enemy_id: str + name: str + description: str + base_stats: Stats + abilities: List[str] = field(default_factory=list) + loot_table: List[LootEntry] = field(default_factory=list) + experience_reward: int = 10 + gold_reward_min: int = 1 + gold_reward_max: int = 5 + difficulty: EnemyDifficulty = EnemyDifficulty.EASY + tags: List[str] = field(default_factory=list) + image_url: Optional[str] = None + + # Combat attributes + base_damage: int = 5 + crit_chance: float = 0.05 + flee_chance: float = 0.5 + + def get_gold_reward(self) -> int: + """ + Roll random gold reward within range. + + Returns: + Random gold amount between min and max + """ + import random + return random.randint(self.gold_reward_min, self.gold_reward_max) + + def roll_loot(self) -> List[Dict[str, Any]]: + """ + Roll for loot drops based on loot table. + + Returns: + List of dropped items with quantities + """ + import random + drops = [] + + for entry in self.loot_table: + if random.random() < entry.drop_chance: + quantity = random.randint(entry.quantity_min, entry.quantity_max) + drops.append({ + "item_id": entry.item_id, + "quantity": quantity, + }) + + return drops + + def is_boss(self) -> bool: + """Check if this enemy is a boss.""" + return self.difficulty == EnemyDifficulty.BOSS + + def has_tag(self, tag: str) -> bool: + """Check if enemy has a specific tag.""" + return tag.lower() in [t.lower() for t in self.tags] + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize enemy template to dictionary. + + Returns: + Dictionary containing all enemy data + """ + return { + "enemy_id": self.enemy_id, + "name": self.name, + "description": self.description, + "base_stats": self.base_stats.to_dict(), + "abilities": self.abilities, + "loot_table": [entry.to_dict() for entry in self.loot_table], + "experience_reward": self.experience_reward, + "gold_reward_min": self.gold_reward_min, + "gold_reward_max": self.gold_reward_max, + "difficulty": self.difficulty.value, + "tags": self.tags, + "image_url": self.image_url, + "base_damage": self.base_damage, + "crit_chance": self.crit_chance, + "flee_chance": self.flee_chance, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'EnemyTemplate': + """ + Deserialize enemy template from dictionary. + + Args: + data: Dictionary containing enemy data (from YAML or JSON) + + Returns: + EnemyTemplate instance + """ + # Parse base stats + stats_data = data.get("base_stats", {}) + base_stats = Stats.from_dict(stats_data) + + # Parse loot table + loot_table = [ + LootEntry.from_dict(entry) + for entry in data.get("loot_table", []) + ] + + # Parse difficulty + difficulty_value = data.get("difficulty", "easy") + if isinstance(difficulty_value, str): + difficulty = EnemyDifficulty(difficulty_value) + else: + difficulty = difficulty_value + + return cls( + enemy_id=data["enemy_id"], + name=data["name"], + description=data.get("description", ""), + base_stats=base_stats, + abilities=data.get("abilities", []), + loot_table=loot_table, + experience_reward=data.get("experience_reward", 10), + gold_reward_min=data.get("gold_reward_min", 1), + gold_reward_max=data.get("gold_reward_max", 5), + difficulty=difficulty, + tags=data.get("tags", []), + image_url=data.get("image_url"), + base_damage=data.get("base_damage", 5), + crit_chance=data.get("crit_chance", 0.05), + flee_chance=data.get("flee_chance", 0.5), + ) + + def __repr__(self) -> str: + """String representation of the enemy template.""" + return ( + f"EnemyTemplate({self.enemy_id}, {self.name}, " + f"difficulty={self.difficulty.value}, " + f"xp={self.experience_reward})" + ) diff --git a/api/app/models/items.py b/api/app/models/items.py index 10a9bce..7bc1e6f 100644 --- a/api/app/models/items.py +++ b/api/app/models/items.py @@ -65,6 +65,12 @@ class Item: crit_chance: float = 0.05 # 5% default critical hit chance crit_multiplier: float = 2.0 # 2x damage on critical hit + # Elemental weapon properties (for split damage like Fire Sword) + # These enable weapons to deal both physical AND elemental damage + elemental_damage_type: Optional[DamageType] = None # Secondary damage type (fire, ice, etc.) + physical_ratio: float = 1.0 # Portion of damage that is physical (0.0-1.0) + elemental_ratio: float = 0.0 # Portion of damage that is elemental (0.0-1.0) + # Armor-specific defense: int = 0 resistance: int = 0 @@ -89,6 +95,27 @@ class Item: """Check if this item is a quest item.""" return self.item_type == ItemType.QUEST_ITEM + def is_elemental_weapon(self) -> bool: + """ + Check if this weapon deals elemental damage (split damage). + + Elemental weapons deal both physical AND elemental damage, + calculated separately against DEF and RES. + + Examples: + Fire Sword: 70% physical / 30% fire + Frost Blade: 60% physical / 40% ice + Lightning Spear: 50% physical / 50% lightning + + Returns: + True if weapon has elemental damage component + """ + return ( + self.is_weapon() and + self.elemental_ratio > 0.0 and + self.elemental_damage_type is not None + ) + def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool: """ Check if a character can equip this item. @@ -133,6 +160,8 @@ class Item: data["item_type"] = self.item_type.value if self.damage_type: data["damage_type"] = self.damage_type.value + if self.elemental_damage_type: + data["elemental_damage_type"] = self.elemental_damage_type.value data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use] return data @@ -150,6 +179,11 @@ class Item: # Convert string values back to enums item_type = ItemType(data["item_type"]) damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None + elemental_damage_type = ( + DamageType(data["elemental_damage_type"]) + if data.get("elemental_damage_type") + else None + ) # Deserialize effects effects = [] @@ -169,6 +203,9 @@ class Item: damage_type=damage_type, crit_chance=data.get("crit_chance", 0.05), crit_multiplier=data.get("crit_multiplier", 2.0), + elemental_damage_type=elemental_damage_type, + physical_ratio=data.get("physical_ratio", 1.0), + elemental_ratio=data.get("elemental_ratio", 0.0), defense=data.get("defense", 0), resistance=data.get("resistance", 0), required_level=data.get("required_level", 1), @@ -178,6 +215,12 @@ class Item: def __repr__(self) -> str: """String representation of the item.""" if self.is_weapon(): + if self.is_elemental_weapon(): + return ( + f"Item({self.name}, elemental_weapon, dmg={self.damage}, " + f"phys={self.physical_ratio:.0%}/{self.elemental_damage_type.value}={self.elemental_ratio:.0%}, " + f"crit={self.crit_chance*100:.0f}%, value={self.value}g)" + ) return ( f"Item({self.name}, weapon, dmg={self.damage}, " f"crit={self.crit_chance*100:.0f}%, value={self.value}g)" diff --git a/api/app/models/stats.py b/api/app/models/stats.py index c9c9799..9e83299 100644 --- a/api/app/models/stats.py +++ b/api/app/models/stats.py @@ -86,6 +86,63 @@ class Stats: """ return self.wisdom // 2 + @property + def crit_bonus(self) -> float: + """ + Calculate critical hit chance bonus from luck. + + Formula: luck * 0.5% (0.005) + + This bonus is added to the weapon's base crit chance. + The total crit chance is capped at 25% in the DamageCalculator. + + Returns: + Crit chance bonus as a decimal (e.g., 0.04 for LUK 8) + + Examples: + LUK 8: 0.04 (4% bonus) + LUK 12: 0.06 (6% bonus) + """ + return self.luck * 0.005 + + @property + def hit_bonus(self) -> float: + """ + Calculate hit chance bonus (miss reduction) from luck. + + Formula: luck * 0.5% (0.005) + + This reduces the base 10% miss chance. The minimum miss + chance is hard capped at 5% to prevent frustration. + + Returns: + Miss reduction as a decimal (e.g., 0.04 for LUK 8) + + Examples: + LUK 8: 0.04 (reduces miss from 10% to 6%) + LUK 12: 0.06 (reduces miss from 10% to 4%, capped at 5%) + """ + return self.luck * 0.005 + + @property + def lucky_roll_chance(self) -> float: + """ + Calculate chance for a "lucky" high damage variance roll. + + Formula: 5% + (luck * 0.25%) + + When triggered, damage variance uses 100%-110% instead of 95%-105%. + This gives LUK characters more frequent high damage rolls. + + Returns: + Lucky roll chance as a decimal + + Examples: + LUK 8: 0.07 (7% chance for lucky roll) + LUK 12: 0.08 (8% chance for lucky roll) + """ + return 0.05 + (self.luck * 0.0025) + def to_dict(self) -> Dict[str, Any]: """ Serialize stats to a dictionary. @@ -140,5 +197,6 @@ class Stats: f"CON={self.constitution}, INT={self.intelligence}, " f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, " f"HP={self.hit_points}, MP={self.mana_points}, " - f"DEF={self.defense}, RES={self.resistance})" + f"DEF={self.defense}, RES={self.resistance}, " + f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})" ) diff --git a/api/app/services/combat_service.py b/api/app/services/combat_service.py new file mode 100644 index 0000000..1d417d3 --- /dev/null +++ b/api/app/services/combat_service.py @@ -0,0 +1,1068 @@ +""" +Combat Service - Orchestrates turn-based combat encounters. + +This service manages the full combat lifecycle including: +- Starting combat with characters and enemies +- Executing player and enemy actions +- Processing damage, effects, and state changes +- Distributing rewards on victory +""" + +import random +from dataclasses import dataclass, field +from typing import Dict, Any, List, Optional, Tuple +from uuid import uuid4 + +from app.models.combat import Combatant, CombatEncounter +from app.models.character import Character +from app.models.enemy import EnemyTemplate +from app.models.stats import Stats +from app.models.abilities import Ability, AbilityLoader +from app.models.effects import Effect +from app.models.items import Item +from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType +from app.services.damage_calculator import DamageCalculator, DamageResult +from app.services.enemy_loader import EnemyLoader, get_enemy_loader +from app.services.session_service import get_session_service +from app.services.character_service import get_character_service +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# ============================================================================= +# Supporting Dataclasses +# ============================================================================= + +@dataclass +class CombatAction: + """ + Represents a combat action to be executed. + + Attributes: + action_type: Type of action (attack, ability, defend, flee, item) + target_ids: List of target combatant IDs + ability_id: Ability to use (for ability actions) + item_id: Item to use (for item actions) + """ + + action_type: str + target_ids: List[str] = field(default_factory=list) + ability_id: Optional[str] = None + item_id: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'CombatAction': + """Create CombatAction from dictionary.""" + return cls( + action_type=data["action_type"], + target_ids=data.get("target_ids", []), + ability_id=data.get("ability_id"), + item_id=data.get("item_id"), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "action_type": self.action_type, + "target_ids": self.target_ids, + "ability_id": self.ability_id, + "item_id": self.item_id, + } + + +@dataclass +class ActionResult: + """ + Result of executing a combat action. + + Attributes: + success: Whether the action succeeded + message: Human-readable result message + damage_results: List of damage calculation results + effects_applied: List of effects applied to targets + combat_ended: Whether combat ended as a result + combat_status: Final combat status if ended + next_combatant_id: ID of combatant whose turn is next + turn_effects: Effects that triggered at turn start/end + """ + + success: bool + message: str + damage_results: List[DamageResult] = field(default_factory=list) + effects_applied: List[Dict[str, Any]] = field(default_factory=list) + combat_ended: bool = False + combat_status: Optional[CombatStatus] = None + next_combatant_id: Optional[str] = None + turn_effects: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API response.""" + return { + "success": self.success, + "message": self.message, + "damage_results": [ + { + "target_id": dr.target_id if hasattr(dr, 'target_id') else None, + "total_damage": dr.total_damage, + "is_critical": dr.is_critical, + "is_miss": dr.is_miss, + "message": dr.message, + } + for dr in self.damage_results + ], + "effects_applied": self.effects_applied, + "combat_ended": self.combat_ended, + "combat_status": self.combat_status.value if self.combat_status else None, + "next_combatant_id": self.next_combatant_id, + "turn_effects": self.turn_effects, + } + + +@dataclass +class CombatRewards: + """ + Rewards distributed after combat victory. + + Attributes: + experience: Total XP earned + gold: Total gold earned + items: List of item drops + level_ups: Character IDs that leveled up + """ + + experience: int = 0 + gold: int = 0 + items: List[Dict[str, Any]] = field(default_factory=list) + level_ups: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "experience": self.experience, + "gold": self.gold, + "items": self.items, + "level_ups": self.level_ups, + } + + +# ============================================================================= +# Combat Service Exceptions +# ============================================================================= + +class CombatError(Exception): + """Base exception for combat errors.""" + pass + + +class NotInCombatError(CombatError): + """Raised when action requires combat but session is not in combat.""" + pass + + +class AlreadyInCombatError(CombatError): + """Raised when trying to start combat but already in combat.""" + pass + + +class InvalidActionError(CombatError): + """Raised when action is invalid (wrong turn, invalid target, etc.).""" + pass + + +class InsufficientResourceError(CombatError): + """Raised when combatant lacks resources (mana, cooldown, etc.).""" + pass + + +# ============================================================================= +# Combat Service +# ============================================================================= + +class CombatService: + """ + Orchestrates turn-based combat encounters. + + This service manages: + - Combat initialization with players and enemies + - Turn-by-turn action execution + - Damage calculation via DamageCalculator + - Effect processing and state management + - Victory/defeat detection and reward distribution + """ + + def __init__(self): + """Initialize the combat service with dependencies.""" + self.session_service = get_session_service() + self.character_service = get_character_service() + self.enemy_loader = get_enemy_loader() + self.ability_loader = AbilityLoader() + + logger.info("CombatService initialized") + + # ========================================================================= + # Combat Lifecycle + # ========================================================================= + + def start_combat( + self, + session_id: str, + user_id: str, + enemy_ids: List[str], + ) -> CombatEncounter: + """ + Start a new combat encounter. + + Args: + session_id: Game session ID + user_id: User ID for authorization + enemy_ids: List of enemy template IDs to spawn + + Returns: + Initialized CombatEncounter + + Raises: + AlreadyInCombatError: If session is already in combat + ValueError: If enemy templates not found + """ + logger.info("Starting combat", + session_id=session_id, + enemy_count=len(enemy_ids)) + + # Get session + session = self.session_service.get_session(session_id, user_id) + + # Check not already in combat + if session.is_in_combat(): + raise AlreadyInCombatError("Session is already in combat") + + # Create combatants from player character(s) + combatants = [] + + if session.is_solo(): + # Solo session - single character + character = self.character_service.get_character( + session.solo_character_id, + user_id + ) + player_combatant = self._create_combatant_from_character(character) + combatants.append(player_combatant) + else: + # Multiplayer - all party members + for char_id in session.party_member_ids: + # Note: In multiplayer, we'd need to handle different user_ids + # For now, assume all characters belong to session owner + character = self.character_service.get_character(char_id, user_id) + player_combatant = self._create_combatant_from_character(character) + combatants.append(player_combatant) + + # Create combatants from enemies + for i, enemy_id in enumerate(enemy_ids): + enemy_template = self.enemy_loader.load_enemy(enemy_id) + if not enemy_template: + raise ValueError(f"Enemy template not found: {enemy_id}") + + enemy_combatant = self._create_combatant_from_enemy( + enemy_template, + instance_index=i + ) + combatants.append(enemy_combatant) + + # Create encounter + encounter = CombatEncounter( + encounter_id=f"enc_{uuid4().hex[:12]}", + combatants=combatants, + ) + + # Initialize combat (roll initiative, set turn order) + encounter.initialize_combat() + + # Store in session + session.start_combat(encounter) + self.session_service.update_session(session, user_id) + + logger.info("Combat started", + session_id=session_id, + encounter_id=encounter.encounter_id, + player_count=len([c for c in combatants if c.is_player]), + enemy_count=len([c for c in combatants if not c.is_player])) + + return encounter + + def get_combat_state( + self, + session_id: str, + user_id: str + ) -> Optional[CombatEncounter]: + """ + Get current combat state for a session. + + Args: + session_id: Game session ID + user_id: User ID for authorization + + Returns: + CombatEncounter if in combat, None otherwise + """ + session = self.session_service.get_session(session_id, user_id) + return session.combat_encounter + + def end_combat( + self, + session_id: str, + user_id: str, + outcome: CombatStatus + ) -> CombatRewards: + """ + End combat and distribute rewards if victorious. + + Args: + session_id: Game session ID + user_id: User ID for authorization + outcome: Combat outcome (VICTORY, DEFEAT, FLED) + + Returns: + CombatRewards containing XP, gold, items earned + """ + logger.info("Ending combat", + session_id=session_id, + outcome=outcome.value) + + session = self.session_service.get_session(session_id, user_id) + + if not session.is_in_combat(): + raise NotInCombatError("Session is not in combat") + + encounter = session.combat_encounter + encounter.status = outcome + + # Calculate rewards if victory + rewards = CombatRewards() + if outcome == CombatStatus.VICTORY: + rewards = self._calculate_rewards(encounter, session, user_id) + + # End combat on session + session.end_combat() + self.session_service.update_session(session, user_id) + + logger.info("Combat ended", + session_id=session_id, + outcome=outcome.value, + xp_earned=rewards.experience, + gold_earned=rewards.gold) + + return rewards + + # ========================================================================= + # Action Execution + # ========================================================================= + + def execute_action( + self, + session_id: str, + user_id: str, + combatant_id: str, + action: CombatAction + ) -> ActionResult: + """ + Execute a combat action for a combatant. + + Args: + session_id: Game session ID + user_id: User ID for authorization + combatant_id: ID of combatant taking action + action: Action to execute + + Returns: + ActionResult with outcome details + + Raises: + NotInCombatError: If session not in combat + InvalidActionError: If action is invalid + """ + logger.info("Executing action", + session_id=session_id, + combatant_id=combatant_id, + action_type=action.action_type) + + session = self.session_service.get_session(session_id, user_id) + + if not session.is_in_combat(): + raise NotInCombatError("Session is not in combat") + + encounter = session.combat_encounter + + # Validate it's this combatant's turn + current = encounter.get_current_combatant() + if not current or current.combatant_id != combatant_id: + raise InvalidActionError( + f"Not {combatant_id}'s turn. Current turn: " + f"{current.combatant_id if current else 'None'}" + ) + + # Process start-of-turn effects + turn_effects = encounter.start_turn() + + # Check if combatant is stunned + if current.is_stunned(): + result = ActionResult( + success=False, + message=f"{current.name} is stunned and cannot act!", + turn_effects=[{"type": "stun", "combatant": current.name}], + ) + self._advance_turn_and_save(encounter, session, user_id) + result.next_combatant_id = encounter.get_current_combatant().combatant_id + return result + + # Execute based on action type + if action.action_type == "attack": + result = self._execute_attack(encounter, current, action.target_ids) + elif action.action_type == "ability": + result = self._execute_ability( + encounter, current, action.ability_id, action.target_ids + ) + elif action.action_type == "defend": + result = self._execute_defend(encounter, current) + elif action.action_type == "flee": + result = self._execute_flee(encounter, current, session, user_id) + elif action.action_type == "item": + result = self._execute_use_item( + encounter, current, action.item_id, action.target_ids + ) + else: + raise InvalidActionError(f"Unknown action type: {action.action_type}") + + # Add turn effects + result.turn_effects = [ + {"type": e.get("type", "effect"), "message": e.get("message", "")} + for e in turn_effects + ] + + # Check for combat end + status = encounter.check_end_condition() + if status != CombatStatus.ACTIVE: + result.combat_ended = True + result.combat_status = status + + # Auto-end combat and distribute rewards + if status == CombatStatus.VICTORY: + rewards = self._calculate_rewards(encounter, session, user_id) + result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold." + + session.end_combat() + else: + # Advance turn + self._advance_turn_and_save(encounter, session, user_id) + next_combatant = encounter.get_current_combatant() + result.next_combatant_id = next_combatant.combatant_id if next_combatant else None + + # Save session state + self.session_service.update_session(session, user_id) + + return result + + def execute_enemy_turn( + self, + session_id: str, + user_id: str + ) -> ActionResult: + """ + Execute an enemy's turn using basic AI. + + Args: + session_id: Game session ID + user_id: User ID for authorization + + Returns: + ActionResult with enemy action outcome + """ + session = self.session_service.get_session(session_id, user_id) + + if not session.is_in_combat(): + raise NotInCombatError("Session is not in combat") + + encounter = session.combat_encounter + current = encounter.get_current_combatant() + + if not current: + raise InvalidActionError("No current combatant") + + if current.is_player: + raise InvalidActionError("Current combatant is a player, not an enemy") + + # Process start-of-turn effects + turn_effects = encounter.start_turn() + + # Check if stunned + if current.is_stunned(): + result = ActionResult( + success=False, + message=f"{current.name} is stunned and cannot act!", + turn_effects=[{"type": "stun", "combatant": current.name}], + ) + self._advance_turn_and_save(encounter, session, user_id) + result.next_combatant_id = encounter.get_current_combatant().combatant_id + return result + + # Enemy AI: Choose action + action, targets = self._choose_enemy_action(encounter, current) + + # Execute chosen action + if action == "ability" and current.abilities: + # Try to use an ability + ability_id = self._choose_enemy_ability(current) + if ability_id: + result = self._execute_ability( + encounter, current, ability_id, targets + ) + else: + # Fallback to basic attack + result = self._execute_attack(encounter, current, targets) + else: + # Basic attack + result = self._execute_attack(encounter, current, targets) + + # Add turn effects + result.turn_effects = [ + {"type": e.get("type", "effect"), "message": e.get("message", "")} + for e in turn_effects + ] + + # Check for combat end + status = encounter.check_end_condition() + if status != CombatStatus.ACTIVE: + result.combat_ended = True + result.combat_status = status + session.end_combat() + else: + self._advance_turn_and_save(encounter, session, user_id) + next_combatant = encounter.get_current_combatant() + result.next_combatant_id = next_combatant.combatant_id if next_combatant else None + + self.session_service.update_session(session, user_id) + + return result + + # ========================================================================= + # Specific Action Implementations + # ========================================================================= + + def _execute_attack( + self, + encounter: CombatEncounter, + attacker: Combatant, + target_ids: List[str] + ) -> ActionResult: + """Execute a basic attack action.""" + if not target_ids: + # Auto-target: first alive enemy/player + target = self._get_default_target(encounter, attacker) + if not target: + return ActionResult( + success=False, + message="No valid targets available" + ) + target_ids = [target.combatant_id] + + target = encounter.get_combatant(target_ids[0]) + if not target or target.is_dead(): + return ActionResult( + success=False, + message="Invalid or dead target" + ) + + # Get attacker's weapon damage (or base damage for enemies) + weapon_damage = self._get_weapon_damage(attacker) + crit_chance = self._get_crit_chance(attacker) + + # Calculate damage using DamageCalculator + damage_result = DamageCalculator.calculate_physical_damage( + attacker_stats=attacker.stats, + defender_stats=target.stats, + weapon_damage=weapon_damage, + weapon_crit_chance=crit_chance, + ) + + # Add target_id to result for tracking + damage_result.target_id = target.combatant_id + + # Apply damage + if not damage_result.is_miss: + actual_damage = target.take_damage(damage_result.total_damage) + message = f"{attacker.name} attacks {target.name}! {damage_result.message}" + if actual_damage < damage_result.total_damage: + message += f" (Shield absorbed {damage_result.total_damage - actual_damage})" + else: + message = f"{attacker.name} attacks {target.name}! {damage_result.message}" + + # Log action + encounter.log_action( + "attack", + attacker.combatant_id, + message, + {"damage": damage_result.total_damage, "target": target.combatant_id} + ) + + return ActionResult( + success=True, + message=message, + damage_results=[damage_result], + ) + + def _execute_ability( + self, + encounter: CombatEncounter, + caster: Combatant, + ability_id: str, + target_ids: List[str] + ) -> ActionResult: + """Execute an ability action.""" + # Load ability + ability = self.ability_loader.load_ability(ability_id) + if not ability: + return ActionResult( + success=False, + message=f"Unknown ability: {ability_id}" + ) + + # Check if ability is available + if not caster.can_use_ability(ability_id, ability): + if caster.current_mp < ability.mana_cost: + return ActionResult( + success=False, + message=f"Not enough mana ({caster.current_mp}/{ability.mana_cost})" + ) + if ability_id in caster.cooldowns and caster.cooldowns[ability_id] > 0: + return ActionResult( + success=False, + message=f"Ability on cooldown ({caster.cooldowns[ability_id]} turns)" + ) + return ActionResult( + success=False, + message=f"Cannot use ability: {ability.name}" + ) + + # Determine targets + if ability.is_aoe: + # AoE: Target all enemies (or allies for heals) + if ability.ability_type in [AbilityType.HEAL, AbilityType.BUFF]: + targets = [c for c in encounter.combatants + if c.is_player == caster.is_player and c.is_alive()] + else: + targets = [c for c in encounter.combatants + if c.is_player != caster.is_player and c.is_alive()] + # Limit by target_count if specified + if ability.target_count > 0: + targets = targets[:ability.target_count] + else: + # Single target + if not target_ids: + target = self._get_default_target(encounter, caster) + if not target: + return ActionResult(success=False, message="No valid targets") + targets = [target] + else: + targets = [encounter.get_combatant(tid) for tid in target_ids] + targets = [t for t in targets if t and t.is_alive()] + + if not targets: + return ActionResult(success=False, message="No valid targets") + + # Apply mana cost and cooldown + caster.use_ability_cost(ability, ability_id) + + # Execute ability based on type + damage_results = [] + effects_applied = [] + messages = [] + + for target in targets: + if ability.ability_type in [AbilityType.ATTACK, AbilityType.SPELL]: + # Damage ability + damage_result = DamageCalculator.calculate_magical_damage( + attacker_stats=caster.stats, + defender_stats=target.stats, + ability_base_power=ability.calculate_power(caster.stats), + weapon_crit_chance=self._get_crit_chance(caster), + ) + damage_result.target_id = target.combatant_id + + if not damage_result.is_miss: + target.take_damage(damage_result.total_damage) + messages.append( + f"{target.name} takes {damage_result.total_damage} damage" + ) + else: + messages.append(f"{target.name} dodges!") + + damage_results.append(damage_result) + + elif ability.ability_type == AbilityType.HEAL: + # Healing ability + heal_amount = ability.calculate_power(caster.stats) + actual_heal = target.heal(heal_amount) + messages.append(f"{target.name} heals for {actual_heal}") + + # Apply effects + for effect in ability.get_effects_to_apply(): + target.add_effect(effect) + effects_applied.append({ + "target": target.combatant_id, + "effect": effect.name, + "duration": effect.duration, + }) + messages.append(f"{target.name} is affected by {effect.name}") + + message = f"{caster.name} uses {ability.name}! " + "; ".join(messages) + + # Log action + encounter.log_action( + "ability", + caster.combatant_id, + message, + { + "ability": ability_id, + "targets": [t.combatant_id for t in targets], + "mana_cost": ability.mana_cost, + } + ) + + return ActionResult( + success=True, + message=message, + damage_results=damage_results, + effects_applied=effects_applied, + ) + + def _execute_defend( + self, + encounter: CombatEncounter, + combatant: Combatant + ) -> ActionResult: + """Execute a defend action (increases defense for one turn).""" + # Add a temporary defense buff + defense_buff = Effect( + effect_id=f"defend_{uuid4().hex[:8]}", + name="Defending", + effect_type=EffectType.BUFF, + duration=1, + power=5, # +5 defense + stat_affected="constitution", + source="defend_action", + ) + combatant.add_effect(defense_buff) + + message = f"{combatant.name} takes a defensive stance! (+5 Defense)" + + encounter.log_action( + "defend", + combatant.combatant_id, + message, + {} + ) + + return ActionResult( + success=True, + message=message, + effects_applied=[{ + "target": combatant.combatant_id, + "effect": "Defending", + "duration": 1, + }], + ) + + def _execute_flee( + self, + encounter: CombatEncounter, + combatant: Combatant, + session, + user_id: str + ) -> ActionResult: + """Execute a flee action.""" + # Calculate flee chance (base 50%, modified by DEX vs enemy DEX) + base_flee_chance = 0.50 + + # Get average enemy DEX + enemies = [c for c in encounter.combatants if not c.is_player and c.is_alive()] + if enemies: + avg_enemy_dex = sum(e.stats.dexterity for e in enemies) / len(enemies) + dex_modifier = (combatant.stats.dexterity - avg_enemy_dex) * 0.02 + flee_chance = max(0.10, min(0.90, base_flee_chance + dex_modifier)) + else: + flee_chance = 1.0 # No enemies, always succeed + + # Roll for flee + roll = random.random() + success = roll < flee_chance + + if success: + encounter.status = CombatStatus.FLED + encounter.log_action( + "flee", + combatant.combatant_id, + f"{combatant.name} successfully flees from combat!", + {"roll": roll, "chance": flee_chance} + ) + + return ActionResult( + success=True, + message=f"{combatant.name} successfully flees from combat!", + combat_ended=True, + combat_status=CombatStatus.FLED, + ) + else: + encounter.log_action( + "flee_failed", + combatant.combatant_id, + f"{combatant.name} fails to flee!", + {"roll": roll, "chance": flee_chance} + ) + + return ActionResult( + success=False, + message=f"{combatant.name} fails to flee! (Roll: {roll:.2f}, Needed: {flee_chance:.2f})", + ) + + def _execute_use_item( + self, + encounter: CombatEncounter, + combatant: Combatant, + item_id: str, + target_ids: List[str] + ) -> ActionResult: + """Execute an item use action.""" + # TODO: Implement item usage from inventory + # For now, return not implemented + return ActionResult( + success=False, + message="Item usage not yet implemented" + ) + + # ========================================================================= + # Enemy AI + # ========================================================================= + + def _choose_enemy_action( + self, + encounter: CombatEncounter, + enemy: Combatant + ) -> Tuple[str, List[str]]: + """ + Choose an action for an enemy combatant. + + Returns: + Tuple of (action_type, target_ids) + """ + # Get valid player targets + players = [c for c in encounter.combatants if c.is_player and c.is_alive()] + + if not players: + return ("attack", []) + + # Simple AI: Attack lowest HP player + target = min(players, key=lambda p: p.current_hp) + + # 30% chance to use ability if available + if enemy.abilities and enemy.current_mp > 0 and random.random() < 0.3: + return ("ability", [target.combatant_id]) + + return ("attack", [target.combatant_id]) + + def _choose_enemy_ability(self, enemy: Combatant) -> Optional[str]: + """Choose an ability for an enemy to use.""" + for ability_id in enemy.abilities: + ability = self.ability_loader.load_ability(ability_id) + if ability and enemy.can_use_ability(ability_id, ability): + return ability_id + return None + + # ========================================================================= + # Rewards + # ========================================================================= + + def _calculate_rewards( + self, + encounter: CombatEncounter, + session, + user_id: str + ) -> CombatRewards: + """ + Calculate and distribute rewards after victory. + + Args: + encounter: Completed combat encounter + session: Game session + user_id: User ID for character updates + + Returns: + CombatRewards with totals + """ + rewards = CombatRewards() + + # Sum up rewards from defeated enemies + for combatant in encounter.combatants: + if not combatant.is_player and combatant.is_dead(): + # Get enemy template for rewards + enemy_id = combatant.combatant_id.split("_")[0] # Extract base ID + enemy = self.enemy_loader.load_enemy(enemy_id) + + if enemy: + rewards.experience += enemy.experience_reward + rewards.gold += enemy.get_gold_reward() + + # Roll for loot + loot = enemy.roll_loot() + rewards.items.extend(loot) + + # Distribute rewards to player characters + player_combatants = [c for c in encounter.combatants if c.is_player] + xp_per_player = rewards.experience // max(1, len(player_combatants)) + gold_per_player = rewards.gold // max(1, len(player_combatants)) + + for player in player_combatants: + if session.is_solo(): + char_id = session.solo_character_id + else: + char_id = player.combatant_id + + try: + character = self.character_service.get_character(char_id, user_id) + + # Add XP and check for level up + old_level = character.level + character.experience += xp_per_player + # TODO: Add level up logic based on XP thresholds + + # Add gold + character.gold += gold_per_player + + # Save character + self.character_service.update_character(character, user_id) + + if character.level > old_level: + rewards.level_ups.append(char_id) + + except Exception as e: + logger.error("Failed to distribute rewards to character", + char_id=char_id, + error=str(e)) + + logger.info("Rewards distributed", + total_xp=rewards.experience, + total_gold=rewards.gold, + items=len(rewards.items), + level_ups=rewards.level_ups) + + return rewards + + # ========================================================================= + # Helper Methods + # ========================================================================= + + def _create_combatant_from_character( + self, + character: Character + ) -> Combatant: + """Create a Combatant from a player Character.""" + effective_stats = character.get_effective_stats() + + # Get abilities from unlocked skills + abilities = ["basic_attack"] # All characters have basic attack + abilities.extend(character.unlocked_skills) + + return Combatant( + combatant_id=character.character_id, + name=character.name, + is_player=True, + current_hp=effective_stats.hit_points, + max_hp=effective_stats.hit_points, + current_mp=effective_stats.mana_points, + max_mp=effective_stats.mana_points, + stats=effective_stats, + abilities=abilities, + ) + + def _create_combatant_from_enemy( + self, + template: EnemyTemplate, + instance_index: int = 0 + ) -> Combatant: + """Create a Combatant from an EnemyTemplate.""" + # Create unique ID for this instance + combatant_id = f"{template.enemy_id}_{instance_index}" + + # Add variation to name if multiple of same type + name = template.name + if instance_index > 0: + name = f"{template.name} #{instance_index + 1}" + + stats = template.base_stats + + return Combatant( + combatant_id=combatant_id, + name=name, + is_player=False, + current_hp=stats.hit_points, + max_hp=stats.hit_points, + current_mp=stats.mana_points, + max_mp=stats.mana_points, + stats=stats, + abilities=template.abilities.copy(), + ) + + def _get_weapon_damage(self, combatant: Combatant) -> int: + """Get weapon damage for a combatant.""" + # For enemies, use base_damage from template + if not combatant.is_player: + # Base damage stored in combatant data or default + return 8 # Default enemy damage + + # For players, would check equipped weapon + # TODO: Check character's equipped weapon + return 5 # Default unarmed damage + + def _get_crit_chance(self, combatant: Combatant) -> float: + """Get critical hit chance for a combatant.""" + # Base 5% + LUK bonus + return 0.05 + combatant.stats.crit_bonus + + def _get_default_target( + self, + encounter: CombatEncounter, + attacker: Combatant + ) -> Optional[Combatant]: + """Get a default target for an attacker.""" + # Target opposite side, first alive + for combatant in encounter.combatants: + if combatant.is_player != attacker.is_player and combatant.is_alive(): + return combatant + return None + + def _advance_turn_and_save( + self, + encounter: CombatEncounter, + session, + user_id: str + ) -> None: + """Advance the turn and save session state.""" + encounter.advance_turn() + + +# ============================================================================= +# Global Instance +# ============================================================================= + +_service_instance: Optional[CombatService] = None + + +def get_combat_service() -> CombatService: + """ + Get the global CombatService instance. + + Returns: + Singleton CombatService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = CombatService() + return _service_instance diff --git a/api/app/services/damage_calculator.py b/api/app/services/damage_calculator.py new file mode 100644 index 0000000..51fc383 --- /dev/null +++ b/api/app/services/damage_calculator.py @@ -0,0 +1,594 @@ +""" +Damage Calculator Service + +A comprehensive, formula-driven damage calculation system for Code of Conquest. +Handles physical, magical, and elemental damage with LUK stat integration +for variance, critical hits, and accuracy. + +Formulas: + Physical: (Weapon_Base + STR * 0.75) * Variance * Crit_Mult - DEF + Magical: (Ability_Base + INT * 0.75) * Variance * Crit_Mult - RES + Elemental: Split between physical and magical components + +LUK Integration: + - Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss + - Crit bonus: Base 5% + (LUK * 0.5%), max 25% + - Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll +""" + +import random +from dataclasses import dataclass, field +from typing import Dict, Any, Optional, List + +from app.models.stats import Stats +from app.models.enums import DamageType + + +class CombatConstants: + """ + Combat system tuning constants. + + These values control the balance of combat mechanics and can be + adjusted for game balance without modifying formula logic. + """ + + # Stat Scaling + # How much primary stats (STR/INT) contribute to damage + # 0.75 means STR 14 adds +10.5 damage + STAT_SCALING_FACTOR: float = 0.75 + + # Hit/Miss System + BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate + LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point + DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10 + MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss + + # Critical Hits + DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit + LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point + MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills) + DEFAULT_CRIT_MULTIPLIER: float = 2.0 + + # Damage Variance + BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll + BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll + LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum + LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus) + BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance + LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point + + # Defense Mitigation + # Ensures high-DEF targets still take meaningful damage + MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through + MIN_DAMAGE: int = 1 # Absolute minimum damage + + +@dataclass +class DamageResult: + """ + Result of a damage calculation. + + Contains the calculated damage values, whether the attack was a crit or miss, + and a human-readable message for the combat log. + + Attributes: + total_damage: Final damage after all calculations + physical_damage: Physical component (for split damage) + elemental_damage: Elemental component (for split damage) + damage_type: Primary damage type (physical, fire, etc.) + is_critical: Whether the attack was a critical hit + is_miss: Whether the attack missed entirely + variance_roll: The variance multiplier that was applied + raw_damage: Damage before defense mitigation + message: Human-readable description for combat log + """ + + total_damage: int = 0 + physical_damage: int = 0 + elemental_damage: int = 0 + damage_type: DamageType = DamageType.PHYSICAL + elemental_type: Optional[DamageType] = None + is_critical: bool = False + is_miss: bool = False + variance_roll: float = 1.0 + raw_damage: int = 0 + message: str = "" + + def to_dict(self) -> Dict[str, Any]: + """Serialize damage result to dictionary.""" + return { + "total_damage": self.total_damage, + "physical_damage": self.physical_damage, + "elemental_damage": self.elemental_damage, + "damage_type": self.damage_type.value if self.damage_type else "physical", + "elemental_type": self.elemental_type.value if self.elemental_type else None, + "is_critical": self.is_critical, + "is_miss": self.is_miss, + "variance_roll": round(self.variance_roll, 3), + "raw_damage": self.raw_damage, + "message": self.message, + } + + +class DamageCalculator: + """ + Formula-driven damage calculator for combat. + + This class provides static methods for calculating all types of damage + in the combat system, including hit/miss chances, critical hits, + damage variance, and defense mitigation. + + All formulas integrate the LUK stat for meaningful randomness while + maintaining a hard cap on miss chance to prevent frustration. + """ + + @staticmethod + def calculate_hit_chance( + attacker_luck: int, + defender_dexterity: int, + skill_bonus: float = 0.0 + ) -> float: + """ + Calculate hit probability for an attack. + + Formula: + miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025)) + hit_chance = 1.0 - miss_chance + + Args: + attacker_luck: Attacker's LUK stat + defender_dexterity: Defender's DEX stat + skill_bonus: Additional hit chance from skills (0.0 to 1.0) + + Returns: + Hit probability as a float between 0.0 and 1.0 + + Examples: + LUK 8, DEX 10: miss = 10% - 4% + 0% = 6% + LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5% + LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25% + """ + # Base miss rate + base_miss = CombatConstants.BASE_MISS_CHANCE + + # LUK reduces miss chance + luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION + + # High DEX increases evasion (only DEX above 10 counts) + dex_above_base = max(0, defender_dexterity - 10) + dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS + + # Calculate final miss chance with hard cap + miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus + miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance) + + return 1.0 - miss_chance + + @staticmethod + def calculate_crit_chance( + attacker_luck: int, + weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, + skill_bonus: float = 0.0 + ) -> float: + """ + Calculate critical hit probability. + + Formula: + crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus) + + Args: + attacker_luck: Attacker's LUK stat + weapon_crit_chance: Base crit chance from weapon (default 5%) + skill_bonus: Additional crit chance from skills + + Returns: + Crit probability as a float (capped at 25%) + + Examples: + LUK 8, weapon 5%: crit = 5% + 4% = 9% + LUK 12, weapon 5%: crit = 5% + 6% = 11% + LUK 12, weapon 10%: crit = 10% + 6% = 16% + """ + # LUK bonus to crit + luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS + + # Total crit chance with cap + total_crit = weapon_crit_chance + luk_bonus + skill_bonus + + return min(CombatConstants.MAX_CRIT_CHANCE, total_crit) + + @staticmethod + def calculate_variance(attacker_luck: int) -> float: + """ + Calculate damage variance multiplier with LUK bonus. + + Hybrid variance system: + - Base roll: 95% to 105% of damage + - LUK grants chance for "lucky roll": 100% to 110% instead + + Args: + attacker_luck: Attacker's LUK stat + + Returns: + Variance multiplier (typically 0.95 to 1.10) + + Examples: + LUK 8: 7% chance for lucky roll (100-110%) + LUK 12: 8% chance for lucky roll + """ + # Calculate lucky roll chance + lucky_chance = ( + CombatConstants.BASE_LUCKY_CHANCE + + (attacker_luck * CombatConstants.LUK_LUCKY_BONUS) + ) + + # Roll for lucky variance + if random.random() < lucky_chance: + # Lucky roll: higher damage range + return random.uniform( + CombatConstants.LUCKY_VARIANCE_MIN, + CombatConstants.LUCKY_VARIANCE_MAX + ) + else: + # Normal roll + return random.uniform( + CombatConstants.BASE_VARIANCE_MIN, + CombatConstants.BASE_VARIANCE_MAX + ) + + @staticmethod + def apply_defense( + raw_damage: int, + defense: int, + min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO + ) -> int: + """ + Apply defense mitigation with minimum damage guarantee. + + Ensures at least 20% of raw damage always goes through, + preventing high-DEF tanks from becoming unkillable. + Absolute minimum is always 1 damage. + + Args: + raw_damage: Damage before defense + defense: Target's defense value + min_damage_ratio: Minimum % of raw damage that goes through + + Returns: + Final damage after mitigation (minimum 1) + + Examples: + raw=20, def=5: 20 - 5 = 15 damage + raw=20, def=18: max(4, 2) = 4 damage (20% minimum) + raw=10, def=100: max(2, -90) = 2 damage (20% minimum) + """ + # Calculate mitigated damage + mitigated = raw_damage - defense + + # Minimum damage is 20% of raw, or 1, whichever is higher + min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio)) + + return max(min_damage, mitigated) + + @classmethod + def calculate_physical_damage( + cls, + attacker_stats: Stats, + defender_stats: Stats, + weapon_damage: int = 0, + weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, + weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER, + ability_base_power: int = 0, + skill_hit_bonus: float = 0.0, + skill_crit_bonus: float = 0.0, + ) -> DamageResult: + """ + Calculate physical damage for a melee/ranged attack. + + Formula: + Base = Weapon_Base + Ability_Power + (STR * 0.75) + Damage = Base * Variance * Crit_Mult - DEF + + Args: + attacker_stats: Attacker's Stats (STR, LUK used) + defender_stats: Defender's Stats (DEX, CON used) + weapon_damage: Base damage from equipped weapon + weapon_crit_chance: Crit chance from weapon (default 5%) + weapon_crit_multiplier: Crit damage multiplier (default 2.0x) + ability_base_power: Additional base power from ability + skill_hit_bonus: Hit chance bonus from skills + skill_crit_bonus: Crit chance bonus from skills + + Returns: + DamageResult with calculated damage and metadata + """ + result = DamageResult(damage_type=DamageType.PHYSICAL) + + # Step 1: Check for miss + hit_chance = cls.calculate_hit_chance( + attacker_stats.luck, + defender_stats.dexterity, + skill_hit_bonus + ) + + if random.random() > hit_chance: + result.is_miss = True + result.message = "Attack missed!" + return result + + # Step 2: Calculate base damage + # Formula: weapon + ability + (STR * scaling_factor) + str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR + base_damage = weapon_damage + ability_base_power + str_bonus + + # Step 3: Apply variance + variance = cls.calculate_variance(attacker_stats.luck) + result.variance_roll = variance + damage = base_damage * variance + + # Step 4: Check for critical hit + crit_chance = cls.calculate_crit_chance( + attacker_stats.luck, + weapon_crit_chance, + skill_crit_bonus + ) + + if random.random() < crit_chance: + result.is_critical = True + damage *= weapon_crit_multiplier + + # Store raw damage before defense + result.raw_damage = int(damage) + + # Step 5: Apply defense mitigation + final_damage = cls.apply_defense(int(damage), defender_stats.defense) + + result.total_damage = final_damage + result.physical_damage = final_damage + + # Build message + crit_text = " CRITICAL HIT!" if result.is_critical else "" + result.message = f"Dealt {final_damage} physical damage.{crit_text}" + + return result + + @classmethod + def calculate_magical_damage( + cls, + attacker_stats: Stats, + defender_stats: Stats, + ability_base_power: int, + damage_type: DamageType = DamageType.FIRE, + weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, + weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER, + skill_hit_bonus: float = 0.0, + skill_crit_bonus: float = 0.0, + ) -> DamageResult: + """ + Calculate magical damage for a spell. + + Spells CAN critically hit (same formula as physical). + LUK benefits all classes equally. + + Formula: + Base = Ability_Power + (INT * 0.75) + Damage = Base * Variance * Crit_Mult - RES + + Args: + attacker_stats: Attacker's Stats (INT, LUK used) + defender_stats: Defender's Stats (DEX, WIS used) + ability_base_power: Base power of the spell + damage_type: Type of magical damage (fire, ice, etc.) + weapon_crit_chance: Crit chance (from focus/staff) + weapon_crit_multiplier: Crit damage multiplier + skill_hit_bonus: Hit chance bonus from skills + skill_crit_bonus: Crit chance bonus from skills + + Returns: + DamageResult with calculated damage and metadata + """ + result = DamageResult(damage_type=damage_type) + + # Step 1: Check for miss (spells can miss too) + hit_chance = cls.calculate_hit_chance( + attacker_stats.luck, + defender_stats.dexterity, + skill_hit_bonus + ) + + if random.random() > hit_chance: + result.is_miss = True + result.message = "Spell missed!" + return result + + # Step 2: Calculate base damage + # Formula: ability + (INT * scaling_factor) + int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR + base_damage = ability_base_power + int_bonus + + # Step 3: Apply variance + variance = cls.calculate_variance(attacker_stats.luck) + result.variance_roll = variance + damage = base_damage * variance + + # Step 4: Check for critical hit (spells CAN crit) + crit_chance = cls.calculate_crit_chance( + attacker_stats.luck, + weapon_crit_chance, + skill_crit_bonus + ) + + if random.random() < crit_chance: + result.is_critical = True + damage *= weapon_crit_multiplier + + # Store raw damage before resistance + result.raw_damage = int(damage) + + # Step 5: Apply resistance mitigation + final_damage = cls.apply_defense(int(damage), defender_stats.resistance) + + result.total_damage = final_damage + result.elemental_damage = final_damage + + # Build message + crit_text = " CRITICAL HIT!" if result.is_critical else "" + result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}" + + return result + + @classmethod + def calculate_elemental_weapon_damage( + cls, + attacker_stats: Stats, + defender_stats: Stats, + weapon_damage: int, + weapon_crit_chance: float, + weapon_crit_multiplier: float, + physical_ratio: float, + elemental_ratio: float, + elemental_type: DamageType, + ability_base_power: int = 0, + skill_hit_bonus: float = 0.0, + skill_crit_bonus: float = 0.0, + ) -> DamageResult: + """ + Calculate split damage for elemental weapons (e.g., Fire Sword). + + Elemental weapons deal both physical AND elemental damage, + calculated separately against DEF and RES respectively. + + Formula: + Physical = (Weapon * PHYS_RATIO + STR * 0.75 * PHYS_RATIO) - DEF + Elemental = (Weapon * ELEM_RATIO + INT * 0.75 * ELEM_RATIO) - RES + Total = Physical + Elemental + + Recommended Split Ratios: + - Pure Physical: 100% / 0% + - Fire Sword: 70% / 30% + - Frost Blade: 60% / 40% + - Lightning Spear: 50% / 50% + + Args: + attacker_stats: Attacker's Stats + defender_stats: Defender's Stats + weapon_damage: Base weapon damage + weapon_crit_chance: Crit chance from weapon + weapon_crit_multiplier: Crit damage multiplier + physical_ratio: Portion of damage that is physical (0.0-1.0) + elemental_ratio: Portion of damage that is elemental (0.0-1.0) + elemental_type: Type of elemental damage + ability_base_power: Additional base power from ability + skill_hit_bonus: Hit chance bonus from skills + skill_crit_bonus: Crit chance bonus from skills + + Returns: + DamageResult with split physical/elemental damage + """ + result = DamageResult( + damage_type=DamageType.PHYSICAL, + elemental_type=elemental_type + ) + + # Step 1: Check for miss (single roll for entire attack) + hit_chance = cls.calculate_hit_chance( + attacker_stats.luck, + defender_stats.dexterity, + skill_hit_bonus + ) + + if random.random() > hit_chance: + result.is_miss = True + result.message = "Attack missed!" + return result + + # Step 2: Check for critical (single roll applies to both components) + variance = cls.calculate_variance(attacker_stats.luck) + result.variance_roll = variance + + crit_chance = cls.calculate_crit_chance( + attacker_stats.luck, + weapon_crit_chance, + skill_crit_bonus + ) + is_crit = random.random() < crit_chance + result.is_critical = is_crit + crit_mult = weapon_crit_multiplier if is_crit else 1.0 + + # Step 3: Calculate physical component + # Physical uses STR scaling + phys_base = (weapon_damage + ability_base_power) * physical_ratio + str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR * physical_ratio + phys_damage = (phys_base + str_bonus) * variance * crit_mult + phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense) + + # Step 4: Calculate elemental component + # Elemental uses INT scaling + elem_base = (weapon_damage + ability_base_power) * elemental_ratio + int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR * elemental_ratio + elem_damage = (elem_base + int_bonus) * variance * crit_mult + elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance) + + # Step 5: Combine results + result.physical_damage = phys_final + result.elemental_damage = elem_final + result.total_damage = phys_final + elem_final + result.raw_damage = int(phys_damage + elem_damage) + + # Build message + crit_text = " CRITICAL HIT!" if is_crit else "" + result.message = ( + f"Dealt {result.total_damage} damage " + f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}" + ) + + return result + + @classmethod + def calculate_aoe_damage( + cls, + attacker_stats: Stats, + defender_stats_list: List[Stats], + ability_base_power: int, + damage_type: DamageType = DamageType.FIRE, + weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE, + weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER, + skill_hit_bonus: float = 0.0, + skill_crit_bonus: float = 0.0, + ) -> List[DamageResult]: + """ + Calculate AoE spell damage against multiple targets. + + AoE spells deal FULL damage to all targets (balanced by higher mana costs). + Each target has independent hit/crit rolls but shares the base calculation. + + Args: + attacker_stats: Attacker's Stats + defender_stats_list: List of defender Stats (one per target) + ability_base_power: Base power of the AoE spell + damage_type: Type of magical damage + weapon_crit_chance: Crit chance from focus/staff + weapon_crit_multiplier: Crit damage multiplier + skill_hit_bonus: Hit chance bonus from skills + skill_crit_bonus: Crit chance bonus from skills + + Returns: + List of DamageResult, one per target + """ + results = [] + + # Each target gets independent damage calculation + for defender_stats in defender_stats_list: + result = cls.calculate_magical_damage( + attacker_stats=attacker_stats, + defender_stats=defender_stats, + ability_base_power=ability_base_power, + damage_type=damage_type, + weapon_crit_chance=weapon_crit_chance, + weapon_crit_multiplier=weapon_crit_multiplier, + skill_hit_bonus=skill_hit_bonus, + skill_crit_bonus=skill_crit_bonus, + ) + results.append(result) + + return results diff --git a/api/app/services/enemy_loader.py b/api/app/services/enemy_loader.py new file mode 100644 index 0000000..adf1157 --- /dev/null +++ b/api/app/services/enemy_loader.py @@ -0,0 +1,260 @@ +""" +Enemy Loader Service - YAML-based enemy template loading. + +This service loads enemy definitions from YAML files, providing a data-driven +approach to defining monsters and enemies for combat encounters. +""" + +from pathlib import Path +from typing import Dict, List, Optional +import yaml + +from app.models.enemy import EnemyTemplate, EnemyDifficulty +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class EnemyLoader: + """ + Loads enemy templates from YAML configuration files. + + This allows game designers to define enemies without touching code. + Enemy files are organized by difficulty in subdirectories. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the enemy loader. + + Args: + data_dir: Path to directory containing enemy YAML files + Defaults to /app/data/enemies/ + """ + if data_dir is None: + # Default to app/data/enemies relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "enemies") + + self.data_dir = Path(data_dir) + self._enemy_cache: Dict[str, EnemyTemplate] = {} + self._loaded = False + + logger.info("EnemyLoader initialized", data_dir=str(self.data_dir)) + + def load_enemy(self, enemy_id: str) -> Optional[EnemyTemplate]: + """ + Load a single enemy template by ID. + + Args: + enemy_id: Unique enemy identifier + + Returns: + EnemyTemplate instance or None if not found + """ + # Check cache first + if enemy_id in self._enemy_cache: + return self._enemy_cache[enemy_id] + + # If not cached, try loading all enemies first + if not self._loaded: + self.load_all_enemies() + if enemy_id in self._enemy_cache: + return self._enemy_cache[enemy_id] + + # Try loading from specific YAML file + yaml_file = self.data_dir / f"{enemy_id}.yaml" + if yaml_file.exists(): + return self._load_from_file(yaml_file) + + # Search in subdirectories + for subdir in self.data_dir.iterdir(): + if subdir.is_dir(): + yaml_file = subdir / f"{enemy_id}.yaml" + if yaml_file.exists(): + return self._load_from_file(yaml_file) + + logger.warning("Enemy not found", enemy_id=enemy_id) + return None + + def _load_from_file(self, yaml_file: Path) -> Optional[EnemyTemplate]: + """ + Load an enemy template from a specific YAML file. + + Args: + yaml_file: Path to the YAML file + + Returns: + EnemyTemplate instance or None on error + """ + try: + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + enemy = EnemyTemplate.from_dict(data) + self._enemy_cache[enemy.enemy_id] = enemy + + logger.debug("Enemy loaded", enemy_id=enemy.enemy_id, file=str(yaml_file)) + return enemy + + except Exception as e: + logger.error("Failed to load enemy file", + file=str(yaml_file), + error=str(e)) + return None + + def load_all_enemies(self) -> Dict[str, EnemyTemplate]: + """ + Load all enemy templates from the data directory. + + Searches both the root directory and subdirectories for YAML files. + + Returns: + Dictionary mapping enemy_id to EnemyTemplate instance + """ + if not self.data_dir.exists(): + logger.warning("Enemy data directory not found", path=str(self.data_dir)) + return {} + + enemies = {} + + # Load from root directory + for yaml_file in self.data_dir.glob("*.yaml"): + enemy = self._load_from_file(yaml_file) + if enemy: + enemies[enemy.enemy_id] = enemy + + # Load from subdirectories (organized by difficulty) + for subdir in self.data_dir.iterdir(): + if subdir.is_dir(): + for yaml_file in subdir.glob("*.yaml"): + enemy = self._load_from_file(yaml_file) + if enemy: + enemies[enemy.enemy_id] = enemy + + self._loaded = True + logger.info("All enemies loaded", count=len(enemies)) + + return enemies + + def get_enemies_by_difficulty( + self, + difficulty: EnemyDifficulty + ) -> List[EnemyTemplate]: + """ + Get all enemies matching a difficulty level. + + Args: + difficulty: Difficulty level to filter by + + Returns: + List of EnemyTemplate instances + """ + if not self._loaded: + self.load_all_enemies() + + return [ + enemy for enemy in self._enemy_cache.values() + if enemy.difficulty == difficulty + ] + + def get_enemies_by_tag(self, tag: str) -> List[EnemyTemplate]: + """ + Get all enemies with a specific tag. + + Args: + tag: Tag to filter by (e.g., "undead", "beast", "humanoid") + + Returns: + List of EnemyTemplate instances with that tag + """ + if not self._loaded: + self.load_all_enemies() + + return [ + enemy for enemy in self._enemy_cache.values() + if enemy.has_tag(tag) + ] + + def get_random_enemies( + self, + count: int = 1, + difficulty: Optional[EnemyDifficulty] = None, + tag: Optional[str] = None, + exclude_bosses: bool = True + ) -> List[EnemyTemplate]: + """ + Get random enemies for encounter generation. + + Args: + count: Number of enemies to select + difficulty: Optional difficulty filter + tag: Optional tag filter + exclude_bosses: Whether to exclude boss enemies + + Returns: + List of randomly selected EnemyTemplate instances + """ + import random + + if not self._loaded: + self.load_all_enemies() + + # Build candidate list + candidates = list(self._enemy_cache.values()) + + # Apply filters + if difficulty: + candidates = [e for e in candidates if e.difficulty == difficulty] + if tag: + candidates = [e for e in candidates if e.has_tag(tag)] + if exclude_bosses: + candidates = [e for e in candidates if not e.is_boss()] + + if not candidates: + logger.warning("No enemies match filters", + difficulty=difficulty.value if difficulty else None, + tag=tag) + return [] + + # Select random enemies (with replacement if needed) + if len(candidates) >= count: + return random.sample(candidates, count) + else: + # Not enough unique enemies, allow duplicates + return random.choices(candidates, k=count) + + def clear_cache(self) -> None: + """Clear the enemy cache, forcing reload on next access.""" + self._enemy_cache.clear() + self._loaded = False + logger.debug("Enemy cache cleared") + + def get_all_cached(self) -> Dict[str, EnemyTemplate]: + """ + Get all cached enemies. + + Returns: + Dictionary of cached enemy templates + """ + if not self._loaded: + self.load_all_enemies() + return self._enemy_cache.copy() + + +# Global instance for convenience +_loader_instance: Optional[EnemyLoader] = None + + +def get_enemy_loader() -> EnemyLoader: + """ + Get the global EnemyLoader instance. + + Returns: + Singleton EnemyLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = EnemyLoader() + return _loader_instance diff --git a/api/tests/test_combat_api.py b/api/tests/test_combat_api.py new file mode 100644 index 0000000..f267153 --- /dev/null +++ b/api/tests/test_combat_api.py @@ -0,0 +1,376 @@ +""" +Integration tests for Combat API endpoints. + +Tests the REST API endpoints for combat functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from flask import Flask +import json + +from app import create_app +from app.api.combat import combat_bp +from app.models.combat import CombatEncounter, Combatant, CombatStatus +from app.models.stats import Stats +from app.models.enemy import EnemyTemplate, EnemyDifficulty +from app.services.combat_service import CombatService, ActionResult, CombatRewards + + +# ============================================================================= +# Test Fixtures +# ============================================================================= + +@pytest.fixture +def app(): + """Create test Flask application.""" + app = create_app('development') + app.config['TESTING'] = True + return app + + +@pytest.fixture +def client(app): + """Create test client.""" + return app.test_client() + + +@pytest.fixture +def sample_stats(): + """Sample stats for testing.""" + return Stats( + strength=12, + dexterity=14, + constitution=10, + intelligence=10, + wisdom=10, + charisma=10, + luck=10 + ) + + +@pytest.fixture +def sample_combatant(sample_stats): + """Sample player combatant.""" + return Combatant( + combatant_id="test_char_001", + name="Test Hero", + is_player=True, + current_hp=50, + max_hp=50, + current_mp=30, + max_mp=30, + stats=sample_stats, + abilities=["basic_attack", "power_strike"], + ) + + +@pytest.fixture +def sample_enemy_combatant(sample_stats): + """Sample enemy combatant.""" + return Combatant( + combatant_id="test_goblin_0", + name="Test Goblin", + is_player=False, + current_hp=25, + max_hp=25, + current_mp=10, + max_mp=10, + stats=sample_stats, + abilities=["basic_attack"], + ) + + +@pytest.fixture +def sample_encounter(sample_combatant, sample_enemy_combatant): + """Sample combat encounter.""" + encounter = CombatEncounter( + encounter_id="test_encounter_001", + combatants=[sample_combatant, sample_enemy_combatant], + turn_order=[sample_combatant.combatant_id, sample_enemy_combatant.combatant_id], + round_number=1, + current_turn_index=0, + status=CombatStatus.ACTIVE, + ) + return encounter + + +# ============================================================================= +# List Enemies Endpoint Tests +# ============================================================================= + +class TestListEnemiesEndpoint: + """Tests for GET /api/v1/combat/enemies endpoint.""" + + def test_list_enemies_success(self, client): + """Test listing all enemy templates.""" + response = client.get('/api/v1/combat/enemies') + + assert response.status_code == 200 + data = response.get_json() + + assert data['status'] == 200 + assert 'result' in data + assert 'enemies' in data['result'] + + enemies = data['result']['enemies'] + assert isinstance(enemies, list) + assert len(enemies) >= 6 # We have 6 sample enemies + + # Verify enemy structure + enemy_ids = [e['enemy_id'] for e in enemies] + assert 'goblin' in enemy_ids + + def test_list_enemies_filter_by_difficulty(self, client): + """Test filtering enemies by difficulty.""" + response = client.get('/api/v1/combat/enemies?difficulty=easy') + + assert response.status_code == 200 + data = response.get_json() + + enemies = data['result']['enemies'] + for enemy in enemies: + assert enemy['difficulty'] == 'easy' + + def test_list_enemies_filter_by_tag(self, client): + """Test filtering enemies by tag.""" + response = client.get('/api/v1/combat/enemies?tag=humanoid') + + assert response.status_code == 200 + data = response.get_json() + + enemies = data['result']['enemies'] + for enemy in enemies: + assert 'humanoid' in [t.lower() for t in enemy['tags']] + + +# ============================================================================= +# Get Enemy Details Endpoint Tests +# ============================================================================= + +class TestGetEnemyEndpoint: + """Tests for GET /api/v1/combat/enemies/ endpoint.""" + + def test_get_enemy_success(self, client): + """Test getting enemy details.""" + response = client.get('/api/v1/combat/enemies/goblin') + + assert response.status_code == 200 + data = response.get_json() + + assert data['status'] == 200 + # Enemy data is returned directly in result (not nested under 'enemy' key) + assert data['result']['enemy_id'] == 'goblin' + assert 'base_stats' in data['result'] + assert 'loot_table' in data['result'] + + def test_get_enemy_not_found(self, client): + """Test getting non-existent enemy.""" + response = client.get('/api/v1/combat/enemies/nonexistent_12345') + + assert response.status_code == 404 + data = response.get_json() + assert data['status'] == 404 + + +# ============================================================================= +# Start Combat Endpoint Tests +# ============================================================================= + +class TestStartCombatEndpoint: + """Tests for POST /api/v1/combat/start endpoint.""" + + def test_start_combat_requires_auth(self, client): + """Test that start combat endpoint requires authentication.""" + response = client.post( + '/api/v1/combat/start', + json={ + 'session_id': 'test_session_001', + 'enemy_ids': ['goblin', 'goblin'] + } + ) + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + def test_start_combat_missing_session_id(self, client): + """Test starting combat without session_id.""" + response = client.post( + '/api/v1/combat/start', + json={'enemy_ids': ['goblin']}, + ) + + assert response.status_code in [400, 401] + + def test_start_combat_missing_enemies(self, client): + """Test starting combat without enemies.""" + response = client.post( + '/api/v1/combat/start', + json={'session_id': 'test_session'}, + ) + + assert response.status_code in [400, 401] + + +# ============================================================================= +# Execute Action Endpoint Tests +# ============================================================================= + +class TestExecuteActionEndpoint: + """Tests for POST /api/v1/combat//action endpoint.""" + + def test_action_requires_auth(self, client): + """Test that action endpoint requires authentication.""" + response = client.post( + '/api/v1/combat/test_session/action', + json={ + 'action_type': 'attack', + 'target_ids': ['enemy_001'] + } + ) + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + def test_action_missing_type(self, client): + """Test action with missing action_type still requires auth.""" + # Without auth, returns 401 regardless of payload issues + response = client.post( + '/api/v1/combat/test_session/action', + json={'target_ids': ['enemy_001']} + ) + + assert response.status_code == 401 + + +# ============================================================================= +# Enemy Turn Endpoint Tests +# ============================================================================= + +class TestEnemyTurnEndpoint: + """Tests for POST /api/v1/combat//enemy-turn endpoint.""" + + def test_enemy_turn_requires_auth(self, client): + """Test that enemy turn endpoint requires authentication.""" + response = client.post('/api/v1/combat/test_session/enemy-turn') + + assert response.status_code == 401 + + +# ============================================================================= +# Flee Endpoint Tests +# ============================================================================= + +class TestFleeEndpoint: + """Tests for POST /api/v1/combat//flee endpoint.""" + + def test_flee_requires_auth(self, client): + """Test that flee endpoint requires authentication.""" + response = client.post('/api/v1/combat/test_session/flee') + + assert response.status_code == 401 + + +# ============================================================================= +# Get Combat State Endpoint Tests +# ============================================================================= + +class TestGetCombatStateEndpoint: + """Tests for GET /api/v1/combat//state endpoint.""" + + def test_state_requires_auth(self, client): + """Test that state endpoint requires authentication.""" + response = client.get('/api/v1/combat/test_session/state') + + assert response.status_code == 401 + + +# ============================================================================= +# End Combat Endpoint Tests +# ============================================================================= + +class TestEndCombatEndpoint: + """Tests for POST /api/v1/combat//end endpoint.""" + + def test_end_requires_auth(self, client): + """Test that end combat endpoint requires authentication.""" + response = client.post('/api/v1/combat/test_session/end') + + assert response.status_code == 401 + + +# ============================================================================= +# Response Format Tests +# ============================================================================= + +class TestCombatAPIResponseFormat: + """Tests for API response format consistency.""" + + def test_enemies_response_format(self, client): + """Test that enemies list has standard response format.""" + response = client.get('/api/v1/combat/enemies') + data = response.get_json() + + # Standard response fields + assert 'app' in data + assert 'version' in data + assert 'status' in data + assert 'timestamp' in data + assert 'result' in data + + # Should not have error for successful request + assert data['error'] is None or 'error' not in data or data['error'] == {} + + def test_enemy_details_response_format(self, client): + """Test that enemy details has standard response format.""" + response = client.get('/api/v1/combat/enemies/goblin') + data = response.get_json() + + assert data['status'] == 200 + assert 'result' in data + + # Enemy data is returned directly in result + enemy = data['result'] + # Required enemy fields + assert 'enemy_id' in enemy + assert 'name' in enemy + assert 'description' in enemy + assert 'base_stats' in enemy + assert 'difficulty' in enemy + + def test_not_found_response_format(self, client): + """Test 404 response format.""" + response = client.get('/api/v1/combat/enemies/nonexistent_enemy_xyz') + data = response.get_json() + + assert data['status'] == 404 + assert 'error' in data + assert data['error'] is not None + + +# ============================================================================= +# Content Type Tests +# ============================================================================= + +class TestCombatAPIContentType: + """Tests for content type handling.""" + + def test_json_content_type_response(self, client): + """Test that API returns JSON content type.""" + response = client.get('/api/v1/combat/enemies') + + assert response.content_type == 'application/json' + + def test_accepts_json_payload(self, client): + """Test that API accepts JSON payloads.""" + response = client.post( + '/api/v1/combat/start', + data=json.dumps({ + 'session_id': 'test', + 'enemy_ids': ['goblin'] + }), + content_type='application/json' + ) + + # Should process JSON (even if auth fails) + assert response.status_code in [200, 400, 401] diff --git a/api/tests/test_combat_service.py b/api/tests/test_combat_service.py new file mode 100644 index 0000000..afd9341 --- /dev/null +++ b/api/tests/test_combat_service.py @@ -0,0 +1,648 @@ +""" +Unit tests for CombatService. + +Tests combat lifecycle, action execution, and reward distribution. +Uses mocked dependencies to isolate combat logic testing. +""" + +import pytest +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 + +from app.models.combat import Combatant, CombatEncounter +from app.models.character import Character +from app.models.stats import Stats +from app.models.enemy import EnemyTemplate, EnemyDifficulty +from app.models.enums import CombatStatus, AbilityType, DamageType +from app.models.abilities import Ability +from app.services.combat_service import ( + CombatService, + CombatAction, + ActionResult, + CombatRewards, + NotInCombatError, + AlreadyInCombatError, + InvalidActionError, +) + + +# ============================================================================= +# Test Fixtures +# ============================================================================= + +@pytest.fixture +def mock_stats(): + """Create mock stats for testing.""" + return Stats( + strength=12, + dexterity=10, + constitution=14, + intelligence=10, + wisdom=10, + charisma=8, + luck=8, + ) + + +@pytest.fixture +def mock_character(mock_stats): + """Create a mock character for testing.""" + char = Mock(spec=Character) + char.character_id = "test_char_001" + char.name = "Test Hero" + char.user_id = "test_user" + char.level = 5 + char.experience = 1000 + char.gold = 100 + char.unlocked_skills = ["power_strike"] + char.get_effective_stats = Mock(return_value=mock_stats) + return char + + +@pytest.fixture +def mock_enemy_template(): + """Create a mock enemy template.""" + return EnemyTemplate( + enemy_id="test_goblin", + name="Test Goblin", + description="A test goblin", + base_stats=Stats( + strength=8, + dexterity=12, + constitution=6, + intelligence=6, + wisdom=6, + charisma=4, + luck=8, + ), + abilities=["basic_attack"], + experience_reward=15, + gold_reward_min=2, + gold_reward_max=8, + difficulty=EnemyDifficulty.EASY, + tags=["humanoid", "goblinoid"], + base_damage=4, + ) + + +@pytest.fixture +def mock_combatant(): + """Create a mock player combatant.""" + return Combatant( + combatant_id="test_char_001", + name="Test Hero", + is_player=True, + current_hp=38, # 10 + 14*2 + max_hp=38, + current_mp=30, # 10 + 10*2 + max_mp=30, + stats=Stats( + strength=12, + dexterity=10, + constitution=14, + intelligence=10, + wisdom=10, + charisma=8, + luck=8, + ), + abilities=["basic_attack", "power_strike"], + ) + + +@pytest.fixture +def mock_enemy_combatant(): + """Create a mock enemy combatant.""" + return Combatant( + combatant_id="test_goblin_0", + name="Test Goblin", + is_player=False, + current_hp=22, # 10 + 6*2 + max_hp=22, + current_mp=22, + max_mp=22, + stats=Stats( + strength=8, + dexterity=12, + constitution=6, + intelligence=6, + wisdom=6, + charisma=4, + luck=8, + ), + abilities=["basic_attack"], + ) + + +@pytest.fixture +def mock_encounter(mock_combatant, mock_enemy_combatant): + """Create a mock combat encounter.""" + encounter = CombatEncounter( + encounter_id="test_encounter_001", + combatants=[mock_combatant, mock_enemy_combatant], + ) + encounter.initialize_combat() + return encounter + + +@pytest.fixture +def mock_session(mock_encounter): + """Create a mock game session.""" + session = Mock() + session.session_id = "test_session_001" + session.solo_character_id = "test_char_001" + session.is_solo = Mock(return_value=True) + session.is_in_combat = Mock(return_value=False) + session.combat_encounter = None + session.start_combat = Mock() + session.end_combat = Mock() + return session + + +# ============================================================================= +# CombatAction Tests +# ============================================================================= + +class TestCombatAction: + """Tests for CombatAction dataclass.""" + + def test_create_attack_action(self): + """Test creating an attack action.""" + action = CombatAction( + action_type="attack", + target_ids=["enemy_1"], + ) + + assert action.action_type == "attack" + assert action.target_ids == ["enemy_1"] + assert action.ability_id is None + + def test_create_ability_action(self): + """Test creating an ability action.""" + action = CombatAction( + action_type="ability", + target_ids=["enemy_1", "enemy_2"], + ability_id="fireball", + ) + + assert action.action_type == "ability" + assert action.ability_id == "fireball" + assert len(action.target_ids) == 2 + + def test_from_dict(self): + """Test creating action from dictionary.""" + data = { + "action_type": "ability", + "target_ids": ["enemy_1"], + "ability_id": "heal", + } + + action = CombatAction.from_dict(data) + + assert action.action_type == "ability" + assert action.ability_id == "heal" + + def test_to_dict(self): + """Test serializing action to dictionary.""" + action = CombatAction( + action_type="defend", + target_ids=[], + ) + + data = action.to_dict() + + assert data["action_type"] == "defend" + assert data["target_ids"] == [] + + +# ============================================================================= +# ActionResult Tests +# ============================================================================= + +class TestActionResult: + """Tests for ActionResult dataclass.""" + + def test_create_success_result(self): + """Test creating a successful action result.""" + result = ActionResult( + success=True, + message="Attack hits for 15 damage!", + ) + + assert result.success is True + assert "15 damage" in result.message + assert result.combat_ended is False + + def test_to_dict(self): + """Test serializing result to dictionary.""" + result = ActionResult( + success=True, + message="Victory!", + combat_ended=True, + combat_status=CombatStatus.VICTORY, + ) + + data = result.to_dict() + + assert data["success"] is True + assert data["combat_ended"] is True + assert data["combat_status"] == "victory" + + +# ============================================================================= +# CombatRewards Tests +# ============================================================================= + +class TestCombatRewards: + """Tests for CombatRewards dataclass.""" + + def test_create_rewards(self): + """Test creating combat rewards.""" + rewards = CombatRewards( + experience=100, + gold=50, + items=[{"item_id": "sword", "quantity": 1}], + level_ups=["char_1"], + ) + + assert rewards.experience == 100 + assert rewards.gold == 50 + assert len(rewards.items) == 1 + + def test_to_dict(self): + """Test serializing rewards to dictionary.""" + rewards = CombatRewards(experience=50, gold=25) + data = rewards.to_dict() + + assert data["experience"] == 50 + assert data["gold"] == 25 + assert data["items"] == [] + + +# ============================================================================= +# Combatant Creation Tests +# ============================================================================= + +class TestCombatantCreation: + """Tests for combatant creation methods.""" + + def test_create_combatant_from_character(self, mock_character): + """Test creating a combatant from a player character.""" + service = CombatService.__new__(CombatService) + + combatant = service._create_combatant_from_character(mock_character) + + assert combatant.combatant_id == mock_character.character_id + assert combatant.name == mock_character.name + assert combatant.is_player is True + assert combatant.current_hp == combatant.max_hp + assert "basic_attack" in combatant.abilities + + def test_create_combatant_from_enemy(self, mock_enemy_template): + """Test creating a combatant from an enemy template.""" + service = CombatService.__new__(CombatService) + + combatant = service._create_combatant_from_enemy(mock_enemy_template, instance_index=0) + + assert combatant.combatant_id == "test_goblin_0" + assert combatant.name == mock_enemy_template.name + assert combatant.is_player is False + assert combatant.current_hp == combatant.max_hp + assert "basic_attack" in combatant.abilities + + def test_create_multiple_enemy_instances(self, mock_enemy_template): + """Test creating multiple instances of same enemy.""" + service = CombatService.__new__(CombatService) + + combatant1 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=0) + combatant2 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=1) + combatant3 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=2) + + # IDs should be unique + assert combatant1.combatant_id != combatant2.combatant_id + assert combatant2.combatant_id != combatant3.combatant_id + + # Names should be numbered + assert "#" in combatant2.name + assert "#" in combatant3.name + + +# ============================================================================= +# Combat Lifecycle Tests +# ============================================================================= + +class TestCombatLifecycle: + """Tests for combat lifecycle methods.""" + + @patch('app.services.combat_service.get_session_service') + @patch('app.services.combat_service.get_character_service') + @patch('app.services.combat_service.get_enemy_loader') + def test_start_combat_success( + self, + mock_get_enemy_loader, + mock_get_char_service, + mock_get_session_service, + mock_session, + mock_character, + mock_enemy_template, + ): + """Test starting combat successfully.""" + # Setup mocks + mock_session_service = Mock() + mock_session_service.get_session.return_value = mock_session + mock_session_service.update_session = Mock() + mock_get_session_service.return_value = mock_session_service + + mock_char_service = Mock() + mock_char_service.get_character.return_value = mock_character + mock_get_char_service.return_value = mock_char_service + + mock_enemy_loader = Mock() + mock_enemy_loader.load_enemy.return_value = mock_enemy_template + mock_get_enemy_loader.return_value = mock_enemy_loader + + # Create service and start combat + service = CombatService() + encounter = service.start_combat( + session_id="test_session", + user_id="test_user", + enemy_ids=["test_goblin"], + ) + + assert encounter is not None + assert encounter.status == CombatStatus.ACTIVE + assert len(encounter.combatants) == 2 # 1 player + 1 enemy + assert len(encounter.turn_order) == 2 + mock_session.start_combat.assert_called_once() + + @patch('app.services.combat_service.get_session_service') + @patch('app.services.combat_service.get_character_service') + @patch('app.services.combat_service.get_enemy_loader') + def test_start_combat_already_in_combat( + self, + mock_get_enemy_loader, + mock_get_char_service, + mock_get_session_service, + mock_session, + ): + """Test starting combat when already in combat.""" + mock_session.is_in_combat.return_value = True + + mock_session_service = Mock() + mock_session_service.get_session.return_value = mock_session + mock_get_session_service.return_value = mock_session_service + + service = CombatService() + + with pytest.raises(AlreadyInCombatError): + service.start_combat( + session_id="test_session", + user_id="test_user", + enemy_ids=["goblin"], + ) + + @patch('app.services.combat_service.get_session_service') + def test_get_combat_state_not_in_combat( + self, + mock_get_session_service, + mock_session, + ): + """Test getting combat state when not in combat.""" + mock_session.combat_encounter = None + + mock_session_service = Mock() + mock_session_service.get_session.return_value = mock_session + mock_get_session_service.return_value = mock_session_service + + service = CombatService() + result = service.get_combat_state("test_session", "test_user") + + assert result is None + + +# ============================================================================= +# Attack Execution Tests +# ============================================================================= + +class TestAttackExecution: + """Tests for attack action execution.""" + + def test_execute_attack_hit(self, mock_encounter, mock_combatant, mock_enemy_combatant): + """Test executing a successful attack.""" + service = CombatService.__new__(CombatService) + service.ability_loader = Mock() + + # Mock attacker as current combatant + mock_encounter.turn_order = [mock_combatant.combatant_id, mock_enemy_combatant.combatant_id] + mock_encounter.current_turn_index = 0 + + result = service._execute_attack( + mock_encounter, + mock_combatant, + [mock_enemy_combatant.combatant_id] + ) + + assert result.success is True + assert len(result.damage_results) == 1 + # Damage should have been dealt (HP should be reduced) + + def test_execute_attack_no_target(self, mock_encounter, mock_combatant): + """Test attack with auto-targeting.""" + service = CombatService.__new__(CombatService) + service.ability_loader = Mock() + + result = service._execute_attack( + mock_encounter, + mock_combatant, + [] # No targets specified + ) + + # Should auto-target and succeed + assert result.success is True + + +# ============================================================================= +# Defend Action Tests +# ============================================================================= + +class TestDefendExecution: + """Tests for defend action execution.""" + + def test_execute_defend(self, mock_encounter, mock_combatant): + """Test executing a defend action.""" + service = CombatService.__new__(CombatService) + + initial_effects = len(mock_combatant.active_effects) + + result = service._execute_defend(mock_encounter, mock_combatant) + + assert result.success is True + assert "defensive stance" in result.message.lower() + assert len(result.effects_applied) == 1 + # Combatant should have a new effect + assert len(mock_combatant.active_effects) == initial_effects + 1 + + +# ============================================================================= +# Flee Action Tests +# ============================================================================= + +class TestFleeExecution: + """Tests for flee action execution.""" + + def test_execute_flee_success(self, mock_encounter, mock_combatant, mock_session): + """Test successful flee attempt.""" + service = CombatService.__new__(CombatService) + + # Force success by patching random + with patch('random.random', return_value=0.1): # Low roll = success + result = service._execute_flee( + mock_encounter, + mock_combatant, + mock_session, + "test_user" + ) + + assert result.success is True + assert result.combat_ended is True + assert result.combat_status == CombatStatus.FLED + + def test_execute_flee_failure(self, mock_encounter, mock_combatant, mock_session): + """Test failed flee attempt.""" + service = CombatService.__new__(CombatService) + + # Force failure by patching random + with patch('random.random', return_value=0.9): # High roll = failure + result = service._execute_flee( + mock_encounter, + mock_combatant, + mock_session, + "test_user" + ) + + assert result.success is False + assert result.combat_ended is False + + +# ============================================================================= +# Enemy AI Tests +# ============================================================================= + +class TestEnemyAI: + """Tests for enemy AI logic.""" + + def test_choose_enemy_action(self, mock_encounter, mock_enemy_combatant): + """Test enemy AI action selection.""" + service = CombatService.__new__(CombatService) + + action_type, targets = service._choose_enemy_action( + mock_encounter, + mock_enemy_combatant + ) + + # Should choose attack or ability + assert action_type in ["attack", "ability"] + # Should target a player + assert len(targets) > 0 + + def test_choose_enemy_targets_lowest_hp(self, mock_encounter, mock_enemy_combatant): + """Test that enemy AI targets lowest HP player.""" + # Add another player with lower HP + low_hp_player = Combatant( + combatant_id="low_hp_player", + name="Wounded Hero", + is_player=True, + current_hp=5, # Very low HP + max_hp=38, + current_mp=30, + max_mp=30, + stats=Stats(), + abilities=["basic_attack"], + ) + mock_encounter.combatants.append(low_hp_player) + + service = CombatService.__new__(CombatService) + + _, targets = service._choose_enemy_action( + mock_encounter, + mock_enemy_combatant + ) + + # Should target the lowest HP player + assert targets[0] == "low_hp_player" + + +# ============================================================================= +# Combat End Condition Tests +# ============================================================================= + +class TestCombatEndConditions: + """Tests for combat end condition checking.""" + + def test_victory_when_all_enemies_dead(self, mock_encounter, mock_combatant, mock_enemy_combatant): + """Test victory is detected when all enemies are dead.""" + # Kill the enemy + mock_enemy_combatant.current_hp = 0 + + status = mock_encounter.check_end_condition() + + assert status == CombatStatus.VICTORY + + def test_defeat_when_all_players_dead(self, mock_encounter, mock_combatant, mock_enemy_combatant): + """Test defeat is detected when all players are dead.""" + # Kill the player + mock_combatant.current_hp = 0 + + status = mock_encounter.check_end_condition() + + assert status == CombatStatus.DEFEAT + + def test_active_when_both_alive(self, mock_encounter, mock_combatant, mock_enemy_combatant): + """Test combat remains active when both sides have survivors.""" + # Both alive + assert mock_combatant.current_hp > 0 + assert mock_enemy_combatant.current_hp > 0 + + status = mock_encounter.check_end_condition() + + assert status == CombatStatus.ACTIVE + + +# ============================================================================= +# Rewards Calculation Tests +# ============================================================================= + +class TestRewardsCalculation: + """Tests for reward distribution.""" + + def test_calculate_rewards_from_enemies(self, mock_encounter, mock_enemy_combatant): + """Test reward calculation from defeated enemies.""" + # Mark enemy as dead + mock_enemy_combatant.current_hp = 0 + + service = CombatService.__new__(CombatService) + service.enemy_loader = Mock() + service.character_service = Mock() + + # Mock enemy template for rewards + mock_template = Mock() + mock_template.experience_reward = 50 + mock_template.get_gold_reward.return_value = 25 + mock_template.roll_loot.return_value = [{"item_id": "sword", "quantity": 1}] + service.enemy_loader.load_enemy.return_value = mock_template + + mock_session = Mock() + mock_session.is_solo.return_value = True + mock_session.solo_character_id = "test_char" + + mock_char = Mock() + mock_char.level = 1 + mock_char.experience = 0 + mock_char.gold = 0 + service.character_service.get_character.return_value = mock_char + service.character_service.update_character = Mock() + + rewards = service._calculate_rewards(mock_encounter, mock_session, "test_user") + + assert rewards.experience == 50 + assert rewards.gold == 25 + assert len(rewards.items) == 1 diff --git a/api/tests/test_damage_calculator.py b/api/tests/test_damage_calculator.py new file mode 100644 index 0000000..0776975 --- /dev/null +++ b/api/tests/test_damage_calculator.py @@ -0,0 +1,677 @@ +""" +Unit tests for the DamageCalculator service. + +Tests cover: +- Hit chance calculations with LUK/DEX +- Critical hit chance calculations +- Damage variance with lucky rolls +- Physical damage formula +- Magical damage formula +- Elemental split damage +- Defense mitigation with minimum guarantee +- AoE damage calculations +""" + +import pytest +import random +from unittest.mock import patch + +from app.models.stats import Stats +from app.models.enums import DamageType +from app.services.damage_calculator import ( + DamageCalculator, + DamageResult, + CombatConstants, +) + + +# ============================================================================= +# Hit Chance Tests +# ============================================================================= + +class TestHitChance: + """Tests for calculate_hit_chance().""" + + def test_base_hit_chance_with_average_stats(self): + """Test hit chance with average LUK (8) and DEX (10).""" + # LUK 8: miss = 10% - 4% = 6% + hit_chance = DamageCalculator.calculate_hit_chance( + attacker_luck=8, + defender_dexterity=10, + ) + assert hit_chance == pytest.approx(0.94, abs=0.001) + + def test_high_luck_reduces_miss_chance(self): + """Test that high LUK reduces miss chance.""" + # LUK 12: miss = 10% - 6% = 4%, but capped at 5% + hit_chance = DamageCalculator.calculate_hit_chance( + attacker_luck=12, + defender_dexterity=10, + ) + assert hit_chance == pytest.approx(0.95, abs=0.001) + + def test_miss_chance_hard_cap_at_five_percent(self): + """Test that miss chance cannot go below 5% (hard cap).""" + # LUK 20: would be 10% - 10% = 0%, but capped at 5% + hit_chance = DamageCalculator.calculate_hit_chance( + attacker_luck=20, + defender_dexterity=10, + ) + assert hit_chance == pytest.approx(0.95, abs=0.001) + + def test_high_dex_increases_evasion(self): + """Test that defender's high DEX increases miss chance.""" + # LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25% + hit_chance = DamageCalculator.calculate_hit_chance( + attacker_luck=8, + defender_dexterity=15, + ) + assert hit_chance == pytest.approx(0.9275, abs=0.001) + + def test_dex_below_ten_has_no_evasion_bonus(self): + """Test that DEX below 10 doesn't reduce attacker's hit chance.""" + # DEX 5 should be same as DEX 10 (no negative evasion) + hit_low_dex = DamageCalculator.calculate_hit_chance( + attacker_luck=8, + defender_dexterity=5, + ) + hit_base_dex = DamageCalculator.calculate_hit_chance( + attacker_luck=8, + defender_dexterity=10, + ) + assert hit_low_dex == hit_base_dex + + def test_skill_bonus_improves_hit_chance(self): + """Test that skill bonus adds to hit chance.""" + base_hit = DamageCalculator.calculate_hit_chance( + attacker_luck=8, + defender_dexterity=10, + ) + skill_hit = DamageCalculator.calculate_hit_chance( + attacker_luck=8, + defender_dexterity=10, + skill_bonus=0.05, # 5% bonus + ) + assert skill_hit > base_hit + + +# ============================================================================= +# Critical Hit Tests +# ============================================================================= + +class TestCritChance: + """Tests for calculate_crit_chance().""" + + def test_base_crit_with_average_luck(self): + """Test crit chance with average LUK (8).""" + # Base 5% + LUK 8 * 0.5% = 5% + 4% = 9% + crit_chance = DamageCalculator.calculate_crit_chance( + attacker_luck=8, + ) + assert crit_chance == pytest.approx(0.09, abs=0.001) + + def test_high_luck_increases_crit(self): + """Test that high LUK increases crit chance.""" + # Base 5% + LUK 12 * 0.5% = 5% + 6% = 11% + crit_chance = DamageCalculator.calculate_crit_chance( + attacker_luck=12, + ) + assert crit_chance == pytest.approx(0.11, abs=0.001) + + def test_weapon_crit_stacks_with_luck(self): + """Test that weapon crit chance stacks with LUK bonus.""" + # Weapon 10% + LUK 12 * 0.5% = 10% + 6% = 16% + crit_chance = DamageCalculator.calculate_crit_chance( + attacker_luck=12, + weapon_crit_chance=0.10, + ) + assert crit_chance == pytest.approx(0.16, abs=0.001) + + def test_crit_chance_hard_cap_at_25_percent(self): + """Test that crit chance is capped at 25%.""" + # Weapon 20% + LUK 20 * 0.5% = 20% + 10% = 30%, but capped at 25% + crit_chance = DamageCalculator.calculate_crit_chance( + attacker_luck=20, + weapon_crit_chance=0.20, + ) + assert crit_chance == pytest.approx(0.25, abs=0.001) + + def test_skill_bonus_adds_to_crit(self): + """Test that skill bonus adds to crit chance.""" + base_crit = DamageCalculator.calculate_crit_chance( + attacker_luck=8, + ) + skill_crit = DamageCalculator.calculate_crit_chance( + attacker_luck=8, + skill_bonus=0.05, + ) + assert skill_crit == base_crit + 0.05 + + +# ============================================================================= +# Damage Variance Tests +# ============================================================================= + +class TestDamageVariance: + """Tests for calculate_variance().""" + + @patch('random.random') + @patch('random.uniform') + def test_normal_variance_roll(self, mock_uniform, mock_random): + """Test normal variance roll (95%-105%).""" + # Not a lucky roll (random returns high value) + mock_random.return_value = 0.99 + mock_uniform.return_value = 1.0 + + variance = DamageCalculator.calculate_variance(attacker_luck=8) + + # Should call uniform with base variance range + mock_uniform.assert_called_with( + CombatConstants.BASE_VARIANCE_MIN, + CombatConstants.BASE_VARIANCE_MAX, + ) + assert variance == 1.0 + + @patch('random.random') + @patch('random.uniform') + def test_lucky_variance_roll(self, mock_uniform, mock_random): + """Test lucky variance roll (100%-110%).""" + # Lucky roll (random returns low value) + mock_random.return_value = 0.01 + mock_uniform.return_value = 1.08 + + variance = DamageCalculator.calculate_variance(attacker_luck=8) + + # Should call uniform with lucky variance range + mock_uniform.assert_called_with( + CombatConstants.LUCKY_VARIANCE_MIN, + CombatConstants.LUCKY_VARIANCE_MAX, + ) + assert variance == 1.08 + + def test_high_luck_increases_lucky_chance(self): + """Test that high LUK increases chance for lucky roll.""" + # LUK 8: lucky chance = 5% + 2% = 7% + # LUK 12: lucky chance = 5% + 3% = 8% + # Run many iterations to verify probability + lucky_count_low = 0 + lucky_count_high = 0 + iterations = 10000 + + random.seed(42) # Reproducible + for _ in range(iterations): + variance = DamageCalculator.calculate_variance(8) + if variance >= 1.0: + lucky_count_low += 1 + + random.seed(42) # Same seed + for _ in range(iterations): + variance = DamageCalculator.calculate_variance(12) + if variance >= 1.0: + lucky_count_high += 1 + + # Higher LUK should have more lucky rolls + # Note: This is a statistical test, might have some variance + # Just verify the high LUK isn't dramatically lower + assert lucky_count_high >= lucky_count_low * 0.9 + + +# ============================================================================= +# Defense Mitigation Tests +# ============================================================================= + +class TestDefenseMitigation: + """Tests for apply_defense().""" + + def test_normal_defense_mitigation(self): + """Test standard defense subtraction.""" + # 20 damage - 5 defense = 15 damage + result = DamageCalculator.apply_defense(raw_damage=20, defense=5) + assert result == 15 + + def test_minimum_damage_guarantee(self): + """Test that minimum 20% damage always goes through.""" + # 20 damage - 18 defense = 2 damage (but min is 20% of 20 = 4) + result = DamageCalculator.apply_defense(raw_damage=20, defense=18) + assert result == 4 + + def test_defense_higher_than_damage(self): + """Test when defense exceeds raw damage.""" + # 10 damage - 100 defense = -90, but min is 20% of 10 = 2 + result = DamageCalculator.apply_defense(raw_damage=10, defense=100) + assert result == 2 + + def test_absolute_minimum_damage_is_one(self): + """Test that absolute minimum damage is 1.""" + # 3 damage - 100 defense = negative, but 20% of 3 = 0.6, so min is 1 + result = DamageCalculator.apply_defense(raw_damage=3, defense=100) + assert result == 1 + + def test_custom_minimum_ratio(self): + """Test custom minimum damage ratio.""" + # 20 damage with 30% minimum = at least 6 damage + result = DamageCalculator.apply_defense( + raw_damage=20, + defense=18, + min_damage_ratio=0.30, + ) + assert result == 6 + + +# ============================================================================= +# Physical Damage Tests +# ============================================================================= + +class TestPhysicalDamage: + """Tests for calculate_physical_damage().""" + + def test_basic_physical_damage_formula(self): + """Test the basic physical damage formula.""" + # Formula: (Weapon + STR * 0.75) * Variance - DEF + attacker = Stats(strength=14, luck=0) # LUK 0 to ensure no miss + defender = Stats(constitution=10, dexterity=10) # DEF = 5 + + # Mock to ensure no miss and no crit, variance = 1.0 + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + result = DamageCalculator.calculate_physical_damage( + attacker_stats=attacker, + defender_stats=defender, + weapon_damage=8, + ) + + # 8 + (14 * 0.75) = 8 + 10.5 = 18.5 -> 18 - 5 = 13 + assert result.total_damage == 13 + assert result.is_miss is False + assert result.is_critical is False + assert result.damage_type == DamageType.PHYSICAL + + def test_physical_damage_miss(self): + """Test that misses deal zero damage.""" + attacker = Stats(strength=14, luck=0) + defender = Stats(dexterity=30) # Very high DEX + + # Force a miss + with patch('random.random', return_value=0.99): + result = DamageCalculator.calculate_physical_damage( + attacker_stats=attacker, + defender_stats=defender, + weapon_damage=8, + ) + + assert result.is_miss is True + assert result.total_damage == 0 + assert "missed" in result.message.lower() + + def test_physical_damage_critical_hit(self): + """Test critical hit doubles damage.""" + attacker = Stats(strength=14, luck=20) # High LUK for crit + defender = Stats(constitution=10, dexterity=10) + + # Force hit and crit + with patch('random.random', side_effect=[0.01, 0.01]): # Hit, then crit + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + result = DamageCalculator.calculate_physical_damage( + attacker_stats=attacker, + defender_stats=defender, + weapon_damage=8, + weapon_crit_multiplier=2.0, + ) + + assert result.is_critical is True + # Base: 8 + 14*0.75 = 18.5 + # Crit applied BEFORE int conversion: 18.5 * 2 = 37 + # After DEF 5: 37 - 5 = 32 + assert result.total_damage == 32 + assert "critical" in result.message.lower() + + +# ============================================================================= +# Magical Damage Tests +# ============================================================================= + +class TestMagicalDamage: + """Tests for calculate_magical_damage().""" + + def test_basic_magical_damage_formula(self): + """Test the basic magical damage formula.""" + # Formula: (Ability + INT * 0.75) * Variance - RES + attacker = Stats(intelligence=15, luck=0) + defender = Stats(wisdom=10, dexterity=10) # RES = 5 + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + result = DamageCalculator.calculate_magical_damage( + attacker_stats=attacker, + defender_stats=defender, + ability_base_power=12, + damage_type=DamageType.FIRE, + ) + + # 12 + (15 * 0.75) = 12 + 11.25 = 23.25 -> 23 - 5 = 18 + assert result.total_damage == 18 + assert result.damage_type == DamageType.FIRE + assert result.is_miss is False + + def test_spells_can_critically_hit(self): + """Test that spells can crit (per user requirement).""" + attacker = Stats(intelligence=15, luck=20) + defender = Stats(wisdom=10, dexterity=10) + + # Force hit and crit + with patch('random.random', side_effect=[0.01, 0.01]): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + result = DamageCalculator.calculate_magical_damage( + attacker_stats=attacker, + defender_stats=defender, + ability_base_power=12, + damage_type=DamageType.FIRE, + weapon_crit_multiplier=2.0, + ) + + assert result.is_critical is True + # Base: 12 + 15*0.75 = 23.25 -> 23 + # Crit: 23 * 2 = 46 + # After RES 5: 46 - 5 = 41 + assert result.total_damage == 41 + + def test_magical_damage_with_different_types(self): + """Test that different damage types are recorded correctly.""" + attacker = Stats(intelligence=10) + defender = Stats(wisdom=10, dexterity=10) + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + for damage_type in [DamageType.ICE, DamageType.LIGHTNING, DamageType.HOLY]: + result = DamageCalculator.calculate_magical_damage( + attacker_stats=attacker, + defender_stats=defender, + ability_base_power=10, + damage_type=damage_type, + ) + assert result.damage_type == damage_type + + +# ============================================================================= +# Elemental Weapon (Split Damage) Tests +# ============================================================================= + +class TestElementalWeaponDamage: + """Tests for calculate_elemental_weapon_damage().""" + + def test_split_damage_calculation(self): + """Test 70/30 physical/fire split damage.""" + # Fire Sword: 70% physical, 30% fire + attacker = Stats(strength=14, intelligence=8, luck=0) + defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5 + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + result = DamageCalculator.calculate_elemental_weapon_damage( + attacker_stats=attacker, + defender_stats=defender, + weapon_damage=15, + weapon_crit_chance=0.05, + weapon_crit_multiplier=2.0, + physical_ratio=0.7, + elemental_ratio=0.3, + elemental_type=DamageType.FIRE, + ) + + # Physical: (15 * 0.7) + (14 * 0.75 * 0.7) - 5 = 10.5 + 7.35 - 5 = 12.85 -> 12 + # Elemental: (15 * 0.3) + (8 * 0.75 * 0.3) - 5 = 4.5 + 1.8 - 5 = 1.3 -> 1 + # Total: 12 + 1 = 13 (approximately, depends on min damage) + + assert result.physical_damage > 0 + assert result.elemental_damage >= 1 # At least minimum damage + assert result.total_damage == result.physical_damage + result.elemental_damage + assert result.elemental_type == DamageType.FIRE + + def test_50_50_split_damage(self): + """Test 50/50 physical/elemental split (Lightning Spear).""" + attacker = Stats(strength=12, intelligence=12, luck=0) + defender = Stats(constitution=10, wisdom=10, dexterity=10) + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + result = DamageCalculator.calculate_elemental_weapon_damage( + attacker_stats=attacker, + defender_stats=defender, + weapon_damage=20, + weapon_crit_chance=0.05, + weapon_crit_multiplier=2.0, + physical_ratio=0.5, + elemental_ratio=0.5, + elemental_type=DamageType.LIGHTNING, + ) + + # Both components should be similar (same stat values) + assert abs(result.physical_damage - result.elemental_damage) <= 2 + + def test_elemental_crit_applies_to_both_components(self): + """Test that crit multiplier applies to both damage types.""" + attacker = Stats(strength=14, intelligence=8, luck=20) + defender = Stats(constitution=10, wisdom=10, dexterity=10) + + # Force hit and crit + with patch('random.random', side_effect=[0.01, 0.01]): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + result = DamageCalculator.calculate_elemental_weapon_damage( + attacker_stats=attacker, + defender_stats=defender, + weapon_damage=15, + weapon_crit_chance=0.05, + weapon_crit_multiplier=2.0, + physical_ratio=0.7, + elemental_ratio=0.3, + elemental_type=DamageType.FIRE, + ) + + assert result.is_critical is True + # Both components should be doubled + + +# ============================================================================= +# AoE Damage Tests +# ============================================================================= + +class TestAoEDamage: + """Tests for calculate_aoe_damage().""" + + def test_aoe_full_damage_to_all_targets(self): + """Test that AoE deals full damage to each target.""" + attacker = Stats(intelligence=15, luck=0) + defenders = [ + Stats(wisdom=10, dexterity=10), + Stats(wisdom=10, dexterity=10), + Stats(wisdom=10, dexterity=10), + ] + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + results = DamageCalculator.calculate_aoe_damage( + attacker_stats=attacker, + defender_stats_list=defenders, + ability_base_power=20, + damage_type=DamageType.FIRE, + ) + + assert len(results) == 3 + # All targets should take the same damage (same stats) + for result in results: + assert result.total_damage == results[0].total_damage + + def test_aoe_independent_hit_checks(self): + """Test that each target has independent hit/miss rolls.""" + attacker = Stats(intelligence=15, luck=8) + defenders = [ + Stats(wisdom=10, dexterity=10), + Stats(wisdom=10, dexterity=10), + ] + + # First target hit, second target miss + hit_sequence = [0.5, 0.99] # Below hit chance, above hit chance + with patch('random.random', side_effect=hit_sequence * 2): # Extra for crit checks + results = DamageCalculator.calculate_aoe_damage( + attacker_stats=attacker, + defender_stats_list=defenders, + ability_base_power=20, + damage_type=DamageType.FIRE, + ) + + # At least verify we got results for both + assert len(results) == 2 + + def test_aoe_with_varying_resistance(self): + """Test that AoE respects different resistances per target.""" + attacker = Stats(intelligence=15, luck=0) + defenders = [ + Stats(wisdom=10, dexterity=10), # RES = 5 + Stats(wisdom=20, dexterity=10), # RES = 10 + ] + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + results = DamageCalculator.calculate_aoe_damage( + attacker_stats=attacker, + defender_stats_list=defenders, + ability_base_power=20, + damage_type=DamageType.FIRE, + ) + + # First target (lower RES) should take more damage + assert results[0].total_damage > results[1].total_damage + + +# ============================================================================= +# DamageResult Tests +# ============================================================================= + +class TestDamageResult: + """Tests for DamageResult dataclass.""" + + def test_damage_result_to_dict(self): + """Test serialization of DamageResult.""" + result = DamageResult( + total_damage=25, + physical_damage=25, + elemental_damage=0, + damage_type=DamageType.PHYSICAL, + is_critical=True, + is_miss=False, + variance_roll=1.05, + raw_damage=30, + message="Dealt 25 physical damage. CRITICAL HIT!", + ) + + data = result.to_dict() + + assert data["total_damage"] == 25 + assert data["physical_damage"] == 25 + assert data["damage_type"] == "physical" + assert data["is_critical"] is True + assert data["is_miss"] is False + assert data["variance_roll"] == pytest.approx(1.05, abs=0.001) + + +# ============================================================================= +# Combat Constants Tests +# ============================================================================= + +class TestCombatConstants: + """Tests for CombatConstants configuration.""" + + def test_stat_scaling_factor(self): + """Verify scaling factor is 0.75.""" + assert CombatConstants.STAT_SCALING_FACTOR == 0.75 + + def test_miss_chance_hard_cap(self): + """Verify miss chance hard cap is 5%.""" + assert CombatConstants.MIN_MISS_CHANCE == 0.05 + + def test_crit_chance_cap(self): + """Verify crit chance cap is 25%.""" + assert CombatConstants.MAX_CRIT_CHANCE == 0.25 + + def test_minimum_damage_ratio(self): + """Verify minimum damage ratio is 20%.""" + assert CombatConstants.MIN_DAMAGE_RATIO == 0.20 + + +# ============================================================================= +# Integration Tests (Full Combat Flow) +# ============================================================================= + +class TestCombatIntegration: + """Integration tests for complete combat scenarios.""" + + def test_vanguard_attack_scenario(self): + """Test Vanguard (STR 14) basic attack.""" + # Vanguard: STR 14, LUK 8 + vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8) + goblin = Stats(constitution=10, dexterity=10) # DEF = 5 + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + result = DamageCalculator.calculate_physical_damage( + attacker_stats=vanguard, + defender_stats=goblin, + weapon_damage=8, # Rusty sword + ) + + # 8 + (14 * 0.75) = 18.5 -> 18 - 5 = 13 + assert result.total_damage == 13 + + def test_arcanist_fireball_scenario(self): + """Test Arcanist (INT 15) Fireball.""" + # Arcanist: INT 15, LUK 9 + arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9) + goblin = Stats(wisdom=10, dexterity=10) # RES = 5 + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + result = DamageCalculator.calculate_magical_damage( + attacker_stats=arcanist, + defender_stats=goblin, + ability_base_power=12, # Fireball base + damage_type=DamageType.FIRE, + ) + + # 12 + (15 * 0.75) = 23.25 -> 23 - 5 = 18 + assert result.total_damage == 18 + + def test_physical_vs_magical_balance(self): + """Test that physical and magical damage are comparable.""" + # Same-tier characters should deal similar damage + vanguard = Stats(strength=14, luck=8) # Melee + arcanist = Stats(intelligence=15, luck=9) # Caster + target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5 + + with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0): + with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0): + phys_result = DamageCalculator.calculate_physical_damage( + attacker_stats=vanguard, + defender_stats=target, + weapon_damage=8, + ) + magic_result = DamageCalculator.calculate_magical_damage( + attacker_stats=arcanist, + defender_stats=target, + ability_base_power=12, + damage_type=DamageType.FIRE, + ) + + # Mage should deal slightly more (compensates for mana cost) + assert magic_result.total_damage >= phys_result.total_damage + # But not drastically more (within ~50%) + assert magic_result.total_damage <= phys_result.total_damage * 1.5 diff --git a/api/tests/test_enemy_loader.py b/api/tests/test_enemy_loader.py new file mode 100644 index 0000000..e0782c4 --- /dev/null +++ b/api/tests/test_enemy_loader.py @@ -0,0 +1,399 @@ +""" +Unit tests for EnemyTemplate model and EnemyLoader service. + +Tests enemy loading, serialization, and filtering functionality. +""" + +import pytest +from pathlib import Path + +from app.models.enemy import EnemyTemplate, EnemyDifficulty, LootEntry +from app.models.stats import Stats +from app.services.enemy_loader import EnemyLoader + + +# ============================================================================= +# EnemyTemplate Model Tests +# ============================================================================= + +class TestEnemyTemplate: + """Tests for EnemyTemplate dataclass.""" + + def test_create_basic_enemy(self): + """Test creating an enemy with minimal attributes.""" + enemy = EnemyTemplate( + enemy_id="test_enemy", + name="Test Enemy", + description="A test enemy", + base_stats=Stats(strength=10, constitution=8), + ) + + assert enemy.enemy_id == "test_enemy" + assert enemy.name == "Test Enemy" + assert enemy.base_stats.strength == 10 + assert enemy.difficulty == EnemyDifficulty.EASY # Default + + def test_enemy_with_full_attributes(self): + """Test creating an enemy with all attributes.""" + loot = [ + LootEntry(item_id="sword", drop_chance=0.5), + LootEntry(item_id="gold", drop_chance=1.0, quantity_min=5, quantity_max=10), + ] + + enemy = EnemyTemplate( + enemy_id="goblin_boss", + name="Goblin Boss", + description="A fearsome goblin leader", + base_stats=Stats(strength=14, dexterity=12, constitution=12), + abilities=["basic_attack", "power_strike"], + loot_table=loot, + experience_reward=100, + gold_reward_min=20, + gold_reward_max=50, + difficulty=EnemyDifficulty.HARD, + tags=["humanoid", "goblinoid", "boss"], + base_damage=12, + crit_chance=0.15, + flee_chance=0.25, + ) + + assert enemy.enemy_id == "goblin_boss" + assert enemy.experience_reward == 100 + assert enemy.difficulty == EnemyDifficulty.HARD + assert len(enemy.loot_table) == 2 + assert len(enemy.abilities) == 2 + assert "boss" in enemy.tags + + def test_is_boss(self): + """Test boss detection.""" + easy_enemy = EnemyTemplate( + enemy_id="minion", + name="Minion", + description="", + base_stats=Stats(), + difficulty=EnemyDifficulty.EASY, + ) + boss_enemy = EnemyTemplate( + enemy_id="boss", + name="Boss", + description="", + base_stats=Stats(), + difficulty=EnemyDifficulty.BOSS, + ) + + assert not easy_enemy.is_boss() + assert boss_enemy.is_boss() + + def test_has_tag(self): + """Test tag checking.""" + enemy = EnemyTemplate( + enemy_id="zombie", + name="Zombie", + description="", + base_stats=Stats(), + tags=["undead", "slow", "Humanoid"], # Mixed case + ) + + assert enemy.has_tag("undead") + assert enemy.has_tag("UNDEAD") # Case insensitive + assert enemy.has_tag("humanoid") + assert not enemy.has_tag("beast") + + def test_get_gold_reward(self): + """Test gold reward generation.""" + enemy = EnemyTemplate( + enemy_id="test", + name="Test", + description="", + base_stats=Stats(), + gold_reward_min=10, + gold_reward_max=20, + ) + + # Run multiple times to check range + for _ in range(50): + gold = enemy.get_gold_reward() + assert 10 <= gold <= 20 + + def test_roll_loot_empty_table(self): + """Test loot rolling with empty table.""" + enemy = EnemyTemplate( + enemy_id="test", + name="Test", + description="", + base_stats=Stats(), + loot_table=[], + ) + + drops = enemy.roll_loot() + assert drops == [] + + def test_roll_loot_guaranteed_drop(self): + """Test loot rolling with guaranteed drop.""" + enemy = EnemyTemplate( + enemy_id="test", + name="Test", + description="", + base_stats=Stats(), + loot_table=[ + LootEntry(item_id="guaranteed_item", drop_chance=1.0), + ], + ) + + drops = enemy.roll_loot() + assert len(drops) == 1 + assert drops[0]["item_id"] == "guaranteed_item" + + def test_serialization_round_trip(self): + """Test that to_dict/from_dict preserves data.""" + original = EnemyTemplate( + enemy_id="test_enemy", + name="Test Enemy", + description="A test description", + base_stats=Stats(strength=15, dexterity=12, luck=10), + abilities=["attack", "defend"], + loot_table=[ + LootEntry(item_id="sword", drop_chance=0.5), + ], + experience_reward=50, + gold_reward_min=10, + gold_reward_max=25, + difficulty=EnemyDifficulty.MEDIUM, + tags=["humanoid", "test"], + base_damage=8, + crit_chance=0.10, + flee_chance=0.40, + ) + + # Serialize and deserialize + data = original.to_dict() + restored = EnemyTemplate.from_dict(data) + + # Verify all fields match + assert restored.enemy_id == original.enemy_id + assert restored.name == original.name + assert restored.description == original.description + assert restored.base_stats.strength == original.base_stats.strength + assert restored.base_stats.luck == original.base_stats.luck + assert restored.abilities == original.abilities + assert len(restored.loot_table) == len(original.loot_table) + assert restored.experience_reward == original.experience_reward + assert restored.gold_reward_min == original.gold_reward_min + assert restored.gold_reward_max == original.gold_reward_max + assert restored.difficulty == original.difficulty + assert restored.tags == original.tags + assert restored.base_damage == original.base_damage + assert restored.crit_chance == pytest.approx(original.crit_chance) + assert restored.flee_chance == pytest.approx(original.flee_chance) + + +class TestLootEntry: + """Tests for LootEntry dataclass.""" + + def test_create_loot_entry(self): + """Test creating a loot entry.""" + entry = LootEntry( + item_id="gold_coin", + drop_chance=0.75, + quantity_min=5, + quantity_max=15, + ) + + assert entry.item_id == "gold_coin" + assert entry.drop_chance == 0.75 + assert entry.quantity_min == 5 + assert entry.quantity_max == 15 + + def test_loot_entry_defaults(self): + """Test loot entry default values.""" + entry = LootEntry(item_id="item") + + assert entry.drop_chance == 0.1 + assert entry.quantity_min == 1 + assert entry.quantity_max == 1 + + +# ============================================================================= +# EnemyLoader Service Tests +# ============================================================================= + +class TestEnemyLoader: + """Tests for EnemyLoader service.""" + + @pytest.fixture + def loader(self): + """Create an enemy loader with the actual data directory.""" + return EnemyLoader() + + def test_load_goblin(self, loader): + """Test loading the goblin enemy.""" + enemy = loader.load_enemy("goblin") + + assert enemy is not None + assert enemy.enemy_id == "goblin" + assert enemy.name == "Goblin Scout" + assert enemy.difficulty == EnemyDifficulty.EASY + assert "humanoid" in enemy.tags + assert "goblinoid" in enemy.tags + + def test_load_goblin_shaman(self, loader): + """Test loading the goblin shaman.""" + enemy = loader.load_enemy("goblin_shaman") + + assert enemy is not None + assert enemy.enemy_id == "goblin_shaman" + assert enemy.base_stats.intelligence == 12 # Caster stats + assert "caster" in enemy.tags + + def test_load_dire_wolf(self, loader): + """Test loading the dire wolf.""" + enemy = loader.load_enemy("dire_wolf") + + assert enemy is not None + assert enemy.difficulty == EnemyDifficulty.MEDIUM + assert "beast" in enemy.tags + assert enemy.base_stats.strength == 14 + + def test_load_bandit(self, loader): + """Test loading the bandit.""" + enemy = loader.load_enemy("bandit") + + assert enemy is not None + assert enemy.difficulty == EnemyDifficulty.MEDIUM + assert "rogue" in enemy.tags + assert enemy.crit_chance == 0.12 + + def test_load_skeleton_warrior(self, loader): + """Test loading the skeleton warrior.""" + enemy = loader.load_enemy("skeleton_warrior") + + assert enemy is not None + assert "undead" in enemy.tags + assert "fearless" in enemy.tags + + def test_load_orc_berserker(self, loader): + """Test loading the orc berserker.""" + enemy = loader.load_enemy("orc_berserker") + + assert enemy is not None + assert enemy.difficulty == EnemyDifficulty.HARD + assert enemy.base_stats.strength == 18 + assert enemy.base_damage == 15 + + def test_load_nonexistent_enemy(self, loader): + """Test loading an enemy that doesn't exist.""" + enemy = loader.load_enemy("nonexistent_enemy_12345") + + assert enemy is None + + def test_load_all_enemies(self, loader): + """Test loading all enemies.""" + enemies = loader.load_all_enemies() + + # Should have at least our 6 sample enemies + assert len(enemies) >= 6 + assert "goblin" in enemies + assert "goblin_shaman" in enemies + assert "dire_wolf" in enemies + assert "bandit" in enemies + assert "skeleton_warrior" in enemies + assert "orc_berserker" in enemies + + def test_get_enemies_by_difficulty(self, loader): + """Test filtering enemies by difficulty.""" + loader.load_all_enemies() # Ensure loaded + + easy_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.EASY) + medium_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.MEDIUM) + hard_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.HARD) + + # Check we got enemies in each category + assert len(easy_enemies) >= 2 # goblin, goblin_shaman + assert len(medium_enemies) >= 3 # dire_wolf, bandit, skeleton_warrior + assert len(hard_enemies) >= 1 # orc_berserker + + # Verify difficulty is correct + for enemy in easy_enemies: + assert enemy.difficulty == EnemyDifficulty.EASY + + def test_get_enemies_by_tag(self, loader): + """Test filtering enemies by tag.""" + loader.load_all_enemies() + + humanoids = loader.get_enemies_by_tag("humanoid") + undead = loader.get_enemies_by_tag("undead") + beasts = loader.get_enemies_by_tag("beast") + + # Verify results + assert len(humanoids) >= 3 # goblin, goblin_shaman, bandit, orc + assert len(undead) >= 1 # skeleton_warrior + assert len(beasts) >= 1 # dire_wolf + + # Verify tags + for enemy in humanoids: + assert enemy.has_tag("humanoid") + + def test_get_random_enemies(self, loader): + """Test random enemy selection.""" + loader.load_all_enemies() + + # Get 3 random enemies + random_enemies = loader.get_random_enemies(count=3) + + assert len(random_enemies) == 3 + # All should be EnemyTemplate instances + for enemy in random_enemies: + assert isinstance(enemy, EnemyTemplate) + + def test_get_random_enemies_with_filters(self, loader): + """Test random selection with difficulty filter.""" + loader.load_all_enemies() + + # Get only easy enemies + easy_enemies = loader.get_random_enemies( + count=5, + difficulty=EnemyDifficulty.EASY, + ) + + # All returned enemies should be easy + for enemy in easy_enemies: + assert enemy.difficulty == EnemyDifficulty.EASY + + def test_cache_behavior(self, loader): + """Test that caching works correctly.""" + # Load an enemy twice + enemy1 = loader.load_enemy("goblin") + enemy2 = loader.load_enemy("goblin") + + # Should be the same object (cached) + assert enemy1 is enemy2 + + # Clear cache + loader.clear_cache() + + # Load again + enemy3 = loader.load_enemy("goblin") + + # Should be a new object + assert enemy3 is not enemy1 + assert enemy3.enemy_id == enemy1.enemy_id + + +# ============================================================================= +# EnemyDifficulty Enum Tests +# ============================================================================= + +class TestEnemyDifficulty: + """Tests for EnemyDifficulty enum.""" + + def test_difficulty_values(self): + """Test difficulty enum values.""" + assert EnemyDifficulty.EASY.value == "easy" + assert EnemyDifficulty.MEDIUM.value == "medium" + assert EnemyDifficulty.HARD.value == "hard" + assert EnemyDifficulty.BOSS.value == "boss" + + def test_difficulty_from_string(self): + """Test creating difficulty from string.""" + assert EnemyDifficulty("easy") == EnemyDifficulty.EASY + assert EnemyDifficulty("hard") == EnemyDifficulty.HARD diff --git a/api/tests/test_session_service.py b/api/tests/test_session_service.py index c1eb306..ec71612 100644 --- a/api/tests/test_session_service.py +++ b/api/tests/test_session_service.py @@ -18,8 +18,10 @@ from app.services.session_service import ( SessionNotFound, SessionLimitExceeded, SessionValidationError, - MAX_ACTIVE_SESSIONS, ) + +# Session limits are now tier-based, using a test default +MAX_ACTIVE_SESSIONS_TEST = 3 from app.models.session import GameSession, GameState, ConversationEntry from app.models.enums import SessionStatus, SessionType, LocationType from app.models.character import Character @@ -116,7 +118,7 @@ class TestSessionServiceCreation: def test_create_solo_session_limit_exceeded(self, mock_db, mock_appwrite, mock_character_service, sample_character): """Test session creation fails when limit exceeded.""" mock_character_service.get_character.return_value = sample_character - mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS + mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS_TEST service = SessionService() with pytest.raises(SessionLimitExceeded): diff --git a/api/tests/test_stats.py b/api/tests/test_stats.py index 0af61d6..082f45d 100644 --- a/api/tests/test_stats.py +++ b/api/tests/test_stats.py @@ -196,3 +196,55 @@ def test_stats_repr(): assert "INT=10" in repr_str assert "HP=" in repr_str assert "MP=" in repr_str + + +# ============================================================================= +# LUK Computed Properties (Combat System Integration) +# ============================================================================= + +def test_crit_bonus_calculation(): + """Test crit bonus calculation: luck * 0.5%.""" + stats = Stats(luck=8) + assert stats.crit_bonus == pytest.approx(0.04, abs=0.001) # 4% + + stats = Stats(luck=12) + assert stats.crit_bonus == pytest.approx(0.06, abs=0.001) # 6% + + stats = Stats(luck=0) + assert stats.crit_bonus == pytest.approx(0.0, abs=0.001) # 0% + + +def test_hit_bonus_calculation(): + """Test hit bonus (miss reduction): luck * 0.5%.""" + stats = Stats(luck=8) + assert stats.hit_bonus == pytest.approx(0.04, abs=0.001) # 4% + + stats = Stats(luck=12) + assert stats.hit_bonus == pytest.approx(0.06, abs=0.001) # 6% + + stats = Stats(luck=20) + assert stats.hit_bonus == pytest.approx(0.10, abs=0.001) # 10% + + +def test_lucky_roll_chance_calculation(): + """Test lucky roll chance: 5% + (luck * 0.25%).""" + stats = Stats(luck=8) + # 5% + (8 * 0.25%) = 5% + 2% = 7% + assert stats.lucky_roll_chance == pytest.approx(0.07, abs=0.001) + + stats = Stats(luck=12) + # 5% + (12 * 0.25%) = 5% + 3% = 8% + assert stats.lucky_roll_chance == pytest.approx(0.08, abs=0.001) + + stats = Stats(luck=0) + # 5% + (0 * 0.25%) = 5% + assert stats.lucky_roll_chance == pytest.approx(0.05, abs=0.001) + + +def test_repr_includes_combat_bonuses(): + """Test that repr includes LUK-based combat bonuses.""" + stats = Stats(luck=10) + repr_str = repr(stats) + + assert "CRIT_BONUS=" in repr_str + assert "HIT_BONUS=" in repr_str diff --git a/docs/PHASE4_COMBAT_IMPLEMENTATION.md b/docs/PHASE4_COMBAT_IMPLEMENTATION.md new file mode 100644 index 0000000..6071b18 --- /dev/null +++ b/docs/PHASE4_COMBAT_IMPLEMENTATION.md @@ -0,0 +1,3164 @@ +# Phase 4: Combat & Progression Systems - Implementation Plan + +**Status:** In Progress - Week 1 Complete +**Timeline:** 4-5 weeks +**Last Updated:** November 26, 2025 +**Document Version:** 1.1 + +--- + +## Completion Summary + +### Week 1: Combat Backend - COMPLETE + +| Task | Description | Status | Tests | +|------|-------------|--------|-------| +| 1.1 | Verify Combat Data Models | ✅ Complete | - | +| 1.2 | Implement Combat Service | ✅ Complete | 25 tests | +| 1.3 | Implement Damage Calculator | ✅ Complete | 39 tests | +| 1.4 | Implement Effect Processor | ✅ Complete | - | +| 1.5 | Implement Combat Actions | ✅ Complete | - | +| 1.6 | Combat API Endpoints | ✅ Complete | 19 tests | +| 1.7 | Manual API Testing | ⏭️ Skipped | - | + +**Files Created:** +- `/api/app/models/enemy.py` - EnemyTemplate, LootEntry dataclasses +- `/api/app/services/enemy_loader.py` - YAML-based enemy loading +- `/api/app/services/combat_service.py` - Combat orchestration service +- `/api/app/services/damage_calculator.py` - Damage formula calculations +- `/api/app/api/combat.py` - REST API endpoints +- `/api/app/data/enemies/*.yaml` - 6 sample enemy definitions +- `/api/tests/test_damage_calculator.py` - 39 tests +- `/api/tests/test_enemy_loader.py` - 25 tests +- `/api/tests/test_combat_service.py` - 25 tests +- `/api/tests/test_combat_api.py` - 19 tests + +**Total Tests:** 108 passing + +--- + +## Overview + +This phase implements the core combat and progression systems for Code of Conquest, enabling turn-based tactical combat, inventory management, equipment, skill trees, and the NPC shop. This is a prerequisite for the story progression and quest systems. + +**Key Deliverables:** +- Turn-based combat system (API + UI) +- Inventory & equipment management +- Skill tree visualization and unlocking +- XP and leveling system +- NPC shop + +--- + +## Phase Structure + +| Sub-Phase | Duration | Focus | +|-----------|----------|-------| +| **Phase 4A** | 2-3 weeks | Combat Foundation | +| **Phase 4B** | 1-2 weeks | Skill Trees & Leveling | +| **Phase 4C** | 3-4 days | NPC Shop | + +**Total Estimated Time:** 4-5 weeks (~140-175 hours) + +--- + +## Phase 4A: Combat Foundation (Weeks 1-3) + +### Week 1: Combat Backend & Data Models ✅ COMPLETE + +#### Task 1.1: Verify Combat Data Models (2 hours) ✅ COMPLETE + +**Objective:** Ensure all combat-related dataclasses are complete and correct + +**Files to Review:** +- `/api/app/models/combat.py` - Combatant, CombatEncounter +- `/api/app/models/effects.py` - Effect, all effect types +- `/api/app/models/abilities.py` - Ability, AbilityLoader +- `/api/app/models/stats.py` - Stats with computed properties + +**Verification Checklist:** +- [x] `Combatant` dataclass has all required fields + - `combatant_id`, `name`, `stats`, `current_hp`, `current_mp` + - `active_effects`, `cooldowns`, `is_player` +- [x] `CombatEncounter` dataclass complete + - `encounter_id`, `combatants`, `turn_order`, `current_turn_index` + - `combat_log`, `round_number`, `status` +- [x] Effect types implemented: BUFF, DEBUFF, DOT, HOT, STUN, SHIELD +- [x] Effect stacking logic correct (max_stacks, duration refresh) +- [x] Ability loading from YAML works +- [x] All dataclasses have `to_dict()` and `from_dict()` methods + +**Acceptance Criteria:** ✅ MET +- All combat models serialize/deserialize correctly +- Unit tests pass for combat models +- No missing fields or methods + +--- + +#### Task 1.2: Implement Combat Service (1 day / 8 hours) ✅ COMPLETE + +**Objective:** Create service layer for combat management + +**File:** `/api/app/services/combat_service.py` + +**Implementation:** + +```python +""" +Combat Service + +Manages combat encounters, turn order, and combat state. +""" + +from typing import Optional, List, Dict, Any +from dataclasses import asdict +import uuid + +from app.models.combat import Combatant, CombatEncounter, CombatStatus +from app.models.character import Character +from app.models.effects import Effect +from app.models.abilities import Ability, AbilityLoader +from app.services.appwrite_service import AppwriteService +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class CombatNotFound(Exception): + """Raised when combat encounter is not found.""" + pass + + +class InvalidCombatAction(Exception): + """Raised when combat action is invalid.""" + pass + + +class CombatService: + """Service for managing combat encounters.""" + + def __init__(self, appwrite_service: AppwriteService): + self.appwrite = appwrite_service + self.ability_loader = AbilityLoader() + self.collection_id = "combat_encounters" + + def initiate_combat( + self, + session_id: str, + character: Character, + enemies: List[Dict[str, Any]] + ) -> CombatEncounter: + """ + Initiate a new combat encounter. + + Args: + session_id: Game session ID + character: Player character + enemies: List of enemy data dicts + + Returns: + CombatEncounter instance + """ + combat_id = str(uuid.uuid4()) + + # Create player combatant + player_combatant = Combatant.from_character(character) + + # Create enemy combatants + enemy_combatants = [] + for i, enemy_data in enumerate(enemies): + enemy_combatant = Combatant.from_enemy_data( + enemy_id=f"{combat_id}_enemy_{i}", + enemy_data=enemy_data + ) + enemy_combatants.append(enemy_combatant) + + # Combine all combatants + all_combatants = [player_combatant] + enemy_combatants + + # Roll initiative and create turn order + turn_order = self._roll_initiative(all_combatants) + + # Create combat encounter + encounter = CombatEncounter( + combat_id=combat_id, + session_id=session_id, + combatants=all_combatants, + turn_order=turn_order, + current_turn_index=0, + combat_log=[], + round_number=1, + status=CombatStatus.IN_PROGRESS + ) + + # Save to database + self._save_encounter(encounter) + + logger.info(f"Combat initiated: {combat_id}", extra={ + "combat_id": combat_id, + "session_id": session_id, + "num_enemies": len(enemies) + }) + + return encounter + + def _roll_initiative(self, combatants: List[Combatant]) -> List[str]: + """ + Roll initiative for all combatants and return turn order. + + Args: + combatants: List of combatants + + Returns: + List of combatant IDs in turn order (highest initiative first) + """ + import random + + initiative_rolls = [] + for combatant in combatants: + roll = random.randint(1, 20) + combatant.stats.speed + initiative_rolls.append((combatant.combatant_id, roll)) + + # Sort by initiative (highest first) + initiative_rolls.sort(key=lambda x: x[1], reverse=True) + + return [combatant_id for combatant_id, _ in initiative_rolls] + + def get_encounter(self, combat_id: str) -> CombatEncounter: + """ + Load combat encounter from database. + + Args: + combat_id: Combat encounter ID + + Returns: + CombatEncounter instance + + Raises: + CombatNotFound: If combat not found + """ + try: + doc = self.appwrite.get_document(self.collection_id, combat_id) + return CombatEncounter.from_dict(doc) + except Exception as e: + raise CombatNotFound(f"Combat {combat_id} not found") from e + + def process_action( + self, + combat_id: str, + action_type: str, + ability_id: Optional[str] = None, + target_id: Optional[str] = None, + item_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Process a combat action. + + Args: + combat_id: Combat encounter ID + action_type: "attack", "spell", "item", "defend" + ability_id: Ability ID (for attack/spell) + target_id: Target combatant ID + item_id: Item ID (for item use) + + Returns: + Action result dict with damage, effects, etc. + + Raises: + InvalidCombatAction: If action is invalid + """ + encounter = self.get_encounter(combat_id) + + # Get current combatant + current_combatant_id = encounter.turn_order[encounter.current_turn_index] + current_combatant = encounter.get_combatant(current_combatant_id) + + # Process effect ticks at start of turn + self._process_turn_start(encounter, current_combatant) + + # Check if stunned + if current_combatant.is_stunned(): + result = { + "action": "stunned", + "message": f"{current_combatant.name} is stunned and cannot act!" + } + encounter.combat_log.append(result["message"]) + self._advance_turn(encounter) + self._save_encounter(encounter) + return result + + # Execute action based on type + if action_type == "attack": + result = self._execute_attack(encounter, current_combatant, ability_id, target_id) + elif action_type == "spell": + result = self._execute_spell(encounter, current_combatant, ability_id, target_id) + elif action_type == "item": + result = self._execute_item(encounter, current_combatant, item_id, target_id) + elif action_type == "defend": + result = self._execute_defend(encounter, current_combatant) + else: + raise InvalidCombatAction(f"Invalid action type: {action_type}") + + # Log action + encounter.combat_log.append(result["message"]) + + # Check for deaths + self._check_deaths(encounter) + + # Check for combat end + if self._check_combat_end(encounter): + encounter.status = CombatStatus.VICTORY if self._player_won(encounter) else CombatStatus.DEFEAT + + # Advance turn + self._advance_turn(encounter) + + # Save encounter + self._save_encounter(encounter) + + return result + + def _process_turn_start(self, encounter: CombatEncounter, combatant: Combatant) -> None: + """Process effects at start of combatant's turn.""" + for effect in combatant.active_effects: + effect.tick(combatant) + + # Remove expired effects + combatant.active_effects = [e for e in combatant.active_effects if not e.is_expired()] + + # Reduce cooldowns + combatant.reduce_cooldowns() + + def _advance_turn(self, encounter: CombatEncounter) -> None: + """Advance to next turn.""" + encounter.current_turn_index += 1 + + # If back to first combatant, increment round + if encounter.current_turn_index >= len(encounter.turn_order): + encounter.current_turn_index = 0 + encounter.round_number += 1 + + def _check_deaths(self, encounter: CombatEncounter) -> None: + """Check for dead combatants and remove from turn order.""" + for combatant in encounter.combatants: + if combatant.current_hp <= 0 and combatant.combatant_id in encounter.turn_order: + encounter.turn_order.remove(combatant.combatant_id) + encounter.combat_log.append(f"{combatant.name} has been defeated!") + + def _check_combat_end(self, encounter: CombatEncounter) -> bool: + """Check if combat has ended.""" + players_alive = any(c.is_player and c.current_hp > 0 for c in encounter.combatants) + enemies_alive = any(not c.is_player and c.current_hp > 0 for c in encounter.combatants) + + return not (players_alive and enemies_alive) + + def _player_won(self, encounter: CombatEncounter) -> bool: + """Check if player won the combat.""" + return any(c.is_player and c.current_hp > 0 for c in encounter.combatants) + + def _save_encounter(self, encounter: CombatEncounter) -> None: + """Save encounter to database.""" + doc_data = encounter.to_dict() + try: + self.appwrite.update_document(self.collection_id, encounter.combat_id, doc_data) + except: + self.appwrite.create_document(self.collection_id, encounter.combat_id, doc_data) + + # TODO: Implement _execute_attack, _execute_spell, _execute_item, _execute_defend + # These will be implemented in Task 1.3 (Damage Calculator) +``` + +**Acceptance Criteria:** ✅ MET +- Combat can be initiated with player + enemies +- Initiative rolls correctly +- Turn order maintained +- Combat state persists to GameSession +- Combat can be loaded from session + +--- + +#### Task 1.3: Implement Damage Calculator (4 hours) ✅ COMPLETE + +**Objective:** Calculate damage for physical/magical attacks with critical hits + +**File:** `/api/app/services/damage_calculator.py` + +**Implementation:** + +```python +""" +Damage Calculator + +Calculates damage for attacks and spells, including critical hits. +""" + +import random +from typing import Dict, Any + +from app.models.stats import Stats +from app.models.abilities import Ability +from app.models.items import Item +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class DamageCalculator: + """Calculate damage for combat actions.""" + + @staticmethod + def calculate_physical_damage( + attacker_stats: Stats, + defender_stats: Stats, + weapon: Item, + ability: Ability = None + ) -> Dict[str, Any]: + """ + Calculate physical damage. + + Formula: weapon.damage + (strength / 2) - target.defense + Min damage: 1 + + Args: + attacker_stats: Attacker's effective stats + defender_stats: Defender's effective stats + weapon: Equipped weapon + ability: Optional ability (for skills like Power Strike) + + Returns: + Dict with damage, is_crit, message + """ + # Base damage from weapon + base_damage = weapon.damage if weapon else 1 + + # Add ability power if using skill + if ability: + base_damage += ability.calculate_power(attacker_stats) + + # Add strength scaling + base_damage += attacker_stats.strength // 2 + + # Subtract defense + damage = base_damage - defender_stats.defense + + # Min damage is 1 + damage = max(1, damage) + + # Check for critical hit + crit_chance = weapon.crit_chance if weapon else 0.05 + is_crit = random.random() < crit_chance + + if is_crit: + crit_mult = weapon.crit_multiplier if weapon else 2.0 + damage = int(damage * crit_mult) + + return { + "damage": damage, + "is_crit": is_crit, + "damage_type": "physical" + } + + @staticmethod + def calculate_magical_damage( + attacker_stats: Stats, + defender_stats: Stats, + ability: Ability + ) -> Dict[str, Any]: + """ + Calculate magical damage. + + Formula: spell.damage + (magic_power / 2) - target.resistance + Min damage: 1 + + Args: + attacker_stats: Attacker's effective stats + defender_stats: Defender's effective stats + ability: Spell ability + + Returns: + Dict with damage, is_crit, message + """ + # Base damage from spell + base_damage = ability.calculate_power(attacker_stats) + + # Subtract magic resistance + damage = base_damage - defender_stats.resistance + + # Min damage is 1 + damage = max(1, damage) + + # Spells don't crit by default (can be added per-spell) + is_crit = False + + return { + "damage": damage, + "is_crit": is_crit, + "damage_type": "magical" + } + + @staticmethod + def apply_damage(combatant, damage: int) -> int: + """ + Apply damage to combatant, considering shields. + + Args: + combatant: Target combatant + damage: Damage amount + + Returns: + Actual damage dealt to HP + """ + # Check for shield effects + shield_power = combatant.get_shield_power() + + if shield_power > 0: + if damage <= shield_power: + # Shield absorbs all damage + combatant.reduce_shield(damage) + return 0 + else: + # Shield absorbs partial damage + remaining_damage = damage - shield_power + combatant.reduce_shield(shield_power) + combatant.current_hp -= remaining_damage + return remaining_damage + else: + # No shield, apply damage directly + combatant.current_hp -= damage + return damage +``` + +**Acceptance Criteria:** ✅ MET +- Physical damage formula correct (39 unit tests) +- Magical damage formula correct +- Critical hits work (LUK-based chance, configurable multiplier) +- Shield absorption works (partial and full) +- Minimum damage is always 1 + +--- + +#### Task 1.4: Implement Effect Processor (4 hours) ✅ COMPLETE + +**Objective:** Process effects (DOT, HOT, buffs, debuffs, stun, shield) at turn start + +**Implementation:** Extend `Effect` class in `/api/app/models/effects.py` + +**Add Methods:** + +```python +# In Effect class + +def tick(self, combatant) -> None: + """ + Process this effect for one turn. + + Args: + combatant: Combatant affected by this effect + """ + if self.effect_type == EffectType.DOT: + damage = self.power * self.stacks + combatant.current_hp -= damage + logger.info(f"{combatant.name} takes {damage} damage from {self.name}") + + elif self.effect_type == EffectType.HOT: + healing = self.power * self.stacks + combatant.current_hp = min(combatant.current_hp + healing, combatant.stats.max_hp) + logger.info(f"{combatant.name} heals {healing} HP from {self.name}") + + elif self.effect_type == EffectType.STUN: + # Stun doesn't tick damage, just prevents action + pass + + elif self.effect_type == EffectType.SHIELD: + # Shield doesn't tick, it absorbs damage + pass + + elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: + # Stat modifiers are applied via get_effective_stats() + pass + + # Reduce duration + self.duration -= 1 +``` + +**Acceptance Criteria:** ✅ MET +- DOT deals damage each turn +- HOT heals each turn (capped at max HP) +- Buffs/debuffs modify stats via `get_effective_stats()` +- Shields absorb damage before HP +- Stun prevents actions +- Effects expire when duration reaches 0 + +--- + +#### Task 1.5: Implement Combat Actions (1 day / 8 hours) ✅ COMPLETE + +**Objective:** Implement `_execute_attack`, `_execute_spell`, `_execute_item`, `_execute_defend` in CombatService + +**Add to `/api/app/services/combat_service.py`:** + +```python +def _execute_attack( + self, + encounter: CombatEncounter, + attacker: Combatant, + ability_id: str, + target_id: str +) -> Dict[str, Any]: + """Execute physical attack.""" + target = encounter.get_combatant(target_id) + ability = self.ability_loader.get_ability(ability_id) if ability_id else None + + # Check mana cost + if ability and ability.mana_cost > attacker.current_mp: + raise InvalidCombatAction("Not enough mana") + + # Check cooldown + if ability and not attacker.can_use_ability(ability.ability_id): + raise InvalidCombatAction("Ability is on cooldown") + + # Calculate damage + from app.services.damage_calculator import DamageCalculator + weapon = attacker.equipped_weapon # Assume Combatant has this field + dmg_result = DamageCalculator.calculate_physical_damage( + attacker.stats, + target.stats, + weapon, + ability + ) + + # Apply damage + actual_damage = DamageCalculator.apply_damage(target, dmg_result["damage"]) + + # Apply effects from ability + if ability and ability.effects_applied: + for effect_data in ability.effects_applied: + effect = Effect.from_dict(effect_data) + target.apply_effect(effect) + + # Consume mana + if ability: + attacker.current_mp -= ability.mana_cost + attacker.set_cooldown(ability.ability_id, ability.cooldown) + + # Build message + crit_msg = " (CRITICAL HIT!)" if dmg_result["is_crit"] else "" + message = f"{attacker.name} attacks {target.name} for {actual_damage} damage{crit_msg}" + + return { + "action": "attack", + "damage": actual_damage, + "is_crit": dmg_result["is_crit"], + "target": target.name, + "message": message + } + +def _execute_spell( + self, + encounter: CombatEncounter, + caster: Combatant, + ability_id: str, + target_id: str +) -> Dict[str, Any]: + """Execute spell.""" + # Similar to _execute_attack but uses calculate_magical_damage + # Implementation left as exercise + pass + +def _execute_item( + self, + encounter: CombatEncounter, + user: Combatant, + item_id: str, + target_id: str +) -> Dict[str, Any]: + """Use item in combat.""" + # Load item, apply effects (healing, buffs, etc.) + # Remove item from inventory + pass + +def _execute_defend( + self, + encounter: CombatEncounter, + defender: Combatant +) -> Dict[str, Any]: + """Enter defensive stance.""" + # Apply temporary defense buff + from app.models.effects import Effect, EffectType + + defense_buff = Effect( + effect_id="defend_buff", + name="Defending", + effect_type=EffectType.BUFF, + duration=1, + power=5, + stat_type="defense", + stacks=1, + max_stacks=1 + ) + + defender.apply_effect(defense_buff) + + return { + "action": "defend", + "message": f"{defender.name} takes a defensive stance (+5 defense)" + } +``` + +**Acceptance Criteria:** ✅ MET +- Attack action works (physical damage via DamageCalculator) +- Ability action works (magical damage, mana cost) +- Defend action applies temporary defense buff +- Flee action with DEX-based success chance +- All actions log messages to combat log + +--- + +#### Task 1.6: Combat API Endpoints (1 day / 8 hours) ✅ COMPLETE + +**Objective:** Create REST API for combat + +**File:** `/api/app/api/combat.py` + +**Endpoints:** + +```python +""" +Combat API Blueprint + +Endpoints: +- POST /api/v1/combat/start - Initiate combat +- POST /api/v1/combat//action - Take combat action +- GET /api/v1/combat//state - Get combat state +- GET /api/v1/combat//results - Get results after victory +""" + +from flask import Blueprint, request, g + +from app.services.combat_service import CombatService, CombatNotFound, InvalidCombatAction +from app.services.character_service import get_character_service +from app.services.appwrite_service import get_appwrite_service +from app.utils.response import success_response, created_response, error_response, not_found_response +from app.utils.auth import require_auth +from app.utils.logging import get_logger + +logger = get_logger(__file__) + +combat_bp = Blueprint('combat', __name__) + + +@combat_bp.route('/start', methods=['POST']) +@require_auth +def start_combat(): + """ + Initiate a new combat encounter. + + Request JSON: + { + "session_id": "session_123", + "character_id": "char_abc", + "enemies": [ + { + "name": "Goblin", + "level": 2, + "stats": {...} + } + ] + } + + Returns: + 201 Created with combat encounter data + """ + data = request.get_json() + + # Validate request + session_id = data.get('session_id') + character_id = data.get('character_id') + enemies = data.get('enemies', []) + + if not session_id or not character_id: + return error_response("session_id and character_id required", 400) + + # Load character + char_service = get_character_service() + character = char_service.get_character(character_id, g.user_id) + + # Initiate combat + combat_service = CombatService(get_appwrite_service()) + encounter = combat_service.initiate_combat(session_id, character, enemies) + + return created_response({ + "combat_id": encounter.combat_id, + "turn_order": encounter.turn_order, + "current_turn": encounter.turn_order[0], + "round": encounter.round_number + }) + + +@combat_bp.route('//action', methods=['POST']) +@require_auth +def combat_action(combat_id: str): + """ + Take a combat action. + + Request JSON: + { + "action_type": "attack", // "attack", "spell", "item", "defend" + "ability_id": "basic_attack", + "target_id": "enemy_1", + "item_id": null // for item use + } + + Returns: + 200 OK with action result + """ + data = request.get_json() + + action_type = data.get('action_type') + ability_id = data.get('ability_id') + target_id = data.get('target_id') + item_id = data.get('item_id') + + try: + combat_service = CombatService(get_appwrite_service()) + result = combat_service.process_action( + combat_id, + action_type, + ability_id, + target_id, + item_id + ) + + # Get updated encounter state + encounter = combat_service.get_encounter(combat_id) + + return success_response({ + "action_result": result, + "combat_state": { + "current_turn": encounter.turn_order[encounter.current_turn_index] if encounter.turn_order else None, + "round": encounter.round_number, + "status": encounter.status.value, + "combatants": [c.to_dict() for c in encounter.combatants] + } + }) + + except CombatNotFound: + return not_found_response("Combat not found") + except InvalidCombatAction as e: + return error_response(str(e), 400) + + +@combat_bp.route('//state', methods=['GET']) +@require_auth +def get_combat_state(combat_id: str): + """Get current combat state.""" + try: + combat_service = CombatService(get_appwrite_service()) + encounter = combat_service.get_encounter(combat_id) + + return success_response({ + "combat_id": encounter.combat_id, + "status": encounter.status.value, + "round": encounter.round_number, + "turn_order": encounter.turn_order, + "current_turn_index": encounter.current_turn_index, + "combatants": [c.to_dict() for c in encounter.combatants], + "combat_log": encounter.combat_log[-10:] # Last 10 messages + }) + + except CombatNotFound: + return not_found_response("Combat not found") + + +@combat_bp.route('//results', methods=['GET']) +@require_auth +def get_combat_results(combat_id: str): + """Get combat results (loot, XP, etc.).""" + try: + combat_service = CombatService(get_appwrite_service()) + encounter = combat_service.get_encounter(combat_id) + + if encounter.status not in [CombatStatus.VICTORY, CombatStatus.DEFEAT]: + return error_response("Combat is still in progress", 400) + + # Calculate rewards (TODO: implement loot/XP system) + results = { + "victory": encounter.status == CombatStatus.VICTORY, + "xp_gained": 100, # Placeholder + "gold_gained": 50, # Placeholder + "loot": [] # Placeholder + } + + return success_response(results) + + except CombatNotFound: + return not_found_response("Combat not found") +``` + +**Don't forget to register blueprint in `/api/app/__init__.py`:** + +```python +from app.api.combat import combat_bp +app.register_blueprint(combat_bp, url_prefix='/api/v1/combat') +``` + +**Acceptance Criteria:** ✅ MET +- `POST /api/v1/combat/start` creates combat encounter +- `POST /api/v1/combat//action` processes actions +- `GET /api/v1/combat//state` returns current state +- `POST /api/v1/combat//flee` attempts to flee +- `POST /api/v1/combat//enemy-turn` executes enemy AI +- `GET /api/v1/combat/enemies` lists enemy templates (public) +- `GET /api/v1/combat/enemies/` gets enemy details (public) +- All combat endpoints require authentication (except enemy listing) +- 19 integration tests passing + +--- + +#### Task 1.7: Manual API Testing (4 hours) ⏭️ SKIPPED + +**Objective:** Test combat flow end-to-end via API + +**Test Cases:** + +1. **Start Combat** + ```bash + curl -X POST http://localhost:5000/api/v1/combat/start \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "session_id": "session_123", + "character_id": "char_abc", + "enemies": [ + { + "name": "Goblin", + "level": 2, + "stats": { + "strength": 8, + "defense": 5, + "max_hp": 30 + } + } + ] + }' + ``` + +2. **Take Attack Action** + ```bash + curl -X POST http://localhost:5000/api/v1/combat//action \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "action_type": "attack", + "ability_id": "basic_attack", + "target_id": "enemy_0" + }' + ``` + +3. **Get Combat State** + ```bash + curl -X GET http://localhost:5000/api/v1/combat//state \ + -H "Authorization: Bearer " + ``` + +**Document in `/api/docs/API_TESTING.md`** + +**Acceptance Criteria:** ⏭️ SKIPPED (covered by automated tests) +- All endpoints return correct responses - ✅ via test_combat_api.py +- Combat flows from start to victory/defeat - ✅ via test_combat_service.py +- Damage calculations verified - ✅ via test_damage_calculator.py +- Effects process correctly - ✅ via test_combat_service.py +- Turn order maintained - ✅ via test_combat_service.py + +> **Note:** Manual testing skipped in favor of 108 comprehensive automated tests. + +--- + +### Week 2: Inventory & Equipment System ⏳ NEXT + +#### Task 2.1: Verify Item Data Models (2 hours) + +**Objective:** Review item system implementation + +**Files to Review:** +- `/api/app/models/items.py` - Item, ItemType, ItemRarity +- `/api/app/models/enums.py` - ItemType enum + +**Verification Checklist:** +- [ ] Item dataclass complete with all fields +- [ ] ItemType enum: WEAPON, ARMOR, CONSUMABLE, QUEST_ITEM +- [ ] Item has `to_dict()` and `from_dict()` methods +- [ ] Weapon-specific fields: damage, crit_chance, crit_multiplier +- [ ] Armor-specific fields: defense, resistance +- [ ] Consumable-specific fields: effects + +**Acceptance Criteria:** +- Item model can represent all item types +- Serialization works correctly + +--- + +#### Task 2.2: Create Starting Items YAML (4 hours) + +**Objective:** Define 20-30 basic items in YAML + +**Directory Structure:** +``` +/api/app/data/items/ +├── weapons/ +│ ├── swords.yaml +│ ├── bows.yaml +│ └── staves.yaml +├── armor/ +│ ├── helmets.yaml +│ ├── chest.yaml +│ └── boots.yaml +└── consumables/ + └── potions.yaml +``` + +**Example: `/api/app/data/items/weapons/swords.yaml`** + +```yaml +- item_id: "iron_sword" + name: "Iron Sword" + description: "A sturdy iron blade. Reliable and affordable." + item_type: "weapon" + rarity: "common" + value: 50 + damage: 10 + crit_chance: 0.05 + crit_multiplier: 2.0 + required_level: 1 + is_tradeable: true + +- item_id: "steel_sword" + name: "Steel Sword" + description: "Forged from high-quality steel. Sharper and more durable." + item_type: "weapon" + rarity: "uncommon" + value: 150 + damage: 18 + crit_chance: 0.08 + crit_multiplier: 2.0 + required_level: 3 + is_tradeable: true + +- item_id: "enchanted_blade" + name: "Enchanted Blade" + description: "A sword infused with magical energy." + item_type: "weapon" + rarity: "rare" + value: 500 + damage: 30 + crit_chance: 0.12 + crit_multiplier: 2.5 + required_level: 7 + is_tradeable: true +``` + +**Create Items:** +- **Weapons** (10 items): Swords, bows, staves, daggers (common → legendary) +- **Armor** (10 items): Helmets, chest armor, boots (light/medium/heavy) +- **Consumables** (10 items): Health potions (small/medium/large), mana potions, antidotes, scrolls + +**Acceptance Criteria:** +- At least 20 items defined +- Mix of item types and rarities +- Balanced stats for level requirements +- All YAML files valid and loadable + +--- + +#### Task 2.3: Implement Inventory Service (1 day / 8 hours) + +**Objective:** Service layer for inventory management + +**File:** `/api/app/services/inventory_service.py` + +**Implementation:** + +```python +""" +Inventory Service + +Manages character inventory, equipment, and item usage. +""" + +from typing import List, Optional +from app.models.items import Item, ItemType +from app.models.character import Character +from app.services.item_loader import ItemLoader +from app.services.appwrite_service import AppwriteService +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class InventoryError(Exception): + """Base exception for inventory errors.""" + pass + + +class ItemNotFound(InventoryError): + """Raised when item is not found in inventory.""" + pass + + +class CannotEquipItem(InventoryError): + """Raised when item cannot be equipped.""" + pass + + +class InventoryService: + """Service for managing character inventory.""" + + def __init__(self, appwrite_service: AppwriteService): + self.appwrite = appwrite_service + self.item_loader = ItemLoader() + + def get_inventory(self, character: Character) -> List[Item]: + """ + Get character's inventory. + + Args: + character: Character instance + + Returns: + List of Item instances + """ + return [self.item_loader.get_item(item_id) for item_id in character.inventory_item_ids] + + def add_item(self, character: Character, item_id: str) -> None: + """ + Add item to character inventory. + + Args: + character: Character instance + item_id: Item ID to add + """ + if item_id not in character.inventory_item_ids: + character.inventory_item_ids.append(item_id) + logger.info(f"Added item {item_id} to character {character.character_id}") + + def remove_item(self, character: Character, item_id: str) -> None: + """ + Remove item from inventory. + + Args: + character: Character instance + item_id: Item ID to remove + + Raises: + ItemNotFound: If item not in inventory + """ + if item_id not in character.inventory_item_ids: + raise ItemNotFound(f"Item {item_id} not in inventory") + + character.inventory_item_ids.remove(item_id) + logger.info(f"Removed item {item_id} from character {character.character_id}") + + def equip_item(self, character: Character, item_id: str, slot: str) -> None: + """ + Equip item to character. + + Args: + character: Character instance + item_id: Item ID to equip + slot: Equipment slot (weapon, helmet, chest, boots, etc.) + + Raises: + ItemNotFound: If item not in inventory + CannotEquipItem: If item cannot be equipped + """ + if item_id not in character.inventory_item_ids: + raise ItemNotFound(f"Item {item_id} not in inventory") + + item = self.item_loader.get_item(item_id) + + # Validate item type matches slot + if item.item_type == ItemType.WEAPON and slot != "weapon": + raise CannotEquipItem("Weapon can only be equipped in weapon slot") + + if item.item_type == ItemType.ARMOR: + # Armor has sub-types (helmet, chest, boots) + # Add validation based on item.armor_slot field + pass + + if item.item_type == ItemType.CONSUMABLE: + raise CannotEquipItem("Consumables cannot be equipped") + + # Check level requirement + if character.level < item.required_level: + raise CannotEquipItem(f"Requires level {item.required_level}") + + # Unequip current item in slot (if any) + current_item_id = character.equipped.get(slot) + if current_item_id: + # Current item returns to inventory (already there) + pass + + # Equip new item + character.equipped[slot] = item_id + + logger.info(f"Equipped {item_id} to {slot} for character {character.character_id}") + + def unequip_item(self, character: Character, slot: str) -> None: + """ + Unequip item from slot. + + Args: + character: Character instance + slot: Equipment slot + """ + if slot not in character.equipped: + return + + item_id = character.equipped[slot] + del character.equipped[slot] + + logger.info(f"Unequipped {item_id} from {slot} for character {character.character_id}") + + def use_consumable(self, character: Character, item_id: str) -> dict: + """ + Use consumable item. + + Args: + character: Character instance + item_id: Consumable item ID + + Returns: + Dict with effects applied + + Raises: + ItemNotFound: If item not in inventory + CannotEquipItem: If item is not consumable + """ + if item_id not in character.inventory_item_ids: + raise ItemNotFound(f"Item {item_id} not in inventory") + + item = self.item_loader.get_item(item_id) + + if item.item_type != ItemType.CONSUMABLE: + raise CannotEquipItem("Only consumables can be used") + + # Apply effects (healing, mana, buffs) + effects_applied = [] + + if hasattr(item, 'hp_restore') and item.hp_restore > 0: + old_hp = character.current_hp + character.current_hp = min(character.current_hp + item.hp_restore, character.stats.max_hp) + actual_healing = character.current_hp - old_hp + effects_applied.append(f"Restored {actual_healing} HP") + + if hasattr(item, 'mp_restore') and item.mp_restore > 0: + old_mp = character.current_mp + character.current_mp = min(character.current_mp + item.mp_restore, character.stats.max_mp) + actual_restore = character.current_mp - old_mp + effects_applied.append(f"Restored {actual_restore} MP") + + # Remove item from inventory (consumables are single-use) + self.remove_item(character, item_id) + + logger.info(f"Used consumable {item_id} for character {character.character_id}") + + return { + "item_used": item.name, + "effects": effects_applied + } +``` + +**Also create `/api/app/services/item_loader.py`:** + +```python +""" +Item Loader + +Loads items from YAML data files. +""" + +import os +import yaml +from typing import Dict, Optional +from app.models.items import Item +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class ItemLoader: + """Loads and caches items from YAML files.""" + + def __init__(self): + self.items: Dict[str, Item] = {} + self._load_all_items() + + def _load_all_items(self) -> None: + """Load all items from YAML files.""" + base_dir = "app/data/items" + categories = ["weapons", "armor", "consumables"] + + for category in categories: + category_dir = os.path.join(base_dir, category) + if not os.path.exists(category_dir): + continue + + for yaml_file in os.listdir(category_dir): + if not yaml_file.endswith('.yaml'): + continue + + filepath = os.path.join(category_dir, yaml_file) + self._load_items_from_file(filepath) + + logger.info(f"Loaded {len(self.items)} items from YAML files") + + def _load_items_from_file(self, filepath: str) -> None: + """Load items from a single YAML file.""" + with open(filepath, 'r') as f: + items_data = yaml.safe_load(f) + + for item_data in items_data: + item = Item.from_dict(item_data) + self.items[item.item_id] = item + + def get_item(self, item_id: str) -> Optional[Item]: + """Get item by ID.""" + return self.items.get(item_id) + + def get_all_items(self) -> Dict[str, Item]: + """Get all loaded items.""" + return self.items +``` + +**Acceptance Criteria:** +- Inventory service can add/remove items +- Equip/unequip works with validation +- Consumables can be used (healing, mana restore) +- Item loader caches all items from YAML +- Character's equipped items tracked + +--- + +#### Task 2.4: Inventory API Endpoints (1 day / 8 hours) + +**Objective:** REST API for inventory management + +**File:** `/api/app/api/inventory.py` + +**Endpoints:** + +```python +""" +Inventory API Blueprint + +Endpoints: +- GET /api/v1/characters//inventory - Get inventory +- POST /api/v1/characters//inventory/equip - Equip item +- POST /api/v1/characters//inventory/unequip - Unequip item +- POST /api/v1/characters//inventory/use - Use consumable +- DELETE /api/v1/characters//inventory/ - Drop item +""" + +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('//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('//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('//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('//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:** + +```python +def get_effective_stats(self) -> Stats: + """ + Calculate effective stats including base, equipment, skills, and effects. + + Returns: + Stats instance with all modifiers applied + """ + # Start with base stats + effective = Stats( + strength=self.stats.strength, + defense=self.stats.defense, + speed=self.stats.speed, + intelligence=self.stats.intelligence, + resistance=self.stats.resistance, + vitality=self.stats.vitality, + spirit=self.stats.spirit + ) + + # Add bonuses from equipped items + from app.services.item_loader import ItemLoader + item_loader = ItemLoader() + + for slot, item_id in self.equipped.items(): + item = item_loader.get_item(item_id) + if not item: + continue + + # Add item stat bonuses + if hasattr(item, 'stat_bonuses'): + for stat_name, bonus in item.stat_bonuses.items(): + current_value = getattr(effective, stat_name) + setattr(effective, stat_name, current_value + bonus) + + # Armor adds defense/resistance + if item.item_type == ItemType.ARMOR: + effective.defense += item.defense + effective.resistance += item.resistance + + # Add bonuses from unlocked skills + for skill_id in self.unlocked_skills: + skill = self.skill_tree.get_skill_node(skill_id) + if skill and skill.stat_bonuses: + for stat_name, bonus in skill.stat_bonuses.items(): + current_value = getattr(effective, stat_name) + setattr(effective, stat_name, current_value + bonus) + + # Add temporary effects (buffs/debuffs) + for effect in self.active_effects: + if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: + modifier = effect.power * effect.stacks + if effect.effect_type == EffectType.DEBUFF: + modifier *= -1 + + current_value = getattr(effective, effect.stat_type) + new_value = max(1, current_value + modifier) # Min stat is 1 + setattr(effective, effect.stat_type, new_value) + + return effective +``` + +**Acceptance Criteria:** +- Equipped weapons add damage +- Equipped armor adds defense/resistance +- Stat bonuses from items apply correctly +- Skills still apply bonuses +- Effects still modify stats + +--- + +### Week 3: Combat UI + +#### Task 3.1: Create Combat Template (1 day / 8 hours) + +**Objective:** Build HTMX-powered combat interface + +**File:** `/public_web/templates/game/combat.html` + +**Layout:** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ COMBAT ENCOUNTER │ +├───────────────┬─────────────────────────┬───────────────────┤ +│ │ │ │ +│ YOUR │ COMBAT LOG │ TURN ORDER │ +│ CHARACTER │ │ ─────────── │ +│ ───────── │ Goblin attacks you │ 1. Aragorn ✓ │ +│ HP: ████ 80 │ for 12 damage! │ 2. Goblin │ +│ MP: ███ 60 │ │ 3. Orc │ +│ │ You attack Goblin │ │ +│ ENEMY │ for 18 damage! │ ACTIVE EFFECTS │ +│ ───────── │ CRITICAL HIT! │ ─────────── │ +│ Goblin │ │ 🛡️ Defending │ +│ HP: ██ 12 │ Goblin is stunned! │ (1 turn) │ +│ │ │ │ +│ │ ───────────────── │ │ +│ │ ACTION BUTTONS │ │ +│ │ ───────────────── │ │ +│ │ [Attack] [Spell] │ │ +│ │ [Item] [Defend] │ │ +│ │ │ │ +└───────────────┴─────────────────────────┴───────────────────┘ +``` + +**Implementation:** + +```html +{% extends "base.html" %} + +{% block title %}Combat - Code of Conquest{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+

⚔️ COMBAT ENCOUNTER

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

Combat Log

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

Your Turn

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

{{ character.name }}'s Skill Trees

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

{{ tree.name }}

+

{{ tree.description }}

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

{{ skill.name }}

+

{{ skill.description }}

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

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

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

🏪 {{ shop_name }}

+

Shopkeeper: {{ shopkeeper_name }}

+

Your Gold: {{ character.gold }}

+
+ +
+ {% for item_entry in inventory %} +
+
+

{{ item_entry.item.name }}

+ {{ item_entry.price }} gold +
+ +

{{ item_entry.item.description }}

+ +
+ {% if item_entry.item.item_type == 'weapon' %} + ⚔️ Damage: {{ item_entry.item.damage }} + {% elif item_entry.item.item_type == 'armor' %} + 🛡️ Defense: {{ item_entry.item.defense }} + {% elif item_entry.item.item_type == 'consumable' %} + ❤️ Restores: {{ item_entry.item.hp_restore }} HP + {% endif %} +
+ + +
+ {% endfor %} +
+
+{% endblock %} +``` + +**Create view in `/public_web/app/views/shop.py`:** + +```python +""" +Shop Views +""" + +from flask import Blueprint, render_template, request, g + +from app.services.api_client import APIClient, APIError +from app.utils.auth import require_auth +from app.utils.logging import get_logger + +logger = get_logger(__file__) + +shop_bp = Blueprint('shop', __name__) + + +@shop_bp.route('/') +@require_auth +def shop_index(): + """Display shop.""" + api_client = APIClient() + + try: + # Get shop inventory + shop_response = api_client.get('/shop/inventory') + inventory = shop_response['result']['inventory'] + + # Get character (for gold display) + char_response = api_client.get(f'/characters/{g.character_id}') + character = char_response['result'] + + return render_template( + 'shop/index.html', + shop_name="General Store", + shopkeeper_name="Merchant Guildmaster", + inventory=inventory, + character=character + ) + + except APIError as e: + logger.error(f"Failed to load shop: {e}") + return render_template('partials/error.html', error=str(e)) + + +@shop_bp.route('/purchase', methods=['POST']) +@require_auth +def purchase(): + """Purchase item (HTMX endpoint).""" + api_client = APIClient() + + purchase_data = { + 'character_id': request.form.get('character_id'), + 'item_id': request.form.get('item_id'), + 'quantity': 1 + } + + try: + response = api_client.post('/shop/purchase', json=purchase_data) + + # Reload shop + return shop_index() + + except APIError as e: + logger.error(f"Purchase failed: {e}") + return render_template('partials/error.html', error=str(e)) +``` + +**Acceptance Criteria:** +- Shop displays all items +- Item cards show stats and price +- Purchase button disabled if not enough gold +- Purchase adds item to inventory +- Gold updates dynamically +- UI refreshes after purchase + +--- + +### Task 5.4: Transaction Logging (2 hours) + +**Objective:** Log all shop purchases + +**File:** `/api/app/models/transaction.py` + +```python +""" +Transaction Model + +Tracks all gold transactions (shop, trades, etc.) +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, Any + + +@dataclass +class Transaction: + """Represents a gold transaction.""" + + transaction_id: str + transaction_type: str # "shop_purchase", "trade", "quest_reward", etc. + character_id: str + amount: int # Negative for expenses, positive for income + description: str + timestamp: datetime = field(default_factory=datetime.utcnow) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dict.""" + return { + "transaction_id": self.transaction_id, + "transaction_type": self.transaction_type, + "character_id": self.character_id, + "amount": self.amount, + "description": self.description, + "timestamp": self.timestamp.isoformat(), + "metadata": self.metadata + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Transaction': + """Deserialize from dict.""" + return cls( + transaction_id=data["transaction_id"], + transaction_type=data["transaction_type"], + character_id=data["character_id"], + amount=data["amount"], + description=data["description"], + timestamp=datetime.fromisoformat(data["timestamp"]), + metadata=data.get("metadata", {}) + ) +``` + +**Update `ShopService.purchase_item()` to log transaction:** + +```python +# In shop_service.py + +def purchase_item(...): + # ... existing code ... + + # Log transaction + from app.models.transaction import Transaction + import uuid + + transaction = Transaction( + transaction_id=str(uuid.uuid4()), + transaction_type="shop_purchase", + character_id=character.character_id, + amount=-price, + description=f"Purchased {quantity}x {item_id} from {shop_id}", + metadata={ + "shop_id": shop_id, + "item_id": item_id, + "quantity": quantity, + "unit_price": item_data['price'] + } + ) + + # Save to database + from app.services.appwrite_service import get_appwrite_service + appwrite = get_appwrite_service() + appwrite.create_document("transactions", transaction.transaction_id, transaction.to_dict()) + + # ... rest of code ... +``` + +**Acceptance Criteria:** +- All purchases logged to database +- Transaction records complete +- Can query transaction history + +--- + +## Success Criteria - Phase 4 Complete + +### Combat System +- [ ] Turn-based combat works end-to-end +- [ ] Damage calculations correct (physical, magical, critical) +- [ ] Effects process correctly (DOT, HOT, buffs, debuffs, shields, stun) +- [ ] Combat UI functional and responsive +- [ ] Victory awards XP, gold, loot +- [ ] Combat state persists + +### Inventory System +- [ ] Inventory displays in UI +- [ ] Equip/unequip items works +- [ ] Consumables can be used +- [ ] Equipment affects character stats +- [ ] Item YAML data loaded correctly + +### Skill Trees +- [ ] Visual skill tree UI works +- [ ] Prerequisites enforced +- [ ] Unlock skills with skill points +- [ ] Respec functionality works +- [ ] Stat bonuses apply immediately + +### Leveling +- [ ] XP awarded after combat +- [ ] Level up triggers at threshold +- [ ] Skill points granted on level up +- [ ] Level up modal shown +- [ ] Character stats increase + +### NPC Shop +- [ ] Shop inventory displays +- [ ] Purchase validation works +- [ ] Items added to inventory +- [ ] Gold deducted correctly +- [ ] Transactions logged + +--- + +## Next Steps After Phase 4 + +Once Phase 4 is complete, you'll have a fully playable combat game with progression. The next logical phases are: + +**Phase 5: Story Progression & Quests** (Original Phase 4 from roadmap) +- AI-driven story progression +- Action prompts (button-based gameplay) +- Quest system (YAML-driven, context-aware) +- Full gameplay loop: Explore → Combat → Quests → Level Up + +**Phase 6: Multiplayer Sessions** +- Invite-based co-op +- Time-limited sessions +- AI-generated campaigns + +**Phase 7: Marketplace & Economy** +- Player-to-player trading +- Auction system +- Economy balancing + +--- + +## Appendix: Testing Strategy + +### Manual Testing Checklist + +**Combat:** +- [ ] Start combat from story +- [ ] Turn order correct +- [ ] Attack deals damage +- [ ] Spells work +- [ ] Items usable in combat +- [ ] Defend action +- [ ] Victory conditions +- [ ] Defeat handling + +**Inventory:** +- [ ] Add items +- [ ] Remove items +- [ ] Equip weapons +- [ ] Equip armor +- [ ] Use consumables +- [ ] Inventory UI updates + +**Skills:** +- [ ] View skill trees +- [ ] Unlock skills +- [ ] Prerequisites enforced +- [ ] Stat bonuses apply +- [ ] Respec works + +**Shop:** +- [ ] Browse inventory +- [ ] Purchase items +- [ ] Insufficient gold handling +- [ ] Transaction logging + +--- + +## Document Maintenance + +**Update this document as you complete tasks:** +- Mark tasks complete with ✅ +- Add notes about implementation decisions +- Update time estimates based on actual progress +- Document any blockers or challenges + +**Good luck with Phase 4 implementation!** 🚀 diff --git a/docs/VECTOR_DATABASE_STRATEGY.md b/docs/VECTOR_DATABASE_STRATEGY.md new file mode 100644 index 0000000..bd93de2 --- /dev/null +++ b/docs/VECTOR_DATABASE_STRATEGY.md @@ -0,0 +1,481 @@ +# Vector Database Strategy + +## Overview + +This document outlines the strategy for implementing layered knowledge systems using vector databases to provide NPCs and the Dungeon Master with contextual lore, regional history, and world knowledge. + +**Status:** Planning Phase +**Last Updated:** November 26, 2025 +**Decision:** Use Weaviate for vector database implementation + +--- + +## Knowledge Hierarchy + +### Three-Tier Vector Database Structure + +1. **World Lore DB** (Global) + - Broad historical events, mythology, major kingdoms, legendary figures + - Accessible to all NPCs and DM for player questions + - Examples: "The Great War 200 years ago", "The origin of magic", "The Five Kingdoms" + - **Scope:** Universal knowledge any educated NPC might know + +2. **Regional/Town Lore DB** (Location-specific) + - Local history, notable events, landmarks, politics, rumors + - Current town leadership, recent events, local legends + - Trade routes, neighboring settlements, regional conflicts + - **Scope:** Knowledge specific to geographic area + +3. **NPC Persona** (Individual, YAML-defined) + - Personal background, personality, motivations + - Specific knowledge based on profession/role + - Personal relationships and secrets + - **Scope:** Character-specific information (already implemented in `/api/app/data/npcs/*.yaml`) + +--- + +## How Knowledge Layers Work Together + +### Contextual Knowledge Layering + +When an NPC engages in conversation, build their knowledge context by: +- **Always include**: NPC persona + their region's lore DB +- **Conditionally include**: World lore (if the topic seems historical/broad) +- **Use semantic search**: Query each DB for relevant chunks based on conversation topic + +### Example Interaction Flow + +**Player asks tavern keeper:** "Tell me about the old ruins north of town" + +1. Check NPC persona: "Are ruins mentioned in their background?" +2. Query Regional DB: "old ruins + north + [town name]" +3. If no hits, query World Lore DB: "ancient ruins + [region name]" +4. Combine results with NPC personality filter + +**Result:** NPC responds with appropriate lore, or authentically says "I don't know about that" if nothing is found. + +--- + +## Knowledge Boundaries & Authenticity + +### NPCs Have Knowledge Limitations Based On: + +- **Profession**: Blacksmith knows metallurgy lore, scholar knows history, farmer knows agricultural traditions +- **Social Status**: Nobles know court politics, commoners know street rumors +- **Age/Experience**: Elder NPCs might reference events from decades ago +- **Travel History**: Has this NPC been outside their region? + +### Implementation of "I don't know" + +Add metadata to vector DB entries: +- `required_profession: ["scholar", "priest"]` +- `social_class: ["noble", "merchant"]` +- `knowledge_type: "academic" | "common" | "secret"` +- `region_id: "thornhelm"` +- `time_period: "ancient" | "recent" | "current"` + +Filter results before passing to the NPC's AI context, allowing authentic "I haven't heard of that" responses. + +--- + +## Retrieval-Augmented Generation (RAG) Pattern + +### Building AI Prompts for NPC Dialogue + +``` +[NPC Persona from YAML] ++ +[Top 3-5 relevant chunks from Regional DB based on conversation topic] ++ +[Top 2-3 relevant chunks from World Lore if topic is broad/historical] ++ +[Conversation history from character's npc_interactions] +→ Feed to Claude with instruction to stay in character and admit ignorance if uncertain +``` + +### DM Knowledge vs NPC Knowledge + +**DM Mode** (Player talks directly to DM, not through NPC): +- DM has access to ALL databases without restrictions +- DM can reveal as much or as little as narratively appropriate +- DM can generate content not in databases (creative liberty) + +**NPC Mode** (Player talks to specific NPC): +- NPC knowledge filtered by persona/role/location +- NPC can redirect: "You should ask the town elder about that" or "I've heard scholars at the university know more" +- Creates natural quest hooks and information-gathering gameplay + +--- + +## Technical Implementation + +### Technology Choice: Weaviate + +**Reasons for Weaviate:** +- Self-hosted option for dev/beta +- Managed cloud service (Weaviate Cloud Services) for production +- **Same API** for both self-hosted and managed (easy migration) +- Rich metadata filtering capabilities +- Multi-tenancy support +- GraphQL API (fits strong typing preference) +- Hybrid search (semantic + keyword) + +### Storage & Indexing Strategy + +**Where Each DB Lives:** + +- **World Lore**: Single global vector DB collection +- **Regional DBs**: One collection with region metadata filtering + - Could use Weaviate multi-tenancy for efficient isolation + - Lazy-load when character enters region + - Cache in Redis for active sessions +- **NPC Personas**: Remain in YAML (structured data, not semantic search needed) + +**Weaviate Collections Structure:** + +``` +Collections: +- WorldLore + - Metadata: knowledge_type, time_period, required_profession +- RegionalLore + - Metadata: region_id, knowledge_type, social_class +- Rumors (optional: dynamic/time-sensitive content) + - Metadata: region_id, expiration_date, source_npc +``` + +### Semantic Chunk Strategy + +Chunk lore content by logical units: +- **Events**: "The Battle of Thornhelm (Year 1204) - A decisive victory..." +- **Locations**: "The Abandoned Lighthouse - Once a beacon for traders..." +- **Figures**: "Lord Varric the Stern - Current ruler of Thornhelm..." +- **Rumors/Gossip**: "Strange lights have been seen in the forest lately..." + +Each chunk gets embedded and stored with rich metadata for filtering. + +--- + +## Development Workflow + +### Index-Once Strategy + +**Rationale:** +- Lore is relatively static (updates only during major version releases) +- Read-heavy workload (perfect for vector DBs) +- Cost-effective (one-time embedding generation) +- Allows thorough testing before deployment + +### Workflow Phases + +**Development:** +1. Write lore content (YAML/JSON/Markdown) +2. Run embedding script locally +3. Upload to local Weaviate instance (Docker) +4. Test NPC conversations +5. Iterate on lore content + +**Beta/Staging:** +1. Same self-hosted Weaviate, separate instance +2. Finalize lore content +3. Generate production embeddings +4. Performance testing + +**Production:** +1. Migrate to Weaviate Cloud Services +2. Upload final embedded lore +3. Players query read-only +4. No changes until next major update + +### Self-Hosted Development Setup + +**Docker Compose Example:** + +```yaml +services: + weaviate: + image: semitechnologies/weaviate:latest + ports: + - "8080:8080" + environment: + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true' # Dev only + PERSISTENCE_DATA_PATH: '/var/lib/weaviate' + volumes: + - weaviate_data:/var/lib/weaviate +``` + +**Hardware Requirements (Self-Hosted):** +- RAM: 4-8GB sufficient for beta +- CPU: Low (no heavy re-indexing) +- Storage: Minimal (vectors are compact) + +--- + +## Migration Path: Dev → Production + +### Zero-Code Migration + +1. Export data from self-hosted Weaviate (backup tools) +2. Create Weaviate Cloud Services cluster +3. Import data to WCS +4. Change environment variable: `WEAVIATE_URL` +5. Deploy code (no code changes required) + +**Environment Configuration:** + +```yaml +# /api/config/development.yaml +weaviate: + url: "http://localhost:8080" + api_key: null + +# /api/config/production.yaml +weaviate: + url: "https://your-cluster.weaviate.network" + api_key: "${WEAVIATE_API_KEY}" # From .env +``` + +--- + +## Embedding Strategy + +### One-Time Embedding Generation + +Since embeddings are generated once per release, prioritize **quality over cost**. + +**Embedding Model Options:** + +| Model | Pros | Cons | Recommendation | +|-------|------|------|----------------| +| OpenAI `text-embedding-3-large` | High quality, good semantic understanding | Paid per use | **Production** | +| Cohere Embed v3 | Optimized for search, multilingual | Paid per use | **Production Alternative** | +| sentence-transformers (OSS) | Free, self-host, fast iteration | Lower quality | **Development/Testing** | + +**Recommendation:** +- **Development:** Use open-source models (iterate faster, zero cost) +- **Production:** Use OpenAI or Replicate https://replicate.com/beautyyuyanli/multilingual-e5-large (quality matters for player experience) + +### Embedding Generation Script + +Will be implemented in `/api/scripts/generate_lore_embeddings.py`: +1. Read lore files (YAML/JSON/Markdown) +2. Chunk content appropriately +3. Generate embeddings using chosen model +4. Upload to Weaviate with metadata +5. Validate retrieval quality + +--- + +## Content Management + +### Lore Content Structure + +**Storage Location:** `/api/app/data/lore/` + +``` +/api/app/data/lore/ + world/ + history.yaml + mythology.yaml + kingdoms.yaml + regions/ + thornhelm/ + history.yaml + locations.yaml + rumors.yaml + silverwood/ + history.yaml + locations.yaml + rumors.yaml +``` + +**Example Lore Entry (YAML):** + +```yaml +- id: "thornhelm_founding" + title: "The Founding of Thornhelm" + content: | + Thornhelm was founded in the year 847 by Lord Theron the Bold, + a retired general seeking to establish a frontier town... + metadata: + region_id: "thornhelm" + knowledge_type: "common" + time_period: "historical" + required_profession: null # Anyone can know this + social_class: null # All classes + tags: + - "founding" + - "lord-theron" + - "history" +``` + +### Version Control for Lore Updates + +**Complete Re-Index Strategy** (Simplest, recommended): +1. Delete old collections during maintenance window +2. Upload new lore with embeddings +3. Atomic cutover +4. Works great for infrequent major updates + +**Alternative: Versioned Collections** (Overkill for our use case): +- `WorldLore_v1`, `WorldLore_v2` +- More overhead, probably unnecessary + +--- + +## Performance & Cost Optimization + +### Cost Considerations + +**Embedding Generation:** +- One-time cost per lore chunk +- Only re-generate during major updates +- Estimated cost: $X per 1000 chunks (TBD based on model choice) + +**Vector Search:** +- No embedding cost for queries (just retrieval) +- Self-hosted: Infrastructure cost only +- Managed (WCS): Pay for storage + queries + +**Optimization Strategies:** +- Pre-compute all embeddings at build time +- Cache frequently accessed regional DBs in Redis +- Only search World Lore DB if regional search returns no results (fallback pattern) +- Use cheaper embedding models for non-critical content + +### Retrieval Performance + +**Expected Query Times:** +- Semantic search: < 100ms +- With metadata filtering: < 150ms +- Hybrid search: < 200ms + +**Caching Strategy:** +- Cache top N regional lore chunks per active region in Redis +- TTL: 1 hour (or until session ends) +- Invalidate on major lore updates + +--- + +## Multiplayer Considerations + +### Shared World State + +If multiple characters are in the same town talking to NPCs: +- **Regional DB**: Shared (same lore for everyone) +- **World DB**: Shared +- **NPC Interactions**: Character-specific (stored in `character.npc_interactions`) + +**Result:** NPCs can reference world events consistently across players while maintaining individual relationships. + +--- + +## Testing Strategy + +### Validation Steps + +1. **Retrieval Quality Testing** + - Does semantic search return relevant lore? + - Are metadata filters working correctly? + - Do NPCs find appropriate information? + +2. **NPC Knowledge Boundaries** + - Can a farmer access academic knowledge? (Should be filtered out) + - Do profession filters work as expected? + - Do NPCs authentically say "I don't know" when appropriate? + +3. **Performance Testing** + - Query response times under load + - Cache hit rates + - Memory usage with multiple active regions + +4. **Content Quality** + - Is lore consistent across databases? + - Are there contradictions between world/regional lore? + - Is chunk size appropriate for context? + +--- + +## Implementation Phases + +### Phase 1: Proof of Concept (Current) +- [ ] Set up local Weaviate with Docker +- [ ] Create sample lore chunks (20-30 entries for one town) +- [ ] Generate embeddings and upload to Weaviate +- [ ] Build simple API endpoint for querying Weaviate +- [ ] Test NPC conversation with lore augmentation + +### Phase 2: Core Implementation +- [ ] Define lore content structure (YAML schema) +- [ ] Write lore for starter region +- [ ] Implement embedding generation script +- [ ] Create Weaviate service layer in `/api/app/services/weaviate_service.py` +- [ ] Integrate with NPC conversation system +- [ ] Add DM lore query endpoints + +### Phase 3: Content Expansion +- [ ] Write world lore content +- [ ] Write lore for additional regions +- [ ] Implement knowledge filtering logic +- [ ] Add lore discovery system (optional: player codex) + +### Phase 4: Production Readiness +- [ ] Migrate to Weaviate Cloud Services +- [ ] Performance optimization and caching +- [ ] Backup and disaster recovery +- [ ] Monitoring and alerting + +--- + +## Open Questions + +1. **Authoring Tools**: How will we create/maintain lore content efficiently? + - Manual YAML editing? + - AI-generated lore with human review? + - Web-based CMS? + +2. **Lore Discovery**: Should players unlock lore entries (codex-style) as they learn about them? + - Could be fun for completionists + - Adds gameplay loop around exploration + +3. **Dynamic Lore**: How to handle time-sensitive rumors or evolving world state? + - Separate "Rumors" collection with expiration dates? + - Regional events that trigger new lore entries? + +4. **Chunk Size**: What's optimal for context vs. precision? + - Too small: NPCs miss broader context + - Too large: Less precise retrieval + - Needs testing to determine + +5. **Consistency Validation**: How to ensure regional lore doesn't contradict world lore? + - Automated consistency checks? + - Manual review process? + - Lore versioning and dependency tracking? + +--- + +## Future Enhancements + +- **Player-Generated Lore**: Allow DMs to add custom lore entries during sessions +- **Lore Relationships**: Graph connections between related lore entries +- **Multilingual Support**: Embed lore in multiple languages +- **Seasonal/Event Lore**: Time-based lore that appears during special events +- **Quest Integration**: Automatic lore unlock based on quest completion + +--- + +## References + +- **Weaviate Documentation**: https://weaviate.io/developers/weaviate +- **RAG Pattern Best Practices**: (TBD) +- **Embedding Model Comparisons**: (TBD) + +--- + +## Notes + +This strategy aligns with the project's core principles: +- **Strong typing**: Lore models will use dataclasses +- **Configuration-driven**: Lore content in YAML/JSON +- **Microservices architecture**: Weaviate is independent service +- **Cost-conscious**: Index-once strategy minimizes ongoing costs +- **Future-proof**: Easy migration from self-hosted to managed