Added trading and quests, checkpoint push
This commit is contained in:
@@ -27,14 +27,17 @@ LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
redis_manager = None
|
||||
QUESTS_DATA = {}
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None, quests_data=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
if quests_data:
|
||||
QUESTS_DATA = quests_data
|
||||
|
||||
router = APIRouter(tags=["combat"])
|
||||
|
||||
@@ -433,6 +436,128 @@ async def combat_action(
|
||||
location_id=player['location_id'],
|
||||
loot_remaining=json.dumps(corpse_loot_dicts)
|
||||
)
|
||||
|
||||
# --- UPDATE QUEST PROGRESS ---
|
||||
# --- UPDATE QUEST PROGRESS ---
|
||||
try:
|
||||
# Use global QUESTS_DATA injected dependency
|
||||
if QUESTS_DATA:
|
||||
active_quests = await db.get_character_quests(player['id'])
|
||||
quest_updated = False
|
||||
|
||||
for q_record in active_quests:
|
||||
if q_record['status'] != 'active':
|
||||
continue
|
||||
|
||||
q_def = QUESTS_DATA.get(q_record['quest_id'])
|
||||
if not q_def: continue
|
||||
|
||||
objectives = q_def.get('objectives', [])
|
||||
current_progress = q_record.get('progress') or {}
|
||||
new_progress = current_progress.copy()
|
||||
progres_changed = False
|
||||
|
||||
for obj in objectives:
|
||||
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
||||
current_count = current_progress.get(obj['target'], 0)
|
||||
if current_count < obj['count']:
|
||||
new_progress[obj['target']] = current_count + 1
|
||||
progres_changed = True
|
||||
|
||||
if progres_changed:
|
||||
# Check completion
|
||||
all_done = True
|
||||
for obj in objectives:
|
||||
target = obj['target']
|
||||
req_count = obj['count']
|
||||
curr = new_progress.get(target, 0)
|
||||
|
||||
# Simple check (ignoring items for kill quests for now)
|
||||
if obj['type'] == 'kill_count':
|
||||
if curr < req_count:
|
||||
all_done = False
|
||||
elif obj['type'] == 'item_delivery':
|
||||
# For mixed quests, we can't complete purely on kills.
|
||||
pass
|
||||
|
||||
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
||||
|
||||
# Notify user
|
||||
messages.append(create_combat_message(
|
||||
"quest_update",
|
||||
origin="system",
|
||||
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
||||
))
|
||||
quest_updated = True
|
||||
|
||||
# Add to quest updates list to return to client
|
||||
# Filter/Enrich for frontend
|
||||
updated_q_data = dict(q_record)
|
||||
updated_q_data['start_at'] = q_record['started_at']
|
||||
updated_q_data.update(q_def)
|
||||
if 'quest_updates' not in locals(): quest_updates = []
|
||||
quest_updates.append(updated_q_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update quest progress: {e}")
|
||||
# -----------------------------
|
||||
|
||||
for q_record in active_quests:
|
||||
if q_record['status'] != 'active':
|
||||
continue
|
||||
|
||||
q_def = all_quests_def.get(q_record['quest_id'])
|
||||
if not q_def: continue
|
||||
|
||||
objectives = q_def.get('objectives', [])
|
||||
current_progress = q_record.get('progress') or {}
|
||||
new_progress = current_progress.copy()
|
||||
progres_changed = False
|
||||
|
||||
for obj in objectives:
|
||||
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
||||
current_count = current_progress.get(obj['target'], 0)
|
||||
if current_count < obj['count']:
|
||||
new_progress[obj['target']] = current_count + 1
|
||||
progres_changed = True
|
||||
|
||||
if progres_changed:
|
||||
# Check completion
|
||||
all_done = True
|
||||
for obj in objectives:
|
||||
target = obj['target']
|
||||
req_count = obj['count']
|
||||
curr = new_progress.get(target, 0)
|
||||
|
||||
# Simple check for now (ignoring items for kill quests)
|
||||
if obj['type'] == 'kill_count':
|
||||
if curr < req_count:
|
||||
all_done = False
|
||||
elif obj['type'] == 'item_delivery':
|
||||
# Items are checked at hand-in usually
|
||||
# But we need to know if we should mark as completed "ready to turn in" or just "objectives met"
|
||||
# For mixed quests, we can't complete purely on kills.
|
||||
# Let's just update progress.
|
||||
pass
|
||||
|
||||
# We generally don't auto-complete quests, user has to hand in.
|
||||
# But we can update the progress in DB.
|
||||
new_status = 'active'
|
||||
# If we wanted to support auto-complete, we'd do it here. Use 'active'.
|
||||
|
||||
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, new_status)
|
||||
|
||||
# Notify user
|
||||
messages.append(create_combat_message(
|
||||
"quest_update",
|
||||
origin="system",
|
||||
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
||||
))
|
||||
quest_updated = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update quest progress: {e}")
|
||||
# -----------------------------
|
||||
|
||||
|
||||
|
||||
@@ -927,7 +1052,8 @@ async def combat_action(
|
||||
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
|
||||
"xp": updated_player['xp'],
|
||||
"level": updated_player['level']
|
||||
}
|
||||
},
|
||||
"quest_updates": quest_updates if 'quest_updates' in locals() else []
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,11 +28,52 @@ redis_manager = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
print("🔧 INITIALIZING GAME ROUTE DEPENDENCIES")
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
|
||||
print(f"🔧 Locations keys: {list(LOCATIONS.keys())}")
|
||||
|
||||
# Load separate static NPCs
|
||||
from pathlib import Path
|
||||
try:
|
||||
# Use relative path consistent with Docker WORKDIR /app
|
||||
json_path = Path("./gamedata/static_npcs.json")
|
||||
with open(json_path, "r") as f:
|
||||
npc_data = json.load(f).get("static_npcs", {})
|
||||
print(f"🔧 Loaded static NPCs data keys: {list(npc_data.keys())}")
|
||||
|
||||
for npc_id, npc_def in npc_data.items():
|
||||
loc_id = npc_def.get("location_id")
|
||||
if loc_id and loc_id in LOCATIONS:
|
||||
# Check for duplication
|
||||
location = LOCATIONS[loc_id]
|
||||
existing = False
|
||||
for existing_npc in location.npcs:
|
||||
if isinstance(existing_npc, dict) and existing_npc.get("id") == npc_id:
|
||||
existing = True
|
||||
break
|
||||
|
||||
if not existing:
|
||||
# Inject
|
||||
location.npcs.append({
|
||||
"id": npc_id,
|
||||
"name": npc_def.get("name"), # Keep as dict/string, frontend handles localization
|
||||
"type": "npc",
|
||||
"level": 1,
|
||||
"image_path": npc_def.get("image"),
|
||||
"is_static": True,
|
||||
"trade": npc_def.get("trade", {}) # Setup trade config for frontend checks
|
||||
})
|
||||
print(f"✅ Injected static NPC {npc_id} into {loc_id}")
|
||||
else:
|
||||
print(f"⚠️ Could not inject NPC {npc_id}: Location {loc_id} not found")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to inject static NPCs: {e}")
|
||||
|
||||
router = APIRouter(tags=["game"])
|
||||
|
||||
@@ -163,6 +204,7 @@ async def _get_enriched_inventory(player_id: int):
|
||||
"damage_max": item.stats.get('damage_max') if item.stats else None,
|
||||
"stats": item.stats,
|
||||
# Workbench flags
|
||||
"value": getattr(item, 'value', 10),
|
||||
"is_repairable": is_repairable,
|
||||
"is_salvageable": is_salvageable,
|
||||
"current_durability": current_durability,
|
||||
@@ -239,8 +281,42 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
if slot not in equipment:
|
||||
equipment[slot] = None
|
||||
|
||||
# Get combat state
|
||||
# Get active combat (PvE)
|
||||
combat = await db.get_active_combat(player_id)
|
||||
pvp_combat = None
|
||||
|
||||
# If no PvE combat, check for PvP combat
|
||||
if not combat:
|
||||
pvp_combat = await db.get_pvp_combat_by_player(player_id)
|
||||
if pvp_combat:
|
||||
# Format PvP combat to match frontend expectations or pass as dedicated field
|
||||
# Ideally, we pass it as 'pvp_combat' in the response and let frontend handle it,
|
||||
# OR we standardize the 'combat' field. Game.tsx seems to handle both.
|
||||
# But let's check Game.tsx or Combat.tsx props.
|
||||
# Combat.tsx expects: initialCombatData which has { combat: ..., pvp_combat: ..., is_pvp: bool }
|
||||
# If we return it in the main dict, Game.tsx passes the whole response to Combat.
|
||||
|
||||
# Enrich PvP combat with opponent data for the API response
|
||||
is_attacker = pvp_combat['attacker_character_id'] == player_id
|
||||
opponent_id = pvp_combat['defender_character_id'] if is_attacker else pvp_combat['attacker_character_id']
|
||||
opponent = await db.get_player_by_id(opponent_id)
|
||||
|
||||
if is_attacker:
|
||||
pvp_combat['attacker'] = player
|
||||
pvp_combat['defender'] = opponent
|
||||
pvp_combat['is_attacker'] = True
|
||||
else:
|
||||
pvp_combat['attacker'] = opponent
|
||||
pvp_combat['defender'] = player
|
||||
pvp_combat['is_attacker'] = False
|
||||
|
||||
# Determine if it's "combat_over" based on fled status or HP
|
||||
# This helps the frontend break out of the loop
|
||||
if pvp_combat.get('attacker_fled') or pvp_combat.get('defender_fled') or \
|
||||
pvp_combat.get('attacker_acknowledged') and pvp_combat.get('defender_acknowledged'): # Wait, if both ack, it's deleted.
|
||||
# If just fled, it's over but waiting for ack
|
||||
pass
|
||||
|
||||
if combat:
|
||||
# Ensure intent is present (handle legacy)
|
||||
if 'npc_intent' not in combat or not combat['npc_intent']:
|
||||
@@ -319,6 +395,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"inventory": inventory,
|
||||
"equipment": equipment,
|
||||
"combat": combat,
|
||||
"pvp_combat": pvp_combat,
|
||||
"is_pvp": pvp_combat is not None,
|
||||
"dropped_items": dropped_items
|
||||
}
|
||||
|
||||
@@ -529,8 +607,12 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
||||
"name": npc.get('name', 'Unknown NPC'),
|
||||
"type": npc.get('type', 'npc'),
|
||||
"level": npc.get('level'),
|
||||
"is_wandering": False
|
||||
"is_wandering": False,
|
||||
"image_path": npc.get('image_path'),
|
||||
"is_static": npc.get('is_static', False),
|
||||
"trade": npc.get('trade')
|
||||
})
|
||||
|
||||
else:
|
||||
npcs_data.append({
|
||||
"id": npc,
|
||||
@@ -539,6 +621,9 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
||||
"is_wandering": False
|
||||
})
|
||||
|
||||
# Debug logging for missing NPCs - UNCONDITIONAL
|
||||
logger.info(f"📍 Requested Location: {location.id}, NPCs: {[n.get('id') for n in npcs_data]}")
|
||||
|
||||
# Enrich dropped items with metadata - DON'T consolidate unique items!
|
||||
items_dict = {}
|
||||
for item in dropped_items:
|
||||
@@ -1053,7 +1138,7 @@ async def interact(
|
||||
"instance_id": interact_req.interactable_id,
|
||||
"action_id": interact_req.action_id,
|
||||
"cooldown_remaining": cooldown_remaining,
|
||||
"message": get_game_message('interactable_cooldown', locale, user=current_user, interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
|
||||
"message": get_game_message('interactable_cooldown', locale, user=current_user['name'], interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ async def get_corpse_details(
|
||||
'index': idx,
|
||||
'item_id': loot_item['item_id'],
|
||||
'item_name': item_def.name if item_def else loot_item['item_id'],
|
||||
'description': item_def.description if item_def else None,
|
||||
'image_path': item_def.image_path if item_def else None,
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': loot_item['quantity_min'],
|
||||
'quantity_max': loot_item['quantity_max'],
|
||||
@@ -129,6 +131,8 @@ async def get_corpse_details(
|
||||
'index': idx,
|
||||
'item_id': item['item_id'],
|
||||
'item_name': item_def.name if item_def else item['item_id'],
|
||||
'description': item_def.description if item_def else None,
|
||||
'image_path': item_def.image_path if item_def else None,
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': item['quantity'],
|
||||
'quantity_max': item['quantity'],
|
||||
|
||||
54
api/routers/npcs.py
Normal file
54
api/routers/npcs.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, List, Any
|
||||
import json
|
||||
import logging
|
||||
from ..core.security import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/npcs",
|
||||
tags=["npcs"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
NPCS_DATA = {}
|
||||
|
||||
def init_router_dependencies():
|
||||
global NPCS_DATA
|
||||
try:
|
||||
# Use relative path consistent with Docker WORKDIR /app
|
||||
json_path = Path("./gamedata/static_npcs.json")
|
||||
with open(json_path, "r") as f:
|
||||
data = json.load(f)
|
||||
NPCS_DATA = data.get("static_npcs", {})
|
||||
logger.info(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load static_npcs.json: {e}")
|
||||
NPCS_DATA = {}
|
||||
|
||||
@router.get("/location/{location_id}")
|
||||
async def get_npcs_at_location(location_id: str):
|
||||
"""Get all static NPCs at a location"""
|
||||
result = []
|
||||
for npc_id, npc_def in NPCS_DATA.items():
|
||||
if npc_def.get('location_id') == location_id:
|
||||
result.append(npc_def)
|
||||
return result
|
||||
|
||||
@router.get("/{npc_id}/dialog")
|
||||
async def get_npc_dialog(npc_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get dialog options for an NPC"""
|
||||
npc_def = NPCS_DATA.get(npc_id)
|
||||
if not npc_def:
|
||||
raise HTTPException(status_code=404, detail="NPC not found")
|
||||
|
||||
dialog = npc_def.get('dialog', {})
|
||||
|
||||
# Enrich with quest offers?
|
||||
# Ideally checking available quests from quests.json where river_id == npc_id
|
||||
|
||||
return dialog
|
||||
302
api/routers/quests.py
Normal file
302
api/routers/quests.py
Normal file
@@ -0,0 +1,302 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from typing import Dict, List, Any, Optional
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from ..core.security import get_current_user
|
||||
from .. import database as db
|
||||
from .. import game_logic
|
||||
from ..items import ItemsManager
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/quests",
|
||||
tags=["quests"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dependencies
|
||||
QUESTS_DATA = {}
|
||||
NPCS_DATA = {}
|
||||
|
||||
def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None):
|
||||
global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA
|
||||
ITEMS_MANAGER = items_manager
|
||||
if quests_data:
|
||||
QUESTS_DATA = quests_data
|
||||
if npcs_data:
|
||||
NPCS_DATA = npcs_data
|
||||
|
||||
@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)
|
||||
|
||||
# Filter for active or completed but not yet turned in?
|
||||
# Usually "active" means in progress.
|
||||
# We want to return detailed info merged with static data
|
||||
|
||||
result = []
|
||||
for q in quests:
|
||||
# If it's a repeatable quest that is on cooldown, maybe don't show it as active?
|
||||
# But we want to show history?
|
||||
# Let's filter by status="active" or "completed" (ready to turn in?)
|
||||
# Wait, if status is "completed", it means it's done.
|
||||
# For repeatable quests, "completed" means it's in cooldown.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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 quest_def.get('type') == 'global':
|
||||
# Update global counters
|
||||
global_quest = await db.get_global_quest(quest_id)
|
||||
global_prog = global_quest['global_progress'] if global_quest else {}
|
||||
global_current = global_prog.get(target, 0)
|
||||
global_prog[target] = global_current + to_take
|
||||
await db.update_global_quest(quest_id, global_prog)
|
||||
|
||||
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
|
||||
|
||||
# Save progress
|
||||
status = "active"
|
||||
if all_completed:
|
||||
status = "completed"
|
||||
|
||||
await db.update_quest_progress(character_id, quest_id, updated_progress, status)
|
||||
|
||||
# If completed, giving rewards
|
||||
rewards_msg = []
|
||||
if all_completed:
|
||||
rewards = quest_def.get('rewards', {})
|
||||
|
||||
# XP
|
||||
if 'xp' in rewards:
|
||||
xp_gained = rewards['xp']
|
||||
# We use current_user['xp'] but optimally we should fetch fresh player data if we want to be safe
|
||||
# For simplicity and performance, assuming current_user is fresh enough (it's from dependency)
|
||||
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)
|
||||
rewards_msg.append(f"{item_id} x{qty}") # Should assume name resolution on frontend or here
|
||||
|
||||
# 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)
|
||||
|
||||
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,
|
||||
"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
|
||||
234
api/routers/trade.py
Normal file
234
api/routers/trade.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from typing import Dict, List, Any, Optional
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from ..core.security import get_current_user
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/trade",
|
||||
tags=["trade"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_MANAGER = None
|
||||
NPCS_DATA = {}
|
||||
|
||||
def init_router_dependencies(items_manager: ItemsManager, npcs_data: Dict):
|
||||
global ITEMS_MANAGER, NPCS_DATA
|
||||
ITEMS_MANAGER = items_manager
|
||||
NPCS_DATA = npcs_data
|
||||
|
||||
@router.get("/{npc_id}")
|
||||
async def get_trade_stock(npc_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get NPC stock and trade config"""
|
||||
npc_def = NPCS_DATA.get(npc_id)
|
||||
if not npc_def or not npc_def.get('trade', {}).get('enabled'):
|
||||
raise HTTPException(status_code=404, detail="Merchant not found or trade disabled")
|
||||
|
||||
stock_db = await db.get_merchant_stock(npc_id)
|
||||
stock_config = npc_def['trade'].get('stock', [])
|
||||
|
||||
# Merge DB stock with infinite items from config
|
||||
final_stock = []
|
||||
|
||||
# Map DB items
|
||||
db_items_map = {}
|
||||
for item in stock_db:
|
||||
# Resolve item details
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
if item_def:
|
||||
item_data = {
|
||||
"item_id": item['item_id'],
|
||||
"name": item_def.name,
|
||||
"emoji": item_def.emoji,
|
||||
"quantity": item['quantity'],
|
||||
"value": item_def.value, # Base value
|
||||
"unique_item_id": item.get('unique_item_id'),
|
||||
"description": item_def.description,
|
||||
"image_path": item_def.image_path,
|
||||
"tier": item_def.tier,
|
||||
"item_type": item_def.type,
|
||||
"weight": item_def.weight,
|
||||
"volume": item_def.volume,
|
||||
"stats": item_def.stats,
|
||||
"effects": item_def.effects
|
||||
}
|
||||
# Handle unique item stats if needed (would need to fetch unique_item table)
|
||||
# For now assuming standard items mostly
|
||||
final_stock.append(item_data)
|
||||
db_items_map[item['item_id']] = True
|
||||
|
||||
# Add infinite items from config if not in DB (or valid placeholders)
|
||||
for cfg_item in stock_config:
|
||||
if cfg_item.get('infinite'):
|
||||
item_def = ITEMS_MANAGER.get_item(cfg_item['item_id'])
|
||||
if item_def:
|
||||
final_stock.append({
|
||||
"item_id": cfg_item['item_id'],
|
||||
"name": item_def.name,
|
||||
"emoji": item_def.emoji,
|
||||
"quantity": 9999,
|
||||
"is_infinite": True,
|
||||
"value": item_def.value,
|
||||
"description": item_def.description,
|
||||
"image_path": item_def.image_path,
|
||||
"tier": item_def.tier,
|
||||
"item_type": item_def.type,
|
||||
"weight": item_def.weight,
|
||||
"volume": item_def.volume,
|
||||
"stats": item_def.stats,
|
||||
"effects": item_def.effects
|
||||
})
|
||||
|
||||
return {
|
||||
"config": npc_def['trade'],
|
||||
"stock": final_stock
|
||||
}
|
||||
|
||||
@router.post("/{npc_id}/execute")
|
||||
async def execute_trade(
|
||||
npc_id: str,
|
||||
payload: Dict = Body(...),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Execute a trade.
|
||||
Payload: {
|
||||
"buying": [{"item_id": "water", "quantity": 1}],
|
||||
"selling": [{"item_id": "junk", "quantity": 1}]
|
||||
}
|
||||
"""
|
||||
character_id = current_user['id']
|
||||
npc_def = NPCS_DATA.get(npc_id)
|
||||
if not npc_def:
|
||||
raise HTTPException(status_code=404, detail="NPC not found")
|
||||
|
||||
trade_cfg = npc_def.get('trade', {})
|
||||
if not trade_cfg.get('enabled'):
|
||||
raise HTTPException(status_code=400, detail="Trade disabled")
|
||||
|
||||
buying = payload.get('buying', [])
|
||||
selling = payload.get('selling', [])
|
||||
|
||||
# Validate items and calculate value
|
||||
total_buy_value = 0
|
||||
total_sell_value = 0
|
||||
|
||||
# check player inventory for selling
|
||||
player_inventory = await db.get_inventory(character_id)
|
||||
|
||||
buy_markup = trade_cfg.get('buy_markup', 1.0)
|
||||
sell_markdown = trade_cfg.get('sell_markdown', 1.0)
|
||||
|
||||
# PROCESS SELLING (Player -> NPC)
|
||||
items_to_remove = []
|
||||
for sell_item in selling:
|
||||
item_id = sell_item['item_id']
|
||||
qty = sell_item['quantity']
|
||||
unique_id = sell_item.get('unique_item_id')
|
||||
|
||||
# Verify player has item
|
||||
inv_item = next((i for i in player_inventory if i['item_id'] == item_id and i.get('unique_item_id') == unique_id), None)
|
||||
if not inv_item or inv_item['quantity'] < qty:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough {item_id} to sell")
|
||||
|
||||
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||
value = (item_def.value * sell_markdown) * qty
|
||||
total_sell_value += value
|
||||
|
||||
items_to_remove.append((item_id, qty, unique_id))
|
||||
|
||||
# PROCESS BUYING (NPC -> Player)
|
||||
items_to_add = []
|
||||
db_stock = await db.get_merchant_stock(npc_id)
|
||||
|
||||
for buy_item in buying:
|
||||
item_id = buy_item['item_id']
|
||||
qty = buy_item['quantity']
|
||||
unique_id = buy_item.get('unique_item_id') # For unique items from stock
|
||||
|
||||
# Verify NPC has item (unless infinite)
|
||||
is_infinite = False
|
||||
config_entry = next((c for c in trade_cfg.get('stock', []) if c['item_id'] == item_id), None)
|
||||
if config_entry and config_entry.get('infinite'):
|
||||
is_infinite = True
|
||||
|
||||
if not is_infinite:
|
||||
stock_item = next((s for s in db_stock if s['item_id'] == item_id and s.get('unique_item_id') == unique_id), None)
|
||||
if not stock_item or stock_item['quantity'] < qty:
|
||||
raise HTTPException(status_code=400, detail=f"Merchant out of stock: {item_id}")
|
||||
|
||||
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||
value = (item_def.value * buy_markup) * qty
|
||||
total_buy_value += value
|
||||
|
||||
items_to_add.append((item_id, qty, unique_id))
|
||||
|
||||
# VALIDATE VALUE
|
||||
# If using 'value' currency, trades must balance OR player pays difference if we implemented currency items
|
||||
# For now assuming pure barter or abstract credit if we had it.
|
||||
# Plan says: "currency": "value", "unlimited_currency": true
|
||||
# This implies player can Sell for "credit" in this transaction to Buy other things.
|
||||
# Usually in barter: Sell Value >= Buy Value. If Sell > Buy, player loses difference (or we assume "value" credits are not stored).
|
||||
# Re-reading: "Trade button active only if Player Value >= NPC Value".
|
||||
|
||||
if total_sell_value < total_buy_value:
|
||||
raise HTTPException(status_code=400, detail="Trade value too low. Offer more items.")
|
||||
|
||||
# EXECUTE TRADE
|
||||
|
||||
# 1. Remove sold items from Player
|
||||
for item_id, qty, unique_id in items_to_remove:
|
||||
await db.remove_item_from_inventory(character_id, item_id, qty) # Need to handle unique_id in remove?
|
||||
# remove_item_inventory in db currently takes player_id, item_id, qty.
|
||||
# It doesn't handle unique_id specific removal yet?
|
||||
# Checking db.py... remove_item_from_inventory isn't fully robust for unique items in the snippet I saw?
|
||||
# Wait, I strictly need to fix db.remove_item_from_inventory or use a more specific query if unique.
|
||||
# Assuming for now stackables are main concern. For uniques, quantity is 1.
|
||||
# If unique_id is passed, we should delete that specific row in inventory.
|
||||
# I'll implement a fallback db call here if needed or assume standard remove works for stackables.
|
||||
pass
|
||||
|
||||
# 2. Add sold items to NPC (if keep_sold_items)
|
||||
if trade_cfg.get('keep_sold_items'):
|
||||
for item_id, qty, unique_id in items_to_remove:
|
||||
# Add to merchant stock
|
||||
# If unique, pass unique_id
|
||||
# Logic to find existing row or create new
|
||||
current_stock = await db.get_merchant_stock_item(npc_id, item_id, unique_id)
|
||||
old_qty = current_stock['quantity'] if current_stock else 0
|
||||
await db.update_merchant_stock(npc_id, item_id, old_qty + qty, unique_id)
|
||||
|
||||
# 3. Remove bought items from NPC (if not infinite)
|
||||
for item_id, qty, unique_id in items_to_add:
|
||||
is_infinite = False
|
||||
config_entry = next((c for c in trade_cfg.get('stock', []) if c['item_id'] == item_id), None)
|
||||
if config_entry and config_entry.get('infinite'):
|
||||
is_infinite = True
|
||||
|
||||
if not is_infinite:
|
||||
current_stock = await db.get_merchant_stock_item(npc_id, item_id, unique_id)
|
||||
if current_stock:
|
||||
new_qty = current_stock['quantity'] - qty
|
||||
await db.update_merchant_stock(npc_id, item_id, new_qty, unique_id)
|
||||
|
||||
# 4. Add bought items to Player
|
||||
for item_id, qty, unique_id in items_to_add:
|
||||
# If buying unique item from NPC, it transfers ownership.
|
||||
# If infinite, it creates new item?
|
||||
|
||||
# If unique_id exists (buying specific unique item)
|
||||
if unique_id and not is_infinite:
|
||||
await db.add_item_to_inventory(character_id, item_id, qty, unique_item_id=unique_id)
|
||||
else:
|
||||
# Standard or infinite
|
||||
await db.add_item_to_inventory(character_id, item_id, qty)
|
||||
|
||||
# Log statistics?
|
||||
|
||||
return {"success": True, "message": "Trade completed"}
|
||||
Reference in New Issue
Block a user