This commit is contained in:
Joan
2025-11-27 16:27:01 +01:00
parent 33cc9586c2
commit 81f8912059
304 changed files with 56149 additions and 10122 deletions

View File

@@ -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")