diff --git a/api/app/api/combat.py b/api/app/api/combat.py index e29c2d5..34e3fe4 100644 --- a/api/app/api/combat.py +++ b/api/app/api/combat.py @@ -251,6 +251,117 @@ def get_combat_state(session_id: str): ) +@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 # ============================================================================= @@ -518,6 +629,20 @@ def attempt_flee(session_id: str): 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=[], @@ -629,6 +754,142 @@ def end_combat(session_id: str): # 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(): """ diff --git a/api/app/data/enemies/bandit.yaml b/api/app/data/enemies/bandit.yaml index 7976de8..973f9ff 100644 --- a/api/app/data/enemies/bandit.yaml +++ b/api/app/data/enemies/bandit.yaml @@ -50,6 +50,10 @@ tags: - 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 index 155c9fe..3ac4828 100644 --- a/api/app/data/enemies/dire_wolf.yaml +++ b/api/app/data/enemies/dire_wolf.yaml @@ -47,6 +47,10 @@ tags: - 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 index ffcf17d..f2f1767 100644 --- a/api/app/data/enemies/goblin.yaml +++ b/api/app/data/enemies/goblin.yaml @@ -40,6 +40,11 @@ tags: - 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 index 23a621c..05acf04 100644 --- a/api/app/data/enemies/goblin_chieftain.yaml +++ b/api/app/data/enemies/goblin_chieftain.yaml @@ -80,6 +80,11 @@ tags: - 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 index b5054fb..f1419b7 100644 --- a/api/app/data/enemies/goblin_scout.yaml +++ b/api/app/data/enemies/goblin_scout.yaml @@ -51,6 +51,11 @@ tags: - 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 index 5320cb5..4a5019f 100644 --- a/api/app/data/enemies/goblin_shaman.yaml +++ b/api/app/data/enemies/goblin_shaman.yaml @@ -47,6 +47,11 @@ tags: - 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 index e5a8ea4..e8ca1e0 100644 --- a/api/app/data/enemies/goblin_warrior.yaml +++ b/api/app/data/enemies/goblin_warrior.yaml @@ -65,6 +65,11 @@ tags: - 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 index 5f1225b..4a0e9d3 100644 --- a/api/app/data/enemies/orc_berserker.yaml +++ b/api/app/data/enemies/orc_berserker.yaml @@ -53,6 +53,10 @@ tags: - 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 index e3f1eba..5bda01e 100644 --- a/api/app/data/enemies/skeleton_warrior.yaml +++ b/api/app/data/enemies/skeleton_warrior.yaml @@ -47,6 +47,11 @@ tags: - 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/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 index 80efd09..4d4062a 100644 --- a/api/app/data/static_items/materials.yaml +++ b/api/app/data/static_items/materials.yaml @@ -66,6 +66,18 @@ items: 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 # ========================================================================== 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 index 894e7c0..64c449d 100644 --- a/api/app/models/enemy.py +++ b/api/app/models/enemy.py @@ -130,6 +130,7 @@ class EnemyTemplate: 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: @@ -149,6 +150,7 @@ class EnemyTemplate: 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 @@ -194,6 +196,10 @@ class EnemyTemplate: """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. @@ -213,6 +219,7 @@ class EnemyTemplate: "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, @@ -259,6 +266,7 @@ class EnemyTemplate: 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), diff --git a/api/app/services/character_service.py b/api/app/services/character_service.py index 50f1d2b..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) diff --git a/api/app/services/combat_service.py b/api/app/services/combat_service.py index 6c3342f..4bfbb75 100644 --- a/api/app/services/combat_service.py +++ b/api/app/services/combat_service.py @@ -20,7 +20,7 @@ from app.models.stats import Stats from app.models.abilities import Ability, AbilityLoader from app.models.effects import Effect from app.models.items import Item -from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType +from app.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 @@ -94,6 +94,7 @@ class ActionResult: 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 @@ -105,6 +106,7 @@ class ActionResult: 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.""" @@ -127,6 +129,7 @@ class ActionResult: "next_combatant_id": self.next_combatant_id, "next_is_player": self.next_is_player, "turn_effects": self.turn_effects, + "rewards": self.rewards, } @@ -451,6 +454,113 @@ class CombatService: 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 # ========================================================================= @@ -549,6 +659,7 @@ class CombatService: 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: @@ -699,6 +810,11 @@ class CombatService: 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( @@ -946,7 +1062,7 @@ class CombatService: effect_type=EffectType.BUFF, duration=1, power=5, # +5 defense - stat_affected="constitution", + stat_affected=StatType.CONSTITUTION, source="defend_action", ) combatant.add_effect(defense_buff) 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 index adf1157..ad0a02f 100644 --- a/api/app/services/enemy_loader.py +++ b/api/app/services/enemy_loader.py @@ -177,6 +177,46 @@ class EnemyLoader: 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, diff --git a/api/app/services/static_item_loader.py b/api/app/services/static_item_loader.py index 64bf313..c9d63ae 100644 --- a/api/app/services/static_item_loader.py +++ b/api/app/services/static_item_loader.py @@ -15,7 +15,7 @@ import yaml from app.models.items import Item from app.models.effects import Effect -from app.models.enums import ItemType, ItemRarity, EffectType +from app.models.enums import ItemType, ItemRarity, EffectType, DamageType from app.utils.logging import get_logger logger = get_logger(__file__) @@ -178,6 +178,20 @@ class StaticItemLoader: # 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), @@ -188,6 +202,17 @@ class StaticItemLoader: 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]: diff --git a/docs/PHASE4_COMBAT_IMPLEMENTATION.md b/docs/PHASE4_COMBAT_IMPLEMENTATION.md index 81b77ac..5092ff5 100644 --- a/docs/PHASE4_COMBAT_IMPLEMENTATION.md +++ b/docs/PHASE4_COMBAT_IMPLEMENTATION.md @@ -711,25 +711,25 @@ app.register_blueprint(combat_bp, url_prefix='/combat') --- -#### Task 3.4: Combat Testing & Polish (1 day / 8 hours) +#### 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 -- [ ] Effects apply and tick correctly -- [ ] Items can be used in combat -- [ ] Defend action works -- [ ] Victory awards XP/gold/loot -- [ ] Defeat handling works -- [ ] Combat log readable -- [ ] HP/MP bars update -- [ ] Multiple enemies work -- [ ] Combat state persists (refresh page) +- ✅ 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 diff --git a/public_web/app/views/combat_views.py b/public_web/app/views/combat_views.py index 6e91aab..0988c4e 100644 --- a/public_web/app/views/combat_views.py +++ b/public_web/app/views/combat_views.py @@ -36,7 +36,7 @@ def combat_view(session_id: str): # 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_id=session_id)) + return redirect(url_for('game.play_session', session_id=session_id)) encounter = result.get('encounter') or {} combat_log = result.get('combat_log', []) @@ -171,9 +171,11 @@ def combat_action(session_id: str): # 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.get("name")}'), + 'message': effect.get('message', f'Effect applied: {effect_name}'), 'type': 'system' }) @@ -417,15 +419,25 @@ def combat_flee(session_id: str): result = response.get('result', {}) if result.get('success'): - # Flee successful - redirect to play page - return redirect(url_for('game.play_session', session_id=session_id)) + # 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 - return f''' + # 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)) @@ -468,18 +480,19 @@ def combat_enemy_turn(session_id: str): ) # Format enemy action for log - action_result = result.get('action_result', {}) + # API returns ActionResult directly in result, not nested under action_result log_entries = [{ - 'actor': action_result.get('actor_name', 'Enemy'), - 'message': action_result.get('message', 'attacks'), + 'actor': 'Enemy', + 'message': result.get('message', 'attacks'), 'type': 'enemy', - 'is_crit': action_result.get('is_critical', False) + 'is_crit': False }] - # Add damage info - damage_results = action_result.get('damage_results', []) + # 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('damage') + 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( diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index d4caad4..c457972 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -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): diff --git a/public_web/static/css/combat.css b/public_web/static/css/combat.css index e3c207c..278c7c2 100644 --- a/public_web/static/css/combat.css +++ b/public_web/static/css/combat.css @@ -12,6 +12,10 @@ --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; 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/templates/game/combat.html b/public_web/templates/game/combat.html index 8425cb0..b72fbd0 100644 --- a/public_web/templates/game/combat.html +++ b/public_web/templates/game/combat.html @@ -142,7 +142,7 @@ {% endif %} {{ effect.name }} - {{ effect.remaining_duration }} {% if effect.remaining_duration == 1 %}turn{% else %}turns{% endif %} + {{ effect.duration }} {% if effect.duration == 1 %}turn{% else %}turns{% endif %} {% endfor %} @@ -206,43 +206,73 @@ } }); - // Guard against duplicate enemy turn requests + // Enemy turn handling with proper chaining for multiple enemies let enemyTurnPending = false; - let enemyTurnTimeout = null; function triggerEnemyTurn() { // Prevent duplicate requests if (enemyTurnPending) { return; } - - // Clear any pending timeout - if (enemyTurnTimeout) { - clearTimeout(enemyTurnTimeout); - } - enemyTurnPending = true; - enemyTurnTimeout = setTimeout(function() { - htmx.ajax('POST', '{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', { - target: '#combat-log', - swap: 'beforeend' - }).then(function() { + + setTimeout(function() { + // Use fetch instead of htmx.ajax for better control over response handling + fetch('{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'HX-Request': 'true' + }, + credentials: 'same-origin' + }) + .then(function(response) { + const hasMoreEnemies = response.headers.get('HX-Trigger')?.includes('enemyTurn'); + return response.text().then(function(html) { + return { html: html, hasMoreEnemies: hasMoreEnemies }; + }); + }) + .then(function(data) { + // Append the log entry + const combatLog = document.getElementById('combat-log'); + if (combatLog) { + combatLog.insertAdjacentHTML('beforeend', data.html); + combatLog.scrollTop = combatLog.scrollHeight; + } + enemyTurnPending = false; - }).catch(function() { + + if (data.hasMoreEnemies) { + // More enemies to go - trigger next enemy turn + triggerEnemyTurn(); + } else { + // All enemies done - refresh page to update UI + setTimeout(function() { + window.location.reload(); + }, 800); + } + }) + .catch(function(error) { + console.error('Enemy turn failed:', error); enemyTurnPending = false; + // Refresh anyway to recover from error state + setTimeout(function() { + window.location.reload(); + }, 1000); }); }, 1000); } - // Handle enemy turn polling + // Handle player action triggering enemy turn document.body.addEventListener('htmx:afterRequest', function(event) { - // Check if we need to trigger enemy turn const response = event.detail.xhr; - if (response && response.getResponseHeader('HX-Trigger')) { - const triggers = response.getResponseHeader('HX-Trigger'); - if (triggers && triggers.includes('enemyTurn')) { - triggerEnemyTurn(); - } + if (!response) return; + + const triggers = response.getResponseHeader('HX-Trigger') || ''; + + // Only trigger enemy turn from player actions (not from our fetch calls) + if (triggers.includes('enemyTurn') && !enemyTurnPending) { + triggerEnemyTurn(); } }); @@ -254,5 +284,13 @@ // Let the full page swap happen for victory/defeat screen } }); + + // Auto-trigger enemy turn on page load if it's not the player's turn + {% if not is_player_turn %} + document.addEventListener('DOMContentLoaded', function() { + // Small delay to let the page render first + triggerEnemyTurn(); + }); + {% endif %} {% endblock %} diff --git a/public_web/templates/game/partials/character_panel.html b/public_web/templates/game/partials/character_panel.html index eac800d..1564bea 100644 --- a/public_web/templates/game/partials/character_panel.html +++ b/public_web/templates/game/partials/character_panel.html @@ -119,6 +119,14 @@ Displays character stats, resource bars, and action buttons hx-swap="innerHTML"> 🗺️ Travel to... + + {# 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 index 288aa7d..ac88617 100644 --- a/public_web/templates/game/partials/combat_actions.html +++ b/public_web/templates/game/partials/combat_actions.html @@ -50,8 +50,8 @@ {# Flee Button - Direct action #} + + + + + + + diff --git a/public_web/templates/game/partials/combat_defeat.html b/public_web/templates/game/partials/combat_defeat.html index 0733802..1c35d7f 100644 --- a/public_web/templates/game/partials/combat_defeat.html +++ b/public_web/templates/game/partials/combat_defeat.html @@ -1,12 +1,5 @@ -{% extends "base.html" %} +{# Combat Defeat Partial - Swapped into combat log when player loses #} -{% block title %}Defeated - Code of Conquest{% endblock %} - -{% block extra_head %} - -{% endblock %} - -{% block content %}
💀

