Commit
This commit is contained in:
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user