Pre-combat-improvements: Combat animations, flee fixes, corpse logic updates
This commit is contained in:
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from .services.constants import PVP_TURN_TIMEOUT
|
||||||
import os
|
import os
|
||||||
import fcntl
|
import fcntl
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
@@ -346,6 +347,112 @@ async def check_combat_timers():
|
|||||||
await asyncio.sleep(10)
|
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
|
# 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):
|
async def decay_corpses(manager=None):
|
||||||
"""Removes old corpses.
|
"""Removes old corpses and empty corpses.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
manager: WebSocket ConnectionManager for broadcasting decay events
|
manager: WebSocket ConnectionManager for broadcasting decay events
|
||||||
@@ -445,6 +552,7 @@ async def decay_corpses(manager=None):
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Running corpse decay...")
|
logger.info("Running corpse decay...")
|
||||||
|
|
||||||
|
# ===== TIME-BASED DECAY =====
|
||||||
# Player corpses decay after 24 hours
|
# Player corpses decay after 24 hours
|
||||||
player_corpse_limit = time.time() - (24 * 3600)
|
player_corpse_limit = time.time() - (24 * 3600)
|
||||||
expired_player_corpses = await db.get_expired_player_corpses(player_corpse_limit)
|
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)
|
expired_npc_corpses = await db.get_expired_npc_corpses(npc_corpse_limit)
|
||||||
npc_corpses_removed = await db.remove_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
|
# Notify players in locations where corpses decayed
|
||||||
if manager:
|
if manager:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -463,10 +585,10 @@ async def decay_corpses(manager=None):
|
|||||||
# Group corpses by location
|
# Group corpses by location
|
||||||
corpses_by_location = defaultdict(lambda: {"player": 0, "npc": 0})
|
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
|
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
|
corpses_by_location[corpse['location_id']]["npc"] += 1
|
||||||
|
|
||||||
# Notify each location
|
# Notify each location
|
||||||
@@ -487,8 +609,13 @@ async def decay_corpses(manager=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
if player_corpses_removed > 0 or npc_corpses_removed > 0:
|
if total_player_removed > 0 or total_npc_removed > 0:
|
||||||
logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error in corpse decay: {e}", exc_info=True)
|
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})
|
await db.update_player(player_id, {'hp': 0, 'is_dead': True})
|
||||||
deaths += 1
|
deaths += 1
|
||||||
|
|
||||||
# Create player corpse
|
# Only create corpse if player has items
|
||||||
inventory = await db.get_inventory(player_id)
|
inventory = await db.get_inventory(player_id)
|
||||||
await db.create_player_corpse(
|
if inventory:
|
||||||
player_name=player['name'],
|
import json
|
||||||
location_id=player['location_id'],
|
await db.create_player_corpse(
|
||||||
items=inventory
|
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
|
# Remove status effects from dead player
|
||||||
await db.remove_all_status_effects(player_id)
|
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(decay_dropped_items(manager)),
|
||||||
asyncio.create_task(regenerate_stamina(manager)),
|
asyncio.create_task(regenerate_stamina(manager)),
|
||||||
asyncio.create_task(check_combat_timers()),
|
asyncio.create_task(check_combat_timers()),
|
||||||
|
asyncio.create_task(check_pvp_combat_timers(manager)),
|
||||||
asyncio.create_task(decay_corpses(manager)),
|
asyncio.create_task(decay_corpses(manager)),
|
||||||
asyncio.create_task(process_status_effects(manager)),
|
asyncio.create_task(process_status_effects(manager)),
|
||||||
# Note: Interactable cooldowns are handled client-side with server validation
|
# Note: Interactable cooldowns are handled client-side with server validation
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
WebSocket connection manager for real-time game updates.
|
WebSocket connection manager for real-time game updates.
|
||||||
Handles WebSocket connections and Redis pub/sub for cross-worker communication.
|
Handles WebSocket connections and Redis pub/sub for cross-worker communication.
|
||||||
"""
|
"""
|
||||||
|
import uuid
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
import logging
|
import logging
|
||||||
@@ -86,9 +87,13 @@ class ConnectionManager:
|
|||||||
connections = self.active_connections[player_id]
|
connections = self.active_connections[player_id]
|
||||||
disconnected_sockets = []
|
disconnected_sockets = []
|
||||||
|
|
||||||
|
# Inject unique message ID for tracing
|
||||||
|
if "id" not in message:
|
||||||
|
message["id"] = str(uuid.uuid4())
|
||||||
|
|
||||||
for websocket in connections:
|
for websocket in connections:
|
||||||
try:
|
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)
|
await websocket.send_json(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send message to player {player_id}: {e}")
|
logger.error(f"Failed to send message to player {player_id}: {e}")
|
||||||
|
|||||||
106
api/database.py
106
api/database.py
@@ -13,6 +13,7 @@ from sqlalchemy import (
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from . import items
|
from . import items
|
||||||
|
from .services.constants import PVP_TURN_TIMEOUT
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -194,7 +195,7 @@ pvp_combats = Table(
|
|||||||
Column("defender_character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
Column("defender_character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||||
Column("turn", String, nullable=False), # "attacker" or "defender"
|
Column("turn", String, nullable=False), # "attacker" or "defender"
|
||||||
Column("turn_started_at", Float, nullable=False),
|
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("location_id", String, nullable=False),
|
||||||
Column("created_at", Float, nullable=False),
|
Column("created_at", Float, nullable=False),
|
||||||
Column("attacker_fled", Boolean, default=False),
|
Column("attacker_fled", Boolean, default=False),
|
||||||
@@ -873,13 +874,13 @@ async def end_combat(player_id: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# PvP Combat Functions
|
# PvP Combat Functions
|
||||||
async def create_pvp_combat(attacker_id: int, defender_id: int, location_id: str, turn_timeout: int = 300) -> dict:
|
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 defender."""
|
"""Create a new PvP combat. First turn goes to attacker."""
|
||||||
async with DatabaseSession() as session:
|
async with DatabaseSession() as session:
|
||||||
stmt = insert(pvp_combats).values(
|
stmt = insert(pvp_combats).values(
|
||||||
attacker_character_id=attacker_id,
|
attacker_character_id=attacker_id,
|
||||||
defender_character_id=defender_id,
|
defender_character_id=defender_id,
|
||||||
turn='defender', # Defender goes first
|
turn='attacker', # Attacker goes first
|
||||||
turn_started_at=time.time(),
|
turn_started_at=time.time(),
|
||||||
turn_timeout_seconds=turn_timeout,
|
turn_timeout_seconds=turn_timeout,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -1970,6 +1971,61 @@ async def remove_expired_npc_corpses(timestamp_limit: float) -> int:
|
|||||||
return result.rowcount
|
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
|
# 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
|
return dict(row._mapping) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def create_pvp_combat(
|
# Note: create_pvp_combat is defined above at line ~876, not duplicated here
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_pvp_combat(combat_id: int, updates: Dict[str, Any]) -> bool:
|
async def update_pvp_combat(combat_id: int, updates: Dict[str, Any]) -> bool:
|
||||||
"""Update PVP combat state."""
|
"""Update PVP combat state."""
|
||||||
import time
|
# Don't add updated_at - column doesn't exist in table
|
||||||
updates['updated_at'] = time.time()
|
|
||||||
|
|
||||||
async with DatabaseSession() as session:
|
async with DatabaseSession() as session:
|
||||||
stmt = update(pvp_combats).where(
|
stmt = update(pvp_combats).where(
|
||||||
pvp_combats.c.id == combat_id
|
pvp_combats.c.id == combat_id
|
||||||
|
|||||||
12
api/main.py
12
api/main.py
@@ -214,9 +214,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str):
|
|||||||
# Keep connection alive
|
# Keep connection alive
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = await websocket.receive_text()
|
data_text = await websocket.receive_text()
|
||||||
# Handle ping/pong or other client messages
|
try:
|
||||||
logger.debug(f"Received from {username}: {data}")
|
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:
|
except WebSocketDisconnect:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from datetime import datetime
|
|||||||
import random
|
import random
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from ..services.constants import PVP_TURN_TIMEOUT
|
||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
@@ -430,7 +431,8 @@ async def combat_action(
|
|||||||
if random.random() < 0.5:
|
if random.random() < 0.5:
|
||||||
messages.append(create_combat_message(
|
messages.append(create_combat_message(
|
||||||
"flee_success",
|
"flee_success",
|
||||||
origin="player"
|
origin="player",
|
||||||
|
message=get_game_message('flee_success_text', locale, name=player['name'])
|
||||||
))
|
))
|
||||||
combat_over = True
|
combat_over = True
|
||||||
player_won = False # Fled, not won
|
player_won = False # Fled, not won
|
||||||
@@ -479,7 +481,8 @@ async def combat_action(
|
|||||||
"flee_fail",
|
"flee_fail",
|
||||||
origin="enemy",
|
origin="enemy",
|
||||||
npc_name=npc_def.name,
|
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:
|
if new_player_hp <= 0:
|
||||||
@@ -509,30 +512,35 @@ async def combat_action(
|
|||||||
'tier': inv_item.get('tier')
|
'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")
|
# 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(
|
corpse_id = await db.create_player_corpse(
|
||||||
player_name=player['name'],
|
player_name=player['name'],
|
||||||
location_id=combat['location_id'],
|
location_id=combat['location_id'],
|
||||||
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
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)}")
|
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)
|
# Clear player's inventory (items are now in corpse)
|
||||||
await db.clear_inventory(player['id'])
|
await db.clear_inventory(player['id'])
|
||||||
|
|
||||||
# Build corpse data for broadcast
|
# Build corpse data for broadcast
|
||||||
corpse_data = {
|
corpse_data = {
|
||||||
"id": f"player_{corpse_id}",
|
"id": f"player_{corpse_id}",
|
||||||
"type": "player",
|
"type": "player",
|
||||||
"name": f"{player['name']}'s Corpse",
|
"name": f"{player['name']}'s Corpse",
|
||||||
"emoji": "⚰️",
|
"emoji": "⚰️",
|
||||||
"player_name": player['name'],
|
"player_name": player['name'],
|
||||||
"loot_count": len(inventory_items),
|
"loot_count": len(inventory_items),
|
||||||
"items": inventory_items,
|
"items": inventory_items,
|
||||||
"timestamp": time_module.time()
|
"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
|
# Respawn enemy if from wandering
|
||||||
if combat.get('from_wandering_enemy'):
|
if combat.get('from_wandering_enemy'):
|
||||||
@@ -551,18 +559,21 @@ async def combat_action(
|
|||||||
|
|
||||||
await db.end_combat(player['id'])
|
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']}")
|
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(
|
await manager.send_to_location(
|
||||||
location_id=combat['location_id'],
|
location_id=combat['location_id'],
|
||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": broadcast_data,
|
||||||
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
|
|
||||||
"action": "player_died",
|
|
||||||
"player_id": player['id'],
|
|
||||||
"corpse": corpse_data
|
|
||||||
},
|
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
},
|
},
|
||||||
exclude_player_id=player['id']
|
exclude_player_id=player['id']
|
||||||
@@ -667,7 +678,6 @@ async def initiate_pvp_combat(
|
|||||||
if not location or location.danger_level < 3:
|
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)")
|
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'])
|
level_diff = abs(attacker['level'] - defender['level'])
|
||||||
if level_diff > 3:
|
if level_diff > 3:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -678,9 +688,9 @@ async def initiate_pvp_combat(
|
|||||||
# Create PvP combat
|
# Create PvP combat
|
||||||
pvp_combat = await db.create_pvp_combat(
|
pvp_combat = await db.create_pvp_combat(
|
||||||
attacker_id=attacker['id'],
|
attacker_id=attacker['id'],
|
||||||
defender_id=defender['id'],
|
defender_id=req.target_player_id,
|
||||||
location_id=attacker['location_id'],
|
location_id=attacker['location_id'],
|
||||||
turn_timeout=300 # 5 minutes
|
turn_timeout=PVP_TURN_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track PvP combat initiation
|
# Track PvP combat initiation
|
||||||
@@ -705,6 +715,22 @@ async def initiate_pvp_combat(
|
|||||||
"timestamp": datetime.utcnow().isoformat()
|
"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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']),
|
"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'],
|
"username": attacker['name'],
|
||||||
"level": attacker['level'],
|
"level": attacker['level'],
|
||||||
"hp": attacker['hp'], # Use actual player HP
|
"hp": attacker['hp'], # Use actual player HP
|
||||||
"max_hp": attacker['max_hp']
|
"max_hp": attacker['max_hp'],
|
||||||
|
"image": "/images/characters/default.webp"
|
||||||
},
|
},
|
||||||
"defender": {
|
"defender": {
|
||||||
"id": defender['id'],
|
"id": defender['id'],
|
||||||
"username": defender['name'],
|
"username": defender['name'],
|
||||||
"level": defender['level'],
|
"level": defender['level'],
|
||||||
"hp": defender['hp'], # Use actual player HP
|
"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,
|
"is_attacker": is_attacker,
|
||||||
"your_turn": your_turn,
|
"your_turn": your_turn,
|
||||||
@@ -959,30 +987,35 @@ async def pvp_combat_action(
|
|||||||
'tier': inv_item.get('tier')
|
'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")
|
# 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(
|
corpse_id = await db.create_player_corpse(
|
||||||
player_name=opponent['name'],
|
player_name=opponent['name'],
|
||||||
location_id=opponent['location_id'],
|
location_id=opponent['location_id'],
|
||||||
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
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)}")
|
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)
|
# Clear opponent's inventory (items are now in corpse)
|
||||||
await db.clear_inventory(opponent['id'])
|
await db.clear_inventory(opponent['id'])
|
||||||
|
|
||||||
# Build corpse data for broadcast
|
# Build corpse data for broadcast
|
||||||
corpse_data = {
|
corpse_data = {
|
||||||
"id": f"player_{corpse_id}",
|
"id": f"player_{corpse_id}",
|
||||||
"type": "player",
|
"type": "player",
|
||||||
"name": f"{opponent['name']}'s Corpse",
|
"name": f"{opponent['name']}'s Corpse",
|
||||||
"emoji": "⚰️",
|
"emoji": "⚰️",
|
||||||
"player_name": opponent['name'],
|
"player_name": opponent['name'],
|
||||||
"loot_count": len(inventory_items),
|
"loot_count": len(inventory_items),
|
||||||
"items": inventory_items,
|
"items": inventory_items,
|
||||||
"timestamp": time_module.time()
|
"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
|
# Update PvP statistics for both players
|
||||||
await db.update_player_statistics(opponent['id'],
|
await db.update_player_statistics(opponent['id'],
|
||||||
@@ -1000,18 +1033,21 @@ async def pvp_combat_action(
|
|||||||
increment=True
|
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']}")
|
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(
|
await manager.send_to_location(
|
||||||
location_id=opponent['location_id'],
|
location_id=opponent['location_id'],
|
||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": broadcast_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
|
|
||||||
},
|
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1048,10 +1084,11 @@ async def pvp_combat_action(
|
|||||||
elif req.action == 'flee':
|
elif req.action == 'flee':
|
||||||
# 50% chance to flee from PvP
|
# 50% chance to flee from PvP
|
||||||
if random.random() < 0.5:
|
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(
|
messages.append(create_combat_message(
|
||||||
"flee_success",
|
"flee_success",
|
||||||
origin="player"
|
origin="player",
|
||||||
|
message=last_action_text
|
||||||
))
|
))
|
||||||
|
|
||||||
combat_over = True
|
combat_over = True
|
||||||
@@ -1069,11 +1106,12 @@ async def pvp_combat_action(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Failed to flee, skip turn
|
# 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(
|
messages.append(create_combat_message(
|
||||||
"flee_fail",
|
"flee_fail",
|
||||||
origin="player",
|
origin="player",
|
||||||
reason="chance"
|
reason="chance",
|
||||||
|
message=last_action_text
|
||||||
))
|
))
|
||||||
|
|
||||||
await db.update_pvp_combat(pvp_combat['id'], {
|
await db.update_pvp_combat(pvp_combat['id'], {
|
||||||
@@ -1113,14 +1151,16 @@ async def pvp_combat_action(
|
|||||||
"username": fresh_attacker['name'],
|
"username": fresh_attacker['name'],
|
||||||
"level": fresh_attacker['level'],
|
"level": fresh_attacker['level'],
|
||||||
"hp": fresh_attacker['hp'],
|
"hp": fresh_attacker['hp'],
|
||||||
"max_hp": fresh_attacker['max_hp']
|
"max_hp": fresh_attacker['max_hp'],
|
||||||
|
"image": "/images/characters/default.webp"
|
||||||
},
|
},
|
||||||
"defender": {
|
"defender": {
|
||||||
"id": fresh_defender['id'],
|
"id": fresh_defender['id'],
|
||||||
"username": fresh_defender['name'],
|
"username": fresh_defender['name'],
|
||||||
"level": fresh_defender['level'],
|
"level": fresh_defender['level'],
|
||||||
"hp": fresh_defender['hp'],
|
"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,
|
"is_attacker": is_attacker,
|
||||||
"your_turn": your_turn,
|
"your_turn": your_turn,
|
||||||
@@ -1134,18 +1174,68 @@ async def pvp_combat_action(
|
|||||||
"defender_fled": updated_pvp.get('defender_fled', False)
|
"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, {
|
await manager.send_personal_message(player_id, {
|
||||||
"type": "combat_update",
|
"type": "combat_update",
|
||||||
"data": {
|
"data": payload_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 []
|
|
||||||
},
|
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1124,46 +1124,54 @@ async def use_item(
|
|||||||
'tier': inv_item.get('tier')
|
'tier': inv_item.get('tier')
|
||||||
})
|
})
|
||||||
|
|
||||||
# Store minimal data in database
|
# Only create corpse if player has items
|
||||||
db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
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")
|
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(
|
corpse_id = await db.create_player_corpse(
|
||||||
player_name=player['name'],
|
player_name=player['name'],
|
||||||
location_id=player['location_id'],
|
location_id=player['location_id'],
|
||||||
items=db_items
|
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)}")
|
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)
|
# Clear player's inventory (items are now in corpse)
|
||||||
await db.clear_inventory(current_user['id'])
|
await db.clear_inventory(current_user['id'])
|
||||||
|
|
||||||
# Build corpse data for broadcast
|
# Build corpse data for broadcast
|
||||||
corpse_data = {
|
corpse_data = {
|
||||||
"id": f"player_{corpse_id}",
|
"id": f"player_{corpse_id}",
|
||||||
"type": "player",
|
"type": "player",
|
||||||
"name": f"{player['name']}'s Corpse",
|
"name": f"{player['name']}'s Corpse",
|
||||||
"emoji": "⚰️",
|
"emoji": "⚰️",
|
||||||
"player_name": player['name'],
|
"player_name": player['name'],
|
||||||
"loot_count": len(inventory_items),
|
"loot_count": len(inventory_items),
|
||||||
"items": inventory_items, # Full item list for UI
|
"items": inventory_items, # Full item list for UI
|
||||||
"timestamp": time_module.time()
|
"timestamp": time_module.time()
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
logger.info(f"Player {player['name']} died (use_item combat) with no items, skipping corpse creation")
|
||||||
|
|
||||||
# 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']}")
|
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(
|
await manager.send_to_location(
|
||||||
location_id=player['location_id'],
|
location_id=player['location_id'],
|
||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": broadcast_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
|
|
||||||
},
|
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
},
|
},
|
||||||
exclude_player_id=current_user['id']
|
exclude_player_id=current_user['id']
|
||||||
|
|||||||
6
api/services/constants.py
Normal file
6
api/services/constants.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Global game constants
|
||||||
|
"""
|
||||||
|
|
||||||
|
# PvP Combat
|
||||||
|
PVP_TURN_TIMEOUT = 60
|
||||||
@@ -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_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_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_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_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_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
|
# Loot
|
||||||
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
|
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
|
||||||
|
|||||||
1
build.sh
Executable file
1
build.sh
Executable file
@@ -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
|
||||||
BIN
images-source/characters/default.png
Normal file
BIN
images-source/characters/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -16,7 +16,7 @@ echo " Source: $SOURCE_DIR"
|
|||||||
echo " Output: $OUTPUT_DIR"
|
echo " Output: $OUTPUT_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
for category in items locations npcs interactables; do
|
for category in items locations npcs interactables characters; do
|
||||||
src="$SOURCE_DIR/$category"
|
src="$SOURCE_DIR/$category"
|
||||||
out="$OUTPUT_DIR/$category"
|
out="$OUTPUT_DIR/$category"
|
||||||
|
|
||||||
|
|||||||
BIN
images/characters/default.webp
Normal file
BIN
images/characters/default.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
27
pwa/public/audio/audios.txt
Normal file
27
pwa/public/audio/audios.txt
Normal file
@@ -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_<ID>.wav (Optional specific sounds, e.g., attack_enemy_1.wav)
|
||||||
BIN
pwa/public/audio/sfx/attack_punch.wav
Normal file
BIN
pwa/public/audio/sfx/attack_punch.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/defeat.wav
Normal file
BIN
pwa/public/audio/sfx/defeat.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/equip.wav
Normal file
BIN
pwa/public/audio/sfx/equip.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/flee.wav
Normal file
BIN
pwa/public/audio/sfx/flee.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/unequip.wav
Normal file
BIN
pwa/public/audio/sfx/unequip.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/use.wav
Normal file
BIN
pwa/public/audio/sfx/use.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/victory.wav
Normal file
BIN
pwa/public/audio/sfx/victory.wav
Normal file
Binary file not shown.
@@ -4124,3 +4124,8 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.text-danger {
|
||||||
|
color: #ff4444 !important;
|
||||||
|
}
|
||||||
@@ -119,7 +119,8 @@ function Game() {
|
|||||||
is_pvp: true,
|
is_pvp: true,
|
||||||
in_combat: true,
|
in_combat: true,
|
||||||
combat_over: message.data.combat_over || false,
|
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) {
|
if (message.data?.player) {
|
||||||
@@ -382,7 +383,7 @@ function Game() {
|
|||||||
onLootCorpseItem={actions.handleLootCorpseItem}
|
onLootCorpseItem={actions.handleLootCorpseItem}
|
||||||
onSetExpandedCorpse={(corpseId: string | null) => {
|
onSetExpandedCorpse={(corpseId: string | null) => {
|
||||||
if (corpseId === null) {
|
if (corpseId === null) {
|
||||||
actions.setSelectedItem(null)
|
actions.handleCloseCorpseDetails()
|
||||||
} else {
|
} else {
|
||||||
actions.handleViewCorpseDetails(corpseId)
|
actions.handleViewCorpseDetails(corpseId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface CombatProps {
|
|||||||
playerState: any;
|
playerState: any;
|
||||||
equipment: any;
|
equipment: any;
|
||||||
onCombatAction: (action: string) => Promise<any>;
|
onCombatAction: (action: string) => Promise<any>;
|
||||||
onPvPAction: (action: string, targetId: number) => Promise<void>;
|
onPvPAction: (action: string, targetId: number) => Promise<any>;
|
||||||
onExitCombat: () => void;
|
onExitCombat: () => void;
|
||||||
onExitPvPCombat: () => Promise<void>;
|
onExitPvPCombat: () => Promise<void>;
|
||||||
addCombatLogEntry: (entry: any) => void;
|
addCombatLogEntry: (entry: any) => void;
|
||||||
@@ -91,11 +91,24 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
}];
|
}];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 ---
|
// --- State Management ---
|
||||||
// We synchronize local state with props, but manage animations locally
|
// We synchronize local state with props, but manage animations locally
|
||||||
const [localCombatState, setLocalCombatState] = useState<CombatState>({
|
const [localCombatState, setLocalCombatState] = useState<CombatState>({
|
||||||
inCombat: true,
|
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,
|
npcId: initialCombatData?.combat?.npc_id || initialCombatData?.pvp_combat?.defender?.id,
|
||||||
npcName: resolveName(initialCombatData?.combat?.npc_name) ||
|
npcName: resolveName(initialCombatData?.combat?.npc_name) ||
|
||||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username),
|
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username),
|
||||||
@@ -103,7 +116,8 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.hp : initialCombatData?.pvp_combat?.attacker?.hp) || 100,
|
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.hp : initialCombatData?.pvp_combat?.attacker?.hp) || 100,
|
||||||
npcMaxHp: initialCombatData?.combat?.npc_max_hp ||
|
npcMaxHp: initialCombatData?.combat?.npc_max_hp ||
|
||||||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.max_hp : initialCombatData?.pvp_combat?.attacker?.max_hp) || 100,
|
(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,
|
playerHp: playerState?.health || profile?.hp || 100,
|
||||||
playerMaxHp: playerState?.max_health || profile?.max_hp || 100,
|
playerMaxHp: playerState?.max_health || profile?.max_hp || 100,
|
||||||
messages: getInitialLogMessage(),
|
messages: getInitialLogMessage(),
|
||||||
@@ -112,7 +126,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
opponentName: isPvP
|
opponentName: isPvP
|
||||||
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
|
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
|
||||||
: undefined,
|
: undefined,
|
||||||
turnTimeRemaining: initialCombatData?.turn_time_remaining
|
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining
|
||||||
});
|
});
|
||||||
|
|
||||||
const [animState, setAnimState] = useState<AnimationState>({
|
const [animState, setAnimState] = useState<AnimationState>({
|
||||||
@@ -144,19 +158,131 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
}, [messageQueue]);
|
}, [messageQueue]);
|
||||||
|
|
||||||
// Update local state when props change (especially for PvP live updates)
|
// Update local state when props change (especially for PvP live updates)
|
||||||
// IMPORTANT: We preserve existing messages to avoid wiping the initial log
|
// This handles both initial state and WebSocket updates (for the passive player)
|
||||||
// NOTE: HP values are NOT synced here - they are managed through processMessage for proper animation timing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCombatData) {
|
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 => ({
|
setLocalCombatState(prev => ({
|
||||||
...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,
|
round: initialCombatData?.combat?.round ?? prev.round,
|
||||||
turnTimeRemaining: initialCombatData?.turn_time_remaining
|
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
|
||||||
// Do NOT overwrite messages or HP here - HP is managed by processMessage
|
// Sync HP for PVP from WebSocket updates
|
||||||
|
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
|
||||||
|
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [initialCombatData]);
|
}, [initialCombatData, isPvP]);
|
||||||
|
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
@@ -186,6 +312,41 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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) => {
|
const triggerAnim = (anim: keyof AnimationState, duration: number = 500) => {
|
||||||
setAnimState(prev => ({ ...prev, [anim]: true }));
|
setAnimState(prev => ({ ...prev, [anim]: true }));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -235,6 +396,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
|
|
||||||
case 'enemy_attack':
|
case 'enemy_attack':
|
||||||
case 'monster_attack':
|
case 'monster_attack':
|
||||||
|
case 'flee_fail': // Failed flee results in enemy counter-attack
|
||||||
triggerAnim('enemyAttacking');
|
triggerAnim('enemyAttacking');
|
||||||
triggerAnim('playerHit', 300); // Player takes damage
|
triggerAnim('playerHit', 300); // Player takes damage
|
||||||
if (data.damage) {
|
if (data.damage) {
|
||||||
@@ -391,8 +553,84 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
|
|
||||||
const handlePvPActionWrapper = async (action: string) => {
|
const handlePvPActionWrapper = async (action: string) => {
|
||||||
if (isProcessingQueue) return;
|
if (isProcessingQueue) return;
|
||||||
// Clean up targetId - standard action doesn't need it usually, or use 0
|
try {
|
||||||
await onPvPAction(action, 0);
|
// 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);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export interface FloatingText {
|
|||||||
|
|
||||||
export interface CombatState {
|
export interface CombatState {
|
||||||
inCombat: boolean;
|
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;
|
npcId?: string;
|
||||||
npcName?: string;
|
npcName?: string;
|
||||||
npcHp: number;
|
npcHp: number;
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
|
|
||||||
// Enemy Attack Sound
|
// Enemy Attack Sound
|
||||||
if (animState.enemyAttacking) {
|
if (animState.enemyAttacking) {
|
||||||
// We can use state.npcId to get specific enemy sounds
|
// We can use state.npcId to get specific enemy sounds (only for PvE)
|
||||||
if (state.npcId) {
|
if (state.npcId && !state.isPvP) {
|
||||||
playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav');
|
playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav');
|
||||||
} else {
|
} else {
|
||||||
playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav');
|
playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav');
|
||||||
@@ -125,7 +125,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
|
|
||||||
{state.turnTimeRemaining !== undefined && (
|
{state.turnTimeRemaining !== undefined && (
|
||||||
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
|
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
|
||||||
⏳ {state.turnTimeRemaining}s
|
⏳ {state.turnTimeRemaining} s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.isPvP && (
|
||||||
|
<span
|
||||||
|
className={`danger-badge ${state.yourTurn ? 'danger-1' : 'danger-3'}`}
|
||||||
|
style={{ fontSize: '0.8rem', marginLeft: '0.5rem', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{state.yourTurn ? '🎯 ' + t('combat.your_turn') : '⏳ ' + t('combat.opponent_turn')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -169,7 +177,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Player HP (Right) */}
|
{/* Player HP (Right) */}
|
||||||
<div className={`stat-block player ${animState.playerAttacking ? 'attacking' : ''} ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
|
<div className={`stat-block player ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
|
||||||
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
|
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
|
||||||
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
|
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
|
||||||
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
|
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
|
||||||
@@ -202,7 +210,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="btn btn-attack"
|
className="btn btn-attack"
|
||||||
onClick={() => onAction('attack')}
|
onClick={() => onAction('attack')}
|
||||||
disabled={isProcessing || state.turn !== 'player'}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
>
|
>
|
||||||
👊 {t('combat.actions.attack')}
|
👊 {t('combat.actions.attack')}
|
||||||
</button>
|
</button>
|
||||||
@@ -210,7 +218,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="btn btn-flee"
|
className="btn btn-flee"
|
||||||
onClick={() => onAction('flee')}
|
onClick={() => onAction('flee')}
|
||||||
disabled={isProcessing || state.turn !== 'player'}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
>
|
>
|
||||||
🏃 {t('combat.actions.flee')}
|
🏃 {t('combat.actions.flee')}
|
||||||
</button>
|
</button>
|
||||||
@@ -230,7 +238,14 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'combat_start': text = t('combat.start'); break;
|
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':
|
case 'enemy_attack':
|
||||||
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
||||||
className += " text-danger";
|
className += " text-danger";
|
||||||
@@ -243,6 +258,14 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
case 'flee_fail': text = t('combat.flee.fail'); break;
|
case 'flee_fail': text = t('combat.flee.fail'); break;
|
||||||
case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); 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 '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;
|
case 'text': text = msg.data?.text || ""; break;
|
||||||
default: text = msg.type;
|
default: text = msg.type;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export interface GameEngineActions {
|
|||||||
// Interactions
|
// Interactions
|
||||||
handleInteract: (interactableId: string, actionId: string) => Promise<void>
|
handleInteract: (interactableId: string, actionId: string) => Promise<void>
|
||||||
handleViewCorpseDetails: (corpseId: string) => Promise<void>
|
handleViewCorpseDetails: (corpseId: string) => Promise<void>
|
||||||
|
handleCloseCorpseDetails: () => void
|
||||||
handleLootCorpse: (corpseId: string) => Promise<void>
|
handleLootCorpse: (corpseId: string) => Promise<void>
|
||||||
handleLootCorpseItem: (corpseId: string, itemIndex: number | null) => Promise<void>
|
handleLootCorpseItem: (corpseId: string, itemIndex: number | null) => Promise<void>
|
||||||
|
|
||||||
@@ -1046,6 +1047,10 @@ export function useGameEngine(
|
|||||||
handleFlee,
|
handleFlee,
|
||||||
handleInteract,
|
handleInteract,
|
||||||
handleViewCorpseDetails,
|
handleViewCorpseDetails,
|
||||||
|
handleCloseCorpseDetails: () => {
|
||||||
|
setExpandedCorpse(null)
|
||||||
|
setCorpseDetails(null)
|
||||||
|
},
|
||||||
handleLootCorpse,
|
handleLootCorpse,
|
||||||
handleLootCorpseItem,
|
handleLootCorpseItem,
|
||||||
handleSpendPoint,
|
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<typeof setInterval> | 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
|
// Initial data load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -1091,51 +1121,22 @@ export function useGameEngine(
|
|||||||
}
|
}
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
// WebSocket connection
|
// WebSocket Event Bus Listener
|
||||||
|
// Instead of maintaining a second connection, we listen to the global connection managed by GameHeader
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
const handleGameMessage = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent;
|
||||||
// Get WebSocket URL based on environment (same logic as api.ts)
|
if (customEvent.detail) {
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || (
|
_handleWebSocketMessage(customEvent.detail);
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
window.addEventListener('game-ws-message', handleGameMessage);
|
||||||
console.error('❌ WebSocket error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log('🔌 WebSocket disconnected')
|
|
||||||
setWebSocket(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
window.removeEventListener('game-ws-message', handleGameMessage);
|
||||||
ws.close()
|
};
|
||||||
}
|
}, [_handleWebSocketMessage]);
|
||||||
}
|
|
||||||
}, [token]) // Removed _handleWebSocketMessage from dependencies
|
|
||||||
|
|
||||||
return [state, actions]
|
return [state, actions]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export const useGameWebSocket = ({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
reconnectAttemptsRef.current = 0;
|
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)
|
// Start heartbeat interval (every 30 seconds)
|
||||||
heartbeatIntervalRef.current = setInterval(sendHeartbeat, 30000);
|
heartbeatIntervalRef.current = setInterval(sendHeartbeat, 30000);
|
||||||
};
|
};
|
||||||
@@ -87,6 +90,16 @@ export const useGameWebSocket = ({
|
|||||||
return;
|
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
|
// Pass message to handler
|
||||||
onMessage(message);
|
onMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -151,6 +151,9 @@
|
|||||||
"inCombat": "In Combat",
|
"inCombat": "In Combat",
|
||||||
"yourTurn": "Your Turn",
|
"yourTurn": "Your Turn",
|
||||||
"enemyTurn": "Enemy's Turn",
|
"enemyTurn": "Enemy's Turn",
|
||||||
|
"your_turn": "Your Turn",
|
||||||
|
"opponent_turn": "Waiting",
|
||||||
|
"turn_timeout": "Time ran out! Turn passed.",
|
||||||
"victory": "Victory!",
|
"victory": "Victory!",
|
||||||
"defeat": "Defeat",
|
"defeat": "Defeat",
|
||||||
"youDied": "You Died",
|
"youDied": "You Died",
|
||||||
|
|||||||
@@ -149,6 +149,9 @@
|
|||||||
"inCombat": "En Combate",
|
"inCombat": "En Combate",
|
||||||
"yourTurn": "Tu Turno",
|
"yourTurn": "Tu Turno",
|
||||||
"enemyTurn": "Turno del Enemigo",
|
"enemyTurn": "Turno del Enemigo",
|
||||||
|
"your_turn": "Tu Turno",
|
||||||
|
"opponent_turn": "Esperando",
|
||||||
|
"turn_timeout": "¡Se acabó el tiempo! Turno pasado.",
|
||||||
"victory": "¡Victoria!",
|
"victory": "¡Victoria!",
|
||||||
"defeat": "Derrota",
|
"defeat": "Derrota",
|
||||||
"youDied": "Has Muerto",
|
"youDied": "Has Muerto",
|
||||||
|
|||||||
Reference in New Issue
Block a user