Defeated

@@ -52,4 +45,3 @@ {% endif %}
-{% endblock %} diff --git a/public_web/templates/game/partials/combat_victory.html b/public_web/templates/game/partials/combat_victory.html index 6c58683..2d3dfbb 100644 --- a/public_web/templates/game/partials/combat_victory.html +++ b/public_web/templates/game/partials/combat_victory.html @@ -1,12 +1,5 @@ -{% extends "base.html" %} +{# Combat Victory Partial - Swapped into combat log when player wins #} -{% block title %}Victory! - Code of Conquest{% endblock %} - -{% block extra_head %} - -{% endblock %} - -{% block content %}
🏆

Victory!

@@ -47,12 +40,12 @@ {% endif %}
- {# Loot Items #} - {% if rewards.items %} + {# Loot Items - use bracket notation to avoid conflict with dict.items() method #} + {% if rewards.get('items') %}

Items Obtained

- {% for item in rewards.items %} + {% for item in rewards.get('items', []) %}
{% if item.type == 'weapon' %}⚔ @@ -63,7 +56,7 @@ {% endif %} {{ item.name }} - {% if item.quantity > 1 %} + {% if item.get('quantity', 1) > 1 %} x{{ item.quantity }} {% endif %}
@@ -81,4 +74,3 @@
-{% endblock %} 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 +#} +