Compare commits

..

6 Commits

Author SHA1 Message Date
6d3fb63355 Combat foundation complete 2025-11-27 22:18:58 -06:00
dd92cf5991 combat testing and polishing in the dev console, many bug fixes 2025-11-27 20:37:53 -06:00
94c4ca9e95 updating docs 2025-11-27 11:51:21 -06:00
19b537d8b0 updating docs 2025-11-27 11:50:06 -06:00
58f0c1b8f6 trimming phase 4 planning doc 2025-11-27 00:10:26 -06:00
29b4853c84 updating docs 2025-11-27 00:05:33 -06:00
68 changed files with 11025 additions and 3753 deletions

View File

@@ -100,7 +100,7 @@ def start_combat():
combat_service = get_combat_service()
encounter = combat_service.start_combat(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
enemy_ids=enemy_ids,
)
@@ -139,9 +139,9 @@ def start_combat():
logger.warning("Attempt to start combat while already in combat",
session_id=session_id)
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="ALREADY_IN_COMBAT"
code="ALREADY_IN_COMBAT"
)
except ValueError as e:
logger.warning("Invalid enemy ID",
@@ -154,9 +154,9 @@ def start_combat():
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to start combat",
error_code="COMBAT_START_ERROR"
code="COMBAT_START_ERROR"
)
@@ -196,7 +196,7 @@ def get_combat_state(session_id: str):
try:
combat_service = get_combat_service()
encounter = combat_service.get_combat_state(session_id, user["user_id"])
encounter = combat_service.get_combat_state(session_id, user.id)
if not encounter:
return success_response({
@@ -245,9 +245,120 @@ def get_combat_state(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to get combat state",
error_code="COMBAT_STATE_ERROR"
code="COMBAT_STATE_ERROR"
)
@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"
)
@@ -306,11 +417,27 @@ def execute_action(session_id: str):
combatant_id = data.get("combatant_id")
action_type = data.get("action_type")
# If combatant_id not provided, auto-detect player combatant
if not combatant_id:
return validation_error_response(
message="combatant_id is required",
details={"field": "combatant_id", "issue": "Missing required field"}
)
try:
combat_service = get_combat_service()
encounter = combat_service.get_combat_state(session_id, user.id)
if encounter:
for combatant in encounter.combatants:
if combatant.is_player:
combatant_id = combatant.combatant_id
break
if not combatant_id:
return validation_error_response(
message="Could not determine player combatant",
details={"field": "combatant_id", "issue": "No player found in combat"}
)
except Exception as e:
logger.error("Failed to auto-detect combatant", error=str(e))
return validation_error_response(
message="combatant_id is required",
details={"field": "combatant_id", "issue": "Missing required field"}
)
if not action_type:
return validation_error_response(
@@ -335,16 +462,21 @@ def execute_action(session_id: str):
try:
combat_service = get_combat_service()
# Support both target_id (singular) and target_ids (array)
target_ids = data.get("target_ids", [])
if not target_ids and data.get("target_id"):
target_ids = [data.get("target_id")]
action = CombatAction(
action_type=action_type,
target_ids=data.get("target_ids", []),
target_ids=target_ids,
ability_id=data.get("ability_id"),
item_id=data.get("item_id"),
)
result = combat_service.execute_action(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
combatant_id=combatant_id,
action=action,
)
@@ -367,9 +499,9 @@ def execute_action(session_id: str):
combatant_id=combatant_id,
error=str(e))
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INVALID_ACTION"
code="INVALID_ACTION"
)
except InsufficientResourceError as e:
logger.warning("Insufficient resources for action",
@@ -377,9 +509,9 @@ def execute_action(session_id: str):
combatant_id=combatant_id,
error=str(e))
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INSUFFICIENT_RESOURCES"
code="INSUFFICIENT_RESOURCES"
)
except Exception as e:
logger.error("Failed to execute combat action",
@@ -389,9 +521,9 @@ def execute_action(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to execute action",
error_code="ACTION_EXECUTION_ERROR"
code="ACTION_EXECUTION_ERROR"
)
@@ -428,7 +560,7 @@ def execute_enemy_turn(session_id: str):
combat_service = get_combat_service()
result = combat_service.execute_enemy_turn(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
)
logger.info("Enemy turn executed",
@@ -441,9 +573,9 @@ def execute_enemy_turn(session_id: str):
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INVALID_ACTION"
code="INVALID_ACTION"
)
except Exception as e:
logger.error("Failed to execute enemy turn",
@@ -451,9 +583,9 @@ def execute_enemy_turn(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to execute enemy turn",
error_code="ENEMY_TURN_ERROR"
code="ENEMY_TURN_ERROR"
)
@@ -497,6 +629,20 @@ def attempt_flee(session_id: str):
try:
combat_service = get_combat_service()
# If combatant_id not provided, auto-detect player combatant
if not combatant_id:
encounter = combat_service.get_combat_state(session_id, user.id)
if encounter:
for combatant in encounter.combatants:
if combatant.is_player:
combatant_id = combatant.combatant_id
break
if not combatant_id:
return validation_error_response(
message="Could not determine player combatant",
details={"field": "combatant_id", "issue": "No player found in combat"}
)
action = CombatAction(
action_type="flee",
target_ids=[],
@@ -504,7 +650,7 @@ def attempt_flee(session_id: str):
result = combat_service.execute_action(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
combatant_id=combatant_id,
action=action,
)
@@ -515,9 +661,9 @@ def attempt_flee(session_id: str):
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INVALID_ACTION"
code="INVALID_ACTION"
)
except Exception as e:
logger.error("Failed flee attempt",
@@ -525,9 +671,9 @@ def attempt_flee(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to attempt flee",
error_code="FLEE_ERROR"
code="FLEE_ERROR"
)
@@ -577,7 +723,7 @@ def end_combat(session_id: str):
combat_service = get_combat_service()
rewards = combat_service.end_combat(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
outcome=outcome,
)
@@ -598,9 +744,9 @@ def end_combat(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to end combat",
error_code="COMBAT_END_ERROR"
code="COMBAT_END_ERROR"
)
@@ -608,6 +754,142 @@ def end_combat(session_id: str):
# Utility Endpoints
# =============================================================================
@combat_bp.route('/encounters', methods=['GET'])
@require_auth
def get_encounters():
"""
Get random encounter options for the current location.
Generates multiple encounter groups appropriate for the player's
current location and character level. Used by the "Search for Monsters"
feature on the story page.
Query Parameters:
session_id: Game session ID (required)
Returns:
{
"location_name": "Thornwood Forest",
"location_type": "wilderness",
"encounters": [
{
"group_id": "enc_abc123",
"enemies": ["goblin", "goblin", "goblin_scout"],
"enemy_names": ["Goblin Scout", "Goblin Scout", "Goblin Scout"],
"display_name": "3 Goblin Scouts",
"challenge": "Easy"
},
...
]
}
Errors:
400: Missing session_id
404: Session not found or no character
404: No enemies found for location
"""
from app.services.session_service import get_session_service
from app.services.character_service import get_character_service
from app.services.encounter_generator import get_encounter_generator
user = get_current_user()
# Get session_id from query params
session_id = request.args.get("session_id")
if not session_id:
return validation_error_response(
message="session_id query parameter is required",
details={"field": "session_id", "issue": "Missing required parameter"}
)
try:
# Get session to find location and character
session_service = get_session_service()
session = session_service.get_session(session_id, user.id)
if not session:
return not_found_response(message=f"Session not found: {session_id}")
# Get character level
character_id = session.get_character_id()
if not character_id:
return not_found_response(message="No character found in session")
character_service = get_character_service()
character = character_service.get_character(character_id, user.id)
if not character:
return not_found_response(message=f"Character not found: {character_id}")
# Get location info from game state
location_name = session.game_state.current_location
location_type = session.game_state.location_type.value
# Map location types to enemy location_tags
# Some location types may need mapping to available enemy tags
location_type_mapping = {
"town": "town",
"village": "town", # Treat village same as town
"tavern": "tavern",
"wilderness": "wilderness",
"forest": "forest",
"dungeon": "dungeon",
"ruins": "ruins",
"crypt": "crypt",
"road": "road",
"safe_area": "town", # Safe areas might have rats/vermin
"library": "dungeon", # Libraries might have undead guardians
}
mapped_location = location_type_mapping.get(location_type, location_type)
# Generate encounters
encounter_generator = get_encounter_generator()
encounters = encounter_generator.generate_encounters(
location_type=mapped_location,
character_level=character.level,
num_encounters=4
)
# If no encounters found, try wilderness as fallback
if not encounters and mapped_location != "wilderness":
encounters = encounter_generator.generate_encounters(
location_type="wilderness",
character_level=character.level,
num_encounters=4
)
if not encounters:
return not_found_response(
message=f"No enemies found for location type: {location_type}"
)
# Format response
response_data = {
"location_name": location_name,
"location_type": location_type,
"encounters": [enc.to_dict() for enc in encounters]
}
logger.info("Generated encounter options",
session_id=session_id,
location_type=location_type,
character_level=character.level,
num_encounters=len(encounters))
return success_response(response_data)
except Exception as e:
logger.error("Failed to generate encounters",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to generate encounters",
code="ENCOUNTER_GENERATION_ERROR"
)
@combat_bp.route('/enemies', methods=['GET'])
def list_enemies():
"""
@@ -680,9 +962,9 @@ def list_enemies():
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to list enemies",
error_code="ENEMY_LIST_ERROR"
code="ENEMY_LIST_ERROR"
)
@@ -723,7 +1005,89 @@ def get_enemy_details(enemy_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to get enemy details",
error_code="ENEMY_DETAILS_ERROR"
code="ENEMY_DETAILS_ERROR"
)
# =============================================================================
# Debug Endpoints
# =============================================================================
@combat_bp.route('/<session_id>/debug/reset-hp-mp', methods=['POST'])
@require_auth
def debug_reset_hp_mp(session_id: str):
"""
Reset player combatant's HP and MP to full (debug endpoint).
This is a debug-only endpoint for testing combat without using items.
Resets the player's current_hp to max_hp and current_mp to max_mp.
Path Parameters:
session_id: Game session ID
Returns:
{
"success": true,
"message": "HP and MP reset to full",
"current_hp": 100,
"max_hp": 100,
"current_mp": 50,
"max_mp": 50
}
Errors:
404: Session not in combat
"""
from app.services.session_service import get_session_service
user = get_current_user()
try:
session_service = get_session_service()
session = session_service.get_session(session_id, user.id)
if not session or not session.combat_encounter:
return not_found_response(message="Session is not in combat")
encounter = session.combat_encounter
# Find player combatant and reset HP/MP
player_combatant = None
for combatant in encounter.combatants:
if combatant.is_player:
combatant.current_hp = combatant.max_hp
combatant.current_mp = combatant.max_mp
player_combatant = combatant
break
if not player_combatant:
return not_found_response(message="No player combatant found in combat")
# Save the updated session state
session_service.update_session(session)
logger.info("Debug: HP/MP reset",
session_id=session_id,
combatant_id=player_combatant.combatant_id)
return success_response({
"success": True,
"message": "HP and MP reset to full",
"current_hp": player_combatant.current_hp,
"max_hp": player_combatant.max_hp,
"current_mp": player_combatant.current_mp,
"max_mp": player_combatant.max_mp,
})
except Exception as e:
logger.error("Failed to reset HP/MP",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to reset HP/MP",
code="DEBUG_RESET_ERROR"
)

View File

@@ -132,23 +132,44 @@ def list_sessions():
user = get_current_user()
user_id = user.id
session_service = get_session_service()
character_service = get_character_service()
# Get user's active sessions
sessions = session_service.get_user_sessions(user_id, active_only=True)
# Build character name lookup for efficiency
character_ids = [s.solo_character_id for s in sessions if s.solo_character_id]
character_names = {}
for char_id in character_ids:
try:
char = character_service.get_character(char_id, user_id)
if char:
character_names[char_id] = char.name
except Exception:
pass # Character may have been deleted
# Build response with basic session info
sessions_list = []
for session in sessions:
# Get combat round if in combat
combat_round = None
if session.is_in_combat() and session.combat_encounter:
combat_round = session.combat_encounter.round_number
sessions_list.append({
'session_id': session.session_id,
'character_id': session.solo_character_id,
'character_name': character_names.get(session.solo_character_id),
'turn_number': session.turn_number,
'status': session.status.value,
'created_at': session.created_at,
'last_activity': session.last_activity,
'in_combat': session.is_in_combat(),
'game_state': {
'current_location': session.game_state.current_location,
'location_type': session.game_state.location_type.value
'location_type': session.game_state.location_type.value,
'in_combat': session.is_in_combat(),
'combat_round': combat_round
}
})
@@ -485,10 +506,12 @@ def get_session_state(session_id: str):
"character_id": session.get_character_id(),
"turn_number": session.turn_number,
"status": session.status.value,
"in_combat": session.is_in_combat(),
"game_state": {
"current_location": session.game_state.current_location,
"location_type": session.game_state.location_type.value,
"active_quests": session.game_state.active_quests
"active_quests": session.game_state.active_quests,
"in_combat": session.is_in_combat()
},
"available_actions": available_actions
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
# Giant Rat - Very easy enemy for starting areas (town, village, tavern)
# A basic enemy for new players to learn combat mechanics
enemy_id: rat
name: Giant Rat
description: >
A mangy rat the size of a small dog. These vermin infest cellars,
sewers, and dark corners of civilization. Weak alone but annoying in packs.
base_stats:
strength: 4
dexterity: 14
constitution: 4
intelligence: 2
wisdom: 8
charisma: 2
luck: 6
abilities:
- basic_attack
loot_table:
- item_id: rat_tail
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.20
quantity_min: 1
quantity_max: 2
experience_reward: 5
gold_reward_min: 0
gold_reward_max: 2
difficulty: easy
tags:
- beast
- vermin
- small
location_tags:
- town
- village
- tavern
- dungeon
base_damage: 2
crit_chance: 0.03
flee_chance: 0.80

View File

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

View File

@@ -0,0 +1,138 @@
# Starting Equipment - Basic items given to new characters based on their class
# These are all common-quality items suitable for Level 1 characters
items:
# ==================== WEAPONS ====================
# Melee Weapons
rusty_sword:
name: Rusty Sword
item_type: weapon
rarity: common
description: >
A battered old sword showing signs of age and neglect.
Its edge is dull but it can still cut.
value: 5
damage: 4
damage_type: physical
is_tradeable: true
rusty_mace:
name: Rusty Mace
item_type: weapon
rarity: common
description: >
A worn mace with a tarnished head. The weight still
makes it effective for crushing blows.
value: 5
damage: 5
damage_type: physical
is_tradeable: true
rusty_dagger:
name: Rusty Dagger
item_type: weapon
rarity: common
description: >
A corroded dagger with a chipped blade. Quick and
deadly in the right hands despite its condition.
value: 4
damage: 3
damage_type: physical
crit_chance: 0.10 # Daggers have higher crit chance
is_tradeable: true
rusty_knife:
name: Rusty Knife
item_type: weapon
rarity: common
description: >
A simple utility knife, more tool than weapon. Every
adventurer keeps one handy for various tasks.
value: 2
damage: 2
damage_type: physical
is_tradeable: true
# Ranged Weapons
rusty_bow:
name: Rusty Bow
item_type: weapon
rarity: common
description: >
An old hunting bow with a frayed string. It still fires
true enough for an aspiring ranger.
value: 5
damage: 4
damage_type: physical
is_tradeable: true
# Magical Weapons (spell_power instead of damage)
worn_staff:
name: Worn Staff
item_type: weapon
rarity: common
description: >
A gnarled wooden staff weathered by time. Faint traces
of arcane energy still pulse through its core.
value: 6
damage: 2 # Low physical damage for staff strikes
spell_power: 4 # Boosts spell damage
damage_type: arcane
is_tradeable: true
bone_wand:
name: Bone Wand
item_type: weapon
rarity: common
description: >
A wand carved from ancient bone, cold to the touch.
It resonates with dark energy.
value: 6
damage: 1 # Minimal physical damage
spell_power: 5 # Higher spell power for dedicated casters
damage_type: shadow # Dark/shadow magic for necromancy
is_tradeable: true
tome:
name: Worn Tome
item_type: weapon
rarity: common
description: >
A leather-bound book filled with faded notes and arcane
formulas. Knowledge is power made manifest.
value: 6
damage: 1 # Can bonk someone with it
spell_power: 4 # Boosts spell damage
damage_type: arcane
is_tradeable: true
# ==================== ARMOR ====================
cloth_armor:
name: Cloth Armor
item_type: armor
rarity: common
description: >
Simple padded cloth garments offering minimal protection.
Better than nothing, barely.
value: 5
defense: 2
resistance: 1
is_tradeable: true
# ==================== SHIELDS/ACCESSORIES ====================
rusty_shield:
name: Rusty Shield
item_type: armor
rarity: common
description: >
A battered wooden shield with a rusted metal rim.
It can still block a blow or two.
value: 5
defense: 3
resistance: 0
stat_bonuses:
constitution: 1
is_tradeable: true

View File

@@ -66,6 +66,18 @@ items:
value: 12
is_tradeable: true
# ==========================================================================
# Vermin Drops
# ==========================================================================
rat_tail:
name: "Rat Tail"
item_type: quest_item
rarity: common
description: "A scaly tail from a giant rat. Sometimes collected for pest control bounties."
value: 1
is_tradeable: true
# ==========================================================================
# Undead Drops
# ==========================================================================

View File

@@ -349,14 +349,32 @@ class CombatEncounter:
return None
def advance_turn(self) -> None:
"""Advance to the next combatant's turn."""
self.current_turn_index += 1
"""Advance to the next alive combatant's turn, skipping dead combatants."""
# Track starting position to detect full cycle
start_index = self.current_turn_index
rounds_advanced = 0
# If we've cycled through all combatants, start a new round
if self.current_turn_index >= len(self.turn_order):
self.current_turn_index = 0
self.round_number += 1
self.log_action("round_start", None, f"Round {self.round_number} begins")
while True:
self.current_turn_index += 1
# If we've cycled through all combatants, start a new round
if self.current_turn_index >= len(self.turn_order):
self.current_turn_index = 0
self.round_number += 1
rounds_advanced += 1
self.log_action("round_start", None, f"Round {self.round_number} begins")
# Get the current combatant
current = self.get_current_combatant()
# If combatant is alive, their turn starts
if current and current.is_alive():
break
# Safety check: if we've gone through all combatants twice without finding
# someone alive, break to avoid infinite loop (combat should end)
if rounds_advanced >= 2:
break
def start_turn(self) -> List[Dict[str, Any]]:
"""

View File

@@ -86,7 +86,12 @@ class Effect:
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
# Buff/Debuff: modify stats
result["stat_affected"] = self.stat_affected.value if self.stat_affected else None
# Handle stat_affected being Enum or string
if self.stat_affected:
stat_value = self.stat_affected.value if hasattr(self.stat_affected, 'value') else self.stat_affected
else:
stat_value = None
result["stat_affected"] = stat_value
result["stat_modifier"] = self.power * self.stacks
if self.effect_type == EffectType.BUFF:
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
@@ -159,9 +164,17 @@ class Effect:
Dictionary containing all effect data
"""
data = asdict(self)
data["effect_type"] = self.effect_type.value
# Handle effect_type (could be Enum or string)
if hasattr(self.effect_type, 'value'):
data["effect_type"] = self.effect_type.value
else:
data["effect_type"] = self.effect_type
# Handle stat_affected (could be Enum, string, or None)
if self.stat_affected:
data["stat_affected"] = self.stat_affected.value
if hasattr(self.stat_affected, 'value'):
data["stat_affected"] = self.stat_affected.value
else:
data["stat_affected"] = self.stat_affected
return data
@classmethod
@@ -193,16 +206,21 @@ class Effect:
def __repr__(self) -> str:
"""String representation of the effect."""
# Helper to safely get value from Enum or string
def safe_value(obj):
return obj.value if hasattr(obj, 'value') else obj
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
stat_str = safe_value(self.stat_affected) if self.stat_affected else 'N/A'
return (
f"Effect({self.name}, {self.effect_type.value}, "
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
f"Effect({self.name}, {safe_value(self.effect_type)}, "
f"{stat_str} "
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
f"{self.duration}t, {self.stacks}x)"
)
else:
return (
f"Effect({self.name}, {self.effect_type.value}, "
f"Effect({self.name}, {safe_value(self.effect_type)}, "
f"power={self.power * self.stacks}, "
f"duration={self.duration}t, stacks={self.stacks}x)"
)

View File

@@ -130,6 +130,7 @@ class EnemyTemplate:
gold_reward_max: Maximum gold dropped
difficulty: Difficulty classification for encounter building
tags: Classification tags (e.g., ["humanoid", "goblinoid"])
location_tags: Location types where this enemy appears (e.g., ["forest", "dungeon"])
image_url: Optional image reference for UI
Combat-specific attributes:
@@ -149,6 +150,7 @@ class EnemyTemplate:
gold_reward_max: int = 5
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
tags: List[str] = field(default_factory=list)
location_tags: List[str] = field(default_factory=list)
image_url: Optional[str] = None
# Combat attributes
@@ -194,6 +196,10 @@ class EnemyTemplate:
"""Check if enemy has a specific tag."""
return tag.lower() in [t.lower() for t in self.tags]
def has_location_tag(self, location_type: str) -> bool:
"""Check if enemy can appear at a specific location type."""
return location_type.lower() in [t.lower() for t in self.location_tags]
def to_dict(self) -> Dict[str, Any]:
"""
Serialize enemy template to dictionary.
@@ -213,6 +219,7 @@ class EnemyTemplate:
"gold_reward_max": self.gold_reward_max,
"difficulty": self.difficulty.value,
"tags": self.tags,
"location_tags": self.location_tags,
"image_url": self.image_url,
"base_damage": self.base_damage,
"crit_chance": self.crit_chance,
@@ -259,6 +266,7 @@ class EnemyTemplate:
gold_reward_max=data.get("gold_reward_max", 5),
difficulty=difficulty,
tags=data.get("tags", []),
location_tags=data.get("location_tags", []),
image_url=data.get("image_url"),
base_damage=data.get("base_damage", 5),
crit_chance=data.get("crit_chance", 0.05),

View File

@@ -167,7 +167,8 @@ class GameSession:
user_id: Owner of the session
party_member_ids: Character IDs in this party (multiplayer only)
config: Session configuration settings
combat_encounter: Current combat (None if not in combat)
combat_encounter: Legacy inline combat data (None if not in combat)
active_combat_encounter_id: Reference to combat_encounters table (new system)
conversation_history: Turn-by-turn log of actions and DM responses
game_state: Current world/quest state
turn_order: Character turn order
@@ -184,7 +185,8 @@ class GameSession:
user_id: str = ""
party_member_ids: List[str] = field(default_factory=list)
config: SessionConfig = field(default_factory=SessionConfig)
combat_encounter: Optional[CombatEncounter] = None
combat_encounter: Optional[CombatEncounter] = None # Legacy: inline combat data
active_combat_encounter_id: Optional[str] = None # New: reference to combat_encounters table
conversation_history: List[ConversationEntry] = field(default_factory=list)
game_state: GameState = field(default_factory=GameState)
turn_order: List[str] = field(default_factory=list)
@@ -202,8 +204,13 @@ class GameSession:
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def is_in_combat(self) -> bool:
"""Check if session is currently in combat."""
return self.combat_encounter is not None
"""
Check if session is currently in combat.
Checks both the new database reference and legacy inline storage
for backward compatibility.
"""
return self.active_combat_encounter_id is not None or self.combat_encounter is not None
def start_combat(self, encounter: CombatEncounter) -> None:
"""
@@ -341,6 +348,7 @@ class GameSession:
"party_member_ids": self.party_member_ids,
"config": self.config.to_dict(),
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
"active_combat_encounter_id": self.active_combat_encounter_id,
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
"game_state": self.game_state.to_dict(),
"turn_order": self.turn_order,
@@ -382,6 +390,7 @@ class GameSession:
party_member_ids=data.get("party_member_ids", []),
config=config,
combat_encounter=combat_encounter,
active_combat_encounter_id=data.get("active_combat_encounter_id"),
conversation_history=conversation_history,
game_state=game_state,
turn_order=data.get("turn_order", []),

View File

@@ -21,6 +21,7 @@ from app.services.database_service import get_database_service
from app.services.appwrite_service import AppwriteService
from app.services.class_loader import get_class_loader
from app.services.origin_service import get_origin_service
from app.services.static_item_loader import get_static_item_loader
from app.utils.logging import get_logger
logger = get_logger(__file__)
@@ -173,6 +174,23 @@ class CharacterService:
current_location=starting_location_id # Set starting location
)
# Add starting equipment to inventory
if player_class.starting_equipment:
item_loader = get_static_item_loader()
for item_id in player_class.starting_equipment:
item = item_loader.get_item(item_id)
if item:
character.add_item(item)
logger.debug("Added starting equipment",
character_id=character_id,
item_id=item_id,
item_name=item.name)
else:
logger.warning("Starting equipment item not found",
character_id=character_id,
item_id=item_id,
class_id=class_id)
# Serialize character to JSON
character_dict = character.to_dict()
character_json = json.dumps(character_dict)
@@ -1074,9 +1092,9 @@ class CharacterService:
character_json = json.dumps(character_dict)
# Update in database
self.db.update_document(
collection_id=self.collection_id,
document_id=character.character_id,
self.db.update_row(
table_id=self.collection_id,
row_id=character.character_id,
data={'characterData': character_json}
)

View File

@@ -0,0 +1,578 @@
"""
Combat Repository - Database operations for combat encounters.
This service handles all CRUD operations for combat data stored in
dedicated database tables (combat_encounters, combat_rounds).
Separates combat persistence from the CombatService which handles
business logic and game mechanics.
"""
import json
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from appwrite.query import Query
from app.models.combat import CombatEncounter, Combatant
from app.models.enums import CombatStatus
from app.services.database_service import get_database_service, DatabaseService
from app.utils.logging import get_logger
logger = get_logger(__file__)
# =============================================================================
# Exceptions
# =============================================================================
class CombatEncounterNotFound(Exception):
"""Raised when combat encounter is not found in database."""
pass
class CombatRoundNotFound(Exception):
"""Raised when combat round is not found in database."""
pass
# =============================================================================
# Combat Repository
# =============================================================================
class CombatRepository:
"""
Repository for combat encounter database operations.
Handles:
- Creating and reading combat encounters
- Updating combat state during actions
- Saving per-round history for logging and replay
- Time-based cleanup of old combat data
Tables:
- combat_encounters: Main encounter state and metadata
- combat_rounds: Per-round action history
"""
# Table IDs
ENCOUNTERS_TABLE = "combat_encounters"
ROUNDS_TABLE = "combat_rounds"
# Default retention period for cleanup (days)
DEFAULT_RETENTION_DAYS = 7
def __init__(self, db: Optional[DatabaseService] = None):
"""
Initialize the combat repository.
Args:
db: Optional DatabaseService instance (for testing/injection)
"""
self.db = db or get_database_service()
logger.info("CombatRepository initialized")
# =========================================================================
# Encounter CRUD Operations
# =========================================================================
def create_encounter(
self,
encounter: CombatEncounter,
session_id: str,
user_id: str
) -> str:
"""
Create a new combat encounter record.
Args:
encounter: CombatEncounter instance to persist
session_id: Game session ID this encounter belongs to
user_id: Owner user ID for authorization
Returns:
encounter_id of created record
"""
created_at = self._get_timestamp()
data = {
'sessionId': session_id,
'userId': user_id,
'status': encounter.status.value,
'roundNumber': encounter.round_number,
'currentTurnIndex': encounter.current_turn_index,
'turnOrder': json.dumps(encounter.turn_order),
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
'combatLog': json.dumps(encounter.combat_log),
'created_at': created_at,
}
self.db.create_row(
table_id=self.ENCOUNTERS_TABLE,
data=data,
row_id=encounter.encounter_id
)
logger.info("Combat encounter created",
encounter_id=encounter.encounter_id,
session_id=session_id,
combatant_count=len(encounter.combatants))
return encounter.encounter_id
def get_encounter(self, encounter_id: str) -> Optional[CombatEncounter]:
"""
Get a combat encounter by ID.
Args:
encounter_id: Encounter ID to fetch
Returns:
CombatEncounter or None if not found
"""
logger.info("Fetching encounter from database",
encounter_id=encounter_id)
row = self.db.get_row(self.ENCOUNTERS_TABLE, encounter_id)
if not row:
logger.warning("Encounter not found", encounter_id=encounter_id)
return None
logger.info("Raw database row data",
encounter_id=encounter_id,
currentTurnIndex=row.data.get('currentTurnIndex'),
roundNumber=row.data.get('roundNumber'))
encounter = self._row_to_encounter(row.data, encounter_id)
logger.info("Encounter object created",
encounter_id=encounter_id,
current_turn_index=encounter.current_turn_index,
turn_order=encounter.turn_order)
return encounter
def get_encounter_by_session(
self,
session_id: str,
active_only: bool = True
) -> Optional[CombatEncounter]:
"""
Get combat encounter for a session.
Args:
session_id: Game session ID
active_only: If True, only return active encounters
Returns:
CombatEncounter or None if not found
"""
queries = [Query.equal('sessionId', session_id)]
if active_only:
queries.append(Query.equal('status', CombatStatus.ACTIVE.value))
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=queries,
limit=1
)
if not rows:
return None
row = rows[0]
return self._row_to_encounter(row.data, row.id)
def get_user_active_encounters(self, user_id: str) -> List[CombatEncounter]:
"""
Get all active encounters for a user.
Args:
user_id: User ID to query
Returns:
List of active CombatEncounter instances
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[
Query.equal('userId', user_id),
Query.equal('status', CombatStatus.ACTIVE.value)
],
limit=25
)
return [self._row_to_encounter(row.data, row.id) for row in rows]
def update_encounter(self, encounter: CombatEncounter) -> None:
"""
Update an existing combat encounter.
Call this after each action to persist the updated state.
Args:
encounter: CombatEncounter with updated state
"""
data = {
'status': encounter.status.value,
'roundNumber': encounter.round_number,
'currentTurnIndex': encounter.current_turn_index,
'turnOrder': json.dumps(encounter.turn_order),
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
'combatLog': json.dumps(encounter.combat_log),
}
logger.info("Saving encounter to database",
encounter_id=encounter.encounter_id,
current_turn_index=encounter.current_turn_index,
combat_log_entries=len(encounter.combat_log))
self.db.update_row(
table_id=self.ENCOUNTERS_TABLE,
row_id=encounter.encounter_id,
data=data
)
logger.info("Encounter saved successfully",
encounter_id=encounter.encounter_id)
def end_encounter(
self,
encounter_id: str,
status: CombatStatus
) -> None:
"""
Mark an encounter as ended.
Args:
encounter_id: Encounter ID to end
status: Final status (VICTORY, DEFEAT, FLED)
"""
ended_at = self._get_timestamp()
data = {
'status': status.value,
'ended_at': ended_at,
}
self.db.update_row(
table_id=self.ENCOUNTERS_TABLE,
row_id=encounter_id,
data=data
)
logger.info("Combat encounter ended",
encounter_id=encounter_id,
status=status.value)
def delete_encounter(self, encounter_id: str) -> bool:
"""
Delete an encounter and all its rounds.
Args:
encounter_id: Encounter ID to delete
Returns:
True if deleted successfully
"""
# Delete rounds first
self._delete_rounds_for_encounter(encounter_id)
# Delete encounter
result = self.db.delete_row(self.ENCOUNTERS_TABLE, encounter_id)
logger.info("Combat encounter deleted", encounter_id=encounter_id)
return result
# =========================================================================
# Round Operations
# =========================================================================
def save_round(
self,
encounter_id: str,
session_id: str,
round_number: int,
actions: List[Dict[str, Any]],
states_start: List[Combatant],
states_end: List[Combatant]
) -> str:
"""
Save a completed round's data for history/replay.
Call this at the end of each round (after all combatants have acted).
Args:
encounter_id: Parent encounter ID
session_id: Game session ID (denormalized for queries)
round_number: Round number (1-indexed)
actions: List of all actions taken this round
states_start: Combatant states at round start
states_end: Combatant states at round end
Returns:
round_id of created record
"""
round_id = f"rnd_{uuid4().hex[:12]}"
created_at = self._get_timestamp()
data = {
'encounterId': encounter_id,
'sessionId': session_id,
'roundNumber': round_number,
'actionsData': json.dumps(actions),
'combatantStatesStart': json.dumps([c.to_dict() for c in states_start]),
'combatantStatesEnd': json.dumps([c.to_dict() for c in states_end]),
'created_at': created_at,
}
self.db.create_row(
table_id=self.ROUNDS_TABLE,
data=data,
row_id=round_id
)
logger.debug("Combat round saved",
round_id=round_id,
encounter_id=encounter_id,
round_number=round_number,
action_count=len(actions))
return round_id
def get_encounter_rounds(
self,
encounter_id: str,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get all rounds for an encounter, ordered by round number.
Args:
encounter_id: Encounter ID to fetch rounds for
limit: Maximum number of rounds to return
Returns:
List of round data dictionaries
"""
rows = self.db.list_rows(
table_id=self.ROUNDS_TABLE,
queries=[Query.equal('encounterId', encounter_id)],
limit=limit
)
rounds = []
for row in rows:
rounds.append({
'round_id': row.id,
'round_number': row.data.get('roundNumber'),
'actions': json.loads(row.data.get('actionsData', '[]')),
'states_start': json.loads(row.data.get('combatantStatesStart', '[]')),
'states_end': json.loads(row.data.get('combatantStatesEnd', '[]')),
'created_at': row.data.get('created_at'),
})
# Sort by round number
return sorted(rounds, key=lambda r: r['round_number'])
def get_session_combat_history(
self,
session_id: str,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
Get combat history for a session.
Returns summary of all encounters for the session.
Args:
session_id: Game session ID
limit: Maximum encounters to return
Returns:
List of encounter summaries
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[Query.equal('sessionId', session_id)],
limit=limit
)
history = []
for row in rows:
history.append({
'encounter_id': row.id,
'status': row.data.get('status'),
'round_count': row.data.get('roundNumber', 1),
'created_at': row.data.get('created_at'),
'ended_at': row.data.get('ended_at'),
})
# Sort by created_at descending (newest first)
return sorted(history, key=lambda h: h['created_at'] or '', reverse=True)
# =========================================================================
# Cleanup Operations
# =========================================================================
def delete_encounters_by_session(self, session_id: str) -> int:
"""
Delete all encounters for a session.
Call this when a session is deleted.
Args:
session_id: Session ID to clean up
Returns:
Number of encounters deleted
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[Query.equal('sessionId', session_id)],
limit=100
)
deleted = 0
for row in rows:
# Delete rounds first
self._delete_rounds_for_encounter(row.id)
# Delete encounter
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
deleted += 1
if deleted > 0:
logger.info("Deleted encounters for session",
session_id=session_id,
deleted_count=deleted)
return deleted
def delete_old_encounters(
self,
older_than_days: int = DEFAULT_RETENTION_DAYS
) -> int:
"""
Delete ended encounters older than specified days.
This is the main cleanup method for time-based retention.
Should be scheduled to run periodically (daily recommended).
Args:
older_than_days: Delete encounters ended more than this many days ago
Returns:
Number of encounters deleted
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
cutoff_str = cutoff.isoformat().replace("+00:00", "Z")
# Find old ended encounters
# Note: We only delete ended encounters, not active ones
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[
Query.notEqual('status', CombatStatus.ACTIVE.value),
Query.lessThan('created_at', cutoff_str)
],
limit=100
)
deleted = 0
for row in rows:
self._delete_rounds_for_encounter(row.id)
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
deleted += 1
if deleted > 0:
logger.info("Deleted old combat encounters",
deleted_count=deleted,
older_than_days=older_than_days)
return deleted
# =========================================================================
# Helper Methods
# =========================================================================
def _delete_rounds_for_encounter(self, encounter_id: str) -> int:
"""
Delete all rounds for an encounter.
Args:
encounter_id: Encounter ID
Returns:
Number of rounds deleted
"""
rows = self.db.list_rows(
table_id=self.ROUNDS_TABLE,
queries=[Query.equal('encounterId', encounter_id)],
limit=100
)
for row in rows:
self.db.delete_row(self.ROUNDS_TABLE, row.id)
return len(rows)
def _row_to_encounter(
self,
data: Dict[str, Any],
encounter_id: str
) -> CombatEncounter:
"""
Convert database row data to CombatEncounter object.
Args:
data: Row data dictionary
encounter_id: Encounter ID
Returns:
Deserialized CombatEncounter
"""
# Parse JSON fields
combatants_data = json.loads(data.get('combatantsData', '[]'))
combatants = [Combatant.from_dict(c) for c in combatants_data]
turn_order = json.loads(data.get('turnOrder', '[]'))
combat_log = json.loads(data.get('combatLog', '[]'))
# Parse status enum
status_str = data.get('status', 'active')
status = CombatStatus(status_str)
return CombatEncounter(
encounter_id=encounter_id,
combatants=combatants,
turn_order=turn_order,
current_turn_index=data.get('currentTurnIndex', 0),
round_number=data.get('roundNumber', 1),
combat_log=combat_log,
status=status,
)
def _get_timestamp(self) -> str:
"""Get current UTC timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# =============================================================================
# Global Instance
# =============================================================================
_repository_instance: Optional[CombatRepository] = None
def get_combat_repository() -> CombatRepository:
"""
Get the global CombatRepository instance.
Returns:
Singleton CombatRepository instance
"""
global _repository_instance
if _repository_instance is None:
_repository_instance = CombatRepository()
return _repository_instance

View File

@@ -20,7 +20,7 @@ from app.models.stats import Stats
from app.models.abilities import Ability, AbilityLoader
from app.models.effects import Effect
from app.models.items import Item
from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType
from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType, StatType
from app.services.damage_calculator import DamageCalculator, DamageResult
from app.services.enemy_loader import EnemyLoader, get_enemy_loader
from app.services.session_service import get_session_service
@@ -30,6 +30,10 @@ from app.services.combat_loot_service import (
CombatLootService,
LootContext
)
from app.services.combat_repository import (
get_combat_repository,
CombatRepository
)
from app.utils.logging import get_logger
logger = get_logger(__file__)
@@ -90,6 +94,7 @@ class ActionResult:
combat_status: Final combat status if ended
next_combatant_id: ID of combatant whose turn is next
turn_effects: Effects that triggered at turn start/end
rewards: Combat rewards if victory (XP, gold, items)
"""
success: bool
@@ -99,7 +104,9 @@ class ActionResult:
combat_ended: bool = False
combat_status: Optional[CombatStatus] = None
next_combatant_id: Optional[str] = None
next_is_player: bool = True # True if next turn is player's
turn_effects: List[Dict[str, Any]] = field(default_factory=list)
rewards: Optional[Dict[str, Any]] = None # Populated on victory
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API response."""
@@ -120,7 +127,9 @@ class ActionResult:
"combat_ended": self.combat_ended,
"combat_status": self.combat_status.value if self.combat_status else None,
"next_combatant_id": self.next_combatant_id,
"next_is_player": self.next_is_player,
"turn_effects": self.turn_effects,
"rewards": self.rewards,
}
@@ -203,6 +212,7 @@ class CombatService:
self.enemy_loader = get_enemy_loader()
self.ability_loader = AbilityLoader()
self.loot_service = get_combat_loot_service()
self.combat_repository = get_combat_repository()
logger.info("CombatService initialized")
@@ -283,9 +293,18 @@ class CombatService:
# Initialize combat (roll initiative, set turn order)
encounter.initialize_combat()
# Store in session
session.start_combat(encounter)
self.session_service.update_session(session, user_id)
# Save encounter to dedicated table
self.combat_repository.create_encounter(
encounter=encounter,
session_id=session_id,
user_id=user_id
)
# Update session with reference to encounter (not inline data)
session.active_combat_encounter_id = encounter.encounter_id
session.combat_encounter = None # Clear legacy inline storage
session.update_activity()
self.session_service.update_session(session)
logger.info("Combat started",
session_id=session_id,
@@ -303,6 +322,9 @@ class CombatService:
"""
Get current combat state for a session.
Uses the new database-backed storage, with fallback to legacy
inline session storage for backward compatibility.
Args:
session_id: Game session ID
user_id: User ID for authorization
@@ -311,7 +333,66 @@ class CombatService:
CombatEncounter if in combat, None otherwise
"""
session = self.session_service.get_session(session_id, user_id)
return session.combat_encounter
# New system: Check for reference to combat_encounters table
if session.active_combat_encounter_id:
encounter = self.combat_repository.get_encounter(
session.active_combat_encounter_id
)
if encounter:
return encounter
# Reference exists but encounter not found - clear stale reference
logger.warning("Stale combat encounter reference, clearing",
session_id=session_id,
encounter_id=session.active_combat_encounter_id)
session.active_combat_encounter_id = None
self.session_service.update_session(session)
return None
# Legacy fallback: Check inline combat data and migrate if present
if session.combat_encounter:
return self._migrate_inline_encounter(session, user_id)
return None
def _migrate_inline_encounter(
self,
session,
user_id: str
) -> CombatEncounter:
"""
Migrate legacy inline combat encounter to database table.
This provides backward compatibility by automatically migrating
existing inline combat data to the new database-backed system
on first access.
Args:
session: GameSession with inline combat_encounter
user_id: User ID
Returns:
The migrated CombatEncounter
"""
encounter = session.combat_encounter
logger.info("Migrating inline combat encounter to database",
session_id=session.session_id,
encounter_id=encounter.encounter_id)
# Save to repository
self.combat_repository.create_encounter(
encounter=encounter,
session_id=session.session_id,
user_id=user_id
)
# Update session to use reference
session.active_combat_encounter_id = encounter.encounter_id
session.combat_encounter = None # Clear inline data
self.session_service.update_session(session)
return encounter
def end_combat(
self,
@@ -339,7 +420,11 @@ class CombatService:
if not session.is_in_combat():
raise NotInCombatError("Session is not in combat")
encounter = session.combat_encounter
# Get encounter from repository (or legacy inline)
encounter = self.get_combat_state(session_id, user_id)
if not encounter:
raise NotInCombatError("Combat encounter not found")
encounter.status = outcome
# Calculate rewards if victory
@@ -347,18 +432,135 @@ class CombatService:
if outcome == CombatStatus.VICTORY:
rewards = self._calculate_rewards(encounter, session, user_id)
# End combat on session
session.end_combat()
self.session_service.update_session(session, user_id)
# End encounter in repository
if session.active_combat_encounter_id:
self.combat_repository.end_encounter(
encounter_id=session.active_combat_encounter_id,
status=outcome
)
# Clear session combat reference
session.active_combat_encounter_id = None
session.combat_encounter = None # Also clear legacy field
session.update_activity()
self.session_service.update_session(session)
logger.info("Combat ended",
session_id=session_id,
encounter_id=encounter.encounter_id,
outcome=outcome.value,
xp_earned=rewards.experience,
gold_earned=rewards.gold)
return rewards
def check_existing_combat(
self,
session_id: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""
Check if a session has an existing active combat encounter.
Returns combat summary if exists, None otherwise.
Args:
session_id: Game session ID
user_id: User ID for authorization
Returns:
Dictionary with combat summary if in combat, None otherwise
"""
logger.info("Checking for existing combat",
session_id=session_id)
session = self.session_service.get_session(session_id, user_id)
if not session.is_in_combat():
return None
# Get encounter details
encounter = self.get_combat_state(session_id, user_id)
if not encounter:
return None
# Build summary of combatants
players = []
enemies = []
for combatant in encounter.combatants:
combatant_info = {
"name": combatant.name,
"current_hp": combatant.current_hp,
"max_hp": combatant.max_hp,
"is_alive": combatant.is_alive(),
}
if combatant.is_player:
players.append(combatant_info)
else:
enemies.append(combatant_info)
return {
"has_active_combat": True,
"encounter_id": encounter.encounter_id,
"round_number": encounter.round_number,
"status": encounter.status.value,
"players": players,
"enemies": enemies,
}
def abandon_combat(
self,
session_id: str,
user_id: str
) -> bool:
"""
Abandon an existing combat encounter without completing it.
Deletes the encounter from the database and clears the session
reference. No rewards are distributed.
Args:
session_id: Game session ID
user_id: User ID for authorization
Returns:
True if combat was abandoned, False if no combat existed
"""
logger.info("Abandoning combat",
session_id=session_id)
session = self.session_service.get_session(session_id, user_id)
if not session.is_in_combat():
logger.info("No combat to abandon",
session_id=session_id)
return False
encounter_id = session.active_combat_encounter_id
# Delete encounter from repository
if encounter_id:
try:
self.combat_repository.delete_encounter(encounter_id)
logger.info("Deleted encounter from repository",
encounter_id=encounter_id)
except Exception as e:
logger.warning("Failed to delete encounter from repository",
encounter_id=encounter_id,
error=str(e))
# Clear session combat references
session.active_combat_encounter_id = None
session.combat_encounter = None # Clear legacy field too
session.update_activity()
self.session_service.update_session(session)
logger.info("Combat abandoned",
session_id=session_id,
encounter_id=encounter_id)
return True
# =========================================================================
# Action Execution
# =========================================================================
@@ -396,7 +598,10 @@ class CombatService:
if not session.is_in_combat():
raise NotInCombatError("Session is not in combat")
encounter = session.combat_encounter
# Get encounter from repository
encounter = self.get_combat_state(session_id, user_id)
if not encounter:
raise NotInCombatError("Combat encounter not found")
# Validate it's this combatant's turn
current = encounter.get_current_combatant()
@@ -454,16 +659,31 @@ class CombatService:
if status == CombatStatus.VICTORY:
rewards = self._calculate_rewards(encounter, session, user_id)
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold."
result.rewards = rewards.to_dict()
session.end_combat()
# End encounter in repository
if session.active_combat_encounter_id:
self.combat_repository.end_encounter(
encounter_id=session.active_combat_encounter_id,
status=status
)
# Clear session combat reference
session.active_combat_encounter_id = None
session.combat_encounter = None
else:
# Advance turn
# Advance turn and save to repository
self._advance_turn_and_save(encounter, session, user_id)
next_combatant = encounter.get_current_combatant()
result.next_combatant_id = next_combatant.combatant_id if next_combatant else None
if next_combatant:
result.next_combatant_id = next_combatant.combatant_id
result.next_is_player = next_combatant.is_player
else:
result.next_combatant_id = None
result.next_is_player = True
# Save session state
self.session_service.update_session(session, user_id)
self.session_service.update_session(session)
return result
@@ -487,7 +707,11 @@ class CombatService:
if not session.is_in_combat():
raise NotInCombatError("Session is not in combat")
encounter = session.combat_encounter
# Get encounter from repository
encounter = self.get_combat_state(session_id, user_id)
if not encounter:
raise NotInCombatError("Combat encounter not found")
current = encounter.get_current_combatant()
if not current:
@@ -496,9 +720,55 @@ class CombatService:
if current.is_player:
raise InvalidActionError("Current combatant is a player, not an enemy")
# Check if the enemy is dead (shouldn't happen with fixed advance_turn, but defensive)
if current.is_dead():
# Skip this dead enemy's turn and advance
self._advance_turn_and_save(encounter, session, user_id)
next_combatant = encounter.get_current_combatant()
return ActionResult(
success=True,
message=f"{current.name} is defeated and cannot act.",
next_combatant_id=next_combatant.combatant_id if next_combatant else None,
next_is_player=next_combatant.is_player if next_combatant else True,
)
# Process start-of-turn effects
turn_effects = encounter.start_turn()
# Check if enemy died from DoT effects at turn start
if current.is_dead():
# Check if combat ended
combat_status = encounter.check_end_condition()
if combat_status in [CombatStatus.VICTORY, CombatStatus.DEFEAT]:
encounter.status = combat_status
result = ActionResult(
success=True,
message=f"{current.name} was defeated by damage over time!",
combat_ended=True,
combat_status=combat_status,
turn_effects=turn_effects,
)
if session.active_combat_encounter_id:
self.combat_repository.end_encounter(
encounter_id=session.active_combat_encounter_id,
status=combat_status
)
session.active_combat_encounter_id = None
session.combat_encounter = None
self.session_service.update_session(session)
return result
else:
# Advance past the dead enemy
self._advance_turn_and_save(encounter, session, user_id)
next_combatant = encounter.get_current_combatant()
return ActionResult(
success=True,
message=f"{current.name} was defeated by damage over time!",
next_combatant_id=next_combatant.combatant_id if next_combatant else None,
next_is_player=next_combatant.is_player if next_combatant else True,
turn_effects=turn_effects,
)
# Check if stunned
if current.is_stunned():
result = ActionResult(
@@ -539,14 +809,44 @@ class CombatService:
if status != CombatStatus.ACTIVE:
result.combat_ended = True
result.combat_status = status
session.end_combat()
# Calculate and distribute rewards on victory
if status == CombatStatus.VICTORY:
rewards = self._calculate_rewards(encounter, session, user_id)
result.rewards = rewards.to_dict()
# End encounter in repository
if session.active_combat_encounter_id:
self.combat_repository.end_encounter(
encounter_id=session.active_combat_encounter_id,
status=status
)
# Clear session combat reference
session.active_combat_encounter_id = None
session.combat_encounter = None
else:
logger.info("Combat still active, advancing turn",
session_id=session_id,
encounter_id=encounter.encounter_id)
self._advance_turn_and_save(encounter, session, user_id)
next_combatant = encounter.get_current_combatant()
result.next_combatant_id = next_combatant.combatant_id if next_combatant else None
logger.info("Next combatant determined",
next_combatant_id=next_combatant.combatant_id if next_combatant else None,
next_is_player=next_combatant.is_player if next_combatant else None)
if next_combatant:
result.next_combatant_id = next_combatant.combatant_id
result.next_is_player = next_combatant.is_player
else:
result.next_combatant_id = None
result.next_is_player = True
self.session_service.update_session(session, user_id)
self.session_service.update_session(session)
logger.info("Enemy turn complete, returning result",
session_id=session_id,
next_combatant_id=result.next_combatant_id,
next_is_player=result.next_is_player)
return result
# =========================================================================
@@ -762,7 +1062,7 @@ class CombatService:
effect_type=EffectType.BUFF,
duration=1,
power=5, # +5 defense
stat_affected="constitution",
stat_affected=StatType.CONSTITUTION,
source="defend_action",
)
combatant.add_effect(defense_buff)
@@ -1146,9 +1446,25 @@ class CombatService:
session,
user_id: str
) -> None:
"""Advance the turn and save session state."""
"""Advance the turn and save encounter state to repository."""
logger.info("_advance_turn_and_save called",
encounter_id=encounter.encounter_id,
before_turn_index=encounter.current_turn_index,
combat_log_count=len(encounter.combat_log))
encounter.advance_turn()
logger.info("Turn advanced, now saving",
encounter_id=encounter.encounter_id,
after_turn_index=encounter.current_turn_index,
combat_log_count=len(encounter.combat_log))
# Save encounter to repository
self.combat_repository.update_encounter(encounter)
logger.info("Encounter saved",
encounter_id=encounter.encounter_id)
# =============================================================================
# Global Instance

