619 lines
24 KiB
Python
619 lines
24 KiB
Python
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
|
|
}
|
|
})
|