diff --git a/.gitignore b/.gitignore index 9945a2a..acfb7ee 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ Thumbs.db logs/ app/logs/ *.log +CLAUDE.md diff --git a/api/app/__init__.py b/api/app/__init__.py index 94cff75..4d46a26 100644 --- a/api/app/__init__.py +++ b/api/app/__init__.py @@ -169,8 +169,17 @@ 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") + + # Import and register Inventory API blueprint + from app.api.inventory import inventory_bp + app.register_blueprint(inventory_bp) + logger.info("Inventory 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..34e3fe4 --- /dev/null +++ b/api/app/api/combat.py @@ -0,0 +1,1093 @@ +""" +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.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=400, + message=str(e), + 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=500, + message="Failed to start combat", + 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.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=500, + message="Failed to get combat state", + code="COMBAT_STATE_ERROR" + ) + + +@combat_bp.route('//check', methods=['GET']) +@require_auth +def check_existing_combat(session_id: str): + """ + Check if session has an existing active combat encounter. + + Used to detect stale combat sessions when a player tries to start new + combat. Returns a summary of the existing combat if present. + + Path Parameters: + session_id: Game session ID + + Returns: + If in combat: + { + "has_active_combat": true, + "encounter_id": "enc_abc123", + "round_number": 3, + "status": "active", + "players": [ + {"name": "Hero", "current_hp": 45, "max_hp": 100, "is_alive": true} + ], + "enemies": [ + {"name": "Goblin", "current_hp": 10, "max_hp": 25, "is_alive": true} + ] + } + + If not in combat: + { + "has_active_combat": false + } + """ + user = get_current_user() + + try: + combat_service = get_combat_service() + result = combat_service.check_existing_combat(session_id, user.id) + + if result: + return success_response(result) + else: + return success_response({"has_active_combat": False}) + + except Exception as e: + logger.error("Failed to check existing combat", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + message="Failed to check combat status", + code="COMBAT_CHECK_ERROR" + ) + + +@combat_bp.route('//abandon', methods=['POST']) +@require_auth +def abandon_combat(session_id: str): + """ + Abandon an existing combat encounter without completing it. + + Deletes the encounter from the database and clears the session reference. + No rewards are distributed. Used when a player wants to discard a stale + combat session and start fresh. + + Path Parameters: + session_id: Game session ID + + Returns: + { + "success": true, + "message": "Combat abandoned" + } + + or if no combat existed: + { + "success": false, + "message": "No active combat to abandon" + } + """ + user = get_current_user() + + try: + combat_service = get_combat_service() + abandoned = combat_service.abandon_combat(session_id, user.id) + + if abandoned: + logger.info("Combat abandoned via API", + session_id=session_id) + return success_response({ + "success": True, + "message": "Combat abandoned" + }) + else: + return success_response({ + "success": False, + "message": "No active combat to abandon" + }) + + except Exception as e: + logger.error("Failed to abandon combat", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + message="Failed to abandon combat", + code="COMBAT_ABANDON_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 combatant_id not provided, auto-detect player combatant + if not combatant_id: + try: + combat_service = get_combat_service() + encounter = combat_service.get_combat_state(session_id, user.id) + if encounter: + for combatant in encounter.combatants: + if combatant.is_player: + combatant_id = combatant.combatant_id + break + if not combatant_id: + return validation_error_response( + message="Could not determine player combatant", + details={"field": "combatant_id", "issue": "No player found in combat"} + ) + except Exception as e: + logger.error("Failed to auto-detect combatant", error=str(e)) + 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() + + # Support both target_id (singular) and target_ids (array) + target_ids = data.get("target_ids", []) + if not target_ids and data.get("target_id"): + target_ids = [data.get("target_id")] + + action = CombatAction( + action_type=action_type, + target_ids=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.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=400, + message=str(e), + 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=400, + message=str(e), + 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=500, + message="Failed to execute action", + 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.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=400, + message=str(e), + 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=500, + message="Failed to execute enemy turn", + 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() + + # If combatant_id not provided, auto-detect player combatant + if not combatant_id: + encounter = combat_service.get_combat_state(session_id, user.id) + if encounter: + for combatant in encounter.combatants: + if combatant.is_player: + combatant_id = combatant.combatant_id + break + if not combatant_id: + return validation_error_response( + message="Could not determine player combatant", + details={"field": "combatant_id", "issue": "No player found in combat"} + ) + + action = CombatAction( + action_type="flee", + target_ids=[], + ) + + result = combat_service.execute_action( + session_id=session_id, + user_id=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=400, + message=str(e), + 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=500, + message="Failed to attempt flee", + 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.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=500, + message="Failed to end combat", + code="COMBAT_END_ERROR" + ) + + +# ============================================================================= +# Utility Endpoints +# ============================================================================= + +@combat_bp.route('/encounters', methods=['GET']) +@require_auth +def get_encounters(): + """ + Get random encounter options for the current location. + + Generates multiple encounter groups appropriate for the player's + current location and character level. Used by the "Search for Monsters" + feature on the story page. + + Query Parameters: + session_id: Game session ID (required) + + Returns: + { + "location_name": "Thornwood Forest", + "location_type": "wilderness", + "encounters": [ + { + "group_id": "enc_abc123", + "enemies": ["goblin", "goblin", "goblin_scout"], + "enemy_names": ["Goblin Scout", "Goblin Scout", "Goblin Scout"], + "display_name": "3 Goblin Scouts", + "challenge": "Easy" + }, + ... + ] + } + + Errors: + 400: Missing session_id + 404: Session not found or no character + 404: No enemies found for location + """ + from app.services.session_service import get_session_service + from app.services.character_service import get_character_service + from app.services.encounter_generator import get_encounter_generator + + user = get_current_user() + + # Get session_id from query params + session_id = request.args.get("session_id") + if not session_id: + return validation_error_response( + message="session_id query parameter is required", + details={"field": "session_id", "issue": "Missing required parameter"} + ) + + try: + # Get session to find location and character + session_service = get_session_service() + session = session_service.get_session(session_id, user.id) + + if not session: + return not_found_response(message=f"Session not found: {session_id}") + + # Get character level + character_id = session.get_character_id() + if not character_id: + return not_found_response(message="No character found in session") + + character_service = get_character_service() + character = character_service.get_character(character_id, user.id) + + if not character: + return not_found_response(message=f"Character not found: {character_id}") + + # Get location info from game state + location_name = session.game_state.current_location + location_type = session.game_state.location_type.value + + # Map location types to enemy location_tags + # Some location types may need mapping to available enemy tags + location_type_mapping = { + "town": "town", + "village": "town", # Treat village same as town + "tavern": "tavern", + "wilderness": "wilderness", + "forest": "forest", + "dungeon": "dungeon", + "ruins": "ruins", + "crypt": "crypt", + "road": "road", + "safe_area": "town", # Safe areas might have rats/vermin + "library": "dungeon", # Libraries might have undead guardians + } + mapped_location = location_type_mapping.get(location_type, location_type) + + # Generate encounters + encounter_generator = get_encounter_generator() + encounters = encounter_generator.generate_encounters( + location_type=mapped_location, + character_level=character.level, + num_encounters=4 + ) + + # If no encounters found, try wilderness as fallback + if not encounters and mapped_location != "wilderness": + encounters = encounter_generator.generate_encounters( + location_type="wilderness", + character_level=character.level, + num_encounters=4 + ) + + if not encounters: + return not_found_response( + message=f"No enemies found for location type: {location_type}" + ) + + # Format response + response_data = { + "location_name": location_name, + "location_type": location_type, + "encounters": [enc.to_dict() for enc in encounters] + } + + logger.info("Generated encounter options", + session_id=session_id, + location_type=location_type, + character_level=character.level, + num_encounters=len(encounters)) + + return success_response(response_data) + + except Exception as e: + logger.error("Failed to generate encounters", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + message="Failed to generate encounters", + code="ENCOUNTER_GENERATION_ERROR" + ) + + +@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=500, + message="Failed to list enemies", + 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=500, + message="Failed to get enemy details", + code="ENEMY_DETAILS_ERROR" + ) + + +# ============================================================================= +# Debug Endpoints +# ============================================================================= + +@combat_bp.route('//debug/reset-hp-mp', methods=['POST']) +@require_auth +def debug_reset_hp_mp(session_id: str): + """ + Reset player combatant's HP and MP to full (debug endpoint). + + This is a debug-only endpoint for testing combat without using items. + Resets the player's current_hp to max_hp and current_mp to max_mp. + + Path Parameters: + session_id: Game session ID + + Returns: + { + "success": true, + "message": "HP and MP reset to full", + "current_hp": 100, + "max_hp": 100, + "current_mp": 50, + "max_mp": 50 + } + + Errors: + 404: Session not in combat + """ + from app.services.session_service import get_session_service + + user = get_current_user() + + try: + session_service = get_session_service() + session = session_service.get_session(session_id, user.id) + + if not session or not session.combat_encounter: + return not_found_response(message="Session is not in combat") + + encounter = session.combat_encounter + + # Find player combatant and reset HP/MP + player_combatant = None + for combatant in encounter.combatants: + if combatant.is_player: + combatant.current_hp = combatant.max_hp + combatant.current_mp = combatant.max_mp + player_combatant = combatant + break + + if not player_combatant: + return not_found_response(message="No player combatant found in combat") + + # Save the updated session state + session_service.update_session(session) + + logger.info("Debug: HP/MP reset", + session_id=session_id, + combatant_id=player_combatant.combatant_id) + + return success_response({ + "success": True, + "message": "HP and MP reset to full", + "current_hp": player_combatant.current_hp, + "max_hp": player_combatant.max_hp, + "current_mp": player_combatant.current_mp, + "max_mp": player_combatant.max_mp, + }) + + except Exception as e: + logger.error("Failed to reset HP/MP", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + message="Failed to reset HP/MP", + code="DEBUG_RESET_ERROR" + ) diff --git a/api/app/api/inventory.py b/api/app/api/inventory.py new file mode 100644 index 0000000..f1d1a63 --- /dev/null +++ b/api/app/api/inventory.py @@ -0,0 +1,639 @@ +""" +Inventory API Blueprint + +Endpoints for managing character inventory and equipment. +All endpoints require authentication and enforce ownership validation. + +Endpoints: +- GET /api/v1/characters//inventory - Get character inventory and equipped items +- POST /api/v1/characters//inventory/equip - Equip an item +- POST /api/v1/characters//inventory/unequip - Unequip an item +- POST /api/v1/characters//inventory/use - Use a consumable item +- DELETE /api/v1/characters//inventory/ - Drop an item +""" + +from flask import Blueprint, request + +from app.services.inventory_service import ( + get_inventory_service, + ItemNotFoundError, + CannotEquipError, + InvalidSlotError, + CannotUseItemError, + InventoryFullError, + VALID_SLOTS, + MAX_INVENTORY_SIZE, +) +from app.services.character_service import ( + get_character_service, + CharacterNotFound, +) +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 +inventory_bp = Blueprint('inventory', __name__) + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@inventory_bp.route('/api/v1/characters//inventory', methods=['GET']) +@require_auth +def get_inventory(character_id: str): + """ + Get character inventory and equipped items. + + Args: + character_id: Character ID + + Returns: + 200: Inventory and equipment data + 401: Not authenticated + 404: Character not found or not owned by user + 500: Internal server error + + Example Response: + { + "result": { + "inventory": [ + { + "item_id": "gen_abc123", + "name": "Flaming Dagger", + "item_type": "weapon", + "rarity": "rare", + ... + } + ], + "equipped": { + "weapon": {...}, + "helmet": null, + ... + }, + "inventory_count": 5, + "max_inventory": 100 + } + } + """ + try: + user = get_current_user() + logger.info("Getting inventory", + user_id=user.id, + character_id=character_id) + + # Get character (validates ownership) + char_service = get_character_service() + character = char_service.get_character(character_id, user.id) + + # Get inventory service + inventory_service = get_inventory_service() + + # Get inventory items + inventory_items = inventory_service.get_inventory(character) + + # Get equipped items + equipped_items = inventory_service.get_equipped_items(character) + + # Build equipped dict with all slots (None for empty slots) + equipped_response = {} + for slot in VALID_SLOTS: + item = equipped_items.get(slot) + equipped_response[slot] = item.to_dict() if item else None + + logger.info("Inventory retrieved successfully", + user_id=user.id, + character_id=character_id, + item_count=len(inventory_items)) + + return success_response( + result={ + "inventory": [item.to_dict() for item in inventory_items], + "equipped": equipped_response, + "inventory_count": len(inventory_items), + "max_inventory": MAX_INVENTORY_SIZE, + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except Exception as e: + logger.error("Failed to get inventory", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return error_response( + code="INVENTORY_GET_ERROR", + message="Failed to retrieve inventory", + status=500 + ) + + +@inventory_bp.route('/api/v1/characters//inventory/equip', methods=['POST']) +@require_auth +def equip_item(character_id: str): + """ + Equip an item from inventory to a specified slot. + + Args: + character_id: Character ID + + Request Body: + { + "item_id": "gen_abc123", + "slot": "weapon" + } + + Returns: + 200: Item equipped successfully + 400: Cannot equip item (wrong type, level requirement, etc.) + 401: Not authenticated + 404: Character or item not found + 422: Validation error (invalid slot) + 500: Internal server error + + Example Response: + { + "result": { + "message": "Equipped Flaming Dagger to weapon slot", + "equipped": {...}, + "unequipped_item": null + } + } + """ + try: + user = get_current_user() + + # Get request data + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"error": "Request body is required"} + ) + + item_id = data.get('item_id', '').strip() + slot = data.get('slot', '').strip().lower() + + # Validate required fields + validation_errors = {} + + if not item_id: + validation_errors['item_id'] = "item_id is required" + + if not slot: + validation_errors['slot'] = "slot is required" + + if validation_errors: + return validation_error_response( + message="Validation failed", + details=validation_errors + ) + + logger.info("Equipping item", + user_id=user.id, + character_id=character_id, + item_id=item_id, + slot=slot) + + # Get character (validates ownership) + char_service = get_character_service() + character = char_service.get_character(character_id, user.id) + + # Get inventory service + inventory_service = get_inventory_service() + + # Equip item + previous_item = inventory_service.equip_item( + character, item_id, slot, user.id + ) + + # Get item name for message + equipped_item = character.equipped.get(slot) + item_name = equipped_item.get_display_name() if equipped_item else item_id + + # Build equipped response + equipped_response = {} + for s in VALID_SLOTS: + item = character.equipped.get(s) + equipped_response[s] = item.to_dict() if item else None + + logger.info("Item equipped successfully", + user_id=user.id, + character_id=character_id, + item_id=item_id, + slot=slot, + previous_item=previous_item.item_id if previous_item else None) + + return success_response( + result={ + "message": f"Equipped {item_name} to {slot} slot", + "equipped": equipped_response, + "unequipped_item": previous_item.to_dict() if previous_item else None, + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found for equip", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except ItemNotFoundError as e: + logger.warning("Item not found for equip", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + item_id=item_id if 'item_id' in locals() else 'unknown', + error=str(e)) + return not_found_response(message=str(e)) + + except InvalidSlotError as e: + logger.warning("Invalid slot for equip", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + slot=slot if 'slot' in locals() else 'unknown', + error=str(e)) + return validation_error_response( + message=str(e), + details={"slot": str(e)} + ) + + except CannotEquipError as e: + logger.warning("Cannot equip item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + item_id=item_id if 'item_id' in locals() else 'unknown', + error=str(e)) + return error_response( + code="CANNOT_EQUIP", + message=str(e), + status=400 + ) + + except Exception as e: + logger.error("Failed to equip item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return error_response( + code="EQUIP_ERROR", + message="Failed to equip item", + status=500 + ) + + +@inventory_bp.route('/api/v1/characters//inventory/unequip', methods=['POST']) +@require_auth +def unequip_item(character_id: str): + """ + Unequip an item from a specified slot (returns to inventory). + + Args: + character_id: Character ID + + Request Body: + { + "slot": "weapon" + } + + Returns: + 200: Item unequipped successfully (or slot was empty) + 400: Inventory full, cannot unequip + 401: Not authenticated + 404: Character not found + 422: Validation error (invalid slot) + 500: Internal server error + + Example Response: + { + "result": { + "message": "Unequipped Flaming Dagger from weapon slot", + "unequipped_item": {...}, + "equipped": {...} + } + } + """ + try: + user = get_current_user() + + # Get request data + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"error": "Request body is required"} + ) + + slot = data.get('slot', '').strip().lower() + + if not slot: + return validation_error_response( + message="Validation failed", + details={"slot": "slot is required"} + ) + + logger.info("Unequipping item", + user_id=user.id, + character_id=character_id, + slot=slot) + + # Get character (validates ownership) + char_service = get_character_service() + character = char_service.get_character(character_id, user.id) + + # Get inventory service + inventory_service = get_inventory_service() + + # Unequip item + unequipped_item = inventory_service.unequip_item(character, slot, user.id) + + # Build equipped response + equipped_response = {} + for s in VALID_SLOTS: + item = character.equipped.get(s) + equipped_response[s] = item.to_dict() if item else None + + # Build message + if unequipped_item: + message = f"Unequipped {unequipped_item.get_display_name()} from {slot} slot" + else: + message = f"Slot '{slot}' was already empty" + + logger.info("Item unequipped", + user_id=user.id, + character_id=character_id, + slot=slot, + unequipped_item=unequipped_item.item_id if unequipped_item else None) + + return success_response( + result={ + "message": message, + "unequipped_item": unequipped_item.to_dict() if unequipped_item else None, + "equipped": equipped_response, + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found for unequip", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except InvalidSlotError as e: + logger.warning("Invalid slot for unequip", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + slot=slot if 'slot' in locals() else 'unknown', + error=str(e)) + return validation_error_response( + message=str(e), + details={"slot": str(e)} + ) + + except InventoryFullError as e: + logger.warning("Inventory full, cannot unequip", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return error_response( + code="INVENTORY_FULL", + message=str(e), + status=400 + ) + + except Exception as e: + logger.error("Failed to unequip item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return error_response( + code="UNEQUIP_ERROR", + message="Failed to unequip item", + status=500 + ) + + +@inventory_bp.route('/api/v1/characters//inventory/use', methods=['POST']) +@require_auth +def use_item(character_id: str): + """ + Use a consumable item from inventory. + + Args: + character_id: Character ID + + Request Body: + { + "item_id": "health_potion_small" + } + + Returns: + 200: Item used successfully + 400: Cannot use item (not consumable) + 401: Not authenticated + 404: Character or item not found + 500: Internal server error + + Example Response: + { + "result": { + "item_used": "Small Health Potion", + "effects_applied": [ + { + "effect_name": "Healing", + "effect_type": "hot", + "value": 25, + "message": "Restored 25 HP" + } + ], + "hp_restored": 25, + "mp_restored": 0, + "message": "Used Small Health Potion: Restored 25 HP" + } + } + """ + try: + user = get_current_user() + + # Get request data + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"error": "Request body is required"} + ) + + item_id = data.get('item_id', '').strip() + + if not item_id: + return validation_error_response( + message="Validation failed", + details={"item_id": "item_id is required"} + ) + + logger.info("Using item", + user_id=user.id, + character_id=character_id, + item_id=item_id) + + # Get character (validates ownership) + char_service = get_character_service() + character = char_service.get_character(character_id, user.id) + + # Get inventory service + inventory_service = get_inventory_service() + + # Use consumable + result = inventory_service.use_consumable(character, item_id, user.id) + + logger.info("Item used successfully", + user_id=user.id, + character_id=character_id, + item_id=item_id, + hp_restored=result.hp_restored, + mp_restored=result.mp_restored) + + return success_response(result=result.to_dict()) + + except CharacterNotFound as e: + logger.warning("Character not found for use item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except ItemNotFoundError as e: + logger.warning("Item not found for use", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + item_id=item_id if 'item_id' in locals() else 'unknown', + error=str(e)) + return not_found_response(message=str(e)) + + except CannotUseItemError as e: + logger.warning("Cannot use item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + item_id=item_id if 'item_id' in locals() else 'unknown', + error=str(e)) + return error_response( + code="CANNOT_USE_ITEM", + message=str(e), + status=400 + ) + + except Exception as e: + logger.error("Failed to use item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return error_response( + code="USE_ITEM_ERROR", + message="Failed to use item", + status=500 + ) + + +@inventory_bp.route('/api/v1/characters//inventory/', methods=['DELETE']) +@require_auth +def drop_item(character_id: str, item_id: str): + """ + Drop (remove) an item from inventory. + + Args: + character_id: Character ID + item_id: Item ID to drop + + Returns: + 200: Item dropped successfully + 401: Not authenticated + 404: Character or item not found + 500: Internal server error + + Example Response: + { + "result": { + "message": "Dropped Rusty Sword", + "dropped_item": {...}, + "inventory_count": 4 + } + } + """ + try: + user = get_current_user() + + logger.info("Dropping item", + user_id=user.id, + character_id=character_id, + item_id=item_id) + + # Get character (validates ownership) + char_service = get_character_service() + character = char_service.get_character(character_id, user.id) + + # Get inventory service + inventory_service = get_inventory_service() + + # Drop item + dropped_item = inventory_service.drop_item(character, item_id, user.id) + + logger.info("Item dropped successfully", + user_id=user.id, + character_id=character_id, + item_id=item_id, + item_name=dropped_item.get_display_name()) + + return success_response( + result={ + "message": f"Dropped {dropped_item.get_display_name()}", + "dropped_item": dropped_item.to_dict(), + "inventory_count": len(character.inventory), + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found for drop", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except ItemNotFoundError as e: + logger.warning("Item not found for drop", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + item_id=item_id, + error=str(e)) + return not_found_response(message=str(e)) + + except Exception as e: + logger.error("Failed to drop item", + user_id=user.id if 'user' in locals() else 'unknown', + character_id=character_id, + item_id=item_id, + error=str(e)) + return error_response( + code="DROP_ITEM_ERROR", + message="Failed to drop item", + status=500 + ) diff --git a/api/app/api/sessions.py b/api/app/api/sessions.py index cdd662a..c3fd4d6 100644 --- a/api/app/api/sessions.py +++ b/api/app/api/sessions.py @@ -132,23 +132,44 @@ def list_sessions(): user = get_current_user() user_id = user.id session_service = get_session_service() + character_service = get_character_service() # Get user's active sessions sessions = session_service.get_user_sessions(user_id, active_only=True) + # Build character name lookup for efficiency + character_ids = [s.solo_character_id for s in sessions if s.solo_character_id] + character_names = {} + for char_id in character_ids: + try: + char = character_service.get_character(char_id, user_id) + if char: + character_names[char_id] = char.name + except Exception: + pass # Character may have been deleted + # Build response with basic session info sessions_list = [] for session in sessions: + # Get combat round if in combat + combat_round = None + if session.is_in_combat() and session.combat_encounter: + combat_round = session.combat_encounter.round_number + sessions_list.append({ 'session_id': session.session_id, 'character_id': session.solo_character_id, + 'character_name': character_names.get(session.solo_character_id), 'turn_number': session.turn_number, 'status': session.status.value, 'created_at': session.created_at, 'last_activity': session.last_activity, + 'in_combat': session.is_in_combat(), 'game_state': { 'current_location': session.game_state.current_location, - 'location_type': session.game_state.location_type.value + 'location_type': session.game_state.location_type.value, + 'in_combat': session.is_in_combat(), + 'combat_round': combat_round } }) @@ -485,10 +506,12 @@ def get_session_state(session_id: str): "character_id": session.get_character_id(), "turn_number": session.turn_number, "status": session.status.value, + "in_combat": session.is_in_combat(), "game_state": { "current_location": session.game_state.current_location, "location_type": session.game_state.location_type.value, - "active_quests": session.game_state.active_quests + "active_quests": session.game_state.active_quests, + "in_combat": session.is_in_combat() }, "available_actions": available_actions }) diff --git a/api/app/data/affixes/prefixes.yaml b/api/app/data/affixes/prefixes.yaml new file mode 100644 index 0000000..294ce2e --- /dev/null +++ b/api/app/data/affixes/prefixes.yaml @@ -0,0 +1,177 @@ +# Item Prefix Affixes +# Prefixes appear before the item name: "Flaming Dagger" +# +# Affix Structure: +# affix_id: Unique identifier +# name: Display name (what appears in the item name) +# affix_type: "prefix" +# tier: "minor" (RARE), "major" (EPIC), "legendary" (LEGENDARY only) +# description: Flavor text describing the effect +# stat_bonuses: Dict of stat_name -> bonus value +# defense_bonus: Direct defense bonus +# resistance_bonus: Direct resistance bonus +# damage_bonus: Flat damage bonus (weapons) +# damage_type: Elemental damage type +# elemental_ratio: Portion converted to elemental (0.0-1.0) +# crit_chance_bonus: Added to crit chance +# crit_multiplier_bonus: Added to crit multiplier +# allowed_item_types: [] = all types, or ["weapon", "armor"] +# required_rarity: null = any, or "legendary" + +prefixes: + # ==================== ELEMENTAL PREFIXES (FIRE) ==================== + flaming: + affix_id: "flaming" + name: "Flaming" + affix_type: "prefix" + tier: "minor" + description: "Imbued with fire magic, dealing bonus fire damage" + damage_type: "fire" + elemental_ratio: 0.25 + damage_bonus: 3 + allowed_item_types: ["weapon"] + + blazing: + affix_id: "blazing" + name: "Blazing" + affix_type: "prefix" + tier: "major" + description: "Wreathed in intense flames" + damage_type: "fire" + elemental_ratio: 0.35 + damage_bonus: 6 + allowed_item_types: ["weapon"] + + # ==================== ELEMENTAL PREFIXES (ICE) ==================== + frozen: + affix_id: "frozen" + name: "Frozen" + affix_type: "prefix" + tier: "minor" + description: "Enchanted with frost magic" + damage_type: "ice" + elemental_ratio: 0.25 + damage_bonus: 3 + allowed_item_types: ["weapon"] + + glacial: + affix_id: "glacial" + name: "Glacial" + affix_type: "prefix" + tier: "major" + description: "Encased in eternal ice" + damage_type: "ice" + elemental_ratio: 0.35 + damage_bonus: 6 + allowed_item_types: ["weapon"] + + # ==================== ELEMENTAL PREFIXES (LIGHTNING) ==================== + shocking: + affix_id: "shocking" + name: "Shocking" + affix_type: "prefix" + tier: "minor" + description: "Crackles with electrical energy" + damage_type: "lightning" + elemental_ratio: 0.25 + damage_bonus: 3 + allowed_item_types: ["weapon"] + + thundering: + affix_id: "thundering" + name: "Thundering" + affix_type: "prefix" + tier: "major" + description: "Charged with the power of storms" + damage_type: "lightning" + elemental_ratio: 0.35 + damage_bonus: 6 + allowed_item_types: ["weapon"] + + # ==================== MATERIAL PREFIXES ==================== + iron: + affix_id: "iron" + name: "Iron" + affix_type: "prefix" + tier: "minor" + description: "Reinforced with sturdy iron" + stat_bonuses: + constitution: 1 + defense_bonus: 2 + + steel: + affix_id: "steel" + name: "Steel" + affix_type: "prefix" + tier: "major" + description: "Forged from fine steel" + stat_bonuses: + constitution: 2 + strength: 1 + defense_bonus: 4 + + # ==================== QUALITY PREFIXES ==================== + sharp: + affix_id: "sharp" + name: "Sharp" + affix_type: "prefix" + tier: "minor" + description: "Honed to a fine edge" + damage_bonus: 3 + crit_chance_bonus: 0.02 + allowed_item_types: ["weapon"] + + keen: + affix_id: "keen" + name: "Keen" + affix_type: "prefix" + tier: "major" + description: "Razor-sharp edge that finds weak points" + damage_bonus: 5 + crit_chance_bonus: 0.04 + allowed_item_types: ["weapon"] + + # ==================== DEFENSIVE PREFIXES ==================== + sturdy: + affix_id: "sturdy" + name: "Sturdy" + affix_type: "prefix" + tier: "minor" + description: "Built to withstand punishment" + defense_bonus: 3 + allowed_item_types: ["armor"] + + reinforced: + affix_id: "reinforced" + name: "Reinforced" + affix_type: "prefix" + tier: "major" + description: "Heavily reinforced for maximum protection" + defense_bonus: 5 + resistance_bonus: 2 + allowed_item_types: ["armor"] + + # ==================== LEGENDARY PREFIXES ==================== + infernal: + affix_id: "infernal" + name: "Infernal" + affix_type: "prefix" + tier: "legendary" + description: "Burns with hellfire" + damage_type: "fire" + elemental_ratio: 0.45 + damage_bonus: 12 + allowed_item_types: ["weapon"] + required_rarity: "legendary" + + vorpal: + affix_id: "vorpal" + name: "Vorpal" + affix_type: "prefix" + tier: "legendary" + description: "Cuts through anything with supernatural precision" + damage_bonus: 10 + crit_chance_bonus: 0.08 + crit_multiplier_bonus: 0.5 + allowed_item_types: ["weapon"] + required_rarity: "legendary" diff --git a/api/app/data/affixes/suffixes.yaml b/api/app/data/affixes/suffixes.yaml new file mode 100644 index 0000000..abc8a69 --- /dev/null +++ b/api/app/data/affixes/suffixes.yaml @@ -0,0 +1,155 @@ +# Item Suffix Affixes +# Suffixes appear after the item name: "Dagger of Strength" +# +# Suffix naming convention: +# - Minor tier: "of [Stat]" (e.g., "of Strength") +# - Major tier: "of the [Animal/Element]" (e.g., "of the Bear") +# - Legendary tier: "of the [Mythical]" (e.g., "of the Titan") + +suffixes: + # ==================== STAT SUFFIXES (MINOR) ==================== + of_strength: + affix_id: "of_strength" + name: "of Strength" + affix_type: "suffix" + tier: "minor" + description: "Grants physical power" + stat_bonuses: + strength: 2 + + of_dexterity: + affix_id: "of_dexterity" + name: "of Dexterity" + affix_type: "suffix" + tier: "minor" + description: "Grants agility and precision" + stat_bonuses: + dexterity: 2 + + of_constitution: + affix_id: "of_constitution" + name: "of Fortitude" + affix_type: "suffix" + tier: "minor" + description: "Grants endurance" + stat_bonuses: + constitution: 2 + + of_intelligence: + affix_id: "of_intelligence" + name: "of Intelligence" + affix_type: "suffix" + tier: "minor" + description: "Grants magical aptitude" + stat_bonuses: + intelligence: 2 + + of_wisdom: + affix_id: "of_wisdom" + name: "of Wisdom" + affix_type: "suffix" + tier: "minor" + description: "Grants insight and perception" + stat_bonuses: + wisdom: 2 + + of_charisma: + affix_id: "of_charisma" + name: "of Charm" + affix_type: "suffix" + tier: "minor" + description: "Grants social influence" + stat_bonuses: + charisma: 2 + + of_luck: + affix_id: "of_luck" + name: "of Fortune" + affix_type: "suffix" + tier: "minor" + description: "Grants favor from fate" + stat_bonuses: + luck: 2 + + # ==================== ENHANCED STAT SUFFIXES (MAJOR) ==================== + of_the_bear: + affix_id: "of_the_bear" + name: "of the Bear" + affix_type: "suffix" + tier: "major" + description: "Grants the might and endurance of a bear" + stat_bonuses: + strength: 4 + constitution: 2 + + of_the_fox: + affix_id: "of_the_fox" + name: "of the Fox" + affix_type: "suffix" + tier: "major" + description: "Grants the cunning and agility of a fox" + stat_bonuses: + dexterity: 4 + luck: 2 + + of_the_owl: + affix_id: "of_the_owl" + name: "of the Owl" + affix_type: "suffix" + tier: "major" + description: "Grants the wisdom and insight of an owl" + stat_bonuses: + intelligence: 3 + wisdom: 3 + + # ==================== DEFENSIVE SUFFIXES ==================== + of_protection: + affix_id: "of_protection" + name: "of Protection" + affix_type: "suffix" + tier: "minor" + description: "Offers physical protection" + defense_bonus: 3 + + of_warding: + affix_id: "of_warding" + name: "of Warding" + affix_type: "suffix" + tier: "major" + description: "Wards against physical and magical harm" + defense_bonus: 5 + resistance_bonus: 3 + + # ==================== LEGENDARY SUFFIXES ==================== + of_the_titan: + affix_id: "of_the_titan" + name: "of the Titan" + affix_type: "suffix" + tier: "legendary" + description: "Grants titanic strength and endurance" + stat_bonuses: + strength: 8 + constitution: 4 + required_rarity: "legendary" + + of_the_wind: + affix_id: "of_the_wind" + name: "of the Wind" + affix_type: "suffix" + tier: "legendary" + description: "Swift as the wind itself" + stat_bonuses: + dexterity: 8 + luck: 4 + crit_chance_bonus: 0.05 + required_rarity: "legendary" + + of_invincibility: + affix_id: "of_invincibility" + name: "of Invincibility" + affix_type: "suffix" + tier: "legendary" + description: "Grants supreme protection" + defense_bonus: 10 + resistance_bonus: 8 + required_rarity: "legendary" diff --git a/api/app/data/base_items/armor.yaml b/api/app/data/base_items/armor.yaml new file mode 100644 index 0000000..a04e23f --- /dev/null +++ b/api/app/data/base_items/armor.yaml @@ -0,0 +1,152 @@ +# Base Armor Templates for Procedural Generation +# +# These templates define the foundation that affixes attach to. +# Example: "Leather Vest" + "Sturdy" prefix = "Sturdy Leather Vest" +# +# Armor categories: +# - Cloth: Low defense, high resistance (mages) +# - Leather: Balanced defense/resistance (rogues) +# - Chain: Medium defense, low resistance (versatile) +# - Plate: High defense, low resistance (warriors) + +armor: + # ==================== CLOTH (MAGE ARMOR) ==================== + cloth_robe: + template_id: "cloth_robe" + name: "Cloth Robe" + item_type: "armor" + description: "Simple cloth robes favored by spellcasters" + base_defense: 2 + base_resistance: 5 + base_value: 15 + required_level: 1 + drop_weight: 1.3 + + silk_robe: + template_id: "silk_robe" + name: "Silk Robe" + item_type: "armor" + description: "Fine silk robes that channel magical energy" + base_defense: 3 + base_resistance: 8 + base_value: 40 + required_level: 3 + drop_weight: 0.9 + + arcane_vestments: + template_id: "arcane_vestments" + name: "Arcane Vestments" + item_type: "armor" + description: "Robes woven with magical threads" + base_defense: 5 + base_resistance: 12 + base_value: 80 + required_level: 5 + drop_weight: 0.6 + min_rarity: "uncommon" + + # ==================== LEATHER (ROGUE ARMOR) ==================== + leather_vest: + template_id: "leather_vest" + name: "Leather Vest" + item_type: "armor" + description: "Basic leather protection for agile fighters" + base_defense: 5 + base_resistance: 2 + base_value: 20 + required_level: 1 + drop_weight: 1.3 + + studded_leather: + template_id: "studded_leather" + name: "Studded Leather" + item_type: "armor" + description: "Leather armor reinforced with metal studs" + base_defense: 8 + base_resistance: 3 + base_value: 45 + required_level: 3 + drop_weight: 1.0 + + hardened_leather: + template_id: "hardened_leather" + name: "Hardened Leather" + item_type: "armor" + description: "Boiled and hardened leather for superior protection" + base_defense: 12 + base_resistance: 5 + base_value: 75 + required_level: 5 + drop_weight: 0.7 + min_rarity: "uncommon" + + # ==================== CHAIN (VERSATILE) ==================== + chain_shirt: + template_id: "chain_shirt" + name: "Chain Shirt" + item_type: "armor" + description: "A shirt of interlocking metal rings" + base_defense: 7 + base_resistance: 2 + base_value: 35 + required_level: 2 + drop_weight: 1.0 + + chainmail: + template_id: "chainmail" + name: "Chainmail" + item_type: "armor" + description: "Full chainmail armor covering torso and arms" + base_defense: 10 + base_resistance: 3 + base_value: 50 + required_level: 3 + drop_weight: 1.0 + + heavy_chainmail: + template_id: "heavy_chainmail" + name: "Heavy Chainmail" + item_type: "armor" + description: "Thick chainmail with reinforced rings" + base_defense: 14 + base_resistance: 4 + base_value: 85 + required_level: 5 + drop_weight: 0.7 + min_rarity: "uncommon" + + # ==================== PLATE (WARRIOR ARMOR) ==================== + scale_mail: + template_id: "scale_mail" + name: "Scale Mail" + item_type: "armor" + description: "Overlapping metal scales on leather backing" + base_defense: 12 + base_resistance: 2 + base_value: 60 + required_level: 4 + drop_weight: 0.8 + + half_plate: + template_id: "half_plate" + name: "Half Plate" + item_type: "armor" + description: "Plate armor protecting vital areas" + base_defense: 16 + base_resistance: 2 + base_value: 120 + required_level: 6 + drop_weight: 0.5 + min_rarity: "rare" + + plate_armor: + template_id: "plate_armor" + name: "Plate Armor" + item_type: "armor" + description: "Full metal plate protection" + base_defense: 22 + base_resistance: 3 + base_value: 200 + required_level: 7 + drop_weight: 0.4 + min_rarity: "rare" diff --git a/api/app/data/base_items/weapons.yaml b/api/app/data/base_items/weapons.yaml new file mode 100644 index 0000000..a2327bd --- /dev/null +++ b/api/app/data/base_items/weapons.yaml @@ -0,0 +1,227 @@ +# Base Weapon Templates for Procedural Generation +# +# These templates define the foundation that affixes attach to. +# Example: "Dagger" + "Flaming" prefix = "Flaming Dagger" +# +# Template Structure: +# template_id: Unique identifier +# name: Base item name +# item_type: "weapon" +# description: Flavor text +# base_damage: Weapon damage +# base_value: Gold value +# damage_type: "physical" (default) +# crit_chance: Critical hit chance (0.0-1.0) +# crit_multiplier: Crit damage multiplier +# required_level: Min level to use/drop +# drop_weight: Higher = more common (1.0 = standard) +# min_rarity: Minimum rarity for this template + +weapons: + # ==================== ONE-HANDED SWORDS ==================== + dagger: + template_id: "dagger" + name: "Dagger" + item_type: "weapon" + description: "A small, quick blade for close combat" + base_damage: 6 + base_value: 15 + damage_type: "physical" + crit_chance: 0.08 + crit_multiplier: 2.0 + required_level: 1 + drop_weight: 1.5 + + short_sword: + template_id: "short_sword" + name: "Short Sword" + item_type: "weapon" + description: "A versatile one-handed blade" + base_damage: 10 + base_value: 30 + damage_type: "physical" + crit_chance: 0.06 + crit_multiplier: 2.0 + required_level: 1 + drop_weight: 1.3 + + longsword: + template_id: "longsword" + name: "Longsword" + item_type: "weapon" + description: "A standard warrior's blade" + base_damage: 14 + base_value: 50 + damage_type: "physical" + crit_chance: 0.05 + crit_multiplier: 2.0 + required_level: 3 + drop_weight: 1.0 + + # ==================== TWO-HANDED WEAPONS ==================== + greatsword: + template_id: "greatsword" + name: "Greatsword" + item_type: "weapon" + description: "A massive two-handed blade" + base_damage: 22 + base_value: 100 + damage_type: "physical" + crit_chance: 0.04 + crit_multiplier: 2.5 + required_level: 5 + drop_weight: 0.7 + min_rarity: "uncommon" + + # ==================== AXES ==================== + hatchet: + template_id: "hatchet" + name: "Hatchet" + item_type: "weapon" + description: "A small throwing axe" + base_damage: 8 + base_value: 20 + damage_type: "physical" + crit_chance: 0.06 + crit_multiplier: 2.2 + required_level: 1 + drop_weight: 1.2 + + battle_axe: + template_id: "battle_axe" + name: "Battle Axe" + item_type: "weapon" + description: "A heavy axe designed for combat" + base_damage: 16 + base_value: 60 + damage_type: "physical" + crit_chance: 0.05 + crit_multiplier: 2.3 + required_level: 4 + drop_weight: 0.9 + + # ==================== BLUNT WEAPONS ==================== + club: + template_id: "club" + name: "Club" + item_type: "weapon" + description: "A simple wooden club" + base_damage: 7 + base_value: 10 + damage_type: "physical" + crit_chance: 0.04 + crit_multiplier: 2.0 + required_level: 1 + drop_weight: 1.5 + + mace: + template_id: "mace" + name: "Mace" + item_type: "weapon" + description: "A flanged mace for crushing armor" + base_damage: 12 + base_value: 40 + damage_type: "physical" + crit_chance: 0.05 + crit_multiplier: 2.0 + required_level: 2 + drop_weight: 1.0 + + # ==================== STAVES ==================== + quarterstaff: + template_id: "quarterstaff" + name: "Quarterstaff" + item_type: "weapon" + description: "A simple wooden staff" + base_damage: 6 + base_value: 10 + damage_type: "physical" + crit_chance: 0.05 + crit_multiplier: 2.0 + required_level: 1 + drop_weight: 1.2 + + wizard_staff: + template_id: "wizard_staff" + name: "Wizard Staff" + item_type: "weapon" + description: "A staff attuned to magical energy" + base_damage: 4 + base_spell_power: 12 + base_value: 45 + damage_type: "arcane" + crit_chance: 0.05 + crit_multiplier: 2.0 + required_level: 3 + drop_weight: 0.8 + + arcane_staff: + template_id: "arcane_staff" + name: "Arcane Staff" + item_type: "weapon" + description: "A powerful staff pulsing with arcane power" + base_damage: 6 + base_spell_power: 18 + base_value: 90 + damage_type: "arcane" + crit_chance: 0.06 + crit_multiplier: 2.0 + required_level: 5 + drop_weight: 0.6 + min_rarity: "uncommon" + + # ==================== WANDS ==================== + wand: + template_id: "wand" + name: "Wand" + item_type: "weapon" + description: "A simple magical focus" + base_damage: 2 + base_spell_power: 8 + base_value: 30 + damage_type: "arcane" + crit_chance: 0.06 + crit_multiplier: 2.0 + required_level: 1 + drop_weight: 1.0 + + crystal_wand: + template_id: "crystal_wand" + name: "Crystal Wand" + item_type: "weapon" + description: "A wand topped with a magical crystal" + base_damage: 3 + base_spell_power: 14 + base_value: 60 + damage_type: "arcane" + crit_chance: 0.07 + crit_multiplier: 2.2 + required_level: 4 + drop_weight: 0.8 + + # ==================== RANGED ==================== + shortbow: + template_id: "shortbow" + name: "Shortbow" + item_type: "weapon" + description: "A compact bow for quick shots" + base_damage: 8 + base_value: 25 + damage_type: "physical" + crit_chance: 0.07 + crit_multiplier: 2.0 + required_level: 1 + drop_weight: 1.1 + + longbow: + template_id: "longbow" + name: "Longbow" + item_type: "weapon" + description: "A powerful bow with excellent range" + base_damage: 14 + base_value: 55 + damage_type: "physical" + crit_chance: 0.08 + crit_multiplier: 2.2 + required_level: 4 + drop_weight: 0.9 diff --git a/api/app/data/classes/arcanist.yaml b/api/app/data/classes/arcanist.yaml index d52c1b1..385c70d 100644 --- a/api/app/data/classes/arcanist.yaml +++ b/api/app/data/classes/arcanist.yaml @@ -8,7 +8,7 @@ description: > excel in devastating spell damage, capable of incinerating groups of foes or freezing enemies in place. Choose your element: embrace the flames or command the frost. -# Base stats (total: 65) +# Base stats (total: 65 + luck) base_stats: strength: 8 # Low physical power dexterity: 10 # Average agility @@ -16,6 +16,7 @@ base_stats: intelligence: 15 # Exceptional magical power wisdom: 12 # Above average perception charisma: 11 # Above average social + luck: 9 # Slight chaos magic boost starting_equipment: - worn_staff diff --git a/api/app/data/classes/assassin.yaml b/api/app/data/classes/assassin.yaml index d2da51f..1796bd7 100644 --- a/api/app/data/classes/assassin.yaml +++ b/api/app/data/classes/assassin.yaml @@ -8,7 +8,7 @@ description: > capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace the shadows or perfect the killing blow. -# Base stats (total: 65) +# Base stats (total: 65 + luck) base_stats: strength: 11 # Above average physical power dexterity: 15 # Exceptional agility @@ -16,6 +16,7 @@ base_stats: intelligence: 9 # Below average magic wisdom: 10 # Average perception charisma: 10 # Average social + luck: 12 # High luck for crits and precision starting_equipment: - rusty_dagger diff --git a/api/app/data/classes/lorekeeper.yaml b/api/app/data/classes/lorekeeper.yaml index 2e4c01a..680313e 100644 --- a/api/app/data/classes/lorekeeper.yaml +++ b/api/app/data/classes/lorekeeper.yaml @@ -8,7 +8,7 @@ description: > excel in supporting allies and controlling enemies through clever magic and mental manipulation. Choose your art: weave arcane power or bend reality itself. -# Base stats (total: 67) +# Base stats (total: 67 + luck) base_stats: strength: 8 # Low physical power dexterity: 11 # Above average agility @@ -16,6 +16,7 @@ base_stats: intelligence: 13 # Above average magical power wisdom: 11 # Above average perception charisma: 14 # High social/performance + luck: 10 # Knowledge is its own luck starting_equipment: - tome diff --git a/api/app/data/classes/luminary.yaml b/api/app/data/classes/luminary.yaml index eaffec2..aa3e89e 100644 --- a/api/app/data/classes/luminary.yaml +++ b/api/app/data/classes/luminary.yaml @@ -8,7 +8,7 @@ description: > capable of becoming a guardian angel for their allies or a righteous crusader smiting evil. Choose your calling: protect the innocent or judge the wicked. -# Base stats (total: 68) +# Base stats (total: 68 + luck) base_stats: strength: 9 # Below average physical power dexterity: 9 # Below average agility @@ -16,6 +16,7 @@ base_stats: intelligence: 12 # Above average magical power wisdom: 14 # High perception/divine power charisma: 13 # Above average social + luck: 11 # Divine favor grants fortune starting_equipment: - rusty_mace diff --git a/api/app/data/classes/necromancer.yaml b/api/app/data/classes/necromancer.yaml index ca70795..1af838d 100644 --- a/api/app/data/classes/necromancer.yaml +++ b/api/app/data/classes/necromancer.yaml @@ -8,7 +8,7 @@ description: > excel in draining enemies over time or overwhelming foes with undead minions. Choose your dark art: curse your enemies or raise an army of the dead. -# Base stats (total: 65) +# Base stats (total: 65 + luck) base_stats: strength: 8 # Low physical power dexterity: 10 # Average agility @@ -16,6 +16,7 @@ base_stats: intelligence: 14 # High magical power wisdom: 11 # Above average perception charisma: 12 # Above average social (commands undead) + luck: 7 # Dark arts come with a cost starting_equipment: - bone_wand diff --git a/api/app/data/classes/oathkeeper.yaml b/api/app/data/classes/oathkeeper.yaml index fd70bd6..9456475 100644 --- a/api/app/data/classes/oathkeeper.yaml +++ b/api/app/data/classes/oathkeeper.yaml @@ -8,7 +8,7 @@ description: > capable of becoming an unyielding shield for their allies or a beacon of healing light. Choose your oath: defend the weak or redeem the fallen. -# Base stats (total: 67) +# Base stats (total: 67 + luck) base_stats: strength: 12 # Above average physical power dexterity: 9 # Below average agility @@ -16,6 +16,7 @@ base_stats: intelligence: 10 # Average magic wisdom: 12 # Above average perception charisma: 11 # Above average social + luck: 9 # Honorable, modest fortune starting_equipment: - rusty_sword diff --git a/api/app/data/classes/vanguard.yaml b/api/app/data/classes/vanguard.yaml index 14f6a5d..680c156 100644 --- a/api/app/data/classes/vanguard.yaml +++ b/api/app/data/classes/vanguard.yaml @@ -8,7 +8,7 @@ description: > capable of becoming an unbreakable shield for their allies or a relentless damage dealer. Choose your path: become a stalwart defender or a devastating weapon master. -# Base stats (total: 65, average: 10.83) +# Base stats (total: 65 + luck) base_stats: strength: 14 # High physical power dexterity: 10 # Average agility @@ -16,6 +16,7 @@ base_stats: intelligence: 8 # Low magic wisdom: 10 # Average perception charisma: 9 # Below average social + luck: 8 # Low luck, relies on strength # Starting equipment (minimal) starting_equipment: diff --git a/api/app/data/classes/wildstrider.yaml b/api/app/data/classes/wildstrider.yaml index f68bb22..33f199a 100644 --- a/api/app/data/classes/wildstrider.yaml +++ b/api/app/data/classes/wildstrider.yaml @@ -8,7 +8,7 @@ description: > can become elite marksmen with unmatched accuracy or beast masters commanding powerful animal companions. Choose your path: perfect your aim or unleash the wild. -# Base stats (total: 66) +# Base stats (total: 66 + luck) base_stats: strength: 10 # Average physical power dexterity: 14 # High agility @@ -16,6 +16,7 @@ base_stats: intelligence: 9 # Below average magic wisdom: 13 # Above average perception charisma: 9 # Below average social + luck: 10 # Average luck, self-reliant starting_equipment: - rusty_bow diff --git a/api/app/data/enemies/bandit.yaml b/api/app/data/enemies/bandit.yaml new file mode 100644 index 0000000..973f9ff --- /dev/null +++ b/api/app/data/enemies/bandit.yaml @@ -0,0 +1,59 @@ +# 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 + +location_tags: + - wilderness + - road + +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..3ac4828 --- /dev/null +++ b/api/app/data/enemies/dire_wolf.yaml @@ -0,0 +1,56 @@ +# 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 + +location_tags: + - forest + - wilderness + +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..f2f1767 --- /dev/null +++ b/api/app/data/enemies/goblin.yaml @@ -0,0 +1,50 @@ +# 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 + +location_tags: + - forest + - wilderness + - dungeon + +base_damage: 4 +crit_chance: 0.05 +flee_chance: 0.60 diff --git a/api/app/data/enemies/goblin_chieftain.yaml b/api/app/data/enemies/goblin_chieftain.yaml new file mode 100644 index 0000000..05acf04 --- /dev/null +++ b/api/app/data/enemies/goblin_chieftain.yaml @@ -0,0 +1,90 @@ +# Goblin Chieftain - Hard variant, elite tribe leader +# A cunning and powerful goblin leader, adorned with trophies. +# Commands respect through fear and violence, drops quality loot. + +enemy_id: goblin_chieftain +name: Goblin Chieftain +description: > + A large, scarred goblin wearing a crown of teeth and bones. + The chieftain has clawed its way to leadership through countless + battles and betrayals. It wields a well-maintained weapon stolen + from a fallen adventurer and commands its tribe with an iron fist. + +base_stats: + strength: 16 + dexterity: 12 + constitution: 14 + intelligence: 10 + wisdom: 10 + charisma: 12 + luck: 12 + +abilities: + - basic_attack + - shield_bash + - intimidating_shout + +loot_table: + # Static drops - guaranteed materials + - loot_type: static + item_id: goblin_ear + drop_chance: 1.0 + quantity_min: 2 + quantity_max: 3 + - loot_type: static + item_id: goblin_chieftain_token + drop_chance: 0.80 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: goblin_war_paint + drop_chance: 0.50 + quantity_min: 1 + quantity_max: 2 + + # Consumable drops + - loot_type: static + item_id: health_potion_medium + drop_chance: 0.40 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: elixir_of_strength + drop_chance: 0.10 + quantity_min: 1 + quantity_max: 1 + + # Procedural equipment drops - higher chance and rarity bonus + - loot_type: procedural + item_type: weapon + drop_chance: 0.25 + rarity_bonus: 0.10 + quantity_min: 1 + quantity_max: 1 + - loot_type: procedural + item_type: armor + drop_chance: 0.15 + rarity_bonus: 0.05 + quantity_min: 1 + quantity_max: 1 + +experience_reward: 75 +gold_reward_min: 20 +gold_reward_max: 50 +difficulty: hard + +tags: + - humanoid + - goblinoid + - leader + - elite + - armed + +location_tags: + - forest + - wilderness + - dungeon + +base_damage: 14 +crit_chance: 0.15 +flee_chance: 0.25 diff --git a/api/app/data/enemies/goblin_scout.yaml b/api/app/data/enemies/goblin_scout.yaml new file mode 100644 index 0000000..f1419b7 --- /dev/null +++ b/api/app/data/enemies/goblin_scout.yaml @@ -0,0 +1,61 @@ +# Goblin Scout - Easy variant, agile but fragile +# A fast, cowardly goblin that serves as a lookout for its tribe. +# Quick to flee, drops minor loot and the occasional small potion. + +enemy_id: goblin_scout +name: Goblin Scout +description: > + A small, wiry goblin with oversized ears and beady yellow eyes. + Goblin scouts are the first line of awareness for their tribes, + often found lurking in shadows or perched in trees. They prefer + to run rather than fight, but will attack if cornered. + +base_stats: + strength: 6 + dexterity: 14 + constitution: 5 + intelligence: 6 + wisdom: 10 + charisma: 4 + luck: 10 + +abilities: + - basic_attack + +loot_table: + # Static drops - materials and consumables + - loot_type: static + item_id: goblin_ear + drop_chance: 0.60 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: goblin_trinket + drop_chance: 0.20 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: health_potion_small + drop_chance: 0.08 + quantity_min: 1 + quantity_max: 1 + +experience_reward: 10 +gold_reward_min: 1 +gold_reward_max: 4 +difficulty: easy + +tags: + - humanoid + - goblinoid + - small + - scout + +location_tags: + - forest + - wilderness + - dungeon + +base_damage: 3 +crit_chance: 0.08 +flee_chance: 0.70 diff --git a/api/app/data/enemies/goblin_shaman.yaml b/api/app/data/enemies/goblin_shaman.yaml new file mode 100644 index 0000000..4a5019f --- /dev/null +++ b/api/app/data/enemies/goblin_shaman.yaml @@ -0,0 +1,57 @@ +# 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 + +location_tags: + - forest + - wilderness + - dungeon + +base_damage: 3 +crit_chance: 0.08 +flee_chance: 0.55 diff --git a/api/app/data/enemies/goblin_warrior.yaml b/api/app/data/enemies/goblin_warrior.yaml new file mode 100644 index 0000000..e8ca1e0 --- /dev/null +++ b/api/app/data/enemies/goblin_warrior.yaml @@ -0,0 +1,75 @@ +# Goblin Warrior - Medium variant, trained fighter +# A battle-hardened goblin wielding crude but effective weapons. +# More dangerous than scouts, fights in organized groups. + +enemy_id: goblin_warrior +name: Goblin Warrior +description: > + A muscular goblin clad in scavenged armor and wielding a crude + but deadly weapon. Goblin warriors are the backbone of any goblin + warband, trained to fight rather than flee. They attack with + surprising ferocity and coordination. + +base_stats: + strength: 12 + dexterity: 10 + constitution: 10 + intelligence: 6 + wisdom: 6 + charisma: 4 + luck: 8 + +abilities: + - basic_attack + - shield_bash + +loot_table: + # Static drops - materials and consumables + - loot_type: static + item_id: goblin_ear + drop_chance: 0.80 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: goblin_war_paint + drop_chance: 0.15 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: health_potion_small + drop_chance: 0.15 + quantity_min: 1 + quantity_max: 1 + - loot_type: static + item_id: iron_ore + drop_chance: 0.10 + quantity_min: 1 + quantity_max: 2 + + # Procedural equipment drops + - loot_type: procedural + item_type: weapon + drop_chance: 0.08 + rarity_bonus: 0.0 + quantity_min: 1 + quantity_max: 1 + +experience_reward: 25 +gold_reward_min: 5 +gold_reward_max: 15 +difficulty: medium + +tags: + - humanoid + - goblinoid + - warrior + - armed + +location_tags: + - forest + - wilderness + - dungeon + +base_damage: 8 +crit_chance: 0.10 +flee_chance: 0.45 diff --git a/api/app/data/enemies/orc_berserker.yaml b/api/app/data/enemies/orc_berserker.yaml new file mode 100644 index 0000000..4a0e9d3 --- /dev/null +++ b/api/app/data/enemies/orc_berserker.yaml @@ -0,0 +1,62 @@ +# 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 + +location_tags: + - dungeon + - wilderness + +base_damage: 15 +crit_chance: 0.15 +flee_chance: 0.30 diff --git a/api/app/data/enemies/rat.yaml b/api/app/data/enemies/rat.yaml new file mode 100644 index 0000000..2010415 --- /dev/null +++ b/api/app/data/enemies/rat.yaml @@ -0,0 +1,50 @@ +# Giant Rat - Very easy enemy for starting areas (town, village, tavern) +# A basic enemy for new players to learn combat mechanics + +enemy_id: rat +name: Giant Rat +description: > + A mangy rat the size of a small dog. These vermin infest cellars, + sewers, and dark corners of civilization. Weak alone but annoying in packs. + +base_stats: + strength: 4 + dexterity: 14 + constitution: 4 + intelligence: 2 + wisdom: 8 + charisma: 2 + luck: 6 + +abilities: + - basic_attack + +loot_table: + - item_id: rat_tail + drop_chance: 0.40 + quantity_min: 1 + quantity_max: 1 + - item_id: gold_coin + drop_chance: 0.20 + quantity_min: 1 + quantity_max: 2 + +experience_reward: 5 +gold_reward_min: 0 +gold_reward_max: 2 +difficulty: easy + +tags: + - beast + - vermin + - small + +location_tags: + - town + - village + - tavern + - dungeon + +base_damage: 2 +crit_chance: 0.03 +flee_chance: 0.80 diff --git a/api/app/data/enemies/skeleton_warrior.yaml b/api/app/data/enemies/skeleton_warrior.yaml new file mode 100644 index 0000000..5bda01e --- /dev/null +++ b/api/app/data/enemies/skeleton_warrior.yaml @@ -0,0 +1,57 @@ +# 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 + +location_tags: + - crypt + - ruins + - dungeon + +base_damage: 9 +crit_chance: 0.08 +flee_chance: 0.50 diff --git a/api/app/data/static_items/consumables.yaml b/api/app/data/static_items/consumables.yaml new file mode 100644 index 0000000..ed07206 --- /dev/null +++ b/api/app/data/static_items/consumables.yaml @@ -0,0 +1,161 @@ +# Consumable items that drop from enemies or are purchased from vendors +# These items have effects_on_use that trigger when consumed + +items: + # ========================================================================== + # Health Potions + # ========================================================================== + + health_potion_small: + name: "Small Health Potion" + item_type: consumable + rarity: common + description: "A small vial of red liquid that restores a modest amount of health." + value: 25 + is_tradeable: true + effects_on_use: + - effect_id: heal_small + name: "Minor Healing" + effect_type: hot + power: 30 + duration: 1 + stacks: 1 + + health_potion_medium: + name: "Health Potion" + item_type: consumable + rarity: uncommon + description: "A standard healing potion used by adventurers across the realm." + value: 75 + is_tradeable: true + effects_on_use: + - effect_id: heal_medium + name: "Healing" + effect_type: hot + power: 75 + duration: 1 + stacks: 1 + + health_potion_large: + name: "Large Health Potion" + item_type: consumable + rarity: rare + description: "A potent healing draught that restores significant health." + value: 150 + is_tradeable: true + effects_on_use: + - effect_id: heal_large + name: "Major Healing" + effect_type: hot + power: 150 + duration: 1 + stacks: 1 + + # ========================================================================== + # Mana Potions + # ========================================================================== + + mana_potion_small: + name: "Small Mana Potion" + item_type: consumable + rarity: common + description: "A small vial of blue liquid that restores mana." + value: 25 + is_tradeable: true + # Note: MP restoration would need custom effect type or game logic + + mana_potion_medium: + name: "Mana Potion" + item_type: consumable + rarity: uncommon + description: "A standard mana potion favored by spellcasters." + value: 75 + is_tradeable: true + + # ========================================================================== + # Status Effect Cures + # ========================================================================== + + antidote: + name: "Antidote" + item_type: consumable + rarity: common + description: "A bitter herbal remedy that cures poison effects." + value: 30 + is_tradeable: true + + smelling_salts: + name: "Smelling Salts" + item_type: consumable + rarity: uncommon + description: "Pungent salts that can revive unconscious allies or cure stun." + value: 40 + is_tradeable: true + + # ========================================================================== + # Combat Buffs + # ========================================================================== + + elixir_of_strength: + name: "Elixir of Strength" + item_type: consumable + rarity: rare + description: "A powerful elixir that temporarily increases strength." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: str_buff + name: "Strength Boost" + effect_type: buff + power: 5 + duration: 5 + stacks: 1 + + elixir_of_agility: + name: "Elixir of Agility" + item_type: consumable + rarity: rare + description: "A shimmering elixir that enhances reflexes and speed." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: dex_buff + name: "Agility Boost" + effect_type: buff + power: 5 + duration: 5 + stacks: 1 + + # ========================================================================== + # Food Items (simple healing, no combat use) + # ========================================================================== + + ration: + name: "Trail Ration" + item_type: consumable + rarity: common + description: "Dried meat, hardtack, and nuts. Sustains an adventurer on long journeys." + value: 5 + is_tradeable: true + effects_on_use: + - effect_id: ration_heal + name: "Nourishment" + effect_type: hot + power: 10 + duration: 1 + stacks: 1 + + cooked_meat: + name: "Cooked Meat" + item_type: consumable + rarity: common + description: "Freshly cooked meat that restores health." + value: 15 + is_tradeable: true + effects_on_use: + - effect_id: meat_heal + name: "Hearty Meal" + effect_type: hot + power: 20 + duration: 1 + stacks: 1 diff --git a/api/app/data/static_items/equipment.yaml b/api/app/data/static_items/equipment.yaml new file mode 100644 index 0000000..e2d5e68 --- /dev/null +++ b/api/app/data/static_items/equipment.yaml @@ -0,0 +1,138 @@ +# Starting Equipment - Basic items given to new characters based on their class +# These are all common-quality items suitable for Level 1 characters + +items: + # ==================== WEAPONS ==================== + + # Melee Weapons + rusty_sword: + name: Rusty Sword + item_type: weapon + rarity: common + description: > + A battered old sword showing signs of age and neglect. + Its edge is dull but it can still cut. + value: 5 + damage: 4 + damage_type: physical + is_tradeable: true + + rusty_mace: + name: Rusty Mace + item_type: weapon + rarity: common + description: > + A worn mace with a tarnished head. The weight still + makes it effective for crushing blows. + value: 5 + damage: 5 + damage_type: physical + is_tradeable: true + + rusty_dagger: + name: Rusty Dagger + item_type: weapon + rarity: common + description: > + A corroded dagger with a chipped blade. Quick and + deadly in the right hands despite its condition. + value: 4 + damage: 3 + damage_type: physical + crit_chance: 0.10 # Daggers have higher crit chance + is_tradeable: true + + rusty_knife: + name: Rusty Knife + item_type: weapon + rarity: common + description: > + A simple utility knife, more tool than weapon. Every + adventurer keeps one handy for various tasks. + value: 2 + damage: 2 + damage_type: physical + is_tradeable: true + + # Ranged Weapons + rusty_bow: + name: Rusty Bow + item_type: weapon + rarity: common + description: > + An old hunting bow with a frayed string. It still fires + true enough for an aspiring ranger. + value: 5 + damage: 4 + damage_type: physical + is_tradeable: true + + # Magical Weapons (spell_power instead of damage) + worn_staff: + name: Worn Staff + item_type: weapon + rarity: common + description: > + A gnarled wooden staff weathered by time. Faint traces + of arcane energy still pulse through its core. + value: 6 + damage: 2 # Low physical damage for staff strikes + spell_power: 4 # Boosts spell damage + damage_type: arcane + is_tradeable: true + + bone_wand: + name: Bone Wand + item_type: weapon + rarity: common + description: > + A wand carved from ancient bone, cold to the touch. + It resonates with dark energy. + value: 6 + damage: 1 # Minimal physical damage + spell_power: 5 # Higher spell power for dedicated casters + damage_type: shadow # Dark/shadow magic for necromancy + is_tradeable: true + + tome: + name: Worn Tome + item_type: weapon + rarity: common + description: > + A leather-bound book filled with faded notes and arcane + formulas. Knowledge is power made manifest. + value: 6 + damage: 1 # Can bonk someone with it + spell_power: 4 # Boosts spell damage + damage_type: arcane + is_tradeable: true + + # ==================== ARMOR ==================== + + cloth_armor: + name: Cloth Armor + item_type: armor + rarity: common + description: > + Simple padded cloth garments offering minimal protection. + Better than nothing, barely. + value: 5 + defense: 2 + resistance: 1 + is_tradeable: true + + # ==================== SHIELDS/ACCESSORIES ==================== + + rusty_shield: + name: Rusty Shield + item_type: armor + rarity: common + description: > + A battered wooden shield with a rusted metal rim. + It can still block a blow or two. + value: 5 + defense: 3 + resistance: 0 + stat_bonuses: + constitution: 1 + is_tradeable: true diff --git a/api/app/data/static_items/materials.yaml b/api/app/data/static_items/materials.yaml new file mode 100644 index 0000000..4d4062a --- /dev/null +++ b/api/app/data/static_items/materials.yaml @@ -0,0 +1,219 @@ +# Trophy items, crafting materials, and quest items dropped by enemies +# These items don't have combat effects but are used for quests, crafting, or selling + +items: + # ========================================================================== + # Goblin Drops + # ========================================================================== + + goblin_ear: + name: "Goblin Ear" + item_type: quest_item + rarity: common + description: "A severed goblin ear. Proof of a kill, sometimes collected for bounties." + value: 2 + is_tradeable: true + + goblin_trinket: + name: "Goblin Trinket" + item_type: quest_item + rarity: common + description: "A crude piece of jewelry stolen by a goblin. Worth a few coins." + value: 8 + is_tradeable: true + + goblin_war_paint: + name: "Goblin War Paint" + item_type: quest_item + rarity: uncommon + description: "Pungent red and black paint used by goblin warriors before battle." + value: 15 + is_tradeable: true + + goblin_chieftain_token: + name: "Chieftain's Token" + item_type: quest_item + rarity: rare + description: "A carved bone token marking the authority of a goblin chieftain." + value: 50 + is_tradeable: true + + # ========================================================================== + # Wolf/Beast Drops + # ========================================================================== + + wolf_pelt: + name: "Wolf Pelt" + item_type: quest_item + rarity: common + description: "A fur pelt from a wolf. Useful for crafting or selling to tanners." + value: 10 + is_tradeable: true + + dire_wolf_fang: + name: "Dire Wolf Fang" + item_type: quest_item + rarity: uncommon + description: "A large fang from a dire wolf. Prized by craftsmen for weapon making." + value: 25 + is_tradeable: true + + beast_hide: + name: "Beast Hide" + item_type: quest_item + rarity: common + description: "Thick hide from a large beast. Can be tanned into leather." + value: 12 + is_tradeable: true + + # ========================================================================== + # Vermin Drops + # ========================================================================== + + rat_tail: + name: "Rat Tail" + item_type: quest_item + rarity: common + description: "A scaly tail from a giant rat. Sometimes collected for pest control bounties." + value: 1 + is_tradeable: true + + # ========================================================================== + # Undead Drops + # ========================================================================== + + skeleton_bone: + name: "Skeleton Bone" + item_type: quest_item + rarity: common + description: "A bone from an animated skeleton. Retains faint magical energy." + value: 5 + is_tradeable: true + + bone_dust: + name: "Bone Dust" + item_type: quest_item + rarity: common + description: "Powdered bone from undead remains. Used in alchemy and rituals." + value: 8 + is_tradeable: true + + skull_fragment: + name: "Skull Fragment" + item_type: quest_item + rarity: uncommon + description: "A piece of an undead skull, still crackling with dark energy." + value: 20 + is_tradeable: true + + # ========================================================================== + # Orc Drops + # ========================================================================== + + orc_tusk: + name: "Orc Tusk" + item_type: quest_item + rarity: uncommon + description: "A large tusk from an orc warrior. A trophy prized by collectors." + value: 25 + is_tradeable: true + + orc_war_banner: + name: "Orc War Banner" + item_type: quest_item + rarity: rare + description: "A bloodstained banner torn from an orc warband. Proof of a hard fight." + value: 45 + is_tradeable: true + + berserker_charm: + name: "Berserker Charm" + item_type: quest_item + rarity: rare + description: "A crude charm worn by orc berserkers. Said to enhance rage." + value: 60 + is_tradeable: true + + # ========================================================================== + # Bandit Drops + # ========================================================================== + + bandit_mask: + name: "Bandit Mask" + item_type: quest_item + rarity: common + description: "A cloth mask worn by bandits to conceal their identity." + value: 8 + is_tradeable: true + + stolen_coin_pouch: + name: "Stolen Coin Pouch" + item_type: quest_item + rarity: common + description: "A small pouch of coins stolen by bandits. Should be returned." + value: 15 + is_tradeable: true + + wanted_poster: + name: "Wanted Poster" + item_type: quest_item + rarity: uncommon + description: "A crumpled wanted poster. May lead to bounty opportunities." + value: 5 + is_tradeable: true + + # ========================================================================== + # Generic/Currency Items + # ========================================================================== + + gold_coin: + name: "Gold Coin" + item_type: quest_item + rarity: common + description: "A single gold coin. Standard currency across the realm." + value: 1 + is_tradeable: true + + silver_coin: + name: "Silver Coin" + item_type: quest_item + rarity: common + description: "A silver coin worth less than gold but still useful." + value: 1 + is_tradeable: true + + # ========================================================================== + # Crafting Materials (Generic) + # ========================================================================== + + iron_ore: + name: "Iron Ore" + item_type: quest_item + rarity: common + description: "Raw iron ore that can be smelted into ingots." + value: 10 + is_tradeable: true + + leather_scraps: + name: "Leather Scraps" + item_type: quest_item + rarity: common + description: "Scraps of leather useful for crafting and repairs." + value: 5 + is_tradeable: true + + cloth_scraps: + name: "Cloth Scraps" + item_type: quest_item + rarity: common + description: "Torn cloth that can be sewn into bandages or used for crafting." + value: 3 + is_tradeable: true + + magic_essence: + name: "Magic Essence" + item_type: quest_item + rarity: uncommon + description: "Crystallized magical energy. Used in enchanting and alchemy." + value: 30 + is_tradeable: true diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 66b9419..512a304 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.enums import ( EffectType, DamageType, ItemType, + ItemRarity, StatType, AbilityType, CombatStatus, @@ -53,6 +54,7 @@ __all__ = [ "EffectType", "DamageType", "ItemType", + "ItemRarity", "StatType", "AbilityType", "CombatStatus", diff --git a/api/app/models/affixes.py b/api/app/models/affixes.py new file mode 100644 index 0000000..03316aa --- /dev/null +++ b/api/app/models/affixes.py @@ -0,0 +1,305 @@ +""" +Item affix system for procedural item generation. + +This module defines affixes (prefixes and suffixes) that can be attached to items +to provide stat bonuses and generate Diablo-style names like "Flaming Dagger of Strength". +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional + +from app.models.enums import AffixType, AffixTier, DamageType, ItemRarity + + +@dataclass +class Affix: + """ + Represents a single item affix (prefix or suffix). + + Affixes provide stat bonuses and contribute to item naming. + Prefixes appear before the item name: "Flaming Dagger" + Suffixes appear after the item name: "Dagger of Strength" + + Attributes: + affix_id: Unique identifier (e.g., "flaming", "of_strength") + name: Display name for the affix (e.g., "Flaming", "of Strength") + affix_type: PREFIX or SUFFIX + tier: MINOR, MAJOR, or LEGENDARY (determines bonus magnitude) + description: Human-readable description of the affix effect + + Stat Bonuses: + stat_bonuses: Dict mapping stat name to bonus value + Example: {"strength": 2, "constitution": 1} + defense_bonus: Direct defense bonus + resistance_bonus: Direct resistance bonus + + Weapon Properties (PREFIX only, elemental): + damage_bonus: Flat damage bonus added to weapon + damage_type: Elemental damage type (fire, ice, etc.) + elemental_ratio: Portion of damage converted to elemental (0.0-1.0) + crit_chance_bonus: Added to weapon crit chance + crit_multiplier_bonus: Added to crit damage multiplier + + Restrictions: + allowed_item_types: Empty list = all types allowed + required_rarity: Minimum rarity to roll this affix (for legendary-only) + """ + + affix_id: str + name: str + affix_type: AffixType + tier: AffixTier + description: str = "" + + # Stat bonuses (applies to any item) + stat_bonuses: Dict[str, int] = field(default_factory=dict) + defense_bonus: int = 0 + resistance_bonus: int = 0 + + # Weapon-specific bonuses + damage_bonus: int = 0 + damage_type: Optional[DamageType] = None + elemental_ratio: float = 0.0 + crit_chance_bonus: float = 0.0 + crit_multiplier_bonus: float = 0.0 + + # Restrictions + allowed_item_types: List[str] = field(default_factory=list) + required_rarity: Optional[str] = None + + def applies_elemental_damage(self) -> bool: + """ + Check if this affix converts damage to elemental. + + Returns: + True if affix adds elemental damage component + """ + return self.damage_type is not None and self.elemental_ratio > 0.0 + + def is_legendary_only(self) -> bool: + """ + Check if this affix only rolls on legendary items. + + Returns: + True if affix requires legendary rarity + """ + return self.required_rarity == "legendary" + + def can_apply_to(self, item_type: str, rarity: str) -> bool: + """ + Check if this affix can be applied to an item. + + Args: + item_type: Type of item ("weapon", "armor", etc.) + rarity: Item rarity ("common", "rare", "epic", "legendary") + + Returns: + True if affix can be applied, False otherwise + """ + # Check rarity requirement + if self.required_rarity and rarity != self.required_rarity: + return False + + # Check item type restriction + if self.allowed_item_types and item_type not in self.allowed_item_types: + return False + + return True + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize affix to dictionary. + + Returns: + Dictionary containing all affix data + """ + data = asdict(self) + data["affix_type"] = self.affix_type.value + data["tier"] = self.tier.value + if self.damage_type: + data["damage_type"] = self.damage_type.value + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Affix': + """ + Deserialize affix from dictionary. + + Args: + data: Dictionary containing affix data + + Returns: + Affix instance + """ + affix_type = AffixType(data["affix_type"]) + tier = AffixTier(data["tier"]) + damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None + + return cls( + affix_id=data["affix_id"], + name=data["name"], + affix_type=affix_type, + tier=tier, + description=data.get("description", ""), + stat_bonuses=data.get("stat_bonuses", {}), + defense_bonus=data.get("defense_bonus", 0), + resistance_bonus=data.get("resistance_bonus", 0), + damage_bonus=data.get("damage_bonus", 0), + damage_type=damage_type, + elemental_ratio=data.get("elemental_ratio", 0.0), + crit_chance_bonus=data.get("crit_chance_bonus", 0.0), + crit_multiplier_bonus=data.get("crit_multiplier_bonus", 0.0), + allowed_item_types=data.get("allowed_item_types", []), + required_rarity=data.get("required_rarity"), + ) + + def __repr__(self) -> str: + """String representation of the affix.""" + bonuses = [] + if self.stat_bonuses: + bonuses.append(f"stats={self.stat_bonuses}") + if self.damage_bonus: + bonuses.append(f"dmg+{self.damage_bonus}") + if self.defense_bonus: + bonuses.append(f"def+{self.defense_bonus}") + if self.applies_elemental_damage(): + bonuses.append(f"{self.damage_type.value}={self.elemental_ratio:.0%}") + + bonus_str = ", ".join(bonuses) if bonuses else "no bonuses" + return f"Affix({self.name}, {self.affix_type.value}, {self.tier.value}, {bonus_str})" + + +@dataclass +class BaseItemTemplate: + """ + Template for base items used in procedural generation. + + Base items define the foundation (e.g., "Dagger", "Longsword", "Chainmail") + that affixes attach to during item generation. + + Attributes: + template_id: Unique identifier (e.g., "dagger", "longsword") + name: Display name (e.g., "Dagger", "Longsword") + item_type: Category ("weapon", "armor") + description: Flavor text for the base item + + Base Stats: + base_damage: Base weapon damage (weapons only) + base_defense: Base armor defense (armor only) + base_resistance: Base magic resistance (armor only) + base_value: Base gold value before rarity/affix modifiers + + Weapon Properties: + damage_type: Primary damage type (usually "physical") + crit_chance: Base critical hit chance + crit_multiplier: Base critical damage multiplier + + Generation: + required_level: Minimum character level for this template + drop_weight: Weighting for random selection (higher = more common) + min_rarity: Minimum rarity this template can generate at + """ + + template_id: str + name: str + item_type: str # "weapon" or "armor" + description: str = "" + + # Base stats + base_damage: int = 0 + base_spell_power: int = 0 # For magical weapons (staves, wands) + base_defense: int = 0 + base_resistance: int = 0 + base_value: int = 10 + + # Weapon properties + damage_type: str = "physical" + crit_chance: float = 0.05 + crit_multiplier: float = 2.0 + + # Generation settings + required_level: int = 1 + drop_weight: float = 1.0 + min_rarity: str = "common" + + def can_generate_at_rarity(self, rarity: str) -> bool: + """ + Check if this template can generate at a given rarity. + + Some templates (like greatswords) may only drop at rare+. + + Args: + rarity: Target rarity to check + + Returns: + True if template can generate at this rarity + """ + rarity_order = ["common", "uncommon", "rare", "epic", "legendary"] + min_index = rarity_order.index(self.min_rarity) + target_index = rarity_order.index(rarity) + return target_index >= min_index + + def can_drop_for_level(self, character_level: int) -> bool: + """ + Check if this template can drop for a character level. + + Args: + character_level: Character's current level + + Returns: + True if template can drop for this level + """ + return character_level >= self.required_level + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize template to dictionary. + + Returns: + Dictionary containing all template data + """ + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'BaseItemTemplate': + """ + Deserialize template from dictionary. + + Args: + data: Dictionary containing template data + + Returns: + BaseItemTemplate instance + """ + return cls( + template_id=data["template_id"], + name=data["name"], + item_type=data["item_type"], + description=data.get("description", ""), + base_damage=data.get("base_damage", 0), + base_spell_power=data.get("base_spell_power", 0), + base_defense=data.get("base_defense", 0), + base_resistance=data.get("base_resistance", 0), + base_value=data.get("base_value", 10), + damage_type=data.get("damage_type", "physical"), + crit_chance=data.get("crit_chance", 0.05), + crit_multiplier=data.get("crit_multiplier", 2.0), + required_level=data.get("required_level", 1), + drop_weight=data.get("drop_weight", 1.0), + min_rarity=data.get("min_rarity", "common"), + ) + + def __repr__(self) -> str: + """String representation of the template.""" + if self.item_type == "weapon": + return ( + f"BaseItemTemplate({self.name}, weapon, dmg={self.base_damage}, " + f"crit={self.crit_chance*100:.0f}%, lvl={self.required_level})" + ) + elif self.item_type == "armor": + return ( + f"BaseItemTemplate({self.name}, armor, def={self.base_defense}, " + f"res={self.base_resistance}, lvl={self.required_level})" + ) + else: + return f"BaseItemTemplate({self.name}, {self.item_type})" diff --git a/api/app/models/character.py b/api/app/models/character.py index 867780c..f6c6e24 100644 --- a/api/app/models/character.py +++ b/api/app/models/character.py @@ -13,7 +13,7 @@ from app.models.stats import Stats from app.models.items import Item from app.models.skills import PlayerClass, SkillNode from app.models.effects import Effect -from app.models.enums import EffectType, StatType +from app.models.enums import EffectType, StatType, ItemType from app.models.origins import Origin @@ -92,7 +92,11 @@ class Character: This is the CRITICAL METHOD that combines: 1. Base stats (from character) - 2. Equipment bonuses (from equipped items) + 2. Equipment bonuses (from equipped items): + - stat_bonuses dict applied to corresponding stats + - Weapon damage added to damage_bonus + - Weapon spell_power added to spell_power_bonus + - Armor defense/resistance added to defense_bonus/resistance_bonus 3. Skill tree bonuses (from unlocked skills) 4. Active effect modifiers (buffs/debuffs) @@ -100,18 +104,30 @@ class Character: active_effects: Currently active effects on this character (from combat) Returns: - Stats instance with all modifiers applied + Stats instance with all modifiers applied (including computed + damage, defense, resistance properties that incorporate bonuses) """ # Start with a copy of base stats effective = self.base_stats.copy() # Apply equipment bonuses for item in self.equipped.values(): + # Apply stat bonuses from item (e.g., +3 strength) for stat_name, bonus in item.stat_bonuses.items(): if hasattr(effective, stat_name): current_value = getattr(effective, stat_name) setattr(effective, stat_name, current_value + bonus) + # Add weapon damage and spell_power to bonus fields + if item.item_type == ItemType.WEAPON: + effective.damage_bonus += item.damage + effective.spell_power_bonus += item.spell_power + + # Add armor defense and resistance to bonus fields + if item.item_type == ItemType.ARMOR: + effective.defense_bonus += item.defense + effective.resistance_bonus += item.resistance + # Apply skill tree bonuses skill_bonuses = self._get_skill_bonuses() for stat_name, bonus in skill_bonuses.items(): diff --git a/api/app/models/combat.py b/api/app/models/combat.py index 11e20c7..5f9e65a 100644 --- a/api/app/models/combat.py +++ b/api/app/models/combat.py @@ -12,7 +12,7 @@ import random from app.models.stats import Stats from app.models.effects import Effect from app.models.abilities import Ability -from app.models.enums import CombatStatus, EffectType +from app.models.enums import CombatStatus, EffectType, DamageType @dataclass @@ -36,6 +36,12 @@ class Combatant: abilities: Available abilities for this combatant cooldowns: Map of ability_id to turns remaining initiative: Turn order value (rolled at combat start) + weapon_crit_chance: Critical hit chance from equipped weapon + weapon_crit_multiplier: Critical hit damage multiplier + weapon_damage_type: Primary damage type of weapon + elemental_damage_type: Secondary damage type for elemental weapons + physical_ratio: Portion of damage that is physical (0.0-1.0) + elemental_ratio: Portion of damage that is elemental (0.0-1.0) """ combatant_id: str @@ -51,6 +57,16 @@ class Combatant: cooldowns: Dict[str, int] = field(default_factory=dict) initiative: int = 0 + # Weapon properties (for combat calculations) + weapon_crit_chance: float = 0.05 + weapon_crit_multiplier: float = 2.0 + weapon_damage_type: Optional[DamageType] = None + + # Elemental weapon properties (for split damage) + elemental_damage_type: Optional[DamageType] = None + physical_ratio: float = 1.0 + elemental_ratio: float = 0.0 + def is_alive(self) -> bool: """Check if combatant is still alive.""" return self.current_hp > 0 @@ -228,6 +244,12 @@ class Combatant: "abilities": self.abilities, "cooldowns": self.cooldowns, "initiative": self.initiative, + "weapon_crit_chance": self.weapon_crit_chance, + "weapon_crit_multiplier": self.weapon_crit_multiplier, + "weapon_damage_type": self.weapon_damage_type.value if self.weapon_damage_type else None, + "elemental_damage_type": self.elemental_damage_type.value if self.elemental_damage_type else None, + "physical_ratio": self.physical_ratio, + "elemental_ratio": self.elemental_ratio, } @classmethod @@ -236,6 +258,15 @@ class Combatant: stats = Stats.from_dict(data["stats"]) active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])] + # Parse damage types + weapon_damage_type = None + if data.get("weapon_damage_type"): + weapon_damage_type = DamageType(data["weapon_damage_type"]) + + elemental_damage_type = None + if data.get("elemental_damage_type"): + elemental_damage_type = DamageType(data["elemental_damage_type"]) + return cls( combatant_id=data["combatant_id"], name=data["name"], @@ -249,6 +280,12 @@ class Combatant: abilities=data.get("abilities", []), cooldowns=data.get("cooldowns", {}), initiative=data.get("initiative", 0), + weapon_crit_chance=data.get("weapon_crit_chance", 0.05), + weapon_crit_multiplier=data.get("weapon_crit_multiplier", 2.0), + weapon_damage_type=weapon_damage_type, + elemental_damage_type=elemental_damage_type, + physical_ratio=data.get("physical_ratio", 1.0), + elemental_ratio=data.get("elemental_ratio", 0.0), ) @@ -312,14 +349,32 @@ class CombatEncounter: return None def advance_turn(self) -> None: - """Advance to the next combatant's turn.""" - self.current_turn_index += 1 + """Advance to the next alive combatant's turn, skipping dead combatants.""" + # Track starting position to detect full cycle + start_index = self.current_turn_index + rounds_advanced = 0 - # If we've cycled through all combatants, start a new round - if self.current_turn_index >= len(self.turn_order): - self.current_turn_index = 0 - self.round_number += 1 - self.log_action("round_start", None, f"Round {self.round_number} begins") + while True: + self.current_turn_index += 1 + + # If we've cycled through all combatants, start a new round + if self.current_turn_index >= len(self.turn_order): + self.current_turn_index = 0 + self.round_number += 1 + rounds_advanced += 1 + self.log_action("round_start", None, f"Round {self.round_number} begins") + + # Get the current combatant + current = self.get_current_combatant() + + # If combatant is alive, their turn starts + if current and current.is_alive(): + break + + # Safety check: if we've gone through all combatants twice without finding + # someone alive, break to avoid infinite loop (combat should end) + if rounds_advanced >= 2: + break def start_turn(self) -> List[Dict[str, Any]]: """ diff --git a/api/app/models/effects.py b/api/app/models/effects.py index 5347856..b3ee70c 100644 --- a/api/app/models/effects.py +++ b/api/app/models/effects.py @@ -86,7 +86,12 @@ class Effect: elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: # Buff/Debuff: modify stats - result["stat_affected"] = self.stat_affected.value if self.stat_affected else None + # Handle stat_affected being Enum or string + if self.stat_affected: + stat_value = self.stat_affected.value if hasattr(self.stat_affected, 'value') else self.stat_affected + else: + stat_value = None + result["stat_affected"] = stat_value result["stat_modifier"] = self.power * self.stacks if self.effect_type == EffectType.BUFF: result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}" @@ -159,9 +164,17 @@ class Effect: Dictionary containing all effect data """ data = asdict(self) - data["effect_type"] = self.effect_type.value + # Handle effect_type (could be Enum or string) + if hasattr(self.effect_type, 'value'): + data["effect_type"] = self.effect_type.value + else: + data["effect_type"] = self.effect_type + # Handle stat_affected (could be Enum, string, or None) if self.stat_affected: - data["stat_affected"] = self.stat_affected.value + if hasattr(self.stat_affected, 'value'): + data["stat_affected"] = self.stat_affected.value + else: + data["stat_affected"] = self.stat_affected return data @classmethod @@ -193,16 +206,21 @@ class Effect: def __repr__(self) -> str: """String representation of the effect.""" + # Helper to safely get value from Enum or string + def safe_value(obj): + return obj.value if hasattr(obj, 'value') else obj + if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: + stat_str = safe_value(self.stat_affected) if self.stat_affected else 'N/A' return ( - f"Effect({self.name}, {self.effect_type.value}, " - f"{self.stat_affected.value if self.stat_affected else 'N/A'} " + f"Effect({self.name}, {safe_value(self.effect_type)}, " + f"{stat_str} " f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, " f"{self.duration}t, {self.stacks}x)" ) else: return ( - f"Effect({self.name}, {self.effect_type.value}, " + f"Effect({self.name}, {safe_value(self.effect_type)}, " f"power={self.power * self.stacks}, " f"duration={self.duration}t, stacks={self.stacks}x)" ) diff --git a/api/app/models/enemy.py b/api/app/models/enemy.py new file mode 100644 index 0000000..64c449d --- /dev/null +++ b/api/app/models/enemy.py @@ -0,0 +1,282 @@ +""" +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" + + +class LootType(Enum): + """ + Types of loot drops in enemy loot tables. + + STATIC: Fixed item_id reference (consumables, quest items, materials) + PROCEDURAL: Procedurally generated equipment (weapons, armor with affixes) + """ + STATIC = "static" + PROCEDURAL = "procedural" + + +@dataclass +class LootEntry: + """ + Single entry in an enemy's loot table. + + Supports two types of loot: + + STATIC loot (default): + - item_id references a predefined item (health_potion, gold_coin, etc.) + - quantity_min/max define stack size + + PROCEDURAL loot: + - item_type specifies "weapon" or "armor" + - rarity_bonus adds to rarity roll (difficulty contribution) + - Generated equipment uses the ItemGenerator system + + Attributes: + loot_type: Type of loot (static or procedural) + drop_chance: Probability of dropping (0.0 to 1.0) + quantity_min: Minimum quantity if dropped + quantity_max: Maximum quantity if dropped + item_id: Reference to item definition (for STATIC loot) + item_type: Type of equipment to generate (for PROCEDURAL loot) + rarity_bonus: Added to rarity roll (0.0 to 0.5, for PROCEDURAL) + """ + + # Common fields + loot_type: LootType = LootType.STATIC + drop_chance: float = 0.1 + quantity_min: int = 1 + quantity_max: int = 1 + + # Static loot fields + item_id: Optional[str] = None + + # Procedural loot fields + item_type: Optional[str] = None # "weapon" or "armor" + rarity_bonus: float = 0.0 # Added to rarity roll (0.0 to 0.5) + + def to_dict(self) -> Dict[str, Any]: + """Serialize loot entry to dictionary.""" + data = { + "loot_type": self.loot_type.value, + "drop_chance": self.drop_chance, + "quantity_min": self.quantity_min, + "quantity_max": self.quantity_max, + } + # Only include relevant fields based on loot type + if self.item_id is not None: + data["item_id"] = self.item_id + if self.item_type is not None: + data["item_type"] = self.item_type + data["rarity_bonus"] = self.rarity_bonus + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry': + """ + Deserialize loot entry from dictionary. + + Backward compatible: entries without loot_type default to STATIC, + and item_id is required for STATIC entries (for backward compat). + """ + # Parse loot type with backward compatibility + loot_type_str = data.get("loot_type", "static") + loot_type = LootType(loot_type_str) + + return cls( + loot_type=loot_type, + drop_chance=data.get("drop_chance", 0.1), + quantity_min=data.get("quantity_min", 1), + quantity_max=data.get("quantity_max", 1), + item_id=data.get("item_id"), + item_type=data.get("item_type"), + rarity_bonus=data.get("rarity_bonus", 0.0), + ) + + +@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"]) + location_tags: Location types where this enemy appears (e.g., ["forest", "dungeon"]) + 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) + location_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 has_location_tag(self, location_type: str) -> bool: + """Check if enemy can appear at a specific location type.""" + return location_type.lower() in [t.lower() for t in self.location_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, + "location_tags": self.location_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", []), + location_tags=data.get("location_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/enums.py b/api/app/models/enums.py index 206e85c..361e469 100644 --- a/api/app/models/enums.py +++ b/api/app/models/enums.py @@ -29,6 +29,7 @@ class DamageType(Enum): HOLY = "holy" # Holy/divine damage SHADOW = "shadow" # Dark/shadow magic damage POISON = "poison" # Poison damage (usually DoT) + ARCANE = "arcane" # Pure magical damage (staves, wands) class ItemType(Enum): @@ -40,6 +41,31 @@ class ItemType(Enum): QUEST_ITEM = "quest_item" # Story-related, non-tradeable +class ItemRarity(Enum): + """Item rarity tiers affecting drop rates, value, and visual styling.""" + + COMMON = "common" # White/gray - basic items + UNCOMMON = "uncommon" # Green - slightly better + RARE = "rare" # Blue - noticeably better + EPIC = "epic" # Purple - powerful items + LEGENDARY = "legendary" # Orange/gold - best items + + +class AffixType(Enum): + """Types of item affixes for procedural item generation.""" + + PREFIX = "prefix" # Appears before item name: "Flaming Dagger" + SUFFIX = "suffix" # Appears after item name: "Dagger of Strength" + + +class AffixTier(Enum): + """Affix power tiers determining bonus magnitudes.""" + + MINOR = "minor" # Weaker bonuses, rolls on RARE items + MAJOR = "major" # Medium bonuses, rolls on EPIC items + LEGENDARY = "legendary" # Strongest bonuses, LEGENDARY only + + class StatType(Enum): """Character attribute types.""" @@ -49,6 +75,7 @@ class StatType(Enum): INTELLIGENCE = "intelligence" # Magical power WISDOM = "wisdom" # Perception and insight CHARISMA = "charisma" # Social influence + LUCK = "luck" # Fortune and fate class AbilityType(Enum): diff --git a/api/app/models/items.py b/api/app/models/items.py index 10a9bce..77703e0 100644 --- a/api/app/models/items.py +++ b/api/app/models/items.py @@ -8,7 +8,7 @@ including weapons, armor, consumables, and quest items. from dataclasses import dataclass, field, asdict from typing import Dict, Any, List, Optional -from app.models.enums import ItemType, DamageType +from app.models.enums import ItemType, ItemRarity, DamageType from app.models.effects import Effect @@ -24,6 +24,7 @@ class Item: item_id: Unique identifier name: Display name item_type: Category (weapon, armor, consumable, quest_item) + rarity: Rarity tier (common, uncommon, rare, epic, legendary) description: Item lore and information value: Gold value for buying/selling is_tradeable: Whether item can be sold on marketplace @@ -32,7 +33,8 @@ class Item: effects_on_use: Effects applied when consumed (consumables only) Weapon-specific attributes: - damage: Base weapon damage + damage: Base weapon damage (physical/melee/ranged) + spell_power: Spell power for staves/wands (boosts spell damage) damage_type: Type of damage (physical, fire, etc.) crit_chance: Probability of critical hit (0.0 to 1.0) crit_multiplier: Damage multiplier on critical hit @@ -49,7 +51,8 @@ class Item: item_id: str name: str item_type: ItemType - description: str + rarity: ItemRarity = ItemRarity.COMMON + description: str = "" value: int = 0 is_tradeable: bool = True @@ -60,11 +63,18 @@ class Item: effects_on_use: List[Effect] = field(default_factory=list) # Weapon-specific - damage: int = 0 + damage: int = 0 # Physical damage for melee/ranged weapons + spell_power: int = 0 # Spell power for staves/wands (boosts spell damage) damage_type: Optional[DamageType] = None 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 @@ -73,6 +83,24 @@ class Item: required_level: int = 1 required_class: Optional[str] = None + # Affix tracking (for procedurally generated items) + applied_affixes: List[str] = field(default_factory=list) # List of affix_ids + base_template_id: Optional[str] = None # ID of base item template used + generated_name: Optional[str] = None # Full generated name with affixes + is_generated: bool = False # True if created by item generator + + def get_display_name(self) -> str: + """ + Get the item's display name. + + For generated items, returns the affix-enhanced name. + For static items, returns the base name. + + Returns: + Display name string + """ + return self.generated_name or self.name + def is_weapon(self) -> bool: """Check if this item is a weapon.""" return self.item_type == ItemType.WEAPON @@ -89,6 +117,39 @@ 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 is_magical_weapon(self) -> bool: + """ + Check if this weapon is a spell-casting weapon (staff, wand, tome). + + Magical weapons provide spell_power which boosts spell damage, + rather than physical damage for melee/ranged attacks. + + Returns: + True if weapon has spell_power (staves, wands, etc.) + """ + return self.is_weapon() and self.spell_power > 0 + def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool: """ Check if a character can equip this item. @@ -131,9 +192,14 @@ class Item: """ data = asdict(self) data["item_type"] = self.item_type.value + data["rarity"] = self.rarity.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] + # Include display_name for convenience + data["display_name"] = self.get_display_name() return data @classmethod @@ -149,7 +215,13 @@ class Item: """ # Convert string values back to enums item_type = ItemType(data["item_type"]) + rarity = ItemRarity(data.get("rarity", "common")) 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 = [] @@ -160,7 +232,8 @@ class Item: item_id=data["item_id"], name=data["name"], item_type=item_type, - description=data["description"], + rarity=rarity, + description=data.get("description", ""), value=data.get("value", 0), is_tradeable=data.get("is_tradeable", True), stat_bonuses=data.get("stat_bonuses", {}), @@ -169,15 +242,29 @@ 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), required_class=data.get("required_class"), + # Affix tracking fields + applied_affixes=data.get("applied_affixes", []), + base_template_id=data.get("base_template_id"), + generated_name=data.get("generated_name"), + is_generated=data.get("is_generated", False), ) 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/session.py b/api/app/models/session.py index cab046c..bb64a1c 100644 --- a/api/app/models/session.py +++ b/api/app/models/session.py @@ -167,7 +167,8 @@ class GameSession: user_id: Owner of the session party_member_ids: Character IDs in this party (multiplayer only) config: Session configuration settings - combat_encounter: Current combat (None if not in combat) + combat_encounter: Legacy inline combat data (None if not in combat) + active_combat_encounter_id: Reference to combat_encounters table (new system) conversation_history: Turn-by-turn log of actions and DM responses game_state: Current world/quest state turn_order: Character turn order @@ -184,7 +185,8 @@ class GameSession: user_id: str = "" party_member_ids: List[str] = field(default_factory=list) config: SessionConfig = field(default_factory=SessionConfig) - combat_encounter: Optional[CombatEncounter] = None + combat_encounter: Optional[CombatEncounter] = None # Legacy: inline combat data + active_combat_encounter_id: Optional[str] = None # New: reference to combat_encounters table conversation_history: List[ConversationEntry] = field(default_factory=list) game_state: GameState = field(default_factory=GameState) turn_order: List[str] = field(default_factory=list) @@ -202,8 +204,13 @@ class GameSession: self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def is_in_combat(self) -> bool: - """Check if session is currently in combat.""" - return self.combat_encounter is not None + """ + Check if session is currently in combat. + + Checks both the new database reference and legacy inline storage + for backward compatibility. + """ + return self.active_combat_encounter_id is not None or self.combat_encounter is not None def start_combat(self, encounter: CombatEncounter) -> None: """ @@ -341,6 +348,7 @@ class GameSession: "party_member_ids": self.party_member_ids, "config": self.config.to_dict(), "combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None, + "active_combat_encounter_id": self.active_combat_encounter_id, "conversation_history": [entry.to_dict() for entry in self.conversation_history], "game_state": self.game_state.to_dict(), "turn_order": self.turn_order, @@ -382,6 +390,7 @@ class GameSession: party_member_ids=data.get("party_member_ids", []), config=config, combat_encounter=combat_encounter, + active_combat_encounter_id=data.get("active_combat_encounter_id"), conversation_history=conversation_history, game_state=game_state, turn_order=data.get("turn_order", []), diff --git a/api/app/models/stats.py b/api/app/models/stats.py index 083c571..b807f30 100644 --- a/api/app/models/stats.py +++ b/api/app/models/stats.py @@ -21,12 +21,19 @@ class Stats: intelligence: Magical power, affects spell damage and MP wisdom: Perception and insight, affects magical resistance charisma: Social influence, affects NPC interactions + luck: Fortune and fate, affects critical hits, loot, and random outcomes + damage_bonus: Flat damage bonus from equipped weapons (default 0) + spell_power_bonus: Flat spell power bonus from staves/wands (default 0) + defense_bonus: Flat defense bonus from equipped armor (default 0) + resistance_bonus: Flat resistance bonus from equipped armor (default 0) Computed Properties: hit_points: Maximum HP = 10 + (constitution × 2) mana_points: Maximum MP = 10 + (intelligence × 2) - defense: Physical defense = constitution // 2 - resistance: Magical resistance = wisdom // 2 + damage: Physical damage = int(strength × 0.75) + damage_bonus + spell_power: Spell power = int(intelligence × 0.75) + spell_power_bonus + defense: Physical defense = (constitution // 2) + defense_bonus + resistance: Magical resistance = (wisdom // 2) + resistance_bonus """ strength: int = 10 @@ -35,6 +42,13 @@ class Stats: intelligence: int = 10 wisdom: int = 10 charisma: int = 10 + luck: int = 8 + + # Equipment bonus fields (populated by get_effective_stats()) + damage_bonus: int = 0 # From weapons (physical damage) + spell_power_bonus: int = 0 # From staves/wands (magical damage) + defense_bonus: int = 0 # From armor + resistance_bonus: int = 0 # From armor @property def hit_points(self) -> int: @@ -60,29 +74,122 @@ class Stats: """ return 10 + (self.intelligence * 2) + @property + def damage(self) -> int: + """ + Calculate total physical damage from strength and equipment. + + Formula: int(strength * 0.75) + damage_bonus + + The damage_bonus comes from equipped weapons and is populated + by Character.get_effective_stats(). + + Returns: + Total physical damage value + """ + return int(self.strength * 0.75) + self.damage_bonus + + @property + def spell_power(self) -> int: + """ + Calculate spell power from intelligence and equipment. + + Formula: int(intelligence * 0.75) + spell_power_bonus + + The spell_power_bonus comes from equipped staves/wands and is + populated by Character.get_effective_stats(). + + Returns: + Total spell power value + """ + return int(self.intelligence * 0.75) + self.spell_power_bonus + @property def defense(self) -> int: """ - Calculate physical defense from constitution. + Calculate physical defense from constitution and equipment. - Formula: constitution // 2 + Formula: (constitution // 2) + defense_bonus + + The defense_bonus comes from equipped armor and is populated + by Character.get_effective_stats(). Returns: Physical defense value (damage reduction) """ - return self.constitution // 2 + return (self.constitution // 2) + self.defense_bonus @property def resistance(self) -> int: """ - Calculate magical resistance from wisdom. + Calculate magical resistance from wisdom and equipment. - Formula: wisdom // 2 + Formula: (wisdom // 2) + resistance_bonus + + The resistance_bonus comes from equipped armor and is populated + by Character.get_effective_stats(). Returns: Magical resistance value (spell damage reduction) """ - return self.wisdom // 2 + return (self.wisdom // 2) + self.resistance_bonus + + @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]: """ @@ -111,6 +218,11 @@ class Stats: intelligence=data.get("intelligence", 10), wisdom=data.get("wisdom", 10), charisma=data.get("charisma", 10), + luck=data.get("luck", 8), + damage_bonus=data.get("damage_bonus", 0), + spell_power_bonus=data.get("spell_power_bonus", 0), + defense_bonus=data.get("defense_bonus", 0), + resistance_bonus=data.get("resistance_bonus", 0), ) def copy(self) -> 'Stats': @@ -127,6 +239,11 @@ class Stats: intelligence=self.intelligence, wisdom=self.wisdom, charisma=self.charisma, + luck=self.luck, + damage_bonus=self.damage_bonus, + spell_power_bonus=self.spell_power_bonus, + defense_bonus=self.defense_bonus, + resistance_bonus=self.resistance_bonus, ) def __repr__(self) -> str: @@ -134,7 +251,9 @@ class Stats: return ( f"Stats(STR={self.strength}, DEX={self.dexterity}, " f"CON={self.constitution}, INT={self.intelligence}, " - f"WIS={self.wisdom}, CHA={self.charisma}, " + 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"DMG={self.damage}, SP={self.spell_power}, " + 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/affix_loader.py b/api/app/services/affix_loader.py new file mode 100644 index 0000000..0acafc2 --- /dev/null +++ b/api/app/services/affix_loader.py @@ -0,0 +1,315 @@ +""" +Affix Loader Service - YAML-based affix pool loading. + +This service loads prefix and suffix affix definitions from YAML files, +providing a data-driven approach to item generation. +""" + +from pathlib import Path +from typing import Dict, List, Optional +import random +import yaml + +from app.models.affixes import Affix +from app.models.enums import AffixType, AffixTier +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class AffixLoader: + """ + Loads and manages item affixes from YAML configuration files. + + This allows game designers to define affixes without touching code. + Affixes are organized into prefixes.yaml and suffixes.yaml files. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the affix loader. + + Args: + data_dir: Path to directory containing affix YAML files + Defaults to /app/data/affixes/ + """ + if data_dir is None: + # Default to app/data/affixes relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "affixes") + + self.data_dir = Path(data_dir) + self._prefix_cache: Dict[str, Affix] = {} + self._suffix_cache: Dict[str, Affix] = {} + self._loaded = False + + logger.info("AffixLoader initialized", data_dir=str(self.data_dir)) + + def _ensure_loaded(self) -> None: + """Ensure affixes are loaded before any operation.""" + if not self._loaded: + self.load_all() + + def load_all(self) -> None: + """Load all affixes from YAML files.""" + if not self.data_dir.exists(): + logger.warning("Affix data directory not found", path=str(self.data_dir)) + return + + # Load prefixes + prefixes_file = self.data_dir / "prefixes.yaml" + if prefixes_file.exists(): + self._load_affixes_from_file(prefixes_file, self._prefix_cache) + + # Load suffixes + suffixes_file = self.data_dir / "suffixes.yaml" + if suffixes_file.exists(): + self._load_affixes_from_file(suffixes_file, self._suffix_cache) + + self._loaded = True + logger.info( + "Affixes loaded", + prefix_count=len(self._prefix_cache), + suffix_count=len(self._suffix_cache) + ) + + def _load_affixes_from_file( + self, + yaml_file: Path, + cache: Dict[str, Affix] + ) -> None: + """ + Load affixes from a YAML file into the cache. + + Args: + yaml_file: Path to the YAML file + cache: Cache dictionary to populate + """ + try: + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + # Get the top-level key (prefixes or suffixes) + affix_key = "prefixes" if "prefixes" in data else "suffixes" + affixes_data = data.get(affix_key, {}) + + for affix_id, affix_data in affixes_data.items(): + # Ensure affix_id is set + affix_data["affix_id"] = affix_id + + # Set defaults for missing optional fields + affix_data.setdefault("stat_bonuses", {}) + affix_data.setdefault("defense_bonus", 0) + affix_data.setdefault("resistance_bonus", 0) + affix_data.setdefault("damage_bonus", 0) + affix_data.setdefault("elemental_ratio", 0.0) + affix_data.setdefault("crit_chance_bonus", 0.0) + affix_data.setdefault("crit_multiplier_bonus", 0.0) + affix_data.setdefault("allowed_item_types", []) + affix_data.setdefault("required_rarity", None) + + affix = Affix.from_dict(affix_data) + cache[affix.affix_id] = affix + + logger.debug( + "Affixes loaded from file", + file=str(yaml_file), + count=len(affixes_data) + ) + + except Exception as e: + logger.error( + "Failed to load affix file", + file=str(yaml_file), + error=str(e) + ) + + def get_affix(self, affix_id: str) -> Optional[Affix]: + """ + Get a specific affix by ID. + + Args: + affix_id: Unique affix identifier + + Returns: + Affix instance or None if not found + """ + self._ensure_loaded() + + if affix_id in self._prefix_cache: + return self._prefix_cache[affix_id] + if affix_id in self._suffix_cache: + return self._suffix_cache[affix_id] + + return None + + def get_eligible_prefixes( + self, + item_type: str, + rarity: str, + tier: Optional[AffixTier] = None + ) -> List[Affix]: + """ + Get all prefixes eligible for an item. + + Args: + item_type: Type of item ("weapon", "armor") + rarity: Item rarity ("rare", "epic", "legendary") + tier: Optional tier filter + + Returns: + List of eligible Affix instances + """ + self._ensure_loaded() + + eligible = [] + for affix in self._prefix_cache.values(): + # Check if affix can apply to this item + if not affix.can_apply_to(item_type, rarity): + continue + + # Apply tier filter if specified + if tier and affix.tier != tier: + continue + + eligible.append(affix) + + return eligible + + def get_eligible_suffixes( + self, + item_type: str, + rarity: str, + tier: Optional[AffixTier] = None + ) -> List[Affix]: + """ + Get all suffixes eligible for an item. + + Args: + item_type: Type of item ("weapon", "armor") + rarity: Item rarity ("rare", "epic", "legendary") + tier: Optional tier filter + + Returns: + List of eligible Affix instances + """ + self._ensure_loaded() + + eligible = [] + for affix in self._suffix_cache.values(): + # Check if affix can apply to this item + if not affix.can_apply_to(item_type, rarity): + continue + + # Apply tier filter if specified + if tier and affix.tier != tier: + continue + + eligible.append(affix) + + return eligible + + def get_random_prefix( + self, + item_type: str, + rarity: str, + tier: Optional[AffixTier] = None, + exclude_ids: Optional[List[str]] = None + ) -> Optional[Affix]: + """ + Get a random eligible prefix. + + Args: + item_type: Type of item ("weapon", "armor") + rarity: Item rarity + tier: Optional tier filter + exclude_ids: Affix IDs to exclude (for avoiding duplicates) + + Returns: + Random eligible Affix or None if none available + """ + eligible = self.get_eligible_prefixes(item_type, rarity, tier) + + # Filter out excluded IDs + if exclude_ids: + eligible = [a for a in eligible if a.affix_id not in exclude_ids] + + if not eligible: + return None + + return random.choice(eligible) + + def get_random_suffix( + self, + item_type: str, + rarity: str, + tier: Optional[AffixTier] = None, + exclude_ids: Optional[List[str]] = None + ) -> Optional[Affix]: + """ + Get a random eligible suffix. + + Args: + item_type: Type of item ("weapon", "armor") + rarity: Item rarity + tier: Optional tier filter + exclude_ids: Affix IDs to exclude (for avoiding duplicates) + + Returns: + Random eligible Affix or None if none available + """ + eligible = self.get_eligible_suffixes(item_type, rarity, tier) + + # Filter out excluded IDs + if exclude_ids: + eligible = [a for a in eligible if a.affix_id not in exclude_ids] + + if not eligible: + return None + + return random.choice(eligible) + + def get_all_prefixes(self) -> Dict[str, Affix]: + """ + Get all cached prefixes. + + Returns: + Dictionary of prefix affixes + """ + self._ensure_loaded() + return self._prefix_cache.copy() + + def get_all_suffixes(self) -> Dict[str, Affix]: + """ + Get all cached suffixes. + + Returns: + Dictionary of suffix affixes + """ + self._ensure_loaded() + return self._suffix_cache.copy() + + def clear_cache(self) -> None: + """Clear the affix cache, forcing reload on next access.""" + self._prefix_cache.clear() + self._suffix_cache.clear() + self._loaded = False + logger.debug("Affix cache cleared") + + +# Global instance for convenience +_loader_instance: Optional[AffixLoader] = None + + +def get_affix_loader() -> AffixLoader: + """ + Get the global AffixLoader instance. + + Returns: + Singleton AffixLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = AffixLoader() + return _loader_instance diff --git a/api/app/services/base_item_loader.py b/api/app/services/base_item_loader.py new file mode 100644 index 0000000..5fb3023 --- /dev/null +++ b/api/app/services/base_item_loader.py @@ -0,0 +1,274 @@ +""" +Base Item Loader Service - YAML-based base item template loading. + +This service loads base item templates (weapons, armor) from YAML files, +providing the foundation for procedural item generation. +""" + +from pathlib import Path +from typing import Dict, List, Optional +import random +import yaml + +from app.models.affixes import BaseItemTemplate +from app.utils.logging import get_logger + +logger = get_logger(__file__) + +# Rarity order for comparison +RARITY_ORDER = { + "common": 0, + "uncommon": 1, + "rare": 2, + "epic": 3, + "legendary": 4 +} + + +class BaseItemLoader: + """ + Loads and manages base item templates from YAML configuration files. + + This allows game designers to define base items without touching code. + Templates are organized into weapons.yaml and armor.yaml files. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the base item loader. + + Args: + data_dir: Path to directory containing base item YAML files + Defaults to /app/data/base_items/ + """ + if data_dir is None: + # Default to app/data/base_items relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "base_items") + + self.data_dir = Path(data_dir) + self._weapon_cache: Dict[str, BaseItemTemplate] = {} + self._armor_cache: Dict[str, BaseItemTemplate] = {} + self._loaded = False + + logger.info("BaseItemLoader initialized", data_dir=str(self.data_dir)) + + def _ensure_loaded(self) -> None: + """Ensure templates are loaded before any operation.""" + if not self._loaded: + self.load_all() + + def load_all(self) -> None: + """Load all base item templates from YAML files.""" + if not self.data_dir.exists(): + logger.warning("Base item data directory not found", path=str(self.data_dir)) + return + + # Load weapons + weapons_file = self.data_dir / "weapons.yaml" + if weapons_file.exists(): + self._load_templates_from_file(weapons_file, "weapons", self._weapon_cache) + + # Load armor + armor_file = self.data_dir / "armor.yaml" + if armor_file.exists(): + self._load_templates_from_file(armor_file, "armor", self._armor_cache) + + self._loaded = True + logger.info( + "Base item templates loaded", + weapon_count=len(self._weapon_cache), + armor_count=len(self._armor_cache) + ) + + def _load_templates_from_file( + self, + yaml_file: Path, + key: str, + cache: Dict[str, BaseItemTemplate] + ) -> None: + """ + Load templates from a YAML file into the cache. + + Args: + yaml_file: Path to the YAML file + key: Top-level key in YAML (e.g., "weapons", "armor") + cache: Cache dictionary to populate + """ + try: + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + templates_data = data.get(key, {}) + + for template_id, template_data in templates_data.items(): + # Ensure template_id is set + template_data["template_id"] = template_id + + # Set defaults for missing optional fields + template_data.setdefault("description", "") + template_data.setdefault("base_damage", 0) + template_data.setdefault("base_spell_power", 0) + template_data.setdefault("base_defense", 0) + template_data.setdefault("base_resistance", 0) + template_data.setdefault("base_value", 10) + template_data.setdefault("damage_type", "physical") + template_data.setdefault("crit_chance", 0.05) + template_data.setdefault("crit_multiplier", 2.0) + template_data.setdefault("required_level", 1) + template_data.setdefault("drop_weight", 1.0) + template_data.setdefault("min_rarity", "common") + + template = BaseItemTemplate.from_dict(template_data) + cache[template.template_id] = template + + logger.debug( + "Templates loaded from file", + file=str(yaml_file), + count=len(templates_data) + ) + + except Exception as e: + logger.error( + "Failed to load base item file", + file=str(yaml_file), + error=str(e) + ) + + def get_template(self, template_id: str) -> Optional[BaseItemTemplate]: + """ + Get a specific template by ID. + + Args: + template_id: Unique template identifier + + Returns: + BaseItemTemplate instance or None if not found + """ + self._ensure_loaded() + + if template_id in self._weapon_cache: + return self._weapon_cache[template_id] + if template_id in self._armor_cache: + return self._armor_cache[template_id] + + return None + + def get_eligible_templates( + self, + item_type: str, + rarity: str, + character_level: int = 1 + ) -> List[BaseItemTemplate]: + """ + Get all templates eligible for generation. + + Args: + item_type: Type of item ("weapon", "armor") + rarity: Target rarity + character_level: Player level for eligibility + + Returns: + List of eligible BaseItemTemplate instances + """ + self._ensure_loaded() + + # Select the appropriate cache + if item_type == "weapon": + cache = self._weapon_cache + elif item_type == "armor": + cache = self._armor_cache + else: + logger.warning("Unknown item type", item_type=item_type) + return [] + + eligible = [] + for template in cache.values(): + # Check level requirement + if not template.can_drop_for_level(character_level): + continue + + # Check rarity requirement + if not template.can_generate_at_rarity(rarity): + continue + + eligible.append(template) + + return eligible + + def get_random_template( + self, + item_type: str, + rarity: str, + character_level: int = 1 + ) -> Optional[BaseItemTemplate]: + """ + Get a random eligible template, weighted by drop_weight. + + Args: + item_type: Type of item ("weapon", "armor") + rarity: Target rarity + character_level: Player level for eligibility + + Returns: + Random eligible BaseItemTemplate or None if none available + """ + eligible = self.get_eligible_templates(item_type, rarity, character_level) + + if not eligible: + logger.warning( + "No templates match criteria", + item_type=item_type, + rarity=rarity, + level=character_level + ) + return None + + # Weighted random selection based on drop_weight + weights = [t.drop_weight for t in eligible] + return random.choices(eligible, weights=weights, k=1)[0] + + def get_all_weapons(self) -> Dict[str, BaseItemTemplate]: + """ + Get all cached weapon templates. + + Returns: + Dictionary of weapon templates + """ + self._ensure_loaded() + return self._weapon_cache.copy() + + def get_all_armor(self) -> Dict[str, BaseItemTemplate]: + """ + Get all cached armor templates. + + Returns: + Dictionary of armor templates + """ + self._ensure_loaded() + return self._armor_cache.copy() + + def clear_cache(self) -> None: + """Clear the template cache, forcing reload on next access.""" + self._weapon_cache.clear() + self._armor_cache.clear() + self._loaded = False + logger.debug("Base item template cache cleared") + + +# Global instance for convenience +_loader_instance: Optional[BaseItemLoader] = None + + +def get_base_item_loader() -> BaseItemLoader: + """ + Get the global BaseItemLoader instance. + + Returns: + Singleton BaseItemLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = BaseItemLoader() + return _loader_instance diff --git a/api/app/services/character_service.py b/api/app/services/character_service.py index e97e9b5..7ef6db9 100644 --- a/api/app/services/character_service.py +++ b/api/app/services/character_service.py @@ -21,6 +21,7 @@ from app.services.database_service import get_database_service from app.services.appwrite_service import AppwriteService from app.services.class_loader import get_class_loader from app.services.origin_service import get_origin_service +from app.services.static_item_loader import get_static_item_loader from app.utils.logging import get_logger logger = get_logger(__file__) @@ -173,6 +174,23 @@ class CharacterService: current_location=starting_location_id # Set starting location ) + # Add starting equipment to inventory + if player_class.starting_equipment: + item_loader = get_static_item_loader() + for item_id in player_class.starting_equipment: + item = item_loader.get_item(item_id) + if item: + character.add_item(item) + logger.debug("Added starting equipment", + character_id=character_id, + item_id=item_id, + item_name=item.name) + else: + logger.warning("Starting equipment item not found", + character_id=character_id, + item_id=item_id, + class_id=class_id) + # Serialize character to JSON character_dict = character.to_dict() character_json = json.dumps(character_dict) @@ -1074,9 +1092,9 @@ class CharacterService: character_json = json.dumps(character_dict) # Update in database - self.db.update_document( - collection_id=self.collection_id, - document_id=character.character_id, + self.db.update_row( + table_id=self.collection_id, + row_id=character.character_id, data={'characterData': character_json} ) diff --git a/api/app/services/combat_loot_service.py b/api/app/services/combat_loot_service.py new file mode 100644 index 0000000..c990317 --- /dev/null +++ b/api/app/services/combat_loot_service.py @@ -0,0 +1,359 @@ +""" +Combat Loot Service - Orchestrates loot generation from combat encounters. + +This service bridges the EnemyTemplate loot tables with both the StaticItemLoader +(for consumables and materials) and ItemGenerator (for procedural equipment). + +The service calculates effective rarity based on: +- Party average level +- Enemy difficulty tier +- Character luck stat +- Optional loot bonus modifiers (from abilities, buffs, etc.) +""" + +import random +from dataclasses import dataclass +from typing import List, Optional + +from app.models.enemy import EnemyTemplate, LootEntry, LootType, EnemyDifficulty +from app.models.items import Item +from app.services.item_generator import get_item_generator, ItemGenerator +from app.services.static_item_loader import get_static_item_loader, StaticItemLoader +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# Difficulty tier rarity bonuses (converted to effective luck points) +# Higher difficulty enemies have better chances of dropping rare items +DIFFICULTY_RARITY_BONUS = { + EnemyDifficulty.EASY: 0.0, + EnemyDifficulty.MEDIUM: 0.05, + EnemyDifficulty.HARD: 0.15, + EnemyDifficulty.BOSS: 0.30, +} + +# Multiplier for converting rarity bonus to effective luck points +# Each 0.05 bonus translates to +1 effective luck +LUCK_CONVERSION_FACTOR = 20 + + +@dataclass +class LootContext: + """ + Context for loot generation calculations. + + Provides all the factors that influence loot quality and rarity. + + Attributes: + party_average_level: Average level of player characters in the encounter + enemy_difficulty: Difficulty tier of the enemy being looted + luck_stat: Party's luck stat (typically average or leader's luck) + loot_bonus: Additional bonus from abilities, buffs, or modifiers (0.0 to 1.0) + """ + party_average_level: int = 1 + enemy_difficulty: EnemyDifficulty = EnemyDifficulty.EASY + luck_stat: int = 8 + loot_bonus: float = 0.0 + + +class CombatLootService: + """ + Service for generating combat loot drops. + + Supports two types of loot: + - STATIC: Predefined items loaded from YAML (consumables, materials) + - PROCEDURAL: Generated equipment with affixes (weapons, armor) + + The service handles: + - Rolling for drops based on drop_chance + - Loading static items via StaticItemLoader + - Generating procedural items via ItemGenerator + - Calculating effective rarity based on context + """ + + def __init__( + self, + item_generator: Optional[ItemGenerator] = None, + static_loader: Optional[StaticItemLoader] = None + ): + """ + Initialize the combat loot service. + + Args: + item_generator: ItemGenerator instance (uses global singleton if None) + static_loader: StaticItemLoader instance (uses global singleton if None) + """ + self.item_generator = item_generator or get_item_generator() + self.static_loader = static_loader or get_static_item_loader() + logger.info("CombatLootService initialized") + + def generate_loot_from_enemy( + self, + enemy: EnemyTemplate, + context: LootContext + ) -> List[Item]: + """ + Generate all loot drops from a defeated enemy. + + Iterates through the enemy's loot table, rolling for each entry + and generating appropriate items based on loot type. + + Args: + enemy: The defeated enemy template + context: Loot generation context (party level, luck, etc.) + + Returns: + List of Item objects to add to player inventory + """ + items = [] + + for entry in enemy.loot_table: + # Roll for drop chance + if random.random() >= entry.drop_chance: + continue + + # Determine quantity + quantity = random.randint(entry.quantity_min, entry.quantity_max) + + if entry.loot_type == LootType.STATIC: + # Static item: load from predefined templates + static_items = self._generate_static_items(entry, quantity) + items.extend(static_items) + + elif entry.loot_type == LootType.PROCEDURAL: + # Procedural equipment: generate with ItemGenerator + procedural_items = self._generate_procedural_items( + entry, quantity, context + ) + items.extend(procedural_items) + + logger.info( + "Loot generated from enemy", + enemy_id=enemy.enemy_id, + enemy_difficulty=enemy.difficulty.value, + item_count=len(items), + party_level=context.party_average_level, + luck=context.luck_stat + ) + + return items + + def _generate_static_items( + self, + entry: LootEntry, + quantity: int + ) -> List[Item]: + """ + Generate static items from a loot entry. + + Args: + entry: The loot table entry + quantity: Number of items to generate + + Returns: + List of Item instances + """ + items = [] + + if not entry.item_id: + logger.warning( + "Static loot entry missing item_id", + entry=entry.to_dict() + ) + return items + + for _ in range(quantity): + item = self.static_loader.get_item(entry.item_id) + if item: + items.append(item) + else: + logger.warning( + "Failed to load static item", + item_id=entry.item_id + ) + + return items + + def _generate_procedural_items( + self, + entry: LootEntry, + quantity: int, + context: LootContext + ) -> List[Item]: + """ + Generate procedural items from a loot entry. + + Calculates effective luck based on: + - Base luck stat + - Entry-specific rarity bonus + - Difficulty bonus + - Loot bonus from abilities/buffs + + Args: + entry: The loot table entry + quantity: Number of items to generate + context: Loot generation context + + Returns: + List of generated Item instances + """ + items = [] + + if not entry.item_type: + logger.warning( + "Procedural loot entry missing item_type", + entry=entry.to_dict() + ) + return items + + # Calculate effective luck for rarity roll + effective_luck = self._calculate_effective_luck(entry, context) + + for _ in range(quantity): + item = self.item_generator.generate_loot_drop( + character_level=context.party_average_level, + luck_stat=effective_luck, + item_type=entry.item_type + ) + if item: + items.append(item) + else: + logger.warning( + "Failed to generate procedural item", + item_type=entry.item_type, + level=context.party_average_level + ) + + return items + + def _calculate_effective_luck( + self, + entry: LootEntry, + context: LootContext + ) -> int: + """ + Calculate effective luck for rarity rolling. + + Combines multiple factors: + - Base luck stat from party + - Entry-specific rarity bonus (defined per loot entry) + - Difficulty bonus (based on enemy tier) + - Loot bonus (from abilities, buffs, etc.) + + The formula: + effective_luck = base_luck + (entry_bonus + difficulty_bonus + loot_bonus) * FACTOR + + Args: + entry: The loot table entry + context: Loot generation context + + Returns: + Effective luck stat for rarity calculations + """ + # Get difficulty bonus + difficulty_bonus = DIFFICULTY_RARITY_BONUS.get( + context.enemy_difficulty, 0.0 + ) + + # Sum all bonuses + total_bonus = ( + entry.rarity_bonus + + difficulty_bonus + + context.loot_bonus + ) + + # Convert bonus to effective luck points + bonus_luck = int(total_bonus * LUCK_CONVERSION_FACTOR) + + effective_luck = context.luck_stat + bonus_luck + + logger.debug( + "Effective luck calculated", + base_luck=context.luck_stat, + entry_bonus=entry.rarity_bonus, + difficulty_bonus=difficulty_bonus, + loot_bonus=context.loot_bonus, + total_bonus=total_bonus, + effective_luck=effective_luck + ) + + return effective_luck + + def generate_boss_loot( + self, + enemy: EnemyTemplate, + context: LootContext, + guaranteed_drops: int = 1 + ) -> List[Item]: + """ + Generate loot from a boss enemy with guaranteed drops. + + Boss enemies are guaranteed to drop at least one piece of equipment + in addition to their normal loot table rolls. + + Args: + enemy: The boss enemy template + context: Loot generation context + guaranteed_drops: Number of guaranteed equipment drops + + Returns: + List of Item objects including guaranteed drops + """ + # Generate normal loot first + items = self.generate_loot_from_enemy(enemy, context) + + # Add guaranteed procedural drops for bosses + if enemy.is_boss(): + context_for_boss = LootContext( + party_average_level=context.party_average_level, + enemy_difficulty=EnemyDifficulty.BOSS, + luck_stat=context.luck_stat, + loot_bonus=context.loot_bonus + 0.1 # Extra bonus for bosses + ) + + for _ in range(guaranteed_drops): + # Alternate between weapon and armor + item_type = random.choice(["weapon", "armor"]) + effective_luck = self._calculate_effective_luck( + LootEntry( + loot_type=LootType.PROCEDURAL, + item_type=item_type, + rarity_bonus=0.15 # Boss-tier bonus + ), + context_for_boss + ) + + item = self.item_generator.generate_loot_drop( + character_level=context.party_average_level, + luck_stat=effective_luck, + item_type=item_type + ) + if item: + items.append(item) + + logger.info( + "Boss loot generated", + enemy_id=enemy.enemy_id, + guaranteed_drops=guaranteed_drops, + total_items=len(items) + ) + + return items + + +# Global singleton +_service_instance: Optional[CombatLootService] = None + + +def get_combat_loot_service() -> CombatLootService: + """ + Get the global CombatLootService instance. + + Returns: + Singleton CombatLootService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = CombatLootService() + return _service_instance diff --git a/api/app/services/combat_repository.py b/api/app/services/combat_repository.py new file mode 100644 index 0000000..a3cc9b5 --- /dev/null +++ b/api/app/services/combat_repository.py @@ -0,0 +1,578 @@ +""" +Combat Repository - Database operations for combat encounters. + +This service handles all CRUD operations for combat data stored in +dedicated database tables (combat_encounters, combat_rounds). + +Separates combat persistence from the CombatService which handles +business logic and game mechanics. +""" + +import json +from typing import Dict, Any, List, Optional +from datetime import datetime, timezone, timedelta +from uuid import uuid4 + +from appwrite.query import Query + +from app.models.combat import CombatEncounter, Combatant +from app.models.enums import CombatStatus +from app.services.database_service import get_database_service, DatabaseService +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# ============================================================================= +# Exceptions +# ============================================================================= + +class CombatEncounterNotFound(Exception): + """Raised when combat encounter is not found in database.""" + pass + + +class CombatRoundNotFound(Exception): + """Raised when combat round is not found in database.""" + pass + + +# ============================================================================= +# Combat Repository +# ============================================================================= + +class CombatRepository: + """ + Repository for combat encounter database operations. + + Handles: + - Creating and reading combat encounters + - Updating combat state during actions + - Saving per-round history for logging and replay + - Time-based cleanup of old combat data + + Tables: + - combat_encounters: Main encounter state and metadata + - combat_rounds: Per-round action history + """ + + # Table IDs + ENCOUNTERS_TABLE = "combat_encounters" + ROUNDS_TABLE = "combat_rounds" + + # Default retention period for cleanup (days) + DEFAULT_RETENTION_DAYS = 7 + + def __init__(self, db: Optional[DatabaseService] = None): + """ + Initialize the combat repository. + + Args: + db: Optional DatabaseService instance (for testing/injection) + """ + self.db = db or get_database_service() + logger.info("CombatRepository initialized") + + # ========================================================================= + # Encounter CRUD Operations + # ========================================================================= + + def create_encounter( + self, + encounter: CombatEncounter, + session_id: str, + user_id: str + ) -> str: + """ + Create a new combat encounter record. + + Args: + encounter: CombatEncounter instance to persist + session_id: Game session ID this encounter belongs to + user_id: Owner user ID for authorization + + Returns: + encounter_id of created record + """ + created_at = self._get_timestamp() + + data = { + 'sessionId': session_id, + 'userId': user_id, + 'status': encounter.status.value, + 'roundNumber': encounter.round_number, + 'currentTurnIndex': encounter.current_turn_index, + 'turnOrder': json.dumps(encounter.turn_order), + 'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]), + 'combatLog': json.dumps(encounter.combat_log), + 'created_at': created_at, + } + + self.db.create_row( + table_id=self.ENCOUNTERS_TABLE, + data=data, + row_id=encounter.encounter_id + ) + + logger.info("Combat encounter created", + encounter_id=encounter.encounter_id, + session_id=session_id, + combatant_count=len(encounter.combatants)) + + return encounter.encounter_id + + def get_encounter(self, encounter_id: str) -> Optional[CombatEncounter]: + """ + Get a combat encounter by ID. + + Args: + encounter_id: Encounter ID to fetch + + Returns: + CombatEncounter or None if not found + """ + logger.info("Fetching encounter from database", + encounter_id=encounter_id) + + row = self.db.get_row(self.ENCOUNTERS_TABLE, encounter_id) + if not row: + logger.warning("Encounter not found", encounter_id=encounter_id) + return None + + logger.info("Raw database row data", + encounter_id=encounter_id, + currentTurnIndex=row.data.get('currentTurnIndex'), + roundNumber=row.data.get('roundNumber')) + + encounter = self._row_to_encounter(row.data, encounter_id) + + logger.info("Encounter object created", + encounter_id=encounter_id, + current_turn_index=encounter.current_turn_index, + turn_order=encounter.turn_order) + + return encounter + + def get_encounter_by_session( + self, + session_id: str, + active_only: bool = True + ) -> Optional[CombatEncounter]: + """ + Get combat encounter for a session. + + Args: + session_id: Game session ID + active_only: If True, only return active encounters + + Returns: + CombatEncounter or None if not found + """ + queries = [Query.equal('sessionId', session_id)] + if active_only: + queries.append(Query.equal('status', CombatStatus.ACTIVE.value)) + + rows = self.db.list_rows( + table_id=self.ENCOUNTERS_TABLE, + queries=queries, + limit=1 + ) + + if not rows: + return None + + row = rows[0] + return self._row_to_encounter(row.data, row.id) + + def get_user_active_encounters(self, user_id: str) -> List[CombatEncounter]: + """ + Get all active encounters for a user. + + Args: + user_id: User ID to query + + Returns: + List of active CombatEncounter instances + """ + rows = self.db.list_rows( + table_id=self.ENCOUNTERS_TABLE, + queries=[ + Query.equal('userId', user_id), + Query.equal('status', CombatStatus.ACTIVE.value) + ], + limit=25 + ) + + return [self._row_to_encounter(row.data, row.id) for row in rows] + + def update_encounter(self, encounter: CombatEncounter) -> None: + """ + Update an existing combat encounter. + + Call this after each action to persist the updated state. + + Args: + encounter: CombatEncounter with updated state + """ + data = { + 'status': encounter.status.value, + 'roundNumber': encounter.round_number, + 'currentTurnIndex': encounter.current_turn_index, + 'turnOrder': json.dumps(encounter.turn_order), + 'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]), + 'combatLog': json.dumps(encounter.combat_log), + } + + logger.info("Saving encounter to database", + encounter_id=encounter.encounter_id, + current_turn_index=encounter.current_turn_index, + combat_log_entries=len(encounter.combat_log)) + + self.db.update_row( + table_id=self.ENCOUNTERS_TABLE, + row_id=encounter.encounter_id, + data=data + ) + + logger.info("Encounter saved successfully", + encounter_id=encounter.encounter_id) + + def end_encounter( + self, + encounter_id: str, + status: CombatStatus + ) -> None: + """ + Mark an encounter as ended. + + Args: + encounter_id: Encounter ID to end + status: Final status (VICTORY, DEFEAT, FLED) + """ + ended_at = self._get_timestamp() + + data = { + 'status': status.value, + 'ended_at': ended_at, + } + + self.db.update_row( + table_id=self.ENCOUNTERS_TABLE, + row_id=encounter_id, + data=data + ) + + logger.info("Combat encounter ended", + encounter_id=encounter_id, + status=status.value) + + def delete_encounter(self, encounter_id: str) -> bool: + """ + Delete an encounter and all its rounds. + + Args: + encounter_id: Encounter ID to delete + + Returns: + True if deleted successfully + """ + # Delete rounds first + self._delete_rounds_for_encounter(encounter_id) + + # Delete encounter + result = self.db.delete_row(self.ENCOUNTERS_TABLE, encounter_id) + + logger.info("Combat encounter deleted", encounter_id=encounter_id) + return result + + # ========================================================================= + # Round Operations + # ========================================================================= + + def save_round( + self, + encounter_id: str, + session_id: str, + round_number: int, + actions: List[Dict[str, Any]], + states_start: List[Combatant], + states_end: List[Combatant] + ) -> str: + """ + Save a completed round's data for history/replay. + + Call this at the end of each round (after all combatants have acted). + + Args: + encounter_id: Parent encounter ID + session_id: Game session ID (denormalized for queries) + round_number: Round number (1-indexed) + actions: List of all actions taken this round + states_start: Combatant states at round start + states_end: Combatant states at round end + + Returns: + round_id of created record + """ + round_id = f"rnd_{uuid4().hex[:12]}" + created_at = self._get_timestamp() + + data = { + 'encounterId': encounter_id, + 'sessionId': session_id, + 'roundNumber': round_number, + 'actionsData': json.dumps(actions), + 'combatantStatesStart': json.dumps([c.to_dict() for c in states_start]), + 'combatantStatesEnd': json.dumps([c.to_dict() for c in states_end]), + 'created_at': created_at, + } + + self.db.create_row( + table_id=self.ROUNDS_TABLE, + data=data, + row_id=round_id + ) + + logger.debug("Combat round saved", + round_id=round_id, + encounter_id=encounter_id, + round_number=round_number, + action_count=len(actions)) + + return round_id + + def get_encounter_rounds( + self, + encounter_id: str, + limit: int = 100 + ) -> List[Dict[str, Any]]: + """ + Get all rounds for an encounter, ordered by round number. + + Args: + encounter_id: Encounter ID to fetch rounds for + limit: Maximum number of rounds to return + + Returns: + List of round data dictionaries + """ + rows = self.db.list_rows( + table_id=self.ROUNDS_TABLE, + queries=[Query.equal('encounterId', encounter_id)], + limit=limit + ) + + rounds = [] + for row in rows: + rounds.append({ + 'round_id': row.id, + 'round_number': row.data.get('roundNumber'), + 'actions': json.loads(row.data.get('actionsData', '[]')), + 'states_start': json.loads(row.data.get('combatantStatesStart', '[]')), + 'states_end': json.loads(row.data.get('combatantStatesEnd', '[]')), + 'created_at': row.data.get('created_at'), + }) + + # Sort by round number + return sorted(rounds, key=lambda r: r['round_number']) + + def get_session_combat_history( + self, + session_id: str, + limit: int = 50 + ) -> List[Dict[str, Any]]: + """ + Get combat history for a session. + + Returns summary of all encounters for the session. + + Args: + session_id: Game session ID + limit: Maximum encounters to return + + Returns: + List of encounter summaries + """ + rows = self.db.list_rows( + table_id=self.ENCOUNTERS_TABLE, + queries=[Query.equal('sessionId', session_id)], + limit=limit + ) + + history = [] + for row in rows: + history.append({ + 'encounter_id': row.id, + 'status': row.data.get('status'), + 'round_count': row.data.get('roundNumber', 1), + 'created_at': row.data.get('created_at'), + 'ended_at': row.data.get('ended_at'), + }) + + # Sort by created_at descending (newest first) + return sorted(history, key=lambda h: h['created_at'] or '', reverse=True) + + # ========================================================================= + # Cleanup Operations + # ========================================================================= + + def delete_encounters_by_session(self, session_id: str) -> int: + """ + Delete all encounters for a session. + + Call this when a session is deleted. + + Args: + session_id: Session ID to clean up + + Returns: + Number of encounters deleted + """ + rows = self.db.list_rows( + table_id=self.ENCOUNTERS_TABLE, + queries=[Query.equal('sessionId', session_id)], + limit=100 + ) + + deleted = 0 + for row in rows: + # Delete rounds first + self._delete_rounds_for_encounter(row.id) + # Delete encounter + self.db.delete_row(self.ENCOUNTERS_TABLE, row.id) + deleted += 1 + + if deleted > 0: + logger.info("Deleted encounters for session", + session_id=session_id, + deleted_count=deleted) + + return deleted + + def delete_old_encounters( + self, + older_than_days: int = DEFAULT_RETENTION_DAYS + ) -> int: + """ + Delete ended encounters older than specified days. + + This is the main cleanup method for time-based retention. + Should be scheduled to run periodically (daily recommended). + + Args: + older_than_days: Delete encounters ended more than this many days ago + + Returns: + Number of encounters deleted + """ + cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days) + cutoff_str = cutoff.isoformat().replace("+00:00", "Z") + + # Find old ended encounters + # Note: We only delete ended encounters, not active ones + rows = self.db.list_rows( + table_id=self.ENCOUNTERS_TABLE, + queries=[ + Query.notEqual('status', CombatStatus.ACTIVE.value), + Query.lessThan('created_at', cutoff_str) + ], + limit=100 + ) + + deleted = 0 + for row in rows: + self._delete_rounds_for_encounter(row.id) + self.db.delete_row(self.ENCOUNTERS_TABLE, row.id) + deleted += 1 + + if deleted > 0: + logger.info("Deleted old combat encounters", + deleted_count=deleted, + older_than_days=older_than_days) + + return deleted + + # ========================================================================= + # Helper Methods + # ========================================================================= + + def _delete_rounds_for_encounter(self, encounter_id: str) -> int: + """ + Delete all rounds for an encounter. + + Args: + encounter_id: Encounter ID + + Returns: + Number of rounds deleted + """ + rows = self.db.list_rows( + table_id=self.ROUNDS_TABLE, + queries=[Query.equal('encounterId', encounter_id)], + limit=100 + ) + + for row in rows: + self.db.delete_row(self.ROUNDS_TABLE, row.id) + + return len(rows) + + def _row_to_encounter( + self, + data: Dict[str, Any], + encounter_id: str + ) -> CombatEncounter: + """ + Convert database row data to CombatEncounter object. + + Args: + data: Row data dictionary + encounter_id: Encounter ID + + Returns: + Deserialized CombatEncounter + """ + # Parse JSON fields + combatants_data = json.loads(data.get('combatantsData', '[]')) + combatants = [Combatant.from_dict(c) for c in combatants_data] + + turn_order = json.loads(data.get('turnOrder', '[]')) + combat_log = json.loads(data.get('combatLog', '[]')) + + # Parse status enum + status_str = data.get('status', 'active') + status = CombatStatus(status_str) + + return CombatEncounter( + encounter_id=encounter_id, + combatants=combatants, + turn_order=turn_order, + current_turn_index=data.get('currentTurnIndex', 0), + round_number=data.get('roundNumber', 1), + combat_log=combat_log, + status=status, + ) + + def _get_timestamp(self) -> str: + """Get current UTC timestamp in ISO format.""" + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +# ============================================================================= +# Global Instance +# ============================================================================= + +_repository_instance: Optional[CombatRepository] = None + + +def get_combat_repository() -> CombatRepository: + """ + Get the global CombatRepository instance. + + Returns: + Singleton CombatRepository instance + """ + global _repository_instance + if _repository_instance is None: + _repository_instance = CombatRepository() + return _repository_instance diff --git a/api/app/services/combat_service.py b/api/app/services/combat_service.py new file mode 100644 index 0000000..4bfbb75 --- /dev/null +++ b/api/app/services/combat_service.py @@ -0,0 +1,1486 @@ +""" +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, EnemyDifficulty +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, StatType +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.services.combat_loot_service import ( + get_combat_loot_service, + CombatLootService, + LootContext +) +from app.services.combat_repository import ( + get_combat_repository, + CombatRepository +) +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 + rewards: Combat rewards if victory (XP, gold, items) + """ + + 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 + next_is_player: bool = True # True if next turn is player's + turn_effects: List[Dict[str, Any]] = field(default_factory=list) + rewards: Optional[Dict[str, Any]] = None # Populated on victory + + 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, + "next_is_player": self.next_is_player, + "turn_effects": self.turn_effects, + "rewards": self.rewards, + } + + +@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() + self.loot_service = get_combat_loot_service() + self.combat_repository = get_combat_repository() + + 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() + + # Save encounter to dedicated table + self.combat_repository.create_encounter( + encounter=encounter, + session_id=session_id, + user_id=user_id + ) + + # Update session with reference to encounter (not inline data) + session.active_combat_encounter_id = encounter.encounter_id + session.combat_encounter = None # Clear legacy inline storage + session.update_activity() + self.session_service.update_session(session) + + 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. + + Uses the new database-backed storage, with fallback to legacy + inline session storage for backward compatibility. + + 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) + + # New system: Check for reference to combat_encounters table + if session.active_combat_encounter_id: + encounter = self.combat_repository.get_encounter( + session.active_combat_encounter_id + ) + if encounter: + return encounter + # Reference exists but encounter not found - clear stale reference + logger.warning("Stale combat encounter reference, clearing", + session_id=session_id, + encounter_id=session.active_combat_encounter_id) + session.active_combat_encounter_id = None + self.session_service.update_session(session) + return None + + # Legacy fallback: Check inline combat data and migrate if present + if session.combat_encounter: + return self._migrate_inline_encounter(session, user_id) + + return None + + def _migrate_inline_encounter( + self, + session, + user_id: str + ) -> CombatEncounter: + """ + Migrate legacy inline combat encounter to database table. + + This provides backward compatibility by automatically migrating + existing inline combat data to the new database-backed system + on first access. + + Args: + session: GameSession with inline combat_encounter + user_id: User ID + + Returns: + The migrated CombatEncounter + """ + encounter = session.combat_encounter + + logger.info("Migrating inline combat encounter to database", + session_id=session.session_id, + encounter_id=encounter.encounter_id) + + # Save to repository + self.combat_repository.create_encounter( + encounter=encounter, + session_id=session.session_id, + user_id=user_id + ) + + # Update session to use reference + session.active_combat_encounter_id = encounter.encounter_id + session.combat_encounter = None # Clear inline data + self.session_service.update_session(session) + + return 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") + + # Get encounter from repository (or legacy inline) + encounter = self.get_combat_state(session_id, user_id) + if not encounter: + raise NotInCombatError("Combat encounter not found") + + encounter.status = outcome + + # Calculate rewards if victory + rewards = CombatRewards() + if outcome == CombatStatus.VICTORY: + rewards = self._calculate_rewards(encounter, session, user_id) + + # End encounter in repository + if session.active_combat_encounter_id: + self.combat_repository.end_encounter( + encounter_id=session.active_combat_encounter_id, + status=outcome + ) + + # Clear session combat reference + session.active_combat_encounter_id = None + session.combat_encounter = None # Also clear legacy field + session.update_activity() + self.session_service.update_session(session) + + logger.info("Combat ended", + session_id=session_id, + encounter_id=encounter.encounter_id, + outcome=outcome.value, + xp_earned=rewards.experience, + gold_earned=rewards.gold) + + return rewards + + def check_existing_combat( + self, + session_id: str, + user_id: str + ) -> Optional[Dict[str, Any]]: + """ + Check if a session has an existing active combat encounter. + + Returns combat summary if exists, None otherwise. + + Args: + session_id: Game session ID + user_id: User ID for authorization + + Returns: + Dictionary with combat summary if in combat, None otherwise + """ + logger.info("Checking for existing combat", + session_id=session_id) + + session = self.session_service.get_session(session_id, user_id) + + if not session.is_in_combat(): + return None + + # Get encounter details + encounter = self.get_combat_state(session_id, user_id) + if not encounter: + return None + + # Build summary of combatants + players = [] + enemies = [] + for combatant in encounter.combatants: + combatant_info = { + "name": combatant.name, + "current_hp": combatant.current_hp, + "max_hp": combatant.max_hp, + "is_alive": combatant.is_alive(), + } + if combatant.is_player: + players.append(combatant_info) + else: + enemies.append(combatant_info) + + return { + "has_active_combat": True, + "encounter_id": encounter.encounter_id, + "round_number": encounter.round_number, + "status": encounter.status.value, + "players": players, + "enemies": enemies, + } + + def abandon_combat( + self, + session_id: str, + user_id: str + ) -> bool: + """ + Abandon an existing combat encounter without completing it. + + Deletes the encounter from the database and clears the session + reference. No rewards are distributed. + + Args: + session_id: Game session ID + user_id: User ID for authorization + + Returns: + True if combat was abandoned, False if no combat existed + """ + logger.info("Abandoning combat", + session_id=session_id) + + session = self.session_service.get_session(session_id, user_id) + + if not session.is_in_combat(): + logger.info("No combat to abandon", + session_id=session_id) + return False + + encounter_id = session.active_combat_encounter_id + + # Delete encounter from repository + if encounter_id: + try: + self.combat_repository.delete_encounter(encounter_id) + logger.info("Deleted encounter from repository", + encounter_id=encounter_id) + except Exception as e: + logger.warning("Failed to delete encounter from repository", + encounter_id=encounter_id, + error=str(e)) + + # Clear session combat references + session.active_combat_encounter_id = None + session.combat_encounter = None # Clear legacy field too + session.update_activity() + self.session_service.update_session(session) + + logger.info("Combat abandoned", + session_id=session_id, + encounter_id=encounter_id) + + return True + + # ========================================================================= + # 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") + + # Get encounter from repository + encounter = self.get_combat_state(session_id, user_id) + if not encounter: + raise NotInCombatError("Combat encounter not found") + + # 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." + result.rewards = rewards.to_dict() + + # End encounter in repository + if session.active_combat_encounter_id: + self.combat_repository.end_encounter( + encounter_id=session.active_combat_encounter_id, + status=status + ) + + # Clear session combat reference + session.active_combat_encounter_id = None + session.combat_encounter = None + else: + # Advance turn and save to repository + self._advance_turn_and_save(encounter, session, user_id) + next_combatant = encounter.get_current_combatant() + if next_combatant: + result.next_combatant_id = next_combatant.combatant_id + result.next_is_player = next_combatant.is_player + else: + result.next_combatant_id = None + result.next_is_player = True + + # Save session state + self.session_service.update_session(session) + + 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") + + # Get encounter from repository + encounter = self.get_combat_state(session_id, user_id) + if not encounter: + raise NotInCombatError("Combat encounter not found") + + 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") + + # Check if the enemy is dead (shouldn't happen with fixed advance_turn, but defensive) + if current.is_dead(): + # Skip this dead enemy's turn and advance + self._advance_turn_and_save(encounter, session, user_id) + next_combatant = encounter.get_current_combatant() + return ActionResult( + success=True, + message=f"{current.name} is defeated and cannot act.", + next_combatant_id=next_combatant.combatant_id if next_combatant else None, + next_is_player=next_combatant.is_player if next_combatant else True, + ) + + # Process start-of-turn effects + turn_effects = encounter.start_turn() + + # Check if enemy died from DoT effects at turn start + if current.is_dead(): + # Check if combat ended + combat_status = encounter.check_end_condition() + if combat_status in [CombatStatus.VICTORY, CombatStatus.DEFEAT]: + encounter.status = combat_status + result = ActionResult( + success=True, + message=f"{current.name} was defeated by damage over time!", + combat_ended=True, + combat_status=combat_status, + turn_effects=turn_effects, + ) + if session.active_combat_encounter_id: + self.combat_repository.end_encounter( + encounter_id=session.active_combat_encounter_id, + status=combat_status + ) + session.active_combat_encounter_id = None + session.combat_encounter = None + self.session_service.update_session(session) + return result + else: + # Advance past the dead enemy + self._advance_turn_and_save(encounter, session, user_id) + next_combatant = encounter.get_current_combatant() + return ActionResult( + success=True, + message=f"{current.name} was defeated by damage over time!", + next_combatant_id=next_combatant.combatant_id if next_combatant else None, + next_is_player=next_combatant.is_player if next_combatant else True, + turn_effects=turn_effects, + ) + + # 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 + + # Calculate and distribute rewards on victory + if status == CombatStatus.VICTORY: + rewards = self._calculate_rewards(encounter, session, user_id) + result.rewards = rewards.to_dict() + + # End encounter in repository + if session.active_combat_encounter_id: + self.combat_repository.end_encounter( + encounter_id=session.active_combat_encounter_id, + status=status + ) + + # Clear session combat reference + session.active_combat_encounter_id = None + session.combat_encounter = None + else: + logger.info("Combat still active, advancing turn", + session_id=session_id, + encounter_id=encounter.encounter_id) + self._advance_turn_and_save(encounter, session, user_id) + next_combatant = encounter.get_current_combatant() + logger.info("Next combatant determined", + next_combatant_id=next_combatant.combatant_id if next_combatant else None, + next_is_player=next_combatant.is_player if next_combatant else None) + if next_combatant: + result.next_combatant_id = next_combatant.combatant_id + result.next_is_player = next_combatant.is_player + else: + result.next_combatant_id = None + result.next_is_player = True + + self.session_service.update_session(session) + + logger.info("Enemy turn complete, returning result", + session_id=session_id, + next_combatant_id=result.next_combatant_id, + next_is_player=result.next_is_player) + 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" + ) + + # Check if this is an elemental weapon attack + if attacker.elemental_ratio > 0.0 and attacker.elemental_damage_type: + # Elemental weapon: split damage between physical and elemental + damage_result = DamageCalculator.calculate_elemental_weapon_damage( + attacker_stats=attacker.stats, + defender_stats=target.stats, + weapon_crit_chance=attacker.weapon_crit_chance, + weapon_crit_multiplier=attacker.weapon_crit_multiplier, + physical_ratio=attacker.physical_ratio, + elemental_ratio=attacker.elemental_ratio, + elemental_type=attacker.elemental_damage_type, + ) + else: + # Normal physical attack + damage_result = DamageCalculator.calculate_physical_damage( + attacker_stats=attacker.stats, + defender_stats=target.stats, + weapon_crit_chance=attacker.weapon_crit_chance, + weapon_crit_multiplier=attacker.weapon_crit_multiplier, + ) + + # Add target_id to result for tracking + 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=StatType.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. + + Uses CombatLootService for loot generation, supporting both + static items (consumables) and procedural equipment. + + Args: + encounter: Completed combat encounter + session: Game session + user_id: User ID for character updates + + Returns: + CombatRewards with totals + """ + rewards = CombatRewards() + + # Build loot context from encounter + loot_context = self._build_loot_context(encounter) + + # 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() + + # Generate loot using the loot service + # Update context with this specific enemy's difficulty + enemy_context = LootContext( + party_average_level=loot_context.party_average_level, + enemy_difficulty=enemy.difficulty, + luck_stat=loot_context.luck_stat, + loot_bonus=loot_context.loot_bonus + ) + + # Use boss loot for boss enemies + if enemy.is_boss(): + loot_items = self.loot_service.generate_boss_loot( + enemy, enemy_context + ) + else: + loot_items = self.loot_service.generate_loot_from_enemy( + enemy, enemy_context + ) + + # Convert Item objects to dicts for serialization + for item in loot_items: + rewards.items.append(item.to_dict()) + + # 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 + + def _build_loot_context(self, encounter: CombatEncounter) -> LootContext: + """ + Build loot generation context from a combat encounter. + + Calculates: + - Party average level + - Party average luck stat + - Default difficulty (uses EASY, specific enemies override) + + Args: + encounter: Combat encounter with player combatants + + Returns: + LootContext for loot generation + """ + player_combatants = [c for c in encounter.combatants if c.is_player] + + # Calculate party average level + if player_combatants: + # Use combatant's level if available, otherwise default to 1 + levels = [] + for p in player_combatants: + # Try to get level from stats or combatant + level = getattr(p, 'level', 1) + levels.append(level) + avg_level = sum(levels) // len(levels) if levels else 1 + else: + avg_level = 1 + + # Calculate party average luck + if player_combatants: + luck_values = [p.stats.luck for p in player_combatants] + avg_luck = sum(luck_values) // len(luck_values) if luck_values else 8 + else: + avg_luck = 8 + + return LootContext( + party_average_level=avg_level, + enemy_difficulty=EnemyDifficulty.EASY, # Default; overridden per-enemy + luck_stat=avg_luck, + loot_bonus=0.0 # Future: add buffs/abilities bonus + ) + + # ========================================================================= + # 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) + + # Extract weapon properties from equipped weapon + weapon = character.equipped.get("weapon") + weapon_crit_chance = 0.05 + weapon_crit_multiplier = 2.0 + weapon_damage_type = DamageType.PHYSICAL + elemental_damage_type = None + physical_ratio = 1.0 + elemental_ratio = 0.0 + + if weapon and weapon.is_weapon(): + weapon_crit_chance = weapon.crit_chance + weapon_crit_multiplier = weapon.crit_multiplier + weapon_damage_type = weapon.damage_type or DamageType.PHYSICAL + + if weapon.is_elemental_weapon(): + elemental_damage_type = weapon.elemental_damage_type + physical_ratio = weapon.physical_ratio + elemental_ratio = weapon.elemental_ratio + + return Combatant( + 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, + weapon_crit_chance=weapon_crit_chance, + weapon_crit_multiplier=weapon_crit_multiplier, + weapon_damage_type=weapon_damage_type, + elemental_damage_type=elemental_damage_type, + physical_ratio=physical_ratio, + elemental_ratio=elemental_ratio, + ) + + def _create_combatant_from_enemy( + 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}" + + # Copy stats and populate damage_bonus with base_damage + stats = template.base_stats.copy() + stats.damage_bonus = template.base_damage + + 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(), + weapon_crit_chance=template.crit_chance, + weapon_crit_multiplier=2.0, + weapon_damage_type=DamageType.PHYSICAL, + ) + + def _get_crit_chance(self, combatant: Combatant) -> float: + """Get critical hit chance for a combatant.""" + # Weapon crit chance + LUK bonus + return combatant.weapon_crit_chance + 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 encounter state to repository.""" + logger.info("_advance_turn_and_save called", + encounter_id=encounter.encounter_id, + before_turn_index=encounter.current_turn_index, + combat_log_count=len(encounter.combat_log)) + + encounter.advance_turn() + + logger.info("Turn advanced, now saving", + encounter_id=encounter.encounter_id, + after_turn_index=encounter.current_turn_index, + combat_log_count=len(encounter.combat_log)) + + # Save encounter to repository + self.combat_repository.update_encounter(encounter) + + logger.info("Encounter saved", + encounter_id=encounter.encounter_id) + + +# ============================================================================= +# 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..a447d34 --- /dev/null +++ b/api/app/services/damage_calculator.py @@ -0,0 +1,590 @@ +""" +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: (effective_stats.damage + ability_power) * Variance * Crit_Mult - DEF + where effective_stats.damage = int(STR * 0.75) + damage_bonus (from weapon) + Magical: (effective_stats.spell_power + ability_power) * Variance * Crit_Mult - RES + where effective_stats.spell_power = int(INT * 0.75) + spell_power_bonus (from staff/wand) + Elemental: Split between physical and magical components using ratios + +LUK Integration: + - 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_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 = attacker_stats.damage + ability_base_power + where attacker_stats.damage = int(STR * 0.75) + damage_bonus + Damage = Base * Variance * Crit_Mult - DEF + + Args: + attacker_stats: Attacker's Stats (includes weapon damage via damage property) + defender_stats: Defender's Stats (DEX, CON used) + 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 + # attacker_stats.damage already includes: int(STR * 0.75) + damage_bonus (weapon) + base_damage = attacker_stats.damage + ability_base_power + + # 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 = attacker_stats.spell_power + ability_base_power + where attacker_stats.spell_power = int(INT * 0.75) + spell_power_bonus + Damage = Base * Variance * Crit_Mult - RES + + Args: + attacker_stats: Attacker's Stats (includes staff/wand spell_power via spell_power property) + 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 + # attacker_stats.spell_power already includes: int(INT * 0.75) + spell_power_bonus (staff/wand) + base_damage = attacker_stats.spell_power + ability_base_power + + # 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_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 = (attacker_stats.damage + ability_power) * PHYS_RATIO - DEF + Elemental = (attacker_stats.spell_power + ability_power) * 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 (damage and spell_power include equipment) + defender_stats: Defender's Stats + 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 + # attacker_stats.damage includes: int(STR * 0.75) + damage_bonus (weapon) + phys_base = (attacker_stats.damage + ability_base_power) * physical_ratio + phys_damage = phys_base * variance * crit_mult + phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense) + + # Step 4: Calculate elemental component + # attacker_stats.spell_power includes: int(INT * 0.75) + spell_power_bonus (staff/wand) + elem_base = (attacker_stats.spell_power + ability_base_power) * elemental_ratio + elem_damage = elem_base * 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/database_init.py b/api/app/services/database_init.py index f86e7ae..7a618e6 100644 --- a/api/app/services/database_init.py +++ b/api/app/services/database_init.py @@ -106,6 +106,24 @@ class DatabaseInitService: logger.error("Failed to initialize chat_messages table", error=str(e)) results['chat_messages'] = False + # Initialize combat_encounters table + try: + self.init_combat_encounters_table() + results['combat_encounters'] = True + logger.info("Combat encounters table initialized successfully") + except Exception as e: + logger.error("Failed to initialize combat_encounters table", error=str(e)) + results['combat_encounters'] = False + + # Initialize combat_rounds table + try: + self.init_combat_rounds_table() + results['combat_rounds'] = True + logger.info("Combat rounds table initialized successfully") + except Exception as e: + logger.error("Failed to initialize combat_rounds table", error=str(e)) + results['combat_rounds'] = False + success_count = sum(1 for v in results.values() if v) total_count = len(results) @@ -746,6 +764,326 @@ class DatabaseInitService: code=e.code) raise + def init_combat_encounters_table(self) -> bool: + """ + Initialize the combat_encounters table for storing combat encounter state. + + Table schema: + - sessionId (string, required): Game session ID (FK to game_sessions) + - userId (string, required): Owner user ID for authorization + - status (string, required): Combat status (active, victory, defeat, fled) + - roundNumber (integer, required): Current round number + - currentTurnIndex (integer, required): Index in turn_order for current turn + - turnOrder (string, required): JSON array of combatant IDs in initiative order + - combatantsData (string, required): JSON array of Combatant objects (full state) + - combatLog (string, optional): JSON array of all combat log entries + - created_at (string, required): ISO timestamp of combat start + - ended_at (string, optional): ISO timestamp when combat ended + + Indexes: + - idx_sessionId: Session-based lookups + - idx_userId_status: User's active combats query + - idx_status_created_at: Time-based cleanup queries + + Returns: + True if successful + + Raises: + AppwriteException: If table creation fails + """ + table_id = 'combat_encounters' + + logger.info("Initializing combat_encounters table", table_id=table_id) + + try: + # Check if table already exists + try: + self.tables_db.get_table( + database_id=self.database_id, + table_id=table_id + ) + logger.info("Combat encounters table already exists", table_id=table_id) + return True + except AppwriteException as e: + if e.code != 404: + raise + logger.info("Combat encounters table does not exist, creating...") + + # Create table + logger.info("Creating combat_encounters table") + table = self.tables_db.create_table( + database_id=self.database_id, + table_id=table_id, + name='Combat Encounters' + ) + logger.info("Combat encounters table created", table_id=table['$id']) + + # Create columns + self._create_column( + table_id=table_id, + column_id='sessionId', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='userId', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='status', + column_type='string', + size=20, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='roundNumber', + column_type='integer', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='currentTurnIndex', + column_type='integer', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='turnOrder', + column_type='string', + size=2000, # JSON array of combatant IDs + required=True + ) + + self._create_column( + table_id=table_id, + column_id='combatantsData', + column_type='string', + size=65535, # Large text field for JSON combatant array + required=True + ) + + self._create_column( + table_id=table_id, + column_id='combatLog', + column_type='string', + size=65535, # Large text field for combat log + required=False + ) + + self._create_column( + table_id=table_id, + column_id='created_at', + column_type='string', + size=50, # ISO timestamp format + required=True + ) + + self._create_column( + table_id=table_id, + column_id='ended_at', + column_type='string', + size=50, # ISO timestamp format + required=False + ) + + # Wait for columns to fully propagate + logger.info("Waiting for columns to propagate before creating indexes...") + time.sleep(2) + + # Create indexes + self._create_index( + table_id=table_id, + index_id='idx_sessionId', + index_type='key', + attributes=['sessionId'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_userId_status', + index_type='key', + attributes=['userId', 'status'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_status_created_at', + index_type='key', + attributes=['status', 'created_at'] + ) + + logger.info("Combat encounters table initialized successfully", table_id=table_id) + return True + + except AppwriteException as e: + logger.error("Failed to initialize combat_encounters table", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def init_combat_rounds_table(self) -> bool: + """ + Initialize the combat_rounds table for storing per-round action history. + + Table schema: + - encounterId (string, required): FK to combat_encounters + - sessionId (string, required): Denormalized for efficient queries + - roundNumber (integer, required): Round number (1-indexed) + - actionsData (string, required): JSON array of all actions in this round + - combatantStatesStart (string, required): JSON snapshot of combatant states at round start + - combatantStatesEnd (string, required): JSON snapshot of combatant states at round end + - created_at (string, required): ISO timestamp when round completed + + Indexes: + - idx_encounterId: Encounter-based lookups + - idx_encounterId_roundNumber: Ordered retrieval of rounds + - idx_sessionId: Session-based queries + - idx_created_at: Time-based cleanup + + Returns: + True if successful + + Raises: + AppwriteException: If table creation fails + """ + table_id = 'combat_rounds' + + logger.info("Initializing combat_rounds table", table_id=table_id) + + try: + # Check if table already exists + try: + self.tables_db.get_table( + database_id=self.database_id, + table_id=table_id + ) + logger.info("Combat rounds table already exists", table_id=table_id) + return True + except AppwriteException as e: + if e.code != 404: + raise + logger.info("Combat rounds table does not exist, creating...") + + # Create table + logger.info("Creating combat_rounds table") + table = self.tables_db.create_table( + database_id=self.database_id, + table_id=table_id, + name='Combat Rounds' + ) + logger.info("Combat rounds table created", table_id=table['$id']) + + # Create columns + self._create_column( + table_id=table_id, + column_id='encounterId', + column_type='string', + size=36, # UUID format: enc_xxxxxxxxxxxx + required=True + ) + + self._create_column( + table_id=table_id, + column_id='sessionId', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='roundNumber', + column_type='integer', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='actionsData', + column_type='string', + size=65535, # JSON array of action objects + required=True + ) + + self._create_column( + table_id=table_id, + column_id='combatantStatesStart', + column_type='string', + size=65535, # JSON snapshot of combatant states + required=True + ) + + self._create_column( + table_id=table_id, + column_id='combatantStatesEnd', + column_type='string', + size=65535, # JSON snapshot of combatant states + required=True + ) + + self._create_column( + table_id=table_id, + column_id='created_at', + column_type='string', + size=50, # ISO timestamp format + required=True + ) + + # Wait for columns to fully propagate + logger.info("Waiting for columns to propagate before creating indexes...") + time.sleep(2) + + # Create indexes + self._create_index( + table_id=table_id, + index_id='idx_encounterId', + index_type='key', + attributes=['encounterId'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_encounterId_roundNumber', + index_type='key', + attributes=['encounterId', 'roundNumber'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_sessionId', + index_type='key', + attributes=['sessionId'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_created_at', + index_type='key', + attributes=['created_at'] + ) + + logger.info("Combat rounds table initialized successfully", table_id=table_id) + return True + + except AppwriteException as e: + logger.error("Failed to initialize combat_rounds table", + table_id=table_id, + error=str(e), + code=e.code) + raise + def _create_column( self, table_id: str, diff --git a/api/app/services/encounter_generator.py b/api/app/services/encounter_generator.py new file mode 100644 index 0000000..07e9797 --- /dev/null +++ b/api/app/services/encounter_generator.py @@ -0,0 +1,308 @@ +""" +Encounter Generator Service - Generate random combat encounters. + +This service generates location-appropriate, level-scaled encounter groups +for the "Search for Monsters" feature. Players can select from generated +encounter options to initiate combat. +""" + +import random +import uuid +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +from collections import Counter + +from app.models.enemy import EnemyTemplate, EnemyDifficulty +from app.services.enemy_loader import get_enemy_loader +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +@dataclass +class EncounterGroup: + """ + A generated encounter option for the player to choose. + + Attributes: + group_id: Unique identifier for this encounter option + enemies: List of enemy_ids that will spawn + enemy_names: Display names for the UI + display_name: Formatted display string (e.g., "3 Goblin Scouts") + challenge: Difficulty label ("Easy", "Medium", "Hard", "Boss") + total_xp: Total XP reward (not displayed to player, used internally) + """ + group_id: str + enemies: List[str] # List of enemy_ids + enemy_names: List[str] # Display names + display_name: str # Formatted for display + challenge: str # "Easy", "Medium", "Hard", "Boss" + total_xp: int # Internal tracking, not displayed + + def to_dict(self) -> Dict[str, Any]: + """Serialize encounter group for API response.""" + return { + "group_id": self.group_id, + "enemies": self.enemies, + "enemy_names": self.enemy_names, + "display_name": self.display_name, + "challenge": self.challenge, + } + + +class EncounterGenerator: + """ + Generates random encounter groups for a given location and character level. + + Encounter difficulty is determined by: + - Character level (higher level = more enemies, harder varieties) + - Location type (different monsters in different areas) + - Random variance (some encounters harder/easier than average) + """ + + def __init__(self): + """Initialize the encounter generator.""" + self.enemy_loader = get_enemy_loader() + + def generate_encounters( + self, + location_type: str, + character_level: int, + num_encounters: int = 4 + ) -> List[EncounterGroup]: + """ + Generate multiple encounter options for the player to choose from. + + Args: + location_type: Type of location (e.g., "forest", "town", "dungeon") + character_level: Current character level (1-20+) + num_encounters: Number of encounter options to generate (default 4) + + Returns: + List of EncounterGroup options, each with different difficulty + """ + # Get enemies available at this location + available_enemies = self.enemy_loader.get_enemies_by_location(location_type) + + if not available_enemies: + logger.warning( + "No enemies found for location", + location_type=location_type + ) + return [] + + # Generate a mix of difficulties + # Always try to include: 1 Easy, 1-2 Medium, 0-1 Hard + encounters = [] + difficulty_mix = self._get_difficulty_mix(character_level, num_encounters) + + for target_difficulty in difficulty_mix: + encounter = self._generate_single_encounter( + available_enemies=available_enemies, + character_level=character_level, + target_difficulty=target_difficulty + ) + if encounter: + encounters.append(encounter) + + logger.info( + "Generated encounters", + location_type=location_type, + character_level=character_level, + num_encounters=len(encounters) + ) + + return encounters + + def _get_difficulty_mix( + self, + character_level: int, + num_encounters: int + ) -> List[str]: + """ + Determine the mix of encounter difficulties to generate. + + Lower-level characters see more easy encounters. + Higher-level characters see more hard encounters. + + Args: + character_level: Character's current level + num_encounters: Total encounters to generate + + Returns: + List of target difficulty strings + """ + if character_level <= 2: + # Very low level: mostly easy + mix = ["Easy", "Easy", "Medium", "Easy"] + elif character_level <= 5: + # Low level: easy and medium + mix = ["Easy", "Medium", "Medium", "Hard"] + elif character_level <= 10: + # Mid level: balanced + mix = ["Easy", "Medium", "Hard", "Hard"] + else: + # High level: harder encounters + mix = ["Medium", "Hard", "Hard", "Boss"] + + return mix[:num_encounters] + + def _generate_single_encounter( + self, + available_enemies: List[EnemyTemplate], + character_level: int, + target_difficulty: str + ) -> Optional[EncounterGroup]: + """ + Generate a single encounter group. + + Args: + available_enemies: Pool of enemies to choose from + character_level: Character's level for scaling + target_difficulty: Target difficulty ("Easy", "Medium", "Hard", "Boss") + + Returns: + EncounterGroup or None if generation fails + """ + # Map target difficulty to enemy difficulty levels + difficulty_mapping = { + "Easy": [EnemyDifficulty.EASY], + "Medium": [EnemyDifficulty.EASY, EnemyDifficulty.MEDIUM], + "Hard": [EnemyDifficulty.MEDIUM, EnemyDifficulty.HARD], + "Boss": [EnemyDifficulty.HARD, EnemyDifficulty.BOSS], + } + + allowed_difficulties = difficulty_mapping.get(target_difficulty, [EnemyDifficulty.EASY]) + + # Filter enemies by difficulty + candidates = [ + e for e in available_enemies + if e.difficulty in allowed_difficulties + ] + + if not candidates: + # Fall back to any available enemy + candidates = available_enemies + + if not candidates: + return None + + # Determine enemy count based on difficulty and level + enemy_count = self._calculate_enemy_count( + target_difficulty=target_difficulty, + character_level=character_level + ) + + # Select enemies (allowing duplicates for packs) + selected_enemies = random.choices(candidates, k=enemy_count) + + # Build encounter group + enemy_ids = [e.enemy_id for e in selected_enemies] + enemy_names = [e.name for e in selected_enemies] + total_xp = sum(e.experience_reward for e in selected_enemies) + + # Create display name (e.g., "3 Goblin Scouts" or "2 Goblins, 1 Goblin Shaman") + display_name = self._format_display_name(enemy_names) + + return EncounterGroup( + group_id=f"enc_{uuid.uuid4().hex[:8]}", + enemies=enemy_ids, + enemy_names=enemy_names, + display_name=display_name, + challenge=target_difficulty, + total_xp=total_xp + ) + + def _calculate_enemy_count( + self, + target_difficulty: str, + character_level: int + ) -> int: + """ + Calculate how many enemies should be in the encounter. + + Args: + target_difficulty: Target difficulty level + character_level: Character's level + + Returns: + Number of enemies to include + """ + # Base counts by difficulty + base_counts = { + "Easy": (1, 2), # 1-2 enemies + "Medium": (2, 3), # 2-3 enemies + "Hard": (2, 4), # 2-4 enemies + "Boss": (1, 3), # 1 boss + 0-2 adds + } + + min_count, max_count = base_counts.get(target_difficulty, (1, 2)) + + # Scale slightly with level (higher level = can handle more) + level_bonus = min(character_level // 5, 2) # +1 enemy every 5 levels, max +2 + max_count = min(max_count + level_bonus, 6) # Cap at 6 enemies + + return random.randint(min_count, max_count) + + def _format_display_name(self, enemy_names: List[str]) -> str: + """ + Format enemy names for display. + + Examples: + ["Goblin Scout"] -> "Goblin Scout" + ["Goblin Scout", "Goblin Scout", "Goblin Scout"] -> "3 Goblin Scouts" + ["Goblin Scout", "Goblin Shaman"] -> "Goblin Scout, Goblin Shaman" + + Args: + enemy_names: List of enemy display names + + Returns: + Formatted display string + """ + if len(enemy_names) == 1: + return enemy_names[0] + + # Count occurrences + counts = Counter(enemy_names) + + if len(counts) == 1: + # All same enemy type + name = list(counts.keys())[0] + count = list(counts.values())[0] + # Simple pluralization + if count > 1: + if name.endswith('f'): + # wolf -> wolves + plural_name = name[:-1] + "ves" + elif name.endswith('s') or name.endswith('x') or name.endswith('ch'): + plural_name = name + "es" + else: + plural_name = name + "s" + return f"{count} {plural_name}" + return name + else: + # Mixed enemy types - list them + parts = [] + for name, count in counts.items(): + if count > 1: + parts.append(f"{count}x {name}") + else: + parts.append(name) + return ", ".join(parts) + + +# Global instance +_generator_instance: Optional[EncounterGenerator] = None + + +def get_encounter_generator() -> EncounterGenerator: + """ + Get the global EncounterGenerator instance. + + Returns: + Singleton EncounterGenerator instance + """ + global _generator_instance + if _generator_instance is None: + _generator_instance = EncounterGenerator() + return _generator_instance diff --git a/api/app/services/enemy_loader.py b/api/app/services/enemy_loader.py new file mode 100644 index 0000000..ad0a02f --- /dev/null +++ b/api/app/services/enemy_loader.py @@ -0,0 +1,300 @@ +""" +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_enemies_by_location( + self, + location_type: str, + difficulty: Optional[EnemyDifficulty] = None + ) -> List[EnemyTemplate]: + """ + Get all enemies that can appear at a specific location type. + + This is used by the encounter generator to find location-appropriate + enemies for random encounters. + + Args: + location_type: Location type to filter by (e.g., "forest", "dungeon", + "town", "wilderness", "crypt", "ruins", "road") + difficulty: Optional difficulty filter to narrow results + + Returns: + List of EnemyTemplate instances that can appear at the location + """ + if not self._loaded: + self.load_all_enemies() + + candidates = [ + enemy for enemy in self._enemy_cache.values() + if enemy.has_location_tag(location_type) + ] + + # Apply difficulty filter if specified + if difficulty is not None: + candidates = [e for e in candidates if e.difficulty == difficulty] + + logger.debug( + "Enemies found for location", + location_type=location_type, + difficulty=difficulty.value if difficulty else None, + count=len(candidates) + ) + + return candidates + + 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/app/services/inventory_service.py b/api/app/services/inventory_service.py new file mode 100644 index 0000000..a50402a --- /dev/null +++ b/api/app/services/inventory_service.py @@ -0,0 +1,867 @@ +""" +Inventory Service - Manages character inventory, equipment, and consumable usage. + +This service provides an orchestration layer on top of the Character model's +inventory methods, adding: +- Input validation and error handling +- Equipment slot validation (weapon vs armor slots) +- Level and class requirement checks +- Consumable effect application +- Integration with CharacterService for persistence + +Usage: + from app.services.inventory_service import get_inventory_service + + inventory_service = get_inventory_service() + inventory_service.equip_item(character, item, "weapon", user_id) + inventory_service.use_consumable(character, "health_potion_small", user_id) +""" + +from typing import List, Optional, Dict, Any, Tuple +from dataclasses import dataclass + +from app.models.character import Character +from app.models.items import Item +from app.models.effects import Effect +from app.models.enums import ItemType, EffectType +from app.services.character_service import get_character_service, CharacterService +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# ============================================================================= +# Custom Exceptions +# ============================================================================= + +class InventoryError(Exception): + """Base exception for inventory operations.""" + pass + + +class ItemNotFoundError(InventoryError): + """Raised when an item is not found in the character's inventory.""" + pass + + +class CannotEquipError(InventoryError): + """Raised when an item cannot be equipped (wrong slot, level requirement, etc.).""" + pass + + +class InvalidSlotError(InventoryError): + """Raised when an invalid equipment slot is specified.""" + pass + + +class CannotUseItemError(InventoryError): + """Raised when an item cannot be used (not consumable, etc.).""" + pass + + +class InventoryFullError(InventoryError): + """Raised when inventory capacity is exceeded.""" + pass + + +# ============================================================================= +# Equipment Slot Configuration +# ============================================================================= + +# Valid equipment slots in the game +VALID_SLOTS = { + "weapon", # Primary weapon + "off_hand", # Shield or secondary weapon + "helmet", # Head armor + "chest", # Chest armor + "gloves", # Hand armor + "boots", # Foot armor + "accessory_1", # Ring, amulet, etc. + "accessory_2", # Secondary accessory +} + +# Map item types to allowed slots +ITEM_TYPE_SLOTS = { + ItemType.WEAPON: {"weapon", "off_hand"}, + ItemType.ARMOR: {"helmet", "chest", "gloves", "boots"}, +} + +# Maximum inventory size (0 = unlimited) +MAX_INVENTORY_SIZE = 100 + + +# ============================================================================= +# Consumable Effect Result +# ============================================================================= + +@dataclass +class ConsumableResult: + """Result of using a consumable item.""" + + item_name: str + effects_applied: List[Dict[str, Any]] + hp_restored: int = 0 + mp_restored: int = 0 + message: str = "" + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dictionary for API response.""" + return { + "item_name": self.item_name, + "effects_applied": self.effects_applied, + "hp_restored": self.hp_restored, + "mp_restored": self.mp_restored, + "message": self.message, + } + + +# ============================================================================= +# Inventory Service +# ============================================================================= + +class InventoryService: + """ + Service for managing character inventory and equipment. + + This service wraps the Character model's inventory methods with additional + validation, error handling, and persistence integration. + + All methods that modify state will persist changes via CharacterService. + """ + + def __init__(self, character_service: Optional[CharacterService] = None): + """ + Initialize inventory service. + + Args: + character_service: Optional CharacterService instance (uses global if not provided) + """ + self._character_service = character_service + logger.info("InventoryService initialized") + + @property + def character_service(self) -> CharacterService: + """Get CharacterService instance (lazy-loaded).""" + if self._character_service is None: + self._character_service = get_character_service() + return self._character_service + + # ========================================================================= + # Read Operations + # ========================================================================= + + def get_inventory(self, character: Character) -> List[Item]: + """ + Get all items in character's inventory. + + Args: + character: Character instance + + Returns: + List of Item objects in inventory + """ + return list(character.inventory) + + def get_equipped_items(self, character: Character) -> Dict[str, Item]: + """ + Get all equipped items. + + Args: + character: Character instance + + Returns: + Dictionary mapping slot names to equipped Item objects + """ + return dict(character.equipped) + + def get_item_by_id(self, character: Character, item_id: str) -> Optional[Item]: + """ + Find an item in inventory by ID. + + Args: + character: Character instance + item_id: Item ID to find + + Returns: + Item if found, None otherwise + """ + for item in character.inventory: + if item.item_id == item_id: + return item + return None + + def get_equipped_item(self, character: Character, slot: str) -> Optional[Item]: + """ + Get the item equipped in a specific slot. + + Args: + character: Character instance + slot: Equipment slot name + + Returns: + Item if slot is occupied, None otherwise + """ + return character.equipped.get(slot) + + def get_inventory_count(self, character: Character) -> int: + """ + Get the number of items in inventory. + + Args: + character: Character instance + + Returns: + Number of items in inventory + """ + return len(character.inventory) + + # ========================================================================= + # Add/Remove Operations + # ========================================================================= + + def add_item( + self, + character: Character, + item: Item, + user_id: str, + save: bool = True + ) -> None: + """ + Add an item to character's inventory. + + Args: + character: Character instance + item: Item to add + user_id: User ID for persistence authorization + save: Whether to persist changes (default True) + + Raises: + InventoryFullError: If inventory is at maximum capacity + """ + # Check inventory capacity + if MAX_INVENTORY_SIZE > 0 and len(character.inventory) >= MAX_INVENTORY_SIZE: + raise InventoryFullError( + f"Inventory is full ({MAX_INVENTORY_SIZE} items max)" + ) + + # Add to inventory + character.add_item(item) + + logger.info("Item added to inventory", + character_id=character.character_id, + item_id=item.item_id, + item_name=item.get_display_name()) + + # Persist changes + if save: + self.character_service.update_character(character, user_id) + + def remove_item( + self, + character: Character, + item_id: str, + user_id: str, + save: bool = True + ) -> Item: + """ + Remove an item from character's inventory. + + Args: + character: Character instance + item_id: ID of item to remove + user_id: User ID for persistence authorization + save: Whether to persist changes (default True) + + Returns: + The removed Item + + Raises: + ItemNotFoundError: If item is not in inventory + """ + # Find item first (for better error message) + item = self.get_item_by_id(character, item_id) + if item is None: + raise ItemNotFoundError(f"Item not found in inventory: {item_id}") + + # Remove from inventory + removed_item = character.remove_item(item_id) + + logger.info("Item removed from inventory", + character_id=character.character_id, + item_id=item_id, + item_name=item.get_display_name()) + + # Persist changes + if save: + self.character_service.update_character(character, user_id) + + return removed_item + + def drop_item( + self, + character: Character, + item_id: str, + user_id: str + ) -> Item: + """ + Drop an item (remove permanently with no return). + + This is an alias for remove_item, but semantically indicates + the item is being discarded rather than transferred. + + Args: + character: Character instance + item_id: ID of item to drop + user_id: User ID for persistence authorization + + Returns: + The dropped Item (for logging/notification purposes) + + Raises: + ItemNotFoundError: If item is not in inventory + """ + return self.remove_item(character, item_id, user_id, save=True) + + # ========================================================================= + # Equipment Operations + # ========================================================================= + + def equip_item( + self, + character: Character, + item_id: str, + slot: str, + user_id: str + ) -> Optional[Item]: + """ + Equip an item from inventory to a specific slot. + + Args: + character: Character instance + item_id: ID of item to equip (must be in inventory) + slot: Equipment slot to use + user_id: User ID for persistence authorization + + Returns: + Previously equipped item in that slot (or None) + + Raises: + ItemNotFoundError: If item is not in inventory + InvalidSlotError: If slot name is invalid + CannotEquipError: If item cannot be equipped (wrong type, level, etc.) + """ + # Validate slot + if slot not in VALID_SLOTS: + raise InvalidSlotError( + f"Invalid equipment slot: '{slot}'. " + f"Valid slots: {', '.join(sorted(VALID_SLOTS))}" + ) + + # Find item in inventory + item = self.get_item_by_id(character, item_id) + if item is None: + raise ItemNotFoundError(f"Item not found in inventory: {item_id}") + + # Validate item can be equipped + self._validate_equip(character, item, slot) + + # Perform equip (Character.equip_item handles inventory management) + previous_item = character.equip_item(item, slot) + + logger.info("Item equipped", + character_id=character.character_id, + item_id=item_id, + slot=slot, + previous_item=previous_item.item_id if previous_item else None) + + # Persist changes + self.character_service.update_character(character, user_id) + + return previous_item + + def unequip_item( + self, + character: Character, + slot: str, + user_id: str + ) -> Optional[Item]: + """ + Unequip an item from a specific slot (returns to inventory). + + Args: + character: Character instance + slot: Equipment slot to unequip from + user_id: User ID for persistence authorization + + Returns: + The unequipped Item (or None if slot was empty) + + Raises: + InvalidSlotError: If slot name is invalid + InventoryFullError: If inventory is full and cannot receive the item + """ + # Validate slot + if slot not in VALID_SLOTS: + raise InvalidSlotError( + f"Invalid equipment slot: '{slot}'. " + f"Valid slots: {', '.join(sorted(VALID_SLOTS))}" + ) + + # Check if slot has an item + equipped_item = character.equipped.get(slot) + if equipped_item is None: + logger.debug("Unequip from empty slot", + character_id=character.character_id, + slot=slot) + return None + + # Check inventory capacity (item will return to inventory) + if MAX_INVENTORY_SIZE > 0 and len(character.inventory) >= MAX_INVENTORY_SIZE: + raise InventoryFullError( + "Cannot unequip: inventory is full" + ) + + # Perform unequip (Character.unequip_item handles inventory management) + unequipped_item = character.unequip_item(slot) + + logger.info("Item unequipped", + character_id=character.character_id, + item_id=unequipped_item.item_id if unequipped_item else None, + slot=slot) + + # Persist changes + self.character_service.update_character(character, user_id) + + return unequipped_item + + def swap_equipment( + self, + character: Character, + item_id: str, + slot: str, + user_id: str + ) -> Optional[Item]: + """ + Swap equipment: equip item and return the previous item. + + This is semantically the same as equip_item but makes the swap + intention explicit. + + Args: + character: Character instance + item_id: ID of item to equip + slot: Equipment slot to use + user_id: User ID for persistence authorization + + Returns: + Previously equipped item (or None) + """ + return self.equip_item(character, item_id, slot, user_id) + + def _validate_equip(self, character: Character, item: Item, slot: str) -> None: + """ + Validate that an item can be equipped to a slot. + + Args: + character: Character instance + item: Item to validate + slot: Target equipment slot + + Raises: + CannotEquipError: If item cannot be equipped + """ + # Check item type is equippable + if item.item_type not in ITEM_TYPE_SLOTS: + raise CannotEquipError( + f"Cannot equip {item.item_type.value} items. " + f"Only weapons and armor can be equipped." + ) + + # Check slot matches item type + allowed_slots = ITEM_TYPE_SLOTS[item.item_type] + if slot not in allowed_slots: + raise CannotEquipError( + f"Cannot equip {item.item_type.value} to '{slot}' slot. " + f"Allowed slots: {', '.join(sorted(allowed_slots))}" + ) + + # Check level requirement + if not item.can_equip(character.level, character.class_id): + if character.level < item.required_level: + raise CannotEquipError( + f"Cannot equip '{item.get_display_name()}': " + f"requires level {item.required_level} (you are level {character.level})" + ) + if item.required_class and item.required_class != character.class_id: + raise CannotEquipError( + f"Cannot equip '{item.get_display_name()}': " + f"requires class '{item.required_class}'" + ) + + # ========================================================================= + # Consumable Operations + # ========================================================================= + + def use_consumable( + self, + character: Character, + item_id: str, + user_id: str, + current_hp: Optional[int] = None, + max_hp: Optional[int] = None, + current_mp: Optional[int] = None, + max_mp: Optional[int] = None + ) -> ConsumableResult: + """ + Use a consumable item and apply its effects. + + For HP/MP restoration effects, provide current/max values to calculate + actual restoration (clamped to max). If not provided, uses character's + computed max_hp from stats. + + Note: Outside of combat, characters are always at full HP. During combat, + HP tracking is handled by the combat system and current_hp should be passed. + + Args: + character: Character instance + item_id: ID of consumable to use + user_id: User ID for persistence authorization + current_hp: Current HP (for healing calculations) + max_hp: Maximum HP (for healing cap) + current_mp: Current MP (for mana restore calculations) + max_mp: Maximum MP (for mana restore cap) + + Returns: + ConsumableResult with details of effects applied + + Raises: + ItemNotFoundError: If item is not in inventory + CannotUseItemError: If item is not a consumable + """ + # Find item in inventory + item = self.get_item_by_id(character, item_id) + if item is None: + raise ItemNotFoundError(f"Item not found in inventory: {item_id}") + + # Validate item is consumable + if not item.is_consumable(): + raise CannotUseItemError( + f"Cannot use '{item.get_display_name()}': not a consumable item" + ) + + # Use character computed values if not provided + if max_hp is None: + max_hp = character.max_hp + if current_hp is None: + current_hp = max_hp # Outside combat, assume full HP + + # MP handling (if character has MP system) + effective_stats = character.get_effective_stats() + if max_mp is None: + max_mp = getattr(effective_stats, 'magic_points', 100) + if current_mp is None: + current_mp = max_mp # Outside combat, assume full MP + + # Apply effects + result = self._apply_consumable_effects( + item, current_hp, max_hp, current_mp, max_mp + ) + + # Remove consumable from inventory (it's used up) + character.remove_item(item_id) + + logger.info("Consumable used", + character_id=character.character_id, + item_id=item_id, + item_name=item.get_display_name(), + hp_restored=result.hp_restored, + mp_restored=result.mp_restored) + + # Persist changes + self.character_service.update_character(character, user_id) + + return result + + def _apply_consumable_effects( + self, + item: Item, + current_hp: int, + max_hp: int, + current_mp: int, + max_mp: int + ) -> ConsumableResult: + """ + Apply consumable effects and calculate results. + + Args: + item: Consumable item + current_hp: Current HP + max_hp: Maximum HP + current_mp: Current MP + max_mp: Maximum MP + + Returns: + ConsumableResult with effect details + """ + effects_applied = [] + total_hp_restored = 0 + total_mp_restored = 0 + messages = [] + + for effect in item.effects_on_use: + effect_result = { + "effect_name": effect.name, + "effect_type": effect.effect_type.value, + } + + if effect.effect_type == EffectType.HOT: + # Instant heal (for potions, treat HOT as instant outside combat) + heal_amount = effect.power * effect.stacks + actual_heal = min(heal_amount, max_hp - current_hp) + current_hp += actual_heal + total_hp_restored += actual_heal + + effect_result["value"] = actual_heal + effect_result["message"] = f"Restored {actual_heal} HP" + messages.append(f"Restored {actual_heal} HP") + + elif effect.effect_type == EffectType.BUFF: + # Stat buff - would be applied in combat context + stat_name = effect.stat_affected.value if effect.stat_affected else "unknown" + effect_result["stat_affected"] = stat_name + effect_result["modifier"] = effect.power + effect_result["duration"] = effect.duration + effect_result["message"] = f"+{effect.power} {stat_name} for {effect.duration} turns" + messages.append(f"+{effect.power} {stat_name}") + + elif effect.effect_type == EffectType.SHIELD: + # Apply shield effect + shield_power = effect.power * effect.stacks + effect_result["shield_power"] = shield_power + effect_result["duration"] = effect.duration + effect_result["message"] = f"Shield for {shield_power} damage" + messages.append(f"Shield: {shield_power}") + + else: + # Other effect types (DOT, DEBUFF, STUN - unusual for consumables) + effect_result["power"] = effect.power + effect_result["duration"] = effect.duration + effect_result["message"] = f"{effect.name} applied" + + effects_applied.append(effect_result) + + # Build summary message + summary = f"Used {item.get_display_name()}" + if messages: + summary += f": {', '.join(messages)}" + + return ConsumableResult( + item_name=item.get_display_name(), + effects_applied=effects_applied, + hp_restored=total_hp_restored, + mp_restored=total_mp_restored, + message=summary, + ) + + def use_consumable_in_combat( + self, + character: Character, + item_id: str, + user_id: str, + current_hp: int, + max_hp: int, + current_mp: int = 0, + max_mp: int = 0 + ) -> Tuple[ConsumableResult, List[Effect]]: + """ + Use a consumable during combat. + + Returns both the result summary and a list of Effect objects that + should be applied to the combatant for duration-based effects. + + Args: + character: Character instance + item_id: ID of consumable to use + user_id: User ID for persistence authorization + current_hp: Current combat HP + max_hp: Maximum combat HP + current_mp: Current combat MP + max_mp: Maximum combat MP + + Returns: + Tuple of (ConsumableResult, List[Effect]) for combat system + + Raises: + ItemNotFoundError: If item not in inventory + CannotUseItemError: If item is not consumable + """ + # Find item + item = self.get_item_by_id(character, item_id) + if item is None: + raise ItemNotFoundError(f"Item not found in inventory: {item_id}") + + if not item.is_consumable(): + raise CannotUseItemError( + f"Cannot use '{item.get_display_name()}': not a consumable" + ) + + # Separate instant effects from duration effects + instant_effects = [] + duration_effects = [] + + for effect in item.effects_on_use: + # HOT effects in combat should tick, not instant heal + if effect.duration > 1 or effect.effect_type in [ + EffectType.BUFF, EffectType.DEBUFF, EffectType.DOT, + EffectType.HOT, EffectType.SHIELD, EffectType.STUN + ]: + # Copy effect for combat tracking + combat_effect = Effect( + effect_id=f"{item.item_id}_{effect.effect_id}", + name=effect.name, + effect_type=effect.effect_type, + duration=effect.duration, + power=effect.power, + stat_affected=effect.stat_affected, + stacks=effect.stacks, + max_stacks=effect.max_stacks, + source=item.item_id, + ) + duration_effects.append(combat_effect) + else: + instant_effects.append(effect) + + # Calculate instant effect results + result = self._apply_consumable_effects( + item, current_hp, max_hp, current_mp, max_mp + ) + + # Remove from inventory + character.remove_item(item_id) + + logger.info("Consumable used in combat", + character_id=character.character_id, + item_id=item_id, + duration_effects=len(duration_effects)) + + # Persist + self.character_service.update_character(character, user_id) + + return result, duration_effects + + # ========================================================================= + # Bulk Operations + # ========================================================================= + + def add_items( + self, + character: Character, + items: List[Item], + user_id: str + ) -> int: + """ + Add multiple items to inventory (e.g., loot drop). + + Args: + character: Character instance + items: List of items to add + user_id: User ID for persistence + + Returns: + Number of items actually added + + Note: + Stops adding if inventory becomes full. Does not raise error + for partial success. + """ + added_count = 0 + for item in items: + try: + self.add_item(character, item, user_id, save=False) + added_count += 1 + except InventoryFullError: + logger.warning("Inventory full, dropping remaining loot", + character_id=character.character_id, + items_dropped=len(items) - added_count) + break + + # Save once after all items added + if added_count > 0: + self.character_service.update_character(character, user_id) + + return added_count + + def get_items_by_type( + self, + character: Character, + item_type: ItemType + ) -> List[Item]: + """ + Get all inventory items of a specific type. + + Args: + character: Character instance + item_type: Type to filter by + + Returns: + List of matching items + """ + return [ + item for item in character.inventory + if item.item_type == item_type + ] + + def get_equippable_items( + self, + character: Character, + slot: Optional[str] = None + ) -> List[Item]: + """ + Get all items that can be equipped. + + Args: + character: Character instance + slot: Optional slot to filter by + + Returns: + List of equippable items (optionally filtered by slot) + """ + equippable = [] + for item in character.inventory: + # Skip non-equippable types + if item.item_type not in ITEM_TYPE_SLOTS: + continue + + # Skip items that don't meet requirements + if not item.can_equip(character.level, character.class_id): + continue + + # Filter by slot if specified + if slot: + allowed_slots = ITEM_TYPE_SLOTS[item.item_type] + if slot not in allowed_slots: + continue + + equippable.append(item) + + return equippable + + +# ============================================================================= +# Global Instance +# ============================================================================= + +_service_instance: Optional[InventoryService] = None + + +def get_inventory_service() -> InventoryService: + """ + Get the global InventoryService instance. + + Returns: + Singleton InventoryService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = InventoryService() + return _service_instance diff --git a/api/app/services/item_generator.py b/api/app/services/item_generator.py new file mode 100644 index 0000000..ce5164b --- /dev/null +++ b/api/app/services/item_generator.py @@ -0,0 +1,536 @@ +""" +Item Generator Service - Procedural item generation with affixes. + +This service generates Diablo-style items by combining base templates with +random affixes, creating items like "Flaming Dagger of Strength". +""" + +import uuid +import random +from typing import List, Optional, Tuple, Dict, Any + +from app.models.items import Item +from app.models.affixes import Affix, BaseItemTemplate +from app.models.enums import ItemType, ItemRarity, DamageType, AffixTier +from app.services.affix_loader import get_affix_loader, AffixLoader +from app.services.base_item_loader import get_base_item_loader, BaseItemLoader +from app.utils.logging import get_logger + +logger = get_logger(__file__) + +# Affix count by rarity (COMMON/UNCOMMON get 0 affixes - plain items) +AFFIX_COUNTS = { + ItemRarity.COMMON: 0, + ItemRarity.UNCOMMON: 0, + ItemRarity.RARE: 1, + ItemRarity.EPIC: 2, + ItemRarity.LEGENDARY: 3, +} + +# Tier selection probabilities by rarity +# Higher rarity items have better chance at higher tier affixes +TIER_WEIGHTS = { + ItemRarity.RARE: { + AffixTier.MINOR: 0.8, + AffixTier.MAJOR: 0.2, + AffixTier.LEGENDARY: 0.0, + }, + ItemRarity.EPIC: { + AffixTier.MINOR: 0.3, + AffixTier.MAJOR: 0.7, + AffixTier.LEGENDARY: 0.0, + }, + ItemRarity.LEGENDARY: { + AffixTier.MINOR: 0.1, + AffixTier.MAJOR: 0.4, + AffixTier.LEGENDARY: 0.5, + }, +} + +# Rarity value multipliers (higher rarity = more valuable) +RARITY_VALUE_MULTIPLIER = { + ItemRarity.COMMON: 1.0, + ItemRarity.UNCOMMON: 1.5, + ItemRarity.RARE: 2.5, + ItemRarity.EPIC: 5.0, + ItemRarity.LEGENDARY: 10.0, +} + + +class ItemGenerator: + """ + Generates procedural items with Diablo-style naming. + + This service combines base item templates with randomly selected affixes + to create unique items with combined stats and generated names. + """ + + def __init__( + self, + affix_loader: Optional[AffixLoader] = None, + base_item_loader: Optional[BaseItemLoader] = None + ): + """ + Initialize the item generator. + + Args: + affix_loader: Optional custom AffixLoader instance + base_item_loader: Optional custom BaseItemLoader instance + """ + self.affix_loader = affix_loader or get_affix_loader() + self.base_item_loader = base_item_loader or get_base_item_loader() + + logger.info("ItemGenerator initialized") + + def generate_item( + self, + item_type: str, + rarity: ItemRarity, + character_level: int = 1, + base_template_id: Optional[str] = None + ) -> Optional[Item]: + """ + Generate a procedural item. + + Args: + item_type: "weapon" or "armor" + rarity: Target rarity + character_level: Player level for template eligibility + base_template_id: Optional specific base template to use + + Returns: + Generated Item instance or None if generation fails + """ + # 1. Get base template + base_template = self._get_base_template( + item_type, rarity, character_level, base_template_id + ) + if not base_template: + logger.warning( + "No base template available", + item_type=item_type, + rarity=rarity.value, + level=character_level + ) + return None + + # 2. Get affix count for this rarity + affix_count = AFFIX_COUNTS.get(rarity, 0) + + # 3. Select affixes + prefixes, suffixes = self._select_affixes( + base_template.item_type, rarity, affix_count + ) + + # 4. Build the item + item = self._build_item(base_template, rarity, prefixes, suffixes) + + logger.info( + "Item generated", + item_id=item.item_id, + name=item.get_display_name(), + rarity=rarity.value, + affixes=[a.affix_id for a in prefixes + suffixes] + ) + + return item + + def _get_base_template( + self, + item_type: str, + rarity: ItemRarity, + character_level: int, + template_id: Optional[str] = None + ) -> Optional[BaseItemTemplate]: + """ + Get a base template for item generation. + + Args: + item_type: Type of item + rarity: Target rarity + character_level: Player level + template_id: Optional specific template ID + + Returns: + BaseItemTemplate instance or None + """ + if template_id: + return self.base_item_loader.get_template(template_id) + + return self.base_item_loader.get_random_template( + item_type, rarity.value, character_level + ) + + def _select_affixes( + self, + item_type: str, + rarity: ItemRarity, + count: int + ) -> Tuple[List[Affix], List[Affix]]: + """ + Select random affixes for an item. + + Distribution logic: + - RARE (1 affix): 50% chance prefix, 50% chance suffix + - EPIC (2 affixes): 1 prefix AND 1 suffix + - LEGENDARY (3 affixes): Mix of prefixes and suffixes + + Args: + item_type: Type of item + rarity: Item rarity + count: Number of affixes to select + + Returns: + Tuple of (prefixes, suffixes) + """ + prefixes: List[Affix] = [] + suffixes: List[Affix] = [] + used_ids: List[str] = [] + + if count == 0: + return prefixes, suffixes + + # Determine tier for affix selection + tier = self._roll_affix_tier(rarity) + + if count == 1: + # RARE: Either prefix OR suffix (50/50) + if random.random() < 0.5: + prefix = self.affix_loader.get_random_prefix( + item_type, rarity.value, tier, used_ids + ) + if prefix: + prefixes.append(prefix) + used_ids.append(prefix.affix_id) + else: + suffix = self.affix_loader.get_random_suffix( + item_type, rarity.value, tier, used_ids + ) + if suffix: + suffixes.append(suffix) + used_ids.append(suffix.affix_id) + + elif count == 2: + # EPIC: 1 prefix AND 1 suffix + tier = self._roll_affix_tier(rarity) + prefix = self.affix_loader.get_random_prefix( + item_type, rarity.value, tier, used_ids + ) + if prefix: + prefixes.append(prefix) + used_ids.append(prefix.affix_id) + + tier = self._roll_affix_tier(rarity) + suffix = self.affix_loader.get_random_suffix( + item_type, rarity.value, tier, used_ids + ) + if suffix: + suffixes.append(suffix) + used_ids.append(suffix.affix_id) + + elif count >= 3: + # LEGENDARY: Mix of prefixes and suffixes + # Try: 2 prefixes + 1 suffix OR 1 prefix + 2 suffixes + distribution = random.choice([(2, 1), (1, 2)]) + prefix_count, suffix_count = distribution + + for _ in range(prefix_count): + tier = self._roll_affix_tier(rarity) + prefix = self.affix_loader.get_random_prefix( + item_type, rarity.value, tier, used_ids + ) + if prefix: + prefixes.append(prefix) + used_ids.append(prefix.affix_id) + + for _ in range(suffix_count): + tier = self._roll_affix_tier(rarity) + suffix = self.affix_loader.get_random_suffix( + item_type, rarity.value, tier, used_ids + ) + if suffix: + suffixes.append(suffix) + used_ids.append(suffix.affix_id) + + return prefixes, suffixes + + def _roll_affix_tier(self, rarity: ItemRarity) -> Optional[AffixTier]: + """ + Roll for affix tier based on item rarity. + + Args: + rarity: Item rarity + + Returns: + Selected AffixTier or None for no tier filter + """ + weights = TIER_WEIGHTS.get(rarity) + if not weights: + return None + + tiers = list(weights.keys()) + tier_weights = list(weights.values()) + + # Filter out zero-weight options + valid_tiers = [] + valid_weights = [] + for t, w in zip(tiers, tier_weights): + if w > 0: + valid_tiers.append(t) + valid_weights.append(w) + + if not valid_tiers: + return None + + return random.choices(valid_tiers, weights=valid_weights, k=1)[0] + + def _build_item( + self, + base_template: BaseItemTemplate, + rarity: ItemRarity, + prefixes: List[Affix], + suffixes: List[Affix] + ) -> Item: + """ + Build an Item from base template and affixes. + + Args: + base_template: Base item template + rarity: Item rarity + prefixes: List of prefix affixes + suffixes: List of suffix affixes + + Returns: + Fully constructed Item instance + """ + # Generate unique ID + item_id = f"gen_{uuid.uuid4().hex[:12]}" + + # Build generated name + generated_name = self._build_name(base_template.name, prefixes, suffixes) + + # Combine stats from all affixes + combined_stats = self._combine_affix_stats(prefixes + suffixes) + + # Calculate final item values + item_type = ItemType.WEAPON if base_template.item_type == "weapon" else ItemType.ARMOR + + # Base values from template + damage = base_template.base_damage + combined_stats["damage_bonus"] + spell_power = base_template.base_spell_power # Magical weapon damage + defense = base_template.base_defense + combined_stats["defense_bonus"] + resistance = base_template.base_resistance + combined_stats["resistance_bonus"] + crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"] + crit_multiplier = base_template.crit_multiplier + combined_stats["crit_multiplier_bonus"] + + # Calculate value with rarity multiplier + base_value = base_template.base_value + rarity_mult = RARITY_VALUE_MULTIPLIER.get(rarity, 1.0) + # Add value for each affix + affix_value = len(prefixes + suffixes) * 25 + final_value = int((base_value + affix_value) * rarity_mult) + + # Determine elemental damage type (from prefix affixes) + elemental_damage_type = None + elemental_ratio = 0.0 + for prefix in prefixes: + if prefix.applies_elemental_damage(): + elemental_damage_type = prefix.damage_type + elemental_ratio = prefix.elemental_ratio + break # Use first elemental prefix + + # Track applied affixes + applied_affixes = [a.affix_id for a in prefixes + suffixes] + + # Create the item + item = Item( + item_id=item_id, + name=base_template.name, # Base name + item_type=item_type, + rarity=rarity, + description=base_template.description, + value=final_value, + is_tradeable=True, + stat_bonuses=combined_stats["stat_bonuses"], + effects_on_use=[], # Not a consumable + damage=damage, + spell_power=spell_power, # Magical weapon damage bonus + damage_type=DamageType(base_template.damage_type) if damage > 0 else None, + crit_chance=crit_chance, + crit_multiplier=crit_multiplier, + elemental_damage_type=elemental_damage_type, + physical_ratio=1.0 - elemental_ratio if elemental_ratio > 0 else 1.0, + elemental_ratio=elemental_ratio, + defense=defense, + resistance=resistance, + required_level=base_template.required_level, + required_class=None, + # Affix tracking + applied_affixes=applied_affixes, + base_template_id=base_template.template_id, + generated_name=generated_name, + is_generated=True, + ) + + return item + + def _build_name( + self, + base_name: str, + prefixes: List[Affix], + suffixes: List[Affix] + ) -> str: + """ + Build the full item name with affixes. + + Examples: + - RARE (1 prefix): "Flaming Dagger" + - RARE (1 suffix): "Dagger of Strength" + - EPIC: "Flaming Dagger of Strength" + - LEGENDARY: "Blazing Glacial Dagger of the Titan" + + Note: Rarity is NOT included in name (shown via UI). + + Args: + base_name: Base item name (e.g., "Dagger") + prefixes: List of prefix affixes + suffixes: List of suffix affixes + + Returns: + Full generated name string + """ + parts = [] + + # Add prefix names (in order) + for prefix in prefixes: + parts.append(prefix.name) + + # Add base name + parts.append(base_name) + + # Build name string from parts + name = " ".join(parts) + + # Add suffix names (they include "of") + for suffix in suffixes: + name += f" {suffix.name}" + + return name + + def _combine_affix_stats(self, affixes: List[Affix]) -> Dict[str, Any]: + """ + Combine stats from multiple affixes. + + Args: + affixes: List of affixes to combine + + Returns: + Dictionary with combined stat values + """ + combined = { + "stat_bonuses": {}, + "damage_bonus": 0, + "defense_bonus": 0, + "resistance_bonus": 0, + "crit_chance_bonus": 0.0, + "crit_multiplier_bonus": 0.0, + } + + for affix in affixes: + # Combine stat bonuses + for stat_name, bonus in affix.stat_bonuses.items(): + current = combined["stat_bonuses"].get(stat_name, 0) + combined["stat_bonuses"][stat_name] = current + bonus + + # Combine direct bonuses + combined["damage_bonus"] += affix.damage_bonus + combined["defense_bonus"] += affix.defense_bonus + combined["resistance_bonus"] += affix.resistance_bonus + combined["crit_chance_bonus"] += affix.crit_chance_bonus + combined["crit_multiplier_bonus"] += affix.crit_multiplier_bonus + + return combined + + def generate_loot_drop( + self, + character_level: int, + luck_stat: int = 8, + item_type: Optional[str] = None + ) -> Optional[Item]: + """ + Generate a random loot drop with luck-influenced rarity. + + Args: + character_level: Player level + luck_stat: Player's luck stat (affects rarity chance) + item_type: Optional item type filter + + Returns: + Generated Item or None + """ + # Choose random item type if not specified + if item_type is None: + item_type = random.choice(["weapon", "armor"]) + + # Roll rarity with luck bonus + rarity = self._roll_rarity(luck_stat) + + return self.generate_item(item_type, rarity, character_level) + + def _roll_rarity(self, luck_stat: int) -> ItemRarity: + """ + Roll item rarity with luck bonus. + + Base chances (luck 8): + - COMMON: 50% + - UNCOMMON: 30% + - RARE: 15% + - EPIC: 4% + - LEGENDARY: 1% + + Luck modifies these chances slightly. + + Args: + luck_stat: Player's luck stat + + Returns: + Rolled ItemRarity + """ + # Calculate luck bonus (luck 8 = baseline) + luck_bonus = (luck_stat - 8) * 0.005 + + roll = random.random() + + # Thresholds (cumulative) + legendary_threshold = 0.01 + luck_bonus + epic_threshold = legendary_threshold + 0.04 + luck_bonus * 2 + rare_threshold = epic_threshold + 0.15 + luck_bonus * 3 + uncommon_threshold = rare_threshold + 0.30 + + if roll < legendary_threshold: + return ItemRarity.LEGENDARY + elif roll < epic_threshold: + return ItemRarity.EPIC + elif roll < rare_threshold: + return ItemRarity.RARE + elif roll < uncommon_threshold: + return ItemRarity.UNCOMMON + else: + return ItemRarity.COMMON + + +# Global instance for convenience +_generator_instance: Optional[ItemGenerator] = None + + +def get_item_generator() -> ItemGenerator: + """ + Get the global ItemGenerator instance. + + Returns: + Singleton ItemGenerator instance + """ + global _generator_instance + if _generator_instance is None: + _generator_instance = ItemGenerator() + return _generator_instance diff --git a/api/app/services/session_service.py b/api/app/services/session_service.py index 5ecd427..3383ff5 100644 --- a/api/app/services/session_service.py +++ b/api/app/services/session_service.py @@ -272,9 +272,9 @@ class SessionService: session_json = json.dumps(session_dict) # Update in database - self.db.update_document( - collection_id=self.collection_id, - document_id=session.session_id, + self.db.update_row( + table_id=self.collection_id, + row_id=session.session_id, data={ 'sessionData': session_json, 'status': session.status.value diff --git a/api/app/services/static_item_loader.py b/api/app/services/static_item_loader.py new file mode 100644 index 0000000..c9d63ae --- /dev/null +++ b/api/app/services/static_item_loader.py @@ -0,0 +1,301 @@ +""" +Static Item Loader Service - YAML-based static item loading. + +This service loads predefined item definitions (consumables, materials, quest items) +from YAML files, providing a way to reference specific items by ID in loot tables. + +Static items differ from procedurally generated items in that they have fixed +properties defined in YAML rather than randomly generated affixes. +""" + +from pathlib import Path +from typing import Dict, List, Optional +import uuid +import yaml + +from app.models.items import Item +from app.models.effects import Effect +from app.models.enums import ItemType, ItemRarity, EffectType, DamageType +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class StaticItemLoader: + """ + Loads and manages static item definitions from YAML configuration files. + + Static items are predefined items (consumables, materials, quest items) + that can be referenced by item_id in enemy loot tables. + + Items are loaded from: + - api/app/data/static_items/consumables.yaml + - api/app/data/static_items/materials.yaml + + Each call to get_item() creates a new Item instance with a unique ID, + so multiple drops of the same item_id become distinct inventory items. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the static item loader. + + Args: + data_dir: Path to directory containing static item YAML files. + Defaults to /app/data/static_items/ + """ + if data_dir is None: + # Default to app/data/static_items relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "static_items") + + self.data_dir = Path(data_dir) + self._cache: Dict[str, dict] = {} + self._loaded = False + + logger.info("StaticItemLoader initialized", data_dir=str(self.data_dir)) + + def _ensure_loaded(self) -> None: + """Ensure items are loaded before any operation.""" + if not self._loaded: + self._load_all() + + def _load_all(self) -> None: + """Load all static item YAML files.""" + if not self.data_dir.exists(): + logger.warning( + "Static items directory not found", + path=str(self.data_dir) + ) + self._loaded = True + return + + # Load all YAML files in the directory + for yaml_file in self.data_dir.glob("*.yaml"): + self._load_file(yaml_file) + + self._loaded = True + logger.info("Static items loaded", count=len(self._cache)) + + def _load_file(self, yaml_file: Path) -> None: + """ + Load items from a single YAML file. + + Args: + yaml_file: Path to the YAML file + """ + try: + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + if data is None: + logger.warning("Empty YAML file", file=str(yaml_file)) + return + + items = data.get("items", {}) + for item_id, item_data in items.items(): + # Store the template data with its ID + item_data["_item_id"] = item_id + self._cache[item_id] = item_data + + logger.debug( + "Static items loaded from file", + file=str(yaml_file), + count=len(items) + ) + + except Exception as e: + logger.error( + "Failed to load static items file", + file=str(yaml_file), + error=str(e) + ) + + def get_item(self, item_id: str, quantity: int = 1) -> Optional[Item]: + """ + Get an item instance by ID. + + Creates a new Item instance with a unique ID for each call, + so multiple drops become distinct inventory items. + + Args: + item_id: The static item ID (e.g., "health_potion_small") + quantity: Requested quantity (not used for individual item, + but available for future stackable item support) + + Returns: + Item instance or None if item_id not found + """ + self._ensure_loaded() + + template = self._cache.get(item_id) + if template is None: + logger.warning("Static item not found", item_id=item_id) + return None + + # Create new instance with unique ID + instance_id = f"{item_id}_{uuid.uuid4().hex[:8]}" + + # Parse item type + item_type_str = template.get("item_type", "quest_item") + try: + item_type = ItemType(item_type_str) + except ValueError: + logger.warning( + "Unknown item type, defaulting to quest_item", + item_type=item_type_str, + item_id=item_id + ) + item_type = ItemType.QUEST_ITEM + + # Parse rarity + rarity_str = template.get("rarity", "common") + try: + rarity = ItemRarity(rarity_str) + except ValueError: + logger.warning( + "Unknown rarity, defaulting to common", + rarity=rarity_str, + item_id=item_id + ) + rarity = ItemRarity.COMMON + + # Parse effects if present + effects = [] + for effect_data in template.get("effects_on_use", []): + try: + effect = self._parse_effect(effect_data) + if effect: + effects.append(effect) + except Exception as e: + logger.warning( + "Failed to parse effect", + item_id=item_id, + error=str(e) + ) + + # Parse stat bonuses if present + stat_bonuses = template.get("stat_bonuses", {}) + + # Parse damage type if present (for weapons) + damage_type = None + damage_type_str = template.get("damage_type") + if damage_type_str: + try: + damage_type = DamageType(damage_type_str) + except ValueError: + logger.warning( + "Unknown damage type, defaulting to physical", + damage_type=damage_type_str, + item_id=item_id + ) + damage_type = DamageType.PHYSICAL + + return Item( + item_id=instance_id, + name=template.get("name", item_id), + item_type=item_type, + rarity=rarity, + description=template.get("description", ""), + value=template.get("value", 1), + is_tradeable=template.get("is_tradeable", True), + stat_bonuses=stat_bonuses, + effects_on_use=effects, + # Weapon-specific fields + damage=template.get("damage", 0), + spell_power=template.get("spell_power", 0), + damage_type=damage_type, + crit_chance=template.get("crit_chance", 0.05), + crit_multiplier=template.get("crit_multiplier", 2.0), + # Armor-specific fields + defense=template.get("defense", 0), + resistance=template.get("resistance", 0), + # Level requirements + required_level=template.get("required_level", 1), + ) + + def _parse_effect(self, effect_data: Dict) -> Optional[Effect]: + """ + Parse an effect from YAML data. + + Supports simplified YAML format where effect_type is a string. + + Args: + effect_data: Effect definition from YAML + + Returns: + Effect instance or None if parsing fails + """ + # Parse effect type + effect_type_str = effect_data.get("effect_type", "buff") + try: + effect_type = EffectType(effect_type_str) + except ValueError: + logger.warning( + "Unknown effect type", + effect_type=effect_type_str + ) + return None + + # Generate effect ID if not provided + effect_id = effect_data.get( + "effect_id", + f"effect_{uuid.uuid4().hex[:8]}" + ) + + return Effect( + effect_id=effect_id, + name=effect_data.get("name", "Unknown Effect"), + effect_type=effect_type, + duration=effect_data.get("duration", 1), + power=effect_data.get("power", 0), + stacks=effect_data.get("stacks", 1), + max_stacks=effect_data.get("max_stacks", 5), + ) + + def get_all_item_ids(self) -> List[str]: + """ + Get list of all available static item IDs. + + Returns: + List of item_id strings + """ + self._ensure_loaded() + return list(self._cache.keys()) + + def has_item(self, item_id: str) -> bool: + """ + Check if an item ID exists. + + Args: + item_id: The item ID to check + + Returns: + True if item exists in cache + """ + self._ensure_loaded() + return item_id in self._cache + + def clear_cache(self) -> None: + """Clear the item cache, forcing reload on next access.""" + self._cache.clear() + self._loaded = False + logger.debug("Static item cache cleared") + + +# Global instance for convenience +_loader_instance: Optional[StaticItemLoader] = None + + +def get_static_item_loader() -> StaticItemLoader: + """ + Get the global StaticItemLoader instance. + + Returns: + Singleton StaticItemLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = StaticItemLoader() + return _loader_instance diff --git a/api/app/tasks/combat_cleanup.py b/api/app/tasks/combat_cleanup.py new file mode 100644 index 0000000..8d4ce49 --- /dev/null +++ b/api/app/tasks/combat_cleanup.py @@ -0,0 +1,144 @@ +""" +Combat Cleanup Tasks. + +This module provides scheduled tasks for cleaning up ended combat +encounters that are older than the retention period. + +The cleanup can be scheduled to run periodically (daily recommended) +via APScheduler, cron, or manual invocation. + +Usage: + # Manual invocation + from app.tasks.combat_cleanup import cleanup_old_combat_encounters + result = cleanup_old_combat_encounters(older_than_days=7) + + # Via APScheduler + scheduler.add_job( + cleanup_old_combat_encounters, + 'interval', + days=1, + kwargs={'older_than_days': 7} + ) +""" + +from typing import Dict, Any + +from app.services.combat_repository import get_combat_repository +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# Default retention period in days +DEFAULT_RETENTION_DAYS = 7 + + +def cleanup_old_combat_encounters( + older_than_days: int = DEFAULT_RETENTION_DAYS +) -> Dict[str, Any]: + """ + Delete ended combat encounters older than specified days. + + This is the main cleanup function for time-based retention. + Should be scheduled to run periodically (daily recommended). + + Only deletes ENDED encounters (victory, defeat, fled) - active + encounters are never deleted. + + Args: + older_than_days: Number of days after which to delete ended combats. + Default is 7 days. + + Returns: + Dict containing: + - deleted_encounters: Number of encounters deleted + - deleted_rounds: Approximate rounds deleted (cascaded) + - older_than_days: The threshold used + - success: Whether the operation completed successfully + - error: Error message if failed + + Example: + >>> result = cleanup_old_combat_encounters(older_than_days=7) + >>> print(f"Deleted {result['deleted_encounters']} encounters") + """ + logger.info("Starting combat encounter cleanup", + older_than_days=older_than_days) + + try: + repo = get_combat_repository() + deleted_count = repo.delete_old_encounters(older_than_days) + + result = { + "deleted_encounters": deleted_count, + "older_than_days": older_than_days, + "success": True, + "error": None + } + + logger.info("Combat encounter cleanup completed successfully", + deleted_count=deleted_count, + older_than_days=older_than_days) + + return result + + except Exception as e: + logger.error("Combat encounter cleanup failed", + error=str(e), + older_than_days=older_than_days) + + return { + "deleted_encounters": 0, + "older_than_days": older_than_days, + "success": False, + "error": str(e) + } + + +def cleanup_encounters_for_session(session_id: str) -> Dict[str, Any]: + """ + Delete all combat encounters for a specific session. + + Call this when a session is being deleted to clean up + associated combat data. + + Args: + session_id: The session ID to clean up + + Returns: + Dict containing: + - deleted_encounters: Number of encounters deleted + - session_id: The session ID processed + - success: Whether the operation completed successfully + - error: Error message if failed + """ + logger.info("Cleaning up combat encounters for session", + session_id=session_id) + + try: + repo = get_combat_repository() + deleted_count = repo.delete_encounters_by_session(session_id) + + result = { + "deleted_encounters": deleted_count, + "session_id": session_id, + "success": True, + "error": None + } + + logger.info("Session combat cleanup completed", + session_id=session_id, + deleted_count=deleted_count) + + return result + + except Exception as e: + logger.error("Session combat cleanup failed", + session_id=session_id, + error=str(e)) + + return { + "deleted_encounters": 0, + "session_id": session_id, + "success": False, + "error": str(e) + } diff --git a/api/docs/API_REFERENCE.md b/api/docs/API_REFERENCE.md index 1d2f611..9a6140b 100644 --- a/api/docs/API_REFERENCE.md +++ b/api/docs/API_REFERENCE.md @@ -4,10 +4,10 @@ All API responses follow standardized format: ```json { - "app": "AI Dungeon Master", - "version": "1.0.0", + "app": "Code of Conquest", + "version": "0.1.0", "status": 200, - "timestamp": "2025-11-14T12:00:00Z", + "timestamp": "2025-11-27T12:00:00Z", "request_id": "optional-request-id", "result": {}, "error": null, @@ -203,21 +203,7 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -### Reset Password (Display Form) - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/auth/reset-password` | -| **Description** | Display password reset form | -| **Auth Required** | No | - -**Query Parameters:** -- `userId` - User ID from reset email -- `secret` - Reset secret from email - -**Success:** Renders password reset form - -### Reset Password (Submit) +### Reset Password | | | |---|---| @@ -439,25 +425,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (400 Bad Request - Validation Error):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "INVALID_INPUT", - "message": "Validation failed", - "details": { - "name": "Character name must be at least 2 characters", - "class_id": "Invalid class ID. Must be one of: vanguard, assassin, arcanist, luminary, wildstrider, oathkeeper, necromancer, lorekeeper" - } - } -} -``` - ### Delete Character | | | @@ -512,38 +479,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (400 Bad Request - Prerequisites Not Met):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "SKILL_UNLOCK_ERROR", - "message": "Prerequisite not met: iron_defense required for fortified_resolve", - "details": {} - } -} -``` - -**Error Response (400 Bad Request - No Skill Points):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "SKILL_UNLOCK_ERROR", - "message": "No skill points available (Level 1, 1 skills unlocked)", - "details": {} - } -} -``` - ### Respec Skills | | | @@ -569,22 +504,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (400 Bad Request - Insufficient Gold):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "INSUFFICIENT_GOLD", - "message": "Insufficient gold for respec. Cost: 500, Available: 100", - "details": {} - } -} -``` - --- ## Classes & Origins (Reference Data) @@ -621,22 +540,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "skill_trees": ["Shield Bearer", "Weapon Master"], "starting_equipment": ["rusty_sword"], "starting_abilities": ["basic_attack"] - }, - { - "class_id": "assassin", - "name": "Assassin", - "description": "A master of stealth and precision...", - "base_stats": { - "strength": 11, - "dexterity": 15, - "constitution": 10, - "intelligence": 9, - "wisdom": 10, - "charisma": 10 - }, - "skill_trees": ["Shadow Dancer", "Blade Specialist"], - "starting_equipment": ["rusty_dagger"], - "starting_abilities": ["basic_attack"] } ], "count": 8 @@ -689,12 +592,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "unlocks_abilities": ["shield_bash"] } ] - }, - { - "tree_id": "weapon_master", - "name": "Weapon Master", - "description": "Offensive damage specialization", - "nodes": [] } ], "starting_equipment": ["rusty_sword"], @@ -703,22 +600,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (404 Not Found):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 404, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "NOT_FOUND", - "message": "Class not found: invalid_class", - "details": {} - } -} -``` - ### List Origins | | | @@ -748,11 +629,7 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age }, "narrative_hooks": [ "Why were you brought back to life?", - "What happened in the centuries you were dead?", - "Do you remember your past life?", - "Who or what resurrected you?", - "Are there others like you?", - "What is your purpose now?" + "What happened in the centuries you were dead?" ], "starting_bonus": { "trait": "Deathless Resolve", @@ -768,6 +645,187 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age --- +## Inventory + +### Get Inventory + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/characters//inventory` | +| **Description** | Get character inventory and equipped items | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "inventory": [ + { + "item_id": "gen_abc123", + "name": "Flaming Dagger", + "item_type": "weapon", + "rarity": "rare", + "value": 250, + "description": "A dagger imbued with fire magic" + } + ], + "equipped": { + "weapon": { + "item_id": "rusty_sword", + "name": "Rusty Sword", + "item_type": "weapon", + "rarity": "common" + }, + "helmet": null, + "chest": null, + "legs": null, + "boots": null, + "gloves": null, + "ring1": null, + "ring2": null, + "amulet": null + }, + "inventory_count": 5, + "max_inventory": 100 + } +} +``` + +### Equip Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//inventory/equip` | +| **Description** | Equip an item from inventory to a specified slot | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "item_id": "gen_abc123", + "slot": "weapon" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "message": "Equipped Flaming Dagger to weapon slot", + "equipped": { + "weapon": {...}, + "helmet": null + }, + "unequipped_item": { + "item_id": "rusty_sword", + "name": "Rusty Sword" + } + } +} +``` + +### Unequip Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//inventory/unequip` | +| **Description** | Unequip an item from a specified slot (returns to inventory) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "slot": "weapon" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "message": "Unequipped Flaming Dagger from weapon slot", + "unequipped_item": {...}, + "equipped": {...} + } +} +``` + +### Use Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//inventory/use` | +| **Description** | Use a consumable item from inventory | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "item_id": "health_potion_small" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "item_used": "Small Health Potion", + "effects_applied": [ + { + "effect_name": "Healing", + "effect_type": "hot", + "value": 25, + "message": "Restored 25 HP" + } + ], + "hp_restored": 25, + "mp_restored": 0, + "message": "Used Small Health Potion: Restored 25 HP" + } +} +``` + +### Drop Item + +| | | +|---|---| +| **Endpoint** | `DELETE /api/v1/characters//inventory/` | +| **Description** | Drop (remove) an item from inventory | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "message": "Dropped Rusty Sword", + "dropped_item": {...}, + "inventory_count": 4 + } +} +``` + +--- + ## Health ### Health Check @@ -818,25 +876,23 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age { "session_id": "sess_789", "character_id": "char_456", + "character_name": "Thorin", "turn_number": 5, "status": "active", "created_at": "2025-11-16T10:00:00Z", "last_activity": "2025-11-16T10:25:00Z", + "in_combat": false, "game_state": { "current_location": "crossville_village", - "location_type": "town" + "location_type": "town", + "in_combat": false, + "combat_round": null } } ] } ``` -**Error Responses:** -- `401` - Not authenticated -- `500` - Internal server error - ---- - ### Create Session | | | @@ -872,11 +928,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `400` - Validation error (missing character_id) -- `404` - Character not found -- `409` - Session limit exceeded (tier-based limit) - **Session Limits by Tier:** | Tier | Max Active Sessions | |------|---------------------| @@ -885,23 +936,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age | PREMIUM | 3 | | ELITE | 5 | -**Error Response (409 Conflict - Session Limit Exceeded):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 409, - "timestamp": "2025-11-16T10:30:00Z", - "result": null, - "error": { - "code": "SESSION_LIMIT_EXCEEDED", - "message": "Maximum active sessions reached for free tier (1/1). Please delete an existing session to start a new one." - } -} -``` - ---- - ### Get Session State | | | @@ -922,10 +956,12 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "character_id": "char_456", "turn_number": 5, "status": "active", + "in_combat": false, "game_state": { "current_location": "The Rusty Anchor", "location_type": "tavern", - "active_quests": ["quest_goblin_cave"] + "active_quests": ["quest_goblin_cave"], + "in_combat": false }, "available_actions": [ { @@ -945,11 +981,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `404` - Session not found or not owned by user - ---- - ### Take Action | | | @@ -985,31 +1016,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age - Only button actions with predefined prompts are supported - Poll `/api/v1/jobs//status` to check processing status - Rate limits apply based on subscription tier -- Available actions depend on user tier and current location - -**Error Responses:** -- `400` - Validation error (invalid action_type, missing prompt_id) -- `403` - Action not available for tier/location -- `404` - Session not found -- `429` - Rate limit exceeded - -**Rate Limit Error Response (429):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 429, - "timestamp": "2025-11-16T10:30:00Z", - "result": null, - "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Daily turn limit reached (20 turns). Resets at 00:00 UTC", - "details": {} - } -} -``` - ---- ### Get Job Status @@ -1050,26 +1056,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Response (200 OK - Failed):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 200, - "timestamp": "2025-11-16T10:30:10Z", - "result": { - "job_id": "ai_TaskType.NARRATIVE_abc123", - "status": "failed", - "error": "AI generation failed" - } -} -``` - -**Error Responses:** -- `404` - Job not found - ---- - ### Get Conversation History | | | @@ -1097,12 +1083,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "action": "I explore the tavern", "dm_response": "You enter a smoky tavern filled with weary travelers...", "timestamp": "2025-11-16T10:30:00Z" - }, - { - "turn": 2, - "action": "Ask locals for information", - "dm_response": "A grizzled dwarf at the bar tells you about goblin raids...", - "timestamp": "2025-11-16T10:32:00Z" } ], "pagination": { @@ -1114,11 +1094,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `404` - Session not found - ---- - ### Delete Session | | | @@ -1127,12 +1102,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age | **Description** | Permanently delete a session and all associated chat messages | | **Auth Required** | Yes | -**Behavior:** -- Permanently removes the session from the database (hard delete) -- Also deletes all chat messages associated with this session -- Frees up the session slot for the user's tier limit -- Cannot be undone - **Response (200 OK):** ```json { @@ -1147,38 +1116,38 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `401` - Not authenticated -- `404` - Session not found or not owned by user -- `500` - Internal server error - ---- - -### Export Session +### Get Usage Information | | | |---|---| -| **Endpoint** | `GET /api/v1/sessions//export` | -| **Description** | Export session log as Markdown | +| **Endpoint** | `GET /api/v1/usage` | +| **Description** | Get user's daily usage information (turn limits) | | **Auth Required** | Yes | -**Response:** -```markdown -# Session Log: sess_789 -**Date:** 2025-11-14 -**Character:** Aragorn the Brave - -## Turn 1 -**Action:** I explore the tavern -**DM:** You enter a smoky tavern... +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "user_id": "user_123", + "user_tier": "free", + "current_usage": 15, + "daily_limit": 50, + "remaining": 35, + "reset_time": "2025-11-27T00:00:00+00:00", + "is_limited": false, + "is_unlimited": false + } +} ``` --- ## Travel -The Travel API enables location-based world exploration. Locations are defined in YAML files and players can travel to any unlocked (discovered) location. - ### Get Available Destinations | | | @@ -1206,24 +1175,12 @@ The Travel API enables location-based world exploration. Locations are defined i "location_type": "tavern", "region_id": "crossville", "description": "A cozy tavern where travelers share tales..." - }, - { - "location_id": "crossville_forest", - "name": "Whispering Woods", - "location_type": "wilderness", - "region_id": "crossville", - "description": "A dense forest on the outskirts of town..." } ] } } ``` -**Error Responses:** -- `400` - Missing session_id parameter -- `404` - Session or character not found -- `500` - Internal server error - ### Travel to Location | | | @@ -1279,12 +1236,6 @@ The Travel API enables location-based world exploration. Locations are defined i } ``` -**Error Responses:** -- `400` - Location not discovered -- `403` - Location not discovered -- `404` - Session or location not found -- `500` - Internal server error - ### Get Location Details | | | @@ -1301,42 +1252,18 @@ The Travel API enables location-based world exploration. Locations are defined i "status": 200, "timestamp": "2025-11-25T10:30:00Z", "result": { - "location": { - "location_id": "crossville_village", - "name": "Crossville Village", - "location_type": "town", - "region_id": "crossville", - "description": "A modest farming village built around a central square...", - "lore": "Founded two centuries ago by settlers from the eastern kingdoms...", - "ambient_description": "The village square bustles with activity...", - "available_quests": ["quest_mayors_request"], - "npc_ids": ["npc_mayor_aldric", "npc_blacksmith_hilda"], - "discoverable_locations": ["crossville_tavern", "crossville_forest"], - "is_starting_location": true, - "tags": ["town", "social", "merchant", "safe"] - }, - "npcs_present": [ - { - "npc_id": "npc_mayor_aldric", - "name": "Mayor Aldric", - "role": "village mayor", - "appearance": "A portly man in fine robes" - } - ] + "location": {...}, + "npcs_present": [...] } } ``` -**Error Responses:** -- `404` - Location not found -- `500` - Internal server error - ### Get Current Location | | | |---|---| | **Endpoint** | `GET /api/v1/travel/current` | -| **Description** | Get current location details with NPCs present | +| **Description** | Get details about the current location in a session | | **Auth Required** | Yes | **Query Parameters:** @@ -1350,24 +1277,8 @@ The Travel API enables location-based world exploration. Locations are defined i "status": 200, "timestamp": "2025-11-25T10:30:00Z", "result": { - "location": { - "location_id": "crossville_village", - "name": "Crossville Village", - "location_type": "town", - "description": "A modest farming village..." - }, - "npcs_present": [ - { - "npc_id": "npc_mayor_aldric", - "name": "Mayor Aldric", - "role": "village mayor" - }, - { - "npc_id": "npc_blacksmith_hilda", - "name": "Hilda Ironforge", - "role": "blacksmith" - } - ] + "location": {...}, + "npcs_present": [...] } } ``` @@ -1376,8 +1287,6 @@ The Travel API enables location-based world exploration. Locations are defined i ## NPCs -The NPC API enables interaction with persistent NPCs. NPCs have personalities, knowledge, and relationships that affect dialogue generation. - ### Get NPC Details | | | @@ -1449,11 +1358,6 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k } ``` -**Parameters:** -- `session_id` (required): Active game session ID -- `topic` (optional): Conversation topic for initial greeting (default: "greeting") -- `player_response` (optional): Player's custom message to the NPC. If provided, overrides `topic`. Enables bidirectional conversation. - **Response (202 Accepted):** ```json { @@ -1488,31 +1392,12 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k { "player_line": "Hello there!", "npc_response": "*nods gruffly* \"Welcome to the Rusty Anchor.\"" - }, - { - "player_line": "What's the news around town?", - "npc_response": "*leans in* \"Strange folk been coming through lately...\"" } ] } } ``` -**Conversation History:** -- Previous dialogue exchanges are automatically stored per character-NPC pair -- Up to 10 exchanges are kept per NPC (oldest are pruned) -- The AI receives the last 3 exchanges as context for continuity -- The job result includes prior `conversation_history` for UI display - -**Bidirectional Dialogue:** -- If `player_response` is provided in the request, it overrides `topic` and enables full bidirectional conversation -- The player's response is stored in the conversation history -- The NPC's reply takes into account the full conversation context - -**Error Responses:** -- `400` - NPC not at current location -- `404` - NPC or session not found - ### Get NPCs at Location | | | @@ -1538,14 +1423,6 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k "appearance": "Stout dwarf with a braided grey beard", "tags": ["merchant", "quest_giver"], "image_url": "/static/images/npcs/crossville/grom_ironbeard.png" - }, - { - "npc_id": "npc_mira_swiftfoot", - "name": "Mira Swiftfoot", - "role": "traveling rogue", - "appearance": "Lithe half-elf with sharp eyes", - "tags": ["information", "secret_keeper"], - "image_url": "/static/images/npcs/crossville/mira_swiftfoot.png" } ] } @@ -1618,7 +1495,7 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k ## Chat / Conversation History -The Chat API provides access to complete player-NPC conversation history. All dialogue exchanges are stored in the `chat_messages` collection for unlimited history, with the most recent 3 messages cached in character documents for quick AI context. +The Chat API provides access to complete player-NPC conversation history. All dialogue exchanges are stored in the `chat_messages` collection for unlimited history. ### Get All Conversations Summary @@ -1643,13 +1520,6 @@ The Chat API provides access to complete player-NPC conversation history. All di "last_message_timestamp": "2025-11-25T14:30:00Z", "message_count": 15, "recent_preview": "Aye, the rats in the cellar have been causing trouble..." - }, - { - "npc_id": "npc_mira_swiftfoot", - "npc_name": "Mira Swiftfoot", - "last_message_timestamp": "2025-11-25T12:15:00Z", - "message_count": 8, - "recent_preview": "*leans in and whispers* I've heard rumors about the mayor..." } ] } @@ -1692,19 +1562,6 @@ The Chat API provides access to complete player-NPC conversation history. All di "session_id": "sess_789", "metadata": {}, "is_deleted": false - }, - { - "message_id": "msg_abc122", - "character_id": "char_123", - "npc_id": "npc_grom_ironbeard", - "player_message": "Hello there!", - "npc_response": "*nods gruffly* Welcome to the Rusty Anchor.", - "timestamp": "2025-11-25T14:25:00Z", - "context": "dialogue", - "location_id": "crossville_tavern", - "session_id": "sess_789", - "metadata": {}, - "is_deleted": false } ], "pagination": { @@ -1716,14 +1573,6 @@ The Chat API provides access to complete player-NPC conversation history. All di } ``` -**Message Context Types:** -- `dialogue` - General conversation -- `quest_offered` - Quest offering dialogue -- `quest_completed` - Quest completion dialogue -- `shop` - Merchant transaction -- `location_revealed` - New location discovered -- `lore` - Lore/backstory reveals - ### Search Messages | | | @@ -1736,16 +1585,11 @@ The Chat API provides access to complete player-NPC conversation history. All di - `q` (required): Search text to find in player_message and npc_response - `npc_id` (optional): Filter by specific NPC - `context` (optional): Filter by message context type -- `date_from` (optional): Start date in ISO format (e.g., 2025-11-25T00:00:00Z) +- `date_from` (optional): Start date in ISO format - `date_to` (optional): End date in ISO format - `limit` (optional): Maximum messages to return (default: 50, max: 100) - `offset` (optional): Number of messages to skip (default: 0) -**Example Request:** -``` -GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&context=quest_offered -``` - **Response (200 OK):** ```json { @@ -1762,23 +1606,7 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "date_to": null }, "total_results": 2, - "messages": [ - { - "message_id": "msg_abc125", - "character_id": "char_123", - "npc_id": "npc_grom_ironbeard", - "player_message": "Do you have any work for me?", - "npc_response": "*sighs heavily* Aye, there's rats in me cellar. Big ones.", - "timestamp": "2025-11-25T13:00:00Z", - "context": "quest_offered", - "location_id": "crossville_tavern", - "session_id": "sess_789", - "metadata": { - "quest_id": "quest_cellar_rats" - }, - "is_deleted": false - } - ], + "messages": [...], "pagination": { "limit": 50, "offset": 0, @@ -1788,7 +1616,7 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c } ``` -### Delete Message (Soft Delete) +### Delete Message | | | |---|---| @@ -1810,152 +1638,325 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c } ``` -**Notes:** -- Messages are soft deleted (is_deleted=true), not removed from database -- Deleted messages are filtered from all queries -- Only the character owner can delete their own messages - -**Error Responses:** -- `403` - User does not own the character -- `404` - Message not found - --- ## Combat -### Attack +### Start Combat | | | |---|---| -| **Endpoint** | `POST /api/v1/combat//attack` | -| **Description** | Execute physical attack | +| **Endpoint** | `POST /api/v1/combat/start` | +| **Description** | Start a new combat encounter | | **Auth Required** | Yes | **Request Body:** ```json { - "attacker_id": "char123", - "target_id": "enemy1", - "weapon_id": "sword1" + "session_id": "sess_123", + "enemy_ids": ["goblin", "goblin", "goblin_shaman"] } ``` -**Response:** +**Response (200 OK):** ```json { + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", "result": { - "damage": 15, - "critical": true, - "narrative": "Aragorn's blade strikes true...", - "target_hp": 25 - } -} -``` - -### Cast Spell - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/combat//cast` | -| **Description** | Cast spell or ability | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "caster_id": "char123", - "spell_id": "fireball", - "target_id": "enemy1" -} -``` - -**Response:** -```json -{ - "result": { - "damage": 30, - "mana_cost": 15, - "narrative": "Flames engulf the target...", - "effects_applied": ["burning"] - } -} -``` - -### Use Item - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/combat//item` | -| **Description** | Use item from inventory | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "character_id": "char123", - "item_id": "health_potion", - "target_id": "char123" -} -``` - -**Response:** -```json -{ - "result": { - "healing": 50, - "narrative": "You drink the potion and feel refreshed", - "current_hp": 100 - } -} -``` - -### Defend - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/combat//defend` | -| **Description** | Take defensive stance | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "character_id": "char123" -} -``` - -**Response:** -```json -{ - "result": { - "defense_bonus": 10, - "duration": 1, - "narrative": "You brace for impact" - } -} -``` - -### Get Combat Status - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/combat//status` | -| **Description** | Get current combat state | -| **Auth Required** | Yes | - -**Response:** -```json -{ - "result": { - "combatants": [], - "turn_order": [], - "current_turn": 0, + "encounter_id": "enc_abc123", + "combatants": [ + { + "combatant_id": "char_456", + "name": "Thorin", + "is_player": true, + "current_hp": 100, + "max_hp": 100, + "current_mp": 50, + "max_mp": 50, + "initiative": 15, + "abilities": ["basic_attack", "shield_bash"] + } + ], + "turn_order": ["char_456", "goblin_0", "goblin_shaman_0", "goblin_1"], + "current_turn": "char_456", "round_number": 1, "status": "active" } } ``` +### Get Combat State + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat//state` | +| **Description** | Get current combat state for a session | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "in_combat": true, + "encounter": { + "encounter_id": "enc_abc123", + "combatants": [...], + "turn_order": [...], + "current_turn": "char_456", + "round_number": 2, + "status": "active", + "combat_log": [ + "Thorin attacks Goblin for 15 damage!", + "Goblin attacks Thorin for 5 damage!" + ] + } + } +} +``` + +### Execute Combat Action + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//action` | +| **Description** | Execute a combat action for a combatant | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "combatant_id": "char_456", + "action_type": "attack", + "target_ids": ["goblin_0"], + "ability_id": "shield_bash" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "Shield Bash hits Goblin for 18 damage and stuns!", + "damage_results": [ + { + "target_id": "goblin_0", + "damage": 18, + "is_critical": false + } + ], + "effects_applied": [ + { + "target_id": "goblin_0", + "effect": "stunned", + "duration": 1 + } + ], + "combat_ended": false, + "combat_status": null, + "next_combatant_id": "goblin_1" + } +} +``` + +### Execute Enemy Turn + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//enemy-turn` | +| **Description** | Execute the current enemy's turn using AI logic | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "Goblin attacks Thorin for 8 damage!", + "damage_results": [...], + "effects_applied": [], + "combat_ended": false, + "combat_status": null, + "next_combatant_id": "char_456" + } +} +``` + +### Attempt Flee + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//flee` | +| **Description** | Attempt to flee from combat | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "combatant_id": "char_456" +} +``` + +**Response (200 OK - Success):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "Successfully fled from combat!", + "combat_ended": true, + "combat_status": "fled" + } +} +``` + +### End Combat + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//end` | +| **Description** | Force end the current combat (debug/admin endpoint) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "outcome": "victory" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "outcome": "victory", + "rewards": { + "experience": 100, + "gold": 50, + "items": [...], + "level_ups": [] + } + } +} +``` + +### List Enemies + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat/enemies` | +| **Description** | List all available enemy templates | +| **Auth Required** | No | + +**Query Parameters:** +- `difficulty` (optional): Filter by difficulty (easy, medium, hard, boss) +- `tag` (optional): Filter by tag (undead, beast, humanoid, etc.) + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "enemies": [ + { + "enemy_id": "goblin", + "name": "Goblin Scout", + "description": "A small, cunning creature...", + "difficulty": "easy", + "tags": ["humanoid", "goblinoid"], + "experience_reward": 15, + "gold_reward_range": [5, 15] + } + ] + } +} +``` + +### Get Enemy Details + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat/enemies/` | +| **Description** | Get detailed information about a specific enemy template | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "enemy_id": "goblin", + "name": "Goblin Scout", + "description": "A small, cunning creature with sharp claws", + "base_stats": { + "strength": 8, + "dexterity": 14, + "constitution": 10 + }, + "abilities": ["quick_strike", "dodge"], + "loot_table": [...], + "difficulty": "easy", + "experience_reward": 15, + "gold_reward_min": 5, + "gold_reward_max": 15 + } +} +``` + +### Debug: Reset HP/MP + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//debug/reset-hp-mp` | +| **Description** | Reset player combatant's HP and MP to full (debug endpoint) | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "HP and MP reset to full", + "current_hp": 100, + "max_hp": 100, + "current_mp": 50, + "max_mp": 50 + } +} +``` + --- ## Game Mechanics @@ -2053,12 +2054,6 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "name": "Ancient Coin", "description": "A weathered coin from ages past", "value": 25 - }, - { - "template_key": "healing_herb", - "name": "Healing Herb", - "description": "A medicinal plant bundle", - "value": 10 } ], "gold_found": 15 @@ -2066,94 +2061,11 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c } ``` -**Response (200 OK - Check Failed):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 200, - "timestamp": "2025-11-23T10:30:00Z", - "result": { - "check_result": { - "roll": 7, - "modifier": 1, - "total": 8, - "dc": 15, - "success": false, - "margin": -7, - "skill_type": "stealth" - }, - "context": { - "skill_used": "stealth", - "stat_used": "dexterity", - "situation": "Sneaking past guards" - } - } -} -``` - -**Error Response (400 Bad Request - Invalid skill):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-23T10:30:00Z", - "result": null, - "error": { - "code": "INVALID_INPUT", - "message": "Invalid skill type", - "details": { - "field": "skill", - "issue": "Must be one of: perception, insight, survival, medicine, stealth, acrobatics, sleight_of_hand, lockpicking, persuasion, deception, intimidation, performance, athletics, arcana, history, investigation, nature, religion, endurance" - } - } -} -``` - -**Error Response (404 Not Found - Character not found):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 404, - "timestamp": "2025-11-23T10:30:00Z", - "result": null, - "error": { - "code": "NOT_FOUND", - "message": "Character not found: char_999" - } -} -``` - -**Error Response (403 Forbidden - Not character owner):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 403, - "timestamp": "2025-11-23T10:30:00Z", - "result": null, - "error": { - "code": "FORBIDDEN", - "message": "You don't have permission to access this character" - } -} -``` - **Notes:** - `check_type` must be "search" or "skill" - For skill checks, `skill` is required -- For search checks, `location_type` is optional (defaults to "default") - `dc` or `difficulty` must be provided (dc takes precedence) - Valid difficulty values: trivial (5), easy (10), medium (15), hard (20), very_hard (25), nearly_impossible (30) -- `bonus` is optional (defaults to 0) -- `context` is optional and merged with the response for AI narration -- Roll uses d20 + stat modifier + optional bonus -- Margin is calculated as (total - dc) -- Items found depend on location type and success margin - ---- ### List Available Skills @@ -2172,94 +2084,16 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "timestamp": "2025-11-23T10:30:00Z", "result": { "skills": [ - { - "name": "perception", - "stat": "wisdom" - }, - { - "name": "insight", - "stat": "wisdom" - }, - { - "name": "survival", - "stat": "wisdom" - }, - { - "name": "medicine", - "stat": "wisdom" - }, - { - "name": "stealth", - "stat": "dexterity" - }, - { - "name": "acrobatics", - "stat": "dexterity" - }, - { - "name": "sleight_of_hand", - "stat": "dexterity" - }, - { - "name": "lockpicking", - "stat": "dexterity" - }, - { - "name": "persuasion", - "stat": "charisma" - }, - { - "name": "deception", - "stat": "charisma" - }, - { - "name": "intimidation", - "stat": "charisma" - }, - { - "name": "performance", - "stat": "charisma" - }, - { - "name": "athletics", - "stat": "strength" - }, - { - "name": "arcana", - "stat": "intelligence" - }, - { - "name": "history", - "stat": "intelligence" - }, - { - "name": "investigation", - "stat": "intelligence" - }, - { - "name": "nature", - "stat": "intelligence" - }, - { - "name": "religion", - "stat": "intelligence" - }, - { - "name": "endurance", - "stat": "constitution" - } + {"name": "perception", "stat": "wisdom"}, + {"name": "insight", "stat": "wisdom"}, + {"name": "stealth", "stat": "dexterity"}, + {"name": "persuasion", "stat": "charisma"}, + {"name": "athletics", "stat": "strength"} ] } } ``` -**Notes:** -- No authentication required -- Skills are grouped by their associated stat -- Use the skill names in the `skill` parameter of the `/check` endpoint - ---- - ### List Difficulty Levels | | | @@ -2277,322 +2111,17 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "timestamp": "2025-11-23T10:30:00Z", "result": { "difficulties": [ - { - "name": "trivial", - "dc": 5 - }, - { - "name": "easy", - "dc": 10 - }, - { - "name": "medium", - "dc": 15 - }, - { - "name": "hard", - "dc": 20 - }, - { - "name": "very_hard", - "dc": 25 - }, - { - "name": "nearly_impossible", - "dc": 30 - } + {"name": "trivial", "dc": 5}, + {"name": "easy", "dc": 10}, + {"name": "medium", "dc": 15}, + {"name": "hard", "dc": 20}, + {"name": "very_hard", "dc": 25}, + {"name": "nearly_impossible", "dc": 30} ] } } ``` -**Notes:** -- No authentication required -- Use difficulty names in the `difficulty` parameter of the `/check` endpoint instead of providing raw DC values -- DC values range from 5 (trivial) to 30 (nearly impossible) - ---- - -## Marketplace - -### Browse Listings - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace` | -| **Description** | Browse marketplace listings | -| **Auth Required** | Yes (Premium+ only) | - -**Query Parameters:** -- `type` - "auction" or "fixed_price" -- `category` - "weapon", "armor", "consumable" -- `min_price` - Minimum price -- `max_price` - Maximum price -- `sort` - "price_asc", "price_desc", "ending_soon" -- `page` - Page number -- `limit` - Items per page - -**Response:** -```json -{ - "result": { - "listings": [ - { - "listing_id": "list123", - "item": {}, - "listing_type": "auction", - "current_bid": 500, - "buyout_price": 1000, - "auction_end": "2025-11-15T12:00:00Z" - } - ], - "total": 50, - "page": 1, - "pages": 5 - } -} -``` - -### Get Listing - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace/` | -| **Description** | Get listing details | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "listing_id": "list123", - "seller_name": "Aragorn", - "item": {}, - "listing_type": "auction", - "current_bid": 500, - "bid_count": 5, - "bids": [], - "auction_end": "2025-11-15T12:00:00Z" - } -} -``` - -### Create Listing - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/marketplace/list` | -| **Description** | Create new marketplace listing | -| **Auth Required** | Yes (Premium+ only) | - -**Request Body (Auction):** -```json -{ - "character_id": "char123", - "item_id": "sword1", - "listing_type": "auction", - "starting_bid": 100, - "buyout_price": 1000, - "duration_hours": 48 -} -``` - -**Request Body (Fixed Price):** -```json -{ - "character_id": "char123", - "item_id": "sword1", - "listing_type": "fixed_price", - "price": 500 -} -``` - -**Response:** -```json -{ - "result": { - "listing_id": "list123", - "message": "Listing created successfully" - } -} -``` - -### Place Bid - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/marketplace//bid` | -| **Description** | Place bid on auction | -| **Auth Required** | Yes (Premium+ only) | - -**Request Body:** -```json -{ - "character_id": "char123", - "amount": 600 -} -``` - -**Response:** -```json -{ - "result": { - "current_bid": 600, - "is_winning": true, - "message": "Bid placed successfully" - } -} -``` - -### Buyout - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/marketplace//buyout` | -| **Description** | Instant purchase at buyout price | -| **Auth Required** | Yes (Premium+ only) | - -**Request Body:** -```json -{ - "character_id": "char123" -} -``` - -**Response:** -```json -{ - "result": { - "transaction_id": "trans123", - "price": 1000, - "item": {}, - "message": "Purchase successful" - } -} -``` - -### Cancel Listing - -| | | -|---|---| -| **Endpoint** | `DELETE /api/v1/marketplace/` | -| **Description** | Cancel listing (owner only) | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "message": "Listing cancelled, item returned" - } -} -``` - -### My Listings - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace/my-listings` | -| **Description** | Get current user's active listings | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "listings": [], - "total": 5 - } -} -``` - -### My Bids - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace/my-bids` | -| **Description** | Get current user's active bids | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "bids": [ - { - "listing_id": "list123", - "item": {}, - "your_bid": 500, - "current_bid": 600, - "is_winning": false, - "auction_end": "2025-11-15T12:00:00Z" - } - ] - } -} -``` - ---- - -## Shop - -### Browse Shop - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/shop/items` | -| **Description** | Browse NPC shop inventory | -| **Auth Required** | Yes | - -**Query Parameters:** -- `category` - "consumable", "weapon", "armor" - -**Response:** -```json -{ - "result": { - "items": [ - { - "item_id": "health_potion", - "name": "Health Potion", - "price": 50, - "stock": -1, - "description": "Restores 50 HP" - } - ] - } -} -``` - -### Purchase from Shop - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/shop/purchase` | -| **Description** | Buy item from NPC shop | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "character_id": "char123", - "item_id": "health_potion", - "quantity": 5 -} -``` - -**Response:** -```json -{ - "result": { - "transaction_id": "trans123", - "total_cost": 250, - "items_purchased": 5, - "remaining_gold": 750 - } -} -``` - --- ## Error Responses @@ -2601,8 +2130,8 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c ```json { - "app": "AI Dungeon Master", - "version": "1.0.0", + "app": "Code of Conquest", + "version": "0.1.0", "status": 400, "timestamp": "2025-11-14T12:00:00Z", "result": null, @@ -2630,12 +2159,18 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c | `SESSION_LIMIT_EXCEEDED` | 409 | User has reached session limit for their tier | | `CHARACTER_NOT_FOUND` | 404 | Character does not exist or not accessible | | `SKILL_UNLOCK_ERROR` | 400 | Skill unlock failed (prerequisites, points, or tier) | -| `INSUFFICIENT_FUNDS` | 400 | Not enough gold | +| `INSUFFICIENT_GOLD` | 400 | Not enough gold | +| `INSUFFICIENT_RESOURCES` | 400 | Not enough MP/items for action | | `INVALID_ACTION` | 400 | Action not allowed | | `SESSION_FULL` | 400 | Session at max capacity | | `NOT_YOUR_TURN` | 400 | Not active player's turn | | `AI_LIMIT_EXCEEDED` | 429 | Daily AI call limit reached | | `PREMIUM_REQUIRED` | 403 | Feature requires premium subscription | +| `ALREADY_IN_COMBAT` | 400 | Session is already in combat | +| `NOT_IN_COMBAT` | 404 | Session is not in combat | +| `INVENTORY_FULL` | 400 | Inventory is full | +| `CANNOT_EQUIP` | 400 | Item cannot be equipped | +| `CANNOT_USE_ITEM` | 400 | Item cannot be used | --- @@ -2676,24 +2211,3 @@ Endpoints that return lists support pagination: } } ``` - ---- - -## Realtime Events (WebSocket) - -**Subscribe to session updates:** - -```javascript -client.subscribe( - 'databases.main.collections.game_sessions.documents.{sessionId}', - callback -); -``` - -**Event Types:** -- Session state change -- Turn change -- Combat update -- Chat message -- Player joined/left -- Marketplace bid notification diff --git a/api/docs/DATA_MODELS.md b/api/docs/DATA_MODELS.md index 8f72486..2b824ed 100644 --- a/api/docs/DATA_MODELS.md +++ b/api/docs/DATA_MODELS.md @@ -50,6 +50,7 @@ All enum types are defined in `/app/models/enums.py` for type safety throughout | `INTELLIGENCE` | Magical power | | `WISDOM` | Perception and insight | | `CHARISMA` | Social influence | +| `LUCK` | Fortune and fate (affects crits, loot, random outcomes) | ### AbilityType @@ -597,14 +598,15 @@ success = service.soft_delete_message( ### Stats -| Field | Type | Description | -|-------|------|-------------| -| `strength` | int | Physical power | -| `dexterity` | int | Agility and precision | -| `constitution` | int | Endurance and health | -| `intelligence` | int | Magical power | -| `wisdom` | int | Perception and insight | -| `charisma` | int | Social influence | +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `strength` | int | 10 | Physical power | +| `dexterity` | int | 10 | Agility and precision | +| `constitution` | int | 10 | Endurance and health | +| `intelligence` | int | 10 | Magical power | +| `wisdom` | int | 10 | Perception and insight | +| `charisma` | int | 10 | Social influence | +| `luck` | int | 8 | Fortune and fate (affects crits, loot, random outcomes) | **Derived Properties (Computed):** - `hit_points` = 10 + (constitution × 2) @@ -614,6 +616,8 @@ success = service.soft_delete_message( **Note:** Defense and resistance are computed properties, not stored values. They are calculated on-the-fly from constitution and wisdom. +**Luck Stat:** The luck stat has a lower default (8) compared to other stats (10). Each class has a specific luck value ranging from 7 (Necromancer) to 12 (Assassin). Luck will influence critical hit chance, hit/miss calculations, base damage variance, NPC interactions, loot generation, and spell power in future implementations. + ### SkillNode | Field | Type | Description | @@ -662,16 +666,26 @@ success = service.soft_delete_message( ### Initial 8 Player Classes -| Class | Theme | Skill Tree 1 | Skill Tree 2 | -|-------|-------|--------------|--------------| -| **Vanguard** | Tank/melee | Defensive (shields, armor, taunts) | Offensive (weapon mastery, heavy strikes) | -| **Assassin** | Stealth/critical | Assassination (critical hits, poisons) | Shadow (stealth, evasion) | -| **Arcanist** | Elemental spells | Pyromancy (fire spells, DoT) | Cryomancy (ice spells, crowd control) | -| **Luminary** | Healing/support | Holy (healing, buffs) | Divine Wrath (smite, undead damage) | -| **Wildstrider** | Ranged/nature | Marksmanship (bow skills, critical shots) | Beast Master (pet companion, nature magic) | -| **Oathkeeper** | Hybrid tank/healer | Protection (defensive auras, healing) | Retribution (holy damage, smites) | -| **Necromancer** | Death magic/summon | Dark Arts (curses, life drain) | Summoning (undead minions) | -| **Lorekeeper** | Support/control | Performance (buffs, debuffs via music) | Trickery (illusions, charm) | +| Class | Theme | LUK | Skill Tree 1 | Skill Tree 2 | +|-------|-------|-----|--------------|--------------| +| **Vanguard** | Tank/melee | 8 | Defensive (shields, armor, taunts) | Offensive (weapon mastery, heavy strikes) | +| **Assassin** | Stealth/critical | 12 | Assassination (critical hits, poisons) | Shadow (stealth, evasion) | +| **Arcanist** | Elemental spells | 9 | Pyromancy (fire spells, DoT) | Cryomancy (ice spells, crowd control) | +| **Luminary** | Healing/support | 11 | Holy (healing, buffs) | Divine Wrath (smite, undead damage) | +| **Wildstrider** | Ranged/nature | 10 | Marksmanship (bow skills, critical shots) | Beast Master (pet companion, nature magic) | +| **Oathkeeper** | Hybrid tank/healer | 9 | Protection (defensive auras, healing) | Retribution (holy damage, smites) | +| **Necromancer** | Death magic/summon | 7 | Dark Arts (curses, life drain) | Summoning (undead minions) | +| **Lorekeeper** | Support/control | 10 | Performance (buffs, debuffs via music) | Trickery (illusions, charm) | + +**Class Luck Values:** +- **Assassin (12):** Highest luck - critical strike specialists benefit most from fortune +- **Luminary (11):** Divine favor grants above-average luck +- **Wildstrider (10):** Average luck - self-reliant nature +- **Lorekeeper (10):** Average luck - knowledge is their advantage +- **Arcanist (9):** Slight chaos magic influence +- **Oathkeeper (9):** Honorable path grants modest fortune +- **Vanguard (8):** Relies on strength and skill, not luck +- **Necromancer (7):** Lowest luck - dark arts exact a toll **Extensibility:** Class system designed to easily add more classes in future updates. @@ -694,6 +708,149 @@ success = service.soft_delete_message( - **Consumable:** One-time use (potions, scrolls) - **Quest Item:** Story-related, non-tradeable +--- + +## Procedural Item Generation (Affix System) + +The game uses a Diablo-style procedural item generation system where weapons and armor +are created by combining base templates with random affixes. + +### Core Models + +#### Affix + +Represents a prefix or suffix that modifies an item's stats and name. + +| Field | Type | Description | +|-------|------|-------------| +| `affix_id` | str | Unique identifier | +| `name` | str | Display name ("Flaming", "of Strength") | +| `affix_type` | AffixType | PREFIX or SUFFIX | +| `tier` | AffixTier | MINOR, MAJOR, or LEGENDARY | +| `description` | str | Affix description | +| `stat_bonuses` | Dict[str, int] | Stat modifications | +| `damage_bonus` | int | Flat damage increase | +| `defense_bonus` | int | Flat defense increase | +| `resistance_bonus` | int | Flat resistance increase | +| `damage_type` | DamageType | For elemental affixes | +| `elemental_ratio` | float | Portion of damage converted to element | +| `crit_chance_bonus` | float | Critical hit chance modifier | +| `crit_multiplier_bonus` | float | Critical damage modifier | +| `allowed_item_types` | List[str] | Item types this affix can apply to | +| `required_rarity` | str | Minimum rarity required (for legendary affixes) | + +**Methods:** +- `applies_elemental_damage() -> bool` - Check if affix adds elemental damage +- `is_legendary_only() -> bool` - Check if requires legendary rarity +- `can_apply_to(item_type, rarity) -> bool` - Check if affix can be applied + +#### BaseItemTemplate + +Foundation template for procedural item generation. + +| Field | Type | Description | +|-------|------|-------------| +| `template_id` | str | Unique identifier | +| `name` | str | Base item name ("Dagger") | +| `item_type` | str | "weapon" or "armor" | +| `description` | str | Template description | +| `base_damage` | int | Starting damage value | +| `base_defense` | int | Starting defense value | +| `base_resistance` | int | Starting resistance value | +| `base_value` | int | Base gold value | +| `damage_type` | str | Physical, fire, etc. | +| `crit_chance` | float | Base critical chance | +| `crit_multiplier` | float | Base critical multiplier | +| `required_level` | int | Minimum level to use | +| `min_rarity` | str | Minimum rarity this generates as | +| `drop_weight` | int | Relative drop probability | + +**Methods:** +- `can_generate_at_rarity(rarity) -> bool` - Check if template supports rarity +- `can_drop_for_level(level) -> bool` - Check level requirement + +### Item Model Updates for Generated Items + +The `Item` dataclass includes fields for tracking generated items: + +| Field | Type | Description | +|-------|------|-------------| +| `applied_affixes` | List[str] | IDs of affixes on this item | +| `base_template_id` | str | ID of base template used | +| `generated_name` | str | Full name with affixes (e.g., "Flaming Dagger of Strength") | +| `is_generated` | bool | True if procedurally generated | + +**Methods:** +- `get_display_name() -> str` - Returns generated_name if available, otherwise base name + +### Generation Enumerations + +#### ItemRarity + +Item quality tiers affecting affix count and value: + +| Value | Affix Count | Value Multiplier | +|-------|-------------|------------------| +| `COMMON` | 0 | 1.0× | +| `UNCOMMON` | 0 | 1.5× | +| `RARE` | 1 | 2.5× | +| `EPIC` | 2 | 5.0× | +| `LEGENDARY` | 3 | 10.0× | + +#### AffixType + +| Value | Description | +|-------|-------------| +| `PREFIX` | Appears before item name ("Flaming Dagger") | +| `SUFFIX` | Appears after item name ("Dagger of Strength") | + +#### AffixTier + +Affix power level, determines eligibility by item rarity: + +| Value | Description | Available For | +|-------|-------------|---------------| +| `MINOR` | Basic affixes | RARE+ | +| `MAJOR` | Stronger affixes | RARE+ (higher weight at EPIC+) | +| `LEGENDARY` | Most powerful affixes | LEGENDARY only | + +### Item Generation Service + +**Location:** `/app/services/item_generator.py` + +**Usage:** +```python +from app.services.item_generator import get_item_generator +from app.models.enums import ItemRarity + +generator = get_item_generator() + +# Generate specific item +item = generator.generate_item( + item_type="weapon", + rarity=ItemRarity.EPIC, + character_level=5 +) + +# Generate random loot drop with luck influence +item = generator.generate_loot_drop( + character_level=10, + luck_stat=12 +) +``` + +**Related Loaders:** +- `AffixLoader` (`/app/services/affix_loader.py`) - Loads affix definitions from YAML +- `BaseItemLoader` (`/app/services/base_item_loader.py`) - Loads base templates from YAML + +**Data Files:** +- `/app/data/affixes/prefixes.yaml` - Prefix definitions +- `/app/data/affixes/suffixes.yaml` - Suffix definitions +- `/app/data/base_items/weapons.yaml` - Weapon templates +- `/app/data/base_items/armor.yaml` - Armor templates + +--- + ### Ability Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses). diff --git a/api/docs/GAME_SYSTEMS.md b/api/docs/GAME_SYSTEMS.md index 4268f50..4296e26 100644 --- a/api/docs/GAME_SYSTEMS.md +++ b/api/docs/GAME_SYSTEMS.md @@ -402,6 +402,111 @@ effects_applied: --- +## Procedural Item Generation + +### Overview + +Weapons and armor are procedurally generated using a Diablo-style affix system. +Items are created by combining: +1. **Base Template** - Defines item type, base stats, level requirement +2. **Affixes** - Prefixes and suffixes that add stats and modify the name + +### Generation Process + +1. Select base template (filtered by level, rarity) +2. Determine affix count based on rarity (0-3) +3. Roll affix tier based on rarity weights +4. Select random affixes avoiding duplicates +5. Combine stats and generate name + +### Rarity System + +| Rarity | Affixes | Value Multiplier | Color | +|--------|---------|------------------|-------| +| COMMON | 0 | 1.0× | Gray | +| UNCOMMON | 0 | 1.5× | Green | +| RARE | 1 | 2.5× | Blue | +| EPIC | 2 | 5.0× | Purple | +| LEGENDARY | 3 | 10.0× | Orange | + +### Affix Distribution + +| Rarity | Affix Count | Distribution | +|--------|-------------|--------------| +| RARE | 1 | 50% prefix OR 50% suffix | +| EPIC | 2 | 1 prefix AND 1 suffix | +| LEGENDARY | 3 | Mix (2+1 or 1+2) | + +### Affix Tiers + +Higher rarity items have better chances at higher tier affixes: + +| Rarity | MINOR | MAJOR | LEGENDARY | +|--------|-------|-------|-----------| +| RARE | 80% | 20% | 0% | +| EPIC | 30% | 70% | 0% | +| LEGENDARY | 10% | 40% | 50% | + +### Name Generation Examples + +- **COMMON:** "Dagger" +- **RARE (prefix):** "Flaming Dagger" +- **RARE (suffix):** "Dagger of Strength" +- **EPIC:** "Flaming Dagger of Strength" +- **LEGENDARY:** "Blazing Glacial Dagger of the Titan" + +### Luck Influence + +Player's LUK stat affects rarity rolls for loot drops: + +**Base chances at LUK 8:** +- COMMON: 50% +- UNCOMMON: 30% +- RARE: 15% +- EPIC: 4% +- LEGENDARY: 1% + +**Luck Bonus:** +Each point of LUK above 8 adds +0.5% to higher rarity chances. + +**Examples:** +- LUK 8 (baseline): 1% legendary chance +- LUK 12: ~3% legendary chance +- LUK 16: ~5% legendary chance + +### Service Usage + +```python +from app.services.item_generator import get_item_generator +from app.models.enums import ItemRarity + +generator = get_item_generator() + +# Generate item of specific rarity +sword = generator.generate_item( + item_type="weapon", + rarity=ItemRarity.EPIC, + character_level=5 +) + +# Generate random loot with luck bonus +loot = generator.generate_loot_drop( + character_level=10, + luck_stat=15 +) +``` + +### Data Files + +| File | Description | +|------|-------------| +| `/app/data/base_items/weapons.yaml` | 13 weapon templates | +| `/app/data/base_items/armor.yaml` | 12 armor templates | +| `/app/data/affixes/prefixes.yaml` | 18 prefix affixes | +| `/app/data/affixes/suffixes.yaml` | 11 suffix affixes | + +--- + ## Quest System (Future) ### Quest Types diff --git a/api/scripts/migrate_combat_data.py b/api/scripts/migrate_combat_data.py new file mode 100644 index 0000000..e0055a5 --- /dev/null +++ b/api/scripts/migrate_combat_data.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Combat Data Migration Script. + +This script migrates existing inline combat encounter data from game_sessions +to the dedicated combat_encounters table. + +The migration is idempotent - it's safe to run multiple times. Sessions that +have already been migrated (have active_combat_encounter_id) are skipped. + +Usage: + python scripts/migrate_combat_data.py + +Note: + - Run this after deploying the new combat database schema + - The application handles automatic migration on-demand, so this is optional + - This script is useful for proactively migrating all data at once +""" + +import sys +import os +import json +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from dotenv import load_dotenv + +# Load environment variables before importing app modules +load_dotenv() + +from app.services.database_service import get_database_service +from app.services.combat_repository import get_combat_repository +from app.models.session import GameSession +from app.models.combat import CombatEncounter +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +def migrate_inline_combat_encounters() -> dict: + """ + Migrate all inline combat encounters to the dedicated table. + + Scans all game sessions for inline combat_encounter data and migrates + them to the combat_encounters table. Updates sessions to use the new + active_combat_encounter_id reference. + + Returns: + Dict with migration statistics: + - total_sessions: Number of sessions scanned + - migrated: Number of sessions with combat data migrated + - skipped: Number of sessions already migrated or without combat + - errors: Number of sessions that failed to migrate + """ + db = get_database_service() + repo = get_combat_repository() + + stats = { + 'total_sessions': 0, + 'migrated': 0, + 'skipped': 0, + 'errors': 0, + 'error_details': [] + } + + print("Scanning game_sessions for inline combat data...") + + # Query all sessions (paginated) + offset = 0 + limit = 100 + + while True: + try: + rows = db.list_rows( + table_id='game_sessions', + limit=limit, + offset=offset + ) + except Exception as e: + logger.error("Failed to query sessions", error=str(e)) + print(f"Error querying sessions: {e}") + break + + if not rows: + break + + for row in rows: + stats['total_sessions'] += 1 + session_id = row.id + + try: + # Parse session data + session_json = row.data.get('sessionData', '{}') + session_data = json.loads(session_json) + + # Check if already migrated (has reference, no inline data) + if (session_data.get('active_combat_encounter_id') and + not session_data.get('combat_encounter')): + stats['skipped'] += 1 + continue + + # Check if has inline combat data to migrate + combat_data = session_data.get('combat_encounter') + if not combat_data: + stats['skipped'] += 1 + continue + + # Parse combat encounter + encounter = CombatEncounter.from_dict(combat_data) + user_id = session_data.get('user_id', row.data.get('userId', '')) + + logger.info("Migrating inline combat encounter", + session_id=session_id, + encounter_id=encounter.encounter_id) + + # Check if encounter already exists in repository + existing = repo.get_encounter(encounter.encounter_id) + if existing: + # Already migrated, just update session reference + session_data['active_combat_encounter_id'] = encounter.encounter_id + session_data['combat_encounter'] = None + else: + # Save to repository + repo.create_encounter( + encounter=encounter, + session_id=session_id, + user_id=user_id + ) + session_data['active_combat_encounter_id'] = encounter.encounter_id + session_data['combat_encounter'] = None + + # Update session + db.update_row( + table_id='game_sessions', + row_id=session_id, + data={'sessionData': json.dumps(session_data)} + ) + + stats['migrated'] += 1 + print(f" Migrated: {session_id} -> {encounter.encounter_id}") + + except Exception as e: + stats['errors'] += 1 + error_msg = f"Session {session_id}: {str(e)}" + stats['error_details'].append(error_msg) + logger.error("Failed to migrate session", + session_id=session_id, + error=str(e)) + print(f" Error: {session_id} - {e}") + + offset += limit + + # Safety check to prevent infinite loop + if offset > 10000: + print("Warning: Stopped after 10000 sessions (safety limit)") + break + + return stats + + +def main(): + """Run the migration.""" + print("=" * 60) + print("Code of Conquest - Combat Data Migration") + print("=" * 60) + print() + + # Verify environment variables + required_vars = [ + 'APPWRITE_ENDPOINT', + 'APPWRITE_PROJECT_ID', + 'APPWRITE_API_KEY', + 'APPWRITE_DATABASE_ID' + ] + + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + print("ERROR: Missing required environment variables:") + for var in missing_vars: + print(f" - {var}") + print() + print("Please ensure your .env file is configured correctly.") + sys.exit(1) + + print("Environment configuration:") + print(f" Endpoint: {os.getenv('APPWRITE_ENDPOINT')}") + print(f" Project: {os.getenv('APPWRITE_PROJECT_ID')}") + print(f" Database: {os.getenv('APPWRITE_DATABASE_ID')}") + print() + + # Confirm before proceeding + print("This script will migrate inline combat data to the dedicated") + print("combat_encounters table. This operation is safe and idempotent.") + print() + response = input("Proceed with migration? (y/N): ").strip().lower() + if response != 'y': + print("Migration cancelled.") + sys.exit(0) + + print() + print("Starting migration...") + print() + + try: + stats = migrate_inline_combat_encounters() + + print() + print("=" * 60) + print("Migration Results") + print("=" * 60) + print() + print(f"Total sessions scanned: {stats['total_sessions']}") + print(f"Successfully migrated: {stats['migrated']}") + print(f"Skipped (no combat): {stats['skipped']}") + print(f"Errors: {stats['errors']}") + print() + + if stats['error_details']: + print("Error details:") + for error in stats['error_details'][:10]: # Show first 10 + print(f" - {error}") + if len(stats['error_details']) > 10: + print(f" ... and {len(stats['error_details']) - 10} more") + print() + + if stats['errors'] > 0: + print("Some sessions failed to migrate. Check logs for details.") + sys.exit(1) + else: + print("Migration completed successfully!") + + except Exception as e: + logger.error("Migration failed", error=str(e)) + print() + print(f"MIGRATION FAILED: {str(e)}") + print() + print("Check logs for details.") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/api/tests/test_character.py b/api/tests/test_character.py index 1664087..30971bc 100644 --- a/api/tests/test_character.py +++ b/api/tests/test_character.py @@ -452,3 +452,183 @@ def test_character_round_trip_serialization(basic_character): assert restored.unlocked_skills == basic_character.unlocked_skills assert "weapon" in restored.equipped assert restored.equipped["weapon"].item_id == "sword" + + +# ============================================================================= +# Equipment Combat Bonuses (Task 2.5) +# ============================================================================= + +def test_get_effective_stats_weapon_damage_bonus(basic_character): + """Test that weapon damage is added to effective stats damage_bonus.""" + # Create weapon with damage + weapon = Item( + item_id="iron_sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + description="A sturdy iron sword", + damage=15, # 15 damage + ) + + basic_character.equipped["weapon"] = weapon + effective = basic_character.get_effective_stats() + + # Base strength is 12, so base damage = int(12 * 0.75) = 9 + # Weapon damage = 15 + # Total damage property = 9 + 15 = 24 + assert effective.damage_bonus == 15 + assert effective.damage == 24 # int(12 * 0.75) + 15 + + +def test_get_effective_stats_armor_defense_bonus(basic_character): + """Test that armor defense is added to effective stats defense_bonus.""" + # Create armor with defense + armor = Item( + item_id="iron_chestplate", + name="Iron Chestplate", + item_type=ItemType.ARMOR, + description="A sturdy iron chestplate", + defense=10, + resistance=0, + ) + + basic_character.equipped["chest"] = armor + effective = basic_character.get_effective_stats() + + # Base constitution is 14, so base defense = 14 // 2 = 7 + # Armor defense = 10 + # Total defense property = 7 + 10 = 17 + assert effective.defense_bonus == 10 + assert effective.defense == 17 # (14 // 2) + 10 + + +def test_get_effective_stats_armor_resistance_bonus(basic_character): + """Test that armor resistance is added to effective stats resistance_bonus.""" + # Create armor with resistance + robe = Item( + item_id="magic_robe", + name="Magic Robe", + item_type=ItemType.ARMOR, + description="An enchanted robe", + defense=2, + resistance=8, + ) + + basic_character.equipped["chest"] = robe + effective = basic_character.get_effective_stats() + + # Base wisdom is 10, so base resistance = 10 // 2 = 5 + # Armor resistance = 8 + # Total resistance property = 5 + 8 = 13 + assert effective.resistance_bonus == 8 + assert effective.resistance == 13 # (10 // 2) + 8 + + +def test_get_effective_stats_multiple_armor_pieces(basic_character): + """Test that multiple armor pieces stack their bonuses.""" + # Create multiple armor pieces + helmet = Item( + item_id="iron_helmet", + name="Iron Helmet", + item_type=ItemType.ARMOR, + description="Protects your head", + defense=5, + resistance=2, + ) + + chestplate = Item( + item_id="iron_chestplate", + name="Iron Chestplate", + item_type=ItemType.ARMOR, + description="Protects your torso", + defense=10, + resistance=3, + ) + + boots = Item( + item_id="iron_boots", + name="Iron Boots", + item_type=ItemType.ARMOR, + description="Protects your feet", + defense=3, + resistance=1, + ) + + basic_character.equipped["helmet"] = helmet + basic_character.equipped["chest"] = chestplate + basic_character.equipped["boots"] = boots + + effective = basic_character.get_effective_stats() + + # Total defense bonus = 5 + 10 + 3 = 18 + # Total resistance bonus = 2 + 3 + 1 = 6 + assert effective.defense_bonus == 18 + assert effective.resistance_bonus == 6 + + # Base constitution is 14: base defense = 7 + # Base wisdom is 10: base resistance = 5 + assert effective.defense == 25 # 7 + 18 + assert effective.resistance == 11 # 5 + 6 + + +def test_get_effective_stats_weapon_and_armor_combined(basic_character): + """Test that weapon damage and armor defense/resistance work together.""" + # Create weapon + weapon = Item( + item_id="flaming_sword", + name="Flaming Sword", + item_type=ItemType.WEAPON, + description="A sword wreathed in flame", + damage=18, + stat_bonuses={"strength": 3}, # Also has stat bonus + ) + + # Create armor + armor = Item( + item_id="dragon_armor", + name="Dragon Armor", + item_type=ItemType.ARMOR, + description="Forged from dragon scales", + defense=15, + resistance=10, + stat_bonuses={"constitution": 2}, # Also has stat bonus + ) + + basic_character.equipped["weapon"] = weapon + basic_character.equipped["chest"] = armor + + effective = basic_character.get_effective_stats() + + # Weapon: damage=18, +3 STR + # Armor: defense=15, resistance=10, +2 CON + # Base STR=12 -> 12+3=15, damage = int(15 * 0.75) + 18 = 11 + 18 = 29 + assert effective.strength == 15 + assert effective.damage_bonus == 18 + assert effective.damage == 29 + + # Base CON=14 -> 14+2=16, defense = (16//2) + 15 = 8 + 15 = 23 + assert effective.constitution == 16 + assert effective.defense_bonus == 15 + assert effective.defense == 23 + + # Base WIS=10, resistance = (10//2) + 10 = 5 + 10 = 15 + assert effective.resistance_bonus == 10 + assert effective.resistance == 15 + + +def test_get_effective_stats_no_equipment_bonuses(basic_character): + """Test that bonus fields are zero when no equipment is equipped.""" + effective = basic_character.get_effective_stats() + + assert effective.damage_bonus == 0 + assert effective.defense_bonus == 0 + assert effective.resistance_bonus == 0 + + # Damage/defense/resistance should just be base stat derived values + # Base STR=12, damage = int(12 * 0.75) = 9 + assert effective.damage == 9 + + # Base CON=14, defense = 14 // 2 = 7 + assert effective.defense == 7 + + # Base WIS=10, resistance = 10 // 2 = 5 + assert effective.resistance == 5 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_loot_service.py b/api/tests/test_combat_loot_service.py new file mode 100644 index 0000000..c12308b --- /dev/null +++ b/api/tests/test_combat_loot_service.py @@ -0,0 +1,428 @@ +""" +Tests for CombatLootService. + +Tests the service that orchestrates loot generation from combat, +supporting both static and procedural loot drops. +""" + +import pytest +from unittest.mock import Mock, patch + +from app.services.combat_loot_service import ( + CombatLootService, + LootContext, + get_combat_loot_service, + DIFFICULTY_RARITY_BONUS, + LUCK_CONVERSION_FACTOR +) +from app.models.enemy import ( + EnemyTemplate, + EnemyDifficulty, + LootEntry, + LootType +) +from app.models.stats import Stats +from app.models.items import Item +from app.models.enums import ItemType, ItemRarity + + +class TestLootContext: + """Test LootContext dataclass.""" + + def test_default_values(self): + """Test default context values.""" + context = LootContext() + + assert context.party_average_level == 1 + assert context.enemy_difficulty == EnemyDifficulty.EASY + assert context.luck_stat == 8 + assert context.loot_bonus == 0.0 + + def test_custom_values(self): + """Test creating context with custom values.""" + context = LootContext( + party_average_level=10, + enemy_difficulty=EnemyDifficulty.HARD, + luck_stat=15, + loot_bonus=0.1 + ) + + assert context.party_average_level == 10 + assert context.enemy_difficulty == EnemyDifficulty.HARD + assert context.luck_stat == 15 + assert context.loot_bonus == 0.1 + + +class TestDifficultyBonuses: + """Test difficulty rarity bonus constants.""" + + def test_easy_bonus(self): + """Easy enemies have no bonus.""" + assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.EASY] == 0.0 + + def test_medium_bonus(self): + """Medium enemies have small bonus.""" + assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.MEDIUM] == 0.05 + + def test_hard_bonus(self): + """Hard enemies have moderate bonus.""" + assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.HARD] == 0.15 + + def test_boss_bonus(self): + """Boss enemies have large bonus.""" + assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.BOSS] == 0.30 + + +class TestCombatLootServiceInit: + """Test service initialization.""" + + def test_init_uses_defaults(self): + """Service should initialize with default dependencies.""" + service = CombatLootService() + + assert service.item_generator is not None + assert service.static_loader is not None + + def test_singleton_returns_same_instance(self): + """get_combat_loot_service should return singleton.""" + service1 = get_combat_loot_service() + service2 = get_combat_loot_service() + + assert service1 is service2 + + +class TestCombatLootServiceEffectiveLuck: + """Test effective luck calculation.""" + + def test_base_luck_no_bonus(self): + """With no bonuses, effective luck equals base luck.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + rarity_bonus=0.0 + ) + context = LootContext( + luck_stat=8, + enemy_difficulty=EnemyDifficulty.EASY, + loot_bonus=0.0 + ) + + effective = service._calculate_effective_luck(entry, context) + + # No bonus, so effective should equal base + assert effective == 8 + + def test_difficulty_bonus_adds_luck(self): + """Difficulty bonus should increase effective luck.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + rarity_bonus=0.0 + ) + context = LootContext( + luck_stat=8, + enemy_difficulty=EnemyDifficulty.BOSS, # 0.30 bonus + loot_bonus=0.0 + ) + + effective = service._calculate_effective_luck(entry, context) + + # Boss bonus = 0.30 * 20 = 6 extra luck + assert effective == 8 + 6 + + def test_entry_rarity_bonus_adds_luck(self): + """Entry rarity bonus should increase effective luck.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + rarity_bonus=0.10 # Entry-specific bonus + ) + context = LootContext( + luck_stat=8, + enemy_difficulty=EnemyDifficulty.EASY, + loot_bonus=0.0 + ) + + effective = service._calculate_effective_luck(entry, context) + + # 0.10 * 20 = 2 extra luck + assert effective == 8 + 2 + + def test_combined_bonuses(self): + """All bonuses should stack.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + rarity_bonus=0.10 + ) + context = LootContext( + luck_stat=10, + enemy_difficulty=EnemyDifficulty.HARD, # 0.15 + loot_bonus=0.05 + ) + + effective = service._calculate_effective_luck(entry, context) + + # Total bonus = 0.10 + 0.15 + 0.05 = 0.30 + # Extra luck = 0.30 * 20 = 6 + expected = 10 + 6 + assert effective == expected + + +class TestCombatLootServiceStaticItems: + """Test static item generation.""" + + def test_generate_static_items_returns_items(self): + """Should return Item instances for static entries.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.STATIC, + item_id="health_potion_small", + drop_chance=1.0 + ) + + items = service._generate_static_items(entry, quantity=1) + + assert len(items) == 1 + assert items[0].name == "Small Health Potion" + + def test_generate_static_items_respects_quantity(self): + """Should generate correct quantity of items.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.STATIC, + item_id="goblin_ear", + drop_chance=1.0 + ) + + items = service._generate_static_items(entry, quantity=3) + + assert len(items) == 3 + # All should be goblin ears with unique IDs + for item in items: + assert "goblin_ear" in item.item_id + + def test_generate_static_items_missing_id(self): + """Should return empty list if item_id is missing.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.STATIC, + item_id=None, + drop_chance=1.0 + ) + + items = service._generate_static_items(entry, quantity=1) + + assert len(items) == 0 + + +class TestCombatLootServiceProceduralItems: + """Test procedural item generation.""" + + def test_generate_procedural_items_returns_items(self): + """Should return generated Item instances.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + drop_chance=1.0, + rarity_bonus=0.0 + ) + context = LootContext(party_average_level=5) + + items = service._generate_procedural_items(entry, quantity=1, context=context) + + assert len(items) == 1 + assert items[0].is_weapon() + + def test_generate_procedural_armor(self): + """Should generate armor when item_type is armor.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="armor", + drop_chance=1.0 + ) + context = LootContext(party_average_level=5) + + items = service._generate_procedural_items(entry, quantity=1, context=context) + + assert len(items) == 1 + assert items[0].is_armor() + + def test_generate_procedural_missing_type(self): + """Should return empty list if item_type is missing.""" + service = CombatLootService() + + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type=None, + drop_chance=1.0 + ) + context = LootContext() + + items = service._generate_procedural_items(entry, quantity=1, context=context) + + assert len(items) == 0 + + +class TestCombatLootServiceGenerateFromEnemy: + """Test full loot generation from enemy templates.""" + + @pytest.fixture + def sample_enemy(self): + """Create a sample enemy template for testing.""" + return EnemyTemplate( + enemy_id="test_goblin", + name="Test Goblin", + description="A test goblin", + base_stats=Stats(), + abilities=["basic_attack"], + loot_table=[ + LootEntry( + loot_type=LootType.STATIC, + item_id="goblin_ear", + drop_chance=1.0, # Guaranteed drop for testing + quantity_min=1, + quantity_max=1 + ) + ], + experience_reward=10, + difficulty=EnemyDifficulty.EASY + ) + + def test_generate_loot_from_enemy_basic(self, sample_enemy): + """Should generate loot from enemy loot table.""" + service = CombatLootService() + context = LootContext() + + items = service.generate_loot_from_enemy(sample_enemy, context) + + assert len(items) == 1 + assert "goblin_ear" in items[0].item_id + + def test_generate_loot_respects_drop_chance(self): + """Items with 0 drop chance should never drop.""" + enemy = EnemyTemplate( + enemy_id="test_enemy", + name="Test Enemy", + description="Test", + base_stats=Stats(), + abilities=[], + loot_table=[ + LootEntry( + loot_type=LootType.STATIC, + item_id="rare_item", + drop_chance=0.0, # Never drops + ) + ], + difficulty=EnemyDifficulty.EASY + ) + service = CombatLootService() + context = LootContext() + + # Run multiple times to ensure it never drops + for _ in range(10): + items = service.generate_loot_from_enemy(enemy, context) + assert len(items) == 0 + + def test_generate_loot_multiple_entries(self): + """Should process all loot table entries.""" + enemy = EnemyTemplate( + enemy_id="test_enemy", + name="Test Enemy", + description="Test", + base_stats=Stats(), + abilities=[], + loot_table=[ + LootEntry( + loot_type=LootType.STATIC, + item_id="goblin_ear", + drop_chance=1.0, + ), + LootEntry( + loot_type=LootType.STATIC, + item_id="health_potion_small", + drop_chance=1.0, + ) + ], + difficulty=EnemyDifficulty.EASY + ) + service = CombatLootService() + context = LootContext() + + items = service.generate_loot_from_enemy(enemy, context) + + assert len(items) == 2 + + +class TestCombatLootServiceBossLoot: + """Test boss loot generation.""" + + @pytest.fixture + def boss_enemy(self): + """Create a boss enemy template for testing.""" + return EnemyTemplate( + enemy_id="test_boss", + name="Test Boss", + description="A test boss", + base_stats=Stats(strength=20, constitution=20), + abilities=["basic_attack"], + loot_table=[ + LootEntry( + loot_type=LootType.STATIC, + item_id="goblin_chieftain_token", + drop_chance=1.0, + ) + ], + experience_reward=100, + difficulty=EnemyDifficulty.BOSS + ) + + def test_generate_boss_loot_includes_guaranteed_drops(self, boss_enemy): + """Boss loot should include guaranteed equipment drops.""" + service = CombatLootService() + context = LootContext(party_average_level=10) + + items = service.generate_boss_loot(boss_enemy, context, guaranteed_drops=1) + + # Should have at least the loot table drop + guaranteed drop + assert len(items) >= 2 + + def test_generate_boss_loot_non_boss_skips_guaranteed(self): + """Non-boss enemies shouldn't get guaranteed drops.""" + enemy = EnemyTemplate( + enemy_id="test_enemy", + name="Test Enemy", + description="Test", + base_stats=Stats(), + abilities=[], + loot_table=[ + LootEntry( + loot_type=LootType.STATIC, + item_id="goblin_ear", + drop_chance=1.0, + ) + ], + difficulty=EnemyDifficulty.EASY # Not a boss + ) + service = CombatLootService() + context = LootContext() + + items = service.generate_boss_loot(enemy, context, guaranteed_drops=2) + + # Should only have the one loot table drop + assert len(items) == 1 diff --git a/api/tests/test_combat_service.py b/api/tests/test_combat_service.py new file mode 100644 index 0000000..16bdba1 --- /dev/null +++ b/api/tests/test_combat_service.py @@ -0,0 +1,657 @@ +""" +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.equipped = {} # No equipment by default + 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() + service.loot_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.difficulty = Mock() + mock_template.difficulty.value = "easy" + mock_template.is_boss.return_value = False + service.enemy_loader.load_enemy.return_value = mock_template + + # Mock loot service to return mock items + mock_item = Mock() + mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1} + service.loot_service.generate_loot_from_enemy.return_value = [mock_item] + + 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..98ef86f --- /dev/null +++ b/api/tests/test_damage_calculator.py @@ -0,0 +1,674 @@ +""" +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: (stats.damage + ability_power) * Variance - DEF + # where stats.damage = int(STR * 0.75) + damage_bonus + attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus + defender = Stats(constitution=10, dexterity=10) # DEF = 5 + + # 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, + ) + + # int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 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, damage_bonus=8) # Weapon damage in bonus + 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, + ) + + 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, damage_bonus=8) # High LUK for crit, weapon in bonus + 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_crit_multiplier=2.0, + ) + + assert result.is_critical is True + # Base: int(14 * 0.75) + 8 = 10 + 8 = 18 + # Crit: 18 * 2 = 36 + # After DEF 5: 36 - 5 = 31 + assert result.total_damage == 31 + 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 + # Weapon contributes to both damage_bonus (physical) and spell_power_bonus (elemental) + attacker = Stats(strength=14, intelligence=8, luck=0, damage_bonus=15, spell_power_bonus=15) + defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5 + + 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_crit_chance=0.05, + weapon_crit_multiplier=2.0, + physical_ratio=0.7, + elemental_ratio=0.3, + elemental_type=DamageType.FIRE, + ) + + # stats.damage = int(14 * 0.75) + 15 = 10 + 15 = 25 + # stats.spell_power = int(8 * 0.75) + 15 = 6 + 15 = 21 + # Physical: 25 * 0.7 = 17.5 -> 17 - 5 DEF = 12 + # Elemental: 21 * 0.3 = 6.3 -> 6 - 5 RES = 1 + + assert result.physical_damage > 0 + assert result.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).""" + # Same stats and weapon bonuses means similar damage on both sides + attacker = Stats(strength=12, intelligence=12, luck=0, damage_bonus=20, spell_power_bonus=20) + defender = Stats(constitution=10, wisdom=10, dexterity=10) + + 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_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 and weapon bonuses) + 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, damage_bonus=15, spell_power_bonus=15) + 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_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, equipped with Rusty Sword (8 damage) + vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8, damage_bonus=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, + ) + + # int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 13 + assert result.total_damage == 13 + + def test_arcanist_fireball_scenario(self): + """Test Arcanist (INT 15) Fireball.""" + # Arcanist: INT 15, LUK 9 (no staff equipped, pure ability damage) + 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, + ) + + # stats.spell_power = int(15 * 0.75) + 0 = 11 + # 11 + 12 (ability) = 23 - 5 RES = 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, damage_bonus=8) # Melee with weapon + arcanist = Stats(intelligence=15, luck=9) # Caster (no staff) + 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, + ) + 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_inventory_api.py b/api/tests/test_inventory_api.py new file mode 100644 index 0000000..35e1f2b --- /dev/null +++ b/api/tests/test_inventory_api.py @@ -0,0 +1,462 @@ +""" +Integration tests for Inventory API endpoints. + +Tests the REST API endpoints for inventory management functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from flask import Flask +import json + +from app import create_app +from app.api.inventory import inventory_bp +from app.models.items import Item +from app.models.character import Character +from app.models.stats import Stats +from app.models.skills import PlayerClass +from app.models.origins import Origin +from app.models.enums import ItemType, ItemRarity, DamageType +from app.services.inventory_service import ( + InventoryService, + ItemNotFoundError, + CannotEquipError, + InvalidSlotError, + CannotUseItemError, + InventoryFullError, + VALID_SLOTS, +) +from app.services.character_service import CharacterNotFound + + +# ============================================================================= +# 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_weapon(): + """Sample weapon item.""" + return Item( + item_id="test_sword_001", + name="Iron Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.COMMON, + description="A sturdy iron sword", + value=50, + damage=8, + damage_type=DamageType.PHYSICAL, + crit_chance=0.05, + crit_multiplier=2.0, + required_level=1, + ) + + +@pytest.fixture +def sample_armor(): + """Sample armor item.""" + return Item( + item_id="test_helmet_001", + name="Iron Helmet", + item_type=ItemType.ARMOR, + rarity=ItemRarity.COMMON, + description="A sturdy iron helmet", + value=30, + defense=5, + resistance=2, + required_level=1, + ) + + +@pytest.fixture +def sample_consumable(): + """Sample consumable item.""" + return Item( + item_id="health_potion_small", + name="Small Health Potion", + item_type=ItemType.CONSUMABLE, + rarity=ItemRarity.COMMON, + description="Restores a small amount of health", + value=25, + effects_on_use=[], # Simplified for testing + ) + + +@pytest.fixture +def sample_class(): + """Sample player class.""" + return PlayerClass( + class_id="vanguard", + name="Vanguard", + description="A heavily armored warrior", + base_stats=Stats( + strength=14, + dexterity=10, + constitution=14, + intelligence=8, + wisdom=8, + charisma=10, + luck=10 + ), + skill_trees=[], + starting_equipment=[], + starting_abilities=[], + ) + + +@pytest.fixture +def sample_origin(): + """Sample origin.""" + return Origin( + id="soul_revenant", + name="Soul Revenant", + description="Returned from death", + starting_location={"area": "graveyard", "name": "Graveyard"}, + narrative_hooks=[], + starting_bonus={}, + ) + + +@pytest.fixture +def sample_character(sample_class, sample_origin, sample_weapon, sample_armor, sample_consumable): + """Sample character with inventory.""" + char = Character( + character_id="test_char_001", + user_id="test_user_001", + name="Test Hero", + player_class=sample_class, + origin=sample_origin, + level=5, + experience=0, + gold=100, + inventory=[sample_weapon, sample_armor, sample_consumable], + equipped={}, + unlocked_skills=[], + ) + return char + + +# ============================================================================= +# GET Inventory Endpoint Tests +# ============================================================================= + +class TestGetInventoryEndpoint: + """Tests for GET /api/v1/characters//inventory endpoint.""" + + def test_get_inventory_requires_auth(self, client): + """Test that inventory endpoint requires authentication.""" + response = client.get('/api/v1/characters/test_char_001/inventory') + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + def test_get_inventory_character_not_found(self, client): + """Test getting inventory for non-existent character returns 404 (after auth).""" + # Without auth, returns 401 regardless + response = client.get('/api/v1/characters/nonexistent_12345/inventory') + + assert response.status_code == 401 + + +# ============================================================================= +# POST Equip Endpoint Tests +# ============================================================================= + +class TestEquipEndpoint: + """Tests for POST /api/v1/characters//inventory/equip endpoint.""" + + def test_equip_requires_auth(self, client): + """Test that equip endpoint requires authentication.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/equip', + json={ + 'item_id': 'test_sword_001', + 'slot': 'weapon' + } + ) + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + def test_equip_missing_item_id(self, client): + """Test equip without item_id still requires auth first.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/equip', + json={'slot': 'weapon'} + ) + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + def test_equip_missing_slot(self, client): + """Test equip without slot still requires auth first.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/equip', + json={'item_id': 'test_sword_001'} + ) + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + def test_equip_missing_body(self, client): + """Test equip without request body still requires auth first.""" + response = client.post('/api/v1/characters/test_char_001/inventory/equip') + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + +# ============================================================================= +# POST Unequip Endpoint Tests +# ============================================================================= + +class TestUnequipEndpoint: + """Tests for POST /api/v1/characters//inventory/unequip endpoint.""" + + def test_unequip_requires_auth(self, client): + """Test that unequip endpoint requires authentication.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/unequip', + json={'slot': 'weapon'} + ) + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + def test_unequip_missing_slot(self, client): + """Test unequip without slot still requires auth first.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/unequip', + json={} + ) + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + def test_unequip_missing_body(self, client): + """Test unequip without request body still requires auth first.""" + response = client.post('/api/v1/characters/test_char_001/inventory/unequip') + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + +# ============================================================================= +# POST Use Item Endpoint Tests +# ============================================================================= + +class TestUseItemEndpoint: + """Tests for POST /api/v1/characters//inventory/use endpoint.""" + + def test_use_requires_auth(self, client): + """Test that use item endpoint requires authentication.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/use', + json={'item_id': 'health_potion_small'} + ) + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + def test_use_missing_item_id(self, client): + """Test use item without item_id still requires auth first.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/use', + json={} + ) + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + def test_use_missing_body(self, client): + """Test use item without request body still requires auth first.""" + response = client.post('/api/v1/characters/test_char_001/inventory/use') + + # Without auth, returns 401 regardless of payload issues + assert response.status_code == 401 + + +# ============================================================================= +# DELETE Drop Item Endpoint Tests +# ============================================================================= + +class TestDropItemEndpoint: + """Tests for DELETE /api/v1/characters//inventory/ endpoint.""" + + def test_drop_requires_auth(self, client): + """Test that drop item endpoint requires authentication.""" + response = client.delete( + '/api/v1/characters/test_char_001/inventory/test_sword_001' + ) + + # Should return 401 Unauthorized without valid session + assert response.status_code == 401 + + +# ============================================================================= +# Valid Slot Tests (Unit level) +# ============================================================================= + +class TestValidSlots: + """Tests to verify slot configuration.""" + + def test_valid_slots_defined(self): + """Test that all expected slots are defined.""" + expected_slots = { + 'weapon', 'off_hand', 'helmet', 'chest', + 'gloves', 'boots', 'accessory_1', 'accessory_2' + } + assert VALID_SLOTS == expected_slots + + def test_valid_slots_count(self): + """Test that we have exactly 8 equipment slots.""" + assert len(VALID_SLOTS) == 8 + + +# ============================================================================= +# Endpoint URL Pattern Tests +# ============================================================================= + +class TestEndpointURLPatterns: + """Tests to verify correct URL patterns.""" + + def test_get_inventory_url(self, client): + """Test GET inventory URL pattern.""" + response = client.get('/api/v1/characters/any_id/inventory') + # Should be 401 (auth required), not 404 (route not found) + assert response.status_code == 401 + + def test_equip_url(self, client): + """Test POST equip URL pattern.""" + response = client.post( + '/api/v1/characters/any_id/inventory/equip', + json={'item_id': 'x', 'slot': 'weapon'} + ) + # Should be 401 (auth required), not 404 (route not found) + assert response.status_code == 401 + + def test_unequip_url(self, client): + """Test POST unequip URL pattern.""" + response = client.post( + '/api/v1/characters/any_id/inventory/unequip', + json={'slot': 'weapon'} + ) + # Should be 401 (auth required), not 404 (route not found) + assert response.status_code == 401 + + def test_use_url(self, client): + """Test POST use URL pattern.""" + response = client.post( + '/api/v1/characters/any_id/inventory/use', + json={'item_id': 'x'} + ) + # Should be 401 (auth required), not 404 (route not found) + assert response.status_code == 401 + + def test_drop_url(self, client): + """Test DELETE drop URL pattern.""" + response = client.delete('/api/v1/characters/any_id/inventory/item_123') + # Should be 401 (auth required), not 404 (route not found) + assert response.status_code == 401 + + +# ============================================================================= +# Response Format Tests (verifying blueprint registration) +# ============================================================================= + +class TestResponseFormats: + """Tests to verify API response format consistency.""" + + def test_get_inventory_401_format(self, client): + """Test that 401 response follows standard format.""" + response = client.get('/api/v1/characters/test_char_001/inventory') + + assert response.status_code == 401 + data = response.get_json() + + # Standard response format should include status + assert data is not None + assert 'status' in data + assert data['status'] == 401 + + def test_equip_401_format(self, client): + """Test that equip 401 response follows standard format.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/equip', + json={'item_id': 'test', 'slot': 'weapon'} + ) + + assert response.status_code == 401 + data = response.get_json() + + assert data is not None + assert 'status' in data + assert data['status'] == 401 + + def test_unequip_401_format(self, client): + """Test that unequip 401 response follows standard format.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/unequip', + json={'slot': 'weapon'} + ) + + assert response.status_code == 401 + data = response.get_json() + + assert data is not None + assert 'status' in data + assert data['status'] == 401 + + def test_use_401_format(self, client): + """Test that use 401 response follows standard format.""" + response = client.post( + '/api/v1/characters/test_char_001/inventory/use', + json={'item_id': 'test'} + ) + + assert response.status_code == 401 + data = response.get_json() + + assert data is not None + assert 'status' in data + assert data['status'] == 401 + + def test_drop_401_format(self, client): + """Test that drop 401 response follows standard format.""" + response = client.delete( + '/api/v1/characters/test_char_001/inventory/test_item' + ) + + assert response.status_code == 401 + data = response.get_json() + + assert data is not None + assert 'status' in data + assert data['status'] == 401 diff --git a/api/tests/test_inventory_service.py b/api/tests/test_inventory_service.py new file mode 100644 index 0000000..54281c8 --- /dev/null +++ b/api/tests/test_inventory_service.py @@ -0,0 +1,819 @@ +""" +Unit tests for the InventoryService. + +Tests cover: +- Adding and removing items +- Equipment slot validation +- Level and class requirement checks +- Consumable usage and effect application +- Bulk operations +- Error handling +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from typing import List + +from app.models.character import Character +from app.models.items import Item +from app.models.effects import Effect +from app.models.stats import Stats +from app.models.enums import ItemType, ItemRarity, EffectType, StatType, DamageType +from app.models.skills import PlayerClass +from app.models.origins import Origin +from app.services.inventory_service import ( + InventoryService, + ItemNotFoundError, + CannotEquipError, + InvalidSlotError, + CannotUseItemError, + InventoryFullError, + ConsumableResult, + VALID_SLOTS, + ITEM_TYPE_SLOTS, + MAX_INVENTORY_SIZE, + get_inventory_service, +) + + +# ============================================================================= +# Test Fixtures +# ============================================================================= + +@pytest.fixture +def mock_character_service(): + """Create a mock CharacterService.""" + service = Mock() + service.update_character = Mock() + return service + + +@pytest.fixture +def inventory_service(mock_character_service): + """Create InventoryService with mocked dependencies.""" + return InventoryService(character_service=mock_character_service) + + +@pytest.fixture +def mock_origin(): + """Create a minimal Origin for testing.""" + from app.models.origins import StartingLocation, StartingBonus + + starting_location = StartingLocation( + id="test_location", + name="Test Village", + region="Test Region", + description="A test location" + ) + + return Origin( + id="test_origin", + name="Test Origin", + description="A test origin for testing purposes", + starting_location=starting_location, + narrative_hooks=["test hook"], + starting_bonus=None, + ) + + +@pytest.fixture +def mock_player_class(): + """Create a minimal PlayerClass for testing.""" + return PlayerClass( + class_id="warrior", + name="Warrior", + description="A mighty warrior", + base_stats=Stats( + strength=14, + dexterity=10, + constitution=12, + intelligence=8, + wisdom=10, + charisma=8, + luck=8, + ), + skill_trees=[], + starting_abilities=["basic_attack"], + ) + + +@pytest.fixture +def test_character(mock_player_class, mock_origin): + """Create a test character.""" + return Character( + character_id="char_test_123", + user_id="user_test_456", + name="Test Hero", + player_class=mock_player_class, + origin=mock_origin, + level=5, + experience=0, + base_stats=mock_player_class.base_stats.copy(), + inventory=[], + equipped={}, + gold=100, + ) + + +@pytest.fixture +def test_weapon(): + """Create a test weapon item.""" + return Item( + item_id="iron_sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.COMMON, + description="A sturdy iron sword", + value=50, + damage=10, + damage_type=DamageType.PHYSICAL, + crit_chance=0.05, + crit_multiplier=2.0, + required_level=1, + ) + + +@pytest.fixture +def test_armor(): + """Create a test armor item.""" + return Item( + item_id="leather_chest", + name="Leather Chestpiece", + item_type=ItemType.ARMOR, + rarity=ItemRarity.COMMON, + description="Simple leather armor", + value=40, + defense=5, + resistance=2, + required_level=1, + ) + + +@pytest.fixture +def test_helmet(): + """Create a test helmet item.""" + return Item( + item_id="iron_helm", + name="Iron Helmet", + item_type=ItemType.ARMOR, + rarity=ItemRarity.UNCOMMON, + description="A protective iron helmet", + value=30, + defense=3, + resistance=1, + required_level=3, + ) + + +@pytest.fixture +def test_consumable(): + """Create a test consumable item (health potion).""" + return Item( + item_id="health_potion_small", + name="Small Health Potion", + item_type=ItemType.CONSUMABLE, + rarity=ItemRarity.COMMON, + description="Restores 25 HP", + value=10, + effects_on_use=[ + Effect( + effect_id="heal_25", + name="Minor Healing", + effect_type=EffectType.HOT, + duration=1, + power=25, + stacks=1, + ) + ], + ) + + +@pytest.fixture +def test_buff_potion(): + """Create a test buff potion.""" + return Item( + item_id="strength_potion", + name="Potion of Strength", + item_type=ItemType.CONSUMABLE, + rarity=ItemRarity.UNCOMMON, + description="Increases strength temporarily", + value=25, + effects_on_use=[ + Effect( + effect_id="str_buff", + name="Strength Boost", + effect_type=EffectType.BUFF, + duration=3, + power=5, + stat_affected=StatType.STRENGTH, + stacks=1, + ) + ], + ) + + +@pytest.fixture +def test_quest_item(): + """Create a test quest item.""" + return Item( + item_id="ancient_key", + name="Ancient Key", + item_type=ItemType.QUEST_ITEM, + rarity=ItemRarity.RARE, + description="An ornate key to the ancient tomb", + value=0, + is_tradeable=False, + ) + + +@pytest.fixture +def high_level_weapon(): + """Create a weapon with high level requirement.""" + return Item( + item_id="legendary_blade", + name="Blade of Ages", + item_type=ItemType.WEAPON, + rarity=ItemRarity.LEGENDARY, + description="A blade forged in ancient times", + value=5000, + damage=50, + damage_type=DamageType.PHYSICAL, + required_level=20, # Higher than test character's level 5 + ) + + +# ============================================================================= +# Read Operation Tests +# ============================================================================= + +class TestGetInventory: + """Tests for get_inventory() and related read operations.""" + + def test_get_empty_inventory(self, inventory_service, test_character): + """Test getting inventory when it's empty.""" + items = inventory_service.get_inventory(test_character) + assert items == [] + + def test_get_inventory_with_items(self, inventory_service, test_character, test_weapon, test_armor): + """Test getting inventory with items.""" + test_character.inventory = [test_weapon, test_armor] + + items = inventory_service.get_inventory(test_character) + + assert len(items) == 2 + assert test_weapon in items + assert test_armor in items + + def test_get_inventory_returns_copy(self, inventory_service, test_character, test_weapon): + """Test that get_inventory returns a new list (not the original).""" + test_character.inventory = [test_weapon] + + items = inventory_service.get_inventory(test_character) + items.append(test_weapon) # Modify returned list + + # Original inventory should be unchanged + assert len(test_character.inventory) == 1 + + def test_get_item_by_id_found(self, inventory_service, test_character, test_weapon): + """Test finding an item by ID.""" + test_character.inventory = [test_weapon] + + item = inventory_service.get_item_by_id(test_character, "iron_sword") + + assert item is test_weapon + + def test_get_item_by_id_not_found(self, inventory_service, test_character, test_weapon): + """Test item not found returns None.""" + test_character.inventory = [test_weapon] + + item = inventory_service.get_item_by_id(test_character, "nonexistent_item") + + assert item is None + + def test_get_equipped_items_empty(self, inventory_service, test_character): + """Test getting equipped items when nothing equipped.""" + equipped = inventory_service.get_equipped_items(test_character) + assert equipped == {} + + def test_get_equipped_items_with_equipment(self, inventory_service, test_character, test_weapon): + """Test getting equipped items.""" + test_character.equipped = {"weapon": test_weapon} + + equipped = inventory_service.get_equipped_items(test_character) + + assert "weapon" in equipped + assert equipped["weapon"] is test_weapon + + def test_get_equipped_item_specific_slot(self, inventory_service, test_character, test_weapon): + """Test getting item from a specific slot.""" + test_character.equipped = {"weapon": test_weapon} + + item = inventory_service.get_equipped_item(test_character, "weapon") + + assert item is test_weapon + + def test_get_equipped_item_empty_slot(self, inventory_service, test_character): + """Test getting item from empty slot returns None.""" + item = inventory_service.get_equipped_item(test_character, "weapon") + assert item is None + + def test_get_inventory_count(self, inventory_service, test_character, test_weapon, test_armor): + """Test counting inventory items.""" + test_character.inventory = [test_weapon, test_armor] + + count = inventory_service.get_inventory_count(test_character) + + assert count == 2 + + +# ============================================================================= +# Add/Remove Item Tests +# ============================================================================= + +class TestAddItem: + """Tests for add_item().""" + + def test_add_item_success(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test successfully adding an item.""" + inventory_service.add_item(test_character, test_weapon, "user_test_456") + + assert test_weapon in test_character.inventory + mock_character_service.update_character.assert_called_once() + + def test_add_item_without_save(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test adding item without persistence.""" + inventory_service.add_item(test_character, test_weapon, "user_test_456", save=False) + + assert test_weapon in test_character.inventory + mock_character_service.update_character.assert_not_called() + + def test_add_multiple_items(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service): + """Test adding multiple items.""" + inventory_service.add_item(test_character, test_weapon, "user_test_456") + inventory_service.add_item(test_character, test_armor, "user_test_456") + + assert len(test_character.inventory) == 2 + + +class TestRemoveItem: + """Tests for remove_item().""" + + def test_remove_item_success(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test successfully removing an item.""" + test_character.inventory = [test_weapon] + + removed = inventory_service.remove_item(test_character, "iron_sword", "user_test_456") + + assert removed is test_weapon + assert test_weapon not in test_character.inventory + mock_character_service.update_character.assert_called_once() + + def test_remove_item_not_found(self, inventory_service, test_character): + """Test removing non-existent item raises error.""" + with pytest.raises(ItemNotFoundError) as exc: + inventory_service.remove_item(test_character, "nonexistent", "user_test_456") + + assert "nonexistent" in str(exc.value) + + def test_remove_item_from_multiple(self, inventory_service, test_character, test_weapon, test_armor): + """Test removing one item from multiple.""" + test_character.inventory = [test_weapon, test_armor] + + inventory_service.remove_item(test_character, "iron_sword", "user_test_456") + + assert test_weapon not in test_character.inventory + assert test_armor in test_character.inventory + + def test_drop_item_alias(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test drop_item is an alias for remove_item.""" + test_character.inventory = [test_weapon] + + dropped = inventory_service.drop_item(test_character, "iron_sword", "user_test_456") + + assert dropped is test_weapon + assert test_weapon not in test_character.inventory + + +# ============================================================================= +# Equipment Tests +# ============================================================================= + +class TestEquipItem: + """Tests for equip_item().""" + + def test_equip_weapon_to_weapon_slot(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test equipping a weapon to weapon slot.""" + test_character.inventory = [test_weapon] + + previous = inventory_service.equip_item( + test_character, "iron_sword", "weapon", "user_test_456" + ) + + assert previous is None + assert test_character.equipped.get("weapon") is test_weapon + assert test_weapon not in test_character.inventory + mock_character_service.update_character.assert_called_once() + + def test_equip_armor_to_chest_slot(self, inventory_service, test_character, test_armor, mock_character_service): + """Test equipping armor to chest slot.""" + test_character.inventory = [test_armor] + + inventory_service.equip_item(test_character, "leather_chest", "chest", "user_test_456") + + assert test_character.equipped.get("chest") is test_armor + + def test_equip_returns_previous_item(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test that equipping returns the previously equipped item.""" + old_weapon = Item( + item_id="old_sword", + name="Old Sword", + item_type=ItemType.WEAPON, + damage=5, + ) + test_character.inventory = [test_weapon] + test_character.equipped = {"weapon": old_weapon} + + previous = inventory_service.equip_item( + test_character, "iron_sword", "weapon", "user_test_456" + ) + + assert previous is old_weapon + assert old_weapon in test_character.inventory # Returned to inventory + + def test_equip_to_invalid_slot_raises_error(self, inventory_service, test_character, test_weapon): + """Test equipping to invalid slot raises InvalidSlotError.""" + test_character.inventory = [test_weapon] + + with pytest.raises(InvalidSlotError) as exc: + inventory_service.equip_item( + test_character, "iron_sword", "invalid_slot", "user_test_456" + ) + + assert "invalid_slot" in str(exc.value) + assert "Valid slots" in str(exc.value) + + def test_equip_weapon_to_armor_slot_raises_error(self, inventory_service, test_character, test_weapon): + """Test equipping weapon to armor slot raises CannotEquipError.""" + test_character.inventory = [test_weapon] + + with pytest.raises(CannotEquipError) as exc: + inventory_service.equip_item( + test_character, "iron_sword", "chest", "user_test_456" + ) + + assert "weapon" in str(exc.value).lower() + + def test_equip_armor_to_weapon_slot_raises_error(self, inventory_service, test_character, test_armor): + """Test equipping armor to weapon slot raises CannotEquipError.""" + test_character.inventory = [test_armor] + + with pytest.raises(CannotEquipError) as exc: + inventory_service.equip_item( + test_character, "leather_chest", "weapon", "user_test_456" + ) + + assert "armor" in str(exc.value).lower() + + def test_equip_item_not_in_inventory(self, inventory_service, test_character): + """Test equipping item not in inventory raises ItemNotFoundError.""" + with pytest.raises(ItemNotFoundError): + inventory_service.equip_item( + test_character, "nonexistent", "weapon", "user_test_456" + ) + + def test_equip_item_level_requirement_not_met(self, inventory_service, test_character, high_level_weapon): + """Test equipping item with unmet level requirement raises error.""" + test_character.inventory = [high_level_weapon] + test_character.level = 5 # Item requires level 20 + + with pytest.raises(CannotEquipError) as exc: + inventory_service.equip_item( + test_character, "legendary_blade", "weapon", "user_test_456" + ) + + assert "level 20" in str(exc.value) + assert "level 5" in str(exc.value) + + def test_equip_consumable_raises_error(self, inventory_service, test_character, test_consumable): + """Test equipping consumable raises CannotEquipError.""" + test_character.inventory = [test_consumable] + + with pytest.raises(CannotEquipError) as exc: + inventory_service.equip_item( + test_character, "health_potion_small", "weapon", "user_test_456" + ) + + assert "consumable" in str(exc.value).lower() + + def test_equip_quest_item_raises_error(self, inventory_service, test_character, test_quest_item): + """Test equipping quest item raises CannotEquipError.""" + test_character.inventory = [test_quest_item] + + with pytest.raises(CannotEquipError) as exc: + inventory_service.equip_item( + test_character, "ancient_key", "weapon", "user_test_456" + ) + + def test_equip_weapon_to_off_hand(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test equipping weapon to off_hand slot.""" + test_character.inventory = [test_weapon] + + inventory_service.equip_item( + test_character, "iron_sword", "off_hand", "user_test_456" + ) + + assert test_character.equipped.get("off_hand") is test_weapon + + +class TestUnequipItem: + """Tests for unequip_item().""" + + def test_unequip_item_success(self, inventory_service, test_character, test_weapon, mock_character_service): + """Test successfully unequipping an item.""" + test_character.equipped = {"weapon": test_weapon} + + unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456") + + assert unequipped is test_weapon + assert "weapon" not in test_character.equipped + assert test_weapon in test_character.inventory + mock_character_service.update_character.assert_called_once() + + def test_unequip_empty_slot_returns_none(self, inventory_service, test_character, mock_character_service): + """Test unequipping from empty slot returns None.""" + unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456") + + assert unequipped is None + + def test_unequip_invalid_slot_raises_error(self, inventory_service, test_character): + """Test unequipping from invalid slot raises InvalidSlotError.""" + with pytest.raises(InvalidSlotError): + inventory_service.unequip_item(test_character, "invalid_slot", "user_test_456") + + +# ============================================================================= +# Consumable Tests +# ============================================================================= + +class TestUseConsumable: + """Tests for use_consumable().""" + + def test_use_health_potion(self, inventory_service, test_character, test_consumable, mock_character_service): + """Test using a health potion restores HP.""" + test_character.inventory = [test_consumable] + + result = inventory_service.use_consumable( + test_character, "health_potion_small", "user_test_456", + current_hp=50, max_hp=100 + ) + + assert isinstance(result, ConsumableResult) + assert result.hp_restored == 25 # Potion restores 25, capped at missing HP + assert result.item_name == "Small Health Potion" + assert test_consumable not in test_character.inventory + mock_character_service.update_character.assert_called_once() + + def test_use_health_potion_capped_at_max(self, inventory_service, test_character, test_consumable): + """Test HP restoration is capped at max HP.""" + test_character.inventory = [test_consumable] + + result = inventory_service.use_consumable( + test_character, "health_potion_small", "user_test_456", + current_hp=90, max_hp=100 # Only missing 10 HP + ) + + assert result.hp_restored == 10 # Only restores missing amount + + def test_use_consumable_at_full_hp(self, inventory_service, test_character, test_consumable): + """Test using potion at full HP restores 0.""" + test_character.inventory = [test_consumable] + + result = inventory_service.use_consumable( + test_character, "health_potion_small", "user_test_456", + current_hp=100, max_hp=100 + ) + + assert result.hp_restored == 0 + + def test_use_buff_potion(self, inventory_service, test_character, test_buff_potion, mock_character_service): + """Test using a buff potion.""" + test_character.inventory = [test_buff_potion] + + result = inventory_service.use_consumable( + test_character, "strength_potion", "user_test_456" + ) + + assert result.item_name == "Potion of Strength" + assert len(result.effects_applied) == 1 + assert result.effects_applied[0]["effect_type"] == "buff" + assert result.effects_applied[0]["stat_affected"] == "strength" + + def test_use_non_consumable_raises_error(self, inventory_service, test_character, test_weapon): + """Test using non-consumable item raises CannotUseItemError.""" + test_character.inventory = [test_weapon] + + with pytest.raises(CannotUseItemError) as exc: + inventory_service.use_consumable( + test_character, "iron_sword", "user_test_456" + ) + + assert "not a consumable" in str(exc.value) + + def test_use_item_not_in_inventory_raises_error(self, inventory_service, test_character): + """Test using item not in inventory raises ItemNotFoundError.""" + with pytest.raises(ItemNotFoundError): + inventory_service.use_consumable( + test_character, "nonexistent", "user_test_456" + ) + + def test_consumable_result_to_dict(self, inventory_service, test_character, test_consumable): + """Test ConsumableResult serialization.""" + test_character.inventory = [test_consumable] + + result = inventory_service.use_consumable( + test_character, "health_potion_small", "user_test_456", + current_hp=50, max_hp=100 + ) + + result_dict = result.to_dict() + + assert "item_name" in result_dict + assert "hp_restored" in result_dict + assert "effects_applied" in result_dict + assert "message" in result_dict + + +class TestUseConsumableInCombat: + """Tests for use_consumable_in_combat().""" + + def test_combat_consumable_returns_effects(self, inventory_service, test_character, test_buff_potion): + """Test combat consumable returns duration effects.""" + test_character.inventory = [test_buff_potion] + + result, effects = inventory_service.use_consumable_in_combat( + test_character, "strength_potion", "user_test_456", + current_hp=50, max_hp=100 + ) + + assert isinstance(result, ConsumableResult) + assert len(effects) == 1 + assert effects[0].effect_type == EffectType.BUFF + assert effects[0].duration == 3 + + def test_combat_instant_heal_potion(self, inventory_service, test_character, test_consumable): + """Test instant heal in combat.""" + test_character.inventory = [test_consumable] + + result, effects = inventory_service.use_consumable_in_combat( + test_character, "health_potion_small", "user_test_456", + current_hp=50, max_hp=100 + ) + + # HOT with duration 1 should be returned as duration effect for combat tracking + assert len(effects) >= 0 # Implementation may vary + + +# ============================================================================= +# Bulk Operation Tests +# ============================================================================= + +class TestBulkOperations: + """Tests for bulk inventory operations.""" + + def test_add_items_bulk(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service): + """Test adding multiple items at once.""" + items = [test_weapon, test_armor] + + count = inventory_service.add_items(test_character, items, "user_test_456") + + assert count == 2 + assert len(test_character.inventory) == 2 + mock_character_service.update_character.assert_called_once() + + def test_get_items_by_type(self, inventory_service, test_character, test_weapon, test_armor, test_consumable): + """Test filtering items by type.""" + test_character.inventory = [test_weapon, test_armor, test_consumable] + + weapons = inventory_service.get_items_by_type(test_character, ItemType.WEAPON) + armor = inventory_service.get_items_by_type(test_character, ItemType.ARMOR) + consumables = inventory_service.get_items_by_type(test_character, ItemType.CONSUMABLE) + + assert len(weapons) == 1 + assert test_weapon in weapons + assert len(armor) == 1 + assert test_armor in armor + assert len(consumables) == 1 + + def test_get_equippable_items(self, inventory_service, test_character, test_weapon, test_armor, test_consumable): + """Test getting only equippable items.""" + test_character.inventory = [test_weapon, test_armor, test_consumable] + + equippable = inventory_service.get_equippable_items(test_character) + + assert test_weapon in equippable + assert test_armor in equippable + assert test_consumable not in equippable + + def test_get_equippable_items_for_slot(self, inventory_service, test_character, test_weapon, test_armor): + """Test getting equippable items for a specific slot.""" + test_character.inventory = [test_weapon, test_armor] + + for_weapon = inventory_service.get_equippable_items(test_character, slot="weapon") + for_chest = inventory_service.get_equippable_items(test_character, slot="chest") + + assert test_weapon in for_weapon + assert test_armor not in for_weapon + assert test_armor in for_chest + assert test_weapon not in for_chest + + def test_get_equippable_items_excludes_high_level(self, inventory_service, test_character, test_weapon, high_level_weapon): + """Test that items above character level are excluded.""" + test_character.inventory = [test_weapon, high_level_weapon] + test_character.level = 5 + + equippable = inventory_service.get_equippable_items(test_character) + + assert test_weapon in equippable + assert high_level_weapon not in equippable + + +# ============================================================================= +# Edge Cases and Error Handling +# ============================================================================= + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_valid_slots_constant(self): + """Test VALID_SLOTS contains expected slots.""" + expected = {"weapon", "off_hand", "helmet", "chest", "gloves", "boots", "accessory_1", "accessory_2"} + assert VALID_SLOTS == expected + + def test_item_type_slots_mapping(self): + """Test ITEM_TYPE_SLOTS mapping is correct.""" + assert ItemType.WEAPON in ITEM_TYPE_SLOTS + assert "weapon" in ITEM_TYPE_SLOTS[ItemType.WEAPON] + assert "off_hand" in ITEM_TYPE_SLOTS[ItemType.WEAPON] + assert ItemType.ARMOR in ITEM_TYPE_SLOTS + assert "chest" in ITEM_TYPE_SLOTS[ItemType.ARMOR] + assert "helmet" in ITEM_TYPE_SLOTS[ItemType.ARMOR] + + def test_generated_item_with_unique_id(self, inventory_service, test_character, mock_character_service): + """Test handling of generated items with unique IDs.""" + generated_item = Item( + item_id="gen_abc123", # Generated item ID format + name="Dagger", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + damage=15, + is_generated=True, + generated_name="Flaming Dagger of Strength", + base_template_id="dagger", + applied_affixes=["flaming", "of_strength"], + ) + + inventory_service.add_item(test_character, generated_item, "user_test_456") + + assert generated_item in test_character.inventory + assert generated_item.get_display_name() == "Flaming Dagger of Strength" + + def test_equip_generated_item(self, inventory_service, test_character, mock_character_service): + """Test equipping a generated item.""" + generated_item = Item( + item_id="gen_xyz789", + name="Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.EPIC, + damage=25, + is_generated=True, + generated_name="Blazing Sword of Power", + required_level=1, + ) + test_character.inventory = [generated_item] + + inventory_service.equip_item(test_character, "gen_xyz789", "weapon", "user_test_456") + + assert test_character.equipped.get("weapon") is generated_item + + +# ============================================================================= +# Global Instance Tests +# ============================================================================= + +class TestGlobalInstance: + """Tests for the global singleton pattern.""" + + def test_get_inventory_service_returns_instance(self): + """Test get_inventory_service returns InventoryService.""" + with patch('app.services.inventory_service._service_instance', None): + with patch('app.services.inventory_service.get_character_service'): + service = get_inventory_service() + assert isinstance(service, InventoryService) + + def test_get_inventory_service_returns_same_instance(self): + """Test get_inventory_service returns singleton.""" + with patch('app.services.inventory_service._service_instance', None): + with patch('app.services.inventory_service.get_character_service'): + service1 = get_inventory_service() + service2 = get_inventory_service() + assert service1 is service2 diff --git a/api/tests/test_item_generator.py b/api/tests/test_item_generator.py new file mode 100644 index 0000000..4811c33 --- /dev/null +++ b/api/tests/test_item_generator.py @@ -0,0 +1,527 @@ +""" +Tests for the Item Generator and Affix System. + +Tests cover: +- Affix loading from YAML +- Base item template loading +- Item generation with affixes +- Name generation +- Stat combination +""" + +import pytest +from unittest.mock import patch, MagicMock + +from app.models.affixes import Affix, BaseItemTemplate +from app.models.enums import AffixType, AffixTier, ItemRarity, ItemType, DamageType +from app.services.affix_loader import AffixLoader, get_affix_loader +from app.services.base_item_loader import BaseItemLoader, get_base_item_loader +from app.services.item_generator import ItemGenerator, get_item_generator + + +class TestAffixModel: + """Tests for the Affix dataclass.""" + + def test_affix_creation(self): + """Test creating an Affix instance.""" + affix = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + description="Fire damage", + damage_type=DamageType.FIRE, + elemental_ratio=0.25, + damage_bonus=3, + ) + + assert affix.affix_id == "flaming" + assert affix.name == "Flaming" + assert affix.affix_type == AffixType.PREFIX + assert affix.tier == AffixTier.MINOR + assert affix.applies_elemental_damage() + + def test_affix_can_apply_to(self): + """Test affix eligibility checking.""" + # Weapon-only affix + weapon_affix = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + allowed_item_types=["weapon"], + ) + + assert weapon_affix.can_apply_to("weapon", "rare") + assert not weapon_affix.can_apply_to("armor", "rare") + + def test_affix_legendary_only(self): + """Test legendary-only affix restriction.""" + legendary_affix = Affix( + affix_id="vorpal", + name="Vorpal", + affix_type=AffixType.PREFIX, + tier=AffixTier.LEGENDARY, + required_rarity="legendary", + ) + + assert legendary_affix.is_legendary_only() + assert legendary_affix.can_apply_to("weapon", "legendary") + assert not legendary_affix.can_apply_to("weapon", "epic") + + def test_affix_serialization(self): + """Test affix to_dict and from_dict.""" + affix = Affix( + affix_id="of_strength", + name="of Strength", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + stat_bonuses={"strength": 2}, + ) + + data = affix.to_dict() + restored = Affix.from_dict(data) + + assert restored.affix_id == affix.affix_id + assert restored.name == affix.name + assert restored.stat_bonuses == affix.stat_bonuses + + +class TestBaseItemTemplate: + """Tests for the BaseItemTemplate dataclass.""" + + def test_template_creation(self): + """Test creating a BaseItemTemplate instance.""" + template = BaseItemTemplate( + template_id="dagger", + name="Dagger", + item_type="weapon", + base_damage=6, + base_value=15, + crit_chance=0.08, + required_level=1, + ) + + assert template.template_id == "dagger" + assert template.base_damage == 6 + assert template.crit_chance == 0.08 + + def test_template_rarity_eligibility(self): + """Test template rarity checking.""" + template = BaseItemTemplate( + template_id="plate_armor", + name="Plate Armor", + item_type="armor", + min_rarity="rare", + ) + + assert template.can_generate_at_rarity("rare") + assert template.can_generate_at_rarity("epic") + assert template.can_generate_at_rarity("legendary") + assert not template.can_generate_at_rarity("common") + assert not template.can_generate_at_rarity("uncommon") + + def test_template_level_eligibility(self): + """Test template level checking.""" + template = BaseItemTemplate( + template_id="greatsword", + name="Greatsword", + item_type="weapon", + required_level=5, + ) + + assert template.can_drop_for_level(5) + assert template.can_drop_for_level(10) + assert not template.can_drop_for_level(4) + + +class TestAffixLoader: + """Tests for the AffixLoader service.""" + + def test_loader_initialization(self): + """Test AffixLoader initializes correctly.""" + loader = get_affix_loader() + assert loader is not None + + def test_load_prefixes(self): + """Test loading prefixes from YAML.""" + loader = get_affix_loader() + loader.load_all() + + prefixes = loader.get_all_prefixes() + assert len(prefixes) > 0 + + # Check for known prefix + flaming = loader.get_affix("flaming") + assert flaming is not None + assert flaming.affix_type == AffixType.PREFIX + assert flaming.name == "Flaming" + + def test_load_suffixes(self): + """Test loading suffixes from YAML.""" + loader = get_affix_loader() + loader.load_all() + + suffixes = loader.get_all_suffixes() + assert len(suffixes) > 0 + + # Check for known suffix + of_strength = loader.get_affix("of_strength") + assert of_strength is not None + assert of_strength.affix_type == AffixType.SUFFIX + assert of_strength.name == "of Strength" + + def test_get_eligible_prefixes(self): + """Test filtering eligible prefixes.""" + loader = get_affix_loader() + + # Get weapon prefixes for rare items + eligible = loader.get_eligible_prefixes("weapon", "rare") + assert len(eligible) > 0 + + # All should be applicable to weapons + for prefix in eligible: + assert prefix.can_apply_to("weapon", "rare") + + def test_get_random_prefix(self): + """Test random prefix selection.""" + loader = get_affix_loader() + + prefix = loader.get_random_prefix("weapon", "rare") + assert prefix is not None + assert prefix.affix_type == AffixType.PREFIX + + +class TestBaseItemLoader: + """Tests for the BaseItemLoader service.""" + + def test_loader_initialization(self): + """Test BaseItemLoader initializes correctly.""" + loader = get_base_item_loader() + assert loader is not None + + def test_load_weapons(self): + """Test loading weapon templates from YAML.""" + loader = get_base_item_loader() + loader.load_all() + + weapons = loader.get_all_weapons() + assert len(weapons) > 0 + + # Check for known weapon + dagger = loader.get_template("dagger") + assert dagger is not None + assert dagger.item_type == "weapon" + assert dagger.base_damage > 0 + + def test_load_armor(self): + """Test loading armor templates from YAML.""" + loader = get_base_item_loader() + loader.load_all() + + armor = loader.get_all_armor() + assert len(armor) > 0 + + # Check for known armor + chainmail = loader.get_template("chainmail") + assert chainmail is not None + assert chainmail.item_type == "armor" + assert chainmail.base_defense > 0 + + def test_get_eligible_templates(self): + """Test filtering eligible templates.""" + loader = get_base_item_loader() + + # Get weapons for level 1, common rarity + eligible = loader.get_eligible_templates("weapon", "common", 1) + assert len(eligible) > 0 + + # All should be eligible + for template in eligible: + assert template.can_drop_for_level(1) + assert template.can_generate_at_rarity("common") + + def test_get_random_template(self): + """Test random template selection.""" + loader = get_base_item_loader() + + template = loader.get_random_template("weapon", "common", 1) + assert template is not None + assert template.item_type == "weapon" + + +class TestItemGenerator: + """Tests for the ItemGenerator service.""" + + def test_generator_initialization(self): + """Test ItemGenerator initializes correctly.""" + generator = get_item_generator() + assert generator is not None + + def test_generate_common_item(self): + """Test generating a common item (no affixes).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.COMMON, 1) + assert item is not None + assert item.rarity == ItemRarity.COMMON + assert item.is_generated + assert len(item.applied_affixes) == 0 + # Common items have no generated name + assert item.generated_name == item.name + + def test_generate_rare_item(self): + """Test generating a rare item (1 affix).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.RARE, 1) + assert item is not None + assert item.rarity == ItemRarity.RARE + assert item.is_generated + assert len(item.applied_affixes) == 1 + assert item.generated_name != item.name + + def test_generate_epic_item(self): + """Test generating an epic item (2 affixes).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.EPIC, 1) + assert item is not None + assert item.rarity == ItemRarity.EPIC + assert item.is_generated + assert len(item.applied_affixes) == 2 + + def test_generate_legendary_item(self): + """Test generating a legendary item (3 affixes).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.LEGENDARY, 5) + assert item is not None + assert item.rarity == ItemRarity.LEGENDARY + assert item.is_generated + assert len(item.applied_affixes) == 3 + + def test_generated_name_format(self): + """Test that generated names follow the expected format.""" + generator = get_item_generator() + + # Generate multiple items and check name patterns + for _ in range(10): + item = generator.generate_item("weapon", ItemRarity.EPIC, 1) + if item: + name = item.get_display_name() + # EPIC should have both prefix and suffix (typically) + # Name should contain the base item name + assert item.name in name or item.base_template_id in name.lower() + + def test_stat_combination(self): + """Test that affix stats are properly combined.""" + generator = get_item_generator() + + # Generate items and verify stat bonuses are present + for _ in range(5): + item = generator.generate_item("weapon", ItemRarity.RARE, 1) + if item and item.applied_affixes: + # Item should have some stat modifications + # Either stat_bonuses, damage_bonus, or elemental properties + has_stats = ( + bool(item.stat_bonuses) or + item.damage > 0 or + item.elemental_ratio > 0 + ) + assert has_stats + + def test_generate_armor(self): + """Test generating armor items.""" + generator = get_item_generator() + + item = generator.generate_item("armor", ItemRarity.RARE, 1) + assert item is not None + assert item.item_type == ItemType.ARMOR + assert item.defense > 0 or item.resistance > 0 + + def test_generate_loot_drop(self): + """Test random loot drop generation.""" + generator = get_item_generator() + + # Generate multiple drops to test randomness + rarities_seen = set() + for _ in range(50): + item = generator.generate_loot_drop(5, luck_stat=8) + if item: + rarities_seen.add(item.rarity) + + # Should see at least common and uncommon + assert ItemRarity.COMMON in rarities_seen or ItemRarity.UNCOMMON in rarities_seen + + def test_luck_affects_rarity(self): + """Test that higher luck increases rare drops.""" + generator = get_item_generator() + + # This is a statistical test - higher luck should trend toward better rarity + low_luck_rares = 0 + high_luck_rares = 0 + + for _ in range(100): + low_luck_item = generator.generate_loot_drop(5, luck_stat=1) + high_luck_item = generator.generate_loot_drop(5, luck_stat=20) + + if low_luck_item and low_luck_item.rarity in [ItemRarity.RARE, ItemRarity.EPIC, ItemRarity.LEGENDARY]: + low_luck_rares += 1 + if high_luck_item and high_luck_item.rarity in [ItemRarity.RARE, ItemRarity.EPIC, ItemRarity.LEGENDARY]: + high_luck_rares += 1 + + # High luck should generally produce more rare+ items + # (This may occasionally fail due to randomness, but should pass most of the time) + # We're just checking the trend, not a strict guarantee + # logger.info(f"Low luck rares: {low_luck_rares}, High luck rares: {high_luck_rares}") + + +class TestNameGeneration: + """Tests specifically for item name generation.""" + + def test_prefix_only_name(self): + """Test name with only a prefix.""" + generator = get_item_generator() + + # Create mock affixes + prefix = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [prefix], []) + assert name == "Flaming Dagger" + + def test_suffix_only_name(self): + """Test name with only a suffix.""" + generator = get_item_generator() + + suffix = Affix( + affix_id="of_strength", + name="of Strength", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [], [suffix]) + assert name == "Dagger of Strength" + + def test_full_name(self): + """Test name with prefix and suffix.""" + generator = get_item_generator() + + prefix = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + suffix = Affix( + affix_id="of_strength", + name="of Strength", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [prefix], [suffix]) + assert name == "Flaming Dagger of Strength" + + def test_multiple_prefixes(self): + """Test name with multiple prefixes.""" + generator = get_item_generator() + + prefix1 = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + prefix2 = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [prefix1, prefix2], []) + assert name == "Flaming Sharp Dagger" + + +class TestStatCombination: + """Tests for combining affix stats.""" + + def test_combine_stat_bonuses(self): + """Test combining stat bonuses from multiple affixes.""" + generator = get_item_generator() + + affix1 = Affix( + affix_id="test1", + name="Test1", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + stat_bonuses={"strength": 2, "constitution": 1}, + ) + affix2 = Affix( + affix_id="test2", + name="Test2", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + stat_bonuses={"strength": 3, "dexterity": 2}, + ) + + combined = generator._combine_affix_stats([affix1, affix2]) + + assert combined["stat_bonuses"]["strength"] == 5 + assert combined["stat_bonuses"]["constitution"] == 1 + assert combined["stat_bonuses"]["dexterity"] == 2 + + def test_combine_damage_bonuses(self): + """Test combining damage bonuses.""" + generator = get_item_generator() + + affix1 = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + damage_bonus=3, + ) + affix2 = Affix( + affix_id="keen", + name="Keen", + affix_type=AffixType.PREFIX, + tier=AffixTier.MAJOR, + damage_bonus=5, + ) + + combined = generator._combine_affix_stats([affix1, affix2]) + + assert combined["damage_bonus"] == 8 + + def test_combine_crit_bonuses(self): + """Test combining crit chance and multiplier bonuses.""" + generator = get_item_generator() + + affix1 = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + crit_chance_bonus=0.02, + ) + affix2 = Affix( + affix_id="keen", + name="Keen", + affix_type=AffixType.PREFIX, + tier=AffixTier.MAJOR, + crit_chance_bonus=0.04, + crit_multiplier_bonus=0.5, + ) + + combined = generator._combine_affix_stats([affix1, affix2]) + + assert combined["crit_chance_bonus"] == pytest.approx(0.06) + assert combined["crit_multiplier_bonus"] == pytest.approx(0.5) diff --git a/api/tests/test_items.py b/api/tests/test_items.py new file mode 100644 index 0000000..893ed3e --- /dev/null +++ b/api/tests/test_items.py @@ -0,0 +1,387 @@ +""" +Unit tests for Item dataclass and ItemRarity enum. + +Tests item creation, rarity, type checking, and serialization. +""" + +import pytest +from app.models.items import Item +from app.models.enums import ItemType, ItemRarity, DamageType + + +class TestItemRarityEnum: + """Tests for ItemRarity enum.""" + + def test_rarity_values(self): + """Test all rarity values exist and have correct string values.""" + assert ItemRarity.COMMON.value == "common" + assert ItemRarity.UNCOMMON.value == "uncommon" + assert ItemRarity.RARE.value == "rare" + assert ItemRarity.EPIC.value == "epic" + assert ItemRarity.LEGENDARY.value == "legendary" + + def test_rarity_from_string(self): + """Test creating rarity from string value.""" + assert ItemRarity("common") == ItemRarity.COMMON + assert ItemRarity("uncommon") == ItemRarity.UNCOMMON + assert ItemRarity("rare") == ItemRarity.RARE + assert ItemRarity("epic") == ItemRarity.EPIC + assert ItemRarity("legendary") == ItemRarity.LEGENDARY + + def test_rarity_count(self): + """Test that there are exactly 5 rarity tiers.""" + assert len(ItemRarity) == 5 + + +class TestItemCreation: + """Tests for creating Item instances.""" + + def test_create_basic_item(self): + """Test creating a basic item with minimal fields.""" + item = Item( + item_id="test_item", + name="Test Item", + item_type=ItemType.QUEST_ITEM, + ) + + assert item.item_id == "test_item" + assert item.name == "Test Item" + assert item.item_type == ItemType.QUEST_ITEM + assert item.rarity == ItemRarity.COMMON # Default + assert item.description == "" + assert item.value == 0 + assert item.is_tradeable == True + + def test_item_default_rarity_is_common(self): + """Test that items default to COMMON rarity.""" + item = Item( + item_id="sword_1", + name="Iron Sword", + item_type=ItemType.WEAPON, + ) + + assert item.rarity == ItemRarity.COMMON + + def test_create_item_with_rarity(self): + """Test creating items with different rarity levels.""" + uncommon = Item( + item_id="sword_2", + name="Steel Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.UNCOMMON, + ) + assert uncommon.rarity == ItemRarity.UNCOMMON + + rare = Item( + item_id="sword_3", + name="Mithril Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + ) + assert rare.rarity == ItemRarity.RARE + + epic = Item( + item_id="sword_4", + name="Dragon Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.EPIC, + ) + assert epic.rarity == ItemRarity.EPIC + + legendary = Item( + item_id="sword_5", + name="Excalibur", + item_type=ItemType.WEAPON, + rarity=ItemRarity.LEGENDARY, + ) + assert legendary.rarity == ItemRarity.LEGENDARY + + def test_create_weapon(self): + """Test creating a weapon with all weapon-specific fields.""" + weapon = Item( + item_id="fire_sword", + name="Flame Blade", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + description="A sword wreathed in flames.", + value=500, + damage=25, + damage_type=DamageType.PHYSICAL, + crit_chance=0.15, + crit_multiplier=2.5, + elemental_damage_type=DamageType.FIRE, + physical_ratio=0.7, + elemental_ratio=0.3, + ) + + assert weapon.is_weapon() == True + assert weapon.is_elemental_weapon() == True + assert weapon.damage == 25 + assert weapon.crit_chance == 0.15 + assert weapon.crit_multiplier == 2.5 + assert weapon.elemental_damage_type == DamageType.FIRE + assert weapon.physical_ratio == 0.7 + assert weapon.elemental_ratio == 0.3 + + def test_create_armor(self): + """Test creating armor with defense/resistance.""" + armor = Item( + item_id="plate_armor", + name="Steel Plate Armor", + item_type=ItemType.ARMOR, + rarity=ItemRarity.UNCOMMON, + description="Heavy steel armor.", + value=300, + defense=15, + resistance=5, + ) + + assert armor.is_armor() == True + assert armor.defense == 15 + assert armor.resistance == 5 + + def test_create_consumable(self): + """Test creating a consumable item.""" + potion = Item( + item_id="health_potion", + name="Health Potion", + item_type=ItemType.CONSUMABLE, + rarity=ItemRarity.COMMON, + description="Restores 50 HP.", + value=25, + ) + + assert potion.is_consumable() == True + assert potion.is_tradeable == True + + +class TestItemTypeMethods: + """Tests for item type checking methods.""" + + def test_is_weapon(self): + """Test is_weapon() method.""" + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR) + + assert weapon.is_weapon() == True + assert armor.is_weapon() == False + + def test_is_armor(self): + """Test is_armor() method.""" + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR) + + assert armor.is_armor() == True + assert weapon.is_armor() == False + + def test_is_consumable(self): + """Test is_consumable() method.""" + consumable = Item(item_id="c", name="C", item_type=ItemType.CONSUMABLE) + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + + assert consumable.is_consumable() == True + assert weapon.is_consumable() == False + + def test_is_quest_item(self): + """Test is_quest_item() method.""" + quest = Item(item_id="q", name="Q", item_type=ItemType.QUEST_ITEM) + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + + assert quest.is_quest_item() == True + assert weapon.is_quest_item() == False + + +class TestItemSerialization: + """Tests for Item serialization and deserialization.""" + + def test_to_dict_includes_rarity(self): + """Test that to_dict() includes rarity as string.""" + item = Item( + item_id="test", + name="Test", + item_type=ItemType.WEAPON, + rarity=ItemRarity.EPIC, + description="Test item", + ) + + data = item.to_dict() + + assert data["rarity"] == "epic" + assert data["item_type"] == "weapon" + + def test_from_dict_parses_rarity(self): + """Test that from_dict() parses rarity correctly.""" + data = { + "item_id": "test", + "name": "Test", + "item_type": "weapon", + "rarity": "legendary", + "description": "Test item", + } + + item = Item.from_dict(data) + + assert item.rarity == ItemRarity.LEGENDARY + assert item.item_type == ItemType.WEAPON + + def test_from_dict_defaults_to_common_rarity(self): + """Test that from_dict() defaults to COMMON if rarity missing.""" + data = { + "item_id": "test", + "name": "Test", + "item_type": "weapon", + "description": "Test item", + # No rarity field + } + + item = Item.from_dict(data) + + assert item.rarity == ItemRarity.COMMON + + def test_round_trip_serialization(self): + """Test serialization and deserialization preserve all data.""" + original = Item( + item_id="flame_sword", + name="Flame Blade", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + description="A fiery blade.", + value=500, + damage=25, + damage_type=DamageType.PHYSICAL, + crit_chance=0.12, + crit_multiplier=2.5, + elemental_damage_type=DamageType.FIRE, + physical_ratio=0.7, + elemental_ratio=0.3, + defense=0, + resistance=0, + required_level=5, + stat_bonuses={"strength": 3}, + ) + + # Serialize then deserialize + data = original.to_dict() + restored = Item.from_dict(data) + + assert restored.item_id == original.item_id + assert restored.name == original.name + assert restored.item_type == original.item_type + assert restored.rarity == original.rarity + assert restored.description == original.description + assert restored.value == original.value + assert restored.damage == original.damage + assert restored.damage_type == original.damage_type + assert restored.crit_chance == original.crit_chance + assert restored.crit_multiplier == original.crit_multiplier + assert restored.elemental_damage_type == original.elemental_damage_type + assert restored.physical_ratio == original.physical_ratio + assert restored.elemental_ratio == original.elemental_ratio + assert restored.required_level == original.required_level + assert restored.stat_bonuses == original.stat_bonuses + + def test_round_trip_all_rarities(self): + """Test round-trip serialization for all rarity levels.""" + for rarity in ItemRarity: + original = Item( + item_id=f"item_{rarity.value}", + name=f"{rarity.value.title()} Item", + item_type=ItemType.CONSUMABLE, + rarity=rarity, + ) + + data = original.to_dict() + restored = Item.from_dict(data) + + assert restored.rarity == rarity + + +class TestItemEquippability: + """Tests for item equip requirements.""" + + def test_can_equip_level_requirement(self): + """Test level requirement checking.""" + item = Item( + item_id="high_level_sword", + name="Epic Sword", + item_type=ItemType.WEAPON, + required_level=10, + ) + + assert item.can_equip(character_level=5) == False + assert item.can_equip(character_level=10) == True + assert item.can_equip(character_level=15) == True + + def test_can_equip_class_requirement(self): + """Test class requirement checking.""" + item = Item( + item_id="mage_staff", + name="Mage Staff", + item_type=ItemType.WEAPON, + required_class="mage", + ) + + assert item.can_equip(character_level=1, character_class="warrior") == False + assert item.can_equip(character_level=1, character_class="mage") == True + + +class TestItemStatBonuses: + """Tests for item stat bonus methods.""" + + def test_get_total_stat_bonus(self): + """Test getting stat bonuses from items.""" + item = Item( + item_id="ring_of_power", + name="Ring of Power", + item_type=ItemType.ARMOR, + stat_bonuses={"strength": 5, "constitution": 3}, + ) + + assert item.get_total_stat_bonus("strength") == 5 + assert item.get_total_stat_bonus("constitution") == 3 + assert item.get_total_stat_bonus("dexterity") == 0 # Not in bonuses + + +class TestItemRepr: + """Tests for item string representation.""" + + def test_weapon_repr(self): + """Test weapon __repr__ output.""" + weapon = Item( + item_id="sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + damage=10, + value=50, + ) + + repr_str = repr(weapon) + assert "Iron Sword" in repr_str + assert "weapon" in repr_str + + def test_armor_repr(self): + """Test armor __repr__ output.""" + armor = Item( + item_id="armor", + name="Leather Armor", + item_type=ItemType.ARMOR, + defense=5, + value=30, + ) + + repr_str = repr(armor) + assert "Leather Armor" in repr_str + assert "armor" in repr_str + + def test_consumable_repr(self): + """Test consumable __repr__ output.""" + potion = Item( + item_id="potion", + name="Health Potion", + item_type=ItemType.CONSUMABLE, + value=10, + ) + + repr_str = repr(potion) + assert "Health Potion" in repr_str + assert "consumable" in repr_str diff --git a/api/tests/test_loot_entry.py b/api/tests/test_loot_entry.py new file mode 100644 index 0000000..999591a --- /dev/null +++ b/api/tests/test_loot_entry.py @@ -0,0 +1,224 @@ +""" +Tests for LootEntry model with hybrid loot support. + +Tests the extended LootEntry dataclass that supports both static +and procedural loot types with backward compatibility. +""" + +import pytest + +from app.models.enemy import LootEntry, LootType + + +class TestLootEntryBackwardCompatibility: + """Test that existing YAML format still works.""" + + def test_from_dict_defaults_to_static(self): + """Old-style entries without loot_type should default to STATIC.""" + entry_data = { + "item_id": "rusty_dagger", + "drop_chance": 0.15, + } + entry = LootEntry.from_dict(entry_data) + + assert entry.loot_type == LootType.STATIC + assert entry.item_id == "rusty_dagger" + assert entry.drop_chance == 0.15 + assert entry.quantity_min == 1 + assert entry.quantity_max == 1 + + def test_from_dict_with_all_old_fields(self): + """Test entry with all old-style fields.""" + entry_data = { + "item_id": "gold_coin", + "drop_chance": 0.50, + "quantity_min": 1, + "quantity_max": 3, + } + entry = LootEntry.from_dict(entry_data) + + assert entry.loot_type == LootType.STATIC + assert entry.item_id == "gold_coin" + assert entry.drop_chance == 0.50 + assert entry.quantity_min == 1 + assert entry.quantity_max == 3 + + def test_to_dict_includes_loot_type(self): + """Serialization should include loot_type.""" + entry = LootEntry( + loot_type=LootType.STATIC, + item_id="health_potion", + drop_chance=0.2 + ) + data = entry.to_dict() + + assert data["loot_type"] == "static" + assert data["item_id"] == "health_potion" + assert data["drop_chance"] == 0.2 + + +class TestLootEntryStaticType: + """Test static loot entries.""" + + def test_static_entry_creation(self): + """Test creating a static loot entry.""" + entry = LootEntry( + loot_type=LootType.STATIC, + item_id="goblin_ear", + drop_chance=0.60, + quantity_min=1, + quantity_max=2 + ) + + assert entry.loot_type == LootType.STATIC + assert entry.item_id == "goblin_ear" + assert entry.item_type is None + assert entry.rarity_bonus == 0.0 + + def test_static_from_dict_explicit(self): + """Test parsing explicit static entry.""" + entry_data = { + "loot_type": "static", + "item_id": "health_potion_small", + "drop_chance": 0.10, + } + entry = LootEntry.from_dict(entry_data) + + assert entry.loot_type == LootType.STATIC + assert entry.item_id == "health_potion_small" + + def test_static_to_dict_omits_procedural_fields(self): + """Static entries should omit procedural-only fields.""" + entry = LootEntry( + loot_type=LootType.STATIC, + item_id="gold_coin", + drop_chance=0.5 + ) + data = entry.to_dict() + + assert "item_id" in data + assert "item_type" not in data + assert "rarity_bonus" not in data + + +class TestLootEntryProceduralType: + """Test procedural loot entries.""" + + def test_procedural_entry_creation(self): + """Test creating a procedural loot entry.""" + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + drop_chance=0.10, + rarity_bonus=0.15 + ) + + assert entry.loot_type == LootType.PROCEDURAL + assert entry.item_type == "weapon" + assert entry.rarity_bonus == 0.15 + assert entry.item_id is None + + def test_procedural_from_dict(self): + """Test parsing procedural entry from dict.""" + entry_data = { + "loot_type": "procedural", + "item_type": "armor", + "drop_chance": 0.08, + "rarity_bonus": 0.05, + } + entry = LootEntry.from_dict(entry_data) + + assert entry.loot_type == LootType.PROCEDURAL + assert entry.item_type == "armor" + assert entry.drop_chance == 0.08 + assert entry.rarity_bonus == 0.05 + + def test_procedural_to_dict_includes_item_type(self): + """Procedural entries should include item_type and rarity_bonus.""" + entry = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + drop_chance=0.15, + rarity_bonus=0.10 + ) + data = entry.to_dict() + + assert data["loot_type"] == "procedural" + assert data["item_type"] == "weapon" + assert data["rarity_bonus"] == 0.10 + assert "item_id" not in data + + def test_procedural_default_rarity_bonus(self): + """Procedural entries default to 0.0 rarity bonus.""" + entry_data = { + "loot_type": "procedural", + "item_type": "weapon", + "drop_chance": 0.10, + } + entry = LootEntry.from_dict(entry_data) + + assert entry.rarity_bonus == 0.0 + + +class TestLootTypeEnum: + """Test LootType enum values.""" + + def test_static_value(self): + """Test STATIC enum value.""" + assert LootType.STATIC.value == "static" + + def test_procedural_value(self): + """Test PROCEDURAL enum value.""" + assert LootType.PROCEDURAL.value == "procedural" + + def test_from_string(self): + """Test creating enum from string.""" + assert LootType("static") == LootType.STATIC + assert LootType("procedural") == LootType.PROCEDURAL + + def test_invalid_string_raises(self): + """Test that invalid string raises ValueError.""" + with pytest.raises(ValueError): + LootType("invalid") + + +class TestLootEntryRoundTrip: + """Test serialization/deserialization round trips.""" + + def test_static_round_trip(self): + """Static entry should survive round trip.""" + original = LootEntry( + loot_type=LootType.STATIC, + item_id="health_potion_small", + drop_chance=0.15, + quantity_min=1, + quantity_max=2 + ) + + data = original.to_dict() + restored = LootEntry.from_dict(data) + + assert restored.loot_type == original.loot_type + assert restored.item_id == original.item_id + assert restored.drop_chance == original.drop_chance + assert restored.quantity_min == original.quantity_min + assert restored.quantity_max == original.quantity_max + + def test_procedural_round_trip(self): + """Procedural entry should survive round trip.""" + original = LootEntry( + loot_type=LootType.PROCEDURAL, + item_type="weapon", + drop_chance=0.25, + rarity_bonus=0.15, + quantity_min=1, + quantity_max=1 + ) + + data = original.to_dict() + restored = LootEntry.from_dict(data) + + assert restored.loot_type == original.loot_type + assert restored.item_type == original.item_type + assert restored.drop_chance == original.drop_chance + assert restored.rarity_bonus == original.rarity_bonus 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_static_item_loader.py b/api/tests/test_static_item_loader.py new file mode 100644 index 0000000..244b072 --- /dev/null +++ b/api/tests/test_static_item_loader.py @@ -0,0 +1,194 @@ +""" +Tests for StaticItemLoader service. + +Tests the service that loads predefined items (consumables, materials) +from YAML files for use in loot tables. +""" + +import pytest +from pathlib import Path + +from app.services.static_item_loader import StaticItemLoader, get_static_item_loader +from app.models.enums import ItemType, ItemRarity + + +class TestStaticItemLoaderInitialization: + """Test service initialization.""" + + def test_init_with_default_path(self): + """Service should initialize with default data path.""" + loader = StaticItemLoader() + assert loader.data_dir.exists() or not loader._loaded + + def test_init_with_custom_path(self, tmp_path): + """Service should accept custom data path.""" + loader = StaticItemLoader(data_dir=str(tmp_path)) + assert loader.data_dir == tmp_path + + def test_singleton_returns_same_instance(self): + """get_static_item_loader should return singleton.""" + loader1 = get_static_item_loader() + loader2 = get_static_item_loader() + assert loader1 is loader2 + + +class TestStaticItemLoaderLoading: + """Test YAML loading functionality.""" + + def test_loads_consumables(self): + """Should load consumable items from YAML.""" + loader = get_static_item_loader() + + # Check that health potion exists + assert loader.has_item("health_potion_small") + assert loader.has_item("health_potion_medium") + + def test_loads_materials(self): + """Should load material items from YAML.""" + loader = get_static_item_loader() + + # Check that materials exist + assert loader.has_item("goblin_ear") + assert loader.has_item("wolf_pelt") + + def test_get_all_item_ids_returns_list(self): + """get_all_item_ids should return list of item IDs.""" + loader = get_static_item_loader() + item_ids = loader.get_all_item_ids() + + assert isinstance(item_ids, list) + assert len(item_ids) > 0 + assert "health_potion_small" in item_ids + + def test_has_item_returns_false_for_missing(self): + """has_item should return False for non-existent items.""" + loader = get_static_item_loader() + assert not loader.has_item("nonexistent_item_xyz") + + +class TestStaticItemLoaderGetItem: + """Test item retrieval.""" + + def test_get_item_returns_item_object(self): + """get_item should return an Item instance.""" + loader = get_static_item_loader() + item = loader.get_item("health_potion_small") + + assert item is not None + assert item.name == "Small Health Potion" + assert item.item_type == ItemType.CONSUMABLE + assert item.rarity == ItemRarity.COMMON + + def test_get_item_has_unique_id(self): + """Each call should create item with unique ID.""" + loader = get_static_item_loader() + + item1 = loader.get_item("health_potion_small") + item2 = loader.get_item("health_potion_small") + + assert item1.item_id != item2.item_id + assert "health_potion_small" in item1.item_id + assert "health_potion_small" in item2.item_id + + def test_get_item_returns_none_for_missing(self): + """get_item should return None for non-existent items.""" + loader = get_static_item_loader() + item = loader.get_item("nonexistent_item_xyz") + + assert item is None + + def test_get_item_consumable_has_effects(self): + """Consumable items should have effects_on_use.""" + loader = get_static_item_loader() + item = loader.get_item("health_potion_small") + + assert len(item.effects_on_use) > 0 + effect = item.effects_on_use[0] + assert effect.name == "Minor Healing" + assert effect.power > 0 + + def test_get_item_quest_item_type(self): + """Quest items should have correct type.""" + loader = get_static_item_loader() + item = loader.get_item("goblin_ear") + + assert item is not None + assert item.item_type == ItemType.QUEST_ITEM + assert item.rarity == ItemRarity.COMMON + + def test_get_item_has_value(self): + """Items should have value set.""" + loader = get_static_item_loader() + item = loader.get_item("health_potion_small") + + assert item.value > 0 + + def test_get_item_is_tradeable(self): + """Items should default to tradeable.""" + loader = get_static_item_loader() + item = loader.get_item("goblin_ear") + + assert item.is_tradeable is True + + +class TestStaticItemLoaderVariousItems: + """Test loading various item types.""" + + def test_medium_health_potion(self): + """Test medium health potion properties.""" + loader = get_static_item_loader() + item = loader.get_item("health_potion_medium") + + assert item is not None + assert item.rarity == ItemRarity.UNCOMMON + assert item.value > 25 # More expensive than small + + def test_large_health_potion(self): + """Test large health potion properties.""" + loader = get_static_item_loader() + item = loader.get_item("health_potion_large") + + assert item is not None + assert item.rarity == ItemRarity.RARE + + def test_chieftain_token_rarity(self): + """Test that chieftain token is rare.""" + loader = get_static_item_loader() + item = loader.get_item("goblin_chieftain_token") + + assert item is not None + assert item.rarity == ItemRarity.RARE + + def test_elixir_has_buff_effect(self): + """Test that elixirs have buff effects.""" + loader = get_static_item_loader() + item = loader.get_item("elixir_of_strength") + + if item: # Only test if item exists + assert len(item.effects_on_use) > 0 + + +class TestStaticItemLoaderCache: + """Test caching behavior.""" + + def test_clear_cache(self): + """clear_cache should reset loaded state.""" + loader = StaticItemLoader() + + # Trigger loading + loader._ensure_loaded() + assert loader._loaded is True + + # Clear cache + loader.clear_cache() + assert loader._loaded is False + assert len(loader._cache) == 0 + + def test_lazy_loading(self): + """Items should be loaded lazily on first access.""" + loader = StaticItemLoader() + assert loader._loaded is False + + # Access triggers loading + _ = loader.has_item("health_potion_small") + assert loader._loaded is True diff --git a/api/tests/test_stats.py b/api/tests/test_stats.py index 0af61d6..d7a4874 100644 --- a/api/tests/test_stats.py +++ b/api/tests/test_stats.py @@ -196,3 +196,186 @@ 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 + + +# ============================================================================= +# Equipment Bonus Fields (Task 2.5) +# ============================================================================= + +def test_bonus_fields_default_to_zero(): + """Test that equipment bonus fields default to zero.""" + stats = Stats() + + assert stats.damage_bonus == 0 + assert stats.defense_bonus == 0 + assert stats.resistance_bonus == 0 + + +def test_damage_property_with_no_bonus(): + """Test damage calculation: int(strength * 0.75) + damage_bonus with no bonus.""" + stats = Stats(strength=10) + # int(10 * 0.75) = 7, no bonus + assert stats.damage == 7 + + stats = Stats(strength=14) + # int(14 * 0.75) = 10, no bonus + assert stats.damage == 10 + + +def test_damage_property_with_bonus(): + """Test damage calculation includes damage_bonus from weapons.""" + stats = Stats(strength=10, damage_bonus=15) + # int(10 * 0.75) + 15 = 7 + 15 = 22 + assert stats.damage == 22 + + stats = Stats(strength=14, damage_bonus=8) + # int(14 * 0.75) + 8 = 10 + 8 = 18 + assert stats.damage == 18 + + +def test_defense_property_with_bonus(): + """Test defense calculation includes defense_bonus from armor.""" + stats = Stats(constitution=10, defense_bonus=10) + # (10 // 2) + 10 = 5 + 10 = 15 + assert stats.defense == 15 + + stats = Stats(constitution=20, defense_bonus=5) + # (20 // 2) + 5 = 10 + 5 = 15 + assert stats.defense == 15 + + +def test_resistance_property_with_bonus(): + """Test resistance calculation includes resistance_bonus from armor.""" + stats = Stats(wisdom=10, resistance_bonus=8) + # (10 // 2) + 8 = 5 + 8 = 13 + assert stats.resistance == 13 + + stats = Stats(wisdom=14, resistance_bonus=3) + # (14 // 2) + 3 = 7 + 3 = 10 + assert stats.resistance == 10 + + +def test_bonus_fields_serialization(): + """Test that bonus fields are included in to_dict().""" + stats = Stats( + strength=15, + damage_bonus=12, + defense_bonus=8, + resistance_bonus=5, + ) + + data = stats.to_dict() + + assert data["damage_bonus"] == 12 + assert data["defense_bonus"] == 8 + assert data["resistance_bonus"] == 5 + + +def test_bonus_fields_deserialization(): + """Test that bonus fields are restored from from_dict().""" + data = { + "strength": 15, + "damage_bonus": 12, + "defense_bonus": 8, + "resistance_bonus": 5, + } + + stats = Stats.from_dict(data) + + assert stats.damage_bonus == 12 + assert stats.defense_bonus == 8 + assert stats.resistance_bonus == 5 + + +def test_bonus_fields_deserialization_defaults(): + """Test that missing bonus fields default to zero on deserialization.""" + data = { + "strength": 15, + # No bonus fields + } + + stats = Stats.from_dict(data) + + assert stats.damage_bonus == 0 + assert stats.defense_bonus == 0 + assert stats.resistance_bonus == 0 + + +def test_copy_includes_bonus_fields(): + """Test that copy() preserves bonus fields.""" + original = Stats( + strength=15, + damage_bonus=10, + defense_bonus=8, + resistance_bonus=5, + ) + copy = original.copy() + + assert copy.damage_bonus == 10 + assert copy.defense_bonus == 8 + assert copy.resistance_bonus == 5 + + # Verify independence + copy.damage_bonus = 20 + assert original.damage_bonus == 10 + assert copy.damage_bonus == 20 + + +def test_repr_includes_damage(): + """Test that repr includes the damage computed property.""" + stats = Stats(strength=10, damage_bonus=15) + repr_str = repr(stats) + + assert "DMG=" in repr_str diff --git a/docs/PHASE4_COMBAT_IMPLEMENTATION.md b/docs/PHASE4_COMBAT_IMPLEMENTATION.md new file mode 100644 index 0000000..5092ff5 --- /dev/null +++ b/docs/PHASE4_COMBAT_IMPLEMENTATION.md @@ -0,0 +1,864 @@ +# Phase 4: Combat & Progression Systems - Implementation Plan + +**Status:** In Progress - Week 2 Complete, Week 3 Next +**Timeline:** 4-5 weeks +**Last Updated:** November 27, 2025 +**Document Version:** 1.3 + +--- + +## 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 + +### Week 2: Inventory & Equipment - COMPLETE + +| Task | Description | Status | Tests | +|------|-------------|--------|-------| +| 2.1 | Item Data Models (Affixes) | ✅ Complete | 24 tests | +| 2.2 | Item Data Files (YAML) | ✅ Complete | - | +| 2.2.1 | Item Generator Service | ✅ Complete | 35 tests | +| 2.3 | Inventory Service | ✅ Complete | 24 tests | +| 2.4 | Inventory API Endpoints | ✅ Complete | 25 tests | +| 2.5 | Character Stats Calculation | ✅ Complete | 17 tests | +| 2.6 | Equipment-Combat Integration | ✅ Complete | 140 tests | +| 2.7 | Combat Loot Integration | ✅ Complete | 59 tests | + +**Files Created/Modified:** +- `/api/app/models/items.py` - Item with affix support, spell_power field +- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses +- `/api/app/models/stats.py` - spell_power_bonus, updated damage formula +- `/api/app/models/combat.py` - Combatant weapon properties +- `/api/app/services/item_generator.py` - Procedural item generation +- `/api/app/services/inventory_service.py` - Equipment management +- `/api/app/services/damage_calculator.py` - Refactored to use stats properties +- `/api/app/services/combat_service.py` - Equipment integration +- `/api/app/api/inventory.py` - REST API endpoints + +**Total Tests (Week 2):** 324+ 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 | See [`/PHASE4b.md`](/PHASE4b.md) +| **Phase 4C** | 3-4 days | NPC Shop | [`/PHASE4c.md`](/PHASE4c.md) + +**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 ✅ COMPLETE + +**Files:** `/api/app/models/combat.py`, `effects.py`, `abilities.py`, `stats.py` + +Verified: Combatant, CombatEncounter dataclasses, effect types (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD), stacking logic, YAML ability loading, serialization methods. + +--- + +#### Task 1.2: Implement Combat Service ✅ COMPLETE + +**File:** `/api/app/services/combat_service.py` + +Implemented: `CombatService` class with `initiate_combat()`, `process_action()`, initiative rolling, turn management, death checking, combat end detection. + +--- + +#### Task 1.3: Implement Damage Calculator ✅ COMPLETE + +**File:** `/api/app/services/damage_calculator.py` + +Implemented: `calculate_physical_damage()`, `calculate_magical_damage()`, `apply_damage()` with shield absorption. Physical formula: `weapon.damage + (STR/2) - defense`. 39 unit tests. + +--- + +#### Task 1.4: Implement Effect Processor ✅ COMPLETE + +**File:** `/api/app/models/effects.py` + +Implemented: `tick()` method for DOT/HOT damage/healing, duration tracking, stat modifiers via `get_effective_stats()`. + +--- + +#### Task 1.5: Implement Combat Actions ✅ COMPLETE + +**File:** `/api/app/services/combat_service.py` + +Implemented: `_execute_attack()`, `_execute_spell()`, `_execute_item()`, `_execute_defend()` with mana costs, cooldowns, effect application. + +--- + +#### Task 1.6: Combat API Endpoints ✅ COMPLETE + +**File:** `/api/app/api/combat.py` + +**Endpoints:** +- `POST /api/v1/combat/start` - Initiate combat +- `POST /api/v1/combat//action` - Take action +- `GET /api/v1/combat//state` - Get state +- `POST /api/v1/combat//flee` - Attempt flee +- `POST /api/v1/combat//enemy-turn` - Enemy AI +- `GET /api/v1/combat/enemies` - List templates (public) +- `GET /api/v1/combat/enemies/` - Enemy details (public) + +19 integration tests passing. + +--- + +#### Task 1.7: Manual API Testing ⏭️ SKIPPED + +Covered by 108 comprehensive automated tests. + +--- + +### Week 2: Inventory & Equipment System ✅ COMPLETE + +#### Task 2.1: Item Data Models ✅ COMPLETE + +**Files:** `/api/app/models/items.py`, `affixes.py`, `enums.py` + +Implemented: `Item` dataclass with affix support (`applied_affixes`, `base_template_id`, `generated_name`, `is_generated`), `Affix` model (PREFIX/SUFFIX types, MINOR/MAJOR/LEGENDARY tiers), `BaseItemTemplate` for procedural generation. 24 tests. + +--- + +#### Task 2.2: Item Data Files ✅ COMPLETE + +**Directory:** `/api/app/data/` + +Created: +- `base_items/weapons.yaml` - 13 weapon templates +- `base_items/armor.yaml` - 12 armor templates (cloth/leather/chain/plate) +- `affixes/prefixes.yaml` - 18 prefixes (elemental, material, quality, legendary) +- `affixes/suffixes.yaml` - 11 suffixes (stat bonuses, animal totems, legendary) +- `items/consumables/potions.yaml` - Health/mana potions (small/medium/large) + +--- + +#### Task 2.2.1: Item Generator Service ✅ COMPLETE + +**Files:** `/api/app/services/item_generator.py`, `affix_loader.py`, `base_item_loader.py` + +Implemented Diablo-style procedural generation: +- Affix distribution: COMMON/UNCOMMON (0), RARE (1), EPIC (2), LEGENDARY (3) +- Name generation: "Flaming Dagger of Strength" +- Tier weights by rarity (RARE: 80% MINOR, EPIC: 70% MAJOR, LEGENDARY: 50% LEGENDARY) +- Luck-influenced rarity rolling + +35 tests. + +--- + +#### Task 2.3: Implement Inventory Service ✅ COMPLETE + +**File:** `/api/app/services/inventory_service.py` + +Implemented: `add_item()`, `remove_item()`, `equip_item()`, `unequip_item()`, `use_consumable()`, `use_consumable_in_combat()`. Full object storage for generated items. Validation for slots, levels, item types. 24 tests. + +--- + +#### Task 2.4: Inventory API Endpoints ✅ COMPLETE + +**File:** `/api/app/api/inventory.py` + +**Endpoints:** +- `GET /api/v1/characters//inventory` - Get inventory + equipped +- `POST /api/v1/characters//inventory/equip` - Equip item +- `POST /api/v1/characters//inventory/unequip` - Unequip item +- `POST /api/v1/characters//inventory/use` - Use consumable +- `DELETE /api/v1/characters//inventory/` - Drop item + +25 tests. + +--- + +#### Task 2.5: Update Character Stats Calculation ✅ COMPLETE + +**Files:** `/api/app/models/stats.py`, `character.py` + +Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields to Stats. Updated `get_effective_stats()` to populate from equipped weapon/armor. 17 tests. + +--- + +#### Task 2.6: Equipment-Combat Integration ✅ COMPLETE + +**Files:** `stats.py`, `items.py`, `character.py`, `combat.py`, `combat_service.py`, `damage_calculator.py` + +Key changes: +- Damage scaling: `int(STR * 0.75) + damage_bonus` (was `STR // 2`) +- Added `spell_power` system for magical weapons +- Combatant weapon properties (crit_chance, crit_multiplier, elemental support) +- DamageCalculator uses `stats.damage` directly (removed `weapon_damage` param) + +140 tests. + +--- + +#### Task 2.7: Combat Loot Integration ✅ COMPLETE + +**Files:** `combat_loot_service.py`, `static_item_loader.py`, `app/models/enemy.py` + +Implemented hybrid loot system: +- Static drops (consumables, materials) via `StaticItemLoader` +- Procedural drops (equipment) via `ItemGenerator` +- Difficulty bonuses: EASY +0%, MEDIUM +5%, HARD +15%, BOSS +30% +- Enemy variants: goblin_scout, goblin_warrior, goblin_chieftain + +59 tests. + +--- + +### Week 3: Combat UI + +#### Task 3.1: Create Combat Template ✅ COMPLETE + +**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 ✅ COMPLETE + +**Objective:** Wire combat UI to API via HTMX + +**File:** `/public_web/app/views/game_views.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 ✅ COMPLETE + +**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 ✅ COMPLETE + +**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 - unable to test +- ✅ Effects apply and tick correctly +- [ ] Items can be used in combat - unable to test +- ✅ Defend action works +- ✅ Victory awards XP/gold/loot +- ✅ Defeat handling works +- ✅ Combat log readable +- ✅ HP/MP bars update +- ✅ Multiple enemies work - would like to update to allow the player to select which enemy to attack +- ✅ 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) +See [`/PHASE4b.md`](/PHASE4b.md) + +## Phase 4C: NPC Shop (Days 15-18) +See [`/PHASE4c.md`](/PHASE4c.md) + + +## 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/PHASE4b.md b/docs/PHASE4b.md new file mode 100644 index 0000000..9be4ba9 --- /dev/null +++ b/docs/PHASE4b.md @@ -0,0 +1,467 @@ + +## 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 + +--- diff --git a/docs/Phase4c.md b/docs/Phase4c.md new file mode 100644 index 0000000..0f6485d --- /dev/null +++ b/docs/Phase4c.md @@ -0,0 +1,513 @@ + +## 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 + +--- 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 diff --git a/public_web/app/__init__.py b/public_web/app/__init__.py index 9b22a14..20b8456 100644 --- a/public_web/app/__init__.py +++ b/public_web/app/__init__.py @@ -56,11 +56,13 @@ def create_app(): # Register blueprints from .views.auth_views import auth_bp from .views.character_views import character_bp + from .views.combat_views import combat_bp from .views.game_views import game_bp from .views.pages import pages_bp app.register_blueprint(auth_bp) app.register_blueprint(character_bp) + app.register_blueprint(combat_bp) app.register_blueprint(game_bp) app.register_blueprint(pages_bp) @@ -109,6 +111,6 @@ def create_app(): logger.error("internal_server_error", error=str(error)) return render_template('errors/500.html'), 500 - logger.info("flask_app_created", blueprints=["auth", "character", "game", "pages"]) + logger.info("flask_app_created", blueprints=["auth", "character", "combat", "game", "pages"]) return app diff --git a/public_web/app/views/combat_views.py b/public_web/app/views/combat_views.py new file mode 100644 index 0000000..0988c4e --- /dev/null +++ b/public_web/app/views/combat_views.py @@ -0,0 +1,574 @@ +""" +Combat Views + +Routes for combat UI. +""" + +from flask import Blueprint, render_template, request, redirect, url_for, make_response +import structlog + +from ..utils.api_client import get_api_client, APIError, APINotFoundError +from ..utils.auth import require_auth_web as require_auth + +logger = structlog.get_logger(__name__) + +combat_bp = Blueprint('combat', __name__, url_prefix='/combat') + + +@combat_bp.route('/') +@require_auth +def combat_view(session_id: str): + """ + Render the combat page for an active encounter. + + Displays the 3-column combat interface with: + - Left: Combatants (player + enemies) with HP/MP bars + - Center: Combat log + action buttons + - Right: Turn order + active effects + """ + client = get_api_client() + + try: + # Get combat state from API + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + + # Check if combat is still active + if not result.get('in_combat'): + # Combat ended - redirect to game play + return redirect(url_for('game.play_session', session_id=session_id)) + + encounter = result.get('encounter') or {} + combat_log = result.get('combat_log', []) + + # Get current turn combatant ID directly from API response + current_turn_id = encounter.get('current_turn') + + # Find if it's the player's turn + is_player_turn = False + player_combatant = None + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + if combatant.get('combatant_id') == current_turn_id: + is_player_turn = True + break + + # Format combat log entries for display + formatted_log = [] + for entry in combat_log: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + # Detect system messages + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'game/combat.html', + session_id=session_id, + encounter=encounter, + combat_log=formatted_log, + current_turn_id=current_turn_id, + is_player_turn=is_player_turn, + player_combatant=player_combatant + ) + + except APINotFoundError: + logger.warning("combat_not_found", session_id=session_id) + return render_template('errors/404.html', message="No active combat encounter"), 404 + except APIError as e: + logger.error("failed_to_load_combat", session_id=session_id, error=str(e)) + return render_template('errors/500.html', message=str(e)), 500 + + +@combat_bp.route('//action', methods=['POST']) +@require_auth +def combat_action(session_id: str): + """ + Execute a combat action (attack, defend, ability, item). + + Returns updated combat log entries. + """ + client = get_api_client() + + action_type = request.form.get('action_type', 'attack') + ability_id = request.form.get('ability_id') + item_id = request.form.get('item_id') + target_id = request.form.get('target_id') + + try: + # Build action payload + payload = { + 'action_type': action_type + } + + if ability_id: + payload['ability_id'] = ability_id + if item_id: + payload['item_id'] = item_id + if target_id: + payload['target_id'] = target_id + + # POST action to API + response = client.post(f'/api/v1/combat/{session_id}/action', payload) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'game/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'game/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0), + can_retry=result.get('can_retry', False) + ) + + # Format action result for log display + # API returns data directly in result, not nested under 'action_result' + log_entries = [] + + # Player action entry + player_entry = { + 'actor': 'You', + 'message': result.get('message', f'used {action_type}'), + 'type': 'player', + 'is_crit': False + } + + # Add damage info if present + damage_results = result.get('damage_results', []) + if damage_results: + for dmg in damage_results: + player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage') + player_entry['is_crit'] = dmg.get('is_critical', False) + if player_entry['is_crit']: + player_entry['type'] = 'crit' + + # Add healing info if present + if result.get('healing'): + player_entry['heal'] = result.get('healing') + player_entry['type'] = 'heal' + + log_entries.append(player_entry) + + # Add any effect entries + for effect in result.get('effects_applied', []): + # API may use "name" or "effect" key for the effect name + effect_name = effect.get('name') or effect.get('effect') or 'Unknown' + log_entries.append({ + 'actor': '', + 'message': effect.get('message', f'Effect applied: {effect_name}'), + 'type': 'system' + }) + + # Return log entries HTML + resp = make_response(render_template( + 'game/partials/combat_log.html', + combat_log=log_entries + )) + + # Trigger enemy turn if it's no longer player's turn + next_combatant = result.get('next_combatant_id') + if next_combatant and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) + return f''' +
+ Action failed: {e} +
+ ''', 500 + + +@combat_bp.route('//abilities') +@require_auth +def combat_abilities(session_id: str): + """Get abilities modal for combat.""" + client = get_api_client() + + try: + # Get combat state to get player's abilities + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + encounter = result.get('encounter', {}) + + # Find player combatant + player_combatant = None + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + break + + # Get abilities from player combatant or character + abilities = [] + if player_combatant: + ability_ids = player_combatant.get('abilities', []) + current_mp = player_combatant.get('current_mp', 0) + cooldowns = player_combatant.get('cooldowns', {}) + + # Fetch ability details (if API has ability endpoint) + for ability_id in ability_ids: + try: + ability_response = client.get(f'/api/v1/abilities/{ability_id}') + ability_data = ability_response.get('result', {}) + + # Check availability + mp_cost = ability_data.get('mp_cost', 0) + cooldown = cooldowns.get(ability_id, 0) + available = current_mp >= mp_cost and cooldown == 0 + + abilities.append({ + 'id': ability_id, + 'name': ability_data.get('name', ability_id), + 'description': ability_data.get('description', ''), + 'mp_cost': mp_cost, + 'cooldown': cooldown, + 'max_cooldown': ability_data.get('cooldown', 0), + 'damage_type': ability_data.get('damage_type'), + 'effect_type': ability_data.get('effect_type'), + 'available': available + }) + except (APINotFoundError, APIError): + # Ability not found, add basic entry + abilities.append({ + 'id': ability_id, + 'name': ability_id.replace('_', ' ').title(), + 'description': '', + 'mp_cost': 0, + 'cooldown': cooldowns.get(ability_id, 0), + 'max_cooldown': 0, + 'available': True + }) + + return render_template( + 'game/partials/ability_modal.html', + session_id=session_id, + abilities=abilities + ) + + except APIError as e: + logger.error("failed_to_load_abilities", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@combat_bp.route('//items') +@require_auth +def combat_items(session_id: str): + """ + Get combat items bottom sheet (consumables only). + + Returns a bottom sheet UI with only consumable items that can be used in combat. + """ + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + # Get character inventory - filter to consumables only + consumables = [] + if character_id: + try: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + # Filter to consumable items only + for item in inventory: + item_type = item.get('item_type', item.get('type', '')) + if item_type == 'consumable' or item.get('usable_in_combat', False): + consumables.append({ + 'item_id': item.get('item_id'), + 'name': item.get('name', 'Unknown Item'), + 'description': item.get('description', ''), + 'effects_on_use': item.get('effects_on_use', []), + 'rarity': item.get('rarity', 'common') + }) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e)) + + return render_template( + 'game/partials/combat_items_sheet.html', + session_id=session_id, + consumables=consumables, + has_consumables=len(consumables) > 0 + ) + + except APIError as e: + logger.error("failed_to_load_items", session_id=session_id, error=str(e)) + return f''' +
+
+
+

Use Item

+ +
+
+
Failed to load items: {e}
+
+
+
+ ''' + + +@combat_bp.route('//items//detail') +@require_auth +def combat_item_detail(session_id: str, item_id: str): + """Get item detail for combat bottom sheet.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + item = None + if character_id: + # Get inventory and find the item + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for inv_item in inventory: + if inv_item.get('item_id') == item_id: + item = inv_item + break + + if not item: + return '

Item not found

', 404 + + # Format effect description + effect_desc = item.get('description', 'Use this item') + effects = item.get('effects_on_use', []) + if effects: + effect_parts = [] + for effect in effects: + if effect.get('stat') == 'hp': + effect_parts.append(f"Restores {effect.get('value', 0)} HP") + elif effect.get('stat') == 'mp': + effect_parts.append(f"Restores {effect.get('value', 0)} MP") + elif effect.get('name'): + effect_parts.append(effect.get('name')) + if effect_parts: + effect_desc = ', '.join(effect_parts) + + return f''' +
+
{item.get('name', 'Item')}
+
{effect_desc}
+
+ + ''' + + except APIError as e: + logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e)) + return f'

Failed to load item: {e}

', 500 + + +@combat_bp.route('//flee', methods=['POST']) +@require_auth +def combat_flee(session_id: str): + """Attempt to flee from combat.""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/flee', {}) + result = response.get('result', {}) + + if result.get('success'): + # Flee successful - use HX-Redirect for HTMX + resp = make_response(f''' +
+ {result.get('message', 'You fled from combat!')} +
+ ''') + resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id) + return resp + else: + # Flee failed - return log entry, trigger enemy turn + resp = make_response(f''' +
+ {result.get('message', 'Failed to flee!')} +
+ ''') + # Failed flee consumes turn, so trigger enemy turn if needed + if not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + return resp + + except APIError as e: + logger.error("flee_failed", session_id=session_id, error=str(e)) + return f''' +
+ Flee failed: {e} +
+ ''', 500 + + +@combat_bp.route('//enemy-turn', methods=['POST']) +@require_auth +def combat_enemy_turn(session_id: str): + """Execute enemy turn and return result.""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {}) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'game/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'game/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0), + can_retry=result.get('can_retry', False) + ) + + # Format enemy action for log + # API returns ActionResult directly in result, not nested under action_result + log_entries = [{ + 'actor': 'Enemy', + 'message': result.get('message', 'attacks'), + 'type': 'enemy', + 'is_crit': False + }] + + # Add damage info - API returns total_damage, not damage + damage_results = result.get('damage_results', []) + if damage_results: + log_entries[0]['damage'] = damage_results[0].get('total_damage') + log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False) + + # Check if it's still enemy turn (multiple enemies) + resp = make_response(render_template( + 'game/partials/combat_log.html', + combat_log=log_entries + )) + + # If next combatant is also an enemy, trigger another enemy turn + if result.get('next_combatant_id') and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("enemy_turn_failed", session_id=session_id, error=str(e)) + return f''' +
+ Enemy turn error: {e} +
+ ''', 500 + + +@combat_bp.route('//log') +@require_auth +def combat_log(session_id: str): + """Get current combat log.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + combat_log_data = result.get('combat_log', []) + + # Format log entries + formatted_log = [] + for entry in combat_log_data: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'game/partials/combat_log.html', + combat_log=formatted_log + ) + + except APIError as e: + logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e)) + return '
Failed to load combat log
', 500 + + +@combat_bp.route('//results') +@require_auth +def combat_results(session_id: str): + """Display combat results (victory/defeat).""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/results') + results = response.get('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("failed_to_load_combat_results", session_id=session_id, error=str(e)) + return redirect(url_for('game.play_session', session_id=session_id)) diff --git a/public_web/app/views/dev.py b/public_web/app/views/dev.py index 789fe94..e4b979d 100644 --- a/public_web/app/views/dev.py +++ b/public_web/app/views/dev.py @@ -380,3 +380,652 @@ def do_travel(session_id: str): except APIError as e: logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e)) return f'
Travel failed: {e}
', 500 + + +# ===== Combat Test Endpoints ===== + +@dev_bp.route('/combat') +@require_auth +def combat_hub(): + """Combat testing hub - select character and enemies to start combat.""" + client = get_api_client() + + try: + # Get user's characters + characters_response = client.get('/api/v1/characters') + result = characters_response.get('result', {}) + characters = result.get('characters', []) + + # Get available enemy templates + enemies = [] + try: + enemies_response = client.get('/api/v1/combat/enemies') + enemies = enemies_response.get('result', {}).get('enemies', []) + except (APINotFoundError, APIError): + # Enemies endpoint may not exist yet + pass + + # Get all sessions to map characters to their sessions + sessions_in_combat = [] + character_session_map = {} # character_id -> session_id + try: + sessions_response = client.get('/api/v1/sessions') + all_sessions = sessions_response.get('result', []) + for session in all_sessions: + # Map character to session (for dropdown) + char_id = session.get('character_id') + if char_id: + character_session_map[char_id] = session.get('session_id') + + # Track sessions in combat (for resume list) + if session.get('in_combat') or session.get('game_state', {}).get('in_combat'): + sessions_in_combat.append(session) + except (APINotFoundError, APIError): + pass + + # Add session_id to each character for the template + for char in characters: + char['session_id'] = character_session_map.get(char.get('character_id')) + + return render_template( + 'dev/combat.html', + characters=characters, + enemies=enemies, + sessions_in_combat=sessions_in_combat + ) + except APIError as e: + logger.error("failed_to_load_combat_hub", error=str(e)) + return render_template('dev/combat.html', characters=[], enemies=[], sessions_in_combat=[], error=str(e)) + + +@dev_bp.route('/combat/start', methods=['POST']) +@require_auth +def start_combat(): + """Start a new combat encounter - returns redirect to combat session.""" + client = get_api_client() + + session_id = request.form.get('session_id') + enemy_ids = request.form.getlist('enemy_ids') + + logger.info("start_combat called", + session_id=session_id, + enemy_ids=enemy_ids, + form_data=dict(request.form)) + + if not session_id: + return '
No session selected
', 400 + + if not enemy_ids: + return '
No enemies selected
', 400 + + try: + response = client.post('/api/v1/combat/start', { + 'session_id': session_id, + 'enemy_ids': enemy_ids + }) + result = response.get('result', {}) + + # Return redirect script to combat session page + return f''' + +
Combat started! Redirecting...
+ ''' + except APIError as e: + logger.error("failed_to_start_combat", session_id=session_id, error=str(e)) + return f'
Failed to start combat: {e}
', 500 + + +@dev_bp.route('/combat/session/') +@require_auth +def combat_session(session_id: str): + """Combat session debug interface - full 3-column layout.""" + client = get_api_client() + + try: + # Get combat state from API + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + + # Check if combat is still active + if not result.get('in_combat'): + # Combat ended - redirect to combat index + return render_template('dev/combat.html', + message="Combat has ended. Start a new combat to continue.") + + encounter = result.get('encounter') or {} + combat_log = result.get('combat_log', []) + + # Get current turn combatant ID directly from API response + current_turn_id = encounter.get('current_turn') + turn_order = encounter.get('turn_order', []) + + # Find player and determine if it's player's turn + is_player_turn = False + player_combatant = None + enemy_combatants = [] + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + if combatant.get('combatant_id') == current_turn_id: + is_player_turn = True + else: + enemy_combatants.append(combatant) + + # Format combat log entries for display + formatted_log = [] + for entry in combat_log: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + # Detect system messages + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'dev/combat_session.html', + session_id=session_id, + encounter=encounter, + combat_log=formatted_log, + current_turn_id=current_turn_id, + is_player_turn=is_player_turn, + player_combatant=player_combatant, + enemy_combatants=enemy_combatants, + turn_order=turn_order, + raw_state=result + ) + + except APINotFoundError: + logger.warning("combat_not_found", session_id=session_id) + return render_template('dev/combat.html', error=f"No active combat for session {session_id}"), 404 + except APIError as e: + logger.error("failed_to_load_combat_session", session_id=session_id, error=str(e)) + return render_template('dev/combat.html', error=str(e)), 500 + + +@dev_bp.route('/combat//state') +@require_auth +def combat_state(session_id: str): + """Get combat state partial - returns refreshable state panel.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + + # Check if combat is still active + if not result.get('in_combat'): + return '

Combat Ended

No active combat.

' + + encounter = result.get('encounter') or {} + + # Get current turn combatant ID directly from API response + current_turn_id = encounter.get('current_turn') + + # Separate player and enemies + player_combatant = None + enemy_combatants = [] + is_player_turn = False + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + if combatant.get('combatant_id') == current_turn_id: + is_player_turn = True + else: + enemy_combatants.append(combatant) + + return render_template( + 'dev/partials/combat_state.html', + session_id=session_id, + encounter=encounter, + player_combatant=player_combatant, + enemy_combatants=enemy_combatants, + current_turn_id=current_turn_id, + is_player_turn=is_player_turn + ) + + except APIError as e: + logger.error("failed_to_get_combat_state", session_id=session_id, error=str(e)) + return f'
Failed to load state: {e}
', 500 + + +@dev_bp.route('/combat//action', methods=['POST']) +@require_auth +def combat_action(session_id: str): + """Execute a combat action - returns log entry HTML.""" + client = get_api_client() + + action_type = request.form.get('action_type', 'attack') + ability_id = request.form.get('ability_id') + item_id = request.form.get('item_id') + target_id = request.form.get('target_id') + + try: + payload = {'action_type': action_type} + if ability_id: + payload['ability_id'] = ability_id + if item_id: + payload['item_id'] = item_id + if target_id: + payload['target_id'] = target_id + + response = client.post(f'/api/v1/combat/{session_id}/action', payload) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'dev/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'dev/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0) + ) + + # Format action result for log + # API returns data directly in result, not nested under 'action_result' + log_entries = [] + + player_entry = { + 'actor': 'You', + 'message': result.get('message', f'used {action_type}'), + 'type': 'player', + 'is_crit': False + } + + damage_results = result.get('damage_results', []) + if damage_results: + for dmg in damage_results: + player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage') + player_entry['is_crit'] = dmg.get('is_critical', False) + if player_entry['is_crit']: + player_entry['type'] = 'crit' + + if result.get('healing'): + player_entry['heal'] = result.get('healing') + player_entry['type'] = 'heal' + + log_entries.append(player_entry) + + for effect in result.get('effects_applied', []): + log_entries.append({ + 'actor': '', + 'message': effect.get('message', f'Effect applied: {effect.get("name")}'), + 'type': 'system' + }) + + # Return log entries with optional enemy turn trigger + from flask import make_response + resp = make_response(render_template( + 'dev/partials/combat_debug_log.html', + combat_log=log_entries + )) + + # Trigger enemy turn if needed + if result.get('next_combatant_id') and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) + return f''' +
+ Action failed: {e} +
+ ''', 500 + + +@dev_bp.route('/combat//enemy-turn', methods=['POST']) +@require_auth +def combat_enemy_turn(session_id: str): + """Execute enemy turn - returns log entry HTML.""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {}) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'dev/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'dev/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0) + ) + + # Format enemy action for log + # The API returns the action result directly with a complete message + damage_results = result.get('damage_results', []) + is_crit = damage_results[0].get('is_critical', False) if damage_results else False + + log_entries = [{ + 'actor': '', # Message already contains the actor name + 'message': result.get('message', 'Enemy attacks!'), + 'type': 'crit' if is_crit else 'enemy', + 'is_crit': is_crit, + 'damage': damage_results[0].get('total_damage') if damage_results else None + }] + + from flask import make_response + resp = make_response(render_template( + 'dev/partials/combat_debug_log.html', + combat_log=log_entries + )) + + # Trigger another enemy turn if needed + if result.get('next_combatant_id') and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("enemy_turn_failed", session_id=session_id, error=str(e)) + return f''' +
+ Enemy turn error: {e} +
+ ''', 500 + + +@dev_bp.route('/combat//abilities') +@require_auth +def combat_abilities(session_id: str): + """Get abilities modal for combat.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + encounter = result.get('encounter', {}) + + player_combatant = None + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + break + + abilities = [] + if player_combatant: + ability_ids = player_combatant.get('abilities', []) + current_mp = player_combatant.get('current_mp', 0) + cooldowns = player_combatant.get('cooldowns', {}) + + for ability_id in ability_ids: + try: + ability_response = client.get(f'/api/v1/abilities/{ability_id}') + ability_data = ability_response.get('result', {}) + + mp_cost = ability_data.get('mp_cost', 0) + cooldown = cooldowns.get(ability_id, 0) + available = current_mp >= mp_cost and cooldown == 0 + + abilities.append({ + 'id': ability_id, + 'name': ability_data.get('name', ability_id), + 'description': ability_data.get('description', ''), + 'mp_cost': mp_cost, + 'cooldown': cooldown, + 'max_cooldown': ability_data.get('cooldown', 0), + 'damage_type': ability_data.get('damage_type'), + 'effect_type': ability_data.get('effect_type'), + 'available': available + }) + except (APINotFoundError, APIError): + abilities.append({ + 'id': ability_id, + 'name': ability_id.replace('_', ' ').title(), + 'description': '', + 'mp_cost': 0, + 'cooldown': cooldowns.get(ability_id, 0), + 'max_cooldown': 0, + 'available': True + }) + + return render_template( + 'dev/partials/ability_modal.html', + session_id=session_id, + abilities=abilities + ) + + except APIError as e: + logger.error("failed_to_load_abilities", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@dev_bp.route('/combat//items') +@require_auth +def combat_items(session_id: str): + """Get combat items bottom sheet (consumables only).""" + client = get_api_client() + + try: + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + consumables = [] + if character_id: + try: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for item in inventory: + item_type = item.get('item_type', item.get('type', '')) + if item_type == 'consumable' or item.get('usable_in_combat', False): + consumables.append({ + 'item_id': item.get('item_id'), + 'name': item.get('name', 'Unknown Item'), + 'description': item.get('description', ''), + 'effects_on_use': item.get('effects_on_use', []), + 'rarity': item.get('rarity', 'common') + }) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e)) + + return render_template( + 'dev/partials/combat_items_sheet.html', + session_id=session_id, + consumables=consumables, + has_consumables=len(consumables) > 0 + ) + + except APIError as e: + logger.error("failed_to_load_items", session_id=session_id, error=str(e)) + return f''' +
+
+

Use Item

+ +
+
+
Failed to load items: {e}
+
+
+
+ ''' + + +@dev_bp.route('/combat//items//detail') +@require_auth +def combat_item_detail(session_id: str, item_id: str): + """Get item detail for combat bottom sheet.""" + client = get_api_client() + + try: + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + item = None + if character_id: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for inv_item in inventory: + if inv_item.get('item_id') == item_id: + item = inv_item + break + + if not item: + return '

Item not found

', 404 + + effect_desc = item.get('description', 'Use this item') + effects = item.get('effects_on_use', []) + if effects: + effect_parts = [] + for effect in effects: + if effect.get('stat') == 'hp': + effect_parts.append(f"Restores {effect.get('value', 0)} HP") + elif effect.get('stat') == 'mp': + effect_parts.append(f"Restores {effect.get('value', 0)} MP") + elif effect.get('name'): + effect_parts.append(effect.get('name')) + if effect_parts: + effect_desc = ', '.join(effect_parts) + + return f''' +
+
{item.get('name', 'Item')}
+
{effect_desc}
+
+ + ''' + + except APIError as e: + logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e)) + return f'

Failed to load item: {e}

', 500 + + +@dev_bp.route('/combat//end', methods=['POST']) +@require_auth +def force_end_combat(session_id: str): + """Force end combat (debug action).""" + client = get_api_client() + + victory = request.form.get('victory', 'true').lower() == 'true' + + try: + response = client.post(f'/api/v1/combat/{session_id}/end', {'victory': victory}) + result = response.get('result', {}) + + if victory: + return render_template( + 'dev/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + else: + return render_template( + 'dev/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0) + ) + + except APIError as e: + logger.error("failed_to_end_combat", session_id=session_id, error=str(e)) + return f'
Failed to end combat: {e}
', 500 + + +@dev_bp.route('/combat//reset-hp-mp', methods=['POST']) +@require_auth +def reset_hp_mp(session_id: str): + """Reset player HP and MP to full (debug action).""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/debug/reset-hp-mp', {}) + result = response.get('result', {}) + + return f''' +
+ HP and MP restored to full! (HP: {result.get('current_hp', '?')}/{result.get('max_hp', '?')}, MP: {result.get('current_mp', '?')}/{result.get('max_mp', '?')}) +
+ ''' + + except APIError as e: + logger.error("failed_to_reset_hp_mp", session_id=session_id, error=str(e)) + return f''' +
+ Failed to reset HP/MP: {e} +
+ ''', 500 + + +@dev_bp.route('/combat//log') +@require_auth +def combat_log(session_id: str): + """Get full combat log.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + combat_log_data = result.get('combat_log', []) + + formatted_log = [] + for entry in combat_log_data: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'dev/partials/combat_debug_log.html', + combat_log=formatted_log + ) + + except APIError as e: + logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e)) + return '
Failed to load combat log
', 500 diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index 8801aea..c457972 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -7,7 +7,7 @@ Provides the main gameplay interface with 3-column layout: - Right: Accordions for history, quests, NPCs, map """ -from flask import Blueprint, render_template, request +from flask import Blueprint, render_template, request, redirect, url_for import structlog from ..utils.api_client import get_api_client, APIError, APINotFoundError @@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play') DEFAULT_ACTIONS = { 'free': [ {'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']}, - {'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']}, {'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2}, {'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3} ], @@ -718,6 +717,243 @@ def do_travel(session_id: str): return f'
Travel failed: {e}
', 500 +@game_bp.route('/session//monster-modal') +@require_auth +def monster_modal(session_id: str): + """ + Get monster selection modal with encounter options. + + Fetches random encounter groups appropriate for the current location + and character level from the API. + """ + client = get_api_client() + + try: + # Get encounter options from API + response = client.get(f'/api/v1/combat/encounters?session_id={session_id}') + result = response.get('result', {}) + + location_name = result.get('location_name', 'Unknown Area') + encounters = result.get('encounters', []) + + return render_template( + 'game/partials/monster_modal.html', + session_id=session_id, + location_name=location_name, + encounters=encounters + ) + + except APINotFoundError as e: + # No enemies found for this location + return render_template( + 'game/partials/monster_modal.html', + session_id=session_id, + location_name='this area', + encounters=[] + ) + except APIError as e: + logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@game_bp.route('/session//combat/start', methods=['POST']) +@require_auth +def start_combat(session_id: str): + """ + Start combat with selected enemies. + + Called when player selects an encounter from the monster modal. + Initiates combat via API and redirects to combat UI. + + If there's already an active combat session, shows a conflict modal + allowing the user to resume or abandon the existing combat. + """ + from flask import make_response + + client = get_api_client() + + # Get enemy_ids from request + # HTMX hx-vals sends as form data (not JSON), where arrays become multiple values + if request.is_json: + enemy_ids = request.json.get('enemy_ids', []) + else: + # Form data: array values come as multiple entries with the same key + enemy_ids = request.form.getlist('enemy_ids') + + if not enemy_ids: + return '
No enemies selected.
', 400 + + try: + # Start combat via API + response = client.post('/api/v1/combat/start', { + 'session_id': session_id, + 'enemy_ids': enemy_ids + }) + result = response.get('result', {}) + encounter_id = result.get('encounter_id') + + if not encounter_id: + logger.error("combat_start_no_encounter_id", session_id=session_id) + return '
Failed to start combat - no encounter ID returned.
', 500 + + logger.info("combat_started_from_modal", + session_id=session_id, + encounter_id=encounter_id, + enemy_count=len(enemy_ids)) + + # Close modal and redirect to combat page + resp = make_response('') + resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id) + return resp + + except APIError as e: + # Check if this is an "already in combat" error + error_str = str(e) + if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str: + # Fetch existing combat info and show conflict modal + try: + check_response = client.get(f'/api/v1/combat/{session_id}/check') + combat_info = check_response.get('result', {}) + + if combat_info.get('has_active_combat'): + return render_template( + 'game/partials/combat_conflict_modal.html', + session_id=session_id, + combat_info=combat_info, + pending_enemy_ids=enemy_ids + ) + except APIError: + pass # Fall through to generic error + + logger.error("failed_to_start_combat", session_id=session_id, error=str(e)) + return f'
Failed to start combat: {e}
', 500 + + +@game_bp.route('/session//combat/check', methods=['GET']) +@require_auth +def check_combat_status(session_id: str): + """ + Check if the session has an active combat. + + Returns JSON with combat status that can be used by HTMX + to decide whether to show the monster modal or conflict modal. + """ + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/check') + result = response.get('result', {}) + return result + + except APIError as e: + logger.error("failed_to_check_combat", session_id=session_id, error=str(e)) + return {'has_active_combat': False, 'error': str(e)} + + +@game_bp.route('/session//combat/abandon', methods=['POST']) +@require_auth +def abandon_combat(session_id: str): + """ + Abandon an existing combat session. + + Called when player chooses to abandon their current combat + in order to start a fresh one. + """ + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/abandon', {}) + result = response.get('result', {}) + + if result.get('success'): + logger.info("combat_abandoned", session_id=session_id) + # Return success - the frontend will then try to start new combat + return render_template( + 'game/partials/combat_abandoned_success.html', + session_id=session_id, + message="Combat abandoned. You can now start a new encounter." + ) + else: + return '
No active combat to abandon.
', 400 + + except APIError as e: + logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e)) + return f'
Failed to abandon combat: {e}
', 500 + + +@game_bp.route('/session//combat/abandon-and-start', methods=['POST']) +@require_auth +def abandon_and_start_combat(session_id: str): + """ + Abandon existing combat and start a new one in a single action. + + This is a convenience endpoint that combines abandon + start + for a smoother user experience in the conflict modal. + """ + from flask import make_response + + client = get_api_client() + + # Get enemy_ids from request + if request.is_json: + enemy_ids = request.json.get('enemy_ids', []) + else: + enemy_ids = request.form.getlist('enemy_ids') + + if not enemy_ids: + return '
No enemies selected.
', 400 + + try: + # First abandon the existing combat + abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {}) + abandon_result = abandon_response.get('result', {}) + + if not abandon_result.get('success'): + # No combat to abandon, but that's fine - proceed with start + logger.info("no_combat_to_abandon", session_id=session_id) + + # Now start the new combat + start_response = client.post('/api/v1/combat/start', { + 'session_id': session_id, + 'enemy_ids': enemy_ids + }) + result = start_response.get('result', {}) + encounter_id = result.get('encounter_id') + + if not encounter_id: + logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id) + return '
Failed to start combat - no encounter ID returned.
', 500 + + logger.info("combat_started_after_abandon", + session_id=session_id, + encounter_id=encounter_id, + enemy_count=len(enemy_ids)) + + # Close modal and redirect to combat page + resp = make_response('') + resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id) + return resp + + except APIError as e: + logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e)) + return f'
Failed to start combat: {e}
', 500 + + @game_bp.route('/session//npc/') @require_auth def npc_chat_page(session_id: str, npc_id: str): @@ -866,6 +1102,220 @@ def npc_chat_history(session_id: str, npc_id: str): return '
Failed to load history
', 500 +# ===== Inventory Routes ===== + +@game_bp.route('/session//inventory-modal') +@require_auth +def inventory_modal(session_id: str): + """ + Get inventory modal with all items. + + Supports filtering by item type via ?filter= parameter. + """ + client = get_api_client() + filter_type = request.args.get('filter', 'all') + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + inventory = [] + equipped = {} + gold = 0 + inventory_count = 0 + inventory_max = 100 + + if character_id: + try: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + equipped = inv_data.get('equipped', {}) + inventory_count = inv_data.get('inventory_count', len(inventory)) + inventory_max = inv_data.get('max_inventory', 100) + + # Get gold from character + char_response = client.get(f'/api/v1/characters/{character_id}') + char_data = char_response.get('result', {}) + gold = char_data.get('gold', 0) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_inventory", character_id=character_id, error=str(e)) + + # Filter inventory by type if specified + if filter_type != 'all': + inventory = [item for item in inventory if item.get('item_type') == filter_type] + + return render_template( + 'game/partials/inventory_modal.html', + session_id=session_id, + inventory=inventory, + equipped=equipped, + gold=gold, + inventory_count=inventory_count, + inventory_max=inventory_max, + filter=filter_type + ) + + except APIError as e: + logger.error("failed_to_load_inventory_modal", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@game_bp.route('/session//inventory/item/') +@require_auth +def inventory_item_detail(session_id: str, item_id: str): + """Get item detail partial for HTMX swap.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + item = None + if character_id: + # Get inventory and find the item + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for inv_item in inventory: + if inv_item.get('item_id') == item_id: + item = inv_item + break + + if not item: + return '
Item not found
', 404 + + # Determine suggested slot for equipment + suggested_slot = None + item_type = item.get('item_type', '') + if item_type == 'weapon': + suggested_slot = 'weapon' + elif item_type == 'armor': + # Could be any armor slot - default to chest + suggested_slot = 'chest' + + return render_template( + 'game/partials/inventory_item_detail.html', + session_id=session_id, + item=item, + suggested_slot=suggested_slot + ) + + except APIError as e: + logger.error("failed_to_load_item_detail", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to load item: {e}
', 500 + + +@game_bp.route('/session//inventory/use', methods=['POST']) +@require_auth +def inventory_use(session_id: str): + """Use a consumable item.""" + client = get_api_client() + item_id = request.form.get('item_id') + + if not item_id: + return '
No item selected
', 400 + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return '
No character found
', 400 + + # Use the item via API + client.post(f'/api/v1/characters/{character_id}/inventory/use', { + 'item_id': item_id + }) + + # Return updated character panel + return redirect(url_for('game.character_panel', session_id=session_id)) + + except APIError as e: + logger.error("failed_to_use_item", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to use item: {e}
', 500 + + +@game_bp.route('/session//inventory/equip', methods=['POST']) +@require_auth +def inventory_equip(session_id: str): + """Equip an item to a slot.""" + client = get_api_client() + item_id = request.form.get('item_id') + slot = request.form.get('slot') + + if not item_id: + return '
No item selected
', 400 + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return '
No character found
', 400 + + # Equip the item via API + payload = {'item_id': item_id} + if slot: + payload['slot'] = slot + + client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload) + + # Return updated character panel + return redirect(url_for('game.character_panel', session_id=session_id)) + + except APIError as e: + logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to equip item: {e}
', 500 + + +@game_bp.route('/session//inventory/', methods=['DELETE']) +@require_auth +def inventory_drop(session_id: str, item_id: str): + """Drop (delete) an item from inventory.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return '
No character found
', 400 + + # Delete the item via API + client.delete(f'/api/v1/characters/{character_id}/inventory/{item_id}') + + # Return updated inventory modal + return redirect(url_for('game.inventory_modal', session_id=session_id)) + + except APIError as e: + logger.error("failed_to_drop_item", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to drop item: {e}
', 500 + + @game_bp.route('/session//npc//talk', methods=['POST']) @require_auth def talk_to_npc(session_id: str, npc_id: str): diff --git a/public_web/static/css/combat.css b/public_web/static/css/combat.css new file mode 100644 index 0000000..278c7c2 --- /dev/null +++ b/public_web/static/css/combat.css @@ -0,0 +1,1182 @@ +/** + * Code of Conquest - Combat Screen Stylesheet + * Turn-based combat interface with 3-column layout + */ + +/* ===== COMBAT SCREEN VARIABLES ===== */ +:root { + /* Combat-specific colors */ + --combat-player: #3b82f6; /* Blue for player actions */ + --combat-enemy: #ef4444; /* Red for enemy actions */ + --combat-crit: var(--accent-gold); /* Gold for critical hits */ + --combat-system: var(--text-muted); /* Gray for system messages */ + --combat-heal: var(--accent-green); /* Green for healing */ + + /* Resource bar colors */ + --hp-bar-fill: #ef4444; /* Red for HP */ + --mp-bar-fill: #3b82f6; /* Blue for MP */ + + /* Combat panel sizing */ + --combat-sidebar-width: 280px; + --combat-header-height: 60px; + --combat-actions-height: 120px; +} + +/* ===== COMBAT PAGE LAYOUT ===== */ +.combat-page main { + padding: 0; + align-items: stretch; + justify-content: stretch; +} + +.combat-container { + display: grid; + grid-template-columns: var(--combat-sidebar-width) 1fr var(--combat-sidebar-width); + grid-template-rows: auto 1fr; + gap: 1rem; + height: calc(100vh - 140px); + padding: 1rem; + max-width: 2400px; + margin: 0 auto; +} + +/* ===== COMBAT HEADER ===== */ +.combat-header { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.25rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-ornate); + border-radius: 8px; +} + +.combat-title { + font-family: var(--font-heading); + font-size: var(--text-xl); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 2px; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.combat-title-icon { + font-size: var(--text-2xl); +} + +.combat-round { + display: flex; + align-items: center; + gap: 1rem; +} + +.round-counter { + font-family: var(--font-heading); + font-size: var(--text-lg); + color: var(--text-primary); +} + +.round-counter strong { + color: var(--accent-gold); +} + +.turn-indicator { + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.turn-indicator--player { + background: rgba(59, 130, 246, 0.2); + color: var(--combat-player); + border: 1px solid var(--combat-player); +} + +.turn-indicator--enemy { + background: rgba(239, 68, 68, 0.2); + color: var(--combat-enemy); + border: 1px solid var(--combat-enemy); +} + +/* ===== COMBATANT PANEL (Left Column) ===== */ +.combatant-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.combatant-section { + padding: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.combatant-section:last-child { + border-bottom: none; +} + +.combatant-section-title { + font-family: var(--font-heading); + font-size: var(--text-xs); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.75rem; +} + +/* Combatant Card */ +.combatant-card { + padding: 0.75rem; + background: var(--bg-input); + border-radius: 6px; + margin-bottom: 0.5rem; + border-left: 3px solid transparent; + transition: all 0.2s ease; +} + +.combatant-card:last-child { + margin-bottom: 0; +} + +.combatant-card--player { + border-left-color: var(--combat-player); +} + +.combatant-card--enemy { + border-left-color: var(--combat-enemy); +} + +.combatant-card--active { + background: var(--bg-tertiary); + box-shadow: 0 0 10px rgba(243, 156, 18, 0.2); +} + +.combatant-card--defeated { + opacity: 0.5; +} + +.combatant-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.combatant-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); +} + +.combatant-level { + font-size: var(--text-xs); + color: var(--text-muted); +} + +/* Resource Bars (HP/MP) */ +.combatant-resources { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.resource-bar { + position: relative; +} + +.resource-bar-label { + display: flex; + justify-content: space-between; + font-size: var(--text-xs); + margin-bottom: 0.125rem; +} + +.resource-bar-name { + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.resource-bar-value { + color: var(--text-primary); + font-weight: 600; +} + +.resource-bar-track { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; +} + +.resource-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.resource-bar--hp .resource-bar-fill { + background: linear-gradient(90deg, var(--hp-bar-fill), #f87171); +} + +.resource-bar--mp .resource-bar-fill { + background: linear-gradient(90deg, var(--mp-bar-fill), #60a5fa); +} + +/* Low HP warning animation */ +.resource-bar--hp.low .resource-bar-fill { + animation: pulse-hp 1s ease-in-out infinite; +} + +@keyframes pulse-hp { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* ===== COMBAT MAIN (Center Column) ===== */ +.combat-main { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Combat Log */ +.combat-log { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.combat-log__entry { + padding: 0.625rem 0.875rem; + background: var(--bg-input); + border-radius: 6px; + font-size: var(--text-sm); + line-height: 1.5; + border-left: 3px solid transparent; + animation: slideInLog 0.2s ease; +} + +@keyframes slideInLog { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.combat-log__entry--player { + border-left-color: var(--combat-player); + background: rgba(59, 130, 246, 0.1); +} + +.combat-log__entry--enemy { + border-left-color: var(--combat-enemy); + background: rgba(239, 68, 68, 0.1); +} + +.combat-log__entry--crit { + border-left-color: var(--combat-crit); + background: rgba(243, 156, 18, 0.15); +} + +.combat-log__entry--system { + border-left-color: var(--combat-system); + background: var(--bg-tertiary); + font-style: italic; + color: var(--text-secondary); +} + +.combat-log__entry--heal { + border-left-color: var(--combat-heal); + background: rgba(39, 174, 96, 0.1); +} + +.log-actor { + font-weight: 600; + color: var(--text-primary); +} + +.log-message { + color: var(--text-secondary); +} + +.log-damage { + font-weight: 700; + color: var(--combat-enemy); + margin-left: 0.25rem; +} + +.log-damage--crit { + color: var(--combat-crit); + font-size: var(--text-base); +} + +.log-heal { + font-weight: 700; + color: var(--combat-heal); + margin-left: 0.25rem; +} + +/* Empty log state */ +.combat-log__empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 2rem; +} + +/* Combat Actions */ +.combat-actions { + padding: 1rem; + background: var(--bg-tertiary); + border-top: 1px solid var(--border-primary); +} + +.combat-actions__grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.5rem; +} + +.combat-action-btn { + padding: 0.75rem 0.5rem; + font-family: var(--font-heading); + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 2px solid; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.combat-action-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.combat-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.combat-action-btn__icon { + font-size: var(--text-xl); +} + +/* Action button variants */ +.combat-action-btn--attack { + background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); + border-color: var(--accent-red); + color: white; +} + +.combat-action-btn--attack:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(239, 68, 68, 0.4); +} + +.combat-action-btn--ability { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + border-color: #8b5cf6; + color: white; +} + +.combat-action-btn--ability:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); +} + +.combat-action-btn--item { + background: linear-gradient(135deg, var(--accent-green) 0%, #16a34a 100%); + border-color: var(--accent-green); + color: white; +} + +.combat-action-btn--item:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(39, 174, 96, 0.4); +} + +.combat-action-btn--defend { + background: linear-gradient(135deg, var(--accent-blue) 0%, #2563eb 100%); + border-color: var(--accent-blue); + color: white; +} + +.combat-action-btn--defend:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(52, 152, 219, 0.4); +} + +.combat-action-btn--flee { + background: transparent; + border-color: var(--border-primary); + color: var(--text-secondary); +} + +.combat-action-btn--flee:hover:not(:disabled) { + border-color: var(--text-muted); + color: var(--text-primary); +} + +/* Disabled state message */ +.combat-actions__disabled-message { + text-align: center; + padding: 0.5rem; + font-size: var(--text-sm); + color: var(--text-muted); + font-style: italic; +} + +/* ===== COMBAT SIDEBAR (Right Column) ===== */ +.combat-sidebar { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Turn Order */ +.turn-order { + padding: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.turn-order__title { + font-family: var(--font-heading); + font-size: var(--text-xs); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.75rem; +} + +.turn-order__list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.turn-order__item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + background: var(--bg-input); + border-radius: 4px; + font-size: var(--text-sm); + border-left: 3px solid transparent; + transition: all 0.2s ease; +} + +.turn-order__item--active { + background: var(--bg-tertiary); + border-left-color: var(--accent-gold); + box-shadow: 0 0 8px rgba(243, 156, 18, 0.2); +} + +.turn-order__item--player { + border-left-color: var(--combat-player); +} + +.turn-order__item--enemy { + border-left-color: var(--combat-enemy); +} + +.turn-order__item--defeated { + opacity: 0.4; + text-decoration: line-through; +} + +.turn-order__position { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-xs); + font-weight: 600; + background: var(--bg-tertiary); + border-radius: 50%; + color: var(--text-muted); +} + +.turn-order__item--active .turn-order__position { + background: var(--accent-gold); + color: var(--bg-primary); +} + +.turn-order__name { + flex: 1; + color: var(--text-primary); +} + +.turn-order__check { + color: var(--accent-green); + font-size: var(--text-sm); +} + +/* Active Effects */ +.effects-panel { + padding: 1rem; + flex: 1; +} + +.effects-panel__title { + font-family: var(--font-heading); + font-size: var(--text-xs); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.75rem; +} + +.effects-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.effect-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + background: var(--bg-input); + border-radius: 4px; + font-size: var(--text-sm); +} + +.effect-item--buff { + border-left: 3px solid var(--accent-green); +} + +.effect-item--debuff { + border-left: 3px solid var(--accent-red); +} + +.effect-item--shield { + border-left: 3px solid var(--accent-blue); +} + +.effect-icon { + font-size: var(--text-base); +} + +.effect-name { + flex: 1; + color: var(--text-primary); +} + +.effect-duration { + font-size: var(--text-xs); + color: var(--text-muted); + padding: 0.125rem 0.375rem; + background: var(--bg-tertiary); + border-radius: 3px; +} + +.effects-empty { + text-align: center; + color: var(--text-muted); + font-size: var(--text-sm); + font-style: italic; + padding: 1rem; +} + +/* ===== ABILITY MODAL ===== */ +.ability-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ability-btn { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--border-primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.ability-btn:hover:not(:disabled) { + border-color: #8b5cf6; + background: rgba(139, 92, 246, 0.1); +} + +.ability-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ability-icon { + font-size: var(--text-xl); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.ability-info { + flex: 1; + min-width: 0; +} + +.ability-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.ability-description { + font-size: var(--text-xs); + color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.ability-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; +} + +.ability-cost { + font-size: var(--text-xs); + font-weight: 600; + color: var(--mp-bar-fill); + padding: 0.125rem 0.375rem; + background: rgba(59, 130, 246, 0.2); + border-radius: 3px; +} + +.ability-cooldown { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.ability-cooldown--active { + color: var(--accent-red); +} + +/* ===== ITEM MODAL ===== */ +.item-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.item-btn { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--border-primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.item-btn:hover:not(:disabled) { + border-color: var(--accent-green); + background: rgba(39, 174, 96, 0.1); +} + +.item-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.item-icon { + font-size: var(--text-xl); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.item-effect { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.item-quantity { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 4px; +} + +.items-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 2rem; +} + +/* ===== VICTORY/DEFEAT SCREENS ===== */ +.combat-result { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + padding: 2rem; + text-align: center; +} + +.combat-result__icon { + font-size: 5rem; + margin-bottom: 1rem; + animation: bounceIn 0.5s ease; +} + +@keyframes bounceIn { + 0% { transform: scale(0); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +.combat-result__title { + font-family: var(--font-heading); + font-size: var(--text-3xl); + text-transform: uppercase; + letter-spacing: 3px; + margin-bottom: 0.5rem; +} + +.combat-result--victory .combat-result__title { + color: var(--accent-gold); +} + +.combat-result--defeat .combat-result__title { + color: var(--accent-red); +} + +.combat-result__subtitle { + font-size: var(--text-lg); + color: var(--text-secondary); + margin-bottom: 2rem; +} + +/* Rewards Section */ +.combat-rewards { + background: var(--bg-secondary); + border: 2px solid var(--border-ornate); + border-radius: 8px; + padding: 1.5rem 2rem; + margin-bottom: 2rem; + min-width: 300px; +} + +.rewards-title { + font-family: var(--font-heading); + font-size: var(--text-sm); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 1rem; + text-align: center; +} + +.rewards-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.reward-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: var(--bg-input); + border-radius: 4px; +} + +.reward-icon { + font-size: var(--text-xl); +} + +.reward-label { + flex: 1; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.reward-value { + font-size: var(--text-sm); + font-weight: 600; +} + +.reward-value--xp { + color: var(--accent-gold); +} + +.reward-value--gold { + color: #fbbf24; +} + +.reward-value--level { + color: var(--accent-green); +} + +/* Loot Items */ +.loot-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-primary); +} + +.loot-title { + font-size: var(--text-xs); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.75rem; +} + +.loot-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.loot-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + background: var(--bg-tertiary); + border-radius: 4px; + font-size: var(--text-sm); +} + +.loot-item--common { + border-left: 3px solid #9ca3af; +} + +.loot-item--uncommon { + border-left: 3px solid var(--accent-green); +} + +.loot-item--rare { + border-left: 3px solid var(--accent-blue); +} + +.loot-item--epic { + border-left: 3px solid #8b5cf6; +} + +.loot-item--legendary { + border-left: 3px solid var(--accent-gold); +} + +/* Combat Result Actions */ +.combat-result__actions { + display: flex; + gap: 1rem; +} + +.combat-result__actions .btn { + min-width: 150px; +} + +/* ===== RESPONSIVE DESIGN ===== */ + +/* Tablet (768px - 1024px) */ +@media (max-width: 1024px) { + .combat-container { + grid-template-columns: 1fr var(--combat-sidebar-width); + height: auto; + min-height: calc(100vh - 140px); + } + + .combat-header { + grid-column: 1 / -1; + } + + .combatant-panel { + display: none; + } + + /* Show combatants inline in header on tablet */ + .combat-header { + flex-wrap: wrap; + gap: 0.75rem; + } + + .combat-actions__grid { + grid-template-columns: repeat(5, 1fr); + } +} + +/* Mobile (< 768px) */ +@media (max-width: 768px) { + .combat-container { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr auto; + gap: 0; + padding: 0; + height: 100vh; + } + + .combat-header { + border-radius: 0; + padding: 0.75rem 1rem; + position: sticky; + top: 0; + z-index: 100; + } + + .combat-title { + font-size: var(--text-base); + } + + .combat-round { + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + } + + .round-counter { + font-size: var(--text-sm); + } + + .turn-indicator { + font-size: var(--text-xs); + padding: 0.25rem 0.5rem; + } + + /* Mobile combatants bar */ + .combatant-panel--mobile { + display: flex; + flex-direction: row; + overflow-x: auto; + padding: 0.5rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-primary); + gap: 0.5rem; + } + + .combatant-card--mobile { + flex-shrink: 0; + width: 140px; + padding: 0.5rem; + } + + .combat-main { + border-radius: 0; + border-left: none; + border-right: none; + flex: 1; + min-height: 0; + } + + .combat-log { + padding: 0.75rem; + } + + .combat-actions { + position: sticky; + bottom: 0; + padding: 0.75rem; + border-radius: 0; + } + + .combat-actions__grid { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + } + + .combat-action-btn { + padding: 0.625rem 0.375rem; + font-size: var(--text-xs); + } + + .combat-action-btn__icon { + font-size: var(--text-base); + } + + /* Hide sidebar on mobile */ + .combat-sidebar { + display: none; + } + + /* Victory/Defeat mobile adjustments */ + .combat-result { + padding: 1.5rem; + min-height: 50vh; + } + + .combat-result__icon { + font-size: 3rem; + } + + .combat-result__title { + font-size: var(--text-2xl); + } + + .combat-rewards { + padding: 1rem; + min-width: auto; + width: 100%; + } + + .combat-result__actions { + flex-direction: column; + width: 100%; + } + + .combat-result__actions .btn { + width: 100%; + } +} + +/* Very small screens */ +@media (max-width: 400px) { + .combat-actions__grid { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr); + } +} + +/* ===== HTMX LOADING STATES ===== */ +.combat-action-btn.htmx-request { + opacity: 0.7; + pointer-events: none; +} + +.combat-action-btn.htmx-request::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Combat log loading indicator */ +.combat-log.htmx-request::after { + content: 'Processing...'; + display: block; + text-align: center; + padding: 0.5rem; + color: var(--text-muted); + font-style: italic; + font-size: var(--text-sm); +} + +/* ===== ACCESSIBILITY ===== */ +.combat-log { + /* Screen reader announcements */ +} + +.combat-log[aria-live="polite"] .combat-log__entry:last-child { + /* Most recent entry */ +} + +/* Focus styles */ +.combat-action-btn:focus { + outline: 2px solid var(--accent-gold); + outline-offset: 2px; +} + +.ability-btn:focus, +.item-btn:focus { + outline: 2px solid var(--accent-gold); + outline-offset: 2px; +} + +/* Reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + .combat-log__entry, + .combat-result__icon, + .resource-bar-fill { + animation: none; + transition: none; + } +} + +/* ===== CUSTOM SCROLLBAR ===== */ +.combat-log::-webkit-scrollbar, +.combatant-panel::-webkit-scrollbar, +.combat-sidebar::-webkit-scrollbar { + width: 6px; +} + +.combat-log::-webkit-scrollbar-track, +.combatant-panel::-webkit-scrollbar-track, +.combat-sidebar::-webkit-scrollbar-track { + background: var(--bg-tertiary); +} + +.combat-log::-webkit-scrollbar-thumb, +.combatant-panel::-webkit-scrollbar-thumb, +.combat-sidebar::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 3px; +} + +.combat-log::-webkit-scrollbar-thumb:hover, +.combatant-panel::-webkit-scrollbar-thumb:hover, +.combat-sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} diff --git a/public_web/static/css/inventory.css b/public_web/static/css/inventory.css new file mode 100644 index 0000000..a4b2ceb --- /dev/null +++ b/public_web/static/css/inventory.css @@ -0,0 +1,722 @@ +/** + * Code of Conquest - Inventory UI Stylesheet + * Inventory modal, item grid, and combat items sheet + */ + +/* ===== INVENTORY VARIABLES ===== */ +:root { + /* Rarity colors */ + --rarity-common: #9ca3af; + --rarity-uncommon: #22c55e; + --rarity-rare: #3b82f6; + --rarity-epic: #a855f7; + --rarity-legendary: #f59e0b; + + /* Item card */ + --item-bg: var(--bg-input, #1e1e24); + --item-border: var(--border-primary, #3a3a45); + --item-hover-bg: rgba(255, 255, 255, 0.05); + + /* Touch targets - WCAG compliant */ + --touch-target-min: 48px; + --touch-target-primary: 56px; + --touch-spacing: 8px; +} + +/* ===== INVENTORY MODAL ===== */ +.inventory-modal { + max-width: 800px; + width: 95%; + max-height: 85vh; +} + +.inventory-modal .modal-body { + display: flex; + flex-direction: row; + gap: 1rem; + padding: 1rem; + overflow: hidden; +} + +/* ===== TAB FILTER BAR ===== */ +.inventory-tabs { + display: flex; + gap: 0.25rem; + padding: 0 1rem; + background: var(--bg-tertiary, #16161a); + border-bottom: 1px solid var(--play-border, #3a3a45); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.inventory-tabs .tab { + min-height: var(--touch-target-min); + padding: 0.75rem 1rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary, #a0a0a8); + font-size: var(--text-sm, 0.875rem); + cursor: pointer; + white-space: nowrap; + transition: all 0.2s ease; +} + +.inventory-tabs .tab:hover { + color: var(--text-primary, #e5e5e5); + background: var(--item-hover-bg); +} + +.inventory-tabs .tab.active { + color: var(--accent-gold, #f3a61a); + border-bottom-color: var(--accent-gold, #f3a61a); +} + +/* ===== INVENTORY CONTENT LAYOUT ===== */ +.inventory-body { + flex: 1; + display: flex; + gap: 1rem; + overflow: hidden; +} + +.inventory-grid-container { + flex: 1; + overflow-y: auto; + padding-right: 0.5rem; +} + +/* ===== ITEM GRID ===== */ +.inventory-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--touch-spacing); +} + +/* Responsive grid columns */ +@media (max-width: 900px) { + .inventory-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 600px) { + .inventory-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ===== INVENTORY ITEM CARD ===== */ +.inventory-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem; + min-height: 96px; + min-width: 80px; + background: var(--item-bg); + border: 2px solid var(--item-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.inventory-item:hover, +.inventory-item:focus { + background: var(--item-hover-bg); + transform: translateY(-2px); +} + +.inventory-item:focus { + outline: 2px solid var(--accent-gold, #f3a61a); + outline-offset: 2px; +} + +.inventory-item.selected { + border-color: var(--accent-gold, #f3a61a); + box-shadow: 0 0 12px rgba(243, 166, 26, 0.3); +} + +/* Rarity border colors */ +.inventory-item.rarity-common { border-color: var(--rarity-common); } +.inventory-item.rarity-uncommon { border-color: var(--rarity-uncommon); } +.inventory-item.rarity-rare { border-color: var(--rarity-rare); } +.inventory-item.rarity-epic { border-color: var(--rarity-epic); } +.inventory-item.rarity-legendary { + border-color: var(--rarity-legendary); + box-shadow: 0 0 8px rgba(245, 158, 11, 0.3); +} + +/* Item icon */ +.inventory-item img { + width: 40px; + height: 40px; + object-fit: contain; + margin-bottom: 0.5rem; + opacity: 0.9; +} + +/* Item name */ +.inventory-item .item-name { + font-size: var(--text-xs, 0.75rem); + color: var(--text-primary, #e5e5e5); + text-align: center; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* Item quantity badge */ +.inventory-item .item-quantity { + position: absolute; + top: 4px; + right: 4px; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--bg-tertiary, #16161a); + border: 1px solid var(--item-border); + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-primary, #e5e5e5); + display: flex; + align-items: center; + justify-content: center; +} + +/* Empty state */ +.inventory-empty { + grid-column: 1 / -1; + text-align: center; + padding: 2rem; + color: var(--text-muted, #707078); + font-style: italic; +} + +/* ===== ITEM DETAIL PANEL ===== */ +.item-detail { + width: 280px; + min-width: 280px; + background: var(--bg-tertiary, #16161a); + border: 1px solid var(--play-border, #3a3a45); + border-radius: 8px; + padding: 1rem; + display: flex; + flex-direction: column; +} + +.item-detail-empty { + color: var(--text-muted, #707078); + text-align: center; + padding: 2rem 1rem; + font-style: italic; +} + +.item-detail-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.item-detail-header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--play-border, #3a3a45); +} + +.item-detail-icon { + width: 48px; + height: 48px; + object-fit: contain; +} + +.item-detail-title h3 { + font-family: var(--font-heading); + font-size: var(--text-lg, 1.125rem); + margin: 0 0 0.25rem 0; +} + +.item-detail-title .item-type { + font-size: var(--text-xs, 0.75rem); + color: var(--text-muted, #707078); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Rarity text colors */ +.rarity-text-common { color: var(--rarity-common); } +.rarity-text-uncommon { color: var(--rarity-uncommon); } +.rarity-text-rare { color: var(--rarity-rare); } +.rarity-text-epic { color: var(--rarity-epic); } +.rarity-text-legendary { color: var(--rarity-legendary); } + +.item-description { + font-size: var(--text-sm, 0.875rem); + color: var(--text-secondary, #a0a0a8); + line-height: 1.5; + margin-bottom: 1rem; +} + +/* Item stats */ +.item-stats { + background: var(--item-bg); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 1rem; +} + +.item-stats div { + display: flex; + justify-content: space-between; + padding: 0.25rem 0; + font-size: var(--text-sm, 0.875rem); +} + +.item-stats div:not(:last-child) { + border-bottom: 1px solid var(--item-border); +} + +/* Item action buttons */ +.item-actions { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.item-actions .action-btn { + min-height: var(--touch-target-primary); + padding: 0.75rem 1rem; + border: none; + border-radius: 6px; + font-size: var(--text-sm, 0.875rem); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.item-actions .action-btn--primary { + background: var(--accent-gold, #f3a61a); + color: var(--bg-primary, #0a0a0c); +} + +.item-actions .action-btn--primary:hover { + background: var(--accent-gold-hover, #e69500); +} + +.item-actions .action-btn--secondary { + background: var(--bg-input, #1e1e24); + border: 1px solid var(--play-border, #3a3a45); + color: var(--text-primary, #e5e5e5); +} + +.item-actions .action-btn--secondary:hover { + background: var(--item-hover-bg); + border-color: var(--text-muted, #707078); +} + +.item-actions .action-btn--danger { + background: transparent; + border: 1px solid #ef4444; + color: #ef4444; +} + +.item-actions .action-btn--danger:hover { + background: rgba(239, 68, 68, 0.1); +} + +/* ===== MODAL FOOTER ===== */ +.inventory-modal .modal-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.gold-display { + font-size: var(--text-sm, 0.875rem); + color: var(--accent-gold, #f3a61a); + font-weight: 600; +} + +.gold-display::before { + content: "coins "; + font-size: 1.1em; +} + +/* ===== COMBAT ITEMS BOTTOM SHEET ===== */ +.combat-items-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + max-height: 70vh; + background: var(--bg-secondary, #12121a); + border: 2px solid var(--border-ornate, #f3a61a); + border-bottom: none; + border-radius: 16px 16px 0 0; + z-index: 1001; + display: flex; + flex-direction: column; + transform: translateY(100%); + transition: transform 0.3s ease-out; +} + +.combat-items-sheet.open { + transform: translateY(0); +} + +/* Sheet backdrop */ +.sheet-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +/* Drag handle */ +.sheet-handle { + width: 40px; + height: 4px; + background: var(--text-muted, #707078); + border-radius: 2px; + margin: 8px auto; +} + +/* Sheet header */ +.sheet-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--play-border, #3a3a45); +} + +.sheet-header h3 { + font-family: var(--font-heading); + font-size: var(--text-lg, 1.125rem); + color: var(--accent-gold, #f3a61a); + margin: 0; +} + +.sheet-close { + width: var(--touch-target-min); + height: var(--touch-target-min); + background: none; + border: none; + color: var(--text-muted, #707078); + font-size: 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.sheet-close:hover { + color: var(--text-primary, #e5e5e5); +} + +/* Sheet body */ +.sheet-body { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Combat items grid - larger items for combat */ +.combat-items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--touch-spacing); +} + +.combat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + min-height: 120px; + background: var(--item-bg); + border: 2px solid var(--rarity-common); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.combat-item:hover, +.combat-item:focus { + background: var(--item-hover-bg); + border-color: var(--accent-gold, #f3a61a); +} + +.combat-item:focus { + outline: 2px solid var(--accent-gold, #f3a61a); + outline-offset: 2px; +} + +.combat-item.selected { + border-color: var(--accent-gold, #f3a61a); + box-shadow: 0 0 12px rgba(243, 166, 26, 0.3); +} + +.combat-item img { + width: 48px; + height: 48px; + margin-bottom: 0.5rem; +} + +.combat-item .item-name { + font-size: var(--text-sm, 0.875rem); + color: var(--text-primary, #e5e5e5); + font-weight: 500; + text-align: center; + margin-bottom: 0.25rem; +} + +.combat-item .item-effect { + font-size: var(--text-xs, 0.75rem); + color: var(--text-muted, #707078); + text-align: center; +} + +/* Combat item detail section */ +.combat-item-detail { + background: var(--bg-tertiary, #16161a); + border: 1px solid var(--play-border, #3a3a45); + border-radius: 8px; + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.combat-item-detail .detail-info { + flex: 1; +} + +.combat-item-detail .detail-name { + font-weight: 600; + color: var(--text-primary, #e5e5e5); + margin-bottom: 0.25rem; +} + +.combat-item-detail .detail-effect { + font-size: var(--text-sm, 0.875rem); + color: var(--text-secondary, #a0a0a8); +} + +.combat-item-detail .use-btn { + min-width: 100px; + min-height: var(--touch-target-primary); + padding: 0.75rem 1.5rem; + background: var(--hp-bar-fill, #ef4444); + border: none; + border-radius: 6px; + color: white; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.combat-item-detail .use-btn:hover { + background: #dc2626; +} + +/* No consumables message */ +.no-consumables { + text-align: center; + padding: 2rem; + color: var(--text-muted, #707078); + font-style: italic; +} + +/* ===== MOBILE RESPONSIVENESS ===== */ + +/* Full-screen modal on mobile */ +@media (max-width: 768px) { + .inventory-modal { + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + border-radius: 0; + border: none; + } + + .inventory-modal .modal-body { + flex-direction: column; + padding: 0.75rem; + } + + /* Item detail slides in from right on mobile */ + .item-detail { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 100%; + max-width: 320px; + min-width: unset; + z-index: 1002; + border-radius: 0; + border-left: 2px solid var(--border-ornate, #f3a61a); + transform: translateX(100%); + transition: transform 0.3s ease; + } + + .item-detail.visible { + transform: translateX(0); + } + + .item-detail-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + } + + /* Back button for mobile detail view */ + .item-detail-back { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + margin: -1rem -1rem 1rem -1rem; + background: var(--bg-secondary, #12121a); + border: none; + border-bottom: 1px solid var(--play-border, #3a3a45); + color: var(--accent-gold, #f3a61a); + font-size: var(--text-sm, 0.875rem); + cursor: pointer; + width: calc(100% + 2rem); + } + + .item-detail-back:hover { + background: var(--item-hover-bg); + } + + /* Action buttons fixed at bottom on mobile */ + .item-actions { + position: sticky; + bottom: 0; + background: var(--bg-tertiary, #16161a); + padding: 1rem; + margin: auto -1rem -1rem -1rem; + border-top: 1px solid var(--play-border, #3a3a45); + } + + /* Larger touch targets on mobile */ + .inventory-item { + min-height: 88px; + padding: 0.5rem; + } + + /* Tabs scroll horizontally on mobile */ + .inventory-tabs { + padding: 0 0.5rem; + } + + .inventory-tabs .tab { + min-height: 44px; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + } + + /* Combat sheet takes more space on mobile */ + .combat-items-sheet { + max-height: 80vh; + } + + .combat-items-grid { + grid-template-columns: repeat(2, 1fr); + } + + .combat-item { + min-height: 100px; + padding: 0.75rem; + } +} + +/* Extra small screens */ +@media (max-width: 400px) { + .inventory-grid { + grid-template-columns: repeat(2, 1fr); + } + + .inventory-item { + min-height: 80px; + } + + .inventory-item img { + width: 32px; + height: 32px; + } +} + +/* ===== LOADING STATE ===== */ +.inventory-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--text-muted, #707078); +} + +.inventory-loading::after { + content: ""; + width: 24px; + height: 24px; + margin-left: 0.75rem; + border: 2px solid var(--text-muted, #707078); + border-top-color: var(--accent-gold, #f3a61a); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== ACCESSIBILITY ===== */ + +/* Focus visible for keyboard navigation */ +.inventory-item:focus-visible, +.combat-item:focus-visible, +.inventory-tabs .tab:focus-visible, +.action-btn:focus-visible { + outline: 2px solid var(--accent-gold, #f3a61a); + outline-offset: 2px; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .inventory-item, + .combat-item, + .combat-items-sheet, + .item-detail { + transition: none; + } + + .inventory-loading::after { + animation: none; + } +} diff --git a/public_web/static/css/play.css b/public_web/static/css/play.css index d31ee54..1cc0c8b 100644 --- a/public_web/static/css/play.css +++ b/public_web/static/css/play.css @@ -1119,6 +1119,161 @@ margin-top: 0.25rem; } +/* Monster Selection Modal */ +.monster-modal { + max-width: 500px; +} + +.monster-modal-location { + color: var(--text-secondary); + margin-bottom: 1rem; + font-size: var(--text-sm); +} + +.monster-modal-hint { + color: var(--text-muted); + margin-top: 1rem; + text-align: center; +} + +.encounter-options { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.encounter-option { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--play-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + width: 100%; + border-left: 4px solid var(--text-muted); +} + +.encounter-option:hover { + transform: translateX(4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +/* Challenge level border colors */ +.encounter-option--easy { + border-left-color: #2ecc71; /* Green for easy */ +} + +.encounter-option--easy:hover { + border-color: #2ecc71; + background: rgba(46, 204, 113, 0.1); +} + +.encounter-option--medium { + border-left-color: #f39c12; /* Gold/orange for medium */ +} + +.encounter-option--medium:hover { + border-color: #f39c12; + background: rgba(243, 156, 18, 0.1); +} + +.encounter-option--hard { + border-left-color: #e74c3c; /* Red for hard */ +} + +.encounter-option--hard:hover { + border-color: #e74c3c; + background: rgba(231, 76, 60, 0.1); +} + +.encounter-option--boss { + border-left-color: #9b59b6; /* Purple for boss */ +} + +.encounter-option--boss:hover { + border-color: #9b59b6; + background: rgba(155, 89, 182, 0.1); +} + +.encounter-info { + flex: 1; +} + +.encounter-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.encounter-enemies { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.enemy-badge { + font-size: var(--text-xs); + color: var(--text-muted); + background: var(--bg-card); + padding: 0.125rem 0.375rem; + border-radius: 4px; +} + +.encounter-challenge { + font-size: var(--text-sm); + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.challenge--easy { + color: #2ecc71; + background: rgba(46, 204, 113, 0.15); +} + +.challenge--medium { + color: #f39c12; + background: rgba(243, 156, 18, 0.15); +} + +.challenge--hard { + color: #e74c3c; + background: rgba(231, 76, 60, 0.15); +} + +.challenge--boss { + color: #9b59b6; + background: rgba(155, 89, 182, 0.15); +} + +.encounter-empty { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.encounter-empty p { + margin: 0.5rem 0; +} + +/* Combat action button highlight */ +.action-btn--combat { + background: linear-gradient(135deg, rgba(231, 76, 60, 0.2), rgba(155, 89, 182, 0.2)); + border-color: rgba(231, 76, 60, 0.4); +} + +.action-btn--combat:hover { + background: linear-gradient(135deg, rgba(231, 76, 60, 0.3), rgba(155, 89, 182, 0.3)); + border-color: rgba(231, 76, 60, 0.6); +} + /* NPC Chat Modal */ .npc-chat-header { display: flex; diff --git a/public_web/static/img/items/armor.svg b/public_web/static/img/items/armor.svg new file mode 100644 index 0000000..ef170d9 --- /dev/null +++ b/public_web/static/img/items/armor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public_web/static/img/items/consumable.svg b/public_web/static/img/items/consumable.svg new file mode 100644 index 0000000..845c045 --- /dev/null +++ b/public_web/static/img/items/consumable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public_web/static/img/items/default.svg b/public_web/static/img/items/default.svg new file mode 100644 index 0000000..1054075 --- /dev/null +++ b/public_web/static/img/items/default.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public_web/static/img/items/quest_item.svg b/public_web/static/img/items/quest_item.svg new file mode 100644 index 0000000..9290fb9 --- /dev/null +++ b/public_web/static/img/items/quest_item.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public_web/static/img/items/weapon.svg b/public_web/static/img/items/weapon.svg new file mode 100644 index 0000000..bcf0a0f --- /dev/null +++ b/public_web/static/img/items/weapon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public_web/templates/character/detail.html b/public_web/templates/character/detail.html index fb1ea13..3a86530 100644 --- a/public_web/templates/character/detail.html +++ b/public_web/templates/character/detail.html @@ -81,6 +81,10 @@ CHA {{ character.base_stats.charisma }} +
+ LUK + {{ character.base_stats.luck }} +
diff --git a/public_web/templates/dev/combat.html b/public_web/templates/dev/combat.html new file mode 100644 index 0000000..a9c3e32 --- /dev/null +++ b/public_web/templates/dev/combat.html @@ -0,0 +1,337 @@ +{% extends "base.html" %} + +{% block title %}Combat Tester - Dev Tools{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ DEV MODE - Combat System Tester +
+ +
+ ← Back to Dev Tools + + {% if error %} +
{{ error }}
+ {% endif %} + + +
+

Start New Combat

+ +
+ + +
+ + +

You need an active story session to start combat. Create one in the Story Tester first.

+
+ + +
+ + {% if enemies %} +
+ {% for enemy in enemies %} + + {% endfor %} +
+ {% else %} +
+ No enemy templates available. Check that the API has enemy data loaded. +
+ {% endif %} +
+ + +
+ +
+
+ + +
+

Active Combat Sessions

+ + {% if sessions_in_combat %} +
+ {% for session in sessions_in_combat %} +
+
+
{{ session.session_id[:12] }}...
+
{{ session.character_name or 'Unknown Character' }}
+
In Combat - Round {{ session.game_state.combat_round or 1 }}
+
+ + Resume Combat + +
+ {% endfor %} +
+ {% else %} +
+ No active combat sessions. Start a new combat above. +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/public_web/templates/dev/combat_session.html b/public_web/templates/dev/combat_session.html new file mode 100644 index 0000000..308a3b9 --- /dev/null +++ b/public_web/templates/dev/combat_session.html @@ -0,0 +1,864 @@ +{% extends "base.html" %} + +{% block title %}Combat Debug - Dev Tools{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ DEV MODE - Combat Session {{ session_id[:8] }}... +
+ +
+ +
+

+ Combat State + +

+
+ {% include 'dev/partials/combat_state.html' %} +
+ + +
+

Debug Actions

+ + + +
+ + +
+ + +
+

Combat Log

+ + +
+ {% for entry in combat_log %} +
+ {% if entry.actor %} + {{ entry.actor }} + {% endif %} + {{ entry.message }} + {% if entry.damage %} + -{{ entry.damage }} HP + {% endif %} + {% if entry.heal %} + +{{ entry.heal }} HP + {% endif %} + {% if entry.is_crit %} + CRITICAL! + {% endif %} +
+ {% else %} +
+ Combat begins! + {% if is_player_turn %} + Take your action. + {% else %} + Waiting for enemy turn... + {% endif %} +
+ {% endfor %} +
+ + +
+ + + + + +
+ + +
+
+ [+] Raw State JSON (click to toggle) +
+ +
+
+ + +
+

Turn Order

+ +
+ {% for combatant_id in turn_order %} + {% set ns = namespace(combatant=None) %} + {% for c in encounter.combatants %} + {% if c.combatant_id == combatant_id %} + {% set ns.combatant = c %} + {% endif %} + {% endfor %} +
+ {{ loop.index }} + + {% if ns.combatant %}{{ ns.combatant.name }}{% else %}{{ combatant_id[:8] }}{% endif %} + +
+ {% endfor %} +
+ +

Active Effects

+
+ {% if player_combatant and player_combatant.active_effects %} + {% for effect in player_combatant.active_effects %} +
+ {{ effect.name }} + {{ effect.remaining_duration }} turns +
+ {% endfor %} + {% else %} +

No active effects

+ {% endif %} +
+
+
+ + + + + +
+ + +{% endblock %} diff --git a/public_web/templates/dev/index.html b/public_web/templates/dev/index.html index 72c3846..a2677b7 100644 --- a/public_web/templates/dev/index.html +++ b/public_web/templates/dev/index.html @@ -83,6 +83,14 @@ + +

Quest System

diff --git a/public_web/templates/dev/partials/ability_modal.html b/public_web/templates/dev/partials/ability_modal.html new file mode 100644 index 0000000..3209b1e --- /dev/null +++ b/public_web/templates/dev/partials/ability_modal.html @@ -0,0 +1,62 @@ + + + diff --git a/public_web/templates/dev/partials/combat_debug_log.html b/public_web/templates/dev/partials/combat_debug_log.html new file mode 100644 index 0000000..2d91408 --- /dev/null +++ b/public_web/templates/dev/partials/combat_debug_log.html @@ -0,0 +1,19 @@ + + +{% for entry in combat_log %} +
+ {% if entry.actor %} + {{ entry.actor }} + {% endif %} + {{ entry.message }} + {% if entry.damage %} + -{{ entry.damage }} HP + {% endif %} + {% if entry.heal %} + +{{ entry.heal }} HP + {% endif %} + {% if entry.is_crit %} + CRITICAL! + {% endif %} +
+{% endfor %} diff --git a/public_web/templates/dev/partials/combat_defeat.html b/public_web/templates/dev/partials/combat_defeat.html new file mode 100644 index 0000000..1048455 --- /dev/null +++ b/public_web/templates/dev/partials/combat_defeat.html @@ -0,0 +1,32 @@ + + +
+
💀
+

Defeat

+

You have been defeated in battle...

+ + + {% if gold_lost and gold_lost > 0 %} +
+
+ -{{ gold_lost }} gold lost +
+
+ {% endif %} + +

+ Your progress has been saved. You can try again or return to town. +

+ + + +
diff --git a/public_web/templates/dev/partials/combat_items_sheet.html b/public_web/templates/dev/partials/combat_items_sheet.html new file mode 100644 index 0000000..d4c0a55 --- /dev/null +++ b/public_web/templates/dev/partials/combat_items_sheet.html @@ -0,0 +1,88 @@ + + +
+
+
+

Use Item

+ +
+ +
+ {% if has_consumables %} +
+ {% for item in consumables %} + + {% endfor %} +
+ + +
+
+ Select an item to see details +
+
+ {% else %} +
+ No consumable items in inventory. +
+ {% endif %} +
+
+ + diff --git a/public_web/templates/dev/partials/combat_state.html b/public_web/templates/dev/partials/combat_state.html new file mode 100644 index 0000000..f66809b --- /dev/null +++ b/public_web/templates/dev/partials/combat_state.html @@ -0,0 +1,84 @@ + + +
+

Encounter Info

+
+
Round
+
{{ encounter.round_number or 1 }}
+
+
+
Status
+
{{ encounter.status or 'active' }}
+
+
+
Current Turn
+
+ {% if is_player_turn %} + Your Turn + {% else %} + Enemy Turn + {% endif %} +
+
+
+ + +{% if player_combatant %} +
+

Player

+
+
{{ player_combatant.name }}
+ + + {% set hp_percent = (player_combatant.current_hp / player_combatant.max_hp * 100) if player_combatant.max_hp > 0 else 0 %} +
+
+
+
+ HP + {{ player_combatant.current_hp }}/{{ player_combatant.max_hp }} +
+ + + {% if player_combatant.max_mp and player_combatant.max_mp > 0 %} + {% set mp_percent = (player_combatant.current_mp / player_combatant.max_mp * 100) if player_combatant.max_mp > 0 else 0 %} +
+
+
+
+ MP + {{ player_combatant.current_mp }}/{{ player_combatant.max_mp }} +
+ {% endif %} +
+
+{% endif %} + + +{% if enemy_combatants %} +
+

Enemies ({{ enemy_combatants | length }})

+ {% for enemy in enemy_combatants %} +
+
+ {{ enemy.name }} + {% if enemy.current_hp <= 0 %} + (Defeated) + {% endif %} +
+ + + {% set enemy_hp_percent = (enemy.current_hp / enemy.max_hp * 100) if enemy.max_hp > 0 else 0 %} +
+
+
+
+ HP + {{ enemy.current_hp }}/{{ enemy.max_hp }} +
+
+ {% endfor %} +
+{% endif %} diff --git a/public_web/templates/dev/partials/combat_victory.html b/public_web/templates/dev/partials/combat_victory.html new file mode 100644 index 0000000..d574cb9 --- /dev/null +++ b/public_web/templates/dev/partials/combat_victory.html @@ -0,0 +1,68 @@ + + +
+
🏆
+

Victory!

+

You have defeated your enemies!

+ + + {% if rewards %} +
+

Rewards

+ + {% if rewards.experience %} +
+ Experience + +{{ rewards.experience }} XP +
+ {% endif %} + + {% if rewards.gold %} +
+ Gold + +{{ rewards.gold }} gold +
+ {% endif %} + + {% if rewards.level_ups %} +
+
Level Up!
+
You have reached a new level!
+
+ {% endif %} + + {% if rewards.items %} +
+
Loot Obtained:
+ {% for item in rewards.items %} +
+ {{ item.name }} + {% if item.rarity and item.rarity != 'common' %} + + ({{ item.rarity }}) + + {% endif %} +
+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + + + +
diff --git a/public_web/templates/game/combat.html b/public_web/templates/game/combat.html new file mode 100644 index 0000000..b72fbd0 --- /dev/null +++ b/public_web/templates/game/combat.html @@ -0,0 +1,296 @@ +{% extends "base.html" %} + +{% block title %}Combat - Code of Conquest{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+ {# ===== COMBAT HEADER ===== #} +
+

+ + Combat Encounter +

+
+ Round {{ encounter.round_number }} + {% if is_player_turn %} + Your Turn + {% else %} + Enemy Turn + {% endif %} +
+
+ + {# ===== LEFT COLUMN: COMBATANTS ===== #} + + + {# ===== CENTER COLUMN: COMBAT LOG + ACTIONS ===== #} +
+ {# Combat Log #} +
+ {% include "game/partials/combat_log.html" %} +
+ + {# Combat Actions #} +
+ {% include "game/partials/combat_actions.html" %} +
+
+ + {# ===== RIGHT COLUMN: TURN ORDER + EFFECTS ===== #} + +
+ + {# Modal Container for Ability selection #} + + + {# Combat Items Sheet Container #} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/public_web/templates/game/partials/ability_modal.html b/public_web/templates/game/partials/ability_modal.html new file mode 100644 index 0000000..b0eb176 --- /dev/null +++ b/public_web/templates/game/partials/ability_modal.html @@ -0,0 +1,61 @@ +{# Ability Selection Modal - Shows available abilities during combat #} + + diff --git a/public_web/templates/game/partials/character_panel.html b/public_web/templates/game/partials/character_panel.html index 15b9127..1564bea 100644 --- a/public_web/templates/game/partials/character_panel.html +++ b/public_web/templates/game/partials/character_panel.html @@ -74,12 +74,27 @@ Displays character stats, resource bars, and action buttons
CHA
{{ character.stats.charisma }}
+
+
LUK
+
{{ character.stats.luck }}
+
- {# Quick Actions (Equipment, NPC, Travel) #} + {# Quick Actions (Inventory, Equipment, NPC, Travel) #}
+ {# Inventory - Opens modal #} + + {# Equipment & Gear - Opens modal #} + + {# Search for Monsters - Opens modal with encounter options #} +
{# Actions Section #} diff --git a/public_web/templates/game/partials/combat_abandoned_success.html b/public_web/templates/game/partials/combat_abandoned_success.html new file mode 100644 index 0000000..5644122 --- /dev/null +++ b/public_web/templates/game/partials/combat_abandoned_success.html @@ -0,0 +1,38 @@ +{# +Combat Abandoned Success Message +Shows after successfully abandoning a combat session +#} +
+
+

{{ message }}

+

+ Click "Search for Monsters" to find a new encounter. +

+ +
+ + diff --git a/public_web/templates/game/partials/combat_actions.html b/public_web/templates/game/partials/combat_actions.html new file mode 100644 index 0000000..ac88617 --- /dev/null +++ b/public_web/templates/game/partials/combat_actions.html @@ -0,0 +1,87 @@ +{# Combat Actions Partial - Action buttons for combat #} +{# This partial shows the available combat actions #} + +{% if is_player_turn %} +
+ {# Attack Button - Direct action #} + + + {# Ability Button - Opens modal #} + + + {# Item Button - Opens bottom sheet #} + + + {# Defend Button - Direct action #} + + + {# Flee Button - Direct action #} + +
+{% else %} +
+ {# Disabled buttons when not player's turn #} + + + + + +
+

Waiting for enemy turn...

+{% endif %} diff --git a/public_web/templates/game/partials/combat_conflict_modal.html b/public_web/templates/game/partials/combat_conflict_modal.html new file mode 100644 index 0000000..993fd7c --- /dev/null +++ b/public_web/templates/game/partials/combat_conflict_modal.html @@ -0,0 +1,220 @@ +{# +Combat Conflict Modal +Shows when player tries to start combat but already has an active combat session +#} + + + diff --git a/public_web/templates/game/partials/combat_defeat.html b/public_web/templates/game/partials/combat_defeat.html new file mode 100644 index 0000000..1c35d7f --- /dev/null +++ b/public_web/templates/game/partials/combat_defeat.html @@ -0,0 +1,47 @@ +{# Combat Defeat Partial - Swapped into combat log when player loses #} + +
+
💀
+

Defeated

+

Your party has fallen in battle...

+ + {# Defeat Message #} +
+

Battle Lost

+
+
+ + Your progress has been saved + No items lost +
+ {% if gold_lost %} +
+ 💰 + Gold dropped + -{{ gold_lost }} gold +
+ {% endif %} +
+ +
+

+ "Even the mightiest heroes face setbacks. Rise again, adventurer!" +

+
+
+ + {# Action Buttons #} +
+ + Return to Game + + {% if can_retry %} + + {% endif %} +
+
diff --git a/public_web/templates/game/partials/combat_items_sheet.html b/public_web/templates/game/partials/combat_items_sheet.html new file mode 100644 index 0000000..6d4585a --- /dev/null +++ b/public_web/templates/game/partials/combat_items_sheet.html @@ -0,0 +1,52 @@ +{# +Combat Items Sheet +Bottom sheet for selecting consumable items during combat +#} + +
+ + diff --git a/public_web/templates/game/partials/combat_log.html b/public_web/templates/game/partials/combat_log.html new file mode 100644 index 0000000..f47dabb --- /dev/null +++ b/public_web/templates/game/partials/combat_log.html @@ -0,0 +1,25 @@ +{# Combat Log Partial - Displays combat action history #} +{# This partial is swapped via HTMX when combat actions occur #} + +{% if combat_log %} + {% for entry in combat_log %} +
+ {% if entry.actor %} + {{ entry.actor }} + {% endif %} + {{ entry.message }} + {% if entry.damage %} + + {% if entry.is_crit %}CRITICAL! {% endif %}{{ entry.damage }} damage + + {% endif %} + {% if entry.heal %} + +{{ entry.heal }} HP + {% endif %} +
+ {% endfor %} +{% else %} +
+ Combat begins! Choose your action below. +
+{% endif %} diff --git a/public_web/templates/game/partials/combat_victory.html b/public_web/templates/game/partials/combat_victory.html new file mode 100644 index 0000000..2d3dfbb --- /dev/null +++ b/public_web/templates/game/partials/combat_victory.html @@ -0,0 +1,76 @@ +{# Combat Victory Partial - Swapped into combat log when player wins #} + +
+
🏆
+

Victory!

+

You have defeated your enemies!

+ + {# Rewards Section #} + {% if rewards %} +
+

Rewards Earned

+
+ {# Experience #} + {% if rewards.experience %} +
+ + Experience Points + +{{ rewards.experience }} XP +
+ {% endif %} + + {# Gold #} + {% if rewards.gold %} +
+ 💰 + Gold + +{{ rewards.gold }} gold +
+ {% endif %} + + {# Level Up #} + {% if rewards.level_ups %} + {% for character_id in rewards.level_ups %} +
+ 🌟 + Level Up! + New abilities unlocked! +
+ {% endfor %} + {% endif %} +
+ + {# Loot Items - use bracket notation to avoid conflict with dict.items() method #} + {% if rewards.get('items') %} +
+

Items Obtained

+
+ {% for item in rewards.get('items', []) %} +
+ + {% if item.type == 'weapon' %}⚔ + {% elif item.type == 'armor' %}🧳 + {% elif item.type == 'consumable' %}🍷 + {% elif item.type == 'material' %}🔥 + {% else %}📦 + {% endif %} + + {{ item.name }} + {% if item.get('quantity', 1) > 1 %} + x{{ item.quantity }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + + {# Action Buttons #} + +
diff --git a/public_web/templates/game/partials/inventory_item_detail.html b/public_web/templates/game/partials/inventory_item_detail.html new file mode 100644 index 0000000..f8541f8 --- /dev/null +++ b/public_web/templates/game/partials/inventory_item_detail.html @@ -0,0 +1,118 @@ +{# +Inventory Item Detail +Partial template loaded via HTMX when an item is selected +#} +
+ {# Mobile back button #} + + + {# Item header #} +
+ +
+

{{ item.name }}

+ {{ item.item_type|default('Item')|replace('_', ' ')|title }} +
+
+ + {# Item description #} +

{{ item.description|default('No description available.') }}

+ + {# Stats (for equipment) #} + {% if item.item_type in ['weapon', 'armor'] %} +
+ {% if item.damage %} +
+ Damage + {{ item.damage }} +
+ {% endif %} + {% if item.defense %} +
+ Defense + {{ item.defense }} +
+ {% endif %} + {% if item.spell_power %} +
+ Spell Power + {{ item.spell_power }} +
+ {% endif %} + {% if item.crit_chance %} +
+ Crit Chance + {{ (item.crit_chance * 100)|round|int }}% +
+ {% endif %} + {% if item.stat_bonuses %} + {% for stat, value in item.stat_bonuses.items() %} +
+ {{ stat|replace('_', ' ')|title }} + +{{ value }} +
+ {% endfor %} + {% endif %} +
+ {% endif %} + + {# Effects (for consumables) #} + {% if item.item_type == 'consumable' and item.effects_on_use %} +
+
Effects
+ {% for effect in item.effects_on_use %} +
+ {{ effect.name|default(effect.effect_type|default('Effect')|title) }} + {{ effect.value|default('') }} +
+ {% endfor %} +
+ {% endif %} + + {# Item value #} + {% if item.value %} +
+ Value: {{ item.value }} gold +
+ {% endif %} + + {# Action Buttons #} +
+ {% if item.item_type == 'consumable' %} + + {% elif item.item_type in ['weapon', 'armor'] %} + + {% endif %} + + {% if item.item_type != 'quest_item' %} + + {% else %} +

+ Quest items cannot be dropped +

+ {% endif %} +
+
diff --git a/public_web/templates/game/partials/inventory_modal.html b/public_web/templates/game/partials/inventory_modal.html new file mode 100644 index 0000000..b2d009f --- /dev/null +++ b/public_web/templates/game/partials/inventory_modal.html @@ -0,0 +1,138 @@ +{# +Inventory Modal +Full inventory management modal for play screen +#} + + + diff --git a/public_web/templates/game/partials/item_modal.html b/public_web/templates/game/partials/item_modal.html new file mode 100644 index 0000000..bb62f1b --- /dev/null +++ b/public_web/templates/game/partials/item_modal.html @@ -0,0 +1,51 @@ +{# Item Selection Modal - Shows consumable items during combat #} + + diff --git a/public_web/templates/game/partials/monster_modal.html b/public_web/templates/game/partials/monster_modal.html new file mode 100644 index 0000000..a48a126 --- /dev/null +++ b/public_web/templates/game/partials/monster_modal.html @@ -0,0 +1,53 @@ +{# +Monster Selection Modal +Shows random encounter options for the current location +#} + diff --git a/public_web/templates/game/play.html b/public_web/templates/game/play.html index 1a59db3..3191394 100644 --- a/public_web/templates/game/play.html +++ b/public_web/templates/game/play.html @@ -4,6 +4,7 @@ {% block extra_head %} + {% endblock %} {% block content %}