from fastapi import APIRouter, Depends, HTTPException, Body from typing import Dict, List, Any, Optional import time import json import logging from ..core.websockets import manager from ..core.security import get_current_user from .. import database as db from .. import game_logic from ..items import ItemsManager from ..services.helpers import get_locale_string router = APIRouter( prefix="/api/quests", tags=["quests"], responses={404: {"description": "Not found"}}, ) # Request Models class HistoryParams: page: int = 1 page_size: int = 20 logger = logging.getLogger(__name__) # Dependencies QUESTS_DATA = {} NPCS_DATA = {} LOCATIONS_DATA = {} def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None, locations_data=None): global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS_DATA ITEMS_MANAGER = items_manager if quests_data: QUESTS_DATA = quests_data if npcs_data: NPCS_DATA = npcs_data if locations_data: LOCATIONS_DATA = locations_data @router.get("/history") async def get_quest_history_endpoint( page: int = 1, limit: int = 20, current_user: dict = Depends(get_current_user) ): """Get completed quest history with pagination""" character_id = current_user['id'] history = await db.get_quest_history(character_id, page=page, page_size=limit) # Enrich with quest definitions enriched_data = [] for entry in history['data']: quest_def = QUESTS_DATA.get(entry['quest_id']) if quest_def: # Merge entry data with quest def item = dict(entry) item['title'] = quest_def.get('title') item['description'] = quest_def.get('description') item['type'] = quest_def.get('type') item['objectives'] = quest_def.get('objectives') # Fix: Copy objectives # Enrich with giver info if quest_def.get('giver_id'): giver = NPCS_DATA.get(quest_def['giver_id']) if giver: item['giver_name'] = giver.get('name') item['giver_image'] = giver.get('image') # Get Location Name if giver.get('location_id'): loc = LOCATIONS_DATA.get(giver['location_id']) if loc: item['giver_location_name'] = loc.name else: item['giver_location_name'] = giver['location_id'] enriched_data.append(item) else: # Fallback if quest def removed? enriched_data.append(entry) # 2nd pass: Enrich objectives and rewards for all items in enriched_data final_data = [] for q_data in enriched_data: # ENRICH OBJECTIVES WITH NAMES if 'objectives' in q_data: enriched_objs = [] for obj in q_data['objectives']: new_obj = dict(obj) target = obj.get('target') if obj.get('type') == 'kill_count': npc = NPCS_DATA.get(target) if npc: new_obj['target_name'] = npc.get('name') else: logger.warning(f"NPC not found for target: {target}") elif obj.get('type') == 'item_delivery': item = ITEMS_MANAGER.get_item(target) if item: new_obj['target_name'] = item.name else: logger.warning(f"Item not found for target: {target}") enriched_objs.append(new_obj) q_data['objectives'] = enriched_objs # ENRICH REWARDS WITH NAMES # For history, rewards might be stored in 'rewards' json. if 'rewards' in q_data and 'items' in q_data['rewards']: enriched_items = {} for item_id, qty in q_data['rewards']['items'].items(): item = ITEMS_MANAGER.get_item(item_id) name = item.name if item else item_id enriched_items[item_id] = {'qty': qty, 'name': name} q_data['reward_items_details'] = enriched_items final_data.append(q_data) history['data'] = final_data return history @router.get("/active") async def get_active_quests(current_user: dict = Depends(get_current_user)): """Get all active quests for the character""" character_id = current_user['id'] quests = await db.get_character_quests(character_id) result = [] for q in quests: quest_def = QUESTS_DATA.get(q['quest_id']) if not quest_def: continue # Enrich with static data q_data = dict(q) q_data['start_at'] = q['started_at'] # Consistency q_data.update(quest_def) # Calculate cooldown status for repeatable quests if quest_def.get('repeatable') and q['cooldown_expires_at']: if time.time() < q['cooldown_expires_at']: q_data['on_cooldown'] = True q_data['cooldown_remaining'] = int(q['cooldown_expires_at'] - time.time()) else: q_data['on_cooldown'] = False # Global Quest Progress if quest_def.get('type') == 'global': g_quest = await db.get_global_quest(q['quest_id']) if g_quest: q_data['global_progress'] = g_quest.get('global_progress', {}) q_data['global_is_completed'] = g_quest.get('is_completed', False) # Enrich with giver info if quest_def.get('giver_id'): giver = NPCS_DATA.get(quest_def['giver_id']) if giver: q_data['giver_name'] = giver.get('name') q_data['giver_image'] = giver.get('image') # Get Location Name if giver.get('location_id'): loc = LOCATIONS_DATA.get(giver['location_id']) if loc: q_data['giver_location_name'] = loc.name else: q_data['giver_location_name'] = giver['location_id'] # ENRICH OBJECTIVES WITH NAMES if 'objectives' in q_data: enriched_objs = [] for obj in q_data['objectives']: new_obj = dict(obj) target = obj.get('target') if obj.get('type') == 'kill_count': npc = NPCS_DATA.get(target) if npc: new_obj['target_name'] = npc.get('name') elif obj.get('type') == 'item_delivery': item = ITEMS_MANAGER.get_item(target) if item: new_obj['target_name'] = item.name enriched_objs.append(new_obj) q_data['objectives'] = enriched_objs # ENRICH REWARDS WITH NAMES if 'rewards' in q_data and 'items' in q_data['rewards']: enriched_items = {} for item_id, qty in q_data['rewards']['items'].items(): item = ITEMS_MANAGER.get_item(item_id) name = item.name if item else item_id enriched_items[item_id] = {'qty': qty, 'name': name} # Store back in a way frontend can use, or just replace items dict? # Frontend currently iterates entries of items. # Let's add a new field 'reward_items_details' q_data['reward_items_details'] = enriched_items result.append(q_data) return result @router.get("/available") async def get_available_quests(current_user: dict = Depends(get_current_user)): """Get quests available to be started at current location""" character_id = current_user['id'] location_id = current_user['location_id'] # 1. Identify NPCs at this location local_npcs = [ npc_id for npc_id, npc in NPCS_DATA.items() if npc.get('location_id') == location_id ] if not local_npcs: return [] # 2. Get quests offered by these NPCs potential_quests = [] for q_id, q_def in QUESTS_DATA.items(): if q_def.get('giver_id') in local_npcs: potential_quests.append(q_def) # 3. Filter out active/completed non-repeatable quests # We need to check DB state available = [] # Bulk fetch might be better but loop is fine for now for q_def in potential_quests: q_id = q_def['quest_id'] existing = await db.get_character_quest(character_id, q_id) if not existing: # Never started -> Available available.append(q_def) else: # Exists if existing['status'] == 'active': continue # Already active if existing['status'] == 'completed': if q_def.get('repeatable'): # Check cooldown expires = existing.get('cooldown_expires_at') if not expires or time.time() >= expires: available.append(q_def) else: continue # Completed and not repeatable if existing['status'] == 'failed': available.append(q_def) # Can retry? return available @router.post("/accept/{quest_id}") async def accept_quest(quest_id: str, current_user: dict = Depends(get_current_user)): """Accept a quest""" character_id = current_user['id'] quest_def = QUESTS_DATA.get(quest_id) if not quest_def: raise HTTPException(status_code=404, detail="Quest not found") # Check if repeatable & cooldown existing = await db.get_character_quest(character_id, quest_id) if existing: if not quest_def.get('repeatable'): raise HTTPException(status_code=400, detail="Quest already completed or active") # Check cooldown if existing.get('cooldown_expires_at') and time.time() < existing['cooldown_expires_at']: remaining = int(existing['cooldown_expires_at'] - time.time()) raise HTTPException(status_code=400, detail=f"Quest on cooldown for {remaining}s") if existing['status'] == 'active': raise HTTPException(status_code=400, detail="Quest already active") # Accept quest await db.accept_quest(character_id, quest_id) # Return updated quest data for frontend updated_q_data = dict(quest_def) updated_q_data['status'] = 'active' updated_q_data['start_at'] = int(time.time()) updated_q_data['progress'] = {} # New quest return {"success": True, "message": "Quest accepted", "quest": updated_q_data} @router.post("/hand_in/{quest_id}") async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_user)): """ Hand in items or check completion for a quest. Automatically deducts items from inventory for delivery objectives. """ character_id = current_user['id'] quest_def = QUESTS_DATA.get(quest_id) if not quest_def: raise HTTPException(status_code=404, detail="Quest not found") quest_record = await db.get_character_quest(character_id, quest_id) if not quest_record or quest_record['status'] != 'active': raise HTTPException(status_code=400, detail="Quest not active") current_progress = quest_record.get('progress') or {} objectives = quest_def.get('objectives', []) updated_progress = current_progress.copy() items_deducted = [] all_completed = True # Iterate objectives for obj in objectives: obj_type = obj['type'] target = obj['target'] required_count = obj['count'] current_count = current_progress.get(target, 0) if current_count >= required_count: continue # Already done if obj_type == 'item_delivery': # Check inventory inventory = await db.get_inventory(character_id) inv_item = next((i for i in inventory if i['item_id'] == target), None) if inv_item: available = inv_item['quantity'] needed = required_count - current_count # Personal needed (to match max count) # GLOBAL CAP CHECK is_global = quest_def.get('type') == 'global' if is_global: global_quest = await db.get_global_quest(quest_id) global_prog = global_quest.get('global_progress', {}) if global_quest else {} global_current_val = global_prog.get(target, 0) global_remaining = max(0, required_count - global_current_val) # Cap needed by global remaining needed = min(needed, global_remaining) to_take = min(available, needed) if to_take > 0: # Remove from inventory await db.remove_item_from_inventory(character_id, target, to_take) # Update progress new_count = current_count + to_take updated_progress[target] = new_count items_deducted.append(f"{target} x{to_take}") # Global Quest Logic if is_global: # Re-fetch or use existing? We need to be careful with race conditions slightly, # but safe enough for now to just update. # We already fetched 'global_prog' above. # Add contribution new_global = global_current_val + to_take global_prog[target] = new_global await db.update_global_quest(quest_id, global_prog) # Check for global completion is_global_complete = True for obj in objectives: t = obj['target'] req = obj['count'] # Check cached updated prog if global_prog.get(t, 0) < req: is_global_complete = False break if is_global_complete: # Finish global quest! await finish_global_quest(quest_id, quest_def) # RETURN IMMEDIATELY to prevent double rewards/deletion logic # We construct a success response here. return { "success": True, "message": "Global Quest Completed!", "is_completed": True, "items_deducted": items_deducted, "rewards": ["See Global Rewards"], # Placeholders, real rewards via finish_global_quest/websocket "completion_text": quest_def.get("completion_text", "Global Quest Finished!"), "quest_update": { **quest_def, "quest_id": quest_id, "status": "completed", "progress": updated_progress, "on_cooldown": quest_def.get('repeatable'), } } else: # Prevent individual completion if global is not done all_completed = False if new_count < required_count: all_completed = False else: all_completed = False else: all_completed = False elif obj_type == 'kill_count': # Check if kill count is met (updated via other events usually) if current_count < required_count: all_completed = False # WEIGHT CHECK FOR REWARDS rewards_msg = [] if all_completed: rewards = quest_def.get('rewards', {}) reward_weight = 0.0 if 'items' in rewards: for item_id, qty in rewards['items'].items(): item_def = ITEMS_MANAGER.get_item(item_id) if item_def: reward_weight += item_def.weight * qty # Calculate current weight # Calculate current weight and capacity from ..services.helpers import calculate_player_capacity inventory = await db.get_inventory(character_id) current_weight, capacity, _, _ = await calculate_player_capacity(inventory, ITEMS_MANAGER) if current_weight + reward_weight > capacity: # Rollback? The items for delivery were already removed above! # Ideally we should check weight BEFORE deducting delivery items. # converting this to a "check before action" logic is hard because delivery logic is stateful. # However, delivery items REDUCE weight. So we are likely safe unless rewards are heavier than delivered items. # BUT, if we error here, we technically leave the quest in "partially delivered" state, which is fine. # The user can just clear inventory and try again. raise HTTPException(status_code=400, detail=f"Not enough inventory space for rewards! (Overweight by {current_weight + reward_weight - capacity:.1f})") # Give Rewards # XP if 'xp' in rewards: xp_gained = rewards['xp'] new_xp = current_user['xp'] + xp_gained await db.update_player(character_id, xp=new_xp) rewards_msg.append(f"{xp_gained} XP") # Check for level up try: level_up_result = await game_logic.check_and_apply_level_up(character_id) if level_up_result and level_up_result.get('leveled_up'): new_level = level_up_result['new_level'] stats_gained = level_up_result['levels_gained'] rewards_msg.append(f"Level Up! (Lvl {new_level}) +{stats_gained} Stat Points") except Exception as e: logger.error(f"Failed to check level up in quest hand-in: {e}") # Items if 'items' in rewards: for item_id, qty in rewards['items'].items(): await db.add_item_to_inventory(character_id, item_id, qty) # Resolve name idev = ITEMS_MANAGER.get_item(item_id) name = idev.name if idev else item_id rewards_msg.append(f"{name} x{qty}") # Set cooldown if repeatable if quest_def.get('repeatable'): cooldown_hours = quest_def.get('cooldown_hours', 24) expires = time.time() + (cooldown_hours * 3600) await db.set_quest_cooldown(character_id, quest_id, expires) # LOG HISTORY await db.log_quest_completion( character_id=character_id, quest_id=quest_id, started_at=quest_record.get('started_at') or time.time(), rewards=quest_def.get('rewards', {}) ) # REMOVE FROM ACTIVE QUESTS (DELETE) await db.delete_character_quest(character_id, quest_id) status = "completed" else: # Not completed, just update progress status = "active" await db.update_quest_progress(character_id, quest_id, updated_progress, status) # ENRICH OBJECTIVES FOR RESPONSE enriched_objs = [] for obj in objectives: new_obj = dict(obj) target = obj.get('target') # Add current progress new_obj['current'] = updated_progress.get(target, 0) # Add names if obj.get('type') == 'kill_count': npc = NPCS_DATA.get(target) if npc: new_obj['target_name'] = npc.get('name') elif obj.get('type') == 'item_delivery': item = ITEMS_MANAGER.get_item(target) if item: new_obj['target_name'] = item.name enriched_objs.append(new_obj) response = { "success": True, "progress": updated_progress, "is_completed": all_completed, "items_deducted": items_deducted, "message": "Progress updated", "quest_update": { **quest_def, "quest_id": quest_id, "status": status, "progress": updated_progress, "objectives": enriched_objs, "on_cooldown": all_completed and quest_def.get('repeatable'), # other fields as needed } } if all_completed: response["message"] = "Quest Completed!" response["rewards"] = rewards_msg response["completion_text"] = quest_def.get("completion_text", {}) return response # Also exposing global quest state @router.get("/global/{quest_id}") async def get_global_quest_progress(quest_id: str): quest = await db.get_global_quest(quest_id) if not quest: return {"progress": {}} return quest async def finish_global_quest(quest_id: str, quest_def: Dict): """ Handle global quest completion: 1. Mark global quest as completed 2. Unlock content (In-Memory) 3. Distribute rewards to all participants 4. Broadcast completion """ logger.info(f"🌍 Finishing Global Quest: {quest_id}") # 1. Mark as completed in DB await db.mark_global_quest_completed(quest_id) # 2. Unlock content (In-Memory) unlocks = [] # Unlock Locations for loc in LOCATIONS_DATA.values(): if loc.unlocked_by == quest_id: loc.locked = False unlocks.append({"type": "location", "name": get_locale_string(loc.name, 'en'), "id": loc.id}) # Unlock interactables for inter in loc.interactables: if inter.unlocked_by == quest_id: inter.locked = False unlocks.append({"type": "interactable", "name": get_locale_string(inter.name, 'en'), "location": loc.id}) # 3. Distribute Rewards to participants participants = await db.get_all_quest_participants(quest_id) total_xp_pool = quest_def.get('rewards', {}).get('xp', 0) total_required = 0 for obj in quest_def.get('objectives', []): total_required += obj.get('count', 0) for p in participants: # Calculate user contribution user_progress = p.get('progress', {}) user_contribution = 0 for obj in quest_def.get('objectives', []): target = obj['target'] user_contribution += user_progress.get(target, 0) if user_contribution > 0 and total_required > 0: percentage = user_contribution / total_required xp_reward = int(total_xp_pool * percentage) if xp_reward > 0: # Give XP char = await db.get_player_by_id(p['character_id']) if char: new_xp = char['xp'] + xp_reward await db.update_player(p['character_id'], xp=new_xp) # Mark as completed (delete from active) and log history await db.delete_character_quest(p['character_id'], quest_id) await db.log_quest_completion( character_id=p['character_id'], quest_id=quest_id, started_at=p['started_at'], rewards={"xp": xp_reward, "note": f"Contribution: {percentage*100:.1f}%"} ) # 4. Broadcast await manager.broadcast({ "type": "global_quest_completed", "quest_id": quest_id, "title": get_locale_string(quest_def.get('title', 'Global Quest'), 'en'), "outcome": { "unlocks": unlocks } })