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

0
api/routers/__init__.py Normal file
View File

370
api/routers/admin.py Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

561
api/routers/crafting.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

504
api/routers/loot.py Normal file
View 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
View 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
}