Combat foundation complete
This commit is contained in:
@@ -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
|
# Action Execution Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -518,6 +629,20 @@ def attempt_flee(session_id: str):
|
|||||||
try:
|
try:
|
||||||
combat_service = get_combat_service()
|
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 = CombatAction(
|
||||||
action_type="flee",
|
action_type="flee",
|
||||||
target_ids=[],
|
target_ids=[],
|
||||||
@@ -629,6 +754,142 @@ def end_combat(session_id: str):
|
|||||||
# Utility Endpoints
|
# 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'])
|
@combat_bp.route('/enemies', methods=['GET'])
|
||||||
def list_enemies():
|
def list_enemies():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ tags:
|
|||||||
- rogue
|
- rogue
|
||||||
- armed
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- wilderness
|
||||||
|
- road
|
||||||
|
|
||||||
base_damage: 8
|
base_damage: 8
|
||||||
crit_chance: 0.12
|
crit_chance: 0.12
|
||||||
flee_chance: 0.45
|
flee_chance: 0.45
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ tags:
|
|||||||
- large
|
- large
|
||||||
- pack
|
- pack
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
|
||||||
base_damage: 10
|
base_damage: 10
|
||||||
crit_chance: 0.10
|
crit_chance: 0.10
|
||||||
flee_chance: 0.40
|
flee_chance: 0.40
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ tags:
|
|||||||
- goblinoid
|
- goblinoid
|
||||||
- small
|
- small
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
base_damage: 4
|
base_damage: 4
|
||||||
crit_chance: 0.05
|
crit_chance: 0.05
|
||||||
flee_chance: 0.60
|
flee_chance: 0.60
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ tags:
|
|||||||
- elite
|
- elite
|
||||||
- armed
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
base_damage: 14
|
base_damage: 14
|
||||||
crit_chance: 0.15
|
crit_chance: 0.15
|
||||||
flee_chance: 0.25
|
flee_chance: 0.25
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ tags:
|
|||||||
- small
|
- small
|
||||||
- scout
|
- scout
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
base_damage: 3
|
base_damage: 3
|
||||||
crit_chance: 0.08
|
crit_chance: 0.08
|
||||||
flee_chance: 0.70
|
flee_chance: 0.70
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ tags:
|
|||||||
- caster
|
- caster
|
||||||
- small
|
- small
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
base_damage: 3
|
base_damage: 3
|
||||||
crit_chance: 0.08
|
crit_chance: 0.08
|
||||||
flee_chance: 0.55
|
flee_chance: 0.55
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ tags:
|
|||||||
- warrior
|
- warrior
|
||||||
- armed
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
base_damage: 8
|
base_damage: 8
|
||||||
crit_chance: 0.10
|
crit_chance: 0.10
|
||||||
flee_chance: 0.45
|
flee_chance: 0.45
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ tags:
|
|||||||
- berserker
|
- berserker
|
||||||
- large
|
- large
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- wilderness
|
||||||
|
|
||||||
base_damage: 15
|
base_damage: 15
|
||||||
crit_chance: 0.15
|
crit_chance: 0.15
|
||||||
flee_chance: 0.30
|
flee_chance: 0.30
|
||||||
|
|||||||
50
api/app/data/enemies/rat.yaml
Normal file
50
api/app/data/enemies/rat.yaml
Normal 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
|
||||||
@@ -47,6 +47,11 @@ tags:
|
|||||||
- armed
|
- armed
|
||||||
- fearless
|
- fearless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
|
||||||
base_damage: 9
|
base_damage: 9
|
||||||
crit_chance: 0.08
|
crit_chance: 0.08
|
||||||
flee_chance: 0.50
|
flee_chance: 0.50
|
||||||
|
|||||||
138
api/app/data/static_items/equipment.yaml
Normal file
138
api/app/data/static_items/equipment.yaml
Normal 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
|
||||||
@@ -66,6 +66,18 @@ items:
|
|||||||
value: 12
|
value: 12
|
||||||
is_tradeable: true
|
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
|
# Undead Drops
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ class Effect:
|
|||||||
|
|
||||||
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||||
# Buff/Debuff: modify stats
|
# 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
|
result["stat_modifier"] = self.power * self.stacks
|
||||||
if self.effect_type == EffectType.BUFF:
|
if self.effect_type == EffectType.BUFF:
|
||||||
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
||||||
@@ -159,9 +164,17 @@ class Effect:
|
|||||||
Dictionary containing all effect data
|
Dictionary containing all effect data
|
||||||
"""
|
"""
|
||||||
data = asdict(self)
|
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:
|
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
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -193,16 +206,21 @@ class Effect:
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of the effect."""
|
"""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]:
|
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||||
|
stat_str = safe_value(self.stat_affected) if self.stat_affected else 'N/A'
|
||||||
return (
|
return (
|
||||||
f"Effect({self.name}, {self.effect_type.value}, "
|
f"Effect({self.name}, {safe_value(self.effect_type)}, "
|
||||||
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
|
f"{stat_str} "
|
||||||
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
||||||
f"{self.duration}t, {self.stacks}x)"
|
f"{self.duration}t, {self.stacks}x)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return (
|
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"power={self.power * self.stacks}, "
|
||||||
f"duration={self.duration}t, stacks={self.stacks}x)"
|
f"duration={self.duration}t, stacks={self.stacks}x)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ class EnemyTemplate:
|
|||||||
gold_reward_max: Maximum gold dropped
|
gold_reward_max: Maximum gold dropped
|
||||||
difficulty: Difficulty classification for encounter building
|
difficulty: Difficulty classification for encounter building
|
||||||
tags: Classification tags (e.g., ["humanoid", "goblinoid"])
|
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
|
image_url: Optional image reference for UI
|
||||||
|
|
||||||
Combat-specific attributes:
|
Combat-specific attributes:
|
||||||
@@ -149,6 +150,7 @@ class EnemyTemplate:
|
|||||||
gold_reward_max: int = 5
|
gold_reward_max: int = 5
|
||||||
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
||||||
tags: List[str] = field(default_factory=list)
|
tags: List[str] = field(default_factory=list)
|
||||||
|
location_tags: List[str] = field(default_factory=list)
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
# Combat attributes
|
# Combat attributes
|
||||||
@@ -194,6 +196,10 @@ class EnemyTemplate:
|
|||||||
"""Check if enemy has a specific tag."""
|
"""Check if enemy has a specific tag."""
|
||||||
return tag.lower() in [t.lower() for t in self.tags]
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Serialize enemy template to dictionary.
|
Serialize enemy template to dictionary.
|
||||||
@@ -213,6 +219,7 @@ class EnemyTemplate:
|
|||||||
"gold_reward_max": self.gold_reward_max,
|
"gold_reward_max": self.gold_reward_max,
|
||||||
"difficulty": self.difficulty.value,
|
"difficulty": self.difficulty.value,
|
||||||
"tags": self.tags,
|
"tags": self.tags,
|
||||||
|
"location_tags": self.location_tags,
|
||||||
"image_url": self.image_url,
|
"image_url": self.image_url,
|
||||||
"base_damage": self.base_damage,
|
"base_damage": self.base_damage,
|
||||||
"crit_chance": self.crit_chance,
|
"crit_chance": self.crit_chance,
|
||||||
@@ -259,6 +266,7 @@ class EnemyTemplate:
|
|||||||
gold_reward_max=data.get("gold_reward_max", 5),
|
gold_reward_max=data.get("gold_reward_max", 5),
|
||||||
difficulty=difficulty,
|
difficulty=difficulty,
|
||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
|
location_tags=data.get("location_tags", []),
|
||||||
image_url=data.get("image_url"),
|
image_url=data.get("image_url"),
|
||||||
base_damage=data.get("base_damage", 5),
|
base_damage=data.get("base_damage", 5),
|
||||||
crit_chance=data.get("crit_chance", 0.05),
|
crit_chance=data.get("crit_chance", 0.05),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.services.database_service import get_database_service
|
|||||||
from app.services.appwrite_service import AppwriteService
|
from app.services.appwrite_service import AppwriteService
|
||||||
from app.services.class_loader import get_class_loader
|
from app.services.class_loader import get_class_loader
|
||||||
from app.services.origin_service import get_origin_service
|
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
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
@@ -173,6 +174,23 @@ class CharacterService:
|
|||||||
current_location=starting_location_id # Set starting location
|
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
|
# Serialize character to JSON
|
||||||
character_dict = character.to_dict()
|
character_dict = character.to_dict()
|
||||||
character_json = json.dumps(character_dict)
|
character_json = json.dumps(character_dict)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.models.stats import Stats
|
|||||||
from app.models.abilities import Ability, AbilityLoader
|
from app.models.abilities import Ability, AbilityLoader
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
from app.models.items import Item
|
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.damage_calculator import DamageCalculator, DamageResult
|
||||||
from app.services.enemy_loader import EnemyLoader, get_enemy_loader
|
from app.services.enemy_loader import EnemyLoader, get_enemy_loader
|
||||||
from app.services.session_service import get_session_service
|
from app.services.session_service import get_session_service
|
||||||
@@ -94,6 +94,7 @@ class ActionResult:
|
|||||||
combat_status: Final combat status if ended
|
combat_status: Final combat status if ended
|
||||||
next_combatant_id: ID of combatant whose turn is next
|
next_combatant_id: ID of combatant whose turn is next
|
||||||
turn_effects: Effects that triggered at turn start/end
|
turn_effects: Effects that triggered at turn start/end
|
||||||
|
rewards: Combat rewards if victory (XP, gold, items)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
@@ -105,6 +106,7 @@ class ActionResult:
|
|||||||
next_combatant_id: Optional[str] = None
|
next_combatant_id: Optional[str] = None
|
||||||
next_is_player: bool = True # True if next turn is player's
|
next_is_player: bool = True # True if next turn is player's
|
||||||
turn_effects: List[Dict[str, Any]] = field(default_factory=list)
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert to dictionary for API response."""
|
"""Convert to dictionary for API response."""
|
||||||
@@ -127,6 +129,7 @@ class ActionResult:
|
|||||||
"next_combatant_id": self.next_combatant_id,
|
"next_combatant_id": self.next_combatant_id,
|
||||||
"next_is_player": self.next_is_player,
|
"next_is_player": self.next_is_player,
|
||||||
"turn_effects": self.turn_effects,
|
"turn_effects": self.turn_effects,
|
||||||
|
"rewards": self.rewards,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -451,6 +454,113 @@ class CombatService:
|
|||||||
|
|
||||||
return rewards
|
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
|
# Action Execution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -549,6 +659,7 @@ class CombatService:
|
|||||||
if status == CombatStatus.VICTORY:
|
if status == CombatStatus.VICTORY:
|
||||||
rewards = self._calculate_rewards(encounter, session, user_id)
|
rewards = self._calculate_rewards(encounter, session, user_id)
|
||||||
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold."
|
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold."
|
||||||
|
result.rewards = rewards.to_dict()
|
||||||
|
|
||||||
# End encounter in repository
|
# End encounter in repository
|
||||||
if session.active_combat_encounter_id:
|
if session.active_combat_encounter_id:
|
||||||
@@ -699,6 +810,11 @@ class CombatService:
|
|||||||
result.combat_ended = True
|
result.combat_ended = True
|
||||||
result.combat_status = status
|
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
|
# End encounter in repository
|
||||||
if session.active_combat_encounter_id:
|
if session.active_combat_encounter_id:
|
||||||
self.combat_repository.end_encounter(
|
self.combat_repository.end_encounter(
|
||||||
@@ -946,7 +1062,7 @@ class CombatService:
|
|||||||
effect_type=EffectType.BUFF,
|
effect_type=EffectType.BUFF,
|
||||||
duration=1,
|
duration=1,
|
||||||
power=5, # +5 defense
|
power=5, # +5 defense
|
||||||
stat_affected="constitution",
|
stat_affected=StatType.CONSTITUTION,
|
||||||
source="defend_action",
|
source="defend_action",
|
||||||
)
|
)
|
||||||
combatant.add_effect(defense_buff)
|
combatant.add_effect(defense_buff)
|
||||||
|
|||||||
308
api/app/services/encounter_generator.py
Normal file
308
api/app/services/encounter_generator.py
Normal 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
|
||||||
@@ -177,6 +177,46 @@ class EnemyLoader:
|
|||||||
if enemy.has_tag(tag)
|
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(
|
def get_random_enemies(
|
||||||
self,
|
self,
|
||||||
count: int = 1,
|
count: int = 1,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import yaml
|
|||||||
|
|
||||||
from app.models.items import Item
|
from app.models.items import Item
|
||||||
from app.models.effects import Effect
|
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
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
@@ -178,6 +178,20 @@ class StaticItemLoader:
|
|||||||
# Parse stat bonuses if present
|
# Parse stat bonuses if present
|
||||||
stat_bonuses = template.get("stat_bonuses", {})
|
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(
|
return Item(
|
||||||
item_id=instance_id,
|
item_id=instance_id,
|
||||||
name=template.get("name", item_id),
|
name=template.get("name", item_id),
|
||||||
@@ -188,6 +202,17 @@ class StaticItemLoader:
|
|||||||
is_tradeable=template.get("is_tradeable", True),
|
is_tradeable=template.get("is_tradeable", True),
|
||||||
stat_bonuses=stat_bonuses,
|
stat_bonuses=stat_bonuses,
|
||||||
effects_on_use=effects,
|
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]:
|
def _parse_effect(self, effect_data: Dict) -> Optional[Effect]:
|
||||||
|
|||||||
@@ -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
|
**Objective:** Playtest combat and fix bugs
|
||||||
|
|
||||||
**Testing Checklist:**
|
**Testing Checklist:**
|
||||||
- [ ] Start combat from story session
|
- ✅ Start combat from story session
|
||||||
- [ ] Turn order correct
|
- ✅ Turn order correct
|
||||||
- [ ] Attack deals damage
|
- ✅ Attack deals damage
|
||||||
- [ ] Critical hits work
|
- ✅ Critical hits work
|
||||||
- [ ] Spells consume mana
|
- [ ] Spells consume mana - unable to test
|
||||||
- [ ] Effects apply and tick correctly
|
- ✅ Effects apply and tick correctly
|
||||||
- [ ] Items can be used in combat
|
- [ ] Items can be used in combat - unable to test
|
||||||
- [ ] Defend action works
|
- ✅ Defend action works
|
||||||
- [ ] Victory awards XP/gold/loot
|
- ✅ Victory awards XP/gold/loot
|
||||||
- [ ] Defeat handling works
|
- ✅ Defeat handling works
|
||||||
- [ ] Combat log readable
|
- ✅ Combat log readable
|
||||||
- [ ] HP/MP bars update
|
- ✅ HP/MP bars update
|
||||||
- [ ] Multiple enemies work
|
- ✅ Multiple enemies work - would like to update to allow the player to select which enemy to attack
|
||||||
- [ ] Combat state persists (refresh page)
|
- ✅ Combat state persists (refresh page)
|
||||||
|
|
||||||
**Bug Fixes & Polish:**
|
**Bug Fixes & Polish:**
|
||||||
- Fix any calculation errors
|
- Fix any calculation errors
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def combat_view(session_id: str):
|
|||||||
# Check if combat is still active
|
# Check if combat is still active
|
||||||
if not result.get('in_combat'):
|
if not result.get('in_combat'):
|
||||||
# Combat ended - redirect to game play
|
# 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 {}
|
encounter = result.get('encounter') or {}
|
||||||
combat_log = result.get('combat_log', [])
|
combat_log = result.get('combat_log', [])
|
||||||
@@ -171,9 +171,11 @@ def combat_action(session_id: str):
|
|||||||
|
|
||||||
# Add any effect entries
|
# Add any effect entries
|
||||||
for effect in result.get('effects_applied', []):
|
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({
|
log_entries.append({
|
||||||
'actor': '',
|
'actor': '',
|
||||||
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
|
'message': effect.get('message', f'Effect applied: {effect_name}'),
|
||||||
'type': 'system'
|
'type': 'system'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -417,15 +419,25 @@ def combat_flee(session_id: str):
|
|||||||
result = response.get('result', {})
|
result = response.get('result', {})
|
||||||
|
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
# Flee successful - redirect to play page
|
# Flee successful - use HX-Redirect for HTMX
|
||||||
return redirect(url_for('game.play_session', session_id=session_id))
|
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:
|
else:
|
||||||
# Flee failed - return log entry
|
# Flee failed - return log entry, trigger enemy turn
|
||||||
return f'''
|
resp = make_response(f'''
|
||||||
<div class="combat-log__entry combat-log__entry--system">
|
<div class="combat-log__entry combat-log__entry--system">
|
||||||
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
|
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
|
||||||
</div>
|
</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:
|
except APIError as e:
|
||||||
logger.error("flee_failed", session_id=session_id, error=str(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
|
# Format enemy action for log
|
||||||
action_result = result.get('action_result', {})
|
# API returns ActionResult directly in result, not nested under action_result
|
||||||
log_entries = [{
|
log_entries = [{
|
||||||
'actor': action_result.get('actor_name', 'Enemy'),
|
'actor': 'Enemy',
|
||||||
'message': action_result.get('message', 'attacks'),
|
'message': result.get('message', 'attacks'),
|
||||||
'type': 'enemy',
|
'type': 'enemy',
|
||||||
'is_crit': action_result.get('is_critical', False)
|
'is_crit': False
|
||||||
}]
|
}]
|
||||||
|
|
||||||
# Add damage info
|
# Add damage info - API returns total_damage, not damage
|
||||||
damage_results = action_result.get('damage_results', [])
|
damage_results = result.get('damage_results', [])
|
||||||
if 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)
|
# Check if it's still enemy turn (multiple enemies)
|
||||||
resp = make_response(render_template(
|
resp = make_response(render_template(
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play')
|
|||||||
DEFAULT_ACTIONS = {
|
DEFAULT_ACTIONS = {
|
||||||
'free': [
|
'free': [
|
||||||
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
|
{'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': '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}
|
{'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
|
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()">×</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>')
|
@game_bp.route('/session/<session_id>/npc/<npc_id>')
|
||||||
@require_auth
|
@require_auth
|
||||||
def npc_chat_page(session_id: str, npc_id: str):
|
def npc_chat_page(session_id: str, npc_id: str):
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
--combat-system: var(--text-muted); /* Gray for system messages */
|
--combat-system: var(--text-muted); /* Gray for system messages */
|
||||||
--combat-heal: var(--accent-green); /* Green for healing */
|
--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 panel sizing */
|
||||||
--combat-sidebar-width: 280px;
|
--combat-sidebar-width: 280px;
|
||||||
--combat-header-height: 60px;
|
--combat-header-height: 60px;
|
||||||
|
|||||||
@@ -1119,6 +1119,161 @@
|
|||||||
margin-top: 0.25rem;
|
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 Modal */
|
||||||
.npc-chat-header {
|
.npc-chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="effect-name">{{ effect.name }}</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,43 +206,73 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guard against duplicate enemy turn requests
|
// Enemy turn handling with proper chaining for multiple enemies
|
||||||
let enemyTurnPending = false;
|
let enemyTurnPending = false;
|
||||||
let enemyTurnTimeout = null;
|
|
||||||
|
|
||||||
function triggerEnemyTurn() {
|
function triggerEnemyTurn() {
|
||||||
// Prevent duplicate requests
|
// Prevent duplicate requests
|
||||||
if (enemyTurnPending) {
|
if (enemyTurnPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any pending timeout
|
|
||||||
if (enemyTurnTimeout) {
|
|
||||||
clearTimeout(enemyTurnTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
enemyTurnPending = true;
|
enemyTurnPending = true;
|
||||||
enemyTurnTimeout = setTimeout(function() {
|
|
||||||
htmx.ajax('POST', '{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
|
setTimeout(function() {
|
||||||
target: '#combat-log',
|
// Use fetch instead of htmx.ajax for better control over response handling
|
||||||
swap: 'beforeend'
|
fetch('{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
|
||||||
}).then(function() {
|
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;
|
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;
|
enemyTurnPending = false;
|
||||||
|
// Refresh anyway to recover from error state
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle enemy turn polling
|
// Handle player action triggering enemy turn
|
||||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
// Check if we need to trigger enemy turn
|
|
||||||
const response = event.detail.xhr;
|
const response = event.detail.xhr;
|
||||||
if (response && response.getResponseHeader('HX-Trigger')) {
|
if (!response) return;
|
||||||
const triggers = response.getResponseHeader('HX-Trigger');
|
|
||||||
if (triggers && triggers.includes('enemyTurn')) {
|
const triggers = response.getResponseHeader('HX-Trigger') || '';
|
||||||
triggerEnemyTurn();
|
|
||||||
}
|
// 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
|
// 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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ Displays character stats, resource bars, and action buttons
|
|||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
🗺️ Travel to...
|
🗺️ Travel to...
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{# Actions Section #}
|
{# Actions Section #}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{#
|
||||||
|
Combat Abandoned Success Message
|
||||||
|
Shows after successfully abandoning a combat session
|
||||||
|
#}
|
||||||
|
<div class="combat-abandoned-success">
|
||||||
|
<div class="success-icon">✔</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>
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
{# Flee Button - Direct action #}
|
{# Flee Button - Direct action #}
|
||||||
<button class="combat-action-btn combat-action-btn--flee"
|
<button class="combat-action-btn combat-action-btn--flee"
|
||||||
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
|
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
|
||||||
hx-target="body"
|
hx-target="#combat-log"
|
||||||
hx-swap="innerHTML"
|
hx-swap="beforeend"
|
||||||
hx-disabled-elt="this"
|
hx-disabled-elt="this"
|
||||||
hx-confirm="Are you sure you want to flee from combat?"
|
hx-confirm="Are you sure you want to flee from combat?"
|
||||||
title="Attempt to escape from battle">
|
title="Attempt to escape from battle">
|
||||||
|
|||||||
220
public_web/templates/game/partials/combat_conflict_modal.html
Normal file
220
public_web/templates/game/partials/combat_conflict_modal.html
Normal 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()">×</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>
|
||||||
@@ -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 combat-result--defeat">
|
||||||
<div class="combat-result__icon">💀</div>
|
<div class="combat-result__icon">💀</div>
|
||||||
<h1 class="combat-result__title">Defeated</h1>
|
<h1 class="combat-result__title">Defeated</h1>
|
||||||
@@ -52,4 +45,3 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -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 combat-result--victory">
|
||||||
<div class="combat-result__icon">🏆</div>
|
<div class="combat-result__icon">🏆</div>
|
||||||
<h1 class="combat-result__title">Victory!</h1>
|
<h1 class="combat-result__title">Victory!</h1>
|
||||||
@@ -47,12 +40,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Loot Items #}
|
{# Loot Items - use bracket notation to avoid conflict with dict.items() method #}
|
||||||
{% if rewards.items %}
|
{% if rewards.get('items') %}
|
||||||
<div class="loot-section">
|
<div class="loot-section">
|
||||||
<h3 class="loot-title">Items Obtained</h3>
|
<h3 class="loot-title">Items Obtained</h3>
|
||||||
<div class="loot-list">
|
<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') }}">
|
<div class="loot-item loot-item--{{ item.rarity|default('common') }}">
|
||||||
<span>
|
<span>
|
||||||
{% if item.type == 'weapon' %}⚔
|
{% if item.type == 'weapon' %}⚔
|
||||||
@@ -63,7 +56,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span>{{ item.name }}</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>
|
<span style="color: var(--text-muted);">x{{ item.quantity }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -81,4 +74,3 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
53
public_web/templates/game/partials/monster_modal.html
Normal file
53
public_web/templates/game/partials/monster_modal.html
Normal 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()">×</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>
|
||||||
Reference in New Issue
Block a user