Combat foundation complete

This commit is contained in:
2025-11-27 22:18:58 -06:00
parent dd92cf5991
commit 6d3fb63355
33 changed files with 1870 additions and 85 deletions

View File

@@ -251,6 +251,117 @@ def get_combat_state(session_id: str):
)
@combat_bp.route('/<session_id>/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('/<session_id>/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():
"""

View File

@@ -50,6 +50,10 @@ tags:
- rogue
- armed
location_tags:
- wilderness
- road
base_damage: 8
crit_chance: 0.12
flee_chance: 0.45

View File

@@ -47,6 +47,10 @@ tags:
- large
- pack
location_tags:
- forest
- wilderness
base_damage: 10
crit_chance: 0.10
flee_chance: 0.40

View File

@@ -40,6 +40,11 @@ tags:
- goblinoid
- small
location_tags:
- forest
- wilderness
- dungeon
base_damage: 4
crit_chance: 0.05
flee_chance: 0.60

View File

@@ -80,6 +80,11 @@ tags:
- elite
- armed
location_tags:
- forest
- wilderness
- dungeon
base_damage: 14
crit_chance: 0.15
flee_chance: 0.25

View File

@@ -51,6 +51,11 @@ tags:
- small
- scout
location_tags:
- forest
- wilderness
- dungeon
base_damage: 3
crit_chance: 0.08
flee_chance: 0.70

View File

@@ -47,6 +47,11 @@ tags:
- caster
- small
location_tags:
- forest
- wilderness
- dungeon
base_damage: 3
crit_chance: 0.08
flee_chance: 0.55

View File

@@ -65,6 +65,11 @@ tags:
- warrior
- armed
location_tags:
- forest
- wilderness
- dungeon
base_damage: 8
crit_chance: 0.10
flee_chance: 0.45

View File

@@ -53,6 +53,10 @@ tags:
- berserker
- large
location_tags:
- dungeon
- wilderness
base_damage: 15
crit_chance: 0.15
flee_chance: 0.30

View File

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

View File

@@ -47,6 +47,11 @@ tags:
- armed
- fearless
location_tags:
- crypt
- ruins
- dungeon
base_damage: 9
crit_chance: 0.08
flee_chance: 0.50

View File

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

View File

@@ -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
# ==========================================================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'You fled from combat!')}</span>
</div>
''')
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'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
</div>
'''
''')
# 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(

View File

@@ -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'<div class="error">Travel failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/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'''
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">⚔️ Search for Monsters</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="error">Failed to search for monsters: {e}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/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 '<div class="error">No enemies selected.</div>', 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 '<div class="error">Failed to start combat - no encounter ID returned.</div>', 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'<div class="error">Failed to start combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/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/<session_id>/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 '<div class="error">No active combat to abandon.</div>', 400
except APIError as e:
logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to abandon combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/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 '<div class="error">No enemies selected.</div>', 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 '<div class="error">Failed to start combat - no encounter ID returned.</div>', 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'<div class="error">Failed to start combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>')
@require_auth
def npc_chat_page(session_id: str, npc_id: str):

View File

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

View File

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

View File

@@ -142,7 +142,7 @@
{% endif %}
</span>
<span class="effect-name">{{ effect.name }}</span>
<span class="effect-duration">{{ effect.remaining_duration }} {% if effect.remaining_duration == 1 %}turn{% else %}turns{% endif %}</span>
<span class="effect-duration">{{ effect.duration }} {% if effect.duration == 1 %}turn{% else %}turns{% endif %}</span>
</div>
{% endfor %}
</div>
@@ -206,44 +206,74 @@
}
});
// 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;
}
enemyTurnPending = true;
// Clear any pending timeout
if (enemyTurnTimeout) {
clearTimeout(enemyTurnTimeout);
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 = true;
enemyTurnTimeout = setTimeout(function() {
htmx.ajax('POST', '{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
target: '#combat-log',
swap: 'beforeend'
}).then(function() {
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')) {
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();
}
}
});
// Handle combat end redirect
@@ -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 %}
</script>
{% endblock %}

View File

@@ -119,6 +119,14 @@ Displays character stats, resource bars, and action buttons
hx-swap="innerHTML">
🗺️ Travel to...
</button>
{# Search for Monsters - Opens modal with encounter options #}
<button class="action-btn action-btn--special action-btn--combat"
hx-get="{{ url_for('game.monster_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
⚔️ Search for Monsters
</button>
</div>
{# Actions Section #}

View File

@@ -0,0 +1,38 @@
{#
Combat Abandoned Success Message
Shows after successfully abandoning a combat session
#}
<div class="combat-abandoned-success">
<div class="success-icon">&#10004;</div>
<p class="success-message">{{ message }}</p>
<p class="success-hint">
<small>Click "Search for Monsters" to find a new encounter.</small>
</p>
<button class="btn btn-secondary" onclick="closeModal()">
Close
</button>
</div>
<style>
.combat-abandoned-success {
text-align: center;
padding: 2rem;
}
.success-icon {
font-size: 3rem;
color: var(--color-success, #28a745);
margin-bottom: 1rem;
}
.success-message {
font-size: 1.1rem;
color: var(--color-text, #e5e7eb);
margin-bottom: 0.5rem;
}
.success-hint {
color: var(--color-text-secondary, #aaa);
margin-bottom: 1.5rem;
}
</style>

View File

@@ -50,8 +50,8 @@
{# Flee Button - Direct action #}
<button class="combat-action-btn combat-action-btn--flee"
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
hx-target="body"
hx-swap="innerHTML"
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
hx-confirm="Are you sure you want to flee from combat?"
title="Attempt to escape from battle">

View File

@@ -0,0 +1,220 @@
{#
Combat Conflict Modal
Shows when player tries to start combat but already has an active combat session
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content combat-conflict-modal">
<div class="modal-header">
<h3 class="modal-title">Active Combat Detected</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="conflict-warning">
<p>You have an active combat session in progress:</p>
</div>
<div class="combat-summary">
<div class="combat-summary-header">
<span class="combat-round">Round {{ combat_info.round_number }}</span>
<span class="combat-status combat-status--{{ combat_info.status }}">{{ combat_info.status|capitalize }}</span>
</div>
<div class="combatants-section">
<div class="combatants-group combatants-group--players">
<h4>Your Party</h4>
{% for player in combat_info.players %}
<div class="combatant-summary {% if not player.is_alive %}combatant-summary--dead{% endif %}">
<span class="combatant-name">{{ player.name }}</span>
<span class="combatant-hp">
{{ player.current_hp }}/{{ player.max_hp }} HP
</span>
</div>
{% endfor %}
</div>
<div class="combatants-vs">VS</div>
<div class="combatants-group combatants-group--enemies">
<h4>Enemies</h4>
{% for enemy in combat_info.enemies %}
<div class="combatant-summary {% if not enemy.is_alive %}combatant-summary--dead{% endif %}">
<span class="combatant-name">{{ enemy.name }}</span>
<span class="combatant-hp">
{% if enemy.is_alive %}
{{ enemy.current_hp }}/{{ enemy.max_hp }} HP
{% else %}
Defeated
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="conflict-options">
<p>What would you like to do?</p>
</div>
</div>
<div class="modal-footer conflict-actions">
<a href="{{ url_for('combat.combat_view', session_id=session_id) }}"
class="btn btn-primary btn-resume">
Resume Combat
</a>
<button class="btn btn-danger btn-abandon"
hx-post="{{ url_for('game.abandon_and_start_combat', session_id=session_id) }}"
hx-vals='{"enemy_ids": {{ pending_enemy_ids|tojson }}}'
hx-swap="none"
hx-confirm="Are you sure you want to abandon your current combat? You will not receive any rewards.">
Abandon & Start New
</button>
<button class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
</div>
</div>
</div>
<style>
.combat-conflict-modal {
max-width: 500px;
}
.conflict-warning {
background: var(--color-warning-bg, rgba(255, 193, 7, 0.1));
border: 1px solid var(--color-warning, #ffc107);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.conflict-warning p {
margin: 0;
color: var(--color-warning, #ffc107);
font-weight: 500;
}
.combat-summary {
background: var(--color-surface, #2a2a2a);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.combat-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #444);
}
.combat-round {
font-weight: 600;
color: var(--color-text-secondary, #aaa);
}
.combat-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.combat-status--active {
background: var(--color-success-bg, rgba(40, 167, 69, 0.2));
color: var(--color-success, #28a745);
}
.combatants-section {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.combatants-group {
flex: 1;
}
.combatants-group h4 {
font-size: 0.85rem;
color: var(--color-text-secondary, #aaa);
margin: 0 0 0.5rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.combatants-vs {
padding: 0.5rem;
color: var(--color-text-muted, #666);
font-weight: 600;
align-self: center;
}
.combatant-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0.5rem;
background: var(--color-bg, #1a1a1a);
border-radius: 4px;
margin-bottom: 0.25rem;
font-size: 0.9rem;
}
.combatant-summary--dead {
opacity: 0.5;
text-decoration: line-through;
}
.combatant-name {
font-weight: 500;
}
.combatant-hp {
color: var(--color-text-secondary, #aaa);
font-size: 0.85rem;
}
.combatants-group--players .combatant-hp {
color: var(--color-health, #4ade80);
}
.combatants-group--enemies .combatant-hp {
color: var(--color-danger, #ef4444);
}
.conflict-options {
text-align: center;
color: var(--color-text-secondary, #aaa);
}
.conflict-options p {
margin: 0;
}
.conflict-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.conflict-actions .btn {
flex: 1;
min-width: 120px;
}
.btn-resume {
background: var(--color-primary, #8b5cf6);
}
.btn-abandon {
background: var(--color-danger, #ef4444);
}
.btn-abandon:hover {
background: var(--color-danger-hover, #dc2626);
}
</style>

View File

@@ -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 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-result combat-result--defeat">
<div class="combat-result__icon">&#128128;</div>
<h1 class="combat-result__title">Defeated</h1>
@@ -52,4 +45,3 @@
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -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 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-result combat-result--victory">
<div class="combat-result__icon">&#127942;</div>
<h1 class="combat-result__title">Victory!</h1>
@@ -47,12 +40,12 @@
{% endif %}
</div>
{# Loot Items #}
{% if rewards.items %}
{# Loot Items - use bracket notation to avoid conflict with dict.items() method #}
{% if rewards.get('items') %}
<div class="loot-section">
<h3 class="loot-title">Items Obtained</h3>
<div class="loot-list">
{% for item in rewards.items %}
{% for item in rewards.get('items', []) %}
<div class="loot-item loot-item--{{ item.rarity|default('common') }}">
<span>
{% if item.type == 'weapon' %}&#9876;
@@ -63,7 +56,7 @@
{% endif %}
</span>
<span>{{ item.name }}</span>
{% if item.quantity > 1 %}
{% if item.get('quantity', 1) > 1 %}
<span style="color: var(--text-muted);">x{{ item.quantity }}</span>
{% endif %}
</div>
@@ -81,4 +74,3 @@
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{#
Monster Selection Modal
Shows random encounter options for the current location
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content monster-modal">
<div class="modal-header">
<h3 class="modal-title">⚔️ Search for Monsters</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="monster-modal-location">
Searching near <strong>{{ location_name }}</strong>...
</p>
{% if encounters %}
<div class="encounter-options">
{% for enc in encounters %}
<button class="encounter-option encounter-option--{{ enc.challenge|lower }}"
hx-post="{{ url_for('game.start_combat', session_id=session_id) }}"
hx-vals='{"enemy_ids": {{ enc.enemies|tojson }}}'
hx-target="closest .modal-overlay"
hx-swap="outerHTML">
<div class="encounter-info">
<div class="encounter-name">{{ enc.display_name }}</div>
<div class="encounter-enemies">
{% for name in enc.enemy_names %}
<span class="enemy-badge">{{ name }}</span>
{% endfor %}
</div>
</div>
<div class="encounter-challenge challenge--{{ enc.challenge|lower }}">
{{ enc.challenge }}
</div>
</button>
{% endfor %}
</div>
<p class="monster-modal-hint">
<small>Select an encounter to begin combat. Challenge level indicates difficulty.</small>
</p>
{% else %}
<div class="encounter-empty">
<p>No monsters found in this area.</p>
<p><small>Try exploring somewhere more dangerous!</small></p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
</div>
</div>
</div>