Pre-combat-improvements: Combat animations, flee fixes, corpse logic updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
106
api/database.py
106
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
|
||||
|
||||
12
api/main.py
12
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:
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
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_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}"},
|
||||
|
||||
Reference in New Issue
Block a user