Commit
This commit is contained in:
@@ -15,6 +15,7 @@ from api import database as db
|
||||
from data.npcs import (
|
||||
LOCATION_SPAWNS,
|
||||
LOCATION_DANGER,
|
||||
NPCS,
|
||||
get_random_npc_for_location,
|
||||
get_wandering_enemy_chance
|
||||
)
|
||||
@@ -51,10 +52,13 @@ def get_danger_level(location_id: str) -> int:
|
||||
# BACKGROUND TASK: WANDERING ENEMY SPAWNER
|
||||
# ============================================================================
|
||||
|
||||
async def spawn_manager_loop():
|
||||
async def spawn_manager_loop(manager=None):
|
||||
"""
|
||||
Main spawn manager loop.
|
||||
Runs continuously, checking spawn conditions every SPAWN_CHECK_INTERVAL seconds.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting spawn events
|
||||
"""
|
||||
logger.info("🎲 Spawn Manager started")
|
||||
|
||||
@@ -63,7 +67,26 @@ async def spawn_manager_loop():
|
||||
await asyncio.sleep(SPAWN_CHECK_INTERVAL)
|
||||
|
||||
# Clean up expired enemies first
|
||||
expired_enemies = await db.get_expired_wandering_enemies()
|
||||
despawned_count = await db.cleanup_expired_wandering_enemies()
|
||||
|
||||
# Notify players in locations where enemies despawned
|
||||
if manager and expired_enemies:
|
||||
from datetime import datetime
|
||||
for enemy in expired_enemies:
|
||||
await manager.send_to_location(
|
||||
location_id=enemy['location_id'],
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"A wandering enemy left the area",
|
||||
"action": "enemy_despawned",
|
||||
"enemy_id": enemy['id']
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
if despawned_count > 0:
|
||||
logger.info(f"🧹 Cleaned up {despawned_count} expired wandering enemies")
|
||||
|
||||
@@ -95,13 +118,43 @@ async def spawn_manager_loop():
|
||||
# Spawn an enemy
|
||||
npc_id = get_random_npc_for_location(location_id)
|
||||
if npc_id:
|
||||
await db.spawn_wandering_enemy(
|
||||
enemy_data = await db.spawn_wandering_enemy(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
lifetime_seconds=ENEMY_LIFETIME
|
||||
)
|
||||
|
||||
if not enemy_data:
|
||||
logger.error(f"Failed to spawn {npc_id} at {location_id}")
|
||||
continue
|
||||
|
||||
spawned_count += 1
|
||||
logger.info(f"👹 Spawned {npc_id} at {location_id} (current: {current_count + 1}/{max_enemies})")
|
||||
|
||||
# Notify players in this location
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
npc_def = NPCS.get(npc_id)
|
||||
npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
||||
await manager.send_to_location(
|
||||
location_id=location_id,
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"A {npc_name} appeared!",
|
||||
"action": "enemy_spawned",
|
||||
"npc_data": {
|
||||
"id": enemy_data['id'],
|
||||
"npc_id": npc_id,
|
||||
"name": npc_name,
|
||||
"type": "enemy",
|
||||
"is_wandering": True,
|
||||
"image_path": npc_def.image_path if npc_def else None
|
||||
}
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
if spawned_count > 0:
|
||||
logger.info(f"✨ Spawn cycle complete: {spawned_count} enemies spawned")
|
||||
@@ -116,8 +169,12 @@ async def spawn_manager_loop():
|
||||
# BACKGROUND TASK: DROPPED ITEM DECAY
|
||||
# ============================================================================
|
||||
|
||||
async def decay_dropped_items():
|
||||
"""Periodically cleans up old dropped items."""
|
||||
async def decay_dropped_items(manager=None):
|
||||
"""Periodically cleans up old dropped items.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting decay events
|
||||
"""
|
||||
logger.info("🗑️ Item Decay task started")
|
||||
|
||||
while True:
|
||||
@@ -130,8 +187,34 @@ async def decay_dropped_items():
|
||||
# Set decay time to 1 hour (3600 seconds)
|
||||
decay_seconds = 3600
|
||||
timestamp_limit = int(time.time()) - decay_seconds
|
||||
|
||||
# Get expired items before removal to notify locations
|
||||
expired_items = await db.get_expired_dropped_items(timestamp_limit)
|
||||
items_removed = await db.remove_expired_dropped_items(timestamp_limit)
|
||||
|
||||
# Group expired items by location
|
||||
if manager and expired_items:
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
items_by_location = defaultdict(int)
|
||||
|
||||
for item in expired_items:
|
||||
items_by_location[item['location_id']] += 1
|
||||
|
||||
# Notify each location
|
||||
for location_id, count in items_by_location.items():
|
||||
await manager.send_to_location(
|
||||
location_id=location_id,
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{count} dropped item(s) decayed",
|
||||
"action": "items_decayed"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if items_removed > 0:
|
||||
logger.info(f"Decayed and removed {items_removed} old items in {elapsed:.2f}s")
|
||||
@@ -145,8 +228,12 @@ async def decay_dropped_items():
|
||||
# BACKGROUND TASK: STAMINA REGENERATION
|
||||
# ============================================================================
|
||||
|
||||
async def regenerate_stamina():
|
||||
"""Periodically regenerates stamina for all players."""
|
||||
async def regenerate_stamina(manager=None):
|
||||
"""Periodically regenerates stamina for all players.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for notifying players
|
||||
"""
|
||||
logger.info("💪 Stamina Regeneration task started")
|
||||
|
||||
while True:
|
||||
@@ -156,11 +243,28 @@ async def regenerate_stamina():
|
||||
start_time = time.time()
|
||||
logger.info("Running stamina regeneration...")
|
||||
|
||||
players_updated = await db.regenerate_all_players_stamina()
|
||||
updated_players = await db.regenerate_all_players_stamina()
|
||||
|
||||
# Notify each player of their stamina regeneration
|
||||
if manager and updated_players:
|
||||
from datetime import datetime
|
||||
for player in updated_players:
|
||||
await manager.send_personal_message(
|
||||
player['id'],
|
||||
{
|
||||
"type": "stamina_update",
|
||||
"data": {
|
||||
"stamina": int(player['new_stamina']),
|
||||
"max_stamina": player['max_stamina'],
|
||||
"message": "Stamina regenerated"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if players_updated > 0:
|
||||
logger.info(f"Regenerated stamina for {players_updated} players in {elapsed:.2f}s")
|
||||
if updated_players:
|
||||
logger.info(f"Regenerated stamina for {len(updated_players)} players in {elapsed:.2f}s")
|
||||
|
||||
# Alert if regeneration is taking too long (potential scaling issue)
|
||||
if elapsed > 5.0:
|
||||
@@ -193,17 +297,31 @@ async def check_combat_timers():
|
||||
|
||||
for combat in idle_combats:
|
||||
try:
|
||||
# Import combat logic from API
|
||||
from api import game_logic
|
||||
# Only process if it's player's turn (don't double-process)
|
||||
if combat['turn'] != 'player':
|
||||
continue
|
||||
|
||||
# Force end player's turn and let NPC attack
|
||||
if combat['turn'] == 'player':
|
||||
await db.update_combat(combat['player_id'], {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
# NPC attacks
|
||||
await game_logic.npc_attack(combat['player_id'])
|
||||
# Import required modules
|
||||
from api import game_logic
|
||||
from data.npcs import NPCS
|
||||
|
||||
# Get NPC definition
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
if not npc_def:
|
||||
logger.warning(f"NPC definition not found: {combat['npc_id']}")
|
||||
continue
|
||||
|
||||
# Import reduce_armor_durability from equipment router
|
||||
from .routers.equipment import reduce_armor_durability
|
||||
|
||||
# NPC attacks due to timeout
|
||||
logger.info(f"Player {combat['character_id']} combat timed out, NPC attacking...")
|
||||
await game_logic.npc_attack(
|
||||
combat['character_id'],
|
||||
combat,
|
||||
npc_def,
|
||||
reduce_armor_durability
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing idle combat: {e}")
|
||||
|
||||
@@ -221,12 +339,96 @@ async def check_combat_timers():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BACKGROUND TASK: INTERACTABLE COOLDOWN CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def cleanup_interactable_cooldowns(manager=None, world_locations=None):
|
||||
"""
|
||||
Cleans up expired interactable cooldowns and notifies players.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting cooldown expiry
|
||||
world_locations: Dict of Location objects to map instance_id to location_id
|
||||
"""
|
||||
logger.info("⏳ Interactable Cooldown Cleanup task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
|
||||
# Get expired cooldowns before removal
|
||||
expired_cooldowns = await db.get_expired_interactable_cooldowns()
|
||||
removed_count = await db.remove_expired_interactable_cooldowns()
|
||||
|
||||
# Notify players in locations where cooldowns expired
|
||||
if manager and expired_cooldowns and world_locations:
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
# Map instance_id:action_id to location_id
|
||||
cooldowns_by_location = defaultdict(list)
|
||||
|
||||
for cooldown in expired_cooldowns:
|
||||
instance_id = cooldown['instance_id']
|
||||
action_id = cooldown['action_id']
|
||||
|
||||
# Find which location has this interactable
|
||||
for loc_id, location in world_locations.items():
|
||||
for interactable in location.interactables:
|
||||
if interactable.id == instance_id:
|
||||
# Find action name
|
||||
action_name = action_id
|
||||
for action in interactable.actions:
|
||||
if action.id == action_id:
|
||||
action_name = action.label
|
||||
break
|
||||
|
||||
cooldowns_by_location[loc_id].append({
|
||||
'instance_id': instance_id,
|
||||
'action_id': action_id,
|
||||
'name': interactable.name,
|
||||
'action_name': action_name
|
||||
})
|
||||
break
|
||||
|
||||
# Notify each location (only if players are there)
|
||||
for location_id, cooldowns in cooldowns_by_location.items():
|
||||
if not manager.has_players_in_location(location_id):
|
||||
continue # Skip if no active players
|
||||
|
||||
for cooldown_info in cooldowns:
|
||||
await manager.send_to_location(
|
||||
location_id=location_id,
|
||||
message={
|
||||
"type": "interactable_ready",
|
||||
"data": {
|
||||
"instance_id": cooldown_info['instance_id'],
|
||||
"action_id": cooldown_info['action_id'],
|
||||
"message": f"{cooldown_info['action_name']} is ready on {cooldown_info['name']}"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
if removed_count > 0:
|
||||
logger.info(f"🧹 Cleaned up {removed_count} expired interactable cooldowns")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in interactable cooldown cleanup: {e}", exc_info=True)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BACKGROUND TASK: CORPSE DECAY
|
||||
# ============================================================================
|
||||
|
||||
async def decay_corpses():
|
||||
"""Removes old corpses."""
|
||||
async def decay_corpses(manager=None):
|
||||
"""Removes old corpses.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting decay events
|
||||
"""
|
||||
logger.info("💀 Corpse Decay task started")
|
||||
|
||||
while True:
|
||||
@@ -238,12 +440,44 @@ async def decay_corpses():
|
||||
|
||||
# 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)
|
||||
player_corpses_removed = await db.remove_expired_player_corpses(player_corpse_limit)
|
||||
|
||||
# NPC corpses decay after 2 hours
|
||||
npc_corpse_limit = time.time() - (2 * 3600)
|
||||
expired_npc_corpses = await db.get_expired_npc_corpses(npc_corpse_limit)
|
||||
npc_corpses_removed = await db.remove_expired_npc_corpses(npc_corpse_limit)
|
||||
|
||||
# Notify players in locations where corpses decayed
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
# Group corpses by location
|
||||
corpses_by_location = defaultdict(lambda: {"player": 0, "npc": 0})
|
||||
|
||||
for corpse in expired_player_corpses:
|
||||
corpses_by_location[corpse['location_id']]["player"] += 1
|
||||
|
||||
for corpse in expired_npc_corpses:
|
||||
corpses_by_location[corpse['location_id']]["npc"] += 1
|
||||
|
||||
# Notify each location
|
||||
for location_id, counts in corpses_by_location.items():
|
||||
total = counts["player"] + counts["npc"]
|
||||
corpse_type = "corpse" if total == 1 else "corpses"
|
||||
await manager.send_to_location(
|
||||
location_id=location_id,
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{total} {corpse_type} decayed",
|
||||
"action": "corpses_decayed"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -257,10 +491,13 @@ async def decay_corpses():
|
||||
# BACKGROUND TASK: STATUS EFFECTS PROCESSOR
|
||||
# ============================================================================
|
||||
|
||||
async def process_status_effects():
|
||||
async def process_status_effects(manager=None):
|
||||
"""
|
||||
Applies damage from persistent status effects.
|
||||
Runs every 5 minutes to process status effect ticks.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for notifying players
|
||||
"""
|
||||
logger.info("🩸 Status Effects Processor started")
|
||||
|
||||
@@ -321,10 +558,42 @@ async def process_status_effects():
|
||||
# Remove status effects from dead player
|
||||
await db.remove_all_status_effects(player_id)
|
||||
|
||||
# Notify player of death
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
"type": "player_died",
|
||||
"data": {
|
||||
"hp": 0,
|
||||
"is_dead": True,
|
||||
"message": "You died from status effects"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects")
|
||||
else:
|
||||
# Apply damage
|
||||
# Apply damage and notify player
|
||||
await db.update_player(player_id, {'hp': new_hp})
|
||||
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
"type": "status_effect_damage",
|
||||
"data": {
|
||||
"hp": new_hp,
|
||||
"max_hp": player['max_hp'],
|
||||
"damage": total_damage,
|
||||
"message": f"You took {total_damage} damage from status effects"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing status effects for player {player_id}: {e}")
|
||||
@@ -398,11 +667,15 @@ def release_background_tasks_lock():
|
||||
_lock_file_handle = None
|
||||
|
||||
|
||||
async def start_background_tasks():
|
||||
async def start_background_tasks(manager=None, world_locations=None):
|
||||
"""
|
||||
Start all background tasks.
|
||||
Called when the API starts up.
|
||||
Only runs in ONE worker (the first one to acquire the lock).
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting events
|
||||
world_locations: Dict of Location objects for interactable mapping
|
||||
"""
|
||||
# Try to acquire lock - only one worker will succeed
|
||||
if not acquire_background_tasks_lock():
|
||||
@@ -413,12 +686,13 @@ async def start_background_tasks():
|
||||
|
||||
# Create tasks for all background jobs
|
||||
tasks = [
|
||||
asyncio.create_task(spawn_manager_loop()),
|
||||
asyncio.create_task(decay_dropped_items()),
|
||||
asyncio.create_task(regenerate_stamina()),
|
||||
asyncio.create_task(spawn_manager_loop(manager)),
|
||||
asyncio.create_task(decay_dropped_items(manager)),
|
||||
asyncio.create_task(regenerate_stamina(manager)),
|
||||
asyncio.create_task(check_combat_timers()),
|
||||
asyncio.create_task(decay_corpses()),
|
||||
asyncio.create_task(process_status_effects()),
|
||||
asyncio.create_task(decay_corpses(manager)),
|
||||
asyncio.create_task(process_status_effects(manager)),
|
||||
# Note: Interactable cooldowns are handled client-side with server validation
|
||||
]
|
||||
|
||||
logger.info(f"✅ Started {len(tasks)} background tasks")
|
||||
|
||||
Reference in New Issue
Block a user