Files
echoes-of-the-ash/api/routers/quests.py
2026-02-23 15:42:21 +01:00

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