View File

@@ -106,6 +106,24 @@ class DatabaseInitService:
logger.error("Failed to initialize chat_messages table", error=str(e))
results['chat_messages'] = False
# Initialize combat_encounters table
try:
self.init_combat_encounters_table()
results['combat_encounters'] = True
logger.info("Combat encounters table initialized successfully")
except Exception as e:
logger.error("Failed to initialize combat_encounters table", error=str(e))
results['combat_encounters'] = False
# Initialize combat_rounds table
try:
self.init_combat_rounds_table()
results['combat_rounds'] = True
logger.info("Combat rounds table initialized successfully")
except Exception as e:
logger.error("Failed to initialize combat_rounds table", error=str(e))
results['combat_rounds'] = False
success_count = sum(1 for v in results.values() if v)
total_count = len(results)
@@ -746,6 +764,326 @@ class DatabaseInitService:
code=e.code)
raise
def init_combat_encounters_table(self) -> bool:
"""
Initialize the combat_encounters table for storing combat encounter state.
Table schema:
- sessionId (string, required): Game session ID (FK to game_sessions)
- userId (string, required): Owner user ID for authorization
- status (string, required): Combat status (active, victory, defeat, fled)
- roundNumber (integer, required): Current round number
- currentTurnIndex (integer, required): Index in turn_order for current turn
- turnOrder (string, required): JSON array of combatant IDs in initiative order
- combatantsData (string, required): JSON array of Combatant objects (full state)
- combatLog (string, optional): JSON array of all combat log entries
- created_at (string, required): ISO timestamp of combat start
- ended_at (string, optional): ISO timestamp when combat ended
Indexes:
- idx_sessionId: Session-based lookups
- idx_userId_status: User's active combats query
- idx_status_created_at: Time-based cleanup queries
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'combat_encounters'
logger.info("Initializing combat_encounters table", table_id=table_id)
try:
# Check if table already exists
try:
self.tables_db.get_table(
database_id=self.database_id,
table_id=table_id
)
logger.info("Combat encounters table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Combat encounters table does not exist, creating...")
# Create table
logger.info("Creating combat_encounters table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Combat Encounters'
)
logger.info("Combat encounters table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='sessionId',
column_type='string',
size=255,
required=True
)
self._create_column(
table_id=table_id,
column_id='userId',
column_type='string',
size=255,
required=True
)
self._create_column(
table_id=table_id,
column_id='status',
column_type='string',
size=20,
required=True
)
self._create_column(
table_id=table_id,
column_id='roundNumber',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='currentTurnIndex',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='turnOrder',
column_type='string',
size=2000, # JSON array of combatant IDs
required=True
)
self._create_column(
table_id=table_id,
column_id='combatantsData',
column_type='string',
size=65535, # Large text field for JSON combatant array
required=True
)
self._create_column(
table_id=table_id,
column_id='combatLog',
column_type='string',
size=65535, # Large text field for combat log
required=False
)
self._create_column(
table_id=table_id,
column_id='created_at',
column_type='string',
size=50, # ISO timestamp format
required=True
)
self._create_column(
table_id=table_id,
column_id='ended_at',
column_type='string',
size=50, # ISO timestamp format
required=False
)
# Wait for columns to fully propagate
logger.info("Waiting for columns to propagate before creating indexes...")
time.sleep(2)
# Create indexes
self._create_index(
table_id=table_id,
index_id='idx_sessionId',
index_type='key',
attributes=['sessionId']
)
self._create_index(
table_id=table_id,
index_id='idx_userId_status',
index_type='key',
attributes=['userId', 'status']
)
self._create_index(
table_id=table_id,
index_id='idx_status_created_at',
index_type='key',
attributes=['status', 'created_at']
)
logger.info("Combat encounters table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize combat_encounters table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def init_combat_rounds_table(self) -> bool:
"""
Initialize the combat_rounds table for storing per-round action history.
Table schema:
- encounterId (string, required): FK to combat_encounters
- sessionId (string, required): Denormalized for efficient queries
- roundNumber (integer, required): Round number (1-indexed)
- actionsData (string, required): JSON array of all actions in this round
- combatantStatesStart (string, required): JSON snapshot of combatant states at round start
- combatantStatesEnd (string, required): JSON snapshot of combatant states at round end
- created_at (string, required): ISO timestamp when round completed
Indexes:
- idx_encounterId: Encounter-based lookups
- idx_encounterId_roundNumber: Ordered retrieval of rounds
- idx_sessionId: Session-based queries
- idx_created_at: Time-based cleanup
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'combat_rounds'
logger.info("Initializing combat_rounds table", table_id=table_id)
try:
# Check if table already exists
try:
self.tables_db.get_table(
database_id=self.database_id,
table_id=table_id
)
logger.info("Combat rounds table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Combat rounds table does not exist, creating...")
# Create table
logger.info("Creating combat_rounds table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Combat Rounds'
)
logger.info("Combat rounds table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='encounterId',
column_type='string',
size=36, # UUID format: enc_xxxxxxxxxxxx
required=True
)
self._create_column(
table_id=table_id,
column_id='sessionId',
column_type='string',
size=255,
required=True
)
self._create_column(
table_id=table_id,
column_id='roundNumber',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='actionsData',
column_type='string',
size=65535, # JSON array of action objects
required=True
)
self._create_column(
table_id=table_id,
column_id='combatantStatesStart',
column_type='string',
size=65535, # JSON snapshot of combatant states
required=True
)
self._create_column(
table_id=table_id,
column_id='combatantStatesEnd',
column_type='string',
size=65535, # JSON snapshot of combatant states
required=True
)
self._create_column(
table_id=table_id,
column_id='created_at',
column_type='string',
size=50, # ISO timestamp format
required=True
)
# Wait for columns to fully propagate
logger.info("Waiting for columns to propagate before creating indexes...")
time.sleep(2)
# Create indexes
self._create_index(
table_id=table_id,
index_id='idx_encounterId',
index_type='key',
attributes=['encounterId']
)
self._create_index(
table_id=table_id,
index_id='idx_encounterId_roundNumber',
index_type='key',
attributes=['encounterId', 'roundNumber']
)
self._create_index(
table_id=table_id,
index_id='idx_sessionId',
index_type='key',
attributes=['sessionId']
)
self._create_index(
table_id=table_id,
index_id='idx_created_at',
index_type='key',
attributes=['created_at']
)
logger.info("Combat rounds table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize combat_rounds table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def _create_column(
self,
table_id: str,

View File

@@ -0,0 +1,308 @@
"""
Encounter Generator Service - Generate random combat encounters.
This service generates location-appropriate, level-scaled encounter groups
for the "Search for Monsters" feature. Players can select from generated
encounter options to initiate combat.
"""
import random
import uuid
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from collections import Counter
from app.models.enemy import EnemyTemplate, EnemyDifficulty
from app.services.enemy_loader import get_enemy_loader
from app.utils.logging import get_logger
logger = get_logger(__file__)
@dataclass
class EncounterGroup:
"""
A generated encounter option for the player to choose.
Attributes:
group_id: Unique identifier for this encounter option
enemies: List of enemy_ids that will spawn
enemy_names: Display names for the UI
display_name: Formatted display string (e.g., "3 Goblin Scouts")
challenge: Difficulty label ("Easy", "Medium", "Hard", "Boss")
total_xp: Total XP reward (not displayed to player, used internally)
"""
group_id: str
enemies: List[str] # List of enemy_ids
enemy_names: List[str] # Display names
display_name: str # Formatted for display
challenge: str # "Easy", "Medium", "Hard", "Boss"
total_xp: int # Internal tracking, not displayed
def to_dict(self) -> Dict[str, Any]:
"""Serialize encounter group for API response."""
return {
"group_id": self.group_id,
"enemies": self.enemies,
"enemy_names": self.enemy_names,
"display_name": self.display_name,
"challenge": self.challenge,
}
class EncounterGenerator:
"""
Generates random encounter groups for a given location and character level.
Encounter difficulty is determined by:
- Character level (higher level = more enemies, harder varieties)
- Location type (different monsters in different areas)
- Random variance (some encounters harder/easier than average)
"""
def __init__(self):
"""Initialize the encounter generator."""
self.enemy_loader = get_enemy_loader()
def generate_encounters(
self,
location_type: str,
character_level: int,
num_encounters: int = 4
) -> List[EncounterGroup]:
"""
Generate multiple encounter options for the player to choose from.
Args:
location_type: Type of location (e.g., "forest", "town", "dungeon")
character_level: Current character level (1-20+)
num_encounters: Number of encounter options to generate (default 4)
Returns:
List of EncounterGroup options, each with different difficulty
"""
# Get enemies available at this location
available_enemies = self.enemy_loader.get_enemies_by_location(location_type)
if not available_enemies:
logger.warning(
"No enemies found for location",
location_type=location_type
)
return []
# Generate a mix of difficulties
# Always try to include: 1 Easy, 1-2 Medium, 0-1 Hard
encounters = []
difficulty_mix = self._get_difficulty_mix(character_level, num_encounters)
for target_difficulty in difficulty_mix:
encounter = self._generate_single_encounter(
available_enemies=available_enemies,
character_level=character_level,
target_difficulty=target_difficulty
)
if encounter:
encounters.append(encounter)
logger.info(
"Generated encounters",
location_type=location_type,
character_level=character_level,
num_encounters=len(encounters)
)
return encounters
def _get_difficulty_mix(
self,
character_level: int,
num_encounters: int
) -> List[str]:
"""
Determine the mix of encounter difficulties to generate.
Lower-level characters see more easy encounters.
Higher-level characters see more hard encounters.
Args:
character_level: Character's current level
num_encounters: Total encounters to generate
Returns:
List of target difficulty strings
"""
if character_level <= 2:
# Very low level: mostly easy
mix = ["Easy", "Easy", "Medium", "Easy"]
elif character_level <= 5:
# Low level: easy and medium
mix = ["Easy", "Medium", "Medium", "Hard"]
elif character_level <= 10:
# Mid level: balanced
mix = ["Easy", "Medium", "Hard", "Hard"]
else:
# High level: harder encounters
mix = ["Medium", "Hard", "Hard", "Boss"]
return mix[:num_encounters]
def _generate_single_encounter(
self,
available_enemies: List[EnemyTemplate],
character_level: int,
target_difficulty: str
) -> Optional[EncounterGroup]:
"""
Generate a single encounter group.
Args:
available_enemies: Pool of enemies to choose from
character_level: Character's level for scaling
target_difficulty: Target difficulty ("Easy", "Medium", "Hard", "Boss")
Returns:
EncounterGroup or None if generation fails
"""
# Map target difficulty to enemy difficulty levels
difficulty_mapping = {
"Easy": [EnemyDifficulty.EASY],
"Medium": [EnemyDifficulty.EASY, EnemyDifficulty.MEDIUM],
"Hard": [EnemyDifficulty.MEDIUM, EnemyDifficulty.HARD],
"Boss": [EnemyDifficulty.HARD, EnemyDifficulty.BOSS],
}
allowed_difficulties = difficulty_mapping.get(target_difficulty, [EnemyDifficulty.EASY])
# Filter enemies by difficulty
candidates = [
e for e in available_enemies
if e.difficulty in allowed_difficulties
]
if not candidates:
# Fall back to any available enemy
candidates = available_enemies
if not candidates:
return None
# Determine enemy count based on difficulty and level
enemy_count = self._calculate_enemy_count(
target_difficulty=target_difficulty,
character_level=character_level
)
# Select enemies (allowing duplicates for packs)
selected_enemies = random.choices(candidates, k=enemy_count)
# Build encounter group
enemy_ids = [e.enemy_id for e in selected_enemies]
enemy_names = [e.name for e in selected_enemies]
total_xp = sum(e.experience_reward for e in selected_enemies)
# Create display name (e.g., "3 Goblin Scouts" or "2 Goblins, 1 Goblin Shaman")
display_name = self._format_display_name(enemy_names)
return EncounterGroup(
group_id=f"enc_{uuid.uuid4().hex[:8]}",
enemies=enemy_ids,
enemy_names=enemy_names,
display_name=display_name,
challenge=target_difficulty,
total_xp=total_xp
)
def _calculate_enemy_count(
self,
target_difficulty: str,
character_level: int
) -> int:
"""
Calculate how many enemies should be in the encounter.
Args:
target_difficulty: Target difficulty level
character_level: Character's level
Returns:
Number of enemies to include
"""
# Base counts by difficulty
base_counts = {
"Easy": (1, 2), # 1-2 enemies
"Medium": (2, 3), # 2-3 enemies
"Hard": (2, 4), # 2-4 enemies
"Boss": (1, 3), # 1 boss + 0-2 adds
}
min_count, max_count = base_counts.get(target_difficulty, (1, 2))
# Scale slightly with level (higher level = can handle more)
level_bonus = min(character_level // 5, 2) # +1 enemy every 5 levels, max +2
max_count = min(max_count + level_bonus, 6) # Cap at 6 enemies
return random.randint(min_count, max_count)
def _format_display_name(self, enemy_names: List[str]) -> str:
"""
Format enemy names for display.
Examples:
["Goblin Scout"] -> "Goblin Scout"
["Goblin Scout", "Goblin Scout", "Goblin Scout"] -> "3 Goblin Scouts"
["Goblin Scout", "Goblin Shaman"] -> "Goblin Scout, Goblin Shaman"
Args:
enemy_names: List of enemy display names
Returns:
Formatted display string
"""
if len(enemy_names) == 1:
return enemy_names[0]
# Count occurrences
counts = Counter(enemy_names)
if len(counts) == 1:
# All same enemy type
name = list(counts.keys())[0]
count = list(counts.values())[0]
# Simple pluralization
if count > 1:
if name.endswith('f'):
# wolf -> wolves
plural_name = name[:-1] + "ves"
elif name.endswith('s') or name.endswith('x') or name.endswith('ch'):
plural_name = name + "es"
else:
plural_name = name + "s"
return f"{count} {plural_name}"
return name
else:
# Mixed enemy types - list them
parts = []
for name, count in counts.items():
if count > 1:
parts.append(f"{count}x {name}")
else:
parts.append(name)
return ", ".join(parts)
# Global instance
_generator_instance: Optional[EncounterGenerator] = None
def get_encounter_generator() -> EncounterGenerator:
"""
Get the global EncounterGenerator instance.
Returns:
Singleton EncounterGenerator instance
"""
global _generator_instance
if _generator_instance is None:
_generator_instance = EncounterGenerator()
return _generator_instance

View File

@@ -177,6 +177,46 @@ class EnemyLoader:
if enemy.has_tag(tag)
]
def get_enemies_by_location(
self,
location_type: str,
difficulty: Optional[EnemyDifficulty] = None
) -> List[EnemyTemplate]:
"""
Get all enemies that can appear at a specific location type.
This is used by the encounter generator to find location-appropriate
enemies for random encounters.
Args:
location_type: Location type to filter by (e.g., "forest", "dungeon",
"town", "wilderness", "crypt", "ruins", "road")
difficulty: Optional difficulty filter to narrow results
Returns:
List of EnemyTemplate instances that can appear at the location
"""
if not self._loaded:
self.load_all_enemies()
candidates = [
enemy for enemy in self._enemy_cache.values()
if enemy.has_location_tag(location_type)
]
# Apply difficulty filter if specified
if difficulty is not None:
candidates = [e for e in candidates if e.difficulty == difficulty]
logger.debug(
"Enemies found for location",
location_type=location_type,
difficulty=difficulty.value if difficulty else None,
count=len(candidates)
)
return candidates
def get_random_enemies(
self,
count: int = 1,

View File

@@ -272,9 +272,9 @@ class SessionService:
session_json = json.dumps(session_dict)
# Update in database
self.db.update_document(
collection_id=self.collection_id,
document_id=session.session_id,
self.db.update_row(
table_id=self.collection_id,
row_id=session.session_id,
data={
'sessionData': session_json,
'status': session.status.value

View File

@@ -15,7 +15,7 @@ import yaml
from app.models.items import Item
from app.models.effects import Effect
from app.models.enums import ItemType, ItemRarity, EffectType
from app.models.enums import ItemType, ItemRarity, EffectType, DamageType
from app.utils.logging import get_logger
logger = get_logger(__file__)
@@ -178,6 +178,20 @@ class StaticItemLoader:
# Parse stat bonuses if present
stat_bonuses = template.get("stat_bonuses", {})
# Parse damage type if present (for weapons)
damage_type = None
damage_type_str = template.get("damage_type")
if damage_type_str:
try:
damage_type = DamageType(damage_type_str)
except ValueError:
logger.warning(
"Unknown damage type, defaulting to physical",
damage_type=damage_type_str,
item_id=item_id
)
damage_type = DamageType.PHYSICAL
return Item(
item_id=instance_id,
name=template.get("name", item_id),
@@ -188,6 +202,17 @@ class StaticItemLoader:
is_tradeable=template.get("is_tradeable", True),
stat_bonuses=stat_bonuses,
effects_on_use=effects,
# Weapon-specific fields
damage=template.get("damage", 0),
spell_power=template.get("spell_power", 0),
damage_type=damage_type,
crit_chance=template.get("crit_chance", 0.05),
crit_multiplier=template.get("crit_multiplier", 2.0),
# Armor-specific fields
defense=template.get("defense", 0),
resistance=template.get("resistance", 0),
# Level requirements
required_level=template.get("required_level", 1),
)
def _parse_effect(self, effect_data: Dict) -> Optional[Effect]:

View File

@@ -0,0 +1,144 @@
"""
Combat Cleanup Tasks.
This module provides scheduled tasks for cleaning up ended combat
encounters that are older than the retention period.
The cleanup can be scheduled to run periodically (daily recommended)
via APScheduler, cron, or manual invocation.
Usage:
# Manual invocation
from app.tasks.combat_cleanup import cleanup_old_combat_encounters
result = cleanup_old_combat_encounters(older_than_days=7)
# Via APScheduler
scheduler.add_job(
cleanup_old_combat_encounters,
'interval',
days=1,
kwargs={'older_than_days': 7}
)
"""
from typing import Dict, Any
from app.services.combat_repository import get_combat_repository
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Default retention period in days
DEFAULT_RETENTION_DAYS = 7
def cleanup_old_combat_encounters(
older_than_days: int = DEFAULT_RETENTION_DAYS
) -> Dict[str, Any]:
"""
Delete ended combat encounters older than specified days.
This is the main cleanup function for time-based retention.
Should be scheduled to run periodically (daily recommended).
Only deletes ENDED encounters (victory, defeat, fled) - active
encounters are never deleted.
Args:
older_than_days: Number of days after which to delete ended combats.
Default is 7 days.
Returns:
Dict containing:
- deleted_encounters: Number of encounters deleted
- deleted_rounds: Approximate rounds deleted (cascaded)
- older_than_days: The threshold used
- success: Whether the operation completed successfully
- error: Error message if failed
Example:
>>> result = cleanup_old_combat_encounters(older_than_days=7)
>>> print(f"Deleted {result['deleted_encounters']} encounters")
"""
logger.info("Starting combat encounter cleanup",
older_than_days=older_than_days)
try:
repo = get_combat_repository()
deleted_count = repo.delete_old_encounters(older_than_days)
result = {
"deleted_encounters": deleted_count,
"older_than_days": older_than_days,
"success": True,
"error": None
}
logger.info("Combat encounter cleanup completed successfully",
deleted_count=deleted_count,
older_than_days=older_than_days)
return result
except Exception as e:
logger.error("Combat encounter cleanup failed",
error=str(e),
older_than_days=older_than_days)
return {
"deleted_encounters": 0,
"older_than_days": older_than_days,
"success": False,
"error": str(e)
}
def cleanup_encounters_for_session(session_id: str) -> Dict[str, Any]:
"""
Delete all combat encounters for a specific session.
Call this when a session is being deleted to clean up
associated combat data.
Args:
session_id: The session ID to clean up
Returns:
Dict containing:
- deleted_encounters: Number of encounters deleted
- session_id: The session ID processed
- success: Whether the operation completed successfully
- error: Error message if failed
"""
logger.info("Cleaning up combat encounters for session",
session_id=session_id)
try:
repo = get_combat_repository()
deleted_count = repo.delete_encounters_by_session(session_id)
result = {
"deleted_encounters": deleted_count,
"session_id": session_id,
"success": True,
"error": None
}
logger.info("Session combat cleanup completed",
session_id=session_id,
deleted_count=deleted_count)
return result
except Exception as e:
logger.error("Session combat cleanup failed",
session_id=session_id,
error=str(e))
return {
"deleted_encounters": 0,
"session_id": session_id,
"success": False,
"error": str(e)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Combat Data Migration Script.
This script migrates existing inline combat encounter data from game_sessions
to the dedicated combat_encounters table.
The migration is idempotent - it's safe to run multiple times. Sessions that
have already been migrated (have active_combat_encounter_id) are skipped.
Usage:
python scripts/migrate_combat_data.py
Note:
- Run this after deploying the new combat database schema
- The application handles automatic migration on-demand, so this is optional
- This script is useful for proactively migrating all data at once
"""
import sys
import os
import json
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from dotenv import load_dotenv
# Load environment variables before importing app modules
load_dotenv()
from app.services.database_service import get_database_service
from app.services.combat_repository import get_combat_repository
from app.models.session import GameSession
from app.models.combat import CombatEncounter
from app.utils.logging import get_logger
logger = get_logger(__file__)
def migrate_inline_combat_encounters() -> dict:
"""
Migrate all inline combat encounters to the dedicated table.
Scans all game sessions for inline combat_encounter data and migrates
them to the combat_encounters table. Updates sessions to use the new
active_combat_encounter_id reference.
Returns:
Dict with migration statistics:
- total_sessions: Number of sessions scanned
- migrated: Number of sessions with combat data migrated
- skipped: Number of sessions already migrated or without combat
- errors: Number of sessions that failed to migrate
"""
db = get_database_service()
repo = get_combat_repository()
stats = {
'total_sessions': 0,
'migrated': 0,
'skipped': 0,
'errors': 0,
'error_details': []
}
print("Scanning game_sessions for inline combat data...")
# Query all sessions (paginated)
offset = 0
limit = 100
while True:
try:
rows = db.list_rows(
table_id='game_sessions',
limit=limit,
offset=offset
)
except Exception as e:
logger.error("Failed to query sessions", error=str(e))
print(f"Error querying sessions: {e}")
break
if not rows:
break
for row in rows:
stats['total_sessions'] += 1
session_id = row.id
try:
# Parse session data
session_json = row.data.get('sessionData', '{}')
session_data = json.loads(session_json)
# Check if already migrated (has reference, no inline data)
if (session_data.get('active_combat_encounter_id') and
not session_data.get('combat_encounter')):
stats['skipped'] += 1
continue
# Check if has inline combat data to migrate
combat_data = session_data.get('combat_encounter')
if not combat_data:
stats['skipped'] += 1
continue
# Parse combat encounter
encounter = CombatEncounter.from_dict(combat_data)
user_id = session_data.get('user_id', row.data.get('userId', ''))
logger.info("Migrating inline combat encounter",
session_id=session_id,
encounter_id=encounter.encounter_id)
# Check if encounter already exists in repository
existing = repo.get_encounter(encounter.encounter_id)
if existing:
# Already migrated, just update session reference
session_data['active_combat_encounter_id'] = encounter.encounter_id
session_data['combat_encounter'] = None
else:
# Save to repository
repo.create_encounter(
encounter=encounter,
session_id=session_id,
user_id=user_id
)
session_data['active_combat_encounter_id'] = encounter.encounter_id
session_data['combat_encounter'] = None
# Update session
db.update_row(
table_id='game_sessions',
row_id=session_id,
data={'sessionData': json.dumps(session_data)}
)
stats['migrated'] += 1
print(f" Migrated: {session_id} -> {encounter.encounter_id}")
except Exception as e:
stats['errors'] += 1
error_msg = f"Session {session_id}: {str(e)}"
stats['error_details'].append(error_msg)
logger.error("Failed to migrate session",
session_id=session_id,
error=str(e))
print(f" Error: {session_id} - {e}")
offset += limit
# Safety check to prevent infinite loop
if offset > 10000:
print("Warning: Stopped after 10000 sessions (safety limit)")
break
return stats
def main():
"""Run the migration."""
print("=" * 60)
print("Code of Conquest - Combat Data Migration")
print("=" * 60)
print()
# Verify environment variables
required_vars = [
'APPWRITE_ENDPOINT',
'APPWRITE_PROJECT_ID',
'APPWRITE_API_KEY',
'APPWRITE_DATABASE_ID'
]
missing_vars = [var for var in required_vars if not os.getenv(var)]
if missing_vars:
print("ERROR: Missing required environment variables:")
for var in missing_vars:
print(f" - {var}")
print()
print("Please ensure your .env file is configured correctly.")
sys.exit(1)
print("Environment configuration:")
print(f" Endpoint: {os.getenv('APPWRITE_ENDPOINT')}")
print(f" Project: {os.getenv('APPWRITE_PROJECT_ID')}")
print(f" Database: {os.getenv('APPWRITE_DATABASE_ID')}")
print()
# Confirm before proceeding
print("This script will migrate inline combat data to the dedicated")
print("combat_encounters table. This operation is safe and idempotent.")
print()
response = input("Proceed with migration? (y/N): ").strip().lower()
if response != 'y':
print("Migration cancelled.")
sys.exit(0)
print()
print("Starting migration...")
print()
try:
stats = migrate_inline_combat_encounters()
print()
print("=" * 60)
print("Migration Results")
print("=" * 60)
print()
print(f"Total sessions scanned: {stats['total_sessions']}")
print(f"Successfully migrated: {stats['migrated']}")
print(f"Skipped (no combat): {stats['skipped']}")
print(f"Errors: {stats['errors']}")
print()
if stats['error_details']:
print("Error details:")
for error in stats['error_details'][:10]: # Show first 10
print(f" - {error}")
if len(stats['error_details']) > 10:
print(f" ... and {len(stats['error_details']) - 10} more")
print()
if stats['errors'] > 0:
print("Some sessions failed to migrate. Check logs for details.")
sys.exit(1)
else:
print("Migration completed successfully!")
except Exception as e:
logger.error("Migration failed", error=str(e))
print()
print(f"MIGRATION FAILED: {str(e)}")
print()
print("Check logs for details.")
sys.exit(1)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

467
docs/PHASE4b.md Normal file
View File

@@ -0,0 +1,467 @@
## Phase 4B: Skill Trees & Leveling (Week 4)
### Task 4.1: Verify Skill Tree Data (2 hours)
**Objective:** Review skill system
**Files to Review:**
- `/api/app/models/skills.py` - SkillNode, SkillTree, PlayerClass
- `/api/app/data/skills/` - Skill YAML files for all 8 classes
**Verification Checklist:**
- [ ] Skill trees loaded from YAML
- [ ] Each class has 2 skill trees
- [ ] Each tree has 5 tiers
- [ ] Prerequisites work correctly
- [ ] Stat bonuses apply correctly
**Acceptance Criteria:**
- All 8 classes have complete skill trees
- Unlock logic works
- Respec logic implemented
---
### Task 4.2: Create Skill Tree Template (2 days / 16 hours)
**Objective:** Visual skill tree UI
**File:** `/public_web/templates/character/skills.html`
**Layout:**
```
┌─────────────────────────────────────────────────────────────┐
│ CHARACTER SKILL TREES │
├─────────────────────────────────────────────────────────────┤
│ │
│ Skill Points Available: 5 [Respec] ($$$)│
│ │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ TREE 1: Combat │ │ TREE 2: Utility │ │
│ ├────────────────────────┤ ├────────────────────────┤ │
│ │ │ │ │ │
│ │ Tier 5: [⬢] [⬢] │ │ Tier 5: [⬢] [⬢] │ │
│ │ │ │ │ │ │ │ │ │
│ │ Tier 4: [⬢] [⬢] │ │ Tier 4: [⬢] [⬢] │ │
│ │ │ │ │ │ │ │ │ │
│ │ Tier 3: [⬢] [⬢] │ │ Tier 3: [⬢] [⬢] │ │
│ │ │ │ │ │ │ │ │ │
│ │ Tier 2: [✓] [⬢] │ │ Tier 2: [⬢] [✓] │ │
│ │ │ │ │ │ │ │ │ │
│ │ Tier 1: [✓] [✓] │ │ Tier 1: [✓] [✓] │ │
│ │ │ │ │ │
│ └────────────────────────┘ └────────────────────────┘ │
│ │
│ Legend: [✓] Unlocked [⬡] Available [⬢] Locked │
│ │
└─────────────────────────────────────────────────────────────┘
```
**Implementation:**
```html
{% extends "base.html" %}
{% block title %}Skill Trees - {{ character.name }}{% endblock %}
{% block content %}
<div class="skills-container">
<div class="skills-header">
<h1>{{ character.name }}'s Skill Trees</h1>
<div class="skills-info">
<span class="skill-points">Skill Points: <strong>{{ character.skill_points }}</strong></span>
<button class="btn btn-warning btn-respec"
hx-post="/characters/{{ character.character_id }}/skills/respec"
hx-confirm="Respec costs {{ respec_cost }} gold. Continue?"
hx-target=".skills-container"
hx-swap="outerHTML">
Respec ({{ respec_cost }} gold)
</button>
</div>
</div>
<div class="skill-trees-grid">
{% for tree in character.skill_trees %}
<div class="skill-tree">
<h2 class="tree-name">{{ tree.name }}</h2>
<p class="tree-description">{{ tree.description }}</p>
<div class="tree-diagram">
{% for tier in range(5, 0, -1) %}
<div class="skill-tier" data-tier="{{ tier }}">
<span class="tier-label">Tier {{ tier }}</span>
<div class="skill-nodes">
{% for node in tree.get_nodes_by_tier(tier) %}
<div class="skill-node {{ get_node_status(node, character) }}"
data-skill-id="{{ node.skill_id }}"
hx-get="/skills/{{ node.skill_id }}/tooltip"
hx-target="#skill-tooltip"
hx-swap="innerHTML"
hx-trigger="mouseenter">
<div class="node-icon">
{% if node.skill_id in character.unlocked_skills %}
{% elif character.can_unlock(node.skill_id) %}
{% else %}
{% endif %}
</div>
<span class="node-name">{{ node.name }}</span>
{% if character.can_unlock(node.skill_id) and character.skill_points > 0 %}
<button class="btn-unlock"
hx-post="/characters/{{ character.character_id }}/skills/unlock"
hx-vals='{"skill_id": "{{ node.skill_id }}"}'
hx-target=".skills-container"
hx-swap="outerHTML">
Unlock
</button>
{% endif %}
</div>
{# Draw prerequisite lines #}
{% if node.prerequisite_skill_id %}
<div class="prerequisite-line"></div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{# Skill Tooltip (populated via HTMX) #}
<div id="skill-tooltip" class="skill-tooltip"></div>
</div>
{% endblock %}
```
**Also create `/public_web/templates/character/partials/skill_tooltip.html`:**
```html
<div class="tooltip-content">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-description">{{ skill.description }}</p>
<div class="skill-bonuses">
<strong>Bonuses:</strong>
<ul>
{% for stat, bonus in skill.stat_bonuses.items() %}
<li>+{{ bonus }} {{ stat|title }}</li>
{% endfor %}
</ul>
</div>
{% if skill.prerequisite_skill_id %}
<p class="prerequisite">
<strong>Requires:</strong> {{ get_skill_name(skill.prerequisite_skill_id) }}
</p>
{% endif %}
</div>
```
**Acceptance Criteria:**
- Dual skill tree layout works
- 5 tiers × 2 nodes per tree displayed
- Locked/available/unlocked states visual
- Prerequisite lines drawn
- Hover shows tooltip
- Mobile responsive
---
### Task 4.3: Skill Unlock HTMX (4 hours)
**Objective:** Click to unlock skills
**File:** `/public_web/app/views/skills.py`
```python
"""
Skill Views
Routes for skill tree UI.
"""
from flask import Blueprint, render_template, request, g
from app.services.api_client import APIClient, APIError
from app.utils.auth import require_auth
from app.utils.logging import get_logger
logger = get_logger(__file__)
skills_bp = Blueprint('skills', __name__)
@skills_bp.route('/<skill_id>/tooltip', methods=['GET'])
@require_auth
def skill_tooltip(skill_id: str):
"""Get skill tooltip (HTMX partial)."""
# Load skill data
# Return rendered tooltip
pass
@skills_bp.route('/characters/<character_id>/skills', methods=['GET'])
@require_auth
def character_skills(character_id: str):
"""Display character skill trees."""
api_client = APIClient()
try:
# Get character
response = api_client.get(f'/characters/{character_id}')
character = response['result']
# Calculate respec cost
respec_cost = character['level'] * 100
return render_template(
'character/skills.html',
character=character,
respec_cost=respec_cost
)
except APIError as e:
logger.error(f"Failed to load skills: {e}")
return render_template('partials/error.html', error=str(e))
@skills_bp.route('/characters/<character_id>/skills/unlock', methods=['POST'])
@require_auth
def unlock_skill(character_id: str):
"""Unlock skill (HTMX endpoint)."""
api_client = APIClient()
skill_id = request.form.get('skill_id')
try:
# Unlock skill via API
response = api_client.post(
f'/characters/{character_id}/skills/unlock',
json={'skill_id': skill_id}
)
# Re-render skill trees
character = response['result']['character']
respec_cost = character['level'] * 100
return render_template(
'character/skills.html',
character=character,
respec_cost=respec_cost
)
except APIError as e:
logger.error(f"Failed to unlock skill: {e}")
return render_template('partials/error.html', error=str(e))
```
**Acceptance Criteria:**
- Click available node unlocks skill
- Skill points decrease
- Stat bonuses apply immediately
- Prerequisites enforced
- UI updates without page reload
---
### Task 4.4: Respec Functionality (4 hours)
**Objective:** Respec button with confirmation
**Implementation:** (in `skills_bp`)
```python
@skills_bp.route('/characters/<character_id>/skills/respec', methods=['POST'])
@require_auth
def respec_skills(character_id: str):
"""Respec all skills."""
api_client = APIClient()
try:
response = api_client.post(f'/characters/{character_id}/skills/respec')
character = response['result']['character']
respec_cost = character['level'] * 100
return render_template(
'character/skills.html',
character=character,
respec_cost=respec_cost,
message="Skills reset! All skill points refunded."
)
except APIError as e:
logger.error(f"Failed to respec: {e}")
return render_template('partials/error.html', error=str(e))
```
**Acceptance Criteria:**
- Respec button costs gold
- Confirmation modal shown
- All skills reset
- Skill points refunded
- Gold deducted
---
### Task 4.5: XP & Leveling System (1 day / 8 hours)
**Objective:** Award XP after combat, level up grants skill points
**File:** `/api/app/services/leveling_service.py`
```python
"""
Leveling Service
Manages XP gain and level ups.
"""
from app.models.character import Character
from app.utils.logging import get_logger
logger = get_logger(__file__)
class LevelingService:
"""Service for XP and leveling."""
@staticmethod
def xp_required_for_level(level: int) -> int:
"""
Calculate XP required for a given level.
Formula: 100 * (level ^ 2)
"""
return 100 * (level ** 2)
@staticmethod
def award_xp(character: Character, xp_amount: int) -> dict:
"""
Award XP to character and check for level up.
Args:
character: Character instance
xp_amount: XP to award
Returns:
Dict with leveled_up, new_level, skill_points_gained
"""
character.experience += xp_amount
leveled_up = False
levels_gained = 0
# Check for level ups (can level multiple times)
while character.experience >= LevelingService.xp_required_for_level(character.level + 1):
character.level += 1
character.skill_points += 1
levels_gained += 1
leveled_up = True
logger.info(f"Character {character.character_id} leveled up to {character.level}")
return {
'leveled_up': leveled_up,
'new_level': character.level if leveled_up else None,
'skill_points_gained': levels_gained,
'xp_gained': xp_amount
}
```
**Update Combat Results Endpoint:**
```python
# In /api/app/api/combat.py
@combat_bp.route('/<combat_id>/results', methods=['GET'])
@require_auth
def get_combat_results(combat_id: str):
"""Get combat results with XP/loot."""
combat_service = CombatService(get_appwrite_service())
encounter = combat_service.get_encounter(combat_id)
if encounter.status != CombatStatus.VICTORY:
return error_response("Combat not won", 400)
# Calculate XP (based on enemy difficulty)
xp_gained = sum(enemy.level * 50 for enemy in encounter.combatants if not enemy.is_player)
# Award XP to character
char_service = get_character_service()
character = char_service.get_character(encounter.character_id, g.user_id)
from app.services.leveling_service import LevelingService
level_result = LevelingService.award_xp(character, xp_gained)
# Award gold
gold_gained = sum(enemy.level * 25 for enemy in encounter.combatants if not enemy.is_player)
character.gold += gold_gained
# Generate loot (TODO: implement loot tables)
loot = []
# Save character
char_service.update_character(character)
return success_response({
'victory': True,
'xp_gained': xp_gained,
'gold_gained': gold_gained,
'loot': loot,
'level_up': level_result
})
```
**Create Level Up Modal Template:**
**File:** `/public_web/templates/game/partials/level_up_modal.html`
```html
<div class="modal-overlay">
<div class="modal-content level-up-modal">
<div class="modal-header">
<h2>🎉 LEVEL UP! 🎉</h2>
</div>
<div class="modal-body">
<p class="level-up-text">
Congratulations! You've reached <strong>Level {{ new_level }}</strong>!
</p>
<div class="level-up-rewards">
<p>You gained:</p>
<ul>
<li>+1 Skill Point</li>
<li>+{{ stat_increases.vitality }} Vitality</li>
<li>+{{ stat_increases.spirit }} Spirit</li>
</ul>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeModal()">Awesome!</button>
<a href="/characters/{{ character_id }}/skills" class="btn btn-secondary">
View Skill Trees
</a>
</div>
</div>
</div>
```
**Acceptance Criteria:**
- XP awarded after combat victory
- Level up triggers at XP threshold
- Skill points granted on level up
- Level up modal shown
- Character stats increase
---

513
docs/Phase4c.md Normal file
View File

@@ -0,0 +1,513 @@
## Phase 4C: NPC Shop (Days 15-18)
### Task 5.1: Define Shop Inventory (4 hours)
**Objective:** Create YAML for shop items
**File:** `/api/app/data/shop/general_store.yaml`
```yaml
shop_id: "general_store"
shop_name: "General Store"
shop_description: "A well-stocked general store with essential supplies."
shopkeeper_name: "Merchant Guildmaster"
inventory:
# Weapons
- item_id: "iron_sword"
stock: -1 # Unlimited stock (-1)
price: 50
- item_id: "oak_bow"
stock: -1
price: 45
# Armor
- item_id: "leather_helmet"
stock: -1
price: 30
- item_id: "leather_chest"
stock: -1
price: 60
# Consumables
- item_id: "health_potion_small"
stock: -1
price: 10
- item_id: "health_potion_medium"
stock: -1
price: 30
- item_id: "mana_potion_small"
stock: -1
price: 15
- item_id: "antidote"
stock: -1
price: 20
```
**Acceptance Criteria:**
- Shop inventory defined in YAML
- Mix of weapons, armor, consumables
- Reasonable pricing
- Unlimited stock for basics
---
### Task 5.2: Shop API Endpoints (4 hours)
**Objective:** Create shop endpoints
**File:** `/api/app/api/shop.py`
```python
"""
Shop API Blueprint
Endpoints:
- GET /api/v1/shop/inventory - Browse shop items
- POST /api/v1/shop/purchase - Purchase item
"""
from flask import Blueprint, request, g
from app.services.shop_service import ShopService
from app.services.character_service import get_character_service
from app.services.appwrite_service import get_appwrite_service
from app.utils.response import success_response, error_response
from app.utils.auth import require_auth
from app.utils.logging import get_logger
logger = get_logger(__file__)
shop_bp = Blueprint('shop', __name__)
@shop_bp.route('/inventory', methods=['GET'])
@require_auth
def get_shop_inventory():
"""Get shop inventory."""
shop_service = ShopService()
inventory = shop_service.get_shop_inventory("general_store")
return success_response({
'shop_name': "General Store",
'inventory': [
{
'item': item.to_dict(),
'price': price,
'in_stock': True
}
for item, price in inventory
]
})
@shop_bp.route('/purchase', methods=['POST'])
@require_auth
def purchase_item():
"""
Purchase item from shop.
Request JSON:
{
"character_id": "char_abc",
"item_id": "iron_sword",
"quantity": 1
}
"""
data = request.get_json()
character_id = data.get('character_id')
item_id = data.get('item_id')
quantity = data.get('quantity', 1)
# Get character
char_service = get_character_service()
character = char_service.get_character(character_id, g.user_id)
# Purchase item
shop_service = ShopService()
try:
result = shop_service.purchase_item(
character,
"general_store",
item_id,
quantity
)
# Save character
char_service.update_character(character)
return success_response(result)
except Exception as e:
return error_response(str(e), 400)
```
**Also create `/api/app/services/shop_service.py`:**
```python
"""
Shop Service
Manages NPC shop inventory and purchases.
"""
import yaml
from typing import List, Tuple
from app.models.items import Item
from app.models.character import Character
from app.services.item_loader import ItemLoader
from app.utils.logging import get_logger
logger = get_logger(__file__)
class ShopService:
"""Service for NPC shops."""
def __init__(self):
self.item_loader = ItemLoader()
self.shops = self._load_shops()
def _load_shops(self) -> dict:
"""Load all shop data from YAML."""
shops = {}
with open('app/data/shop/general_store.yaml', 'r') as f:
shop_data = yaml.safe_load(f)
shops[shop_data['shop_id']] = shop_data
return shops
def get_shop_inventory(self, shop_id: str) -> List[Tuple[Item, int]]:
"""
Get shop inventory.
Returns:
List of (Item, price) tuples
"""
shop = self.shops.get(shop_id)
if not shop:
return []
inventory = []
for item_data in shop['inventory']:
item = self.item_loader.get_item(item_data['item_id'])
price = item_data['price']
inventory.append((item, price))
return inventory
def purchase_item(
self,
character: Character,
shop_id: str,
item_id: str,
quantity: int = 1
) -> dict:
"""
Purchase item from shop.
Args:
character: Character instance
shop_id: Shop ID
item_id: Item to purchase
quantity: Quantity to buy
Returns:
Purchase result dict
Raises:
ValueError: If insufficient gold or item not found
"""
shop = self.shops.get(shop_id)
if not shop:
raise ValueError("Shop not found")
# Find item in shop inventory
item_data = next(
(i for i in shop['inventory'] if i['item_id'] == item_id),
None
)
if not item_data:
raise ValueError("Item not available in shop")
price = item_data['price'] * quantity
# Check if character has enough gold
if character.gold < price:
raise ValueError(f"Not enough gold. Need {price}, have {character.gold}")
# Deduct gold
character.gold -= price
# Add items to inventory
for _ in range(quantity):
if item_id not in character.inventory_item_ids:
character.inventory_item_ids.append(item_id)
else:
# Item already exists, increment stack (if stackable)
# For now, just add multiple entries
character.inventory_item_ids.append(item_id)
logger.info(f"Character {character.character_id} purchased {quantity}x {item_id} for {price} gold")
return {
'item_purchased': item_id,
'quantity': quantity,
'total_cost': price,
'gold_remaining': character.gold
}
```
**Acceptance Criteria:**
- Shop inventory endpoint works
- Purchase endpoint validates gold
- Items added to inventory
- Gold deducted
- Transactions logged
---
### Task 5.3: Shop UI (1 day / 8 hours)
**Objective:** Shop browse and purchase interface
**File:** `/public_web/templates/shop/index.html`
```html
{% extends "base.html" %}
{% block title %}Shop - Code of Conquest{% endblock %}
{% block content %}
<div class="shop-container">
<div class="shop-header">
<h1>🏪 {{ shop_name }}</h1>
<p class="shopkeeper">Shopkeeper: {{ shopkeeper_name }}</p>
<p class="player-gold">Your Gold: <strong>{{ character.gold }}</strong></p>
</div>
<div class="shop-inventory">
{% for item_entry in inventory %}
<div class="shop-item-card {{ item_entry.item.rarity }}">
<div class="item-header">
<h3>{{ item_entry.item.name }}</h3>
<span class="item-price">{{ item_entry.price }} gold</span>
</div>
<p class="item-description">{{ item_entry.item.description }}</p>
<div class="item-stats">
{% if item_entry.item.item_type == 'weapon' %}
<span>⚔️ Damage: {{ item_entry.item.damage }}</span>
{% elif item_entry.item.item_type == 'armor' %}
<span>🛡️ Defense: {{ item_entry.item.defense }}</span>
{% elif item_entry.item.item_type == 'consumable' %}
<span>❤️ Restores: {{ item_entry.item.hp_restore }} HP</span>
{% endif %}
</div>
<button class="btn btn-primary btn-purchase"
{% if character.gold < item_entry.price %}disabled{% endif %}
hx-post="/shop/purchase"
hx-vals='{"character_id": "{{ character.character_id }}", "item_id": "{{ item_entry.item.item_id }}"}'
hx-target=".shop-container"
hx-swap="outerHTML">
{% if character.gold >= item_entry.price %}
Purchase
{% else %}
Not Enough Gold
{% endif %}
</button>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
```
**Create view in `/public_web/app/views/shop.py`:**
```python
"""
Shop Views
"""
from flask import Blueprint, render_template, request, g
from app.services.api_client import APIClient, APIError
from app.utils.auth import require_auth
from app.utils.logging import get_logger
logger = get_logger(__file__)
shop_bp = Blueprint('shop', __name__)
@shop_bp.route('/')
@require_auth
def shop_index():
"""Display shop."""
api_client = APIClient()
try:
# Get shop inventory
shop_response = api_client.get('/shop/inventory')
inventory = shop_response['result']['inventory']
# Get character (for gold display)
char_response = api_client.get(f'/characters/{g.character_id}')
character = char_response['result']
return render_template(
'shop/index.html',
shop_name="General Store",
shopkeeper_name="Merchant Guildmaster",
inventory=inventory,
character=character
)
except APIError as e:
logger.error(f"Failed to load shop: {e}")
return render_template('partials/error.html', error=str(e))
@shop_bp.route('/purchase', methods=['POST'])
@require_auth
def purchase():
"""Purchase item (HTMX endpoint)."""
api_client = APIClient()
purchase_data = {
'character_id': request.form.get('character_id'),
'item_id': request.form.get('item_id'),
'quantity': 1
}
try:
response = api_client.post('/shop/purchase', json=purchase_data)
# Reload shop
return shop_index()
except APIError as e:
logger.error(f"Purchase failed: {e}")
return render_template('partials/error.html', error=str(e))
```
**Acceptance Criteria:**
- Shop displays all items
- Item cards show stats and price
- Purchase button disabled if not enough gold
- Purchase adds item to inventory
- Gold updates dynamically
- UI refreshes after purchase
---
### Task 5.4: Transaction Logging (2 hours)
**Objective:** Log all shop purchases
**File:** `/api/app/models/transaction.py`
```python
"""
Transaction Model
Tracks all gold transactions (shop, trades, etc.)
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any
@dataclass
class Transaction:
"""Represents a gold transaction."""
transaction_id: str
transaction_type: str # "shop_purchase", "trade", "quest_reward", etc.
character_id: str
amount: int # Negative for expenses, positive for income
description: str
timestamp: datetime = field(default_factory=datetime.utcnow)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dict."""
return {
"transaction_id": self.transaction_id,
"transaction_type": self.transaction_type,
"character_id": self.character_id,
"amount": self.amount,
"description": self.description,
"timestamp": self.timestamp.isoformat(),
"metadata": self.metadata
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
"""Deserialize from dict."""
return cls(
transaction_id=data["transaction_id"],
transaction_type=data["transaction_type"],
character_id=data["character_id"],
amount=data["amount"],
description=data["description"],
timestamp=datetime.fromisoformat(data["timestamp"]),
metadata=data.get("metadata", {})
)
```
**Update `ShopService.purchase_item()` to log transaction:**
```python
# In shop_service.py
def purchase_item(...):
# ... existing code ...
# Log transaction
from app.models.transaction import Transaction
import uuid
transaction = Transaction(
transaction_id=str(uuid.uuid4()),
transaction_type="shop_purchase",
character_id=character.character_id,
amount=-price,
description=f"Purchased {quantity}x {item_id} from {shop_id}",
metadata={
"shop_id": shop_id,
"item_id": item_id,
"quantity": quantity,
"unit_price": item_data['price']
}
)
# Save to database
from app.services.appwrite_service import get_appwrite_service
appwrite = get_appwrite_service()
appwrite.create_document("transactions", transaction.transaction_id, transaction.to_dict())
# ... rest of code ...
```
**Acceptance Criteria:**
- All purchases logged to database
- Transaction records complete
- Can query transaction history
---

View File

@@ -56,11 +56,13 @@ def create_app():
# Register blueprints
from .views.auth_views import auth_bp
from .views.character_views import character_bp
from .views.combat_views import combat_bp
from .views.game_views import game_bp
from .views.pages import pages_bp
app.register_blueprint(auth_bp)
app.register_blueprint(character_bp)
app.register_blueprint(combat_bp)
app.register_blueprint(game_bp)
app.register_blueprint(pages_bp)
@@ -109,6 +111,6 @@ def create_app():
logger.error("internal_server_error", error=str(error))
return render_template('errors/500.html'), 500
logger.info("flask_app_created", blueprints=["auth", "character", "game", "pages"])
logger.info("flask_app_created", blueprints=["auth", "character", "combat", "game", "pages"])
return app

View File

@@ -0,0 +1,574 @@
"""
Combat Views
Routes for combat UI.
"""
from flask import Blueprint, render_template, request, redirect, url_for, make_response
import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError
from ..utils.auth import require_auth_web as require_auth
logger = structlog.get_logger(__name__)
combat_bp = Blueprint('combat', __name__, url_prefix='/combat')
@combat_bp.route('/<session_id>')
@require_auth
def combat_view(session_id: str):
"""
Render the combat page for an active encounter.
Displays the 3-column combat interface with:
- Left: Combatants (player + enemies) with HP/MP bars
- Center: Combat log + action buttons
- Right: Turn order + active effects
"""
client = get_api_client()
try:
# Get combat state from API
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
# Check if combat is still active
if not result.get('in_combat'):
# Combat ended - redirect to game play
return redirect(url_for('game.play_session', session_id=session_id))
encounter = result.get('encounter') or {}
combat_log = result.get('combat_log', [])
# Get current turn combatant ID directly from API response
current_turn_id = encounter.get('current_turn')
# Find if it's the player's turn
is_player_turn = False
player_combatant = None
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
if combatant.get('combatant_id') == current_turn_id:
is_player_turn = True
break
# Format combat log entries for display
formatted_log = []
for entry in combat_log:
log_entry = {
'actor': entry.get('combatant_name', entry.get('actor', '')),
'message': entry.get('message', ''),
'damage': entry.get('damage'),
'heal': entry.get('healing'),
'is_crit': entry.get('is_critical', False),
'type': 'player' if entry.get('is_player', False) else 'enemy'
}
# Detect system messages
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
log_entry['type'] = 'system'
formatted_log.append(log_entry)
return render_template(
'game/combat.html',
session_id=session_id,
encounter=encounter,
combat_log=formatted_log,
current_turn_id=current_turn_id,
is_player_turn=is_player_turn,
player_combatant=player_combatant
)
except APINotFoundError:
logger.warning("combat_not_found", session_id=session_id)
return render_template('errors/404.html', message="No active combat encounter"), 404
except APIError as e:
logger.error("failed_to_load_combat", session_id=session_id, error=str(e))
return render_template('errors/500.html', message=str(e)), 500
@combat_bp.route('/<session_id>/action', methods=['POST'])
@require_auth
def combat_action(session_id: str):
"""
Execute a combat action (attack, defend, ability, item).
Returns updated combat log entries.
"""
client = get_api_client()
action_type = request.form.get('action_type', 'attack')
ability_id = request.form.get('ability_id')
item_id = request.form.get('item_id')
target_id = request.form.get('target_id')
try:
# Build action payload
payload = {
'action_type': action_type
}
if ability_id:
payload['ability_id'] = ability_id
if item_id:
payload['item_id'] = item_id
if target_id:
payload['target_id'] = target_id
# POST action to API
response = client.post(f'/api/v1/combat/{session_id}/action', payload)
result = response.get('result', {})
# Check if combat ended
combat_ended = result.get('combat_ended', False)
combat_status = result.get('combat_status')
if combat_ended:
# API returns lowercase status values: 'victory', 'defeat', 'fled'
status_lower = (combat_status or '').lower()
if status_lower == 'victory':
return render_template(
'game/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
elif status_lower == 'defeat':
return render_template(
'game/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0),
can_retry=result.get('can_retry', False)
)
# Format action result for log display
# API returns data directly in result, not nested under 'action_result'
log_entries = []
# Player action entry
player_entry = {
'actor': 'You',
'message': result.get('message', f'used {action_type}'),
'type': 'player',
'is_crit': False
}
# Add damage info if present
damage_results = result.get('damage_results', [])
if damage_results:
for dmg in damage_results:
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
player_entry['is_crit'] = dmg.get('is_critical', False)
if player_entry['is_crit']:
player_entry['type'] = 'crit'
# Add healing info if present
if result.get('healing'):
player_entry['heal'] = result.get('healing')
player_entry['type'] = 'heal'
log_entries.append(player_entry)
# Add any effect entries
for effect in result.get('effects_applied', []):
# API may use "name" or "effect" key for the effect name
effect_name = effect.get('name') or effect.get('effect') or 'Unknown'
log_entries.append({
'actor': '',
'message': effect.get('message', f'Effect applied: {effect_name}'),
'type': 'system'
})
# Return log entries HTML
resp = make_response(render_template(
'game/partials/combat_log.html',
combat_log=log_entries
))
# Trigger enemy turn if it's no longer player's turn
next_combatant = result.get('next_combatant_id')
if next_combatant and not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
return f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">Action failed: {e}</span>
</div>
''', 500
@combat_bp.route('/<session_id>/abilities')
@require_auth
def combat_abilities(session_id: str):
"""Get abilities modal for combat."""
client = get_api_client()
try:
# Get combat state to get player's abilities
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
encounter = result.get('encounter', {})
# Find player combatant
player_combatant = None
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
break
# Get abilities from player combatant or character
abilities = []
if player_combatant:
ability_ids = player_combatant.get('abilities', [])
current_mp = player_combatant.get('current_mp', 0)
cooldowns = player_combatant.get('cooldowns', {})
# Fetch ability details (if API has ability endpoint)
for ability_id in ability_ids:
try:
ability_response = client.get(f'/api/v1/abilities/{ability_id}')
ability_data = ability_response.get('result', {})
# Check availability
mp_cost = ability_data.get('mp_cost', 0)
cooldown = cooldowns.get(ability_id, 0)
available = current_mp >= mp_cost and cooldown == 0
abilities.append({
'id': ability_id,
'name': ability_data.get('name', ability_id),
'description': ability_data.get('description', ''),
'mp_cost': mp_cost,
'cooldown': cooldown,
'max_cooldown': ability_data.get('cooldown', 0),
'damage_type': ability_data.get('damage_type'),
'effect_type': ability_data.get('effect_type'),
'available': available
})
except (APINotFoundError, APIError):
# Ability not found, add basic entry
abilities.append({
'id': ability_id,
'name': ability_id.replace('_', ' ').title(),
'description': '',
'mp_cost': 0,
'cooldown': cooldowns.get(ability_id, 0),
'max_cooldown': 0,
'available': True
})
return render_template(
'game/partials/ability_modal.html',
session_id=session_id,
abilities=abilities
)
except APIError as e:
logger.error("failed_to_load_abilities", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content modal-content--md">
<div class="modal-header">
<h3 class="modal-title">Select Ability</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="items-empty">Failed to load abilities: {e}</div>
</div>
</div>
</div>
'''
@combat_bp.route('/<session_id>/items')
@require_auth
def combat_items(session_id: str):
"""
Get combat items bottom sheet (consumables only).
Returns a bottom sheet UI with only consumable items that can be used in combat.
"""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Get character inventory - filter to consumables only
consumables = []
if character_id:
try:
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
# Filter to consumable items only
for item in inventory:
item_type = item.get('item_type', item.get('type', ''))
if item_type == 'consumable' or item.get('usable_in_combat', False):
consumables.append({
'item_id': item.get('item_id'),
'name': item.get('name', 'Unknown Item'),
'description': item.get('description', ''),
'effects_on_use': item.get('effects_on_use', []),
'rarity': item.get('rarity', 'common')
})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e))
return render_template(
'game/partials/combat_items_sheet.html',
session_id=session_id,
consumables=consumables,
has_consumables=len(consumables) > 0
)
except APIError as e:
logger.error("failed_to_load_items", session_id=session_id, error=str(e))
return f'''
<div class="combat-items-sheet open">
<div class="sheet-handle"></div>
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
<div class="no-consumables">Failed to load items: {e}</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
'''
@combat_bp.route('/<session_id>/items/<item_id>/detail')
@require_auth
def combat_item_detail(session_id: str, item_id: str):
"""Get item detail for combat bottom sheet."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
item = None
if character_id:
# Get inventory and find the item
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
for inv_item in inventory:
if inv_item.get('item_id') == item_id:
item = inv_item
break
if not item:
return '<p>Item not found</p>', 404
# Format effect description
effect_desc = item.get('description', 'Use this item')
effects = item.get('effects_on_use', [])
if effects:
effect_parts = []
for effect in effects:
if effect.get('stat') == 'hp':
effect_parts.append(f"Restores {effect.get('value', 0)} HP")
elif effect.get('stat') == 'mp':
effect_parts.append(f"Restores {effect.get('value', 0)} MP")
elif effect.get('name'):
effect_parts.append(effect.get('name'))
if effect_parts:
effect_desc = ', '.join(effect_parts)
return f'''
<div class="detail-info">
<div class="detail-name">{item.get('name', 'Item')}</div>
<div class="detail-effect">{effect_desc}</div>
</div>
<button class="use-btn"
hx-post="{url_for('combat.combat_action', session_id=session_id)}"
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeCombatSheet()">
Use
</button>
'''
except APIError as e:
logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e))
return f'<p>Failed to load item: {e}</p>', 500
@combat_bp.route('/<session_id>/flee', methods=['POST'])
@require_auth
def combat_flee(session_id: str):
"""Attempt to flee from combat."""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/flee', {})
result = response.get('result', {})
if result.get('success'):
# Flee successful - use HX-Redirect for HTMX
resp = make_response(f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'You fled from combat!')}</span>
</div>
''')
resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id)
return resp
else:
# Flee failed - return log entry, trigger enemy turn
resp = make_response(f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
</div>
''')
# Failed flee consumes turn, so trigger enemy turn if needed
if not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("flee_failed", session_id=session_id, error=str(e))
return f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">Flee failed: {e}</span>
</div>
''', 500
@combat_bp.route('/<session_id>/enemy-turn', methods=['POST'])
@require_auth
def combat_enemy_turn(session_id: str):
"""Execute enemy turn and return result."""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {})
result = response.get('result', {})
# Check if combat ended
combat_ended = result.get('combat_ended', False)
combat_status = result.get('combat_status')
if combat_ended:
# API returns lowercase status values: 'victory', 'defeat', 'fled'
status_lower = (combat_status or '').lower()
if status_lower == 'victory':
return render_template(
'game/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
elif status_lower == 'defeat':
return render_template(
'game/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0),
can_retry=result.get('can_retry', False)
)
# Format enemy action for log
# API returns ActionResult directly in result, not nested under action_result
log_entries = [{
'actor': 'Enemy',
'message': result.get('message', 'attacks'),
'type': 'enemy',
'is_crit': False
}]
# Add damage info - API returns total_damage, not damage
damage_results = result.get('damage_results', [])
if damage_results:
log_entries[0]['damage'] = damage_results[0].get('total_damage')
log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False)
# Check if it's still enemy turn (multiple enemies)
resp = make_response(render_template(
'game/partials/combat_log.html',
combat_log=log_entries
))
# If next combatant is also an enemy, trigger another enemy turn
if result.get('next_combatant_id') and not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("enemy_turn_failed", session_id=session_id, error=str(e))
return f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">Enemy turn error: {e}</span>
</div>
''', 500
@combat_bp.route('/<session_id>/log')
@require_auth
def combat_log(session_id: str):
"""Get current combat log."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
combat_log_data = result.get('combat_log', [])
# Format log entries
formatted_log = []
for entry in combat_log_data:
log_entry = {
'actor': entry.get('combatant_name', entry.get('actor', '')),
'message': entry.get('message', ''),
'damage': entry.get('damage'),
'heal': entry.get('healing'),
'is_crit': entry.get('is_critical', False),
'type': 'player' if entry.get('is_player', False) else 'enemy'
}
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
log_entry['type'] = 'system'
formatted_log.append(log_entry)
return render_template(
'game/partials/combat_log.html',
combat_log=formatted_log
)
except APIError as e:
logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e))
return '<div class="combat-log__empty">Failed to load combat log</div>', 500
@combat_bp.route('/<session_id>/results')
@require_auth
def combat_results(session_id: str):
"""Display combat results (victory/defeat)."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/results')
results = response.get('result', {})
return render_template(
'game/combat_results.html',
victory=results['victory'],
xp_gained=results['xp_gained'],
gold_gained=results['gold_gained'],
loot=results['loot']
)
except APIError as e:
logger.error("failed_to_load_combat_results", session_id=session_id, error=str(e))
return redirect(url_for('game.play_session', session_id=session_id))

View File

@@ -380,3 +380,652 @@ def do_travel(session_id: str):
except APIError as e:
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
return f'<div class="error">Travel failed: {e}</div>', 500
# ===== Combat Test Endpoints =====
@dev_bp.route('/combat')
@require_auth
def combat_hub():
"""Combat testing hub - select character and enemies to start combat."""
client = get_api_client()
try:
# Get user's characters
characters_response = client.get('/api/v1/characters')
result = characters_response.get('result', {})
characters = result.get('characters', [])
# Get available enemy templates
enemies = []
try:
enemies_response = client.get('/api/v1/combat/enemies')
enemies = enemies_response.get('result', {}).get('enemies', [])
except (APINotFoundError, APIError):
# Enemies endpoint may not exist yet
pass
# Get all sessions to map characters to their sessions
sessions_in_combat = []
character_session_map = {} # character_id -> session_id
try:
sessions_response = client.get('/api/v1/sessions')
all_sessions = sessions_response.get('result', [])
for session in all_sessions:
# Map character to session (for dropdown)
char_id = session.get('character_id')
if char_id:
character_session_map[char_id] = session.get('session_id')
# Track sessions in combat (for resume list)
if session.get('in_combat') or session.get('game_state', {}).get('in_combat'):
sessions_in_combat.append(session)
except (APINotFoundError, APIError):
pass
# Add session_id to each character for the template
for char in characters:
char['session_id'] = character_session_map.get(char.get('character_id'))
return render_template(
'dev/combat.html',
characters=characters,
enemies=enemies,
sessions_in_combat=sessions_in_combat
)
except APIError as e:
logger.error("failed_to_load_combat_hub", error=str(e))
return render_template('dev/combat.html', characters=[], enemies=[], sessions_in_combat=[], error=str(e))
@dev_bp.route('/combat/start', methods=['POST'])
@require_auth
def start_combat():
"""Start a new combat encounter - returns redirect to combat session."""
client = get_api_client()
session_id = request.form.get('session_id')
enemy_ids = request.form.getlist('enemy_ids')
logger.info("start_combat called",
session_id=session_id,
enemy_ids=enemy_ids,
form_data=dict(request.form))
if not session_id:
return '<div class="error">No session selected</div>', 400
if not enemy_ids:
return '<div class="error">No enemies selected</div>', 400
try:
response = client.post('/api/v1/combat/start', {
'session_id': session_id,
'enemy_ids': enemy_ids
})
result = response.get('result', {})
# Return redirect script to combat session page
return f'''
<script>window.location.href = '/dev/combat/session/{session_id}';</script>
<div class="success">Combat started! Redirecting...</div>
'''
except APIError as e:
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
@dev_bp.route('/combat/session/<session_id>')
@require_auth
def combat_session(session_id: str):
"""Combat session debug interface - full 3-column layout."""
client = get_api_client()
try:
# Get combat state from API
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
# Check if combat is still active
if not result.get('in_combat'):
# Combat ended - redirect to combat index
return render_template('dev/combat.html',
message="Combat has ended. Start a new combat to continue.")
encounter = result.get('encounter') or {}
combat_log = result.get('combat_log', [])
# Get current turn combatant ID directly from API response
current_turn_id = encounter.get('current_turn')
turn_order = encounter.get('turn_order', [])
# Find player and determine if it's player's turn
is_player_turn = False
player_combatant = None
enemy_combatants = []
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
if combatant.get('combatant_id') == current_turn_id:
is_player_turn = True
else:
enemy_combatants.append(combatant)
# Format combat log entries for display
formatted_log = []
for entry in combat_log:
log_entry = {
'actor': entry.get('combatant_name', entry.get('actor', '')),
'message': entry.get('message', ''),
'damage': entry.get('damage'),
'heal': entry.get('healing'),
'is_crit': entry.get('is_critical', False),
'type': 'player' if entry.get('is_player', False) else 'enemy'
}
# Detect system messages
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
log_entry['type'] = 'system'
formatted_log.append(log_entry)
return render_template(
'dev/combat_session.html',
session_id=session_id,
encounter=encounter,
combat_log=formatted_log,
current_turn_id=current_turn_id,
is_player_turn=is_player_turn,
player_combatant=player_combatant,
enemy_combatants=enemy_combatants,
turn_order=turn_order,
raw_state=result
)
except APINotFoundError:
logger.warning("combat_not_found", session_id=session_id)
return render_template('dev/combat.html', error=f"No active combat for session {session_id}"), 404
except APIError as e:
logger.error("failed_to_load_combat_session", session_id=session_id, error=str(e))
return render_template('dev/combat.html', error=str(e)), 500
@dev_bp.route('/combat/<session_id>/state')
@require_auth
def combat_state(session_id: str):
"""Get combat state partial - returns refreshable state panel."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
# Check if combat is still active
if not result.get('in_combat'):
return '<div class="state-section"><h4>Combat Ended</h4><p>No active combat.</p></div>'
encounter = result.get('encounter') or {}
# Get current turn combatant ID directly from API response
current_turn_id = encounter.get('current_turn')
# Separate player and enemies
player_combatant = None
enemy_combatants = []
is_player_turn = False
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
if combatant.get('combatant_id') == current_turn_id:
is_player_turn = True
else:
enemy_combatants.append(combatant)
return render_template(
'dev/partials/combat_state.html',
session_id=session_id,
encounter=encounter,
player_combatant=player_combatant,
enemy_combatants=enemy_combatants,
current_turn_id=current_turn_id,
is_player_turn=is_player_turn
)
except APIError as e:
logger.error("failed_to_get_combat_state", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load state: {e}</div>', 500
@dev_bp.route('/combat/<session_id>/action', methods=['POST'])
@require_auth
def combat_action(session_id: str):
"""Execute a combat action - returns log entry HTML."""
client = get_api_client()
action_type = request.form.get('action_type', 'attack')
ability_id = request.form.get('ability_id')
item_id = request.form.get('item_id')
target_id = request.form.get('target_id')
try:
payload = {'action_type': action_type}
if ability_id:
payload['ability_id'] = ability_id
if item_id:
payload['item_id'] = item_id
if target_id:
payload['target_id'] = target_id
response = client.post(f'/api/v1/combat/{session_id}/action', payload)
result = response.get('result', {})
# Check if combat ended
combat_ended = result.get('combat_ended', False)
combat_status = result.get('combat_status')
if combat_ended:
# API returns lowercase status values: 'victory', 'defeat', 'fled'
status_lower = (combat_status or '').lower()
if status_lower == 'victory':
return render_template(
'dev/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
elif status_lower == 'defeat':
return render_template(
'dev/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0)
)
# Format action result for log
# API returns data directly in result, not nested under 'action_result'
log_entries = []
player_entry = {
'actor': 'You',
'message': result.get('message', f'used {action_type}'),
'type': 'player',
'is_crit': False
}
damage_results = result.get('damage_results', [])
if damage_results:
for dmg in damage_results:
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
player_entry['is_crit'] = dmg.get('is_critical', False)
if player_entry['is_crit']:
player_entry['type'] = 'crit'
if result.get('healing'):
player_entry['heal'] = result.get('healing')
player_entry['type'] = 'heal'
log_entries.append(player_entry)
for effect in result.get('effects_applied', []):
log_entries.append({
'actor': '',
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
'type': 'system'
})
# Return log entries with optional enemy turn trigger
from flask import make_response
resp = make_response(render_template(
'dev/partials/combat_debug_log.html',
combat_log=log_entries
))
# Trigger enemy turn if needed
if result.get('next_combatant_id') and not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Action failed: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/enemy-turn', methods=['POST'])
@require_auth
def combat_enemy_turn(session_id: str):
"""Execute enemy turn - returns log entry HTML."""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {})
result = response.get('result', {})
# Check if combat ended
combat_ended = result.get('combat_ended', False)
combat_status = result.get('combat_status')
if combat_ended:
# API returns lowercase status values: 'victory', 'defeat', 'fled'
status_lower = (combat_status or '').lower()
if status_lower == 'victory':
return render_template(
'dev/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
elif status_lower == 'defeat':
return render_template(
'dev/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0)
)
# Format enemy action for log
# The API returns the action result directly with a complete message
damage_results = result.get('damage_results', [])
is_crit = damage_results[0].get('is_critical', False) if damage_results else False
log_entries = [{
'actor': '', # Message already contains the actor name
'message': result.get('message', 'Enemy attacks!'),
'type': 'crit' if is_crit else 'enemy',
'is_crit': is_crit,
'damage': damage_results[0].get('total_damage') if damage_results else None
}]
from flask import make_response
resp = make_response(render_template(
'dev/partials/combat_debug_log.html',
combat_log=log_entries
))
# Trigger another enemy turn if needed
if result.get('next_combatant_id') and not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("enemy_turn_failed", session_id=session_id, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Enemy turn error: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/abilities')
@require_auth
def combat_abilities(session_id: str):
"""Get abilities modal for combat."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
encounter = result.get('encounter', {})
player_combatant = None
for combatant in encounter.get('combatants', []):
if combatant.get('is_player'):
player_combatant = combatant
break
abilities = []
if player_combatant:
ability_ids = player_combatant.get('abilities', [])
current_mp = player_combatant.get('current_mp', 0)
cooldowns = player_combatant.get('cooldowns', {})
for ability_id in ability_ids:
try:
ability_response = client.get(f'/api/v1/abilities/{ability_id}')
ability_data = ability_response.get('result', {})
mp_cost = ability_data.get('mp_cost', 0)
cooldown = cooldowns.get(ability_id, 0)
available = current_mp >= mp_cost and cooldown == 0
abilities.append({
'id': ability_id,
'name': ability_data.get('name', ability_id),
'description': ability_data.get('description', ''),
'mp_cost': mp_cost,
'cooldown': cooldown,
'max_cooldown': ability_data.get('cooldown', 0),
'damage_type': ability_data.get('damage_type'),
'effect_type': ability_data.get('effect_type'),
'available': available
})
except (APINotFoundError, APIError):
abilities.append({
'id': ability_id,
'name': ability_id.replace('_', ' ').title(),
'description': '',
'mp_cost': 0,
'cooldown': cooldowns.get(ability_id, 0),
'max_cooldown': 0,
'available': True
})
return render_template(
'dev/partials/ability_modal.html',
session_id=session_id,
abilities=abilities
)
except APIError as e:
logger.error("failed_to_load_abilities", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content">
<h3>Select Ability</h3>
<div class="error">Failed to load abilities: {e}</div>
<button class="modal-close" onclick="closeModal()">Close</button>
</div>
</div>
'''
@dev_bp.route('/combat/<session_id>/items')
@require_auth
def combat_items(session_id: str):
"""Get combat items bottom sheet (consumables only)."""
client = get_api_client()
try:
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
consumables = []
if character_id:
try:
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
for item in inventory:
item_type = item.get('item_type', item.get('type', ''))
if item_type == 'consumable' or item.get('usable_in_combat', False):
consumables.append({
'item_id': item.get('item_id'),
'name': item.get('name', 'Unknown Item'),
'description': item.get('description', ''),
'effects_on_use': item.get('effects_on_use', []),
'rarity': item.get('rarity', 'common')
})
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e))
return render_template(
'dev/partials/combat_items_sheet.html',
session_id=session_id,
consumables=consumables,
has_consumables=len(consumables) > 0
)
except APIError as e:
logger.error("failed_to_load_items", session_id=session_id, error=str(e))
return f'''
<div class="combat-items-sheet open">
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
<div class="error">Failed to load items: {e}</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
'''
@dev_bp.route('/combat/<session_id>/items/<item_id>/detail')
@require_auth
def combat_item_detail(session_id: str, item_id: str):
"""Get item detail for combat bottom sheet."""
client = get_api_client()
try:
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
item = None
if character_id:
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
for inv_item in inventory:
if inv_item.get('item_id') == item_id:
item = inv_item
break
if not item:
return '<p>Item not found</p>', 404
effect_desc = item.get('description', 'Use this item')
effects = item.get('effects_on_use', [])
if effects:
effect_parts = []
for effect in effects:
if effect.get('stat') == 'hp':
effect_parts.append(f"Restores {effect.get('value', 0)} HP")
elif effect.get('stat') == 'mp':
effect_parts.append(f"Restores {effect.get('value', 0)} MP")
elif effect.get('name'):
effect_parts.append(effect.get('name'))
if effect_parts:
effect_desc = ', '.join(effect_parts)
return f'''
<div class="detail-info">
<div class="detail-name">{item.get('name', 'Item')}</div>
<div class="detail-effect">{effect_desc}</div>
</div>
<button class="use-btn"
hx-post="/dev/combat/{session_id}/action"
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeCombatSheet()">
Use
</button>
'''
except APIError as e:
logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e))
return f'<p>Failed to load item: {e}</p>', 500
@dev_bp.route('/combat/<session_id>/end', methods=['POST'])
@require_auth
def force_end_combat(session_id: str):
"""Force end combat (debug action)."""
client = get_api_client()
victory = request.form.get('victory', 'true').lower() == 'true'
try:
response = client.post(f'/api/v1/combat/{session_id}/end', {'victory': victory})
result = response.get('result', {})
if victory:
return render_template(
'dev/partials/combat_victory.html',
session_id=session_id,
rewards=result.get('rewards', {})
)
else:
return render_template(
'dev/partials/combat_defeat.html',
session_id=session_id,
gold_lost=result.get('gold_lost', 0)
)
except APIError as e:
logger.error("failed_to_end_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to end combat: {e}</div>', 500
@dev_bp.route('/combat/<session_id>/reset-hp-mp', methods=['POST'])
@require_auth
def reset_hp_mp(session_id: str):
"""Reset player HP and MP to full (debug action)."""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/debug/reset-hp-mp', {})
result = response.get('result', {})
return f'''
<div class="log-entry log-entry--heal">
<span class="log-message">HP and MP restored to full! (HP: {result.get('current_hp', '?')}/{result.get('max_hp', '?')}, MP: {result.get('current_mp', '?')}/{result.get('max_mp', '?')})</span>
</div>
'''
except APIError as e:
logger.error("failed_to_reset_hp_mp", session_id=session_id, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Failed to reset HP/MP: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/log')
@require_auth
def combat_log(session_id: str):
"""Get full combat log."""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/state')
result = response.get('result', {})
combat_log_data = result.get('combat_log', [])
formatted_log = []
for entry in combat_log_data:
log_entry = {
'actor': entry.get('combatant_name', entry.get('actor', '')),
'message': entry.get('message', ''),
'damage': entry.get('damage'),
'heal': entry.get('healing'),
'is_crit': entry.get('is_critical', False),
'type': 'player' if entry.get('is_player', False) else 'enemy'
}
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
log_entry['type'] = 'system'
formatted_log.append(log_entry)
return render_template(
'dev/partials/combat_debug_log.html',
combat_log=formatted_log
)
except APIError as e:
logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e))
return '<div class="error">Failed to load combat log</div>', 500

View File

@@ -7,7 +7,7 @@ Provides the main gameplay interface with 3-column layout:
- Right: Accordions for history, quests, NPCs, map
"""
from flask import Blueprint, render_template, request
from flask import Blueprint, render_template, request, redirect, url_for
import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError
@@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play')
DEFAULT_ACTIONS = {
'free': [
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
],
@@ -718,6 +717,243 @@ def do_travel(session_id: str):
return f'<div class="error">Travel failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/monster-modal')
@require_auth
def monster_modal(session_id: str):
"""
Get monster selection modal with encounter options.
Fetches random encounter groups appropriate for the current location
and character level from the API.
"""
client = get_api_client()
try:
# Get encounter options from API
response = client.get(f'/api/v1/combat/encounters?session_id={session_id}')
result = response.get('result', {})
location_name = result.get('location_name', 'Unknown Area')
encounters = result.get('encounters', [])
return render_template(
'game/partials/monster_modal.html',
session_id=session_id,
location_name=location_name,
encounters=encounters
)
except APINotFoundError as e:
# No enemies found for this location
return render_template(
'game/partials/monster_modal.html',
session_id=session_id,
location_name='this area',
encounters=[]
)
except APIError as e:
logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">⚔️ Search for Monsters</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="error">Failed to search for monsters: {e}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/combat/start', methods=['POST'])
@require_auth
def start_combat(session_id: str):
"""
Start combat with selected enemies.
Called when player selects an encounter from the monster modal.
Initiates combat via API and redirects to combat UI.
If there's already an active combat session, shows a conflict modal
allowing the user to resume or abandon the existing combat.
"""
from flask import make_response
client = get_api_client()
# Get enemy_ids from request
# HTMX hx-vals sends as form data (not JSON), where arrays become multiple values
if request.is_json:
enemy_ids = request.json.get('enemy_ids', [])
else:
# Form data: array values come as multiple entries with the same key
enemy_ids = request.form.getlist('enemy_ids')
if not enemy_ids:
return '<div class="error">No enemies selected.</div>', 400
try:
# Start combat via API
response = client.post('/api/v1/combat/start', {
'session_id': session_id,
'enemy_ids': enemy_ids
})
result = response.get('result', {})
encounter_id = result.get('encounter_id')
if not encounter_id:
logger.error("combat_start_no_encounter_id", session_id=session_id)
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
logger.info("combat_started_from_modal",
session_id=session_id,
encounter_id=encounter_id,
enemy_count=len(enemy_ids))
# Close modal and redirect to combat page
resp = make_response('')
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
return resp
except APIError as e:
# Check if this is an "already in combat" error
error_str = str(e)
if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str:
# Fetch existing combat info and show conflict modal
try:
check_response = client.get(f'/api/v1/combat/{session_id}/check')
combat_info = check_response.get('result', {})
if combat_info.get('has_active_combat'):
return render_template(
'game/partials/combat_conflict_modal.html',
session_id=session_id,
combat_info=combat_info,
pending_enemy_ids=enemy_ids
)
except APIError:
pass # Fall through to generic error
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to start combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/combat/check', methods=['GET'])
@require_auth
def check_combat_status(session_id: str):
"""
Check if the session has an active combat.
Returns JSON with combat status that can be used by HTMX
to decide whether to show the monster modal or conflict modal.
"""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/check')
result = response.get('result', {})
return result
except APIError as e:
logger.error("failed_to_check_combat", session_id=session_id, error=str(e))
return {'has_active_combat': False, 'error': str(e)}
@game_bp.route('/session/<session_id>/combat/abandon', methods=['POST'])
@require_auth
def abandon_combat(session_id: str):
"""
Abandon an existing combat session.
Called when player chooses to abandon their current combat
in order to start a fresh one.
"""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
result = response.get('result', {})
if result.get('success'):
logger.info("combat_abandoned", session_id=session_id)
# Return success - the frontend will then try to start new combat
return render_template(
'game/partials/combat_abandoned_success.html',
session_id=session_id,
message="Combat abandoned. You can now start a new encounter."
)
else:
return '<div class="error">No active combat to abandon.</div>', 400
except APIError as e:
logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to abandon combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/combat/abandon-and-start', methods=['POST'])
@require_auth
def abandon_and_start_combat(session_id: str):
"""
Abandon existing combat and start a new one in a single action.
This is a convenience endpoint that combines abandon + start
for a smoother user experience in the conflict modal.
"""
from flask import make_response
client = get_api_client()
# Get enemy_ids from request
if request.is_json:
enemy_ids = request.json.get('enemy_ids', [])
else:
enemy_ids = request.form.getlist('enemy_ids')
if not enemy_ids:
return '<div class="error">No enemies selected.</div>', 400
try:
# First abandon the existing combat
abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
abandon_result = abandon_response.get('result', {})
if not abandon_result.get('success'):
# No combat to abandon, but that's fine - proceed with start
logger.info("no_combat_to_abandon", session_id=session_id)
# Now start the new combat
start_response = client.post('/api/v1/combat/start', {
'session_id': session_id,
'enemy_ids': enemy_ids
})
result = start_response.get('result', {})
encounter_id = result.get('encounter_id')
if not encounter_id:
logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id)
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
logger.info("combat_started_after_abandon",
session_id=session_id,
encounter_id=encounter_id,
enemy_count=len(enemy_ids))
# Close modal and redirect to combat page
resp = make_response('')
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
return resp
except APIError as e:
logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to start combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>')
@require_auth
def npc_chat_page(session_id: str, npc_id: str):
@@ -866,6 +1102,220 @@ def npc_chat_history(session_id: str, npc_id: str):
return '<div class="history-empty">Failed to load history</div>', 500
# ===== Inventory Routes =====
@game_bp.route('/session/<session_id>/inventory-modal')
@require_auth
def inventory_modal(session_id: str):
"""
Get inventory modal with all items.
Supports filtering by item type via ?filter= parameter.
"""
client = get_api_client()
filter_type = request.args.get('filter', 'all')
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
inventory = []
equipped = {}
gold = 0
inventory_count = 0
inventory_max = 100
if character_id:
try:
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
equipped = inv_data.get('equipped', {})
inventory_count = inv_data.get('inventory_count', len(inventory))
inventory_max = inv_data.get('max_inventory', 100)
# Get gold from character
char_response = client.get(f'/api/v1/characters/{character_id}')
char_data = char_response.get('result', {})
gold = char_data.get('gold', 0)
except (APINotFoundError, APIError) as e:
logger.warning("failed_to_load_inventory", character_id=character_id, error=str(e))
# Filter inventory by type if specified
if filter_type != 'all':
inventory = [item for item in inventory if item.get('item_type') == filter_type]
return render_template(
'game/partials/inventory_modal.html',
session_id=session_id,
inventory=inventory,
equipped=equipped,
gold=gold,
inventory_count=inventory_count,
inventory_max=inventory_max,
filter=filter_type
)
except APIError as e:
logger.error("failed_to_load_inventory_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content inventory-modal">
<div class="modal-header">
<h2>Inventory</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="inventory-empty">Failed to load inventory: {e}</div>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/inventory/item/<item_id>')
@require_auth
def inventory_item_detail(session_id: str, item_id: str):
"""Get item detail partial for HTMX swap."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
item = None
if character_id:
# Get inventory and find the item
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
inv_data = inv_response.get('result', {})
inventory = inv_data.get('inventory', [])
for inv_item in inventory:
if inv_item.get('item_id') == item_id:
item = inv_item
break
if not item:
return '<div class="item-detail-empty">Item not found</div>', 404
# Determine suggested slot for equipment
suggested_slot = None
item_type = item.get('item_type', '')
if item_type == 'weapon':
suggested_slot = 'weapon'
elif item_type == 'armor':
# Could be any armor slot - default to chest
suggested_slot = 'chest'
return render_template(
'game/partials/inventory_item_detail.html',
session_id=session_id,
item=item,
suggested_slot=suggested_slot
)
except APIError as e:
logger.error("failed_to_load_item_detail", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="item-detail-empty">Failed to load item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/use', methods=['POST'])
@require_auth
def inventory_use(session_id: str):
"""Use a consumable item."""
client = get_api_client()
item_id = request.form.get('item_id')
if not item_id:
return '<div class="error">No item selected</div>', 400
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return '<div class="error">No character found</div>', 400
# Use the item via API
client.post(f'/api/v1/characters/{character_id}/inventory/use', {
'item_id': item_id
})
# Return updated character panel
return redirect(url_for('game.character_panel', session_id=session_id))
except APIError as e:
logger.error("failed_to_use_item", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="error">Failed to use item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/equip', methods=['POST'])
@require_auth
def inventory_equip(session_id: str):
"""Equip an item to a slot."""
client = get_api_client()
item_id = request.form.get('item_id')
slot = request.form.get('slot')
if not item_id:
return '<div class="error">No item selected</div>', 400
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return '<div class="error">No character found</div>', 400
# Equip the item via API
payload = {'item_id': item_id}
if slot:
payload['slot'] = slot
client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload)
# Return updated character panel
return redirect(url_for('game.character_panel', session_id=session_id))
except APIError as e:
logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="error">Failed to equip item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/<item_id>', methods=['DELETE'])
@require_auth
def inventory_drop(session_id: str, item_id: str):
"""Drop (delete) an item from inventory."""
client = get_api_client()
try:
# Get session to find character
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
if not character_id:
return '<div class="error">No character found</div>', 400
# Delete the item via API
client.delete(f'/api/v1/characters/{character_id}/inventory/{item_id}')
# Return updated inventory modal
return redirect(url_for('game.inventory_modal', session_id=session_id))
except APIError as e:
logger.error("failed_to_drop_item", session_id=session_id, item_id=item_id, error=str(e))
return f'<div class="error">Failed to drop item: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
@require_auth
def talk_to_npc(session_id: str, npc_id: str):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,722 @@
/**
* Code of Conquest - Inventory UI Stylesheet
* Inventory modal, item grid, and combat items sheet
*/
/* ===== INVENTORY VARIABLES ===== */
:root {
/* Rarity colors */
--rarity-common: #9ca3af;
--rarity-uncommon: #22c55e;
--rarity-rare: #3b82f6;
--rarity-epic: #a855f7;
--rarity-legendary: #f59e0b;
/* Item card */
--item-bg: var(--bg-input, #1e1e24);
--item-border: var(--border-primary, #3a3a45);
--item-hover-bg: rgba(255, 255, 255, 0.05);
/* Touch targets - WCAG compliant */
--touch-target-min: 48px;
--touch-target-primary: 56px;
--touch-spacing: 8px;
}
/* ===== INVENTORY MODAL ===== */
.inventory-modal {
max-width: 800px;
width: 95%;
max-height: 85vh;
}
.inventory-modal .modal-body {
display: flex;
flex-direction: row;
gap: 1rem;
padding: 1rem;
overflow: hidden;
}
/* ===== TAB FILTER BAR ===== */
.inventory-tabs {
display: flex;
gap: 0.25rem;
padding: 0 1rem;
background: var(--bg-tertiary, #16161a);
border-bottom: 1px solid var(--play-border, #3a3a45);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.inventory-tabs .tab {
min-height: var(--touch-target-min);
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, #a0a0a8);
font-size: var(--text-sm, 0.875rem);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
}
.inventory-tabs .tab:hover {
color: var(--text-primary, #e5e5e5);
background: var(--item-hover-bg);
}
.inventory-tabs .tab.active {
color: var(--accent-gold, #f3a61a);
border-bottom-color: var(--accent-gold, #f3a61a);
}
/* ===== INVENTORY CONTENT LAYOUT ===== */
.inventory-body {
flex: 1;
display: flex;
gap: 1rem;
overflow: hidden;
}
.inventory-grid-container {
flex: 1;
overflow-y: auto;
padding-right: 0.5rem;
}
/* ===== ITEM GRID ===== */
.inventory-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--touch-spacing);
}
/* Responsive grid columns */
@media (max-width: 900px) {
.inventory-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 600px) {
.inventory-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ===== INVENTORY ITEM CARD ===== */
.inventory-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
min-height: 96px;
min-width: 80px;
background: var(--item-bg);
border: 2px solid var(--item-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.inventory-item:hover,
.inventory-item:focus {
background: var(--item-hover-bg);
transform: translateY(-2px);
}
.inventory-item:focus {
outline: 2px solid var(--accent-gold, #f3a61a);
outline-offset: 2px;
}
.inventory-item.selected {
border-color: var(--accent-gold, #f3a61a);
box-shadow: 0 0 12px rgba(243, 166, 26, 0.3);
}
/* Rarity border colors */
.inventory-item.rarity-common { border-color: var(--rarity-common); }
.inventory-item.rarity-uncommon { border-color: var(--rarity-uncommon); }
.inventory-item.rarity-rare { border-color: var(--rarity-rare); }
.inventory-item.rarity-epic { border-color: var(--rarity-epic); }
.inventory-item.rarity-legendary {
border-color: var(--rarity-legendary);
box-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
}
/* Item icon */
.inventory-item img {
width: 40px;
height: 40px;
object-fit: contain;
margin-bottom: 0.5rem;
opacity: 0.9;
}
/* Item name */
.inventory-item .item-name {
font-size: var(--text-xs, 0.75rem);
color: var(--text-primary, #e5e5e5);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Item quantity badge */
.inventory-item .item-quantity {
position: absolute;
top: 4px;
right: 4px;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--bg-tertiary, #16161a);
border: 1px solid var(--item-border);
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-primary, #e5e5e5);
display: flex;
align-items: center;
justify-content: center;
}
/* Empty state */
.inventory-empty {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: var(--text-muted, #707078);
font-style: italic;
}
/* ===== ITEM DETAIL PANEL ===== */
.item-detail {
width: 280px;
min-width: 280px;
background: var(--bg-tertiary, #16161a);
border: 1px solid var(--play-border, #3a3a45);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
}
.item-detail-empty {
color: var(--text-muted, #707078);
text-align: center;
padding: 2rem 1rem;
font-style: italic;
}
.item-detail-content {
display: flex;
flex-direction: column;
height: 100%;
}
.item-detail-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--play-border, #3a3a45);
}
.item-detail-icon {
width: 48px;
height: 48px;
object-fit: contain;
}
.item-detail-title h3 {
font-family: var(--font-heading);
font-size: var(--text-lg, 1.125rem);
margin: 0 0 0.25rem 0;
}
.item-detail-title .item-type {
font-size: var(--text-xs, 0.75rem);
color: var(--text-muted, #707078);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Rarity text colors */
.rarity-text-common { color: var(--rarity-common); }
.rarity-text-uncommon { color: var(--rarity-uncommon); }
.rarity-text-rare { color: var(--rarity-rare); }
.rarity-text-epic { color: var(--rarity-epic); }
.rarity-text-legendary { color: var(--rarity-legendary); }
.item-description {
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #a0a0a8);
line-height: 1.5;
margin-bottom: 1rem;
}
/* Item stats */
.item-stats {
background: var(--item-bg);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 1rem;
}
.item-stats div {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: var(--text-sm, 0.875rem);
}
.item-stats div:not(:last-child) {
border-bottom: 1px solid var(--item-border);
}
/* Item action buttons */
.item-actions {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-actions .action-btn {
min-height: var(--touch-target-primary);
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
font-size: var(--text-sm, 0.875rem);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.item-actions .action-btn--primary {
background: var(--accent-gold, #f3a61a);
color: var(--bg-primary, #0a0a0c);
}
.item-actions .action-btn--primary:hover {
background: var(--accent-gold-hover, #e69500);
}
.item-actions .action-btn--secondary {
background: var(--bg-input, #1e1e24);
border: 1px solid var(--play-border, #3a3a45);
color: var(--text-primary, #e5e5e5);
}
.item-actions .action-btn--secondary:hover {
background: var(--item-hover-bg);
border-color: var(--text-muted, #707078);
}
.item-actions .action-btn--danger {
background: transparent;
border: 1px solid #ef4444;
color: #ef4444;
}
.item-actions .action-btn--danger:hover {
background: rgba(239, 68, 68, 0.1);
}
/* ===== MODAL FOOTER ===== */
.inventory-modal .modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.gold-display {
font-size: var(--text-sm, 0.875rem);
color: var(--accent-gold, #f3a61a);
font-weight: 600;
}
.gold-display::before {
content: "coins ";
font-size: 1.1em;
}
/* ===== COMBAT ITEMS BOTTOM SHEET ===== */
.combat-items-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 70vh;
background: var(--bg-secondary, #12121a);
border: 2px solid var(--border-ornate, #f3a61a);
border-bottom: none;
border-radius: 16px 16px 0 0;
z-index: 1001;
display: flex;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.3s ease-out;
}
.combat-items-sheet.open {
transform: translateY(0);
}
/* Sheet backdrop */
.sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
/* Drag handle */
.sheet-handle {
width: 40px;
height: 4px;
background: var(--text-muted, #707078);
border-radius: 2px;
margin: 8px auto;
}
/* Sheet header */
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--play-border, #3a3a45);
}
.sheet-header h3 {
font-family: var(--font-heading);
font-size: var(--text-lg, 1.125rem);
color: var(--accent-gold, #f3a61a);
margin: 0;
}
.sheet-close {
width: var(--touch-target-min);
height: var(--touch-target-min);
background: none;
border: none;
color: var(--text-muted, #707078);
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.sheet-close:hover {
color: var(--text-primary, #e5e5e5);
}
/* Sheet body */
.sheet-body {
flex: 1;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Combat items grid - larger items for combat */
.combat-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--touch-spacing);
}
.combat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
min-height: 120px;
background: var(--item-bg);
border: 2px solid var(--rarity-common);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.combat-item:hover,
.combat-item:focus {
background: var(--item-hover-bg);
border-color: var(--accent-gold, #f3a61a);
}
.combat-item:focus {
outline: 2px solid var(--accent-gold, #f3a61a);
outline-offset: 2px;
}
.combat-item.selected {
border-color: var(--accent-gold, #f3a61a);
box-shadow: 0 0 12px rgba(243, 166, 26, 0.3);
}
.combat-item img {
width: 48px;
height: 48px;
margin-bottom: 0.5rem;
}
.combat-item .item-name {
font-size: var(--text-sm, 0.875rem);
color: var(--text-primary, #e5e5e5);
font-weight: 500;
text-align: center;
margin-bottom: 0.25rem;
}
.combat-item .item-effect {
font-size: var(--text-xs, 0.75rem);
color: var(--text-muted, #707078);
text-align: center;
}
/* Combat item detail section */
.combat-item-detail {
background: var(--bg-tertiary, #16161a);
border: 1px solid var(--play-border, #3a3a45);
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.combat-item-detail .detail-info {
flex: 1;
}
.combat-item-detail .detail-name {
font-weight: 600;
color: var(--text-primary, #e5e5e5);
margin-bottom: 0.25rem;
}
.combat-item-detail .detail-effect {
font-size: var(--text-sm, 0.875rem);
color: var(--text-secondary, #a0a0a8);
}
.combat-item-detail .use-btn {
min-width: 100px;
min-height: var(--touch-target-primary);
padding: 0.75rem 1.5rem;
background: var(--hp-bar-fill, #ef4444);
border: none;
border-radius: 6px;
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.combat-item-detail .use-btn:hover {
background: #dc2626;
}
/* No consumables message */
.no-consumables {
text-align: center;
padding: 2rem;
color: var(--text-muted, #707078);
font-style: italic;
}
/* ===== MOBILE RESPONSIVENESS ===== */
/* Full-screen modal on mobile */
@media (max-width: 768px) {
.inventory-modal {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
border: none;
}
.inventory-modal .modal-body {
flex-direction: column;
padding: 0.75rem;
}
/* Item detail slides in from right on mobile */
.item-detail {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 320px;
min-width: unset;
z-index: 1002;
border-radius: 0;
border-left: 2px solid var(--border-ornate, #f3a61a);
transform: translateX(100%);
transition: transform 0.3s ease;
}
.item-detail.visible {
transform: translateX(0);
}
.item-detail-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1001;
}
/* Back button for mobile detail view */
.item-detail-back {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin: -1rem -1rem 1rem -1rem;
background: var(--bg-secondary, #12121a);
border: none;
border-bottom: 1px solid var(--play-border, #3a3a45);
color: var(--accent-gold, #f3a61a);
font-size: var(--text-sm, 0.875rem);
cursor: pointer;
width: calc(100% + 2rem);
}
.item-detail-back:hover {
background: var(--item-hover-bg);
}
/* Action buttons fixed at bottom on mobile */
.item-actions {
position: sticky;
bottom: 0;
background: var(--bg-tertiary, #16161a);
padding: 1rem;
margin: auto -1rem -1rem -1rem;
border-top: 1px solid var(--play-border, #3a3a45);
}
/* Larger touch targets on mobile */
.inventory-item {
min-height: 88px;
padding: 0.5rem;
}
/* Tabs scroll horizontally on mobile */
.inventory-tabs {
padding: 0 0.5rem;
}
.inventory-tabs .tab {
min-height: 44px;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
/* Combat sheet takes more space on mobile */
.combat-items-sheet {
max-height: 80vh;
}
.combat-items-grid {
grid-template-columns: repeat(2, 1fr);
}
.combat-item {
min-height: 100px;
padding: 0.75rem;
}
}
/* Extra small screens */
@media (max-width: 400px) {
.inventory-grid {
grid-template-columns: repeat(2, 1fr);
}
.inventory-item {
min-height: 80px;
}
.inventory-item img {
width: 32px;
height: 32px;
}
}
/* ===== LOADING STATE ===== */
.inventory-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-muted, #707078);
}
.inventory-loading::after {
content: "";
width: 24px;
height: 24px;
margin-left: 0.75rem;
border: 2px solid var(--text-muted, #707078);
border-top-color: var(--accent-gold, #f3a61a);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== ACCESSIBILITY ===== */
/* Focus visible for keyboard navigation */
.inventory-item:focus-visible,
.combat-item:focus-visible,
.inventory-tabs .tab:focus-visible,
.action-btn:focus-visible {
outline: 2px solid var(--accent-gold, #f3a61a);
outline-offset: 2px;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.inventory-item,
.combat-item,
.combat-items-sheet,
.item-detail {
transition: none;
}
.inventory-loading::after {
animation: none;
}
}

View File

@@ -1119,6 +1119,161 @@
margin-top: 0.25rem;
}
/* Monster Selection Modal */
.monster-modal {
max-width: 500px;
}
.monster-modal-location {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: var(--text-sm);
}
.monster-modal-hint {
color: var(--text-muted);
margin-top: 1rem;
text-align: center;
}
.encounter-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.encounter-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--play-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
border-left: 4px solid var(--text-muted);
}
.encounter-option:hover {
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Challenge level border colors */
.encounter-option--easy {
border-left-color: #2ecc71; /* Green for easy */
}
.encounter-option--easy:hover {
border-color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
.encounter-option--medium {
border-left-color: #f39c12; /* Gold/orange for medium */
}
.encounter-option--medium:hover {
border-color: #f39c12;
background: rgba(243, 156, 18, 0.1);
}
.encounter-option--hard {
border-left-color: #e74c3c; /* Red for hard */
}
.encounter-option--hard:hover {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
.encounter-option--boss {
border-left-color: #9b59b6; /* Purple for boss */
}
.encounter-option--boss:hover {
border-color: #9b59b6;
background: rgba(155, 89, 182, 0.1);
}
.encounter-info {
flex: 1;
}
.encounter-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.encounter-enemies {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.enemy-badge {
font-size: var(--text-xs);
color: var(--text-muted);
background: var(--bg-card);
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.encounter-challenge {
font-size: var(--text-sm);
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.challenge--easy {
color: #2ecc71;
background: rgba(46, 204, 113, 0.15);
}
.challenge--medium {
color: #f39c12;
background: rgba(243, 156, 18, 0.15);
}
.challenge--hard {
color: #e74c3c;
background: rgba(231, 76, 60, 0.15);
}
.challenge--boss {
color: #9b59b6;
background: rgba(155, 89, 182, 0.15);
}
.encounter-empty {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.encounter-empty p {
margin: 0.5rem 0;
}
/* Combat action button highlight */
.action-btn--combat {
background: linear-gradient(135deg, rgba(231, 76, 60, 0.2), rgba(155, 89, 182, 0.2));
border-color: rgba(231, 76, 60, 0.4);
}
.action-btn--combat:hover {
background: linear-gradient(135deg, rgba(231, 76, 60, 0.3), rgba(155, 89, 182, 0.3));
border-color: rgba(231, 76, 60, 0.6);
}
/* NPC Chat Modal */
.npc-chat-header {
display: flex;

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Shield shape -->
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<!-- Shield decoration -->
<path d="M12 8v6"/>
<path d="M9 11h6"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Potion bottle body -->
<path d="M10 2v4"/>
<path d="M14 2v4"/>
<!-- Bottle neck -->
<path d="M8 6h8"/>
<!-- Bottle shape -->
<path d="M8 6l-2 4v10a2 2 0 002 2h8a2 2 0 002-2V10l-2-4"/>
<!-- Liquid level -->
<path d="M6 14h12"/>
<!-- Bubbles -->
<circle cx="10" cy="17" r="1"/>
<circle cx="14" cy="16" r="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Box/crate shape -->
<path d="M21 8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16V8z"/>
<!-- Box edges -->
<path d="M3.27 6.96L12 12.01l8.73-5.05"/>
<path d="M12 22.08V12"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Scroll body -->
<path d="M4 4a2 2 0 012-2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"/>
<!-- Scroll roll top -->
<path d="M4 4h16"/>
<ellipse cx="4" cy="4" rx="1" ry="2"/>
<ellipse cx="20" cy="4" rx="1" ry="2"/>
<!-- Text lines -->
<path d="M8 9h8"/>
<path d="M8 13h6"/>
<path d="M8 17h4"/>
</svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Sword blade -->
<path d="M14.5 17.5L3 6V3h3l11.5 11.5"/>
<!-- Sword guard -->
<path d="M13 19l6-6"/>
<!-- Sword handle -->
<path d="M16 16l4 4"/>
<!-- Blade tip detail -->
<path d="M19 21l2-2"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@@ -0,0 +1,337 @@
{% extends "base.html" %}
{% block title %}Combat Tester - Dev Tools{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.combat-hub {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.dev-section {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.dev-section h2 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.25rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
color: #9ca3af;
font-size: 0.85rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.form-select {
width: 100%;
padding: 0.75rem;
background: #1a1a2a;
border: 1px solid #4a4a5a;
border-radius: 6px;
color: #e5e7eb;
font-size: 1rem;
}
.form-select:focus {
outline: none;
border-color: #f59e0b;
}
.enemy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.enemy-option {
display: flex;
align-items: center;
padding: 0.75rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.enemy-option:hover {
background: #3a3a4a;
border-color: #5a5a6a;
}
.enemy-option.selected {
background: #3b3b5b;
border-color: #f59e0b;
}
.enemy-option input[type="checkbox"] {
margin-right: 0.75rem;
width: 18px;
height: 18px;
accent-color: #f59e0b;
}
.enemy-info {
flex: 1;
}
.enemy-name {
color: #e5e7eb;
font-weight: 500;
}
.enemy-level {
color: #9ca3af;
font-size: 0.8rem;
}
.btn-start {
width: 100%;
padding: 1rem;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-start:hover {
background: #059669;
}
.btn-start:disabled {
background: #4a4a5a;
cursor: not-allowed;
}
#create-result {
margin-top: 1rem;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.session-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
}
.session-info {
flex: 1;
}
.session-id {
color: #f59e0b;
font-family: monospace;
font-size: 0.85rem;
}
.session-character {
color: #e5e7eb;
font-weight: 500;
}
.session-status {
color: #10b981;
font-size: 0.85rem;
}
.btn-resume {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 0.9rem;
}
.btn-resume:hover {
background: #2563eb;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 2rem;
font-style: italic;
}
.error {
background: #7f1d1d;
color: #fecaca;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.success {
background: #064e3b;
color: #a7f3d0;
padding: 0.75rem;
border-radius: 6px;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #60a5fa;
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.helper-text {
color: #9ca3af;
font-size: 0.85rem;
margin-top: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Combat System Tester
</div>
<div class="combat-hub">
<a href="{{ url_for('dev.index') }}" class="back-link">&larr; Back to Dev Tools</a>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<!-- Start New Combat -->
<div class="dev-section">
<h2>Start New Combat</h2>
<form hx-post="{{ url_for('dev.start_combat') }}"
hx-target="#create-result"
hx-swap="innerHTML">
<!-- Session Selection -->
<div class="form-group">
<label class="form-label">Select Session (must have a character)</label>
<select name="session_id" class="form-select" required>
<option value="">-- Select a session --</option>
{% for char in characters %}
<option value="{{ char.session_id if char.session_id else '' }}"
{% if not char.session_id %}disabled{% endif %}>
{{ char.name }} ({{ char.class_name }} Lv.{{ char.level }})
{% if not char.session_id %} - No active session{% endif %}
</option>
{% endfor %}
</select>
<p class="helper-text">You need an active story session to start combat. Create one in the Story Tester first.</p>
</div>
<!-- Enemy Selection -->
<div class="form-group">
<label class="form-label">Select Enemies (check multiple for group encounter)</label>
{% if enemies %}
<div class="enemy-grid">
{% for enemy in enemies %}
<label class="enemy-option" onclick="this.classList.toggle('selected')">
<input type="checkbox" name="enemy_ids" value="{{ enemy.enemy_id }}">
<div class="enemy-info">
<div class="enemy-name">{{ enemy.name }}</div>
<div class="enemy-level">{{ enemy.difficulty | capitalize }} · {{ enemy.experience_reward }} XP</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
No enemy templates available. Check that the API has enemy data loaded.
</div>
{% endif %}
</div>
<button type="submit" class="btn-start" {% if not enemies %}disabled{% endif %}>
Start Combat
</button>
</form>
<div id="create-result"></div>
</div>
<!-- Active Combat Sessions -->
<div class="dev-section">
<h2>Active Combat Sessions</h2>
{% if sessions_in_combat %}
<div class="session-list">
{% for session in sessions_in_combat %}
<div class="session-card">
<div class="session-info">
<div class="session-id">{{ session.session_id[:12] }}...</div>
<div class="session-character">{{ session.character_name or 'Unknown Character' }}</div>
<div class="session-status">In Combat - Round {{ session.game_state.combat_round or 1 }}</div>
</div>
<a href="{{ url_for('dev.combat_session', session_id=session.session_id) }}" class="btn-resume">
Resume Combat
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
No active combat sessions. Start a new combat above.
</div>
{% endif %}
</div>
</div>
<script>
// Toggle selected state on checkbox change
document.querySelectorAll('.enemy-option input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', function() {
this.closest('.enemy-option').classList.toggle('selected', this.checked);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,864 @@
{% extends "base.html" %}
{% block title %}Combat Debug - Dev Tools{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.combat-container {
max-width: 1400px;
margin: 1rem auto;
padding: 0 1rem;
display: grid;
grid-template-columns: 280px 1fr 300px;
gap: 1rem;
}
@media (max-width: 1200px) {
.combat-container {
grid-template-columns: 250px 1fr;
}
.right-panel {
display: none;
}
}
@media (max-width: 768px) {
.combat-container {
grid-template-columns: 1fr;
}
.left-panel {
display: none;
}
}
.panel {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1rem;
}
.panel h3 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-refresh {
background: #6366f1;
color: white;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.btn-refresh:hover {
background: #4f46e5;
}
/* Left Panel - State */
.state-section {
margin-bottom: 1.5rem;
}
.state-section h4 {
color: #9ca3af;
font-size: 0.75rem;
text-transform: uppercase;
margin: 0 0 0.5rem 0;
}
.state-item {
margin-bottom: 0.5rem;
}
.state-label {
color: #6b7280;
font-size: 0.75rem;
}
.state-value {
color: #e5e7eb;
font-weight: 500;
}
.combatant-card {
background: #2a2a3a;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-left: 3px solid #4a4a5a;
}
.combatant-card.player {
border-left-color: #3b82f6;
}
.combatant-card.enemy {
border-left-color: #ef4444;
}
.combatant-card.active {
box-shadow: 0 0 0 2px #f59e0b;
}
.combatant-card.defeated {
opacity: 0.5;
}
.combatant-name {
color: #e5e7eb;
font-weight: 600;
margin-bottom: 0.5rem;
}
.resource-bar {
height: 8px;
background: #1a1a2a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.25rem;
}
.resource-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.resource-bar-fill.hp {
background: linear-gradient(90deg, #ef4444, #f87171);
}
.resource-bar-fill.mp {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
}
.resource-bar-fill.low {
background: linear-gradient(90deg, #dc2626, #ef4444);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.resource-text {
font-size: 0.7rem;
color: #9ca3af;
display: flex;
justify-content: space-between;
}
/* Debug Actions */
.debug-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #4a4a5a;
}
.debug-btn {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid #4a4a5a;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.debug-btn.victory {
background: #064e3b;
color: #a7f3d0;
}
.debug-btn.victory:hover {
background: #065f46;
}
.debug-btn.defeat {
background: #7f1d1d;
color: #fecaca;
}
.debug-btn.defeat:hover {
background: #991b1b;
}
.debug-btn.reset {
background: #1e40af;
color: #bfdbfe;
}
.debug-btn.reset:hover {
background: #1d4ed8;
}
/* Center Panel - Main */
.main-panel {
min-height: 600px;
display: flex;
flex-direction: column;
}
#combat-log {
flex: 1;
background: #1a1a2a;
border-radius: 6px;
padding: 1rem;
min-height: 300px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 1rem;
}
.log-entry {
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
.log-entry--player {
background: rgba(59, 130, 246, 0.15);
border-left: 3px solid #3b82f6;
}
.log-entry--enemy {
background: rgba(239, 68, 68, 0.15);
border-left: 3px solid #ef4444;
}
.log-entry--crit {
background: rgba(245, 158, 11, 0.2);
border-left: 3px solid #f59e0b;
}
.log-entry--system {
background: rgba(107, 114, 128, 0.15);
border-left: 3px solid #6b7280;
font-style: italic;
color: #9ca3af;
}
.log-entry--heal {
background: rgba(16, 185, 129, 0.15);
border-left: 3px solid #10b981;
}
.log-actor {
font-weight: 600;
color: #e5e7eb;
}
.log-message {
color: #d1d5db;
}
.log-damage {
color: #ef4444;
font-weight: 600;
}
.log-heal {
color: #10b981;
font-weight: 600;
}
.log-crit {
color: #f59e0b;
font-size: 0.75rem;
margin-left: 0.5rem;
}
/* Action Buttons */
.actions-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (max-width: 900px) {
.actions-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.action-btn {
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
text-align: center;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.attack {
background: #ef4444;
color: white;
}
.action-btn.attack:hover:not(:disabled) {
background: #dc2626;
}
.action-btn.ability {
background: #8b5cf6;
color: white;
}
.action-btn.ability:hover:not(:disabled) {
background: #7c3aed;
}
.action-btn.item {
background: #10b981;
color: white;
}
.action-btn.item:hover:not(:disabled) {
background: #059669;
}
.action-btn.defend {
background: #3b82f6;
color: white;
}
.action-btn.defend:hover:not(:disabled) {
background: #2563eb;
}
.action-btn.flee {
background: #6b7280;
color: white;
}
.action-btn.flee:hover:not(:disabled) {
background: #4b5563;
}
/* Right Panel */
.turn-order {
margin-bottom: 1rem;
}
.turn-item {
display: flex;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.25rem;
background: #2a2a3a;
border-radius: 4px;
font-size: 0.85rem;
}
.turn-item.active {
background: #3b3b5b;
border: 1px solid #f59e0b;
}
.turn-number {
width: 24px;
height: 24px;
background: #4a4a5a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
}
.turn-item.active .turn-number {
background: #f59e0b;
color: #1a1a2a;
}
.turn-name {
color: #e5e7eb;
}
.turn-name.player {
color: #60a5fa;
}
.turn-name.enemy {
color: #f87171;
}
/* Effects Panel */
.effects-panel {
margin-top: 1rem;
}
.effect-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.25rem;
background: #2a2a3a;
border-radius: 4px;
font-size: 0.8rem;
}
.effect-name {
color: #e5e7eb;
}
.effect-duration {
color: #f59e0b;
font-size: 0.75rem;
}
/* Debug Panel */
.debug-panel {
margin-top: 1rem;
padding: 1rem;
background: #1a1a2a;
border-radius: 6px;
font-family: monospace;
font-size: 0.75rem;
}
.debug-toggle {
color: #9ca3af;
cursor: pointer;
user-select: none;
}
.debug-content {
margin-top: 0.5rem;
max-height: 300px;
overflow: auto;
white-space: pre;
color: #a3e635;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #1a1a2a;
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-content h3 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
}
.modal-close {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #6b7280;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
/* Sheet Styles */
.combat-items-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1a1a2a;
border-top: 1px solid #4a4a5a;
border-radius: 16px 16px 0 0;
padding: 1rem;
max-height: 50vh;
overflow-y: auto;
z-index: 1000;
transform: translateY(100%);
transition: transform 0.3s ease;
}
.combat-items-sheet.open {
transform: translateY(0);
}
.sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sheet-header h3 {
color: #f59e0b;
margin: 0;
}
.sheet-close {
background: none;
border: none;
color: #9ca3af;
font-size: 1.5rem;
cursor: pointer;
}
.error {
background: #7f1d1d;
color: #fecaca;
padding: 0.75rem;
border-radius: 6px;
}
.success {
background: #064e3b;
color: #a7f3d0;
padding: 0.75rem;
border-radius: 6px;
}
.back-link {
color: #60a5fa;
text-decoration: none;
font-size: 0.85rem;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Combat Session {{ session_id[:8] }}...
</div>
<div class="combat-container">
<!-- Left Panel: Combat State -->
<div class="panel left-panel">
<h3>
Combat State
<button class="btn-refresh"
hx-get="{{ url_for('dev.combat_state', session_id=session_id) }}"
hx-target="#state-content"
hx-swap="innerHTML">
Refresh
</button>
</h3>
<div id="state-content">
{% include 'dev/partials/combat_state.html' %}
</div>
<!-- Debug Actions -->
<div class="debug-actions">
<h4 style="color: #f59e0b; font-size: 0.85rem; margin: 0 0 0.5rem 0;">Debug Actions</h4>
<button class="debug-btn reset"
hx-post="{{ url_for('dev.reset_hp_mp', session_id=session_id) }}"
hx-target="#combat-log"
hx-swap="beforeend">
Reset HP/MP
</button>
<button class="debug-btn victory"
hx-post="{{ url_for('dev.force_end_combat', session_id=session_id) }}"
hx-vals='{"victory": "true"}'
hx-target="#combat-log"
hx-swap="innerHTML">
Force Victory
</button>
<button class="debug-btn defeat"
hx-post="{{ url_for('dev.force_end_combat', session_id=session_id) }}"
hx-vals='{"victory": "false"}'
hx-target="#combat-log"
hx-swap="innerHTML">
Force Defeat
</button>
</div>
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<a href="{{ url_for('dev.combat_hub') }}" class="back-link">&larr; Back to Combat Hub</a>
</div>
</div>
<!-- Center Panel: Combat Log & Actions -->
<div class="panel main-panel">
<h3>Combat Log</h3>
<!-- Combat Log -->
<div id="combat-log" role="log" aria-live="polite">
{% for entry in combat_log %}
<div class="log-entry log-entry--{{ entry.type }}">
{% if entry.actor %}
<span class="log-actor">{{ entry.actor }}</span>
{% endif %}
<span class="log-message">{{ entry.message }}</span>
{% if entry.damage %}
<span class="log-damage">-{{ entry.damage }} HP</span>
{% endif %}
{% if entry.heal %}
<span class="log-heal">+{{ entry.heal }} HP</span>
{% endif %}
{% if entry.is_crit %}
<span class="log-crit">CRITICAL!</span>
{% endif %}
</div>
{% else %}
<div class="log-entry log-entry--system">
Combat begins!
{% if is_player_turn %}
Take your action.
{% else %}
Waiting for enemy turn...
{% endif %}
</div>
{% endfor %}
</div>
<!-- Action Buttons -->
<div class="actions-grid" id="action-buttons">
<button class="action-btn attack"
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "attack"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not is_player_turn %}disabled{% endif %}>
Attack
</button>
<button class="action-btn ability"
hx-get="{{ url_for('dev.combat_abilities', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
{% if not is_player_turn %}disabled{% endif %}>
Ability
</button>
<button class="action-btn item"
hx-get="{{ url_for('dev.combat_items', session_id=session_id) }}"
hx-target="#sheet-container"
hx-swap="innerHTML"
{% if not is_player_turn %}disabled{% endif %}>
Item
</button>
<button class="action-btn defend"
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "defend"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not is_player_turn %}disabled{% endif %}>
Defend
</button>
<button class="action-btn flee"
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "flee"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not is_player_turn %}disabled{% endif %}>
Flee
</button>
</div>
<!-- Debug Panel -->
<div class="debug-panel">
<div class="debug-toggle" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
[+] Raw State JSON (click to toggle)
</div>
<div class="debug-content" style="display: none;">{{ raw_state | tojson(indent=2) }}</div>
</div>
</div>
<!-- Right Panel: Turn Order & Effects -->
<div class="panel right-panel">
<h3>Turn Order</h3>
<div class="turn-order">
{% for combatant_id in turn_order %}
{% set ns = namespace(combatant=None) %}
{% for c in encounter.combatants %}
{% if c.combatant_id == combatant_id %}
{% set ns.combatant = c %}
{% endif %}
{% endfor %}
<div class="turn-item {% if combatant_id == current_turn_id %}active{% endif %}">
<span class="turn-number">{{ loop.index }}</span>
<span class="turn-name {% if ns.combatant and ns.combatant.is_player %}player{% else %}enemy{% endif %}">
{% if ns.combatant %}{{ ns.combatant.name }}{% else %}{{ combatant_id[:8] }}{% endif %}
</span>
</div>
{% endfor %}
</div>
<h3 style="margin-top: 1rem;">Active Effects</h3>
<div class="effects-panel">
{% if player_combatant and player_combatant.active_effects %}
{% for effect in player_combatant.active_effects %}
<div class="effect-item">
<span class="effect-name">{{ effect.name }}</span>
<span class="effect-duration">{{ effect.remaining_duration }} turns</span>
</div>
{% endfor %}
{% else %}
<p style="color: #6b7280; font-size: 0.85rem; text-align: center;">No active effects</p>
{% endif %}
</div>
</div>
</div>
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Sheet Container -->
<div id="sheet-container"></div>
<script>
// Close modal function
function closeModal() {
document.getElementById('modal-container').innerHTML = '';
}
// Close combat sheet function
function closeCombatSheet() {
document.getElementById('sheet-container').innerHTML = '';
}
// Refresh combat state panel
function refreshCombatState() {
htmx.ajax('GET', '{{ url_for("dev.combat_state", session_id=session_id) }}', {
target: '#state-content',
swap: 'innerHTML'
});
}
// Auto-scroll combat log
const combatLog = document.getElementById('combat-log');
if (combatLog) {
combatLog.scrollTop = combatLog.scrollHeight;
}
// Observe combat log for new entries and auto-scroll
const observer = new MutationObserver(function() {
combatLog.scrollTop = combatLog.scrollHeight;
});
observer.observe(combatLog, { childList: true });
// Guard against duplicate enemy turn requests
let enemyTurnPending = false;
let enemyTurnTimeout = null;
function triggerEnemyTurn(delay = 1000) {
// Prevent duplicate requests
if (enemyTurnPending) {
return;
}
// Clear any pending timeout
if (enemyTurnTimeout) {
clearTimeout(enemyTurnTimeout);
}
enemyTurnPending = true;
enemyTurnTimeout = setTimeout(function() {
htmx.ajax('POST', '{{ url_for("dev.combat_enemy_turn", session_id=session_id) }}', {
target: '#combat-log',
swap: 'beforeend'
}).then(function() {
enemyTurnPending = false;
// Refresh state after enemy turn completes
setTimeout(refreshCombatState, 500);
}).catch(function() {
enemyTurnPending = false;
});
}, delay);
}
// 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(500);
});
{% endif %}
// Handle enemy turn trigger
document.body.addEventListener('htmx:afterRequest', function(event) {
// Check for enemyTurn trigger
const trigger = event.detail.xhr.getResponseHeader('HX-Trigger');
if (trigger && trigger.includes('enemyTurn')) {
triggerEnemyTurn(1000);
}
// Refresh state after any combat action (player action, debug action, but NOT enemy turn - handled above)
const requestUrl = event.detail.pathInfo?.requestPath || '';
const isActionBtn = event.detail.elt && event.detail.elt.classList && event.detail.elt.classList.contains('action-btn');
const isDebugBtn = event.detail.elt && event.detail.elt.classList && event.detail.elt.classList.contains('debug-btn');
if (isActionBtn || isDebugBtn) {
setTimeout(refreshCombatState, 500);
}
});
// Re-enable buttons when player turn returns
document.body.addEventListener('htmx:afterSwap', function(event) {
// If state was updated, check if it's player turn
if (event.detail.target.id === 'state-content') {
const stateContent = document.getElementById('state-content');
const isPlayerTurn = stateContent && stateContent.textContent.includes('Your Turn');
const buttons = document.querySelectorAll('.action-btn');
buttons.forEach(function(btn) {
btn.disabled = !isPlayerTurn;
});
}
});
</script>
{% endblock %}

View File

@@ -83,6 +83,14 @@
</a>
</div>
<div class="dev-section">
<h2>Combat System</h2>
<a href="{{ url_for('dev.combat_hub') }}" class="dev-link">
Combat System Tester
<small>Start encounters, test actions, abilities, items, and enemy AI</small>
</a>
</div>
<div class="dev-section">
<h2>Quest System</h2>
<span class="dev-link dev-link-disabled">

View File

@@ -0,0 +1,62 @@
<!-- Ability Selection Modal -->
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: #f59e0b;">Select Ability</h3>
<button onclick="closeModal()" style="background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
{% if abilities %}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{% for ability in abilities %}
<button style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: {{ '#2a2a3a' if ability.available else '#1a1a2a' }};
border: 1px solid {{ '#4a4a5a' if ability.available else '#3a3a4a' }};
border-radius: 6px;
cursor: {{ 'pointer' if ability.available else 'not-allowed' }};
opacity: {{ '1' if ability.available else '0.5' }};
text-align: left;
transition: all 0.2s;
"
{% if ability.available %}
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "ability", "ability_id": "{{ ability.id }}"}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeModal()"
{% else %}
disabled
{% endif %}>
<div>
<div style="color: #e5e7eb; font-weight: 500;">{{ ability.name }}</div>
{% if ability.description %}
<div style="color: #9ca3af; font-size: 0.8rem; margin-top: 0.25rem;">{{ ability.description[:100] }}{% if ability.description|length > 100 %}...{% endif %}</div>
{% endif %}
</div>
<div style="text-align: right;">
{% if ability.mp_cost > 0 %}
<div style="color: #60a5fa; font-size: 0.85rem;">{{ ability.mp_cost }} MP</div>
{% endif %}
{% if ability.cooldown > 0 %}
<div style="color: #f59e0b; font-size: 0.75rem;">CD: {{ ability.cooldown }}</div>
{% endif %}
</div>
</button>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; color: #6b7280; padding: 2rem;">
No abilities available.
</div>
{% endif %}
<button class="modal-close" onclick="closeModal()" style="width: 100%; margin-top: 1rem;">
Cancel
</button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<!-- Combat Debug Log Entry Partial - appended to combat log -->
{% for entry in combat_log %}
<div class="log-entry log-entry--{{ entry.type }}">
{% if entry.actor %}
<span class="log-actor">{{ entry.actor }}</span>
{% endif %}
<span class="log-message">{{ entry.message }}</span>
{% if entry.damage %}
<span class="log-damage">-{{ entry.damage }} HP</span>
{% endif %}
{% if entry.heal %}
<span class="log-heal">+{{ entry.heal }} HP</span>
{% endif %}
{% if entry.is_crit %}
<span class="log-crit">CRITICAL!</span>
{% endif %}
</div>
{% endfor %}

View File

@@ -0,0 +1,32 @@
<!-- Combat Defeat Screen -->
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;">&#128128;</div>
<h2 style="color: #ef4444; margin-bottom: 1rem;">Defeat</h2>
<p style="color: #d1d5db; margin-bottom: 2rem;">You have been defeated in battle...</p>
<!-- Penalties -->
{% if gold_lost and gold_lost > 0 %}
<div style="background: rgba(127, 29, 29, 0.3); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="color: #fecaca;">
<span style="color: #ef4444; font-weight: 600;">-{{ gold_lost }} gold</span> lost
</div>
</div>
{% endif %}
<p style="color: #9ca3af; font-size: 0.9rem; margin-bottom: 2rem;">
Your progress has been saved. You can try again or return to town.
</p>
<!-- Actions -->
<div style="display: flex; gap: 1rem; justify-content: center;">
<a href="{{ url_for('dev.combat_hub') }}"
style="padding: 0.75rem 1.5rem; background: #ef4444; color: white; border-radius: 6px; text-decoration: none;">
Try Again
</a>
<a href="{{ url_for('dev.story_hub') }}"
style="padding: 0.75rem 1.5rem; background: #6b7280; color: white; border-radius: 6px; text-decoration: none;">
Return to Town
</a>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<!-- Combat Items Bottom Sheet -->
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
<div class="combat-items-sheet open">
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
{% if has_consumables %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.75rem;">
{% for item in consumables %}
<button style="
padding: 1rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
"
hx-get="{{ url_for('dev.combat_item_detail', session_id=session_id, item_id=item.item_id) }}"
hx-target="#item-detail"
hx-swap="innerHTML">
<div style="color: #e5e7eb; font-weight: 500; margin-bottom: 0.25rem;">{{ item.name }}</div>
<div style="color:
{% if item.rarity == 'uncommon' %}#10b981
{% elif item.rarity == 'rare' %}#3b82f6
{% elif item.rarity == 'epic' %}#a78bfa
{% elif item.rarity == 'legendary' %}#f59e0b
{% else %}#9ca3af{% endif %};
font-size: 0.75rem; text-transform: capitalize;">
{{ item.rarity }}
</div>
</button>
{% endfor %}
</div>
<!-- Item Detail Panel -->
<div id="item-detail" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<div style="text-align: center; color: #6b7280; font-size: 0.9rem;">
Select an item to see details
</div>
</div>
{% else %}
<div style="text-align: center; color: #6b7280; padding: 2rem;">
No consumable items in inventory.
</div>
{% endif %}
</div>
</div>
<style>
.detail-info {
background: #1a1a2a;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-name {
color: #e5e7eb;
font-weight: 600;
margin-bottom: 0.5rem;
}
.detail-effect {
color: #10b981;
font-size: 0.9rem;
}
.use-btn {
width: 100%;
padding: 0.75rem;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.use-btn:hover {
background: #059669;
}
</style>

View File

@@ -0,0 +1,84 @@
<!-- Combat State Partial - refreshable via HTMX -->
<div class="state-section">
<h4>Encounter Info</h4>
<div class="state-item">
<div class="state-label">Round</div>
<div class="state-value">{{ encounter.round_number or 1 }}</div>
</div>
<div class="state-item">
<div class="state-label">Status</div>
<div class="state-value">{{ encounter.status or 'active' }}</div>
</div>
<div class="state-item">
<div class="state-label">Current Turn</div>
<div class="state-value">
{% if is_player_turn %}
<span style="color: #60a5fa;">Your Turn</span>
{% else %}
<span style="color: #f87171;">Enemy Turn</span>
{% endif %}
</div>
</div>
</div>
<!-- Player Card -->
{% if player_combatant %}
<div class="state-section">
<h4>Player</h4>
<div class="combatant-card player {% if player_combatant.combatant_id == current_turn_id %}active{% endif %} {% if player_combatant.current_hp <= 0 %}defeated{% endif %}">
<div class="combatant-name">{{ player_combatant.name }}</div>
<!-- HP Bar -->
{% set hp_percent = (player_combatant.current_hp / player_combatant.max_hp * 100) if player_combatant.max_hp > 0 else 0 %}
<div class="resource-bar">
<div class="resource-bar-fill hp {% if hp_percent < 25 %}low{% endif %}"
style="width: {{ hp_percent }}%"></div>
</div>
<div class="resource-text">
<span>HP</span>
<span>{{ player_combatant.current_hp }}/{{ player_combatant.max_hp }}</span>
</div>
<!-- MP Bar -->
{% if player_combatant.max_mp and player_combatant.max_mp > 0 %}
{% set mp_percent = (player_combatant.current_mp / player_combatant.max_mp * 100) if player_combatant.max_mp > 0 else 0 %}
<div class="resource-bar" style="margin-top: 0.5rem;">
<div class="resource-bar-fill mp" style="width: {{ mp_percent }}%"></div>
</div>
<div class="resource-text">
<span>MP</span>
<span>{{ player_combatant.current_mp }}/{{ player_combatant.max_mp }}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Enemy Cards -->
{% if enemy_combatants %}
<div class="state-section">
<h4>Enemies ({{ enemy_combatants | length }})</h4>
{% for enemy in enemy_combatants %}
<div class="combatant-card enemy {% if enemy.combatant_id == current_turn_id %}active{% endif %} {% if enemy.current_hp <= 0 %}defeated{% endif %}">
<div class="combatant-name">
{{ enemy.name }}
{% if enemy.current_hp <= 0 %}
<span style="color: #6b7280; font-size: 0.75rem;">(Defeated)</span>
{% endif %}
</div>
<!-- HP Bar -->
{% set enemy_hp_percent = (enemy.current_hp / enemy.max_hp * 100) if enemy.max_hp > 0 else 0 %}
<div class="resource-bar">
<div class="resource-bar-fill hp {% if enemy_hp_percent < 25 %}low{% endif %}"
style="width: {{ enemy_hp_percent }}%"></div>
</div>
<div class="resource-text">
<span>HP</span>
<span>{{ enemy.current_hp }}/{{ enemy.max_hp }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,68 @@
<!-- Combat Victory Screen -->
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;">&#127942;</div>
<h2 style="color: #10b981; margin-bottom: 1rem;">Victory!</h2>
<p style="color: #d1d5db; margin-bottom: 2rem;">You have defeated your enemies!</p>
<!-- Rewards Section -->
{% if rewards %}
<div style="background: #2a2a3a; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; text-align: left;">
<h3 style="color: #f59e0b; margin-top: 0; margin-bottom: 1rem;">Rewards</h3>
{% if rewards.experience %}
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #9ca3af;">Experience</span>
<span style="color: #a78bfa; font-weight: 600;">+{{ rewards.experience }} XP</span>
</div>
{% endif %}
{% if rewards.gold %}
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #9ca3af;">Gold</span>
<span style="color: #fbbf24; font-weight: 600;">+{{ rewards.gold }} gold</span>
</div>
{% endif %}
{% if rewards.level_ups %}
<div style="background: rgba(168, 85, 247, 0.2); border-radius: 6px; padding: 1rem; margin-top: 1rem;">
<div style="color: #a78bfa; font-weight: 600;">Level Up!</div>
<div style="color: #d1d5db; font-size: 0.9rem;">You have reached a new level!</div>
</div>
{% endif %}
{% if rewards.items %}
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<div style="color: #9ca3af; font-size: 0.85rem; margin-bottom: 0.5rem;">Loot Obtained:</div>
{% for item in rewards.items %}
<div style="display: flex; align-items: center; padding: 0.5rem; background: #1a1a2a; border-radius: 4px; margin-bottom: 0.25rem;">
<span style="color: #e5e7eb;">{{ item.name }}</span>
{% if item.rarity and item.rarity != 'common' %}
<span style="margin-left: 0.5rem; font-size: 0.75rem; color:
{% if item.rarity == 'uncommon' %}#10b981
{% elif item.rarity == 'rare' %}#3b82f6
{% elif item.rarity == 'epic' %}#a78bfa
{% elif item.rarity == 'legendary' %}#f59e0b
{% else %}#9ca3af{% endif %};">
({{ item.rarity }})
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Actions -->
<div style="display: flex; gap: 1rem; justify-content: center;">
<a href="{{ url_for('dev.combat_hub') }}"
style="padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border-radius: 6px; text-decoration: none;">
Back to Combat Hub
</a>
<a href="{{ url_for('dev.story_hub') }}"
style="padding: 0.75rem 1.5rem; background: #6b7280; color: white; border-radius: 6px; text-decoration: none;">
Continue Adventure
</a>
</div>
</div>

View File

@@ -0,0 +1,296 @@
{% extends "base.html" %}
{% block title %}Combat - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
{% endblock %}
{% block content %}
<div class="combat-page">
<div class="combat-container">
{# ===== COMBAT HEADER ===== #}
<header class="combat-header">
<h1 class="combat-title">
<span class="combat-title-icon">&#9876;</span>
Combat Encounter
</h1>
<div class="combat-round">
<span class="round-counter">Round <strong>{{ encounter.round_number }}</strong></span>
{% if is_player_turn %}
<span class="turn-indicator turn-indicator--player">Your Turn</span>
{% else %}
<span class="turn-indicator turn-indicator--enemy">Enemy Turn</span>
{% endif %}
</div>
</header>
{# ===== LEFT COLUMN: COMBATANTS ===== #}
<aside class="combatant-panel">
{# Player Section #}
<div class="combatant-section">
<h2 class="combatant-section-title">Your Party</h2>
{% for combatant in encounter.combatants if combatant.is_player %}
<div class="combatant-card combatant-card--player {% if combatant.combatant_id == current_turn_id %}combatant-card--active{% endif %} {% if combatant.current_hp <= 0 %}combatant-card--defeated{% endif %}">
<div class="combatant-header">
<span class="combatant-name">{{ combatant.name }}</span>
<span class="combatant-level">Lv.{{ combatant.level|default(1) }}</span>
</div>
<div class="combatant-resources">
{% set hp_percent = ((combatant.current_hp / combatant.max_hp) * 100)|round|int %}
<div class="resource-bar resource-bar--hp {% if hp_percent < 25 %}low{% endif %}">
<div class="resource-bar-label">
<span class="resource-bar-name">HP</span>
<span class="resource-bar-value">{{ combatant.current_hp }} / {{ combatant.max_hp }}</span>
</div>
<div class="resource-bar-track">
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
</div>
</div>
{% set mp_percent = ((combatant.current_mp / combatant.max_mp) * 100)|round|int if combatant.max_mp > 0 else 0 %}
<div class="resource-bar resource-bar--mp">
<div class="resource-bar-label">
<span class="resource-bar-name">MP</span>
<span class="resource-bar-value">{{ combatant.current_mp }} / {{ combatant.max_mp }}</span>
</div>
<div class="resource-bar-track">
<div class="resource-bar-fill" style="width: {{ mp_percent }}%"></div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# Enemies Section #}
<div class="combatant-section">
<h2 class="combatant-section-title">Enemies</h2>
{% for combatant in encounter.combatants if not combatant.is_player %}
<div class="combatant-card combatant-card--enemy {% if combatant.combatant_id == current_turn_id %}combatant-card--active{% endif %} {% if combatant.current_hp <= 0 %}combatant-card--defeated{% endif %}">
<div class="combatant-header">
<span class="combatant-name">{{ combatant.name }}</span>
<span class="combatant-level">{% if combatant.current_hp <= 0 %}Defeated{% endif %}</span>
</div>
<div class="combatant-resources">
{% set hp_percent = ((combatant.current_hp / combatant.max_hp) * 100)|round|int %}
<div class="resource-bar resource-bar--hp {% if hp_percent < 25 %}low{% endif %}">
<div class="resource-bar-label">
<span class="resource-bar-name">HP</span>
<span class="resource-bar-value">{{ combatant.current_hp }} / {{ combatant.max_hp }}</span>
</div>
<div class="resource-bar-track">
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</aside>
{# ===== CENTER COLUMN: COMBAT LOG + ACTIONS ===== #}
<main class="combat-main">
{# Combat Log #}
<div id="combat-log" class="combat-log" role="log" aria-live="polite" aria-label="Combat log">
{% include "game/partials/combat_log.html" %}
</div>
{# Combat Actions #}
<div id="combat-actions" class="combat-actions">
{% include "game/partials/combat_actions.html" %}
</div>
</main>
{# ===== RIGHT COLUMN: TURN ORDER + EFFECTS ===== #}
<aside class="combat-sidebar">
{# Turn Order #}
<div class="turn-order">
<h2 class="turn-order__title">Turn Order</h2>
<div class="turn-order__list">
{% for combatant_id in encounter.turn_order %}
{% set combatant = encounter.combatants|selectattr('combatant_id', 'equalto', combatant_id)|first %}
{% if combatant %}
<div class="turn-order__item {% if combatant.is_player %}turn-order__item--player{% else %}turn-order__item--enemy{% endif %} {% if combatant_id == current_turn_id %}turn-order__item--active{% endif %} {% if combatant.current_hp <= 0 %}turn-order__item--defeated{% endif %}">
<span class="turn-order__position">{{ loop.index }}</span>
<span class="turn-order__name">{{ combatant.name }}</span>
{% if combatant_id == current_turn_id %}
<span class="turn-order__check" title="Current turn">&#10148;</span>
{% elif combatant.current_hp <= 0 %}
<span class="turn-order__check" title="Defeated">&#10007;</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{# Active Effects #}
<div class="effects-panel">
<h2 class="effects-panel__title">Active Effects</h2>
{% if player_combatant and player_combatant.active_effects %}
<div class="effects-list">
{% for effect in player_combatant.active_effects %}
<div class="effect-item effect-item--{{ effect.effect_type|default('buff') }}">
<span class="effect-icon">
{% if effect.effect_type == 'shield' %}&#128737;
{% elif effect.effect_type == 'buff' %}&#11014;
{% elif effect.effect_type == 'debuff' %}&#11015;
{% elif effect.effect_type == 'dot' %}&#128293;
{% elif effect.effect_type == 'hot' %}&#10084;
{% else %}&#9733;
{% endif %}
</span>
<span class="effect-name">{{ effect.name }}</span>
<span class="effect-duration">{{ effect.duration }} {% if effect.duration == 1 %}turn{% else %}turns{% endif %}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="effects-empty">No active effects</p>
{% endif %}
</div>
</aside>
</div>
{# Modal Container for Ability selection #}
<div id="modal-container"></div>
{# Combat Items Sheet Container #}
<div id="combat-sheet-container"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-scroll combat log to bottom on new entries
function scrollCombatLog() {
const log = document.getElementById('combat-log');
if (log) {
log.scrollTop = log.scrollHeight;
}
}
// Scroll on page load
document.addEventListener('DOMContentLoaded', scrollCombatLog);
// Scroll after HTMX swaps
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'combat-log' ||
event.detail.target.closest('#combat-log')) {
scrollCombatLog();
}
});
// Close modal function
function closeModal() {
const container = document.getElementById('modal-container');
if (container) {
container.innerHTML = '';
}
}
// Close combat items sheet
function closeCombatSheet() {
const container = document.getElementById('combat-sheet-container');
if (container) {
container.innerHTML = '';
}
}
// Close modal/sheet on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
closeCombatSheet();
}
});
// Enemy turn handling with proper chaining for multiple enemies
let enemyTurnPending = false;
function triggerEnemyTurn() {
// Prevent duplicate requests
if (enemyTurnPending) {
return;
}
enemyTurnPending = true;
setTimeout(function() {
// Use fetch instead of htmx.ajax for better control over response handling
fetch('{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'HX-Request': 'true'
},
credentials: 'same-origin'
})
.then(function(response) {
const hasMoreEnemies = response.headers.get('HX-Trigger')?.includes('enemyTurn');
return response.text().then(function(html) {
return { html: html, hasMoreEnemies: hasMoreEnemies };
});
})
.then(function(data) {
// Append the log entry
const combatLog = document.getElementById('combat-log');
if (combatLog) {
combatLog.insertAdjacentHTML('beforeend', data.html);
combatLog.scrollTop = combatLog.scrollHeight;
}
enemyTurnPending = false;
if (data.hasMoreEnemies) {
// More enemies to go - trigger next enemy turn
triggerEnemyTurn();
} else {
// All enemies done - refresh page to update UI
setTimeout(function() {
window.location.reload();
}, 800);
}
})
.catch(function(error) {
console.error('Enemy turn failed:', error);
enemyTurnPending = false;
// Refresh anyway to recover from error state
setTimeout(function() {
window.location.reload();
}, 1000);
});
}, 1000);
}
// Handle player action triggering enemy turn
document.body.addEventListener('htmx:afterRequest', function(event) {
const response = event.detail.xhr;
if (!response) return;
const triggers = response.getResponseHeader('HX-Trigger') || '';
// Only trigger enemy turn from player actions (not from our fetch calls)
if (triggers.includes('enemyTurn') && !enemyTurnPending) {
triggerEnemyTurn();
}
});
// Handle combat end redirect
document.body.addEventListener('htmx:beforeSwap', function(event) {
// If the response indicates combat ended, handle accordingly
const response = event.detail.xhr;
if (response && response.getResponseHeader('X-Combat-Ended')) {
// Let the full page swap happen for victory/defeat screen
}
});
// Auto-trigger enemy turn on page load if it's not the player's turn
{% if not is_player_turn %}
document.addEventListener('DOMContentLoaded', function() {
// Small delay to let the page render first
triggerEnemyTurn();
});
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{# Ability Selection Modal - Shows available abilities during combat #}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--md">
<div class="modal-header">
<h3 class="modal-title">Select Ability</h3>
<button class="modal-close" onclick="closeModal()" aria-label="Close modal">&times;</button>
</div>
<div class="modal-body">
{% if abilities %}
<div class="ability-list">
{% for ability in abilities %}
<button class="ability-btn"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "ability", "ability_id": "{{ ability.id }}"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not ability.available %}disabled{% endif %}
onclick="closeModal()">
<span class="ability-icon">
{% if ability.damage_type == 'fire' %}&#128293;
{% elif ability.damage_type == 'ice' %}&#10052;
{% elif ability.damage_type == 'lightning' %}&#9889;
{% elif ability.effect_type == 'heal' %}&#10084;
{% elif ability.effect_type == 'buff' %}&#11014;
{% elif ability.effect_type == 'debuff' %}&#11015;
{% else %}&#10024;
{% endif %}
</span>
<div class="ability-info">
<span class="ability-name">{{ ability.name }}</span>
<span class="ability-description">{{ ability.description|default('A powerful ability.') }}</span>
</div>
<div class="ability-meta">
{% if ability.mp_cost > 0 %}
<span class="ability-cost">{{ ability.mp_cost }} MP</span>
{% endif %}
{% if ability.cooldown > 0 %}
<span class="ability-cooldown ability-cooldown--active">{{ ability.cooldown }} turns CD</span>
{% elif ability.max_cooldown > 0 %}
<span class="ability-cooldown">{{ ability.max_cooldown }} turns CD</span>
{% endif %}
</div>
</button>
{% endfor %}
</div>
{% else %}
<div class="items-empty">
<p>No abilities available.</p>
<p style="font-size: var(--text-xs); margin-top: 0.5rem; color: var(--text-muted);">
Learn abilities by leveling up or finding skill tomes.
</p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Cancel</button>
</div>
</div>
</div>

View File

@@ -82,8 +82,19 @@ Displays character stats, resource bars, and action buttons
</div>
</div>
{# Quick Actions (Equipment, NPC, Travel) #}
{# Quick Actions (Inventory, Equipment, NPC, Travel) #}
<div class="quick-actions">
{# Inventory - Opens modal #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
aria-label="Open inventory">
<span class="action-icon">&#128188;</span>
Inventory
<span class="action-count">({{ character.inventory|length|default(0) }})</span>
</button>
{# Equipment & Gear - Opens modal #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.equipment_modal', session_id=session_id) }}"
@@ -108,6 +119,14 @@ Displays character stats, resource bars, and action buttons
hx-swap="innerHTML">
🗺️ Travel to...
</button>
{# Search for Monsters - Opens modal with encounter options #}
<button class="action-btn action-btn--special action-btn--combat"
hx-get="{{ url_for('game.monster_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
⚔️ Search for Monsters
</button>
</div>
{# Actions Section #}

View File

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

View File

@@ -0,0 +1,87 @@
{# Combat Actions Partial - Action buttons for combat #}
{# This partial shows the available combat actions #}
{% if is_player_turn %}
<div class="combat-actions__grid">
{# Attack Button - Direct action #}
<button class="combat-action-btn combat-action-btn--attack"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "attack"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
title="Basic attack with your weapon">
<span class="combat-action-btn__icon">&#9876;</span>
<span>Attack</span>
</button>
{# Ability Button - Opens modal #}
<button class="combat-action-btn combat-action-btn--ability"
hx-get="{{ url_for('combat.combat_abilities', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
title="Use a special ability or spell">
<span class="combat-action-btn__icon">&#10024;</span>
<span>Ability</span>
</button>
{# Item Button - Opens bottom sheet #}
<button class="combat-action-btn combat-action-btn--item"
hx-get="{{ url_for('combat.combat_items', session_id=session_id) }}"
hx-target="#combat-sheet-container"
hx-swap="innerHTML"
title="Use an item from your inventory">
<span class="combat-action-btn__icon">&#127863;</span>
<span>Item</span>
</button>
{# Defend Button - Direct action #}
<button class="combat-action-btn combat-action-btn--defend"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "defend"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
title="Take a defensive stance, reducing damage taken">
<span class="combat-action-btn__icon">&#128737;</span>
<span>Defend</span>
</button>
{# Flee Button - Direct action #}
<button class="combat-action-btn combat-action-btn--flee"
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
hx-confirm="Are you sure you want to flee from combat?"
title="Attempt to escape from battle">
<span class="combat-action-btn__icon">&#127939;</span>
<span>Flee</span>
</button>
</div>
{% else %}
<div class="combat-actions__grid">
{# Disabled buttons when not player's turn #}
<button class="combat-action-btn combat-action-btn--attack" disabled>
<span class="combat-action-btn__icon">&#9876;</span>
<span>Attack</span>
</button>
<button class="combat-action-btn combat-action-btn--ability" disabled>
<span class="combat-action-btn__icon">&#10024;</span>
<span>Ability</span>
</button>
<button class="combat-action-btn combat-action-btn--item" disabled>
<span class="combat-action-btn__icon">&#127863;</span>
<span>Item</span>
</button>
<button class="combat-action-btn combat-action-btn--defend" disabled>
<span class="combat-action-btn__icon">&#128737;</span>
<span>Defend</span>
</button>
<button class="combat-action-btn combat-action-btn--flee" disabled>
<span class="combat-action-btn__icon">&#127939;</span>
<span>Flee</span>
</button>
</div>
<p class="combat-actions__disabled-message">Waiting for enemy turn...</p>
{% endif %}

View File

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

View File

@@ -0,0 +1,47 @@
{# Combat Defeat Partial - Swapped into combat log when player loses #}
<div class="combat-result combat-result--defeat">
<div class="combat-result__icon">&#128128;</div>
<h1 class="combat-result__title">Defeated</h1>
<p class="combat-result__subtitle">Your party has fallen in battle...</p>
{# Defeat Message #}
<div class="combat-rewards" style="border-color: var(--accent-red);">
<h2 class="rewards-title" style="color: var(--accent-red);">Battle Lost</h2>
<div class="rewards-list">
<div class="reward-item">
<span class="reward-icon">&#9888;</span>
<span class="reward-label">Your progress has been saved</span>
<span class="reward-value" style="color: var(--text-muted);">No items lost</span>
</div>
{% if gold_lost %}
<div class="reward-item">
<span class="reward-icon">&#128176;</span>
<span class="reward-label">Gold dropped</span>
<span class="reward-value" style="color: var(--accent-red);">-{{ gold_lost }} gold</span>
</div>
{% endif %}
</div>
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-primary); text-align: center;">
<p style="font-size: var(--text-sm); color: var(--text-secondary); font-style: italic;">
"Even the mightiest heroes face setbacks. Rise again, adventurer!"
</p>
</div>
</div>
{# Action Buttons #}
<div class="combat-result__actions">
<a href="{{ url_for('game.play_session', session_id=session_id) }}" class="btn btn-primary">
Return to Game
</a>
{% if can_retry %}
<button class="btn btn-secondary"
hx-post="{{ url_for('combat.combat_view', session_id=session_id) }}"
hx-target="body"
hx-swap="innerHTML">
Retry Battle
</button>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,52 @@
{#
Combat Items Sheet
Bottom sheet for selecting consumable items during combat
#}
<div class="combat-items-sheet open" role="dialog" aria-modal="true" aria-labelledby="combat-items-title">
{# Drag handle for mobile #}
<div class="sheet-handle" aria-hidden="true"></div>
{# Sheet header #}
<div class="sheet-header">
<h3 id="combat-items-title">Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()" aria-label="Close">&times;</button>
</div>
{# Sheet body #}
<div class="sheet-body">
{# Consumables Grid #}
<div class="combat-items-grid">
{% for item in consumables %}
<button class="combat-item"
hx-get="{{ url_for('combat.combat_item_detail', session_id=session_id, item_id=item.item_id) }}"
hx-target="#combat-item-detail"
hx-swap="innerHTML"
aria-label="{{ item.name }}">
<img src="{{ url_for('static', filename='img/items/consumable.svg') }}" alt="">
<span class="item-name">{{ item.name }}</span>
<span class="item-effect">{{ item.description|truncate(30) }}</span>
</button>
{% else %}
<p class="no-consumables">No usable items in inventory</p>
{% endfor %}
</div>
{# Selected Item Detail + Use Button #}
<div class="combat-item-detail" id="combat-item-detail">
<p style="color: var(--text-muted); text-align: center;">Select an item to use</p>
</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
<script>
// Handle item selection highlighting in combat sheet
document.querySelectorAll('.combat-item').forEach(item => {
item.addEventListener('htmx:afterRequest', function() {
// Remove selected from all items
document.querySelectorAll('.combat-item.selected').forEach(i => i.classList.remove('selected'));
// Add selected to clicked item
this.classList.add('selected');
});
});
</script>

View File

@@ -0,0 +1,25 @@
{# Combat Log Partial - Displays combat action history #}
{# This partial is swapped via HTMX when combat actions occur #}
{% if combat_log %}
{% for entry in combat_log %}
<div class="combat-log__entry combat-log__entry--{{ entry.type|default('system') }}{% if entry.is_crit %} combat-log__entry--crit{% endif %}">
{% if entry.actor %}
<span class="log-actor">{{ entry.actor }}</span>
{% endif %}
<span class="log-message">{{ entry.message }}</span>
{% if entry.damage %}
<span class="log-damage{% if entry.is_crit %} log-damage--crit{% endif %}">
{% if entry.is_crit %}CRITICAL! {% endif %}{{ entry.damage }} damage
</span>
{% endif %}
{% if entry.heal %}
<span class="log-heal">+{{ entry.heal }} HP</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="combat-log__empty">
Combat begins! Choose your action below.
</div>
{% endif %}

View File

@@ -0,0 +1,76 @@
{# Combat Victory Partial - Swapped into combat log when player wins #}
<div class="combat-result combat-result--victory">
<div class="combat-result__icon">&#127942;</div>
<h1 class="combat-result__title">Victory!</h1>
<p class="combat-result__subtitle">You have defeated your enemies!</p>
{# Rewards Section #}
{% if rewards %}
<div class="combat-rewards">
<h2 class="rewards-title">Rewards Earned</h2>
<div class="rewards-list">
{# Experience #}
{% if rewards.experience %}
<div class="reward-item">
<span class="reward-icon">&#11088;</span>
<span class="reward-label">Experience Points</span>
<span class="reward-value reward-value--xp">+{{ rewards.experience }} XP</span>
</div>
{% endif %}
{# Gold #}
{% if rewards.gold %}
<div class="reward-item">
<span class="reward-icon">&#128176;</span>
<span class="reward-label">Gold</span>
<span class="reward-value reward-value--gold">+{{ rewards.gold }} gold</span>
</div>
{% endif %}
{# Level Up #}
{% if rewards.level_ups %}
{% for character_id in rewards.level_ups %}
<div class="reward-item">
<span class="reward-icon">&#127775;</span>
<span class="reward-label">Level Up!</span>
<span class="reward-value reward-value--level">New abilities unlocked!</span>
</div>
{% endfor %}
{% endif %}
</div>
{# Loot Items - use bracket notation to avoid conflict with dict.items() method #}
{% if rewards.get('items') %}
<div class="loot-section">
<h3 class="loot-title">Items Obtained</h3>
<div class="loot-list">
{% for item in rewards.get('items', []) %}
<div class="loot-item loot-item--{{ item.rarity|default('common') }}">
<span>
{% if item.type == 'weapon' %}&#9876;
{% elif item.type == 'armor' %}&#129523;
{% elif item.type == 'consumable' %}&#127863;
{% elif item.type == 'material' %}&#128293;
{% else %}&#128230;
{% endif %}
</span>
<span>{{ item.name }}</span>
{% if item.get('quantity', 1) > 1 %}
<span style="color: var(--text-muted);">x{{ item.quantity }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{# Action Buttons #}
<div class="combat-result__actions">
<a href="{{ url_for('game.play_session', session_id=session_id) }}" class="btn btn-primary">
Continue Adventure
</a>
</div>
</div>

View File

@@ -0,0 +1,118 @@
{#
Inventory Item Detail
Partial template loaded via HTMX when an item is selected
#}
<div class="item-detail-content">
{# Mobile back button #}
<button class="item-detail-back" onclick="hideMobileDetail()" aria-label="Back to inventory">
&larr; Back to items
</button>
{# Item header #}
<div class="item-detail-header">
<img src="{{ url_for('static', filename='img/items/' ~ (item.item_type|default('default')) ~ '.svg') }}"
class="item-detail-icon" alt="">
<div class="item-detail-title">
<h3 class="rarity-text-{{ item.rarity|default('common') }}">{{ item.name }}</h3>
<span class="item-type">{{ item.item_type|default('Item')|replace('_', ' ')|title }}</span>
</div>
</div>
{# Item description #}
<p class="item-description">{{ item.description|default('No description available.') }}</p>
{# Stats (for equipment) #}
{% if item.item_type in ['weapon', 'armor'] %}
<div class="item-stats">
{% if item.damage %}
<div>
<span>Damage</span>
<span>{{ item.damage }}</span>
</div>
{% endif %}
{% if item.defense %}
<div>
<span>Defense</span>
<span>{{ item.defense }}</span>
</div>
{% endif %}
{% if item.spell_power %}
<div>
<span>Spell Power</span>
<span>{{ item.spell_power }}</span>
</div>
{% endif %}
{% if item.crit_chance %}
<div>
<span>Crit Chance</span>
<span>{{ (item.crit_chance * 100)|round|int }}%</span>
</div>
{% endif %}
{% if item.stat_bonuses %}
{% for stat, value in item.stat_bonuses.items() %}
<div>
<span>{{ stat|replace('_', ' ')|title }}</span>
<span>+{{ value }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
{# Effects (for consumables) #}
{% if item.item_type == 'consumable' and item.effects_on_use %}
<div class="item-stats">
<div class="item-stats-title" style="font-weight: 600; margin-bottom: 0.5rem;">Effects</div>
{% for effect in item.effects_on_use %}
<div>
<span>{{ effect.name|default(effect.effect_type|default('Effect')|title) }}</span>
<span>{{ effect.value|default('') }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{# Item value #}
{% if item.value %}
<div class="item-value" style="font-size: var(--text-sm); color: var(--accent-gold); margin-bottom: 1rem;">
Value: {{ item.value }} gold
</div>
{% endif %}
{# Action Buttons #}
<div class="item-actions">
{% if item.item_type == 'consumable' %}
<button class="action-btn action-btn--primary"
hx-post="{{ url_for('game.inventory_use', session_id=session_id) }}"
hx-vals='{"item_id": "{{ item.item_id }}"}'
hx-target="#character-panel"
hx-swap="innerHTML"
onclick="closeModal()">
Use
</button>
{% elif item.item_type in ['weapon', 'armor'] %}
<button class="action-btn action-btn--primary"
hx-post="{{ url_for('game.inventory_equip', session_id=session_id) }}"
hx-vals='{"item_id": "{{ item.item_id }}"{% if suggested_slot %}, "slot": "{{ suggested_slot }}"{% endif %}}'
hx-target="#character-panel"
hx-swap="innerHTML"
onclick="closeModal()">
Equip
</button>
{% endif %}
{% if item.item_type != 'quest_item' %}
<button class="action-btn action-btn--danger"
hx-delete="{{ url_for('game.inventory_drop', session_id=session_id, item_id=item.item_id) }}"
hx-target=".inventory-modal"
hx-swap="outerHTML"
hx-confirm="Drop {{ item.name }}? This cannot be undone.">
Drop
</button>
{% else %}
<p style="font-size: var(--text-xs); color: var(--text-muted); text-align: center; padding: 0.5rem;">
Quest items cannot be dropped
</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,138 @@
{#
Inventory Modal
Full inventory management modal for play screen
#}
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
role="dialog" aria-modal="true" aria-labelledby="inventory-title">
<div class="modal-content inventory-modal">
{# Header #}
<div class="modal-header">
<h2 class="modal-title" id="inventory-title">
Inventory
<span class="inventory-count">({{ inventory_count }}/{{ inventory_max }})</span>
</h2>
<button class="modal-close" onclick="closeModal()" aria-label="Close inventory">&times;</button>
</div>
{# Tab Filter Bar #}
<div class="inventory-tabs" role="tablist">
<button class="tab {% if filter == 'all' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'all' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='all') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
All
</button>
<button class="tab {% if filter == 'weapon' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'weapon' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='weapon') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Weapons
</button>
<button class="tab {% if filter == 'armor' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'armor' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='armor') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Armor
</button>
<button class="tab {% if filter == 'consumable' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'consumable' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='consumable') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Consumables
</button>
<button class="tab {% if filter == 'quest_item' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'quest_item' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='quest_item') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Quest
</button>
</div>
{# Body #}
<div class="modal-body inventory-body">
{# Item Grid #}
<div class="inventory-grid-container">
<div class="inventory-grid" id="inventory-items" role="listbox">
{% for item in inventory %}
<button class="inventory-item rarity-{{ item.rarity|default('common') }}"
role="option"
hx-get="{{ url_for('game.inventory_item_detail', session_id=session_id, item_id=item.item_id) }}"
hx-target="#item-detail"
hx-swap="innerHTML"
aria-label="{{ item.name }}, {{ item.rarity|default('common') }} {{ item.item_type }}">
<img src="{{ url_for('static', filename='img/items/' ~ (item.item_type|default('default')) ~ '.svg') }}"
alt="" aria-hidden="true">
<span class="item-name">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="item-quantity">x{{ item.quantity }}</span>
{% endif %}
</button>
{% else %}
<p class="inventory-empty">
{% if filter == 'all' %}
No items in inventory
{% else %}
No {{ filter|replace('_', ' ') }}s found
{% endif %}
</p>
{% endfor %}
</div>
</div>
{# Item Detail Panel #}
<div class="item-detail" id="item-detail" aria-live="polite">
<p class="item-detail-empty">Select an item to view details</p>
</div>
</div>
{# Footer #}
<div class="modal-footer">
<span class="gold-display">{{ gold }}</span>
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
<script>
// Handle item selection highlighting
document.querySelectorAll('.inventory-item').forEach(item => {
item.addEventListener('htmx:afterRequest', function() {
// Remove selected from all items
document.querySelectorAll('.inventory-item.selected').forEach(i => i.classList.remove('selected'));
// Add selected to clicked item
this.classList.add('selected');
});
});
// Mobile: Show detail panel as slide-in
function showMobileDetail() {
const detail = document.getElementById('item-detail');
if (window.innerWidth <= 768 && detail) {
detail.classList.add('visible');
}
}
function hideMobileDetail() {
const detail = document.getElementById('item-detail');
if (detail) {
detail.classList.remove('visible');
}
}
// Listen for item detail loads on mobile
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'item-detail') {
showMobileDetail();
}
});
</script>

View File

@@ -0,0 +1,51 @@
{# Item Selection Modal - Shows consumable items during combat #}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--md">
<div class="modal-header">
<h3 class="modal-title">Use Item</h3>
<button class="modal-close" onclick="closeModal()" aria-label="Close modal">&times;</button>
</div>
<div class="modal-body">
{% if items %}
<div class="item-list">
{% for item in items %}
<button class="item-btn"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "item", "item_id": "{{ item.id }}"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if item.quantity <= 0 %}disabled{% endif %}
onclick="closeModal()">
<span class="item-icon">
{% if 'health' in item.name|lower or 'heal' in item.effect|lower %}&#127863;
{% elif 'mana' in item.name|lower or 'mp' in item.effect|lower %}&#129389;
{% elif 'antidote' in item.name|lower or 'cure' in item.effect|lower %}&#129514;
{% elif 'bomb' in item.name|lower or 'damage' in item.effect|lower %}&#128163;
{% elif 'elixir' in item.name|lower %}&#129380;
{% else %}&#128230;
{% endif %}
</span>
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-effect">{{ item.effect|default('Use in combat.') }}</span>
</div>
<span class="item-quantity">x{{ item.quantity }}</span>
</button>
{% endfor %}
</div>
{% else %}
<div class="items-empty">
<p>No usable items in inventory.</p>
<p style="font-size: var(--text-xs); margin-top: 0.5rem; color: var(--text-muted);">
Purchase potions from merchants or find them while exploring.
</p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Cancel</button>
</div>
</div>
</div>

View File

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

View File

@@ -4,6 +4,7 @@
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
{% endblock %}
{% block content %}