Files
echoes-of-the-ash/api/routers/crafting.py
2025-11-27 16:27:01 +01:00

561 lines
23 KiB
Python

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