Commit
This commit is contained in:
89
api/analyze_endpoints.py
Normal file
89
api/analyze_endpoints.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Complete migration script - Extracts all endpoints from main.py to routers
|
||||
This preserves all functionality while creating a clean modular structure
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
|
||||
def read_file(path):
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
def extract_section(content, start_marker, end_marker):
|
||||
"""Extract a section between two markers"""
|
||||
start = content.find(start_marker)
|
||||
if start == -1:
|
||||
return None
|
||||
end = content.find(end_marker, start)
|
||||
if end == -1:
|
||||
end = len(content)
|
||||
return content[start:end]
|
||||
|
||||
# Read original main.py
|
||||
main_content = read_file('main.py')
|
||||
|
||||
# Find all endpoint definitions
|
||||
endpoint_pattern = r'@app\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']\)'
|
||||
endpoints = re.findall(endpoint_pattern, main_content)
|
||||
|
||||
print(f"Found {len(endpoints)} endpoints in main.py:")
|
||||
for method, path in endpoints[:20]: # Show first 20
|
||||
print(f" {method.upper():6} {path}")
|
||||
|
||||
if len(endpoints) > 20:
|
||||
print(f" ... and {len(endpoints) - 20} more")
|
||||
|
||||
# Group endpoints by category
|
||||
categories = {
|
||||
'auth': [],
|
||||
'characters': [],
|
||||
'game': [],
|
||||
'combat': [],
|
||||
'equipment': [],
|
||||
'crafting': [],
|
||||
'loot': [],
|
||||
'admin': [],
|
||||
'statistics': [],
|
||||
'health': []
|
||||
}
|
||||
|
||||
for method, path in endpoints:
|
||||
if '/api/auth/' in path:
|
||||
categories['auth'].append((method, path))
|
||||
elif '/api/characters' in path:
|
||||
categories['characters'].append((method, path))
|
||||
elif '/api/game/combat' in path or '/api/game/pvp' in path:
|
||||
categories['combat'].append((method, path))
|
||||
elif '/api/game/equip' in path or '/api/game/unequip' in path or '/api/game/equipment' in path or '/api/game/repair' in path or '/api/game/repairable' in path or '/api/game/salvageable' in path:
|
||||
categories['equipment'].append((method, path))
|
||||
elif '/api/game/craft' in path or '/api/game/uncraft' in path or '/api/game/craftable' in path:
|
||||
categories['crafting'].append((method, path))
|
||||
elif '/api/game/corpse' in path or '/api/game/loot' in path:
|
||||
categories['loot'].append((method, path))
|
||||
elif '/api/internal/' in path:
|
||||
categories['admin'].append((method, path))
|
||||
elif '/api/statistics' in path or '/api/leaderboard' in path:
|
||||
categories['statistics'].append((method, path))
|
||||
elif '/health' in path:
|
||||
categories['health'].append((method, path))
|
||||
elif '/api/game/' in path:
|
||||
categories['game'].append((method, path))
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Endpoint Distribution:")
|
||||
for cat, endpoints_list in categories.items():
|
||||
if endpoints_list:
|
||||
print(f" {cat:15}: {len(endpoints_list):2} endpoints")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("\nNext steps:")
|
||||
print("1. ✅ Auth router - already created")
|
||||
print("2. ✅ Characters router - already created")
|
||||
print("3. ⏳ Game routes router - needs creation (largest)")
|
||||
print("4. ⏳ Combat router - needs creation")
|
||||
print("5. ⏳ Equipment router - needs creation")
|
||||
print("6. ⏳ Crafting router - needs creation")
|
||||
print("7. ⏳ Loot router - needs creation")
|
||||
print("8. ⏳ Admin router - needs creation")
|
||||
print("9. ⏳ Statistics router - needs creation")
|
||||
print("10. ⏳ Clean main.py - after all routers created")
|
||||
@@ -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")
|
||||
|
||||
0
api/core/__init__.py
Normal file
0
api/core/__init__.py
Normal file
32
api/core/config.py
Normal file
32
api/core/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Configuration module for the API.
|
||||
All environment variables and constants are defined here.
|
||||
"""
|
||||
import os
|
||||
|
||||
# JWT Configuration
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production-please")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Internal API Key (for bot communication)
|
||||
API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "change-this-internal-key")
|
||||
|
||||
# CORS Origins
|
||||
CORS_ORIGINS = [
|
||||
"https://staging.echoesoftheash.com",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173"
|
||||
]
|
||||
|
||||
# Database Configuration (imported from database module)
|
||||
# DB settings are in database.py since they're tightly coupled with SQLAlchemy
|
||||
|
||||
# Image Directory
|
||||
from pathlib import Path
|
||||
IMAGES_DIR = Path(__file__).parent.parent.parent / "images"
|
||||
|
||||
# Game Constants
|
||||
MOVEMENT_COOLDOWN = 5 # seconds
|
||||
BASE_CARRYING_CAPACITY = 10.0 # kg
|
||||
BASE_VOLUME_CAPACITY = 10.0 # liters
|
||||
127
api/core/security.py
Normal file
127
api/core/security.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Security module for authentication and authorization.
|
||||
Handles JWT tokens, password hashing, and auth dependencies.
|
||||
"""
|
||||
import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, API_INTERNAL_KEY
|
||||
from .. import database as db
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
"""Create a JWT access token"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode JWT token and return payload"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired"
|
||||
)
|
||||
except (jwt.InvalidTokenError, jwt.DecodeError, Exception):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt"""
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify JWT token and return current character (requires character selection).
|
||||
This is the main auth dependency for protected endpoints.
|
||||
"""
|
||||
try:
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
|
||||
# New system: account_id + character_id
|
||||
account_id = payload.get("account_id")
|
||||
if account_id is not None:
|
||||
character_id = payload.get("character_id")
|
||||
if character_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No character selected. Please select a character first."
|
||||
)
|
||||
|
||||
player = await db.get_player_by_id(character_id)
|
||||
if player is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Character not found"
|
||||
)
|
||||
|
||||
# Verify character belongs to account
|
||||
if player.get('account_id') != account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Character does not belong to this account"
|
||||
)
|
||||
|
||||
return player
|
||||
|
||||
# Old system fallback: player_id (for backward compatibility)
|
||||
player_id = payload.get("player_id")
|
||||
if player_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token: no player or character ID"
|
||||
)
|
||||
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if player is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Player not found"
|
||||
)
|
||||
|
||||
return player
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired"
|
||||
)
|
||||
except (jwt.InvalidTokenError, jwt.DecodeError, Exception) as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
|
||||
async def verify_internal_key(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify internal API key for bot endpoints"""
|
||||
if credentials.credentials != API_INTERNAL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid internal API key"
|
||||
)
|
||||
return True
|
||||
209
api/core/websockets.py
Normal file
209
api/core/websockets.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
WebSocket connection manager for real-time game updates.
|
||||
Handles WebSocket connections and Redis pub/sub for cross-worker communication.
|
||||
"""
|
||||
from typing import Dict, Optional, List
|
||||
from fastapi import WebSocket
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
Manages WebSocket connections for real-time game updates.
|
||||
Tracks active connections and provides methods for broadcasting messages.
|
||||
Uses Redis pub/sub for cross-worker communication.
|
||||
"""
|
||||
def __init__(self):
|
||||
# Maps player_id -> List of WebSocket connections (local to this worker only)
|
||||
self.active_connections: Dict[int, List[WebSocket]] = {}
|
||||
# Maps player_id -> username for debugging
|
||||
self.player_usernames: Dict[int, str] = {}
|
||||
# Redis manager instance (injected later)
|
||||
self.redis_manager = None
|
||||
|
||||
def set_redis_manager(self, redis_manager):
|
||||
"""Inject Redis manager after initialization."""
|
||||
self.redis_manager = redis_manager
|
||||
|
||||
async def connect(self, websocket: WebSocket, player_id: int, username: str):
|
||||
"""Accept a new WebSocket connection and track it."""
|
||||
await websocket.accept()
|
||||
|
||||
if player_id not in self.active_connections:
|
||||
self.active_connections[player_id] = []
|
||||
|
||||
self.active_connections[player_id].append(websocket)
|
||||
self.player_usernames[player_id] = username
|
||||
|
||||
# Subscribe to player's personal channel (only if first connection)
|
||||
if len(self.active_connections[player_id]) == 1 and self.redis_manager:
|
||||
await self.redis_manager.subscribe_to_channels([f"player:{player_id}"])
|
||||
await self.redis_manager.mark_player_connected(player_id)
|
||||
|
||||
logger.info(f"WebSocket connected: {username} (player_id={player_id}, worker={self.redis_manager.worker_id if self.redis_manager else 'N/A'})")
|
||||
|
||||
async def disconnect(self, player_id: int, websocket: WebSocket):
|
||||
"""Remove a WebSocket connection."""
|
||||
if player_id in self.active_connections:
|
||||
username = self.player_usernames.get(player_id, "unknown")
|
||||
|
||||
if websocket in self.active_connections[player_id]:
|
||||
self.active_connections[player_id].remove(websocket)
|
||||
|
||||
# If no more connections for this player, cleanup
|
||||
if not self.active_connections[player_id]:
|
||||
del self.active_connections[player_id]
|
||||
if player_id in self.player_usernames:
|
||||
del self.player_usernames[player_id]
|
||||
|
||||
# Unsubscribe from player's personal channel
|
||||
if self.redis_manager:
|
||||
await self.redis_manager.unsubscribe_from_channel(f"player:{player_id}")
|
||||
await self.redis_manager.mark_player_disconnected(player_id)
|
||||
|
||||
logger.info(f"All WebSockets disconnected: {username} (player_id={player_id})")
|
||||
else:
|
||||
logger.info(f"WebSocket disconnected: {username} (player_id={player_id}). Remaining connections: {len(self.active_connections[player_id])}")
|
||||
|
||||
async def send_personal_message(self, player_id: int, message: dict):
|
||||
"""Send a message to a specific player via Redis pub/sub."""
|
||||
if self.redis_manager:
|
||||
# Send locally first if player is connected to this worker
|
||||
if player_id in self.active_connections:
|
||||
await self._send_direct(player_id, message)
|
||||
else:
|
||||
# Publish to Redis (player might be on another worker)
|
||||
await self.redis_manager.publish_to_player(player_id, message)
|
||||
else:
|
||||
# Fallback to direct send (single worker mode)
|
||||
await self._send_direct(player_id, message)
|
||||
|
||||
async def _send_direct(self, player_id: int, message: dict):
|
||||
"""Directly send to local WebSocket connections."""
|
||||
if player_id in self.active_connections:
|
||||
connections = self.active_connections[player_id]
|
||||
disconnected_sockets = []
|
||||
|
||||
for websocket in connections:
|
||||
try:
|
||||
logger.debug(f"Sending {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}")
|
||||
disconnected_sockets.append(websocket)
|
||||
|
||||
# Cleanup failed sockets
|
||||
for ws in disconnected_sockets:
|
||||
await self.disconnect(player_id, ws)
|
||||
|
||||
async def broadcast(self, message: dict, exclude_player_id: Optional[int] = None):
|
||||
"""Broadcast a message to all connected players via Redis."""
|
||||
if self.redis_manager:
|
||||
await self.redis_manager.publish_global_broadcast(message)
|
||||
|
||||
# ALSO send to LOCAL connections immediately
|
||||
for player_id in list(self.active_connections.keys()):
|
||||
if player_id != exclude_player_id:
|
||||
await self._send_direct(player_id, message)
|
||||
else:
|
||||
# Fallback: direct broadcast to local connections
|
||||
for player_id in list(self.active_connections.keys()):
|
||||
if player_id != exclude_player_id:
|
||||
await self._send_direct(player_id, message)
|
||||
|
||||
async def send_to_location(self, location_id: str, message: dict, exclude_player_id: Optional[int] = None):
|
||||
"""Send a message to all players in a specific location via Redis pub/sub."""
|
||||
if self.redis_manager:
|
||||
# Use Redis pub/sub for cross-worker broadcast
|
||||
message_with_exclude = {
|
||||
**message,
|
||||
"exclude_player_id": exclude_player_id
|
||||
}
|
||||
await self.redis_manager.publish_to_location(location_id, message_with_exclude)
|
||||
|
||||
# ALSO send to LOCAL connections immediately (don't wait for Redis roundtrip)
|
||||
player_ids = await self.redis_manager.get_players_in_location(location_id)
|
||||
for player_id in player_ids:
|
||||
if player_id == exclude_player_id:
|
||||
continue
|
||||
if player_id in self.active_connections:
|
||||
await self._send_direct(player_id, message)
|
||||
else:
|
||||
# Fallback: Query DB and send directly (single worker mode)
|
||||
from .. import database as db
|
||||
players_in_location = await db.get_players_in_location(location_id)
|
||||
|
||||
active_players = [p for p in players_in_location if p['id'] in self.active_connections and p['id'] != exclude_player_id]
|
||||
if not active_players:
|
||||
return
|
||||
|
||||
logger.info(f"Broadcasting to location {location_id}: {message.get('type')} (excluding player {exclude_player_id})")
|
||||
|
||||
sent_count = 0
|
||||
for player in active_players:
|
||||
player_id = player['id']
|
||||
await self._send_direct(player_id, message)
|
||||
sent_count += 1
|
||||
|
||||
logger.info(f"Sent {message.get('type')} to {sent_count} players")
|
||||
|
||||
async def handle_redis_message(self, channel: str, data: dict):
|
||||
"""
|
||||
Handle incoming Redis pub/sub messages and route to local WebSocket connections.
|
||||
This method is called by RedisManager when a message arrives on a subscribed channel.
|
||||
"""
|
||||
try:
|
||||
# Extract message type and data
|
||||
message = {
|
||||
"type": data.get("type"),
|
||||
"data": data.get("data")
|
||||
}
|
||||
|
||||
# Determine routing based on channel type
|
||||
if channel.startswith("player:"):
|
||||
# Personal message to specific player
|
||||
player_id = int(channel.split(":")[1])
|
||||
if player_id in self.active_connections:
|
||||
await self._send_direct(player_id, message)
|
||||
|
||||
elif channel.startswith("location:"):
|
||||
# Broadcast to all players in location (only local connections)
|
||||
location_id = channel.split(":")[1]
|
||||
exclude_player_id = data.get("exclude_player_id")
|
||||
|
||||
# Get players from Redis location registry
|
||||
if self.redis_manager:
|
||||
player_ids = await self.redis_manager.get_players_in_location(location_id)
|
||||
|
||||
for player_id in player_ids:
|
||||
if player_id == exclude_player_id:
|
||||
continue
|
||||
|
||||
# Only send if this worker has the connection
|
||||
if player_id in self.active_connections:
|
||||
await self._send_direct(player_id, message)
|
||||
|
||||
elif channel == "game:broadcast":
|
||||
# Global broadcast to all local connections
|
||||
exclude_player_id = data.get("exclude_player_id")
|
||||
|
||||
for player_id in list(self.active_connections.keys()):
|
||||
if player_id != exclude_player_id:
|
||||
await self._send_direct(player_id, message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling Redis message on channel {channel}: {e}")
|
||||
|
||||
def has_players_in_location(self, location_id: str) -> bool:
|
||||
"""Check if there are any players with active connections in a specific location."""
|
||||
return len(self.active_connections) > 0
|
||||
|
||||
def get_connected_count(self) -> int:
|
||||
"""Get the number of active WebSocket connections."""
|
||||
return len(self.active_connections)
|
||||
|
||||
|
||||
# Global connection manager instance
|
||||
manager = ConnectionManager()
|
||||
802
api/database.py
802
api/database.py
File diff suppressed because it is too large
Load Diff
@@ -33,15 +33,12 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
|
||||
if not new_location:
|
||||
return False, "Destination not found", None, 0, 0
|
||||
|
||||
# Calculate total weight
|
||||
# Calculate total weight and capacity
|
||||
from api.items import items_manager as ITEMS_MANAGER
|
||||
from api.services.helpers import calculate_player_capacity
|
||||
|
||||
inventory = await db.get_inventory(player_id)
|
||||
total_weight = 0.0
|
||||
for inv_item in inventory:
|
||||
item = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if item:
|
||||
total_weight += item.weight * inv_item['quantity']
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
# Calculate distance between locations (1 coordinate unit = 100 meters)
|
||||
import math
|
||||
@@ -53,9 +50,19 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
|
||||
|
||||
# Calculate stamina cost: base from distance, adjusted by weight and agility
|
||||
base_cost = max(1, round(distance / 50)) # 50m = 1 stamina
|
||||
weight_penalty = int(total_weight / 10)
|
||||
weight_penalty = int(current_weight / 10)
|
||||
agility_reduction = int(player.get('agility', 5) / 3)
|
||||
stamina_cost = max(1, base_cost + weight_penalty - agility_reduction)
|
||||
|
||||
# Add over-capacity penalty (50% extra stamina cost if over limit)
|
||||
over_capacity_penalty = 0
|
||||
if current_weight > max_weight or current_volume > max_volume:
|
||||
weight_excess_ratio = max(0, (current_weight - max_weight) / max_weight) if max_weight > 0 else 0
|
||||
volume_excess_ratio = max(0, (current_volume - max_volume) / max_volume) if max_volume > 0 else 0
|
||||
excess_ratio = max(weight_excess_ratio, volume_excess_ratio)
|
||||
# Penalty scales from 50% to 200% based on how much over capacity
|
||||
over_capacity_penalty = int((base_cost + weight_penalty) * (0.5 + min(1.5, excess_ratio)))
|
||||
|
||||
stamina_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction)
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
@@ -130,10 +137,10 @@ async def interact_with_object(
|
||||
if not player:
|
||||
return {"success": False, "message": "Player not found"}
|
||||
|
||||
# Find the interactable
|
||||
# Find the interactable (match by id or instance_id)
|
||||
interactable = None
|
||||
for obj in location.interactables:
|
||||
if obj.id == interactable_id:
|
||||
if obj.id == interactable_id or (hasattr(obj, 'instance_id') and obj.instance_id == interactable_id):
|
||||
interactable = obj
|
||||
break
|
||||
|
||||
@@ -157,13 +164,13 @@ async def interact_with_object(
|
||||
"message": f"Not enough stamina. Need {action.stamina_cost}, have {player['stamina']}."
|
||||
}
|
||||
|
||||
# Check cooldown
|
||||
cooldown_expiry = await db.get_interactable_cooldown(interactable_id)
|
||||
# Check cooldown for this specific action
|
||||
cooldown_expiry = await db.get_interactable_cooldown(interactable_id, action_id)
|
||||
if cooldown_expiry:
|
||||
remaining = int(cooldown_expiry - time.time())
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"This object is still recovering. Wait {remaining} seconds."
|
||||
"message": f"This action is still on cooldown. Wait {remaining} seconds."
|
||||
}
|
||||
|
||||
# Deduct stamina
|
||||
@@ -198,8 +205,10 @@ async def interact_with_object(
|
||||
damage_taken = outcome.damage_taken
|
||||
|
||||
# Calculate current capacity
|
||||
from api.main import calculate_player_capacity
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(player_id)
|
||||
from api.services.helpers import calculate_player_capacity
|
||||
from api.items import items_manager as ITEMS_MANAGER
|
||||
inventory = await db.get_inventory(player_id)
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
# Add items to inventory (or drop if over capacity)
|
||||
for item_id, quantity in outcome.items_reward.items():
|
||||
@@ -233,11 +242,14 @@ async def interact_with_object(
|
||||
current_volume += item.volume
|
||||
else:
|
||||
# Create unique_item and drop to ground
|
||||
# Save base stats to unique_stats
|
||||
base_stats = {k: int(v) if isinstance(v, (int, float)) else v for k, v in item.stats.items()} if item.stats else {}
|
||||
unique_item_id = await db.create_unique_item(
|
||||
item_id=item_id,
|
||||
durability=item.durability,
|
||||
max_durability=item.durability,
|
||||
tier=getattr(item, 'tier', None)
|
||||
tier=getattr(item, 'tier', None),
|
||||
unique_stats=base_stats
|
||||
)
|
||||
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
||||
items_dropped.append(f"{emoji} {item_name}")
|
||||
@@ -267,8 +279,8 @@ async def interact_with_object(
|
||||
if new_hp <= 0:
|
||||
await db.update_player(player_id, is_dead=True)
|
||||
|
||||
# Set cooldown (60 seconds default)
|
||||
await db.set_interactable_cooldown(interactable_id, 60)
|
||||
# Set cooldown for this specific action (60 seconds default)
|
||||
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
||||
|
||||
# Build message
|
||||
final_message = outcome.text
|
||||
@@ -391,25 +403,12 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
|
||||
pickup_qty = quantity
|
||||
|
||||
# Get player and calculate capacity
|
||||
from api.services.helpers import calculate_player_capacity
|
||||
player = await db.get_player_by_id(player_id)
|
||||
inventory = await db.get_inventory(player_id)
|
||||
|
||||
# Calculate current weight and volume (including equipped bag capacity)
|
||||
current_weight = 0.0
|
||||
current_volume = 0.0
|
||||
max_weight = 10.0 # Base capacity
|
||||
max_volume = 10.0 # Base capacity
|
||||
|
||||
for inv_item in inventory:
|
||||
inv_item_def = items_manager.get_item(inv_item['item_id']) if items_manager else None
|
||||
if inv_item_def:
|
||||
current_weight += inv_item_def.weight * inv_item['quantity']
|
||||
current_volume += inv_item_def.volume * inv_item['quantity']
|
||||
|
||||
# Check for equipped bags/containers that increase capacity
|
||||
if inv_item['is_equipped'] and inv_item_def.stats:
|
||||
max_weight += inv_item_def.stats.get('weight_capacity', 0)
|
||||
max_volume += inv_item_def.stats.get('volume_capacity', 0)
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager)
|
||||
|
||||
# Calculate weight and volume for items to pick up
|
||||
item_weight = item_def.weight * pickup_qty
|
||||
@@ -504,3 +503,146 @@ def calculate_status_damage(effects: list) -> int:
|
||||
Total damage per tick
|
||||
"""
|
||||
return sum(effect.get('damage_per_tick', 0) for effect in effects)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMBAT UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
return message, player_defeated
|
||||
|
||||
|
||||
def generate_npc_intent(npc_def, combat_state: dict) -> dict:
|
||||
"""
|
||||
Generate the NEXT intent for an NPC.
|
||||
Returns a dict with intent type and details.
|
||||
"""
|
||||
# Default intent is attack
|
||||
intent = {"type": "attack", "value": 0}
|
||||
|
||||
# Logic could be more complex based on NPC type, HP, etc.
|
||||
roll = random.random()
|
||||
|
||||
# 20% chance to defend if HP < 50%
|
||||
if (combat_state['npc_hp'] / combat_state['npc_max_hp'] < 0.5) and roll < 0.2:
|
||||
intent = {"type": "defend", "value": 0}
|
||||
# 15% chance for special attack (if defined, otherwise strong attack)
|
||||
elif roll < 0.35:
|
||||
intent = {"type": "special", "value": 0}
|
||||
else:
|
||||
intent = {"type": "attack", "value": 0}
|
||||
|
||||
return intent
|
||||
|
||||
|
||||
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[str, bool]:
|
||||
"""
|
||||
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
|
||||
"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
return "Player not found", True
|
||||
|
||||
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
|
||||
# For now, let's assume simple string "attack", "defend", "special" stored in npc_intent
|
||||
# If we want more complex data, we should use JSON, but the migration added VARCHAR.
|
||||
# Let's stick to simple string for the column, but we can store "type:value" if needed.
|
||||
|
||||
current_intent_str = combat.get('npc_intent', 'attack')
|
||||
# Handle legacy/null
|
||||
if not current_intent_str:
|
||||
current_intent_str = 'attack'
|
||||
|
||||
intent_type = current_intent_str
|
||||
|
||||
message = ""
|
||||
actual_damage = 0
|
||||
|
||||
# EXECUTE INTENT
|
||||
if intent_type == 'defend':
|
||||
# NPC defends - maybe heals or takes less damage next turn?
|
||||
# For simplicity: Heals 5% HP
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
message = f"{npc_def.name} defends and recovers {heal_amount} HP!"
|
||||
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
||||
if armor_absorbed > 0:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
else: # Default 'attack'
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
message = f"{npc_def.name} is ENRAGED! "
|
||||
else:
|
||||
message = ""
|
||||
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
message += f"{npc_def.name} attacks for {npc_damage} damage!"
|
||||
if armor_absorbed > 0:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
# GENERATE NEXT INTENT
|
||||
# We need to update the combat state with the new HP values first to make good decisions
|
||||
# But we can just use the values we calculated.
|
||||
|
||||
# Check if player defeated
|
||||
player_defeated = False
|
||||
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
|
||||
# Re-fetch to be sure or just trust calculation
|
||||
if new_player_hp <= 0:
|
||||
message += "\nYou have been defeated!"
|
||||
player_defeated = True
|
||||
await db.update_player(player_id, hp=0, is_dead=True)
|
||||
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
||||
await db.end_combat(player_id)
|
||||
return message, player_defeated
|
||||
|
||||
if not player_defeated:
|
||||
if actual_damage > 0:
|
||||
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
|
||||
|
||||
# Generate NEXT intent
|
||||
# We need the updated NPC HP for the logic
|
||||
current_npc_hp = combat['npc_hp']
|
||||
if intent_type == 'defend':
|
||||
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
|
||||
|
||||
temp_combat_state = combat.copy()
|
||||
temp_combat_state['npc_hp'] = current_npc_hp
|
||||
|
||||
next_intent = generate_npc_intent(npc_def, temp_combat_state)
|
||||
|
||||
# Update combat with new intent and turn
|
||||
await db.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_intent': next_intent['type']
|
||||
})
|
||||
|
||||
return message, player_defeated
|
||||
|
||||
169
api/generate_routers.py
Normal file
169
api/generate_routers.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated endpoint extraction and router generation script.
|
||||
This script reads main.py and generates complete router files.
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def extract_endpoint_function(content, endpoint_decorator):
|
||||
"""
|
||||
Extract the complete function code for an endpoint.
|
||||
Finds the decorator and extracts everything until the next @app decorator or end of file.
|
||||
"""
|
||||
# Find the decorator position
|
||||
start = content.find(endpoint_decorator)
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
# Find the next @app decorator or end of imports section
|
||||
next_endpoint = content.find('\n@app.', start + len(endpoint_decorator))
|
||||
next_section = content.find('\n# ===', start + len(endpoint_decorator))
|
||||
|
||||
# Use whichever comes first
|
||||
if next_endpoint == -1 and next_section == -1:
|
||||
end = len(content)
|
||||
elif next_endpoint == -1:
|
||||
end = next_section
|
||||
elif next_section == -1:
|
||||
end = next_endpoint
|
||||
else:
|
||||
end = min(next_endpoint, next_section)
|
||||
|
||||
return content[start:end].strip()
|
||||
|
||||
def generate_router_file(router_name, endpoints, has_models=False):
|
||||
"""Generate a complete router file with all endpoints"""
|
||||
|
||||
# Base imports
|
||||
imports = f'''"""
|
||||
{router_name.replace('_', ' ').title()} router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
from ..core.websockets import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These will be injected by main.py
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
|
||||
router = APIRouter(tags=["{router_name}"])
|
||||
|
||||
'''
|
||||
|
||||
# Add endpoints
|
||||
router_content = imports + "\n\n# Endpoints\n\n" + "\n\n\n".join(endpoints)
|
||||
|
||||
return router_content
|
||||
|
||||
def main():
|
||||
# Read main.py
|
||||
main_path = Path('main.py')
|
||||
if not main_path.exists():
|
||||
print("ERROR: main.py not found!")
|
||||
return
|
||||
|
||||
content = main_path.read_text()
|
||||
|
||||
# Define endpoint groups
|
||||
endpoint_groups = {
|
||||
'game_routes': [
|
||||
'@app.get("/api/game/state")',
|
||||
'@app.get("/api/game/profile")',
|
||||
'@app.post("/api/game/spend_point")',
|
||||
'@app.get("/api/game/location")',
|
||||
'@app.post("/api/game/move")',
|
||||
'@app.post("/api/game/inspect")',
|
||||
'@app.post("/api/game/interact")',
|
||||
'@app.post("/api/game/use_item")',
|
||||
'@app.post("/api/game/pickup")',
|
||||
'@app.get("/api/game/inventory")',
|
||||
'@app.post("/api/game/item/drop")',
|
||||
],
|
||||
'equipment': [
|
||||
'@app.post("/api/game/equip")',
|
||||
'@app.post("/api/game/unequip")',
|
||||
'@app.get("/api/game/equipment")',
|
||||
'@app.post("/api/game/repair_item")',
|
||||
'@app.get("/api/game/repairable")',
|
||||
'@app.get("/api/game/salvageable")',
|
||||
],
|
||||
'crafting': [
|
||||
'@app.get("/api/game/craftable")',
|
||||
'@app.post("/api/game/craft_item")',
|
||||
'@app.post("/api/game/uncraft_item")',
|
||||
],
|
||||
'loot': [
|
||||
'@app.get("/api/game/corpse/{corpse_id}")',
|
||||
'@app.post("/api/game/loot_corpse")',
|
||||
],
|
||||
'combat': [
|
||||
'@app.get("/api/game/combat")',
|
||||
'@app.post("/api/game/combat/initiate")',
|
||||
'@app.post("/api/game/combat/action")',
|
||||
'@app.post("/api/game/pvp/initiate")',
|
||||
'@app.get("/api/game/pvp/status")',
|
||||
'@app.post("/api/game/pvp/acknowledge")',
|
||||
'@app.post("/api/game/pvp/action")',
|
||||
],
|
||||
'statistics': [
|
||||
'@app.get("/api/statistics/{player_id}")',
|
||||
'@app.get("/api/statistics/me")',
|
||||
'@app.get("/api/leaderboard/{stat_name}")',
|
||||
],
|
||||
}
|
||||
|
||||
# Process each group
|
||||
for router_name, decorators in endpoint_groups.items():
|
||||
print(f"\nProcessing {router_name}...")
|
||||
endpoints = []
|
||||
|
||||
for decorator in decorators:
|
||||
func_code = extract_endpoint_function(content, decorator)
|
||||
if func_code:
|
||||
# Replace @app with @router
|
||||
func_code = func_code.replace('@app.', '@router.')
|
||||
endpoints.append(func_code)
|
||||
print(f" ✓ Extracted: {decorator}")
|
||||
else:
|
||||
print(f" ✗ Not found: {decorator}")
|
||||
|
||||
if endpoints:
|
||||
router_content = generate_router_file(router_name, endpoints)
|
||||
output_path = Path(f'routers/{router_name}.py')
|
||||
output_path.write_text(router_content)
|
||||
print(f" ✅ Created routers/{router_name}.py with {len(endpoints)} endpoints")
|
||||
else:
|
||||
print(f" ⚠️ No endpoints found for {router_name}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Router generation complete!")
|
||||
print("Next step: Create new streamlined main.py")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4400
api/main.py
4400
api/main.py
File diff suppressed because it is too large
Load Diff
170
api/main_new.py
Normal file
170
api/main_new.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Echoes of the Ashes - Main FastAPI Application
|
||||
Streamlined with modular routers for maintainability
|
||||
"""
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
# Import core modules
|
||||
from .core.config import CORS_ORIGINS, IMAGES_DIR
|
||||
from .core.websockets import manager
|
||||
from .core.security import get_current_user
|
||||
|
||||
# Import database and game data
|
||||
from . import database as db
|
||||
from .world_loader import load_world, World, Location
|
||||
from .items import ItemsManager
|
||||
from . import background_tasks
|
||||
from .redis_manager import redis_manager
|
||||
|
||||
# Import routers
|
||||
from .routers import auth, characters
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load game data
|
||||
print("🔄 Loading game world...")
|
||||
WORLD: World = load_world()
|
||||
LOCATIONS = WORLD.locations
|
||||
ITEMS_MANAGER = ItemsManager()
|
||||
print(f"✅ Game world ready: {len(LOCATIONS)} locations, {len(ITEMS_MANAGER.items)} items")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager for startup/shutdown"""
|
||||
# Startup
|
||||
await db.init_db()
|
||||
print("✅ Database initialized")
|
||||
|
||||
# Connect to Redis
|
||||
await redis_manager.connect()
|
||||
print("✅ Redis connected")
|
||||
|
||||
# Inject Redis manager into ConnectionManager
|
||||
manager.set_redis_manager(redis_manager)
|
||||
|
||||
# Subscribe to all location channels + global broadcast
|
||||
location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()]
|
||||
await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast'])
|
||||
print(f"✅ Subscribed to {len(location_channels)} location channels")
|
||||
|
||||
# Register this worker
|
||||
await redis_manager.register_worker()
|
||||
print(f"✅ Worker registered: {redis_manager.worker_id}")
|
||||
|
||||
# Start Redis message listener (background task)
|
||||
redis_manager.start_listener(manager.handle_redis_message)
|
||||
print("✅ Redis listener started")
|
||||
|
||||
# Start background tasks (distributed via Redis locks)
|
||||
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS)
|
||||
if tasks:
|
||||
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
||||
else:
|
||||
print("⏭️ Background tasks running in another worker")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await background_tasks.stop_background_tasks(tasks)
|
||||
|
||||
# Unregister worker
|
||||
await redis_manager.unregister_worker()
|
||||
print(f"🔌 Worker unregistered: {redis_manager.worker_id}")
|
||||
|
||||
# Disconnect from Redis
|
||||
await redis_manager.disconnect()
|
||||
print("✅ Redis disconnected")
|
||||
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Echoes of the Ashes API",
|
||||
version="2.0.0",
|
||||
description="Post-apocalyptic survival RPG API",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files for images
|
||||
if IMAGES_DIR.exists():
|
||||
app.mount("/images", StaticFiles(directory=str(IMAGES_DIR)), name="images")
|
||||
print(f"✅ Mounted images directory: {IMAGES_DIR}")
|
||||
else:
|
||||
print(f"⚠️ Images directory not found: {IMAGES_DIR}")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(characters.router)
|
||||
|
||||
# TODO: Add remaining routers as they are created:
|
||||
# app.include_router(game_routes.router)
|
||||
# app.include_router(combat.router)
|
||||
# app.include_router(equipment.router)
|
||||
# app.include_router(crafting.router)
|
||||
# app.include_router(loot.router)
|
||||
# app.include_router(admin.router)
|
||||
# app.include_router(statistics.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for load balancers"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""WebSocket endpoint for real-time game updates"""
|
||||
player_id = current_user['id']
|
||||
username = current_user['name']
|
||||
|
||||
await manager.connect(websocket, player_id, username)
|
||||
|
||||
# Get player's location and register in Redis
|
||||
location_id = current_user.get('location_id')
|
||||
if location_id and redis_manager:
|
||||
await redis_manager.add_player_to_location(location_id, player_id)
|
||||
# Store session data
|
||||
await redis_manager.update_player_session(player_id, {
|
||||
'username': username,
|
||||
'location_id': location_id,
|
||||
'level': current_user.get('level', 1),
|
||||
'websocket_connected': 'true'
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Keep connection alive
|
||||
data = await websocket.receive_text()
|
||||
# You can handle client messages here if needed
|
||||
logger.debug(f"Received from {username}: {data}")
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(player_id)
|
||||
|
||||
# Remove from location registry
|
||||
if location_id and redis_manager:
|
||||
await redis_manager.remove_player_from_location(location_id, player_id)
|
||||
|
||||
print(f"WebSocket disconnected: {username}")
|
||||
5573
api/main_original_5573_lines.py
Normal file
5573
api/main_original_5573_lines.py
Normal file
File diff suppressed because it is too large
Load Diff
5573
api/main_pre_migration_backup.py
Normal file
5573
api/main_pre_migration_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
90
api/migrate_main.py
Normal file
90
api/migrate_main.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Script to help migrate main.py endpoints to router files.
|
||||
This script analyzes endpoint patterns and generates router code.
|
||||
"""
|
||||
|
||||
# Endpoint grouping patterns
|
||||
ROUTER_GROUPS = {
|
||||
"game_routes": [
|
||||
"/api/game/state",
|
||||
"/api/game/profile",
|
||||
"/api/game/spend_point",
|
||||
"/api/game/location",
|
||||
"/api/game/move",
|
||||
"/api/game/inspect",
|
||||
"/api/game/interact",
|
||||
"/api/game/use_item",
|
||||
"/api/game/pickup",
|
||||
"/api/game/inventory",
|
||||
"/api/game/item/drop"
|
||||
],
|
||||
"equipment": [
|
||||
"/api/game/equip",
|
||||
"/api/game/unequip",
|
||||
"/api/game/equipment",
|
||||
"/api/game/repair_item",
|
||||
"/api/game/repairable",
|
||||
"/api/game/salvageable"
|
||||
],
|
||||
"crafting": [
|
||||
"/api/game/craftable",
|
||||
"/api/game/craft_item",
|
||||
"/api/game/uncraft_item"
|
||||
],
|
||||
"loot": [
|
||||
"/api/game/corpse/{corpse_id}",
|
||||
"/api/game/loot_corpse"
|
||||
],
|
||||
"combat": [
|
||||
"/api/game/combat",
|
||||
"/api/game/combat/initiate",
|
||||
"/api/game/combat/action",
|
||||
"/api/game/pvp/initiate",
|
||||
"/api/game/pvp/status",
|
||||
"/api/game/pvp/acknowledge",
|
||||
"/api/game/pvp/action"
|
||||
],
|
||||
"admin": [
|
||||
"/api/internal/player/by_id/{player_id}",
|
||||
"/api/internal/player/{player_id}/combat",
|
||||
"/api/internal/combat/create",
|
||||
"/api/internal/combat/{player_id}",
|
||||
"/api/internal/player/{player_id}",
|
||||
"/api/internal/player/{player_id}/move",
|
||||
"/api/internal/player/{player_id}/inspect",
|
||||
"/api/internal/player/{player_id}/interact",
|
||||
"/api/internal/player/{player_id}/inventory",
|
||||
"/api/internal/player/{player_id}/use_item",
|
||||
"/api/internal/player/{player_id}/pickup",
|
||||
"/api/internal/player/{player_id}/drop_item",
|
||||
"/api/internal/player/{player_id}/equip",
|
||||
"/api/internal/player/{player_id}/unequip",
|
||||
"/api/internal/dropped-items",
|
||||
"/api/internal/dropped-items/{dropped_item_id}",
|
||||
"/api/internal/location/{location_id}/dropped-items",
|
||||
"/api/internal/corpses/player",
|
||||
"/api/internal/corpses/player/{corpse_id}",
|
||||
"/api/internal/corpses/npc",
|
||||
"/api/internal/corpses/npc/{corpse_id}",
|
||||
"/api/internal/wandering-enemies",
|
||||
"/api/internal/location/{location_id}/wandering-enemies",
|
||||
"/api/internal/wandering-enemies/{enemy_id}",
|
||||
"/api/internal/inventory/item/{item_db_id}",
|
||||
"/api/internal/cooldown/{cooldown_key}",
|
||||
"/api/internal/location/{location_id}/corpses/player",
|
||||
"/api/internal/location/{location_id}/corpses/npc",
|
||||
"/api/internal/image-cache/{image_path:path}",
|
||||
"/api/internal/image-cache",
|
||||
"/api/internal/player/{player_id}/status-effects"
|
||||
],
|
||||
"statistics": [
|
||||
"/api/statistics/{player_id}",
|
||||
"/api/statistics/me",
|
||||
"/api/leaderboard/{stat_name}"
|
||||
]
|
||||
}
|
||||
|
||||
print("Router migration patterns defined")
|
||||
print(f"Total routes to migrate: {sum(len(v) for v in ROUTER_GROUPS.values())}")
|
||||
for router_name, routes in ROUTER_GROUPS.items():
|
||||
print(f" - {router_name}: {len(routes)} routes")
|
||||
17
api/migration_add_intent.py
Normal file
17
api/migration_add_intent.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from api.database import engine
|
||||
|
||||
async def migrate():
|
||||
print("Starting migration: Adding npc_intent column to active_combats table...")
|
||||
async with engine.begin() as conn:
|
||||
try:
|
||||
# Check if column exists first to avoid errors
|
||||
# This is a simple check, might vary based on exact postgres version but usually works
|
||||
await conn.execute(text("ALTER TABLE active_combats ADD COLUMN IF NOT EXISTS npc_intent VARCHAR DEFAULT 'attack'"))
|
||||
print("Migration successful: Added npc_intent column.")
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(migrate())
|
||||
455
api/redis_manager.py
Normal file
455
api/redis_manager.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Redis Manager for Echoes of the Ashes
|
||||
|
||||
Handles Redis pub/sub for cross-worker communication and caching for performance.
|
||||
|
||||
Key Features:
|
||||
- Pub/Sub channels for location broadcasts and personal messages
|
||||
- Player session caching (location, HP, stats)
|
||||
- Location player registry (Set of character IDs per location)
|
||||
- Inventory caching with aggressive invalidation
|
||||
- Combat state caching
|
||||
- Disconnected player tracking
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, List, Optional, Set, Any, Callable
|
||||
import redis.asyncio as redis
|
||||
from redis.asyncio.client import PubSub
|
||||
|
||||
|
||||
class RedisManager:
|
||||
"""Manages Redis connections, pub/sub, and caching."""
|
||||
|
||||
def __init__(self, redis_url: str = "redis://echoes_of_the_ashes_redis:6379"):
|
||||
self.redis_url = redis_url
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
self.pubsub: Optional[PubSub] = None
|
||||
self.worker_id = str(uuid.uuid4())[:8] # Unique worker identifier
|
||||
self.subscribed_channels: Set[str] = set()
|
||||
self.message_handlers: Dict[str, Callable] = {}
|
||||
self._listener_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Establish connection to Redis."""
|
||||
self.redis_client = redis.from_url(
|
||||
self.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=50
|
||||
)
|
||||
self.pubsub = self.redis_client.pubsub()
|
||||
print(f"✅ Redis connected (Worker: {self.worker_id})")
|
||||
|
||||
async def disconnect(self):
|
||||
"""Close Redis connection and cleanup."""
|
||||
if self._listener_task:
|
||||
self._listener_task.cancel()
|
||||
try:
|
||||
await self._listener_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.pubsub:
|
||||
await self.pubsub.unsubscribe()
|
||||
await self.pubsub.close()
|
||||
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
|
||||
print(f"🔌 Redis disconnected (Worker: {self.worker_id})")
|
||||
|
||||
# ==================== PUB/SUB ====================
|
||||
|
||||
async def subscribe_to_channels(self, channels: List[str]):
|
||||
"""Subscribe to multiple channels."""
|
||||
if not self.pubsub:
|
||||
raise RuntimeError("Redis pubsub not initialized")
|
||||
|
||||
for channel in channels:
|
||||
if channel not in self.subscribed_channels:
|
||||
await self.pubsub.subscribe(channel)
|
||||
self.subscribed_channels.add(channel)
|
||||
|
||||
print(f"📡 Worker {self.worker_id} subscribed to {len(channels)} channels")
|
||||
|
||||
async def unsubscribe_from_channel(self, channel: str):
|
||||
"""Unsubscribe from a specific channel."""
|
||||
if self.pubsub and channel in self.subscribed_channels:
|
||||
await self.pubsub.unsubscribe(channel)
|
||||
self.subscribed_channels.discard(channel)
|
||||
|
||||
async def publish_to_channel(self, channel: str, message: Dict[str, Any]):
|
||||
"""Publish a message to a Redis channel."""
|
||||
if not self.redis_client:
|
||||
raise RuntimeError("Redis client not initialized")
|
||||
|
||||
message_data = {
|
||||
"worker_id": self.worker_id,
|
||||
"timestamp": time.time(),
|
||||
**message
|
||||
}
|
||||
|
||||
await self.redis_client.publish(channel, json.dumps(message_data))
|
||||
|
||||
async def publish_to_location(self, location_id: str, message: Dict[str, Any]):
|
||||
"""Publish a message to all players in a location."""
|
||||
await self.publish_to_channel(f"location:{location_id}", message)
|
||||
|
||||
async def publish_to_player(self, character_id: int, message: Dict[str, Any]):
|
||||
"""Publish a personal message to a specific player."""
|
||||
await self.publish_to_channel(f"player:{character_id}", message)
|
||||
|
||||
async def publish_global_broadcast(self, message: Dict[str, Any]):
|
||||
"""Publish a message to all connected players."""
|
||||
await self.publish_to_channel("game:broadcast", message)
|
||||
|
||||
async def listen_for_messages(self, handler: Callable):
|
||||
"""Listen for Redis pub/sub messages and route to handler.
|
||||
|
||||
Args:
|
||||
handler: Async function that receives (channel, message_data)
|
||||
"""
|
||||
if not self.pubsub:
|
||||
raise RuntimeError("Redis pubsub not initialized")
|
||||
|
||||
print(f"👂 Worker {self.worker_id} listening for Redis messages...")
|
||||
|
||||
async for message in self.pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
channel = message["channel"]
|
||||
try:
|
||||
data = json.loads(message["data"])
|
||||
|
||||
# Don't process messages from this same worker (already handled locally)
|
||||
if data.get("worker_id") == self.worker_id:
|
||||
continue
|
||||
|
||||
# Route to handler
|
||||
await handler(channel, data)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print(f"⚠️ Invalid JSON in Redis message: {message['data']}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error handling Redis message: {e}")
|
||||
|
||||
def start_listener(self, handler: Callable):
|
||||
"""Start background task to listen for Redis messages."""
|
||||
self._listener_task = asyncio.create_task(self.listen_for_messages(handler))
|
||||
|
||||
# ==================== PLAYER SESSIONS ====================
|
||||
|
||||
async def set_player_session(self, character_id: int, session_data: Dict[str, Any], ttl: int = 1800):
|
||||
"""Cache player session data (30 min TTL by default).
|
||||
|
||||
Args:
|
||||
character_id: Player's character ID
|
||||
session_data: Dict with keys like 'location_id', 'hp', 'level', etc.
|
||||
ttl: Time-to-live in seconds (default 30 minutes)
|
||||
"""
|
||||
key = f"player:{character_id}:session"
|
||||
|
||||
# Convert all values to strings for Redis hash
|
||||
string_data = {k: str(v) for k, v in session_data.items()}
|
||||
|
||||
await self.redis_client.hset(key, mapping=string_data)
|
||||
await self.redis_client.expire(key, ttl)
|
||||
|
||||
async def get_player_session(self, character_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve cached player session data."""
|
||||
key = f"player:{character_id}:session"
|
||||
data = await self.redis_client.hgetall(key)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Note: Values come back as strings, convert as needed
|
||||
return data
|
||||
|
||||
async def update_player_session_field(self, character_id: int, field: str, value: Any):
|
||||
"""Update a single field in player session (e.g., HP, location)."""
|
||||
key = f"player:{character_id}:session"
|
||||
await self.redis_client.hset(key, field, str(value))
|
||||
# Refresh TTL
|
||||
await self.redis_client.expire(key, 1800)
|
||||
|
||||
async def delete_player_session(self, character_id: int):
|
||||
"""Delete player session from cache (force reload from DB)."""
|
||||
key = f"player:{character_id}:session"
|
||||
await self.redis_client.delete(key)
|
||||
|
||||
# ==================== LOCATION PLAYER REGISTRY ====================
|
||||
|
||||
async def add_player_to_location(self, character_id: int, location_id: str):
|
||||
"""Add player to location's player set."""
|
||||
key = f"location:{location_id}:players"
|
||||
await self.redis_client.sadd(key, character_id)
|
||||
|
||||
async def remove_player_from_location(self, character_id: int, location_id: str):
|
||||
"""Remove player from location's player set."""
|
||||
key = f"location:{location_id}:players"
|
||||
await self.redis_client.srem(key, character_id)
|
||||
|
||||
async def move_player_between_locations(self, character_id: int, from_location: str, to_location: str):
|
||||
"""Atomically move player from one location to another."""
|
||||
pipe = self.redis_client.pipeline()
|
||||
pipe.srem(f"location:{from_location}:players", character_id)
|
||||
pipe.sadd(f"location:{to_location}:players", character_id)
|
||||
await pipe.execute()
|
||||
|
||||
async def get_players_in_location(self, location_id: str) -> List[int]:
|
||||
"""Get list of all player IDs in a location."""
|
||||
key = f"location:{location_id}:players"
|
||||
members = await self.redis_client.smembers(key)
|
||||
return [int(m) for m in members]
|
||||
|
||||
async def is_player_in_location(self, character_id: int, location_id: str) -> bool:
|
||||
"""Check if player is in a specific location."""
|
||||
key = f"location:{location_id}:players"
|
||||
return await self.redis_client.sismember(key, character_id)
|
||||
|
||||
# ==================== INVENTORY CACHING ====================
|
||||
|
||||
async def cache_inventory(self, character_id: int, inventory_data: List[Dict], ttl: int = 600):
|
||||
"""Cache player inventory (10 min TTL).
|
||||
|
||||
Args:
|
||||
character_id: Player's character ID
|
||||
inventory_data: List of inventory items
|
||||
ttl: Time-to-live in seconds (default 10 minutes)
|
||||
"""
|
||||
key = f"player:{character_id}:inventory"
|
||||
await self.redis_client.setex(key, ttl, json.dumps(inventory_data))
|
||||
|
||||
async def get_cached_inventory(self, character_id: int) -> Optional[List[Dict]]:
|
||||
"""Retrieve cached inventory."""
|
||||
key = f"player:{character_id}:inventory"
|
||||
data = await self.redis_client.get(key)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return json.loads(data)
|
||||
|
||||
async def invalidate_inventory(self, character_id: int):
|
||||
"""Delete inventory cache (force reload from DB)."""
|
||||
key = f"player:{character_id}:inventory"
|
||||
await self.redis_client.delete(key)
|
||||
|
||||
# ==================== COMBAT STATE CACHING ====================
|
||||
|
||||
async def cache_combat_state(self, character_id: int, combat_data: Dict[str, Any]):
|
||||
"""Cache active combat state (no expiration, deleted when combat ends).
|
||||
|
||||
Args:
|
||||
character_id: Player's character ID
|
||||
combat_data: Combat state dict (npc_id, npc_hp, turn, etc.)
|
||||
"""
|
||||
key = f"player:{character_id}:combat"
|
||||
|
||||
# Convert to strings for hash
|
||||
string_data = {k: str(v) for k, v in combat_data.items()}
|
||||
|
||||
await self.redis_client.hset(key, mapping=string_data)
|
||||
|
||||
async def get_combat_state(self, character_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve cached combat state."""
|
||||
key = f"player:{character_id}:combat"
|
||||
data = await self.redis_client.hgetall(key)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
async def update_combat_field(self, character_id: int, field: str, value: Any):
|
||||
"""Update single field in combat state (e.g., npc_hp, turn)."""
|
||||
key = f"player:{character_id}:combat"
|
||||
await self.redis_client.hset(key, field, str(value))
|
||||
|
||||
async def delete_combat_state(self, character_id: int):
|
||||
"""Delete combat state (combat ended)."""
|
||||
key = f"player:{character_id}:combat"
|
||||
await self.redis_client.delete(key)
|
||||
|
||||
# ==================== DROPPED ITEMS ====================
|
||||
|
||||
async def add_dropped_item(self, location_id: str, item_data: Dict[str, Any], ttl: int = 3600):
|
||||
"""Add a dropped item to location's list (1 hour TTL).
|
||||
|
||||
Args:
|
||||
location_id: Location where item was dropped
|
||||
item_data: Item details (item_id, unique_item_id, timestamp, etc.)
|
||||
ttl: Time-to-live in seconds (default 1 hour)
|
||||
"""
|
||||
key = f"location:{location_id}:dropped_items"
|
||||
|
||||
# Use a list to store dropped items
|
||||
await self.redis_client.rpush(key, json.dumps(item_data))
|
||||
await self.redis_client.expire(key, ttl)
|
||||
|
||||
async def get_dropped_items(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all dropped items in a location."""
|
||||
key = f"location:{location_id}:dropped_items"
|
||||
items = await self.redis_client.lrange(key, 0, -1)
|
||||
|
||||
return [json.loads(item) for item in items]
|
||||
|
||||
async def remove_dropped_item(self, location_id: str, item_data: Dict[str, Any]):
|
||||
"""Remove a specific dropped item (when picked up)."""
|
||||
key = f"location:{location_id}:dropped_items"
|
||||
await self.redis_client.lrem(key, 1, json.dumps(item_data))
|
||||
|
||||
# ==================== WORKER REGISTRY ====================
|
||||
|
||||
async def register_worker(self):
|
||||
"""Register this worker as active."""
|
||||
await self.redis_client.sadd("active_workers", self.worker_id)
|
||||
# Set heartbeat timestamp
|
||||
await self.redis_client.hset(
|
||||
f"worker:{self.worker_id}:heartbeat",
|
||||
mapping={
|
||||
"timestamp": str(time.time()),
|
||||
"status": "online"
|
||||
}
|
||||
)
|
||||
|
||||
async def unregister_worker(self):
|
||||
"""Unregister this worker."""
|
||||
await self.redis_client.srem("active_workers", self.worker_id)
|
||||
await self.redis_client.delete(f"worker:{self.worker_id}:heartbeat")
|
||||
|
||||
async def get_active_workers(self) -> List[str]:
|
||||
"""Get list of all active worker IDs."""
|
||||
members = await self.redis_client.smembers("active_workers")
|
||||
return list(members)
|
||||
|
||||
async def update_heartbeat(self):
|
||||
"""Update worker heartbeat timestamp."""
|
||||
await self.redis_client.hset(
|
||||
f"worker:{self.worker_id}:heartbeat",
|
||||
"timestamp",
|
||||
str(time.time())
|
||||
)
|
||||
|
||||
# ==================== DISTRIBUTED LOCKS ====================
|
||||
|
||||
async def acquire_lock(self, lock_name: str, ttl: int = 60) -> bool:
|
||||
"""Acquire a distributed lock for background tasks.
|
||||
|
||||
Args:
|
||||
lock_name: Name of the lock (e.g., "spawn_task", "regen_task")
|
||||
ttl: Lock expiration in seconds (default 60s)
|
||||
|
||||
Returns:
|
||||
True if lock acquired, False if already held by another worker
|
||||
"""
|
||||
key = f"lock:{lock_name}"
|
||||
# SET key value NX EX ttl (only set if not exists, with expiration)
|
||||
result = await self.redis_client.set(
|
||||
key,
|
||||
self.worker_id,
|
||||
nx=True,
|
||||
ex=ttl
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def release_lock(self, lock_name: str):
|
||||
"""Release a distributed lock."""
|
||||
key = f"lock:{lock_name}"
|
||||
# Only delete if this worker owns the lock
|
||||
lock_owner = await self.redis_client.get(key)
|
||||
if lock_owner == self.worker_id:
|
||||
await self.redis_client.delete(key)
|
||||
|
||||
# ==================== DISCONNECTED PLAYERS ====================
|
||||
|
||||
async def mark_player_disconnected(self, character_id: int):
|
||||
"""Mark player as disconnected (but keep in location registry)."""
|
||||
session = await self.get_player_session(character_id)
|
||||
if session:
|
||||
await self.update_player_session_field(character_id, "websocket_connected", "false")
|
||||
await self.update_player_session_field(character_id, "disconnect_time", str(time.time()))
|
||||
|
||||
async def mark_player_connected(self, character_id: int):
|
||||
"""Mark player as connected."""
|
||||
await self.update_player_session_field(character_id, "websocket_connected", "true")
|
||||
# Remove disconnect time
|
||||
key = f"player:{character_id}:session"
|
||||
await self.redis_client.hdel(key, "disconnect_time")
|
||||
|
||||
async def is_player_connected(self, character_id: int) -> bool:
|
||||
"""Check if player is currently connected via WebSocket."""
|
||||
session = await self.get_player_session(character_id)
|
||||
if not session:
|
||||
return False
|
||||
return session.get("websocket_connected") == "true"
|
||||
|
||||
async def get_disconnect_duration(self, character_id: int) -> Optional[float]:
|
||||
"""Get how long player has been disconnected (in seconds)."""
|
||||
session = await self.get_player_session(character_id)
|
||||
if not session or session.get("websocket_connected") == "true":
|
||||
return None
|
||||
|
||||
disconnect_time = session.get("disconnect_time")
|
||||
if not disconnect_time:
|
||||
return None
|
||||
|
||||
return time.time() - float(disconnect_time)
|
||||
|
||||
async def cleanup_disconnected_player(self, character_id: int):
|
||||
"""Remove disconnected player from location registry (after timeout)."""
|
||||
session = await self.get_player_session(character_id)
|
||||
if session:
|
||||
location_id = session.get("location_id")
|
||||
if location_id:
|
||||
await self.remove_player_from_location(character_id, location_id)
|
||||
|
||||
await self.delete_player_session(character_id)
|
||||
|
||||
# ==================== UTILITY ====================
|
||||
|
||||
async def ping(self) -> bool:
|
||||
"""Test Redis connection."""
|
||||
try:
|
||||
await self.redis_client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics."""
|
||||
info = await self.redis_client.info("stats")
|
||||
return {
|
||||
"total_commands_processed": info.get("total_commands_processed", 0),
|
||||
"instantaneous_ops_per_sec": info.get("instantaneous_ops_per_sec", 0),
|
||||
"keyspace_hits": info.get("keyspace_hits", 0),
|
||||
"keyspace_misses": info.get("keyspace_misses", 0),
|
||||
"connected_clients": info.get("connected_clients", 0),
|
||||
}
|
||||
|
||||
# ==================== CONNECTED PLAYERS COUNTER ====================
|
||||
|
||||
async def increment_connected_player(self, player_id: int):
|
||||
"""Increment connection count for a player."""
|
||||
key = "connected_players_counts"
|
||||
await self.redis_client.hincrby(key, str(player_id), 1)
|
||||
|
||||
async def decrement_connected_player(self, player_id: int):
|
||||
"""Decrement connection count for a player. Remove if 0."""
|
||||
key = "connected_players_counts"
|
||||
count = await self.redis_client.hincrby(key, str(player_id), -1)
|
||||
if count <= 0:
|
||||
await self.redis_client.hdel(key, str(player_id))
|
||||
|
||||
async def get_connected_player_count(self) -> int:
|
||||
"""Get total number of unique connected players."""
|
||||
key = "connected_players_counts"
|
||||
return await self.redis_client.hlen(key)
|
||||
|
||||
|
||||
# Global instance
|
||||
redis_manager = RedisManager()
|
||||
@@ -3,10 +3,15 @@ fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
gunicorn==21.2.0
|
||||
python-multipart==0.0.6
|
||||
websockets==12.0
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
psycopg[binary]==3.1.13
|
||||
asyncpg==0.29.0 # For migration scripts
|
||||
|
||||
# Redis
|
||||
redis[hiredis]==5.0.1
|
||||
|
||||
# Authentication
|
||||
pyjwt==2.8.0
|
||||
|
||||
0
api/routers/__init__.py
Normal file
0
api/routers/__init__.py
Normal file
370
api/routers/admin.py
Normal file
370
api/routers/admin.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Internal/Admin API router.
|
||||
Endpoints for internal services (bot, admin tools, etc.)
|
||||
Requires API_INTERNAL_KEY for authentication.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Dict, Any
|
||||
import json
|
||||
|
||||
from ..core.security import verify_internal_key
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
|
||||
# These will be injected by main.py
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
IMAGES_DIR = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world, images_dir):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
IMAGES_DIR = images_dir
|
||||
|
||||
router = APIRouter(prefix="/api/internal", tags=["internal"], dependencies=[Depends(verify_internal_key)])
|
||||
|
||||
|
||||
# Player endpoints
|
||||
@router.get("/player/by_id/{player_id}")
|
||||
async def get_player_by_id(player_id: int):
|
||||
"""Get player data by ID"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return player
|
||||
|
||||
|
||||
@router.patch("/player/{player_id}")
|
||||
async def update_player(player_id: int, data: dict):
|
||||
"""Update player"""
|
||||
await db.update_player(player_id, **data)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/player/{player_id}/inventory")
|
||||
async def get_inventory(player_id: int):
|
||||
"""Get player inventory"""
|
||||
inventory = await db.get_inventory(player_id)
|
||||
return inventory
|
||||
|
||||
|
||||
@router.get("/player/{player_id}/status-effects")
|
||||
async def get_player_status_effects(player_id: int):
|
||||
"""Get player's active status effects"""
|
||||
effects = await db.get_active_status_effects(player_id)
|
||||
return effects
|
||||
|
||||
|
||||
# Combat endpoints
|
||||
@router.get("/player/{player_id}/combat")
|
||||
async def get_player_combat(player_id: int):
|
||||
"""Get player's active combat"""
|
||||
combat = await db.get_active_combat(player_id)
|
||||
return combat
|
||||
|
||||
|
||||
@router.post("/combat/create")
|
||||
async def create_combat(data: dict):
|
||||
"""Create combat"""
|
||||
return await db.create_combat(**data)
|
||||
|
||||
|
||||
@router.patch("/combat/{player_id}")
|
||||
async def update_combat(player_id: int, data: dict):
|
||||
"""Update combat"""
|
||||
await db.update_combat(player_id, **data)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/combat/{player_id}")
|
||||
async def end_combat(player_id: int):
|
||||
"""End combat"""
|
||||
await db.end_combat(player_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Game action endpoints
|
||||
@router.post("/player/{player_id}/move")
|
||||
async def move_player(player_id: int, data: dict):
|
||||
"""Move player"""
|
||||
from .. import game_logic
|
||||
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
|
||||
player_id,
|
||||
data['direction'],
|
||||
LOCATIONS
|
||||
)
|
||||
return {"success": success, "message": message, "new_location_id": new_location_id}
|
||||
|
||||
|
||||
@router.get("/player/{player_id}/inspect")
|
||||
async def inspect_player(player_id: int):
|
||||
"""Inspect area for player"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
from .. import game_logic
|
||||
message = await game_logic.inspect_area(player_id, location, {})
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.post("/player/{player_id}/interact")
|
||||
async def interact_player(player_id: int, data: dict):
|
||||
"""Interact for player"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
from .. import game_logic
|
||||
result = await game_logic.interact_with_object(
|
||||
player_id,
|
||||
data['interactable_id'],
|
||||
data['action_id'],
|
||||
location,
|
||||
ITEMS_MANAGER
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/player/{player_id}/use_item")
|
||||
async def use_item(player_id: int, data: dict):
|
||||
"""Use item"""
|
||||
from .. import game_logic
|
||||
result = await game_logic.use_item(player_id, data['item_id'], ITEMS_MANAGER)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/player/{player_id}/pickup")
|
||||
async def pickup_item(player_id: int, data: dict):
|
||||
"""Pickup item"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
from .. import game_logic
|
||||
result = await game_logic.pickup_item(
|
||||
player_id,
|
||||
data['item_id'],
|
||||
player['location_id'],
|
||||
data.get('quantity'),
|
||||
ITEMS_MANAGER
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/player/{player_id}/drop_item")
|
||||
async def drop_item(player_id: int, data: dict):
|
||||
"""Drop item"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
await db.drop_item(player_id, data['item_id'], data['quantity'], player['location_id'])
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Equipment endpoints
|
||||
@router.post("/player/{player_id}/equip")
|
||||
async def equip_item(player_id: int, data: dict):
|
||||
"""Equip item"""
|
||||
inv_item = await db.get_inventory_item_by_id(data['inventory_id'])
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if not item_def or not item_def.equippable:
|
||||
raise HTTPException(status_code=400, detail="Item not equippable")
|
||||
|
||||
# Unequip current item in slot if any
|
||||
current = await db.get_equipped_item_in_slot(player_id, item_def.slot)
|
||||
if current:
|
||||
await db.unequip_item(player_id, item_def.slot)
|
||||
await db.update_inventory_item(current['item_id'], is_equipped=False)
|
||||
|
||||
# Equip new item
|
||||
await db.equip_item(player_id, item_def.slot, data['inventory_id'])
|
||||
await db.update_inventory_item(data['inventory_id'], is_equipped=True)
|
||||
|
||||
return {"success": True, "slot": item_def.slot}
|
||||
|
||||
|
||||
@router.post("/player/{player_id}/unequip")
|
||||
async def unequip_item(player_id: int, data: dict):
|
||||
"""Unequip item"""
|
||||
equipped = await db.get_equipped_item_in_slot(player_id, data['slot'])
|
||||
if not equipped:
|
||||
raise HTTPException(status_code=400, detail="No item in slot")
|
||||
|
||||
await db.unequip_item(player_id, data['slot'])
|
||||
await db.update_inventory_item(equipped['item_id'], is_equipped=False)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Dropped items endpoints
|
||||
@router.post("/dropped-items")
|
||||
async def create_dropped_item(data: dict):
|
||||
"""Create dropped item"""
|
||||
await db.drop_item(None, data['item_id'], data['quantity'], data['location_id'])
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/dropped-items/{dropped_item_id}")
|
||||
async def get_dropped_item(dropped_item_id: int):
|
||||
"""Get dropped item"""
|
||||
item = await db.get_dropped_item(dropped_item_id)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/location/{location_id}/dropped-items")
|
||||
async def get_location_dropped_items(location_id: str):
|
||||
"""Get location's dropped items"""
|
||||
items = await db.get_dropped_items(location_id)
|
||||
return items
|
||||
|
||||
|
||||
@router.patch("/dropped-items/{dropped_item_id}")
|
||||
async def update_dropped_item(dropped_item_id: int, data: dict):
|
||||
"""Update dropped item"""
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/dropped-items/{dropped_item_id}")
|
||||
async def delete_dropped_item(dropped_item_id: int):
|
||||
"""Delete dropped item"""
|
||||
await db.delete_dropped_item(dropped_item_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Corpse endpoints - Player
|
||||
@router.post("/corpses/player")
|
||||
async def create_player_corpse(data: dict):
|
||||
"""Create player corpse"""
|
||||
corpse_id = await db.create_player_corpse(**data)
|
||||
return {"id": corpse_id}
|
||||
|
||||
|
||||
@router.get("/corpses/player/{corpse_id}")
|
||||
async def get_player_corpse(corpse_id: int):
|
||||
"""Get player corpse"""
|
||||
corpse = await db.get_player_corpse(corpse_id)
|
||||
return corpse
|
||||
|
||||
|
||||
@router.patch("/corpses/player/{corpse_id}")
|
||||
async def update_player_corpse(corpse_id: int, data: dict):
|
||||
"""Update player corpse"""
|
||||
await db.update_player_corpse(corpse_id, **data)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/corpses/player/{corpse_id}")
|
||||
async def delete_player_corpse(corpse_id: int):
|
||||
"""Delete player corpse"""
|
||||
await db.delete_player_corpse(corpse_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/location/{location_id}/corpses/player")
|
||||
async def get_player_corpses_in_location(location_id: str):
|
||||
"""Get player corpses in location"""
|
||||
corpses = await db.get_player_corpses_in_location(location_id)
|
||||
return corpses
|
||||
|
||||
|
||||
# Corpse endpoints - NPC
|
||||
@router.post("/corpses/npc")
|
||||
async def create_npc_corpse(data: dict):
|
||||
"""Create NPC corpse"""
|
||||
corpse_id = await db.create_npc_corpse(
|
||||
npc_id=data['npc_id'],
|
||||
location_id=data['location_id'],
|
||||
loot=json.dumps(data['loot'])
|
||||
)
|
||||
return {"id": corpse_id}
|
||||
|
||||
|
||||
@router.get("/corpses/npc/{corpse_id}")
|
||||
async def get_npc_corpse(corpse_id: int):
|
||||
"""Get NPC corpse"""
|
||||
corpse = await db.get_npc_corpse(corpse_id)
|
||||
return corpse
|
||||
|
||||
|
||||
@router.patch("/corpses/npc/{corpse_id}")
|
||||
async def update_npc_corpse(corpse_id: int, data: dict):
|
||||
"""Update NPC corpse"""
|
||||
await db.update_npc_corpse(corpse_id, **data)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/corpses/npc/{corpse_id}")
|
||||
async def delete_npc_corpse(corpse_id: int):
|
||||
"""Delete NPC corpse"""
|
||||
await db.delete_npc_corpse(corpse_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/location/{location_id}/corpses/npc")
|
||||
async def get_npc_corpses_in_location(location_id: str):
|
||||
"""Get NPC corpses in location"""
|
||||
corpses = await db.get_npc_corpses_in_location(location_id)
|
||||
return corpses
|
||||
|
||||
|
||||
# Wandering enemies endpoints
|
||||
@router.post("/wandering-enemies")
|
||||
async def create_wandering_enemy(data: dict):
|
||||
"""Create wandering enemy"""
|
||||
enemy_id = await db.create_wandering_enemy(**data)
|
||||
return {"id": enemy_id}
|
||||
|
||||
|
||||
@router.get("/location/{location_id}/wandering-enemies")
|
||||
async def get_wandering_enemies(location_id: str):
|
||||
"""Get wandering enemies in location"""
|
||||
enemies = await db.get_wandering_enemies_in_location(location_id)
|
||||
return enemies
|
||||
|
||||
|
||||
@router.delete("/wandering-enemies/{enemy_id}")
|
||||
async def delete_wandering_enemy(enemy_id: int):
|
||||
"""Delete wandering enemy"""
|
||||
await db.delete_wandering_enemy(enemy_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Inventory item endpoint
|
||||
@router.get("/inventory/item/{item_db_id}")
|
||||
async def get_inventory_item(item_db_id: int):
|
||||
"""Get inventory item"""
|
||||
item = await db.get_inventory_item_by_id(item_db_id)
|
||||
return item
|
||||
|
||||
|
||||
# Cooldown endpoints
|
||||
@router.get("/cooldown/{cooldown_key}")
|
||||
async def get_cooldown(cooldown_key: str):
|
||||
"""Get cooldown"""
|
||||
parts = cooldown_key.split(':')
|
||||
if len(parts) >= 3:
|
||||
expiry = await db.get_interactable_cooldown(parts[1], parts[2])
|
||||
return {"expiry": expiry}
|
||||
return {"expiry": None}
|
||||
|
||||
|
||||
@router.post("/cooldown/{cooldown_key}")
|
||||
async def set_cooldown(cooldown_key: str, data: dict):
|
||||
"""Set cooldown"""
|
||||
parts = cooldown_key.split(':')
|
||||
if len(parts) >= 3:
|
||||
await db.set_interactable_cooldown(parts[1], parts[2], data['duration'])
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Image cache endpoints
|
||||
@router.get("/image-cache/{image_path:path}")
|
||||
async def get_image_cache(image_path: str):
|
||||
"""Check if image exists"""
|
||||
full_path = IMAGES_DIR / image_path
|
||||
return {"exists": full_path.exists()}
|
||||
|
||||
|
||||
@router.post("/image-cache")
|
||||
async def create_image_cache(data: dict):
|
||||
"""Cache image"""
|
||||
return {"success": True}
|
||||
384
api/routers/auth.py
Normal file
384
api/routers/auth.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Authentication router.
|
||||
Handles user registration, login, and profile retrieval.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from typing import Dict, Any
|
||||
|
||||
from ..core.security import create_access_token, hash_password, verify_password, get_current_user
|
||||
from ..services.models import UserRegister, UserLogin
|
||||
from .. import database as db
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(user: UserRegister):
|
||||
"""Register a new account"""
|
||||
# Check if email already exists
|
||||
existing = await db.get_account_by_email(user.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Hash password
|
||||
password_hash = hash_password(user.password)
|
||||
|
||||
# Create account
|
||||
account = await db.create_account(
|
||||
email=user.email,
|
||||
password_hash=password_hash,
|
||||
account_type="web"
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create account"
|
||||
)
|
||||
|
||||
# Get characters for this account (should be empty for new account)
|
||||
characters = await db.get_characters_by_account_id(account["id"])
|
||||
|
||||
# Create access token with account_id (no character selected yet)
|
||||
access_token = create_access_token({
|
||||
"account_id": account["id"],
|
||||
"character_id": None
|
||||
})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"account": {
|
||||
"id": account["id"],
|
||||
"email": account["email"],
|
||||
"account_type": account["account_type"],
|
||||
"is_premium": account.get("premium_expires_at") is not None,
|
||||
},
|
||||
"characters": characters,
|
||||
"needs_character_creation": len(characters) == 0
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(user: UserLogin):
|
||||
"""Login with email and password"""
|
||||
# Get account by email
|
||||
account = await db.get_account_by_email(user.email)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password"
|
||||
)
|
||||
|
||||
# Verify password
|
||||
if not account.get('password_hash'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password"
|
||||
)
|
||||
|
||||
if not verify_password(user.password, account['password_hash']):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password"
|
||||
)
|
||||
|
||||
# Update last login
|
||||
await db.update_account_last_login(account["id"])
|
||||
|
||||
# Get characters for this account
|
||||
characters = await db.get_characters_by_account_id(account["id"])
|
||||
|
||||
# Create access token with account_id (no character selected yet)
|
||||
access_token = create_access_token({
|
||||
"account_id": account["id"],
|
||||
"character_id": None
|
||||
})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"account": {
|
||||
"id": account["id"],
|
||||
"email": account["email"],
|
||||
"account_type": account["account_type"],
|
||||
"is_premium": account.get("premium_expires_at") is not None,
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"stamina": char["stamina"],
|
||||
"max_stamina": char["max_stamina"],
|
||||
"strength": char["strength"],
|
||||
"agility": char["agility"],
|
||||
"endurance": char["endurance"],
|
||||
"intellect": char["intellect"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
"location_id": char["location_id"],
|
||||
}
|
||||
for char in characters
|
||||
],
|
||||
"needs_character_creation": len(characters) == 0
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Get current user profile"""
|
||||
return {
|
||||
"id": current_user["id"],
|
||||
"username": current_user.get("username"),
|
||||
"name": current_user["name"],
|
||||
"level": current_user["level"],
|
||||
"xp": current_user["xp"],
|
||||
"hp": current_user["hp"],
|
||||
"max_hp": current_user["max_hp"],
|
||||
"stamina": current_user["stamina"],
|
||||
"max_stamina": current_user["max_stamina"],
|
||||
"strength": current_user["strength"],
|
||||
"agility": current_user["agility"],
|
||||
"endurance": current_user["endurance"],
|
||||
"intellect": current_user["intellect"],
|
||||
"location_id": current_user["location_id"],
|
||||
"is_dead": current_user["is_dead"],
|
||||
"unspent_points": current_user["unspent_points"]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/account")
|
||||
async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Get current account details including characters"""
|
||||
# Get account from current user's account_id
|
||||
account_id = current_user.get("account_id")
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No account associated with this user"
|
||||
)
|
||||
|
||||
account = await db.get_account_by_id(account_id)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Account not found"
|
||||
)
|
||||
|
||||
# Get characters for this account
|
||||
characters = await db.get_characters_by_account_id(account_id)
|
||||
|
||||
return {
|
||||
"account": {
|
||||
"id": account["id"],
|
||||
"email": account["email"],
|
||||
"account_type": account["account_type"],
|
||||
"is_premium": account.get("premium_expires_at") is not None and account.get("premium_expires_at") > 0,
|
||||
"premium_expires_at": account.get("premium_expires_at"),
|
||||
"created_at": account.get("created_at"),
|
||||
"last_login_at": account.get("last_login_at"),
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"location_id": char["location_id"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
}
|
||||
for char in characters
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-email")
|
||||
async def change_email(
|
||||
request: "ChangeEmailRequest",
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Change account email address"""
|
||||
from ..services.models import ChangeEmailRequest
|
||||
|
||||
# Get account
|
||||
account_id = current_user.get("account_id")
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No account associated with this user"
|
||||
)
|
||||
|
||||
account = await db.get_account_by_id(account_id)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Account not found"
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
if not account.get('password_hash'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This account does not have a password set"
|
||||
)
|
||||
|
||||
if not verify_password(request.current_password, account['password_hash']):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect"
|
||||
)
|
||||
|
||||
# Validate new email format
|
||||
import re
|
||||
email_regex = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
|
||||
if not re.match(email_regex, request.new_email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid email format"
|
||||
)
|
||||
|
||||
# Update email
|
||||
try:
|
||||
await db.update_account_email(account_id, request.new_email)
|
||||
return {"message": "Email updated successfully", "new_email": request.new_email}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
request: "ChangePasswordRequest",
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Change account password"""
|
||||
from ..services.models import ChangePasswordRequest
|
||||
|
||||
# Get account
|
||||
account_id = current_user.get("account_id")
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No account associated with this user"
|
||||
)
|
||||
|
||||
account = await db.get_account_by_id(account_id)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Account not found"
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
if not account.get('password_hash'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This account does not have a password set"
|
||||
)
|
||||
|
||||
if not verify_password(request.current_password, account['password_hash']):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect"
|
||||
)
|
||||
|
||||
# Validate new password
|
||||
if len(request.new_password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="New password must be at least 6 characters"
|
||||
)
|
||||
|
||||
# Hash and update password
|
||||
new_password_hash = hash_password(request.new_password)
|
||||
await db.update_account_password(account_id, new_password_hash)
|
||||
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
|
||||
@router.post("/steam-login")
|
||||
async def steam_login(steam_data: Dict[str, Any]):
|
||||
"""
|
||||
Login or register with Steam account.
|
||||
Creates account if it doesn't exist.
|
||||
"""
|
||||
steam_id = steam_data.get("steam_id")
|
||||
steam_name = steam_data.get("steam_name", "Steam User")
|
||||
|
||||
if not steam_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Steam ID is required"
|
||||
)
|
||||
|
||||
# Try to find existing account by steam_id
|
||||
account = await db.get_account_by_steam_id(steam_id)
|
||||
|
||||
if not account:
|
||||
# Create new Steam account
|
||||
# Use steam_id as email (unique identifier)
|
||||
email = f"steam_{steam_id}@steamuser.local"
|
||||
|
||||
account = await db.create_account(
|
||||
email=email,
|
||||
password_hash=None, # Steam accounts don't have passwords
|
||||
account_type="steam",
|
||||
steam_id=steam_id
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create Steam account"
|
||||
)
|
||||
|
||||
# Get characters for this account
|
||||
characters = await db.get_characters_by_account_id(account["id"])
|
||||
|
||||
# Create access token with account_id (no character selected yet)
|
||||
access_token = create_access_token({
|
||||
"account_id": account["id"],
|
||||
"character_id": None
|
||||
})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"account": {
|
||||
"id": account["id"],
|
||||
"email": account["email"],
|
||||
"account_type": account["account_type"],
|
||||
"steam_id": steam_id,
|
||||
"steam_name": steam_name,
|
||||
"premium_expires_at": account.get("premium_expires_at"),
|
||||
"created_at": account.get("created_at"),
|
||||
"last_login_at": account.get("last_login_at")
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"location_id": char["location_id"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at")
|
||||
}
|
||||
for char in characters
|
||||
],
|
||||
"needs_character_creation": len(characters) == 0
|
||||
}
|
||||
238
api/routers/characters.py
Normal file
238
api/routers/characters.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Character management router.
|
||||
Handles character creation, selection, and deletion.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from ..core.security import decode_token, create_access_token, security
|
||||
from ..services.models import CharacterCreate, CharacterSelect
|
||||
from .. import database as db
|
||||
|
||||
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""List all characters for the logged-in account"""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
account_id = payload.get("account_id")
|
||||
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
characters = await db.get_characters_by_account_id(account_id)
|
||||
|
||||
return {
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"stamina": char["stamina"],
|
||||
"max_stamina": char["max_stamina"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"location_id": char["location_id"],
|
||||
"created_at": char["created_at"],
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
}
|
||||
for char in characters
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_character_endpoint(
|
||||
character: CharacterCreate,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""Create a new character"""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
account_id = payload.get("account_id")
|
||||
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Check if account can create more characters
|
||||
can_create, error_msg = await db.can_create_character(account_id)
|
||||
if not can_create:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=error_msg
|
||||
)
|
||||
|
||||
# Validate character name
|
||||
if len(character.name) < 3 or len(character.name) > 20:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Character name must be between 3 and 20 characters"
|
||||
)
|
||||
|
||||
# Check if name is unique
|
||||
existing = await db.get_character_by_name(character.name)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Character name already taken"
|
||||
)
|
||||
|
||||
# Validate stat allocation (must total 20 points)
|
||||
total_stats = character.strength + character.agility + character.endurance + character.intellect
|
||||
if total_stats != 20:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Must allocate exactly 20 stat points (you allocated {total_stats})"
|
||||
)
|
||||
|
||||
# Validate each stat is >= 0
|
||||
if any(stat < 0 for stat in [character.strength, character.agility, character.endurance, character.intellect]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Stats cannot be negative"
|
||||
)
|
||||
|
||||
# Create character
|
||||
new_character = await db.create_character(
|
||||
account_id=account_id,
|
||||
name=character.name,
|
||||
strength=character.strength,
|
||||
agility=character.agility,
|
||||
endurance=character.endurance,
|
||||
intellect=character.intellect,
|
||||
avatar_data=character.avatar_data
|
||||
)
|
||||
|
||||
if not new_character:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create character"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Character created successfully",
|
||||
"character": {
|
||||
"id": new_character["id"],
|
||||
"name": new_character["name"],
|
||||
"level": new_character["level"],
|
||||
"strength": new_character["strength"],
|
||||
"agility": new_character["agility"],
|
||||
"endurance": new_character["endurance"],
|
||||
"intellect": new_character["intellect"],
|
||||
"hp": new_character["hp"],
|
||||
"max_hp": new_character["max_hp"],
|
||||
"stamina": new_character["stamina"],
|
||||
"max_stamina": new_character["max_stamina"],
|
||||
"location_id": new_character["location_id"],
|
||||
"avatar_data": new_character.get("avatar_data"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/select")
|
||||
async def select_character(
|
||||
selection: CharacterSelect,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""Select a character to play"""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
account_id = payload.get("account_id")
|
||||
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Verify character belongs to account
|
||||
character = await db.get_character_by_id(selection.character_id)
|
||||
if not character:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Character not found"
|
||||
)
|
||||
|
||||
if character["account_id"] != account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Character does not belong to this account"
|
||||
)
|
||||
|
||||
# Update last played timestamp
|
||||
await db.update_character_last_played(selection.character_id)
|
||||
|
||||
# Create new token with character_id
|
||||
access_token = create_access_token({
|
||||
"account_id": account_id,
|
||||
"character_id": selection.character_id
|
||||
})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"character": {
|
||||
"id": character["id"],
|
||||
"name": character["name"],
|
||||
"level": character["level"],
|
||||
"xp": character["xp"],
|
||||
"hp": character["hp"],
|
||||
"max_hp": character["max_hp"],
|
||||
"stamina": character["stamina"],
|
||||
"max_stamina": character["max_stamina"],
|
||||
"strength": character["strength"],
|
||||
"agility": character["agility"],
|
||||
"endurance": character["endurance"],
|
||||
"intellect": character["intellect"],
|
||||
"location_id": character["location_id"],
|
||||
"avatar_data": character.get("avatar_data"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{character_id}")
|
||||
async def delete_character_endpoint(
|
||||
character_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""Delete a character"""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
account_id = payload.get("account_id")
|
||||
|
||||
if not account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Verify character belongs to account
|
||||
character = await db.get_character_by_id(character_id)
|
||||
if not character:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Character not found"
|
||||
)
|
||||
|
||||
if character["account_id"] != account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Character does not belong to this account"
|
||||
)
|
||||
|
||||
# Delete character
|
||||
await db.delete_character(character_id)
|
||||
|
||||
return {
|
||||
"message": f"Character '{character['name']}' deleted successfully"
|
||||
}
|
||||
1060
api/routers/combat.py
Normal file
1060
api/routers/combat.py
Normal file
File diff suppressed because it is too large
Load Diff
561
api/routers/crafting.py
Normal file
561
api/routers/crafting.py
Normal file
@@ -0,0 +1,561 @@
|
||||
"""
|
||||
Crafting router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
from ..core.websockets import manager
|
||||
from .equipment import consume_tool_durability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These will be injected by main.py
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
|
||||
router = APIRouter(tags=["crafting"])
|
||||
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.get("/api/game/craftable")
|
||||
async def get_craftable_items(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all craftable items with material requirements and availability"""
|
||||
try:
|
||||
player = current_user # current_user is already the character dict
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player's inventory with quantities
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
inventory_counts = {}
|
||||
for inv_item in inventory:
|
||||
item_id = inv_item['item_id']
|
||||
quantity = inv_item.get('quantity', 1)
|
||||
inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity
|
||||
|
||||
craftable_items = []
|
||||
for item_id, item_def in ITEMS_MANAGER.items.items():
|
||||
if not getattr(item_def, 'craftable', False):
|
||||
continue
|
||||
|
||||
craft_materials = getattr(item_def, 'craft_materials', [])
|
||||
if not craft_materials:
|
||||
continue
|
||||
|
||||
# Check material availability
|
||||
materials_info = []
|
||||
can_craft = True
|
||||
for material in craft_materials:
|
||||
mat_item_id = material['item_id']
|
||||
required = material['quantity']
|
||||
available = inventory_counts.get(mat_item_id, 0)
|
||||
|
||||
mat_item_def = ITEMS_MANAGER.items.get(mat_item_id)
|
||||
materials_info.append({
|
||||
'item_id': mat_item_id,
|
||||
'name': mat_item_def.name if mat_item_def else mat_item_id,
|
||||
'emoji': mat_item_def.emoji if mat_item_def else '📦',
|
||||
'required': required,
|
||||
'available': available,
|
||||
'has_enough': available >= required
|
||||
})
|
||||
|
||||
if available < required:
|
||||
can_craft = False
|
||||
|
||||
# Check tool requirements
|
||||
craft_tools = getattr(item_def, 'craft_tools', [])
|
||||
tools_info = []
|
||||
for tool_req in craft_tools:
|
||||
tool_id = tool_req['item_id']
|
||||
durability_cost = tool_req['durability_cost']
|
||||
tool_def = ITEMS_MANAGER.items.get(tool_id)
|
||||
|
||||
# Check if player has this tool
|
||||
has_tool = False
|
||||
tool_durability = 0
|
||||
for inv_item in inventory:
|
||||
# Check if player has this tool (find one with highest durability)
|
||||
has_tool = False
|
||||
tool_durability = 0
|
||||
best_tool_unique = None
|
||||
|
||||
for inv_item in inventory:
|
||||
if inv_item['item_id'] == tool_id and inv_item.get('unique_item_id'):
|
||||
unique = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique and unique.get('durability', 0) >= durability_cost:
|
||||
if best_tool_unique is None or unique.get('durability', 0) > best_tool_unique.get('durability', 0):
|
||||
best_tool_unique = unique
|
||||
has_tool = True
|
||||
tool_durability = unique.get('durability', 0)
|
||||
|
||||
tools_info.append({
|
||||
'item_id': tool_id,
|
||||
'name': tool_def.name if tool_def else tool_id,
|
||||
'emoji': tool_def.emoji if tool_def else '🔧',
|
||||
'durability_cost': durability_cost,
|
||||
'has_tool': has_tool,
|
||||
'tool_durability': tool_durability
|
||||
})
|
||||
|
||||
if not has_tool:
|
||||
can_craft = False
|
||||
|
||||
# Check level requirement
|
||||
craft_level = getattr(item_def, 'craft_level', 1)
|
||||
player_level = player.get('level', 1)
|
||||
meets_level = player_level >= craft_level
|
||||
|
||||
# Don't show recipes above player level
|
||||
if player_level < craft_level:
|
||||
continue
|
||||
|
||||
if not meets_level:
|
||||
can_craft = False
|
||||
|
||||
craftable_items.append({
|
||||
'item_id': item_id,
|
||||
'name': item_def.name,
|
||||
'emoji': item_def.emoji,
|
||||
'description': item_def.description,
|
||||
'tier': getattr(item_def, 'tier', 1),
|
||||
'type': item_def.type,
|
||||
'category': item_def.type, # Add category for filtering
|
||||
'slot': getattr(item_def, 'slot', None),
|
||||
'materials': materials_info,
|
||||
'tools': tools_info,
|
||||
'craft_level': craft_level,
|
||||
'meets_level': meets_level,
|
||||
'uncraftable': getattr(item_def, 'uncraftable', False),
|
||||
'can_craft': can_craft,
|
||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'craft'),
|
||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'craft'),
|
||||
'base_stats': {k: int(v) if isinstance(v, (int, float)) else v for k, v in getattr(item_def, 'stats', {}).items()}
|
||||
})
|
||||
|
||||
# Sort: craftable items first, then by tier, then by name
|
||||
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name']))
|
||||
|
||||
return {'craftable_items': craftable_items}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting craftable items: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class CraftItemRequest(BaseModel):
|
||||
item_id: str
|
||||
|
||||
|
||||
@router.post("/api/game/craft_item")
|
||||
async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Craft an item, consuming materials and creating item with random stats for unique items"""
|
||||
try:
|
||||
player = current_user # current_user is already the character dict
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location_id = player['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
# Check if player is at a workbench
|
||||
if not location or 'workbench' not in getattr(location, 'tags', []):
|
||||
raise HTTPException(status_code=400, detail="You must be at a workbench to craft items")
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.items.get(request.item_id)
|
||||
if not item_def:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
if not getattr(item_def, 'craftable', False):
|
||||
raise HTTPException(status_code=400, detail="This item cannot be crafted")
|
||||
|
||||
# Check level requirement
|
||||
craft_level = getattr(item_def, 'craft_level', 1)
|
||||
player_level = player.get('level', 1)
|
||||
if player_level < craft_level:
|
||||
raise HTTPException(status_code=400, detail=f"You need to be level {craft_level} to craft this item (you are level {player_level})")
|
||||
|
||||
craft_materials = getattr(item_def, 'craft_materials', [])
|
||||
if not craft_materials:
|
||||
raise HTTPException(status_code=400, detail="No crafting recipe found")
|
||||
|
||||
# Check if player has all materials
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
inventory_counts = {}
|
||||
inventory_items_map = {}
|
||||
|
||||
for inv_item in inventory:
|
||||
item_id = inv_item['item_id']
|
||||
quantity = inv_item.get('quantity', 1)
|
||||
inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity
|
||||
if item_id not in inventory_items_map:
|
||||
inventory_items_map[item_id] = []
|
||||
inventory_items_map[item_id].append(inv_item)
|
||||
|
||||
# Check tools requirement
|
||||
craft_tools = getattr(item_def, 'craft_tools', [])
|
||||
if craft_tools:
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], craft_tools, inventory)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
else:
|
||||
tools_consumed = []
|
||||
|
||||
# Verify all materials are available
|
||||
for material in craft_materials:
|
||||
required = material['quantity']
|
||||
available = inventory_counts.get(material['item_id'], 0)
|
||||
if available < required:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Not enough {material['item_id']}. Need {required}, have {available}"
|
||||
)
|
||||
|
||||
# Consume materials
|
||||
materials_used = []
|
||||
for material in craft_materials:
|
||||
item_id = material['item_id']
|
||||
quantity_needed = material['quantity']
|
||||
|
||||
items_of_type = inventory_items_map[item_id]
|
||||
for inv_item in items_of_type:
|
||||
if quantity_needed <= 0:
|
||||
break
|
||||
|
||||
inv_quantity = inv_item.get('quantity', 1)
|
||||
to_remove = min(quantity_needed, inv_quantity)
|
||||
|
||||
if inv_quantity > to_remove:
|
||||
# Update quantity
|
||||
await db.update_inventory_item(
|
||||
inv_item['id'],
|
||||
quantity=inv_quantity - to_remove
|
||||
)
|
||||
else:
|
||||
# Remove entire stack - use item_id string, not inventory row id
|
||||
await db.remove_item_from_inventory(current_user['id'], item_id, to_remove)
|
||||
|
||||
quantity_needed -= to_remove
|
||||
|
||||
mat_item_def = ITEMS_MANAGER.items.get(item_id)
|
||||
materials_used.append({
|
||||
'item_id': item_id,
|
||||
'name': mat_item_def.name if mat_item_def else item_id,
|
||||
'quantity': material['quantity']
|
||||
})
|
||||
|
||||
# Calculate stamina cost
|
||||
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'craft')
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
|
||||
|
||||
# Deduct stamina
|
||||
new_stamina = max(0, player['stamina'] - stamina_cost)
|
||||
await db.update_player_stamina(current_user['id'], new_stamina)
|
||||
|
||||
# Generate random stats for unique items
|
||||
import random
|
||||
created_item = None
|
||||
|
||||
if hasattr(item_def, 'durability') and item_def.durability:
|
||||
# This is a unique item - generate random stats
|
||||
base_durability = item_def.durability
|
||||
# Random durability: 90-110% of base
|
||||
random_durability = int(base_durability * random.uniform(0.9, 1.1))
|
||||
|
||||
# Generate tier based on durability roll
|
||||
durability_percent = (random_durability / base_durability)
|
||||
if durability_percent >= 1.08:
|
||||
tier = 5 # Gold
|
||||
elif durability_percent >= 1.04:
|
||||
tier = 4 # Purple
|
||||
elif durability_percent >= 1.0:
|
||||
tier = 3 # Blue
|
||||
elif durability_percent >= 0.96:
|
||||
tier = 2 # Green
|
||||
else:
|
||||
tier = 1 # White
|
||||
|
||||
# Generate random stats if item has stats
|
||||
random_stats = {}
|
||||
if hasattr(item_def, 'stats') and item_def.stats:
|
||||
for stat_key, stat_value in item_def.stats.items():
|
||||
if isinstance(stat_value, (int, float)):
|
||||
# Random stat: 90-110% of base
|
||||
random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1))
|
||||
else:
|
||||
random_stats[stat_key] = stat_value
|
||||
|
||||
# Create unique item in database
|
||||
unique_item_id = await db.create_unique_item(
|
||||
item_id=request.item_id,
|
||||
durability=random_durability,
|
||||
max_durability=random_durability,
|
||||
tier=tier,
|
||||
unique_stats=random_stats
|
||||
)
|
||||
|
||||
# Add to inventory
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=request.item_id,
|
||||
quantity=1,
|
||||
unique_item_id=unique_item_id
|
||||
)
|
||||
|
||||
created_item = {
|
||||
'item_id': request.item_id,
|
||||
'name': item_def.name,
|
||||
'emoji': item_def.emoji,
|
||||
'tier': tier,
|
||||
'durability': random_durability,
|
||||
'max_durability': random_durability,
|
||||
'stats': random_stats,
|
||||
'unique': True
|
||||
}
|
||||
else:
|
||||
# Stackable item - just add to inventory
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=request.item_id,
|
||||
quantity=1
|
||||
)
|
||||
|
||||
created_item = {
|
||||
'item_id': request.item_id,
|
||||
'name': item_def.name,
|
||||
'emoji': item_def.emoji,
|
||||
'tier': getattr(item_def, 'tier', 1),
|
||||
'unique': False
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"Successfully crafted {item_def.name}!",
|
||||
'item': created_item,
|
||||
'materials_consumed': materials_used,
|
||||
'tools_consumed': tools_consumed,
|
||||
'stamina_cost': stamina_cost,
|
||||
'new_stamina': new_stamina
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error crafting item: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class UncraftItemRequest(BaseModel):
|
||||
inventory_id: int
|
||||
|
||||
|
||||
@router.post("/api/game/uncraft_item")
|
||||
async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Uncraft an item, returning materials with a chance of loss"""
|
||||
try:
|
||||
player = current_user # current_user is already the character dict
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location_id = player['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
# Check if player is at a workbench
|
||||
if not location or 'workbench' not in getattr(location, 'tags', []):
|
||||
raise HTTPException(status_code=400, detail="You must be at a workbench to uncraft items")
|
||||
|
||||
# Get inventory item
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
inv_item = None
|
||||
for item in inventory:
|
||||
if item['id'] == request.inventory_id:
|
||||
inv_item = item
|
||||
break
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
|
||||
if not item_def:
|
||||
raise HTTPException(status_code=404, detail="Item definition not found")
|
||||
|
||||
if not getattr(item_def, 'uncraftable', False):
|
||||
raise HTTPException(status_code=400, detail="This item cannot be uncrafted")
|
||||
|
||||
uncraft_yield = getattr(item_def, 'uncraft_yield', [])
|
||||
if not uncraft_yield:
|
||||
raise HTTPException(status_code=400, detail="No uncraft recipe found")
|
||||
|
||||
# Check tools requirement
|
||||
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
|
||||
if uncraft_tools:
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
else:
|
||||
tools_consumed = []
|
||||
|
||||
# Calculate stamina cost
|
||||
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
|
||||
|
||||
# Deduct stamina
|
||||
new_stamina = max(0, player['stamina'] - stamina_cost)
|
||||
await db.update_player_stamina(current_user['id'], new_stamina)
|
||||
|
||||
# Remove the item from inventory
|
||||
# Use remove_inventory_row since we have the inventory ID
|
||||
await db.remove_inventory_row(inv_item['id'])
|
||||
|
||||
# Calculate durability ratio for yield reduction
|
||||
durability_ratio = 1.0 # Default: full yield
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique_item:
|
||||
current_durability = unique_item.get('durability', 0)
|
||||
max_durability = unique_item.get('max_durability', 1)
|
||||
if max_durability > 0:
|
||||
durability_ratio = current_durability / max_durability
|
||||
|
||||
# Re-fetch inventory to get updated capacity after removing the item
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
# Calculate materials with loss chance and durability reduction
|
||||
import random
|
||||
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
|
||||
yield_info = {
|
||||
'base_yield': uncraft_yield,
|
||||
'loss_chance': loss_chance,
|
||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
}
|
||||
materials_yielded = []
|
||||
materials_lost = []
|
||||
materials_dropped = []
|
||||
|
||||
for material in uncraft_yield:
|
||||
# Apply durability reduction first
|
||||
base_quantity = material['quantity']
|
||||
|
||||
# Calculate adjusted quantity based on durability
|
||||
# Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
|
||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
|
||||
# If durability is too low (< 10%), yield nothing for this material
|
||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||
materials_lost.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'quantity': base_quantity,
|
||||
'reason': 'durability_too_low'
|
||||
})
|
||||
continue
|
||||
|
||||
# Roll for each material separately with loss chance
|
||||
if random.random() < loss_chance:
|
||||
# Lost this material
|
||||
materials_lost.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'quantity': adjusted_quantity,
|
||||
'reason': 'random_loss'
|
||||
})
|
||||
else:
|
||||
# Check if it fits in inventory
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
|
||||
|
||||
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
|
||||
# Fits in inventory
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=material['item_id'],
|
||||
quantity=adjusted_quantity
|
||||
)
|
||||
|
||||
# Update current capacity tracking
|
||||
current_weight += mat_weight
|
||||
current_volume += mat_volume
|
||||
|
||||
materials_yielded.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'emoji': mat_def.emoji if mat_def else '📦',
|
||||
'quantity': adjusted_quantity
|
||||
})
|
||||
else:
|
||||
# Inventory full - drop to ground
|
||||
await db.drop_item_to_world(
|
||||
item_id=material['item_id'],
|
||||
quantity=adjusted_quantity,
|
||||
location_id=player['location_id']
|
||||
)
|
||||
|
||||
materials_dropped.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'emoji': mat_def.emoji if mat_def else '📦',
|
||||
'quantity': adjusted_quantity
|
||||
})
|
||||
|
||||
message = f"Uncrafted {item_def.name}!"
|
||||
if durability_ratio < 1.0:
|
||||
message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)"
|
||||
if materials_lost:
|
||||
message += f" Lost {len(materials_lost)} material type(s)."
|
||||
if materials_dropped:
|
||||
message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'item_name': item_def.name,
|
||||
'materials_yielded': materials_yielded,
|
||||
'materials_lost': materials_lost,
|
||||
'materials_dropped': materials_dropped,
|
||||
'tools_consumed': tools_consumed,
|
||||
'loss_chance': loss_chance,
|
||||
'durability_ratio': round(durability_ratio, 2),
|
||||
'stamina_cost': stamina_cost,
|
||||
'new_stamina': new_stamina
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error uncrafting item: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
783
api/routers/equipment.py
Normal file
783
api/routers/equipment.py
Normal file
@@ -0,0 +1,783 @@
|
||||
"""
|
||||
Equipment router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
from ..core.websockets import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These will be injected by main.py
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
|
||||
router = APIRouter(tags=["equipment"])
|
||||
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.post("/api/game/equip")
|
||||
async def equip_item(
|
||||
equip_req: EquipItemRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Equip an item from inventory"""
|
||||
player_id = current_user['id']
|
||||
|
||||
# Get the inventory item
|
||||
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
||||
if not inv_item or inv_item['character_id'] != player_id:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if not item_def:
|
||||
raise HTTPException(status_code=404, detail="Item definition not found")
|
||||
|
||||
# Check if item is equippable
|
||||
if not item_def.equippable or not item_def.slot:
|
||||
raise HTTPException(status_code=400, detail="This item cannot be equipped")
|
||||
|
||||
# Check if slot is valid
|
||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||
if item_def.slot not in valid_slots:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {item_def.slot}")
|
||||
|
||||
# Check if slot is already occupied
|
||||
current_equipped = await db.get_equipped_item_in_slot(player_id, item_def.slot)
|
||||
unequipped_item_name = None
|
||||
|
||||
if current_equipped and current_equipped.get('item_id'):
|
||||
# Get the old item's name for the message
|
||||
old_inv_item = await db.get_inventory_item_by_id(current_equipped['item_id'])
|
||||
if old_inv_item:
|
||||
old_item_def = ITEMS_MANAGER.get_item(old_inv_item['item_id'])
|
||||
unequipped_item_name = old_item_def.name if old_item_def else "previous item"
|
||||
|
||||
# Unequip current item first
|
||||
await db.unequip_item(player_id, item_def.slot)
|
||||
# Mark as not equipped in inventory
|
||||
await db.update_inventory_item(current_equipped['item_id'], is_equipped=False)
|
||||
|
||||
# Equip the new item
|
||||
await db.equip_item(player_id, item_def.slot, equip_req.inventory_id)
|
||||
|
||||
# Mark as equipped in inventory
|
||||
await db.update_inventory_item(equip_req.inventory_id, is_equipped=True)
|
||||
|
||||
# Initialize unique_item if this is first time equipping an equippable with durability
|
||||
if inv_item.get('unique_item_id') is None and item_def.durability:
|
||||
# Create a unique_item instance for this equipment
|
||||
# Save base stats to unique_stats
|
||||
base_stats = {k: int(v) if isinstance(v, (int, float)) else v for k, v in item_def.stats.items()} if item_def.stats else {}
|
||||
unique_item_id = await db.create_unique_item(
|
||||
item_id=item_def.id,
|
||||
durability=item_def.durability,
|
||||
max_durability=item_def.durability,
|
||||
tier=item_def.tier if hasattr(item_def, 'tier') else 1,
|
||||
unique_stats=base_stats
|
||||
)
|
||||
# Link the inventory item to this unique_item
|
||||
await db.update_inventory_item(
|
||||
equip_req.inventory_id,
|
||||
unique_item_id=unique_item_id
|
||||
)
|
||||
|
||||
# Build message
|
||||
if unequipped_item_name:
|
||||
message = f"Unequipped {unequipped_item_name}, equipped {item_def.name}"
|
||||
else:
|
||||
message = f"Equipped {item_def.name}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"slot": item_def.slot,
|
||||
"unequipped_item": unequipped_item_name
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/game/unequip")
|
||||
async def unequip_item(
|
||||
unequip_req: UnequipItemRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Unequip an item from equipment slot"""
|
||||
player_id = current_user['id']
|
||||
|
||||
# Check if slot is valid
|
||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||
if unequip_req.slot not in valid_slots:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {unequip_req.slot}")
|
||||
|
||||
# Get currently equipped item
|
||||
equipped = await db.get_equipped_item_in_slot(player_id, unequip_req.slot)
|
||||
if not equipped:
|
||||
raise HTTPException(status_code=400, detail=f"No item equipped in {unequip_req.slot} slot")
|
||||
|
||||
# Get inventory item and item definition
|
||||
inv_item = await db.get_inventory_item_by_id(equipped['item_id'])
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
|
||||
# Check if inventory has space (volume-wise)
|
||||
inventory = await db.get_inventory(player_id)
|
||||
total_volume = sum(
|
||||
ITEMS_MANAGER.get_item(i['item_id']).volume * i['quantity']
|
||||
for i in inventory
|
||||
if ITEMS_MANAGER.get_item(i['item_id']) and not i['is_equipped']
|
||||
)
|
||||
|
||||
# Get max volume (base 10 + backpack bonus)
|
||||
max_volume = 10.0
|
||||
for inv in inventory:
|
||||
if inv['is_equipped']:
|
||||
item = ITEMS_MANAGER.get_item(inv['item_id'])
|
||||
if item:
|
||||
# Use unique_stats if this is a unique item, otherwise fall back to default stats
|
||||
if inv.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv['unique_item_id'])
|
||||
if unique_item and unique_item.get('unique_stats'):
|
||||
max_volume += unique_item['unique_stats'].get('volume_capacity', 0)
|
||||
elif item.stats:
|
||||
max_volume += item.stats.get('volume_capacity', 0)
|
||||
|
||||
# If unequipping backpack, check if items will fit
|
||||
if unequip_req.slot == 'backpack':
|
||||
# Get the backpack's volume capacity from unique_stats if available
|
||||
backpack_volume = 0
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique_item and unique_item.get('unique_stats'):
|
||||
backpack_volume = unique_item['unique_stats'].get('volume_capacity', 0)
|
||||
elif item_def.stats:
|
||||
backpack_volume = item_def.stats.get('volume_capacity', 0)
|
||||
|
||||
if backpack_volume > 0 and total_volume > (max_volume - backpack_volume):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot unequip backpack: inventory would exceed volume capacity"
|
||||
)
|
||||
|
||||
# Check if adding this item would exceed volume
|
||||
if total_volume + item_def.volume > max_volume:
|
||||
# Drop to ground instead
|
||||
await db.unequip_item(player_id, unequip_req.slot)
|
||||
await db.update_inventory_item(equipped['item_id'], is_equipped=False)
|
||||
await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id'])
|
||||
await db.remove_from_inventory(player_id, inv_item['item_id'], 1)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Unequipped {item_def.name} (dropped to ground - inventory full)",
|
||||
"dropped": True
|
||||
}
|
||||
|
||||
# Unequip the item
|
||||
await db.unequip_item(player_id, unequip_req.slot)
|
||||
await db.update_inventory_item(equipped['item_id'], is_equipped=False)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Unequipped {item_def.name}",
|
||||
"dropped": False
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/game/equipment")
|
||||
async def get_equipment(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all equipped items"""
|
||||
player_id = current_user['id']
|
||||
|
||||
equipment = await db.get_all_equipment(player_id)
|
||||
|
||||
# Enrich with item data
|
||||
enriched = {}
|
||||
for slot, item_data in equipment.items():
|
||||
if item_data:
|
||||
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if item_def:
|
||||
enriched[slot] = {
|
||||
"inventory_id": item_data['item_id'],
|
||||
"item_id": item_def.id,
|
||||
"name": item_def.name,
|
||||
"description": item_def.description,
|
||||
"emoji": item_def.emoji,
|
||||
"image_path": item_def.image_path,
|
||||
"durability": inv_item.get('durability'),
|
||||
"max_durability": inv_item.get('max_durability'),
|
||||
"tier": inv_item.get('tier', 1),
|
||||
"stats": item_def.stats,
|
||||
"encumbrance": item_def.encumbrance
|
||||
}
|
||||
else:
|
||||
enriched[slot] = None
|
||||
|
||||
return {"equipment": enriched}
|
||||
|
||||
|
||||
@router.post("/api/game/repair_item")
|
||||
async def repair_item(
|
||||
repair_req: RepairItemRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Repair an item using materials at a workbench location"""
|
||||
player_id = current_user['id']
|
||||
|
||||
# Get player's location
|
||||
player = await db.get_player_by_id(player_id)
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# Check if location has workbench
|
||||
location_tags = getattr(location, 'tags', [])
|
||||
if 'workbench' not in location_tags and 'repair_station' not in location_tags:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="You need to be at a location with a workbench to repair items. Try the Gas Station!"
|
||||
)
|
||||
|
||||
# Get inventory item
|
||||
inv_item = await db.get_inventory_item(repair_req.inventory_id)
|
||||
if not inv_item or inv_item['character_id'] != player_id:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if not item_def:
|
||||
raise HTTPException(status_code=404, detail="Item definition not found")
|
||||
|
||||
# Check if item is repairable
|
||||
if not getattr(item_def, 'repairable', False):
|
||||
raise HTTPException(status_code=400, detail=f"{item_def.name} cannot be repaired")
|
||||
|
||||
# Check if item has durability (unique item)
|
||||
if not inv_item.get('unique_item_id'):
|
||||
raise HTTPException(status_code=400, detail="This item doesn't have durability tracking")
|
||||
|
||||
# Get unique item data
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if not unique_item:
|
||||
raise HTTPException(status_code=500, detail="Unique item data not found")
|
||||
|
||||
current_durability = unique_item.get('durability', 0)
|
||||
max_durability = unique_item.get('max_durability', 100)
|
||||
|
||||
# Check if item needs repair
|
||||
if current_durability >= max_durability:
|
||||
raise HTTPException(status_code=400, detail=f"{item_def.name} is already at full durability")
|
||||
|
||||
# Get repair materials
|
||||
repair_materials = getattr(item_def, 'repair_materials', [])
|
||||
if not repair_materials:
|
||||
raise HTTPException(status_code=500, detail="Item repair configuration missing")
|
||||
|
||||
# Get repair tools
|
||||
repair_tools = getattr(item_def, 'repair_tools', [])
|
||||
|
||||
# Check if player has all required materials and tools
|
||||
player_inventory = await db.get_inventory(player_id)
|
||||
inventory_dict = {item['item_id']: item['quantity'] for item in player_inventory}
|
||||
|
||||
missing_materials = []
|
||||
for material in repair_materials:
|
||||
required_qty = material.get('quantity', 1)
|
||||
available_qty = inventory_dict.get(material['item_id'], 0)
|
||||
if available_qty < required_qty:
|
||||
material_def = ITEMS_MANAGER.get_item(material['item_id'])
|
||||
material_name = material_def.name if material_def else material['item_id']
|
||||
missing_materials.append(f"{material_name} ({available_qty}/{required_qty})")
|
||||
|
||||
if missing_materials:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Missing materials: {', '.join(missing_materials)}"
|
||||
)
|
||||
|
||||
# Check and consume tools if required
|
||||
tools_consumed = []
|
||||
if repair_tools:
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(player_id, repair_tools, player_inventory)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
# Calculate stamina cost
|
||||
stamina_cost = calculate_crafting_stamina_cost(unique_item.get('tier', 1), 'repair')
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
|
||||
|
||||
# Deduct stamina
|
||||
new_stamina = max(0, player['stamina'] - stamina_cost)
|
||||
await db.update_player_stamina(player_id, new_stamina)
|
||||
|
||||
# Consume materials
|
||||
for material in repair_materials:
|
||||
await db.remove_item_from_inventory(player_id, material['item_id'], material['quantity'])
|
||||
|
||||
# Calculate repair amount
|
||||
repair_percentage = getattr(item_def, 'repair_percentage', 25)
|
||||
repair_amount = int((max_durability * repair_percentage) / 100)
|
||||
new_durability = min(current_durability + repair_amount, max_durability)
|
||||
|
||||
# Update unique item durability
|
||||
await db.update_unique_item(inv_item['unique_item_id'], durability=new_durability)
|
||||
|
||||
# Build materials consumed message
|
||||
materials_used = []
|
||||
for material in repair_materials:
|
||||
material_def = ITEMS_MANAGER.get_item(material['item_id'])
|
||||
emoji = material_def.emoji if material_def and hasattr(material_def, 'emoji') else '📦'
|
||||
name = material_def.name if material_def else material['item_id']
|
||||
materials_used.append(f"{emoji} {name} x{material['quantity']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Repaired {item_def.name}! Restored {repair_amount} durability.",
|
||||
"item_name": item_def.name,
|
||||
"old_durability": current_durability,
|
||||
"new_durability": new_durability,
|
||||
"max_durability": max_durability,
|
||||
"materials_consumed": materials_used,
|
||||
"tools_consumed": tools_consumed,
|
||||
"repair_amount": repair_amount,
|
||||
"stamina_cost": stamina_cost,
|
||||
"new_stamina": new_stamina
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
|
||||
"""
|
||||
Reduce durability of equipped armor pieces when taking damage.
|
||||
Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate)
|
||||
Base reduction rate: 0.5 (so 10 damage with 5 armor = 1 durability loss)
|
||||
Returns: (armor_damage_absorbed, broken_armor_pieces)
|
||||
"""
|
||||
equipment = await db.get_all_equipment(player_id)
|
||||
armor_pieces = ['head', 'torso', 'legs', 'feet']
|
||||
|
||||
total_armor = 0
|
||||
equipped_armor = []
|
||||
|
||||
# Collect all equipped armor
|
||||
for slot in armor_pieces:
|
||||
if equipment.get(slot) and equipment[slot]:
|
||||
armor_slot = equipment[slot]
|
||||
inv_item = await db.get_inventory_item_by_id(armor_slot['item_id'])
|
||||
if inv_item and inv_item.get('unique_item_id'):
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if item_def and item_def.stats and 'armor' in item_def.stats:
|
||||
armor_value = item_def.stats['armor']
|
||||
total_armor += armor_value
|
||||
equipped_armor.append({
|
||||
'slot': slot,
|
||||
'inv_item_id': armor_slot['item_id'],
|
||||
'unique_item_id': inv_item['unique_item_id'],
|
||||
'item_id': inv_item['item_id'],
|
||||
'item_def': item_def,
|
||||
'armor_value': armor_value
|
||||
})
|
||||
|
||||
if not equipped_armor:
|
||||
return 0, []
|
||||
|
||||
# Calculate damage absorbed by armor (total armor reduces damage)
|
||||
armor_absorbed = min(damage_taken // 2, total_armor) # Armor absorbs up to half the damage
|
||||
|
||||
# Calculate durability loss for each armor piece
|
||||
# Balanced formula: armor should last many combats (10-20+ hits for low tier)
|
||||
base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable
|
||||
broken_armor = []
|
||||
|
||||
for armor in equipped_armor:
|
||||
# Each piece takes durability loss proportional to its armor value
|
||||
proportion = armor['armor_value'] / total_armor if total_armor > 0 else 0
|
||||
# Formula: durability_loss = (damage_taken * proportion / armor_value) * base_rate
|
||||
# This means higher armor value = less durability loss per hit
|
||||
# With base_rate = 0.1, a 5 armor piece taking 10 damage loses ~0.2 durability per hit
|
||||
durability_loss = max(1, int((damage_taken * proportion / max(armor['armor_value'], 1)) * base_reduction_rate * 10))
|
||||
|
||||
# Get current durability
|
||||
unique_item = await db.get_unique_item(armor['unique_item_id'])
|
||||
if unique_item:
|
||||
current_durability = unique_item.get('durability', 0)
|
||||
new_durability = max(0, current_durability - durability_loss)
|
||||
|
||||
await db.update_unique_item(armor['unique_item_id'], durability=new_durability)
|
||||
|
||||
# If armor broke, unequip and remove from inventory
|
||||
if new_durability <= 0:
|
||||
await db.unequip_item(player_id, armor['slot'])
|
||||
await db.remove_inventory_row(armor['inv_item_id'])
|
||||
broken_armor.append({
|
||||
'name': armor['item_def'].name,
|
||||
'emoji': armor['item_def'].emoji,
|
||||
'slot': armor['slot']
|
||||
})
|
||||
|
||||
return armor_absorbed, broken_armor
|
||||
|
||||
|
||||
async def consume_tool_durability(user_id: int, tools: list, inventory: list) -> tuple:
|
||||
"""
|
||||
Consume durability from required tools.
|
||||
Returns: (success, error_message, consumed_tools_info)
|
||||
"""
|
||||
consumed_tools = []
|
||||
tools_map = {}
|
||||
|
||||
# Build map of available tools with durability
|
||||
for inv_item in inventory:
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique_item:
|
||||
item_id = inv_item['item_id']
|
||||
durability = unique_item.get('durability', 0)
|
||||
if item_id not in tools_map:
|
||||
tools_map[item_id] = []
|
||||
tools_map[item_id].append({
|
||||
'inventory_id': inv_item['id'],
|
||||
'unique_item_id': inv_item['unique_item_id'],
|
||||
'durability': durability,
|
||||
'max_durability': unique_item.get('max_durability', 100)
|
||||
})
|
||||
|
||||
# Check and consume tools
|
||||
for tool_req in tools:
|
||||
tool_id = tool_req['item_id']
|
||||
durability_cost = tool_req['durability_cost']
|
||||
|
||||
if tool_id not in tools_map or not tools_map[tool_id]:
|
||||
tool_def = ITEMS_MANAGER.items.get(tool_id)
|
||||
tool_name = tool_def.name if tool_def else tool_id
|
||||
return False, f"Missing required tool: {tool_name}", []
|
||||
|
||||
# Find tool with enough durability
|
||||
tool_found = None
|
||||
for tool in tools_map[tool_id]:
|
||||
if tool['durability'] >= durability_cost:
|
||||
tool_found = tool
|
||||
break
|
||||
|
||||
if not tool_found:
|
||||
tool_def = ITEMS_MANAGER.items.get(tool_id)
|
||||
tool_name = tool_def.name if tool_def else tool_id
|
||||
return False, f"Tool {tool_name} doesn't have enough durability (need {durability_cost})", []
|
||||
|
||||
# Consume durability
|
||||
new_durability = tool_found['durability'] - durability_cost
|
||||
await db.update_unique_item(tool_found['unique_item_id'], durability=new_durability)
|
||||
|
||||
# If tool breaks, remove from inventory
|
||||
if new_durability <= 0:
|
||||
await db.remove_inventory_row(tool_found['inventory_id'])
|
||||
|
||||
tool_def = ITEMS_MANAGER.items.get(tool_id)
|
||||
consumed_tools.append({
|
||||
'item_id': tool_id,
|
||||
'name': tool_def.name if tool_def else tool_id,
|
||||
'durability_cost': durability_cost,
|
||||
'broke': new_durability <= 0
|
||||
})
|
||||
|
||||
return True, "", consumed_tools
|
||||
|
||||
|
||||
@router.get("/api/game/repairable")
|
||||
async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all repairable items from inventory and equipped slots"""
|
||||
try:
|
||||
player = current_user # current_user is already the character dict
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location_id = player['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
# Check if player is at a repair station
|
||||
if not location or 'repair_station' not in getattr(location, 'tags', []):
|
||||
raise HTTPException(status_code=400, detail="You must be at a repair station to repair items")
|
||||
|
||||
repairable_items = []
|
||||
|
||||
# Check inventory items
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
inventory_counts = {}
|
||||
for inv_item in inventory:
|
||||
item_id = inv_item['item_id']
|
||||
quantity = inv_item.get('quantity', 1)
|
||||
inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity
|
||||
|
||||
for inv_item in inventory:
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if not unique_item:
|
||||
continue
|
||||
|
||||
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
|
||||
if not item_def or not getattr(item_def, 'repairable', False):
|
||||
continue
|
||||
|
||||
current_durability = unique_item.get('durability', 0)
|
||||
max_durability = unique_item.get('max_durability', 100)
|
||||
needs_repair = current_durability < max_durability
|
||||
|
||||
# Check materials availability
|
||||
repair_materials = getattr(item_def, 'repair_materials', [])
|
||||
materials_info = []
|
||||
has_materials = True
|
||||
for material in repair_materials:
|
||||
mat_item_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
available = inventory_counts.get(material['item_id'], 0)
|
||||
required = material['quantity']
|
||||
materials_info.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_item_def.name if mat_item_def else material['item_id'],
|
||||
'emoji': mat_item_def.emoji if mat_item_def else '📦',
|
||||
'quantity': required,
|
||||
'available': available,
|
||||
'has_enough': available >= required
|
||||
})
|
||||
if available < required:
|
||||
has_materials = False
|
||||
|
||||
# Check tools availability
|
||||
repair_tools = getattr(item_def, 'repair_tools', [])
|
||||
tools_info = []
|
||||
has_tools = True
|
||||
for tool_req in repair_tools:
|
||||
tool_id = tool_req['item_id']
|
||||
durability_cost = tool_req['durability_cost']
|
||||
tool_def = ITEMS_MANAGER.items.get(tool_id)
|
||||
|
||||
# Check if player has this tool (find one with highest durability)
|
||||
tool_found = False
|
||||
tool_durability = 0
|
||||
best_tool_unique = None
|
||||
|
||||
for check_item in inventory:
|
||||
if check_item['item_id'] == tool_id and check_item.get('unique_item_id'):
|
||||
unique = await db.get_unique_item(check_item['unique_item_id'])
|
||||
if unique and unique.get('durability', 0) >= durability_cost:
|
||||
if best_tool_unique is None or unique.get('durability', 0) > best_tool_unique.get('durability', 0):
|
||||
best_tool_unique = unique
|
||||
tool_found = True
|
||||
tool_durability = unique.get('durability', 0)
|
||||
|
||||
|
||||
tools_info.append({
|
||||
'item_id': tool_id,
|
||||
'name': tool_def.name if tool_def else tool_id,
|
||||
'emoji': tool_def.emoji if tool_def else '🔧',
|
||||
'durability_cost': durability_cost,
|
||||
'has_tool': tool_found,
|
||||
'tool_durability': tool_durability
|
||||
})
|
||||
if not tool_found:
|
||||
has_tools = False
|
||||
|
||||
can_repair = needs_repair and has_materials and has_tools
|
||||
|
||||
repairable_items.append({
|
||||
'inventory_id': inv_item['id'],
|
||||
'unique_item_id': inv_item['unique_item_id'],
|
||||
'item_id': inv_item['item_id'],
|
||||
'name': item_def.name,
|
||||
'emoji': item_def.emoji,
|
||||
'unique_item_data': {k: int(v) if isinstance(v, (int, float)) and k != 'durability_percent' else v for k, v in unique_item.items()},
|
||||
'tier': unique_item.get('tier', 1),
|
||||
'current_durability': current_durability,
|
||||
'max_durability': max_durability,
|
||||
'durability_percent': int((current_durability / max_durability) * 100),
|
||||
'repair_percentage': getattr(item_def, 'repair_percentage', 25),
|
||||
'needs_repair': needs_repair,
|
||||
'materials': materials_info,
|
||||
'tools': tools_info,
|
||||
'can_repair': can_repair,
|
||||
'location': 'equipped' if inv_item.get('is_equipped') else 'inventory',
|
||||
'stamina_cost': calculate_crafting_stamina_cost(unique_item.get('tier', 1), 'repair'),
|
||||
'type': getattr(item_def, 'type', 'misc')
|
||||
})
|
||||
|
||||
# Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name
|
||||
repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name']))
|
||||
|
||||
return {'repairable_items': repairable_items}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting repairable items: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/game/salvageable")
|
||||
async def get_salvageable_items(current_user: dict = Depends(get_current_user)):
|
||||
"""Get list of salvageable (uncraftable) items from inventory with their unique stats"""
|
||||
try:
|
||||
player = current_user # current_user is already the character dict
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location_id = player['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
# Check if player is at a workbench
|
||||
if not location or 'workbench' not in getattr(location, 'tags', []):
|
||||
return {'salvageable_items': [], 'at_workbench': False}
|
||||
|
||||
# Get inventory
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
|
||||
salvageable_items = []
|
||||
for inv_item in inventory:
|
||||
item_id = inv_item['item_id']
|
||||
item_def = ITEMS_MANAGER.items.get(item_id)
|
||||
|
||||
if not item_def or not getattr(item_def, 'uncraftable', False):
|
||||
continue
|
||||
|
||||
# Get unique item details if it exists
|
||||
unique_item_data = None
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique_item:
|
||||
current_durability = unique_item.get('durability', 0)
|
||||
max_durability = unique_item.get('max_durability', 1)
|
||||
durability_percent = int((current_durability / max_durability) * 100) if max_durability > 0 else 0
|
||||
|
||||
# Get item stats from definition merged with unique stats
|
||||
item_stats = {}
|
||||
if item_def.stats:
|
||||
item_stats = dict(item_def.stats)
|
||||
if unique_item.get('unique_stats'):
|
||||
item_stats.update(unique_item.get('unique_stats'))
|
||||
|
||||
unique_item_data = {
|
||||
'current_durability': current_durability,
|
||||
'max_durability': max_durability,
|
||||
'durability_percent': durability_percent,
|
||||
'tier': unique_item.get('tier', 1),
|
||||
'unique_stats': item_stats # Includes both base stats and unique overrides
|
||||
}
|
||||
|
||||
# Get uncraft yield
|
||||
uncraft_yield = getattr(item_def, 'uncraft_yield', [])
|
||||
yield_info = []
|
||||
for material in uncraft_yield:
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
yield_info.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'emoji': mat_def.emoji if mat_def else '📦',
|
||||
'quantity': material['quantity']
|
||||
})
|
||||
|
||||
# Check tools availability for uncrafting
|
||||
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
|
||||
tools_info = []
|
||||
has_tools = True
|
||||
for tool_req in uncraft_tools:
|
||||
tool_id = tool_req['item_id']
|
||||
durability_cost = tool_req['durability_cost']
|
||||
tool_def = ITEMS_MANAGER.items.get(tool_id)
|
||||
|
||||
# Check if player has this tool (find one with highest durability)
|
||||
tool_found = False
|
||||
tool_durability = 0
|
||||
best_tool_unique = None
|
||||
|
||||
for check_item in inventory:
|
||||
if check_item['item_id'] == tool_id and check_item.get('unique_item_id'):
|
||||
unique = await db.get_unique_item(check_item['unique_item_id'])
|
||||
if unique and unique.get('durability', 0) >= durability_cost:
|
||||
if best_tool_unique is None or unique.get('durability', 0) > best_tool_unique.get('durability', 0):
|
||||
best_tool_unique = unique
|
||||
tool_found = True
|
||||
tool_durability = unique.get('durability', 0)
|
||||
|
||||
tools_info.append({
|
||||
'item_id': tool_id,
|
||||
'name': tool_def.name if tool_def else tool_id,
|
||||
'emoji': tool_def.emoji if tool_def else '🔧',
|
||||
'durability_cost': durability_cost,
|
||||
'has_tool': tool_found,
|
||||
'tool_durability': tool_durability
|
||||
})
|
||||
|
||||
if not tool_found:
|
||||
has_tools = False
|
||||
|
||||
can_uncraft = has_tools
|
||||
|
||||
# Build item entry
|
||||
item_entry = {
|
||||
'inventory_id': inv_item['id'],
|
||||
'unique_item_id': inv_item.get('unique_item_id'),
|
||||
'item_id': item_id,
|
||||
'name': item_def.name,
|
||||
'emoji': item_def.emoji,
|
||||
'image_path': getattr(item_def, 'image_path', None),
|
||||
'tier': getattr(item_def, 'tier', 1),
|
||||
'quantity': inv_item['quantity'],
|
||||
'base_yield': yield_info,
|
||||
'loss_chance': getattr(item_def, 'uncraft_loss_chance', 0.3),
|
||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft'),
|
||||
'can_uncraft': can_uncraft,
|
||||
'uncraft_tools': tools_info,
|
||||
'location': 'equipped' if inv_item.get('is_equipped') else 'inventory',
|
||||
'type': getattr(item_def, 'type', 'misc')
|
||||
}
|
||||
|
||||
# Add unique item data if available
|
||||
if unique_item_data:
|
||||
item_entry['unique_item_data'] = unique_item_data
|
||||
item_entry['unique_stats'] = unique_item_data.get('unique_stats', {})
|
||||
item_entry['current_durability'] = unique_item_data.get('current_durability')
|
||||
item_entry['max_durability'] = unique_item_data.get('max_durability')
|
||||
item_entry['durability_percent'] = unique_item_data.get('durability_percent')
|
||||
|
||||
salvageable_items.append(item_entry)
|
||||
|
||||
return {
|
||||
'salvageable_items': salvageable_items,
|
||||
'at_workbench': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting salvageable items: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class LootCorpseRequest(BaseModel):
|
||||
corpse_id: str
|
||||
item_index: Optional[int] = None # Index of specific item to loot (None = all)
|
||||
1391
api/routers/game_routes.py
Normal file
1391
api/routers/game_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
504
api/routers/loot.py
Normal file
504
api/routers/loot.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
Loot router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
from ..core.websockets import manager
|
||||
from .equipment import consume_tool_durability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These will be injected by main.py
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
|
||||
router = APIRouter(tags=["loot"])
|
||||
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.get("/api/game/corpse/{corpse_id}")
|
||||
async def get_corpse_details(
|
||||
corpse_id: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get detailed information about a corpse's lootable items"""
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
from data.npcs import NPCS
|
||||
|
||||
# Parse corpse ID
|
||||
corpse_type, corpse_db_id = corpse_id.split('_', 1)
|
||||
corpse_db_id = int(corpse_db_id)
|
||||
|
||||
player = current_user # current_user is already the character dict
|
||||
|
||||
# Get player's inventory to check available tools
|
||||
inventory = await db.get_inventory(player['id'])
|
||||
available_tools = set([item['item_id'] for item in inventory])
|
||||
|
||||
if corpse_type == 'npc':
|
||||
# Get NPC corpse
|
||||
corpse = await db.get_npc_corpse(corpse_db_id)
|
||||
if not corpse:
|
||||
raise HTTPException(status_code=404, detail="Corpse not found")
|
||||
|
||||
if corpse['location_id'] != player['location_id']:
|
||||
raise HTTPException(status_code=400, detail="Corpse not at this location")
|
||||
|
||||
# Parse remaining loot
|
||||
loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else []
|
||||
|
||||
# Format loot items with tool requirements
|
||||
loot_items = []
|
||||
for idx, loot_item in enumerate(loot_remaining):
|
||||
required_tool = loot_item.get('required_tool')
|
||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||
|
||||
has_tool = required_tool is None or required_tool in available_tools
|
||||
tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None
|
||||
|
||||
loot_items.append({
|
||||
'index': idx,
|
||||
'item_id': loot_item['item_id'],
|
||||
'item_name': item_def.name if item_def else loot_item['item_id'],
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': loot_item['quantity_min'],
|
||||
'quantity_max': loot_item['quantity_max'],
|
||||
'required_tool': required_tool,
|
||||
'required_tool_name': tool_def.name if tool_def else required_tool,
|
||||
'has_tool': has_tool,
|
||||
'can_loot': has_tool
|
||||
})
|
||||
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
|
||||
return {
|
||||
'corpse_id': corpse_id,
|
||||
'type': 'npc',
|
||||
'name': f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
|
||||
'loot_items': loot_items,
|
||||
'total_items': len(loot_items)
|
||||
}
|
||||
|
||||
elif corpse_type == 'player':
|
||||
# Get player corpse
|
||||
corpse = await db.get_player_corpse(corpse_db_id)
|
||||
if not corpse:
|
||||
raise HTTPException(status_code=404, detail="Corpse not found")
|
||||
|
||||
if corpse['location_id'] != player['location_id']:
|
||||
raise HTTPException(status_code=400, detail="Corpse not at this location")
|
||||
|
||||
# Parse items
|
||||
items = json.loads(corpse['items']) if corpse['items'] else []
|
||||
|
||||
# Format items (player corpses don't require tools)
|
||||
loot_items = []
|
||||
for idx, item in enumerate(items):
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
|
||||
loot_items.append({
|
||||
'index': idx,
|
||||
'item_id': item['item_id'],
|
||||
'item_name': item_def.name if item_def else item['item_id'],
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': item['quantity'],
|
||||
'quantity_max': item['quantity'],
|
||||
'required_tool': None,
|
||||
'required_tool_name': None,
|
||||
'has_tool': True,
|
||||
'can_loot': True
|
||||
})
|
||||
|
||||
return {
|
||||
'corpse_id': corpse_id,
|
||||
'type': 'player',
|
||||
'name': f"{corpse['player_name']}'s Corpse",
|
||||
'loot_items': loot_items,
|
||||
'total_items': len(loot_items)
|
||||
}
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid corpse type")
|
||||
|
||||
|
||||
@router.post("/api/game/loot_corpse")
|
||||
async def loot_corpse(
|
||||
req: LootCorpseRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Loot a corpse (NPC or player) - can loot specific item by index or all items"""
|
||||
import json
|
||||
import sys
|
||||
import random
|
||||
sys.path.insert(0, '/app')
|
||||
from data.npcs import NPCS
|
||||
|
||||
# Parse corpse ID
|
||||
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
|
||||
corpse_db_id = int(corpse_db_id)
|
||||
|
||||
player = current_user # current_user is already the character dict
|
||||
|
||||
# Get player's current capacity
|
||||
inventory = await db.get_inventory(player['id'])
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
if corpse_type == 'npc':
|
||||
# Get NPC corpse
|
||||
corpse = await db.get_npc_corpse(corpse_db_id)
|
||||
if not corpse:
|
||||
raise HTTPException(status_code=404, detail="Corpse not found")
|
||||
|
||||
# Check if player is at the same location
|
||||
if corpse['location_id'] != player['location_id']:
|
||||
raise HTTPException(status_code=400, detail="Corpse not at this location")
|
||||
|
||||
# Parse remaining loot
|
||||
loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else []
|
||||
|
||||
if not loot_remaining:
|
||||
raise HTTPException(status_code=400, detail="Corpse has already been looted")
|
||||
|
||||
# Use inventory already fetched for capacity calculation
|
||||
available_tools = set([item['item_id'] for item in inventory])
|
||||
|
||||
looted_items = []
|
||||
remaining_loot = []
|
||||
dropped_items = [] # Items that couldn't fit in inventory
|
||||
tools_consumed = [] # Track tool durability consumed
|
||||
|
||||
# If specific item index provided, loot only that item
|
||||
if req.item_index is not None:
|
||||
if req.item_index < 0 or req.item_index >= len(loot_remaining):
|
||||
raise HTTPException(status_code=400, detail="Invalid item index")
|
||||
|
||||
loot_item = loot_remaining[req.item_index]
|
||||
required_tool = loot_item.get('required_tool')
|
||||
durability_cost = loot_item.get('tool_durability_cost', 5) # Default 5 durability per loot
|
||||
|
||||
# Check if player has required tool and consume durability
|
||||
if required_tool:
|
||||
# Build tool requirement format for consume_tool_durability
|
||||
tool_req = [{
|
||||
'item_id': required_tool,
|
||||
'durability_cost': durability_cost
|
||||
}]
|
||||
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
# Determine quantity
|
||||
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
|
||||
|
||||
if quantity > 0:
|
||||
# Check if item fits in inventory
|
||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||
if item_def:
|
||||
item_weight = item_def.weight * quantity
|
||||
item_volume = item_def.volume * quantity
|
||||
|
||||
if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume:
|
||||
# Item doesn't fit - drop it on ground
|
||||
await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity)
|
||||
dropped_items.append({
|
||||
'item_id': loot_item['item_id'],
|
||||
'quantity': quantity,
|
||||
'emoji': item_def.emoji
|
||||
})
|
||||
else:
|
||||
# Item fits - add to inventory
|
||||
await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity)
|
||||
current_weight += item_weight
|
||||
current_volume += item_volume
|
||||
looted_items.append({
|
||||
'item_id': loot_item['item_id'],
|
||||
'quantity': quantity
|
||||
})
|
||||
|
||||
# Remove this item from loot, keep others
|
||||
remaining_loot = [item for i, item in enumerate(loot_remaining) if i != req.item_index]
|
||||
else:
|
||||
# Loot all items that don't require tools or player has tools for
|
||||
for loot_item in loot_remaining:
|
||||
required_tool = loot_item.get('required_tool')
|
||||
durability_cost = loot_item.get('tool_durability_cost', 5)
|
||||
|
||||
# If tool is required, consume durability
|
||||
can_loot = True
|
||||
if required_tool:
|
||||
tool_req = [{
|
||||
'item_id': required_tool,
|
||||
'durability_cost': durability_cost
|
||||
}]
|
||||
|
||||
# Check if player has tool with enough durability
|
||||
success, error_msg, consumed_info = await consume_tool_durability(player['id'], tool_req, inventory)
|
||||
if success:
|
||||
# Tool consumed successfully
|
||||
tools_consumed.extend(consumed_info)
|
||||
# Refresh inventory after tool consumption
|
||||
inventory = await db.get_inventory(player['id'])
|
||||
else:
|
||||
# Can't loot this item
|
||||
can_loot = False
|
||||
|
||||
if can_loot:
|
||||
# Can loot this item
|
||||
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
|
||||
|
||||
if quantity > 0:
|
||||
# Check if item fits in inventory
|
||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||
if item_def:
|
||||
item_weight = item_def.weight * quantity
|
||||
item_volume = item_def.volume * quantity
|
||||
|
||||
if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume:
|
||||
# Item doesn't fit - drop it on ground
|
||||
await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity)
|
||||
dropped_items.append({
|
||||
'item_id': loot_item['item_id'],
|
||||
'quantity': quantity,
|
||||
'emoji': item_def.emoji
|
||||
})
|
||||
else:
|
||||
# Item fits - add to inventory
|
||||
await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity)
|
||||
current_weight += item_weight
|
||||
current_volume += item_volume
|
||||
looted_items.append({
|
||||
'item_id': loot_item['item_id'],
|
||||
'quantity': quantity
|
||||
})
|
||||
else:
|
||||
# Keep in corpse
|
||||
remaining_loot.append(loot_item)
|
||||
|
||||
# Update or remove corpse
|
||||
if remaining_loot:
|
||||
await db.update_npc_corpse(corpse_db_id, json.dumps(remaining_loot))
|
||||
else:
|
||||
await db.remove_npc_corpse(corpse_db_id)
|
||||
|
||||
# Build response message
|
||||
message_parts = []
|
||||
for item in looted_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||
|
||||
dropped_parts = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||
|
||||
message = ""
|
||||
if message_parts:
|
||||
message = "Looted: " + ", ".join(message_parts)
|
||||
if dropped_parts:
|
||||
if message:
|
||||
message += "\n"
|
||||
message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts)
|
||||
if not message_parts and not dropped_parts:
|
||||
message = "Nothing could be looted"
|
||||
if remaining_loot and req.item_index is None:
|
||||
message += f"\n{len(remaining_loot)} item(s) require tools to extract"
|
||||
|
||||
# Broadcast to location about corpse looting
|
||||
if len(remaining_loot) == 0:
|
||||
# Corpse fully looted
|
||||
await manager.send_to_location(
|
||||
location_id=player['location_id'],
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} fully looted an NPC corpse",
|
||||
"action": "corpse_looted"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
exclude_player_id=player['id']
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"looted_items": looted_items,
|
||||
"dropped_items": dropped_items,
|
||||
"tools_consumed": tools_consumed,
|
||||
"corpse_empty": len(remaining_loot) == 0,
|
||||
"remaining_count": len(remaining_loot)
|
||||
}
|
||||
|
||||
elif corpse_type == 'player':
|
||||
# Get player corpse
|
||||
corpse = await db.get_player_corpse(corpse_db_id)
|
||||
if not corpse:
|
||||
raise HTTPException(status_code=404, detail="Corpse not found")
|
||||
|
||||
if corpse['location_id'] != player['location_id']:
|
||||
raise HTTPException(status_code=400, detail="Corpse not at this location")
|
||||
|
||||
# Parse items
|
||||
items = json.loads(corpse['items']) if corpse['items'] else []
|
||||
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="Corpse has no items")
|
||||
|
||||
looted_items = []
|
||||
remaining_items = []
|
||||
dropped_items = [] # Items that couldn't fit in inventory
|
||||
|
||||
# If specific item index provided, loot only that item
|
||||
if req.item_index is not None:
|
||||
if req.item_index < 0 or req.item_index >= len(items):
|
||||
raise HTTPException(status_code=400, detail="Invalid item index")
|
||||
|
||||
item = items[req.item_index]
|
||||
|
||||
# Check if item fits in inventory
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
if item_def:
|
||||
item_weight = item_def.weight * item['quantity']
|
||||
item_volume = item_def.volume * item['quantity']
|
||||
|
||||
if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume:
|
||||
# Item doesn't fit - drop it on ground
|
||||
await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity'])
|
||||
dropped_items.append({
|
||||
'item_id': item['item_id'],
|
||||
'quantity': item['quantity'],
|
||||
'emoji': item_def.emoji
|
||||
})
|
||||
else:
|
||||
# Item fits - add to inventory
|
||||
await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity'])
|
||||
looted_items.append(item)
|
||||
|
||||
# Remove this item, keep others
|
||||
remaining_items = [it for i, it in enumerate(items) if i != req.item_index]
|
||||
else:
|
||||
# Loot all items
|
||||
for item in items:
|
||||
# Check if item fits in inventory
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
if item_def:
|
||||
item_weight = item_def.weight * item['quantity']
|
||||
item_volume = item_def.volume * item['quantity']
|
||||
|
||||
if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume:
|
||||
# Item doesn't fit - drop it on ground
|
||||
await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity'])
|
||||
dropped_items.append({
|
||||
'item_id': item['item_id'],
|
||||
'quantity': item['quantity'],
|
||||
'emoji': item_def.emoji
|
||||
})
|
||||
else:
|
||||
# Item fits - add to inventory
|
||||
await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity'])
|
||||
current_weight += item_weight
|
||||
current_volume += item_volume
|
||||
looted_items.append(item)
|
||||
|
||||
# Update or remove corpse
|
||||
if remaining_items:
|
||||
await db.update_player_corpse(corpse_db_id, json.dumps(remaining_items))
|
||||
else:
|
||||
await db.remove_player_corpse(corpse_db_id)
|
||||
|
||||
# Build message
|
||||
message_parts = []
|
||||
for item in looted_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||
|
||||
dropped_parts = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||
|
||||
message = ""
|
||||
if message_parts:
|
||||
message = "Looted: " + ", ".join(message_parts)
|
||||
if dropped_parts:
|
||||
if message:
|
||||
message += "\n"
|
||||
message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts)
|
||||
if not message_parts and not dropped_parts:
|
||||
message = "Nothing could be looted"
|
||||
|
||||
# Broadcast to location about corpse looting
|
||||
if len(remaining_items) == 0:
|
||||
# Corpse fully looted - broadcast removal
|
||||
await manager.send_to_location(
|
||||
location_id=player['location_id'],
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} fully looted {corpse['player_name']}'s corpse",
|
||||
"action": "player_corpse_emptied",
|
||||
"corpse_id": req.corpse_id
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
exclude_player_id=player['id']
|
||||
)
|
||||
else:
|
||||
# Corpse partially looted - broadcast item updates
|
||||
await manager.send_to_location(
|
||||
location_id=player['location_id'],
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} looted from {corpse['player_name']}'s corpse",
|
||||
"action": "player_corpse_looted",
|
||||
"corpse_id": req.corpse_id,
|
||||
"remaining_items": remaining_items,
|
||||
"looted_item_ids": [item['item_id'] for item in looted_items] if req.item_index is not None else None
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
exclude_player_id=player['id']
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"looted_items": looted_items,
|
||||
"dropped_items": dropped_items,
|
||||
"corpse_empty": len(remaining_items) == 0,
|
||||
"remaining_count": len(remaining_items)
|
||||
}
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid corpse type")
|
||||
109
api/routers/statistics.py
Normal file
109
api/routers/statistics.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Statistics router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import random
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
from ..core.websockets import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These will be injected by main.py
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
|
||||
router = APIRouter(tags=["statistics"])
|
||||
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.get("/api/statistics/online-players")
|
||||
async def get_online_players():
|
||||
"""Get the current number of connected players"""
|
||||
from ..redis_manager import redis_manager
|
||||
|
||||
if not redis_manager:
|
||||
return {"count": 0}
|
||||
|
||||
count = await redis_manager.get_connected_player_count()
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.get("/api/statistics/me")
|
||||
async def get_my_stats(current_user: dict = Depends(get_current_user)):
|
||||
"""Get current user's statistics"""
|
||||
stats = await db.get_player_statistics(current_user['id'])
|
||||
return {"statistics": stats}
|
||||
|
||||
|
||||
@router.get("/api/statistics/{player_id}")
|
||||
async def get_player_stats(player_id: int):
|
||||
"""Get character statistics by character ID (public)"""
|
||||
stats = await db.get_player_statistics(player_id)
|
||||
if not stats:
|
||||
raise HTTPException(status_code=404, detail="Character statistics not found")
|
||||
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
return {
|
||||
"player": {
|
||||
"id": player['id'],
|
||||
"name": player['name'],
|
||||
"level": player['level']
|
||||
},
|
||||
"statistics": stats
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/leaderboard/{stat_name}")
|
||||
async def get_leaderboard_by_stat(stat_name: str, limit: int = 100):
|
||||
"""
|
||||
Get leaderboard for a specific statistic.
|
||||
Available stats: distance_walked, enemies_killed, damage_dealt, damage_taken,
|
||||
hp_restored, stamina_used, items_collected, deaths, etc.
|
||||
"""
|
||||
valid_stats = [
|
||||
"distance_walked", "enemies_killed", "damage_dealt", "damage_taken",
|
||||
"hp_restored", "stamina_used", "stamina_restored", "items_collected",
|
||||
"items_dropped", "items_used", "deaths", "successful_flees", "failed_flees",
|
||||
"combats_initiated", "total_playtime"
|
||||
]
|
||||
|
||||
if stat_name not in valid_stats:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid stat name. Valid stats: {', '.join(valid_stats)}"
|
||||
)
|
||||
|
||||
leaderboard = await db.get_leaderboard(stat_name, limit)
|
||||
return {
|
||||
"stat_name": stat_name,
|
||||
"leaderboard": leaderboard
|
||||
}
|
||||
|
||||
0
api/services/__init__.py
Normal file
0
api/services/__init__.py
Normal file
245
api/services/helpers.py
Normal file
245
api/services/helpers.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Helper utilities for game calculations and common operations.
|
||||
Contains distance calculations, stamina costs, capacity calculations, etc.
|
||||
"""
|
||||
import math
|
||||
from typing import Tuple, List, Dict, Any
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
|
||||
|
||||
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two points using Euclidean distance.
|
||||
Coordinate system: 1 unit = 100 meters (so distance(0,0 to 1,1) = 141.4m)
|
||||
"""
|
||||
coord_distance = math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||
distance_meters = coord_distance * 100
|
||||
return distance_meters
|
||||
|
||||
|
||||
def calculate_stamina_cost(
|
||||
distance: float,
|
||||
weight: float,
|
||||
agility: int,
|
||||
max_weight: float = 10.0,
|
||||
volume: float = 0.0,
|
||||
max_volume: float = 10.0
|
||||
) -> int:
|
||||
"""
|
||||
Calculate stamina cost based on distance, weight, volume, capacity, and agility.
|
||||
- Base cost: distance / 50 (so 50m = 1 stamina, 100m = 2 stamina)
|
||||
- Weight penalty: +1 stamina per 10kg
|
||||
- Agility reduction: -1 stamina per 3 agility points
|
||||
- Over-capacity penalty: 50-200% extra if over weight OR volume limits
|
||||
- Minimum: 1 stamina
|
||||
"""
|
||||
base_cost = max(1, round(distance / 50))
|
||||
weight_penalty = int(weight / 10)
|
||||
agility_reduction = int(agility / 3)
|
||||
|
||||
# Add over-capacity penalty
|
||||
over_capacity_penalty = 0
|
||||
if weight > max_weight or volume > max_volume:
|
||||
weight_excess_ratio = max(0, (weight - max_weight) / max_weight) if max_weight > 0 else 0
|
||||
volume_excess_ratio = max(0, (volume - max_volume) / max_volume) if max_volume > 0 else 0
|
||||
excess_ratio = max(weight_excess_ratio, volume_excess_ratio)
|
||||
over_capacity_penalty = int((base_cost + weight_penalty) * (0.5 + min(1.5, excess_ratio)))
|
||||
|
||||
total_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction)
|
||||
return total_cost
|
||||
|
||||
|
||||
def calculate_crafting_stamina_cost(tier: int, action_type: str = 'craft') -> int:
|
||||
"""
|
||||
Calculate stamina cost for workbench actions.
|
||||
|
||||
Args:
|
||||
tier: Item tier (1-5)
|
||||
action_type: 'craft', 'repair', or 'uncraft'
|
||||
|
||||
Returns:
|
||||
Stamina cost
|
||||
"""
|
||||
if action_type == 'craft':
|
||||
# Crafting: max(5, tier * 3) -> T1=5, T5=15
|
||||
return max(5, tier * 3)
|
||||
elif action_type == 'repair':
|
||||
# Repairing: max(3, tier * 2) -> T1=3, T5=10
|
||||
return max(3, tier * 2)
|
||||
elif action_type == 'uncraft':
|
||||
# Salvaging: max(2, tier * 1) -> T1=2, T5=5
|
||||
return max(2, tier * 1)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
async def calculate_player_capacity(inventory: List[Dict[str, Any]], items_manager: ItemsManager) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Calculate player's current and max weight/volume capacity.
|
||||
Uses unique_stats for equipped items with unique_item_id.
|
||||
|
||||
Args:
|
||||
inventory: List of inventory items (from db.get_inventory)
|
||||
items_manager: ItemsManager instance
|
||||
|
||||
Returns: (current_weight, max_weight, current_volume, max_volume)
|
||||
"""
|
||||
current_weight = 0.0
|
||||
current_volume = 0.0
|
||||
max_weight = 10.0 # Base capacity
|
||||
max_volume = 10.0 # Base capacity
|
||||
|
||||
# Collect all unique_item_ids for equipped items
|
||||
equipped_unique_item_ids = [
|
||||
inv_item['unique_item_id']
|
||||
for inv_item in inventory
|
||||
if inv_item.get('is_equipped') and inv_item.get('unique_item_id')
|
||||
]
|
||||
|
||||
# Batch fetch all unique items in one query
|
||||
unique_items_map = {}
|
||||
if equipped_unique_item_ids:
|
||||
unique_items_map = await db.get_unique_items_batch(equipped_unique_item_ids)
|
||||
|
||||
for inv_item in inventory:
|
||||
item_def = items_manager.get_item(inv_item['item_id'])
|
||||
if item_def:
|
||||
current_weight += item_def.weight * inv_item['quantity']
|
||||
current_volume += item_def.volume * inv_item['quantity']
|
||||
|
||||
# Check for equipped bags/containers that increase capacity
|
||||
if inv_item['is_equipped']:
|
||||
# Use unique_stats if this is a unique item, otherwise fall back to default stats
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = unique_items_map.get(inv_item['unique_item_id'])
|
||||
if unique_item and unique_item.get('unique_stats'):
|
||||
max_weight += unique_item['unique_stats'].get('weight_capacity', 0)
|
||||
max_volume += unique_item['unique_stats'].get('volume_capacity', 0)
|
||||
elif item_def.stats:
|
||||
# Fallback to default stats if no unique_item_id
|
||||
max_weight += item_def.stats.get('weight_capacity', 0)
|
||||
max_volume += item_def.stats.get('volume_capacity', 0)
|
||||
|
||||
return current_weight, max_weight, current_volume, max_volume
|
||||
|
||||
|
||||
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Reduce durability of equipped armor pieces when taking damage.
|
||||
Returns: (armor_damage_absorbed, broken_armor_pieces)
|
||||
"""
|
||||
equipment = await db.get_all_equipment(player_id)
|
||||
armor_pieces = ['head', 'torso', 'legs', 'feet']
|
||||
|
||||
total_armor = 0
|
||||
equipped_armor = []
|
||||
|
||||
# Collect all equipped armor
|
||||
for slot in armor_pieces:
|
||||
if equipment.get(slot) and equipment[slot]:
|
||||
armor_slot = equipment[slot]
|
||||
inv_item = await db.get_inventory_item_by_id(armor_slot['item_id'])
|
||||
if inv_item and inv_item.get('unique_item_id'):
|
||||
item_def = items_manager.get_item(inv_item['item_id'])
|
||||
if item_def and item_def.stats and 'armor' in item_def.stats:
|
||||
armor_value = item_def.stats['armor']
|
||||
total_armor += armor_value
|
||||
equipped_armor.append({
|
||||
'slot': slot,
|
||||
'inv_item_id': armor_slot['item_id'],
|
||||
'unique_item_id': inv_item['unique_item_id'],
|
||||
'item_id': inv_item['item_id'],
|
||||
'item_def': item_def,
|
||||
'armor_value': armor_value
|
||||
})
|
||||
|
||||
if not equipped_armor:
|
||||
return 0, []
|
||||
|
||||
# Calculate damage absorbed by armor
|
||||
armor_absorbed = min(damage_taken // 2, total_armor)
|
||||
|
||||
# Calculate durability loss for each armor piece
|
||||
base_reduction_rate = 0.1
|
||||
broken_armor = []
|
||||
|
||||
for armor in equipped_armor:
|
||||
proportion = armor['armor_value'] / total_armor if total_armor > 0 else 0
|
||||
durability_loss = max(1, int((damage_taken * proportion / max(armor['armor_value'], 1)) * base_reduction_rate * 10))
|
||||
|
||||
# Get current durability
|
||||
unique_item = await db.get_unique_item(armor['unique_item_id'])
|
||||
if unique_item:
|
||||
current_durability = unique_item.get('durability', 0)
|
||||
new_durability = max(0, current_durability - durability_loss)
|
||||
|
||||
# If armor is about to break, unequip it first
|
||||
if new_durability <= 0:
|
||||
await db.unequip_item(player_id, armor['slot'])
|
||||
# We don't need to manually update inventory is_equipped or remove_from_inventory
|
||||
# because decrease_unique_item_durability will delete the unique item,
|
||||
# which cascades to the inventory row.
|
||||
|
||||
broken_armor.append({
|
||||
'name': armor['item_def'].name,
|
||||
'emoji': getattr(armor['item_def'], 'emoji', '🛡️')
|
||||
})
|
||||
|
||||
# Decrease durability (handles deletion if <= 0)
|
||||
await db.decrease_unique_item_durability(armor['unique_item_id'], durability_loss)
|
||||
|
||||
return armor_absorbed, broken_armor
|
||||
|
||||
|
||||
async def consume_tool_durability(user_id: int, tools: list, inventory: list, items_manager: ItemsManager) -> Tuple[bool, str, list]:
|
||||
"""
|
||||
Consume durability from required tools.
|
||||
Returns: (success, error_message, consumed_tools_info)
|
||||
"""
|
||||
consumed_tools = []
|
||||
tools_map = {}
|
||||
|
||||
# Build map of available tools with durability
|
||||
for inv_item in inventory:
|
||||
item_def = items_manager.get_item(inv_item['item_id'])
|
||||
if item_def and item_def.tool_type and inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique_item and unique_item.get('durability', 0) > 0:
|
||||
tool_type = item_def.tool_type
|
||||
if tool_type not in tools_map:
|
||||
tools_map[tool_type] = []
|
||||
tools_map[tool_type].append({
|
||||
'inv_item_id': inv_item['id'],
|
||||
'unique_item_id': inv_item['unique_item_id'],
|
||||
'item_id': inv_item['item_id'],
|
||||
'durability': unique_item['durability'],
|
||||
'name': item_def.name,
|
||||
'emoji': getattr(item_def, 'emoji', '🔧')
|
||||
})
|
||||
|
||||
# Check and consume tools
|
||||
for tool_req in tools:
|
||||
tool_type = tool_req['type']
|
||||
durability_cost = tool_req.get('durability_cost', 1)
|
||||
|
||||
if tool_type not in tools_map or not tools_map[tool_type]:
|
||||
return False, f"Missing required tool: {tool_type}", []
|
||||
|
||||
# Use first available tool of this type
|
||||
tool = tools_map[tool_type][0]
|
||||
new_durability = tool['durability'] - durability_cost
|
||||
|
||||
if new_durability <= 0:
|
||||
# Tool breaks - unequip first
|
||||
await db.unequip_item(user_id, 'weapon') # Assuming tools are equipped as weapons
|
||||
|
||||
consumed_tools.append(f"{tool['emoji']} {tool['name']} (broke)")
|
||||
tools_map[tool_type].pop(0)
|
||||
else:
|
||||
consumed_tools.append(f"{tool['emoji']} {tool['name']} (-{durability_cost} durability)")
|
||||
|
||||
# Decrease durability (handles deletion if <= 0)
|
||||
await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost)
|
||||
|
||||
return True, "", consumed_tools
|
||||
131
api/services/models.py
Normal file
131
api/services/models.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Pydantic models for request/response validation.
|
||||
All API request and response models are defined here.
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Models
|
||||
# ============================================================================
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class ChangeEmailRequest(BaseModel):
|
||||
current_password: str
|
||||
new_email: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Character Models
|
||||
# ============================================================================
|
||||
|
||||
class CharacterCreate(BaseModel):
|
||||
name: str
|
||||
strength: int = 0
|
||||
agility: int = 0
|
||||
endurance: int = 0
|
||||
intellect: int = 0
|
||||
avatar_data: Optional[str] = None
|
||||
|
||||
|
||||
class CharacterSelect(BaseModel):
|
||||
character_id: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Game Action Models
|
||||
# ============================================================================
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
direction: str
|
||||
|
||||
|
||||
class InteractRequest(BaseModel):
|
||||
interactable_id: str
|
||||
action_id: str
|
||||
|
||||
|
||||
class UseItemRequest(BaseModel):
|
||||
item_id: str
|
||||
|
||||
|
||||
class PickupItemRequest(BaseModel):
|
||||
item_id: int # dropped_item database ID
|
||||
quantity: int = 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Combat Models
|
||||
# ============================================================================
|
||||
|
||||
class InitiateCombatRequest(BaseModel):
|
||||
enemy_id: int # wandering_enemies.id
|
||||
|
||||
|
||||
class CombatActionRequest(BaseModel):
|
||||
action: str # 'attack', 'defend', 'flee'
|
||||
|
||||
|
||||
class PvPCombatInitiateRequest(BaseModel):
|
||||
target_player_id: int
|
||||
|
||||
|
||||
class PvPAcknowledgeRequest(BaseModel):
|
||||
pass # No body needed
|
||||
|
||||
|
||||
class PvPCombatActionRequest(BaseModel):
|
||||
action: str # 'attack', 'defend', 'flee'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Equipment Models
|
||||
# ============================================================================
|
||||
|
||||
class EquipItemRequest(BaseModel):
|
||||
inventory_id: int
|
||||
|
||||
|
||||
class UnequipItemRequest(BaseModel):
|
||||
slot: str
|
||||
|
||||
|
||||
class RepairItemRequest(BaseModel):
|
||||
inventory_id: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Crafting Models
|
||||
# ============================================================================
|
||||
|
||||
class CraftItemRequest(BaseModel):
|
||||
item_id: str
|
||||
|
||||
|
||||
class UncraftItemRequest(BaseModel):
|
||||
inventory_id: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Corpse/Loot Models
|
||||
# ============================================================================
|
||||
|
||||
class LootCorpseRequest(BaseModel):
|
||||
corpse_id: str # Format: "npc_{id}" or "player_{id}"
|
||||
item_index: Optional[int] = None # Specific item index to loot, or None for all
|
||||
18
api/start.sh
18
api/start.sh
@@ -1,20 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Startup script for API with auto-scaling workers
|
||||
|
||||
# Detect number of CPU cores
|
||||
# Auto-detect worker count based on CPU cores
|
||||
# Formula: (CPU_cores / 2) + 1, min 2, max 8
|
||||
CPU_CORES=$(nproc)
|
||||
WORKERS=$(( ($CPU_CORES / 2) + 1 ))
|
||||
WORKERS=$(( WORKERS < 2 ? 2 : WORKERS ))
|
||||
WORKERS=$(( WORKERS > 8 ? 8 : WORKERS ))
|
||||
|
||||
# Calculate optimal workers: (2 x CPU cores) + 1
|
||||
# But cap at 8 workers to avoid over-saturation
|
||||
WORKERS=$((2 * CPU_CORES + 1))
|
||||
if [ $WORKERS -gt 8 ]; then
|
||||
WORKERS=8
|
||||
fi
|
||||
|
||||
# Use environment variable if set, otherwise use calculated value
|
||||
WORKERS=${API_WORKERS:-$WORKERS}
|
||||
|
||||
echo "Starting API with $WORKERS workers (detected $CPU_CORES CPU cores)"
|
||||
echo "Starting API with $WORKERS workers (auto-detected from $CPU_CORES CPU cores)"
|
||||
|
||||
exec gunicorn api.main:app \
|
||||
--workers $WORKERS \
|
||||
|
||||
Reference in New Issue
Block a user