diff --git a/api/background_tasks.py b/api/background_tasks.py index 295be0d..ef10717 100644 --- a/api/background_tasks.py +++ b/api/background_tasks.py @@ -6,6 +6,7 @@ import asyncio import logging import random import time +from .services.constants import PVP_TURN_TIMEOUT import os import fcntl from typing import Dict, Optional @@ -346,6 +347,112 @@ async def check_combat_timers(): await asyncio.sleep(10) +# ============================================================================ +# BACKGROUND TASK: PVP COMBAT TIMERS +# ============================================================================ + +async def check_pvp_combat_timers(manager=None): + """Checks for expired PvP combat turns and auto-advances them.""" + logger.info("⚔️ PvP Combat Timer task started") + + while True: + try: + await asyncio.sleep(30) # Check every 30 seconds + + start_time = time.time() + all_pvp_combats = await db.get_all_pvp_combats() + + processed = 0 + for combat in all_pvp_combats: + try: + # Check if combat has already ended (fled or player dead) + if combat.get('attacker_fled') or combat.get('defender_fled'): + continue + + # Get both players to check HP + attacker = await db.get_player_by_id(combat['attacker_character_id']) + defender = await db.get_player_by_id(combat['defender_character_id']) + + if not attacker or not defender: + # Player doesn't exist, clean up combat + await db.end_pvp_combat(combat['id']) + continue + + # Check if combat ended (someone died) + if attacker['hp'] <= 0 or defender['hp'] <= 0: + continue + + # Check if turn has timed out + turn_timeout = combat.get('turn_timeout_seconds', PVP_TURN_TIMEOUT) + # Use imported constant instead of hardcoded 300 + turn_started = combat.get('turn_started_at', time.time()) + time_elapsed = time.time() - turn_started + + if time_elapsed < turn_timeout: + continue # Turn hasn't timed out yet + + # Turn has timed out - advance to other player + current_turn = combat.get('turn', 'attacker') + new_turn = 'defender' if current_turn == 'attacker' else 'attacker' + + logger.info(f"PvP turn timeout: combat {combat['id']} advancing from {current_turn} to {new_turn}") + + # Update combat with new turn + await db.update_pvp_combat(combat['id'], { + 'turn': new_turn, + 'turn_started_at': time.time(), + 'last_action': f"Turn timeout - {current_turn}'s turn skipped|{time.time()}" + }) + + processed += 1 + + # Send WebSocket notifications to both players + if manager: + # Get updated combat data + updated_combat = await db.get_pvp_combat_by_id(combat['id']) + if updated_combat: + # Calculate time remaining for new turn + time_remaining = turn_timeout + + # Build combat update payload + combat_update = { + "type": "combat_update", + "data": { + "pvp_combat": { + "id": updated_combat['id'], + "turn": new_turn, + "time_remaining": time_remaining, + "turn_timeout": "skipped", + "last_action": f"Turn timeout - {current_turn}'s turn skipped" + }, + "is_pvp": True, + "message": f"⏱️ Turn skipped due to timeout!" + }, + "timestamp": time.time() + } + + # Notify both players + await manager.send_personal_message( + combat['attacker_character_id'], + combat_update + ) + await manager.send_personal_message( + combat['defender_character_id'], + combat_update + ) + + except Exception as e: + logger.error(f"Error processing PvP combat {combat.get('id')}: {e}") + + if processed > 0: + elapsed = time.time() - start_time + logger.info(f"Processed {processed} PvP combat timeouts in {elapsed:.2f}s") + + except Exception as e: + logger.error(f"❌ Error in PvP combat timer check: {e}", exc_info=True) + await asyncio.sleep(10) + + # ============================================================================ # BACKGROUND TASK: INTERACTABLE COOLDOWN CLEANUP # ============================================================================ @@ -431,7 +538,7 @@ async def cleanup_interactable_cooldowns(manager=None, world_locations=None): # ============================================================================ async def decay_corpses(manager=None): - """Removes old corpses. + """Removes old corpses and empty corpses. Args: manager: WebSocket ConnectionManager for broadcasting decay events @@ -445,6 +552,7 @@ async def decay_corpses(manager=None): start_time = time.time() logger.info("Running corpse decay...") + # ===== TIME-BASED DECAY ===== # Player corpses decay after 24 hours player_corpse_limit = time.time() - (24 * 3600) expired_player_corpses = await db.get_expired_player_corpses(player_corpse_limit) @@ -455,6 +563,20 @@ async def decay_corpses(manager=None): expired_npc_corpses = await db.get_expired_npc_corpses(npc_corpse_limit) npc_corpses_removed = await db.remove_expired_npc_corpses(npc_corpse_limit) + # ===== EMPTY CORPSE DECAY ===== + # Empty corpses (no loot remaining) decay immediately + empty_player_corpses = await db.get_empty_player_corpses() + empty_player_removed = await db.remove_empty_player_corpses() + + empty_npc_corpses = await db.get_empty_npc_corpses() + empty_npc_removed = await db.remove_empty_npc_corpses() + + # Combine all decayed corpses for notification + all_decayed_player_corpses = expired_player_corpses + empty_player_corpses + all_decayed_npc_corpses = expired_npc_corpses + empty_npc_corpses + total_player_removed = player_corpses_removed + empty_player_removed + total_npc_removed = npc_corpses_removed + empty_npc_removed + # Notify players in locations where corpses decayed if manager: from datetime import datetime @@ -463,10 +585,10 @@ async def decay_corpses(manager=None): # Group corpses by location corpses_by_location = defaultdict(lambda: {"player": 0, "npc": 0}) - for corpse in expired_player_corpses: + for corpse in all_decayed_player_corpses: corpses_by_location[corpse['location_id']]["player"] += 1 - for corpse in expired_npc_corpses: + for corpse in all_decayed_npc_corpses: corpses_by_location[corpse['location_id']]["npc"] += 1 # Notify each location @@ -487,8 +609,13 @@ async def decay_corpses(manager=None): ) elapsed = time.time() - start_time - if player_corpses_removed > 0 or npc_corpses_removed > 0: - logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s") + if total_player_removed > 0 or total_npc_removed > 0: + logger.info( + f"Decayed {total_player_removed} player corpses " + f"({player_corpses_removed} expired, {empty_player_removed} empty) and " + f"{total_npc_removed} NPC corpses " + f"({npc_corpses_removed} expired, {empty_npc_removed} empty) in {elapsed:.2f}s" + ) except Exception as e: logger.error(f"❌ Error in corpse decay: {e}", exc_info=True) @@ -555,13 +682,18 @@ async def process_status_effects(manager=None): await db.update_player(player_id, {'hp': 0, 'is_dead': True}) deaths += 1 - # Create player corpse + # Only create corpse if player has items inventory = await db.get_inventory(player_id) - await db.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=inventory - ) + if inventory: + import json + await db.create_player_corpse( + player_name=player['name'], + location_id=player['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + logger.info(f"Created corpse for player {player['name']} with {len(inventory)} items") + else: + logger.info(f"Player {player['name']} died (status effects) with no items, skipping corpse creation") # Remove status effects from dead player await db.remove_all_status_effects(player_id) @@ -698,6 +830,7 @@ async def start_background_tasks(manager=None, world_locations=None): asyncio.create_task(decay_dropped_items(manager)), asyncio.create_task(regenerate_stamina(manager)), asyncio.create_task(check_combat_timers()), + asyncio.create_task(check_pvp_combat_timers(manager)), asyncio.create_task(decay_corpses(manager)), asyncio.create_task(process_status_effects(manager)), # Note: Interactable cooldowns are handled client-side with server validation diff --git a/api/core/websockets.py b/api/core/websockets.py index 0824d35..1da3db4 100644 --- a/api/core/websockets.py +++ b/api/core/websockets.py @@ -2,6 +2,7 @@ WebSocket connection manager for real-time game updates. Handles WebSocket connections and Redis pub/sub for cross-worker communication. """ +import uuid from typing import Dict, Optional, List from fastapi import WebSocket import logging @@ -86,9 +87,13 @@ class ConnectionManager: connections = self.active_connections[player_id] disconnected_sockets = [] + # Inject unique message ID for tracing + if "id" not in message: + message["id"] = str(uuid.uuid4()) + for websocket in connections: try: - logger.debug(f"Sending {message.get('type')} to player {player_id}") + logger.debug(f"Using WS: Sending msg {message['id']} type={message.get('type')} to player {player_id}") await websocket.send_json(message) except Exception as e: logger.error(f"Failed to send message to player {player_id}: {e}") diff --git a/api/database.py b/api/database.py index 98c53d4..86b5544 100644 --- a/api/database.py +++ b/api/database.py @@ -13,6 +13,7 @@ from sqlalchemy import ( import time import logging from . import items +from .services.constants import PVP_TURN_TIMEOUT # Configure logging logger = logging.getLogger(__name__) @@ -194,7 +195,7 @@ pvp_combats = Table( Column("defender_character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False), Column("turn", String, nullable=False), # "attacker" or "defender" Column("turn_started_at", Float, nullable=False), - Column("turn_timeout_seconds", Integer, default=300), # 5 minutes default + Column("turn_timeout_seconds", Integer, default=PVP_TURN_TIMEOUT), # Default from constants Column("location_id", String, nullable=False), Column("created_at", Float, nullable=False), Column("attacker_fled", Boolean, default=False), @@ -873,13 +874,13 @@ async def end_combat(player_id: int) -> bool: # PvP Combat Functions -async def create_pvp_combat(attacker_id: int, defender_id: int, location_id: str, turn_timeout: int = 300) -> dict: - """Create a new PvP combat. First turn goes to defender.""" +async def create_pvp_combat(attacker_id: int, defender_id: int, location_id: str, turn_timeout: int = PVP_TURN_TIMEOUT) -> dict: + """Create a new PvP combat. First turn goes to attacker.""" async with DatabaseSession() as session: stmt = insert(pvp_combats).values( attacker_character_id=attacker_id, defender_character_id=defender_id, - turn='defender', # Defender goes first + turn='attacker', # Attacker goes first turn_started_at=time.time(), turn_timeout_seconds=turn_timeout, location_id=location_id, @@ -1970,6 +1971,61 @@ async def remove_expired_npc_corpses(timestamp_limit: float) -> int: return result.rowcount +async def get_empty_player_corpses() -> List[Dict[str, Any]]: + """Get player corpses with no items remaining.""" + async with DatabaseSession() as session: + stmt = select(player_corpses).where( + or_( + player_corpses.c.items == '[]', + player_corpses.c.items == '' + ) + ) + result = await session.execute(stmt) + return [dict(row._mapping) for row in result.fetchall()] + + +async def get_empty_npc_corpses() -> List[Dict[str, Any]]: + """Get NPC corpses with no loot remaining.""" + async with DatabaseSession() as session: + stmt = select(npc_corpses).where( + or_( + npc_corpses.c.loot_remaining == '[]', + npc_corpses.c.loot_remaining == '' + ) + ) + result = await session.execute(stmt) + return [dict(row._mapping) for row in result.fetchall()] + + +async def remove_empty_player_corpses() -> int: + """Remove player corpses with no items remaining.""" + async with DatabaseSession() as session: + stmt = delete(player_corpses).where( + or_( + player_corpses.c.items == '[]', + player_corpses.c.items == '' + ) + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount + + +async def remove_empty_npc_corpses() -> int: + """Remove NPC corpses with no loot remaining.""" + async with DatabaseSession() as session: + stmt = delete(npc_corpses).where( + or_( + npc_corpses.c.loot_remaining == '[]', + npc_corpses.c.loot_remaining == '' + ) + ) + result = await session.execute(stmt) + await session.commit() + return result.rowcount + + + # ============================================================================ # STATUS EFFECTS FUNCTIONS # ============================================================================ @@ -2226,50 +2282,12 @@ async def get_pvp_combat_by_player(character_id: int) -> Optional[Dict[str, Any] return dict(row._mapping) if row else None -async def create_pvp_combat( - attacker_id: int, - defender_id: int, - location_id: str, - turn_timeout: int = 300 -) -> Dict[str, Any]: - """Create a new PVP combat encounter.""" - import time - async with DatabaseSession() as session: - current_time = time.time() - - # Get names for denormalization - attacker_res = await session.execute(select(characters.c.name).where(characters.c.id == attacker_id)) - defender_res = await session.execute(select(characters.c.name).where(characters.c.id == defender_id)) - - attacker_name = attacker_res.scalar() or "Unknown" - defender_name = defender_res.scalar() or "Unknown" - - stmt = insert(pvp_combats).values( - attacker_character_id=attacker_id, - defender_character_id=defender_id, - attacker_name=attacker_name, - defender_name=defender_name, - location_id=location_id, - started_at=current_time, - updated_at=current_time, - turn='defender', # Defender goes first usually, or random? 'initiator pays price?' - # Requirement says: "You have initiated combat... They get the first turn." - turn_started_at=current_time, - turn_timeout_seconds=turn_timeout, - attacker_acknowledged=False, - defender_acknowledged=False - ).returning(pvp_combats) - - result = await session.execute(stmt) - await session.commit() - return dict(result.fetchone()._mapping) +# Note: create_pvp_combat is defined above at line ~876, not duplicated here async def update_pvp_combat(combat_id: int, updates: Dict[str, Any]) -> bool: """Update PVP combat state.""" - import time - updates['updated_at'] = time.time() - + # Don't add updated_at - column doesn't exist in table async with DatabaseSession() as session: stmt = update(pvp_combats).where( pvp_combats.c.id == combat_id diff --git a/api/main.py b/api/main.py index 0b40d53..6c9d2a3 100644 --- a/api/main.py +++ b/api/main.py @@ -214,9 +214,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str): # Keep connection alive while True: try: - data = await websocket.receive_text() - # Handle ping/pong or other client messages - logger.debug(f"Received from {username}: {data}") + data_text = await websocket.receive_text() + try: + data_json = json.loads(data_text) + if data_json.get("type") == "ack": + logger.debug(f"ACK received from {username} for msg {data_json.get('reply_to')}") + else: + logger.debug(f"Received from {username}: {data_text}") + except: + logger.debug(f"Received from {username}: {data_text}") except WebSocketDisconnect: break except Exception as e: diff --git a/api/routers/combat.py b/api/routers/combat.py index 01d5de0..f5417aa 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -9,6 +9,7 @@ from datetime import datetime import random import json import logging +from ..services.constants import PVP_TURN_TIMEOUT from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * @@ -430,7 +431,8 @@ async def combat_action( if random.random() < 0.5: messages.append(create_combat_message( "flee_success", - origin="player" + origin="player", + message=get_game_message('flee_success_text', locale, name=player['name']) )) combat_over = True player_won = False # Fled, not won @@ -479,7 +481,8 @@ async def combat_action( "flee_fail", origin="enemy", npc_name=npc_def.name, - damage=npc_damage + damage=npc_damage, + message=get_game_message('flee_fail_text', locale, name=player['name']) )) if new_player_hp <= 0: @@ -509,30 +512,35 @@ async def combat_action( 'tier': inv_item.get('tier') }) - logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=player['name'], - location_id=combat['location_id'], - items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - ) - - logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}") - - # Clear player's inventory (items are now in corpse) - await db.clear_inventory(player['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{player['name']}'s Corpse", - "emoji": "⚰️", - "player_name": player['name'], - "loot_count": len(inventory_items), - "items": inventory_items, - "timestamp": time_module.time() - } + # Only create corpse if player has items + corpse_data = None + if inventory_items: + logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items") + + corpse_id = await db.create_player_corpse( + player_name=player['name'], + location_id=combat['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + + logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}") + + # Clear player's inventory (items are now in corpse) + await db.clear_inventory(player['id']) + + # Build corpse data for broadcast + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{player['name']}'s Corpse", + "emoji": "⚰️", + "player_name": player['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + else: + logger.info(f"Player {player['name']} died (failed flee) with no items, skipping corpse creation") # Respawn enemy if from wandering if combat.get('from_wandering_enemy'): @@ -551,18 +559,21 @@ async def combat_action( await db.end_combat(player['id']) - # Broadcast to location that player died and corpse appeared + # Broadcast to location that player died (and corpse if created) logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}") + broadcast_data = { + "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), + "action": "player_died", + "player_id": player['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + await manager.send_to_location( location_id=combat['location_id'], message={ "type": "location_update", - "data": { - "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), - "action": "player_died", - "player_id": player['id'], - "corpse": corpse_data - }, + "data": broadcast_data, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] @@ -667,7 +678,6 @@ async def initiate_pvp_combat( if not location or location.danger_level < 3: raise HTTPException(status_code=400, detail="PvP combat is only allowed in dangerous zones (danger level >= 3)") - # Check level difference (+/- 3 levels) level_diff = abs(attacker['level'] - defender['level']) if level_diff > 3: raise HTTPException( @@ -678,9 +688,9 @@ async def initiate_pvp_combat( # Create PvP combat pvp_combat = await db.create_pvp_combat( attacker_id=attacker['id'], - defender_id=defender['id'], + defender_id=req.target_player_id, location_id=attacker['location_id'], - turn_timeout=300 # 5 minutes + turn_timeout=PVP_TURN_TIMEOUT ) # Track PvP combat initiation @@ -705,6 +715,22 @@ async def initiate_pvp_combat( "timestamp": datetime.utcnow().isoformat() }) + # Broadcast to location that PvP combat started - both players should be removed from view + await manager.send_to_location( + attacker['location_id'], + { + "type": "location_update", + "data": { + "message": get_game_message('pvp_combat_started_broadcast', locale, attacker=attacker['name'], defender=defender['name']), + "action": "pvp_combat_started", + "players_in_combat": [attacker['id'], defender['id']], + "player_left_ids": [attacker['id'], defender['id']] # Remove both from location view + }, + "timestamp": datetime.utcnow().isoformat() + }, + exclude_player_id=None # Send to everyone including combatants + ) + return { "success": True, "message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']), @@ -760,14 +786,16 @@ async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): "username": attacker['name'], "level": attacker['level'], "hp": attacker['hp'], # Use actual player HP - "max_hp": attacker['max_hp'] + "max_hp": attacker['max_hp'], + "image": "/images/characters/default.webp" }, "defender": { "id": defender['id'], "username": defender['name'], "level": defender['level'], "hp": defender['hp'], # Use actual player HP - "max_hp": defender['max_hp'] + "max_hp": defender['max_hp'], + "image": "/images/characters/default.webp" }, "is_attacker": is_attacker, "your_turn": your_turn, @@ -959,30 +987,35 @@ async def pvp_combat_action( 'tier': inv_item.get('tier') }) - logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=opponent['name'], - location_id=opponent['location_id'], - items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - ) - - logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}") - - # Clear opponent's inventory (items are now in corpse) - await db.clear_inventory(opponent['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{opponent['name']}'s Corpse", - "emoji": "⚰️", - "player_name": opponent['name'], - "loot_count": len(inventory_items), - "items": inventory_items, - "timestamp": time_module.time() - } + # Only create corpse if opponent has items + corpse_data = None + if inventory_items: + logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items") + + corpse_id = await db.create_player_corpse( + player_name=opponent['name'], + location_id=opponent['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + + logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}") + + # Clear opponent's inventory (items are now in corpse) + await db.clear_inventory(opponent['id']) + + # Build corpse data for broadcast + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{opponent['name']}'s Corpse", + "emoji": "⚰️", + "player_name": opponent['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + else: + logger.info(f"Player {opponent['name']} died (PvP death) with no items, skipping corpse creation") # Update PvP statistics for both players await db.update_player_statistics(opponent['id'], @@ -1000,18 +1033,21 @@ async def pvp_combat_action( increment=True ) - # Broadcast to location that player died and corpse appeared + # Broadcast to location that player died (and corpse if created) logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}") + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']), + "action": "player_died", + "player_id": opponent['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + await manager.send_to_location( location_id=opponent['location_id'], message={ "type": "location_update", - "data": { - "message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']), - "action": "player_died", - "player_id": opponent['id'], - "corpse": corpse_data - }, + "data": broadcast_data, "timestamp": datetime.utcnow().isoformat() } ) @@ -1048,10 +1084,11 @@ async def pvp_combat_action( elif req.action == 'flee': # 50% chance to flee from PvP if random.random() < 0.5: - last_action_text = f"{current_player['name']} fled from combat!" + last_action_text = get_game_message('flee_success_text', locale, name=current_player['name']) messages.append(create_combat_message( "flee_success", - origin="player" + origin="player", + message=last_action_text )) combat_over = True @@ -1069,11 +1106,12 @@ async def pvp_combat_action( ) else: # Failed to flee, skip turn - last_action_text = f"{current_player['name']} tried to flee but failed!" + last_action_text = get_game_message('flee_fail_text', locale, name=current_player['name']) messages.append(create_combat_message( "flee_fail", origin="player", - reason="chance" + reason="chance", + message=last_action_text )) await db.update_pvp_combat(pvp_combat['id'], { @@ -1113,14 +1151,16 @@ async def pvp_combat_action( "username": fresh_attacker['name'], "level": fresh_attacker['level'], "hp": fresh_attacker['hp'], - "max_hp": fresh_attacker['max_hp'] + "max_hp": fresh_attacker['max_hp'], + "image": "/images/characters/default.webp" }, "defender": { "id": fresh_defender['id'], "username": fresh_defender['name'], "level": fresh_defender['level'], "hp": fresh_defender['hp'], - "max_hp": fresh_defender['max_hp'] + "max_hp": fresh_defender['max_hp'], + "image": "/images/characters/default.webp" }, "is_attacker": is_attacker, "your_turn": your_turn, @@ -1134,18 +1174,68 @@ async def pvp_combat_action( "defender_fled": updated_pvp.get('defender_fled', False) } + # Determine which player object to send as "player" data for global state updates + player_data = { + "id": fresh_attacker['id'], + "username": fresh_attacker['name'], + "level": fresh_attacker['level'], + "hp": fresh_attacker['hp'], + "max_hp": fresh_attacker['max_hp'], + "xp": fresh_attacker['xp'], + "max_xp": fresh_attacker['level'] * 1000 + } if is_attacker else { + "id": fresh_defender['id'], + "username": fresh_defender['name'], + "level": fresh_defender['level'], + "hp": fresh_defender['hp'], + "max_hp": fresh_defender['max_hp'], + "xp": fresh_defender['xp'], + "max_xp": fresh_defender['level'] * 1000 + } + + # Process messages for this player + # Use actor_id (current_player['id']) to identify who performed the action + # If I am NOT the actor, then the action was done BY an enemy against me. + # So I swap 'player' origin (Actor) to 'enemy' origin (Attacker from my perspective). + actor_id = current_player['id'] + + import copy + player_messages = [] + is_actor = (player_id == actor_id) + + # For the victim (non-actor), we strip the pre-generated text messages so frontend can generate + # "Enemy hit you" instead of "Alice hit Bob" + + if not is_actor: + msgs_copy = copy.deepcopy(messages) + for m in msgs_copy: + if m.get('origin') == 'player': + m['origin'] = 'enemy' + elif m.get('origin') == 'enemy': + m['origin'] = 'player' + player_messages.append(m) + else: + player_messages = messages + + # Send separate payloads + # For actor: keep full text + # For victim: strip main message text so frontend uses data to render "Enemy hit you" + + payload_data = { + "message": last_action_text if is_actor else None, # key refactor: hide text for victim + "log_entry": last_action_text if is_actor else None, + "pvp_combat": enriched_pvp, + "combat_over": combat_over, + "winner_id": winner_id, + "player": player_data, + "attacker_hp": fresh_attacker['hp'], + "defender_hp": fresh_defender['hp'], + "messages": player_messages + } + await manager.send_personal_message(player_id, { "type": "combat_update", - "data": { - "message": last_action_text if player_id == current_user['id'] else "", - "log_entry": last_action_text if player_id == current_user['id'] else "", # Append to combat log - "pvp_combat": enriched_pvp, - "combat_over": combat_over, - "winner_id": winner_id, - "attacker_hp": fresh_attacker['hp'], - "defender_hp": fresh_defender['hp'], - "messages": messages if player_id == current_user['id'] else [] - }, + "data": payload_data, "timestamp": datetime.utcnow().isoformat() }) diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index b896284..42dc5d6 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -1124,46 +1124,54 @@ async def use_item( 'tier': inv_item.get('tier') }) - # Store minimal data in database - db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + # Only create corpse if player has items + corpse_data = None + if inventory_items: + # Store minimal data in database + db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + + logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items") + + corpse_id = await db.create_player_corpse( + player_name=player['name'], + location_id=player['location_id'], + items=db_items + ) + + logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}") + + # Clear player's inventory (items are now in corpse) + await db.clear_inventory(current_user['id']) + + # Build corpse data for broadcast + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{player['name']}'s Corpse", + "emoji": "⚰️", + "player_name": player['name'], + "loot_count": len(inventory_items), + "items": inventory_items, # Full item list for UI + "timestamp": time_module.time() + } + else: + logger.info(f"Player {player['name']} died (use_item combat) with no items, skipping corpse creation") - logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=db_items - ) - - logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}") - - # Clear player's inventory (items are now in corpse) - await db.clear_inventory(current_user['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{player['name']}'s Corpse", - "emoji": "⚰️", - "player_name": player['name'], - "loot_count": len(inventory_items), - "items": inventory_items, # Full item list for UI - "timestamp": time_module.time() - } - - # Broadcast to location that player died and corpse appeared + # Broadcast to location that player died (and corpse if created) logger.info(f"Broadcasting player_died to location {player['location_id']} for player {player['name']}") + broadcast_data = { + "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), + "action": "player_died", + "player_id": player['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + await manager.send_to_location( location_id=player['location_id'], message={ "type": "location_update", - "data": { - "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), - "action": "player_died", - "player_id": player['id'], - "corpse": corpse_data # Send full corpse data - }, + "data": broadcast_data, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=current_user['id'] diff --git a/api/services/constants.py b/api/services/constants.py new file mode 100644 index 0000000..a84a082 --- /dev/null +++ b/api/services/constants.py @@ -0,0 +1,6 @@ +""" +Global game constants +""" + +# PvP Combat +PVP_TURN_TIMEOUT = 60 diff --git a/api/services/helpers.py b/api/services/helpers.py index ff72d5d..cc4bb6d 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -47,11 +47,13 @@ GAME_MESSAGES = { 'player_entered_combat': {'en': '{player_name} entered combat with {npc_name}', 'es': '{player_name} entró en combate con {npc_name}'}, 'player_returned_pvp': {'en': '{player_name} has returned from PvP combat.', 'es': '{player_name} ha regresado del combate PvP.'}, - 'player_entered_combat': {'en': '{player_name} entered combat with {npc_name}', 'es': '{player_name} entró en combate con {npc_name}'}, - 'player_returned_pvp': {'en': '{player_name} has returned from PvP combat.', 'es': '{player_name} ha regresado del combate PvP.'}, + 'pvp_defeat_broadcast': {'en': '{opponent} was defeated by {winner} in PvP combat', 'es': '{opponent} fue derrotado por {winner} en combate PvP'}, 'pvp_initiated_attacker': {'en': "You have initiated combat with {defender}! They get the first turn.", 'es': "¡Has iniciado combate con {defender}! Tiene el primer turno."}, 'pvp_challenged_defender': {'en': "{attacker} has challenged you to PvP combat! It's your turn.", 'es': "¡{attacker} te ha desafiado a combate PvP! Es tu turno."}, + 'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"}, + 'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"}, + 'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"}, # Loot 'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"}, diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..5c05f0b --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +docker compose build echoes_of_the_ashes_api echoes_of_the_ashes_pwa && docker compose up -d echoes_of_the_ashes_api echoes_of_the_ashes_pwa echoes_of_the_ashes_api \ No newline at end of file diff --git a/images-source/characters/default.png b/images-source/characters/default.png new file mode 100644 index 0000000..1d03012 Binary files /dev/null and b/images-source/characters/default.png differ diff --git a/images-source/make_webp.sh b/images-source/make_webp.sh index 8c2cc96..ac40839 100755 --- a/images-source/make_webp.sh +++ b/images-source/make_webp.sh @@ -16,7 +16,7 @@ echo " Source: $SOURCE_DIR" echo " Output: $OUTPUT_DIR" echo "" -for category in items locations npcs interactables; do +for category in items locations npcs interactables characters; do src="$SOURCE_DIR/$category" out="$OUTPUT_DIR/$category" diff --git a/images/characters/default.webp b/images/characters/default.webp new file mode 100644 index 0000000..19f7013 Binary files /dev/null and b/images/characters/default.webp differ diff --git a/pwa/public/audio/audios.txt b/pwa/public/audio/audios.txt new file mode 100644 index 0000000..962ea36 --- /dev/null +++ b/pwa/public/audio/audios.txt @@ -0,0 +1,27 @@ +Inventory & Interaction +pickup.wav (Picking up an item) +drop.wav (Dropping an item) +equip.wav (Equipping an item) +unequip.wav (Unequipping an item) +use.wav (Using a consumable like food/potion) +interact.wav (Looting a corpse) +Combat - General +hit.wav (When anyone takes damage) +victory.wav (Combat won) +defeat.wav (Combat lost) +flee.wav (Successfully ran away) +Combat - Player Weapons +The system detects keywords in the weapon name to pick the sound. If no match is found, it plays the default. + +attack_sword.wav (Swords, Blades) +attack_axe.wav (Axes) +attack_bow.wav (Bows) +attack_dagger.wav (Daggers) +attack_blunt.wav (Hammers, Maces) +attack_punch.wav (Unarmed/Fists) +attack_default.wav (Required fallback) +Combat - Enemies +The system tries to find a specific sound for the NPC ID first. + +attack_enemy_default.wav (Required fallback) +attack_enemy_.wav (Optional specific sounds, e.g., attack_enemy_1.wav) \ No newline at end of file diff --git a/pwa/public/audio/sfx/attack_punch.wav b/pwa/public/audio/sfx/attack_punch.wav new file mode 100644 index 0000000..7e65940 Binary files /dev/null and b/pwa/public/audio/sfx/attack_punch.wav differ diff --git a/pwa/public/audio/sfx/defeat.wav b/pwa/public/audio/sfx/defeat.wav new file mode 100644 index 0000000..34ab988 Binary files /dev/null and b/pwa/public/audio/sfx/defeat.wav differ diff --git a/pwa/public/audio/sfx/equip.wav b/pwa/public/audio/sfx/equip.wav new file mode 100644 index 0000000..4bab8ad Binary files /dev/null and b/pwa/public/audio/sfx/equip.wav differ diff --git a/pwa/public/audio/sfx/flee.wav b/pwa/public/audio/sfx/flee.wav new file mode 100644 index 0000000..e2ca8d3 Binary files /dev/null and b/pwa/public/audio/sfx/flee.wav differ diff --git a/pwa/public/audio/sfx/unequip.wav b/pwa/public/audio/sfx/unequip.wav new file mode 100644 index 0000000..f112e0c Binary files /dev/null and b/pwa/public/audio/sfx/unequip.wav differ diff --git a/pwa/public/audio/sfx/use.wav b/pwa/public/audio/sfx/use.wav new file mode 100644 index 0000000..0e9837e Binary files /dev/null and b/pwa/public/audio/sfx/use.wav differ diff --git a/pwa/public/audio/sfx/victory.wav b/pwa/public/audio/sfx/victory.wav new file mode 100644 index 0000000..8d1466b Binary files /dev/null and b/pwa/public/audio/sfx/victory.wav differ diff --git a/pwa/src/components/Game.css b/pwa/src/components/Game.css index 26d0823..1ad5e4b 100644 --- a/pwa/src/components/Game.css +++ b/pwa/src/components/Game.css @@ -4123,4 +4123,9 @@ body.no-scroll { transform: translateX(-50%) translateY(-20px); } } +} + +/* Utility classes */ +.text-danger { + color: #ff4444 !important; } \ No newline at end of file diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index ac2bdb4..41502e2 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -119,7 +119,8 @@ function Game() { is_pvp: true, in_combat: true, combat_over: message.data.combat_over || false, - pvp_combat: message.data.pvp_combat + pvp_combat: message.data.pvp_combat, + messages: message.data.messages }) } if (message.data?.player) { @@ -382,7 +383,7 @@ function Game() { onLootCorpseItem={actions.handleLootCorpseItem} onSetExpandedCorpse={(corpseId: string | null) => { if (corpseId === null) { - actions.setSelectedItem(null) + actions.handleCloseCorpseDetails() } else { actions.handleViewCorpseDetails(corpseId) } diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index a2c97f9..cb69edb 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -13,7 +13,7 @@ interface CombatProps { playerState: any; equipment: any; onCombatAction: (action: string) => Promise; - onPvPAction: (action: string, targetId: number) => Promise; + onPvPAction: (action: string, targetId: number) => Promise; onExitCombat: () => void; onExitPvPCombat: () => Promise; addCombatLogEntry: (entry: any) => void; @@ -91,11 +91,24 @@ export const Combat: React.FC = ({ }]; }; + // Calculate if it's your turn for PVP + const computeYourTurn = () => { + if (!isPvP) return initialCombatData?.turn === 'player'; + const pvp = initialCombatData?.pvp_combat; + if (!pvp) return false; + // your_turn comes directly from API, or we calculate it + if (pvp.your_turn !== undefined) return pvp.your_turn; + const isAttacker = pvp.is_attacker; + const currentTurn = pvp.current_turn || pvp.turn; + return (isAttacker && currentTurn === 'attacker') || (!isAttacker && currentTurn === 'defender'); + }; + // --- State Management --- // We synchronize local state with props, but manage animations locally const [localCombatState, setLocalCombatState] = useState({ inCombat: true, - turn: initialCombatData?.turn || 'player', + turn: initialCombatData?.turn || initialCombatData?.pvp_combat?.current_turn || 'player', + yourTurn: computeYourTurn(), npcId: initialCombatData?.combat?.npc_id || initialCombatData?.pvp_combat?.defender?.id, npcName: resolveName(initialCombatData?.combat?.npc_name) || (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username), @@ -103,7 +116,8 @@ export const Combat: React.FC = ({ (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.hp : initialCombatData?.pvp_combat?.attacker?.hp) || 100, npcMaxHp: initialCombatData?.combat?.npc_max_hp || (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.max_hp : initialCombatData?.pvp_combat?.attacker?.max_hp) || 100, - npcImage: initialCombatData?.combat?.npc_image, + npcImage: initialCombatData?.combat?.npc_image || + (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.image : initialCombatData?.pvp_combat?.attacker?.image), playerHp: playerState?.health || profile?.hp || 100, playerMaxHp: playerState?.max_health || profile?.max_hp || 100, messages: getInitialLogMessage(), @@ -112,7 +126,7 @@ export const Combat: React.FC = ({ opponentName: isPvP ? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username) : undefined, - turnTimeRemaining: initialCombatData?.turn_time_remaining + turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining }); const [animState, setAnimState] = useState({ @@ -144,19 +158,131 @@ export const Combat: React.FC = ({ }, [messageQueue]); // Update local state when props change (especially for PvP live updates) - // IMPORTANT: We preserve existing messages to avoid wiping the initial log - // NOTE: HP values are NOT synced here - they are managed through processMessage for proper animation timing + // This handles both initial state and WebSocket updates (for the passive player) useEffect(() => { if (initialCombatData) { + // Get time remaining from multiple possible paths + const newTimeRemaining = + initialCombatData?.pvp_combat?.time_remaining ?? + initialCombatData?.turn_time_remaining ?? + undefined; + + // Calculate yourTurn for PVP + let newYourTurn: boolean | undefined; + if (isPvP) { + const pvp = initialCombatData?.pvp_combat; + if (pvp?.your_turn !== undefined) { + newYourTurn = pvp.your_turn; + } else if (pvp) { + const isAttacker = pvp.is_attacker; + const currentTurn = pvp.current_turn || pvp.turn; + newYourTurn = (isAttacker && currentTurn === 'attacker') || (!isAttacker && currentTurn === 'defender'); + } + } else { + newYourTurn = initialCombatData.turn === 'player' || initialCombatData.combat?.turn === 'player'; + } + + // For PVP: sync HP from WebSocket update for passive player + let newPlayerHp: number | undefined; + let newNpcHp: number | undefined; + if (isPvP && initialCombatData?.pvp_combat) { + const pvp = initialCombatData.pvp_combat; + const isAttacker = pvp.is_attacker; + + // My HP vs opponent HP based on role + if (isAttacker) { + newPlayerHp = pvp.attacker?.hp; + newNpcHp = pvp.defender?.hp; + } else { + newPlayerHp = pvp.defender?.hp; + newNpcHp = pvp.attacker?.hp; + } + } + + // Handle incoming messages from WebSocket (for passive player) + if (isPvP && initialCombatData?.messages && Array.isArray(initialCombatData.messages)) { + const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + for (const msg of initialCombatData.messages) { + // Skip messages originating from 'player' (active user) as they are handled by the immediate API response + if (msg.origin === 'player') continue; + + // Add message to combat log (only for non-player origin) + setLocalCombatState(prev => ({ + ...prev, + messages: [...prev.messages, { ...msg, timestamp }] + })); + + // Trigger animations via WebSocket message (passive player) + if ((msg.type === 'damage' || msg.type === 'player_attack') && msg.origin === 'enemy') { + // Enemy dealt damage to me + triggerAnim('enemyAttacking', 400); + setTimeout(() => { + addFloatingText(`-${msg.data?.damage || 0}`, 'damage', 'player'); + triggerAnim('playerHit', 300); + }, 200); + } else if (msg.type === 'miss' && msg.origin === 'enemy') { + // Enemy missed me + triggerAnim('enemyAttacking', 400); + setTimeout(() => { + addFloatingText('Miss!', 'miss', 'player'); + }, 200); + } else if (msg.type === 'flee_success') { + // Opponent fled -> I win (msg origin is 'enemy' after swap? NO wait.) + // Backend: attacker_fled=True. + // Message generated: "player fled". Origin="player". + // Backend swaps origin to "enemy" for me. + // So I receive: msg.type='flee_success', origin='enemy'. + setCombatResult('victory'); + } else if (msg.type === 'victory' || msg.type === 'player_defeated') { + // If I receive 'victory', and origin='player' (skipped). + // If origin='enemy' -> Enemy Won -> I Defeat. + if (msg.origin === 'player') { + setCombatResult('victory'); + } else { + setCombatResult('defeat'); + } + } + } + } + + // Handle combat_over from WebSocket + if (initialCombatData?.combat_over) { + const pvp = initialCombatData?.pvp_combat; + const myId = pvp?.is_attacker + ? pvp?.attacker?.id + : pvp?.defender?.id; + + // Check if someone fled + const iAmAttacker = pvp?.is_attacker; + const opponentFled = iAmAttacker ? pvp?.defender_fled : pvp?.attacker_fled; + const iFled = iAmAttacker ? pvp?.attacker_fled : pvp?.defender_fled; + + if (opponentFled) { + // Opponent fled - I "win" by default + setCombatResult('victory'); + } else if (iFled) { + // I fled successfully + setCombatResult('fled'); + } else if (initialCombatData?.winner_id === myId) { + setCombatResult('victory'); + } else if (initialCombatData?.winner_id) { + setCombatResult('defeat'); + } + } + setLocalCombatState(prev => ({ ...prev, - turn: initialCombatData.turn || initialCombatData.combat?.turn || prev.turn, + turn: initialCombatData.turn || initialCombatData.combat?.turn || initialCombatData.pvp_combat?.current_turn || prev.turn, + yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn, round: initialCombatData?.combat?.round ?? prev.round, - turnTimeRemaining: initialCombatData?.turn_time_remaining - // Do NOT overwrite messages or HP here - HP is managed by processMessage + turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining, + // Sync HP for PVP from WebSocket updates + ...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}), + ...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {}) })); } - }, [initialCombatData]); + }, [initialCombatData, isPvP]); // --- Handlers --- @@ -186,6 +312,41 @@ export const Combat: React.FC = ({ }; }, []); + // Timer countdown effect for PVP + useEffect(() => { + if (!isPvP || combatResult) return; // Only for active PVP combat + + const timerInterval = setInterval(() => { + setLocalCombatState(prev => { + if (prev.turnTimeRemaining !== undefined && prev.turnTimeRemaining > 0) { + const newTime = prev.turnTimeRemaining - 1; + + // If timer just hit 0 and it was your turn, switch turn and log it + if (newTime === 0 && prev.yourTurn) { + // Add timeout message to log + const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return { + ...prev, + turnTimeRemaining: newTime, + yourTurn: false, // Pass turn to opponent + messages: [...prev.messages, { + type: 'text', + origin: 'system' as const, + data: { text: t('combat.turn_timeout') }, + timestamp + }] + }; + } + + return { ...prev, turnTimeRemaining: newTime }; + } + return prev; + }); + }, 1000); + + return () => clearInterval(timerInterval); + }, [isPvP, combatResult, t]); + const triggerAnim = (anim: keyof AnimationState, duration: number = 500) => { setAnimState(prev => ({ ...prev, [anim]: true })); setTimeout(() => { @@ -235,6 +396,7 @@ export const Combat: React.FC = ({ case 'enemy_attack': case 'monster_attack': + case 'flee_fail': // Failed flee results in enemy counter-attack triggerAnim('enemyAttacking'); triggerAnim('playerHit', 300); // Player takes damage if (data.damage) { @@ -391,8 +553,84 @@ export const Combat: React.FC = ({ const handlePvPActionWrapper = async (action: string) => { if (isProcessingQueue) return; - // Clean up targetId - standard action doesn't need it usually, or use 0 - await onPvPAction(action, 0); + try { + // Call PVP action and process response for animations + const response = await onPvPAction(action, 0); + + if (response) { + // Determine if this is an attack action + const isAttack = action === 'attack'; + + // Trigger player attack animation for attacks + if (isAttack) { + triggerAnim('playerAttacking', 400); + } + + // Process messages for animations and add to combat log + if (response.messages && Array.isArray(response.messages)) { + const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + for (const msg of response.messages) { + // Add message to combat log + setLocalCombatState(prev => ({ + ...prev, + messages: [...prev.messages, { ...msg, timestamp }] + })); + + // Trigger animations based on message type + if ((msg.type === 'damage' || msg.type === 'player_attack') && msg.origin === 'player') { + // Player dealt damage - show damage on enemy + setTimeout(() => { + addFloatingText(`-${msg.data?.damage || 0}`, 'damage', 'enemy'); + triggerAnim('npcHit', 300); + }, 200); + } else if (msg.type === 'miss' && msg.origin === 'player') { + setTimeout(() => { + addFloatingText('Miss!', 'miss', 'enemy'); + }, 200); + } else if (msg.type === 'flee_success') { + // Successfully fled - trigger combat result + setCombatResult('fled'); + } else if (msg.type === 'flee_fail') { + // Failed to flee - just the log message was added + } else if (msg.type === 'victory') { + setCombatResult('victory'); + } else if (msg.type === 'player_defeated') { + setCombatResult('defeat'); + } + } + } + + // Update HP from response if available + const isAttacker = initialCombatData?.pvp_combat?.is_attacker; + if (response.attacker_hp !== undefined && response.defender_hp !== undefined) { + const myHp = isAttacker ? response.attacker_hp : response.defender_hp; + const opponentHp = isAttacker ? response.defender_hp : response.attacker_hp; + + setLocalCombatState(prev => ({ + ...prev, + npcHp: opponentHp, + playerHp: myHp + })); + } + + // Handle combat over state + if (response.combat_over) { + if (response.winner_id === initialCombatData?.pvp_combat?.id) { + // Not used - winner_id is player id, not combat id + } + // Combat result will be set by message type above + } + + // Update turn state + setLocalCombatState(prev => ({ + ...prev, + yourTurn: false // After action, it's always the other player's turn + })); + } + } catch (err) { + console.error('PvP action error:', err); + } }; const [isClosing, setIsClosing] = useState(false); diff --git a/pwa/src/components/game/CombatTypes.ts b/pwa/src/components/game/CombatTypes.ts index c9619af..679a6d4 100644 --- a/pwa/src/components/game/CombatTypes.ts +++ b/pwa/src/components/game/CombatTypes.ts @@ -17,7 +17,8 @@ export interface FloatingText { export interface CombatState { inCombat: boolean; - turn: 'player' | 'enemy'; + turn: 'player' | 'enemy' | 'attacker' | 'defender'; + yourTurn?: boolean; // True when it's the current player's turn (works for both PvE and PvP) npcId?: string; npcName?: string; npcHp: number; diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx index cd2144c..b3d9dca 100644 --- a/pwa/src/components/game/CombatView.tsx +++ b/pwa/src/components/game/CombatView.tsx @@ -71,8 +71,8 @@ export const CombatView: React.FC = ({ // Enemy Attack Sound if (animState.enemyAttacking) { - // We can use state.npcId to get specific enemy sounds - if (state.npcId) { + // We can use state.npcId to get specific enemy sounds (only for PvE) + if (state.npcId && !state.isPvP) { playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav'); } else { playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav'); @@ -125,7 +125,15 @@ export const CombatView: React.FC = ({ {state.turnTimeRemaining !== undefined && ( - ⏳ {state.turnTimeRemaining}s + ⏳ {state.turnTimeRemaining} s + + )} + {state.isPvP && ( + + {state.yourTurn ? '🎯 ' + t('combat.your_turn') : '⏳ ' + t('combat.opponent_turn')} )} @@ -169,7 +177,7 @@ export const CombatView: React.FC = ({ {/* Player HP (Right) */} -
+
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
@@ -202,7 +210,7 @@ export const CombatView: React.FC = ({ @@ -210,7 +218,7 @@ export const CombatView: React.FC = ({ @@ -230,7 +238,14 @@ export const CombatView: React.FC = ({ } else { switch (msg.type) { case 'combat_start': text = t('combat.start'); break; - case 'player_attack': text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 }); break; + case 'player_attack': + if (msg.origin === 'enemy') { + text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 }); + className += " text-danger"; + } else { + text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 }); + } + break; case 'enemy_attack': text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 }); className += " text-danger"; @@ -243,6 +258,14 @@ export const CombatView: React.FC = ({ case 'flee_fail': text = t('combat.flee.fail'); break; case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); break; case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break; + case 'damage': + if (msg.origin === 'enemy') { + text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 }); + className += " text-danger"; + } else { + text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 }); + } + break; case 'text': text = msg.data?.text || ""; break; default: text = msg.type; } diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts index f9a93a1..1ff36ad 100644 --- a/pwa/src/components/game/hooks/useGameEngine.ts +++ b/pwa/src/components/game/hooks/useGameEngine.ts @@ -105,6 +105,7 @@ export interface GameEngineActions { // Interactions handleInteract: (interactableId: string, actionId: string) => Promise handleViewCorpseDetails: (corpseId: string) => Promise + handleCloseCorpseDetails: () => void handleLootCorpse: (corpseId: string) => Promise handleLootCorpseItem: (corpseId: string, itemIndex: number | null) => Promise @@ -1046,6 +1047,10 @@ export function useGameEngine( handleFlee, handleInteract, handleViewCorpseDetails, + handleCloseCorpseDetails: () => { + setExpandedCorpse(null) + setCorpseDetails(null) + }, handleLootCorpse, handleLootCorpseItem, handleSpendPoint, @@ -1084,6 +1089,31 @@ export function useGameEngine( } } + // Polling fallback for PvP Combat reliability + // Polling fallback for PvP Combat reliability + // optimized: poll less frequently (15s) and rely on WS reconnect event + useEffect(() => { + // 1. Listen for WebSocket reconnection to fetch immediately + const handleReconnect = () => { + console.log("[PvP] WebSocket reconnected, fetching fresh state..."); + fetchGameData(true); + }; + window.addEventListener('game-ws-connected', handleReconnect); + + // 2. Slow polling as safety net + let interval: ReturnType | null = null; + if (combatState?.is_pvp && !combatState?.combat_over) { + interval = setInterval(() => { + fetchGameData(true); + }, 15000); // Poll every 15s instead of 3s + } + + return () => { + window.removeEventListener('game-ws-connected', handleReconnect); + if (interval) clearInterval(interval); + }; + }, [combatState?.is_pvp, combatState?.combat_over, fetchGameData]); + // Initial data load useEffect(() => { if (token) { @@ -1091,51 +1121,22 @@ export function useGameEngine( } }, [token]) - // WebSocket connection + // WebSocket Event Bus Listener + // Instead of maintaining a second connection, we listen to the global connection managed by GameHeader useEffect(() => { - if (!token) return - - // Get WebSocket URL based on environment (same logic as api.ts) - const API_BASE = import.meta.env.VITE_API_URL || ( - import.meta.env.PROD - ? 'https://api-staging.echoesoftheash.com' - : 'http://localhost:8000' - ) - const wsBase = API_BASE.replace(/^http/, 'ws') - const wsUrl = `${wsBase}/ws/game/${token}` - console.log('🔌 Connecting to WebSocket:', wsUrl) - - const ws = new WebSocket(wsUrl) - - ws.onopen = () => { - console.log('✅ WebSocket connection established') - setWebSocket(ws) - } - - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data) - _handleWebSocketMessage(message) - } catch (err) { - console.error('Failed to parse WebSocket message:', err) + const handleGameMessage = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + _handleWebSocketMessage(customEvent.detail); } - } + }; - ws.onerror = (error) => { - console.error('❌ WebSocket error:', error) - } - - ws.onclose = () => { - console.log('🔌 WebSocket disconnected') - setWebSocket(null) - } + window.addEventListener('game-ws-message', handleGameMessage); return () => { - if (ws.readyState === WebSocket.OPEN) { - ws.close() - } - } - }, [token]) // Removed _handleWebSocketMessage from dependencies + window.removeEventListener('game-ws-message', handleGameMessage); + }; + }, [_handleWebSocketMessage]); return [state, actions] } diff --git a/pwa/src/hooks/useGameWebSocket.ts b/pwa/src/hooks/useGameWebSocket.ts index 4394455..96b0572 100644 --- a/pwa/src/hooks/useGameWebSocket.ts +++ b/pwa/src/hooks/useGameWebSocket.ts @@ -74,6 +74,9 @@ export const useGameWebSocket = ({ setIsConnected(true); reconnectAttemptsRef.current = 0; + // Dispatch global event for other components to react (e.g., fetch fresh state) + window.dispatchEvent(new Event('game-ws-connected')); + // Start heartbeat interval (every 30 seconds) heartbeatIntervalRef.current = setInterval(sendHeartbeat, 30000); }; @@ -87,6 +90,16 @@ export const useGameWebSocket = ({ return; } + if (message.id) { + // console.log(`📩 Received msg ${message.id} type=${message.type}`); + // Send ACK + wsRef.current?.send(JSON.stringify({ type: "ack", reply_to: message.id })); + } + + // Dispatch to global event bus so other components (like Game.tsx) can react + // without needing their own WebSocket connection + window.dispatchEvent(new CustomEvent('game-ws-message', { detail: message })); + // Pass message to handler onMessage(message); } catch (error) { diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index 3e00890..9bb27be 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -151,6 +151,9 @@ "inCombat": "In Combat", "yourTurn": "Your Turn", "enemyTurn": "Enemy's Turn", + "your_turn": "Your Turn", + "opponent_turn": "Waiting", + "turn_timeout": "Time ran out! Turn passed.", "victory": "Victory!", "defeat": "Defeat", "youDied": "You Died", diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index 91a2547..7b0ebf2 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -149,6 +149,9 @@ "inCombat": "En Combate", "yourTurn": "Tu Turno", "enemyTurn": "Turno del Enemigo", + "your_turn": "Tu Turno", + "opponent_turn": "Esperando", + "turn_timeout": "¡Se acabó el tiempo! Turno pasado.", "victory": "¡Victoria!", "defeat": "Derrota", "youDied": "Has Muerto",