This commit is contained in:
Joan
2026-02-23 15:42:21 +01:00
parent a725ae5836
commit d38d4cc288
102 changed files with 4511 additions and 4454 deletions

View File

@@ -329,6 +329,18 @@ character_quests = Table(
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
)
# Quests: Character History
character_quest_history = Table(
"character_quest_history",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
Column("quest_id", String, nullable=False),
Column("started_at", Float, nullable=False),
Column("completed_at", Float, default=lambda: time.time()),
Column("rewards", JSON, default={}),
)
# Quests: Global progress
global_quests = Table(
"global_quests",
@@ -405,6 +417,8 @@ async def init_db():
# Quests
"CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);",
"CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);",
"CREATE INDEX IF NOT EXISTS idx_character_quest_history_char ON character_quest_history(character_id);",
"CREATE INDEX IF NOT EXISTS idx_character_quest_history_completed ON character_quest_history(completed_at);",
# Merchant Stock
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
@@ -751,7 +765,9 @@ async def get_character_quests(character_id: int) -> List[Dict[str, Any]]:
"""Get all quests for a character"""
async with DatabaseSession() as session:
result = await session.execute(
select(character_quests).where(character_quests.c.character_id == character_id)
select(character_quests)
.where(character_quests.c.character_id == character_id)
.order_by(character_quests.c.started_at.desc())
)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
@@ -799,61 +815,139 @@ async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]:
started_at=time.time(),
times_completed=0
).returning(character_quests)
result = await session.execute(stmt)
row = result.first()
await session.commit()
return dict(row._mapping) if row else None
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: Optional[str] = None) -> bool:
async def delete_character_quest(character_id: int, quest_id: str) -> bool:
"""Delete a character quest (used when completing or abandoning)"""
async with DatabaseSession() as session:
stmt = delete(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
)
await session.execute(stmt)
await session.commit()
return True
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: str = "active") -> bool:
"""Update quest progress"""
values = {"progress": progress}
if status:
values["status"] = status
async with DatabaseSession() as session:
# Check if we need to update timestamp
values = {
"progress": progress,
"status": status
}
if status == "completed":
values["completed_at"] = time.time()
values["last_completed_at"] = time.time()
# Increment times_completed
# We need to read first or use a raw update expression,
# simplest is to just increment in python for now or assume caller logic handles it
# But let's do it right:
# We can't easily do col + 1 in a simple update call without pulling in Table object to the values
# So we'll rely on a fetch-update pattern or standard SQL
pass
async with DatabaseSession() as session:
# If completing, increment counter
if status == "completed":
# We can use the column expression for atomic increment
values["times_completed"] = character_quests.c.times_completed + 1
# We need to do this carefully atomically or just fetch-update
# Doing fetch-update for simplicity as we are inside transaction block if we used one,
# but DatabaseSession is per-call here.
# Using specific update to increment
stmt = update(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
).values(**values)
# Also increment times_completed separately to avoid overwrite race with simple values
stmt2 = update(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
).values(times_completed=character_quests.c.times_completed + 1)
await session.execute(stmt)
await session.execute(stmt2)
else:
stmt = update(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
).values(**values)
await session.execute(stmt)
stmt = update(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
).values(**values)
await session.execute(stmt)
await session.commit()
return True
async def set_quest_cooldown(character_id: int, quest_id: str, cooldown_expires_at: float) -> bool:
"""Set cooldown for a quest"""
async def set_quest_cooldown(character_id: int, quest_id: str, expires_at: float) -> bool:
"""Set cooldown for a repeatable quest"""
async with DatabaseSession() as session:
stmt = update(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
).values(cooldown_expires_at=cooldown_expires_at)
).values(cooldown_expires_at=expires_at)
await session.execute(stmt)
await session.commit()
return True
# Global Quests
async def log_quest_completion(character_id: int, quest_id: str, started_at: float, rewards: Dict) -> bool:
"""Log a quest completion to history"""
async with DatabaseSession() as session:
stmt = insert(character_quest_history).values(
character_id=character_id,
quest_id=quest_id,
started_at=started_at,
completed_at=time.time(),
rewards=rewards
)
await session.execute(stmt)
await session.commit()
return True
async def get_quest_history(character_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""Get quest history with pagination"""
offset = (page - 1) * page_size
async with DatabaseSession() as session:
# Get total count
count_stmt = select(character_quest_history.c.id).where(
character_quest_history.c.character_id == character_id
)
count_result = await session.execute(count_stmt)
total_count = len(count_result.fetchall())
# Get paged results
stmt = select(character_quest_history).where(
character_quest_history.c.character_id == character_id
).order_by(
character_quest_history.c.completed_at.desc()
).offset(offset).limit(page_size)
result = await session.execute(stmt)
rows = result.fetchall()
data = [dict(row._mapping) for row in rows]
return {
"data": data,
"total": total_count,
"page": page,
"pages": (total_count + page_size - 1) // page_size
}
# ========================================================================
# GLOBAL QUEST OPERATIONS
# ========================================================================
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
"""Get global quest progress"""
async with DatabaseSession() as session:
@@ -864,34 +958,63 @@ async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
return dict(row._mapping) if row else None
async def update_global_quest(quest_id: str, progress: Dict, is_completed: bool = False) -> bool:
"""Update or create global quest progress"""
# Upsert logic
existing = await get_global_quest(quest_id)
async def update_global_quest(quest_id: str, progress: Dict) -> bool:
"""Update global quest progress"""
async with DatabaseSession() as session:
if existing:
stmt = update(global_quests).where(
global_quests.c.quest_id == quest_id
).values(
global_progress=progress,
is_completed=is_completed,
updated_at=time.time()
)
# Upsert
existing = await session.execute(
select(global_quests).where(global_quests.c.quest_id == quest_id)
)
if existing.first():
stmt = update(global_quests).where(
global_quests.c.quest_id == quest_id
).values(
global_progress=progress,
updated_at=time.time()
)
else:
stmt = insert(global_quests).values(
quest_id=quest_id,
global_progress=progress,
is_completed=is_completed,
updated_at=time.time()
)
stmt = insert(global_quests).values(
quest_id=quest_id,
global_progress=progress,
updated_at=time.time()
)
await session.execute(stmt)
await session.commit()
return True
# ========================================================================
async def get_completed_global_quests() -> List[str]:
"""Get list of IDs of all completed global quests"""
async with DatabaseSession() as session:
result = await session.execute(
select(global_quests.c.quest_id).where(global_quests.c.is_completed == True)
)
return [row[0] for row in result.fetchall()]
async def mark_global_quest_completed(quest_id: str) -> bool:
"""Mark a global quest as completed"""
async with DatabaseSession() as session:
stmt = update(global_quests).where(
global_quests.c.quest_id == quest_id
).values(
is_completed=True,
updated_at=time.time()
)
await session.execute(stmt)
await session.commit()
return True
async def get_all_quest_participants(quest_id: str) -> List[Dict[str, Any]]:
"""Get all characters who have this quest active or completed"""
async with DatabaseSession() as session:
result = await session.execute(
select(character_quests).where(character_quests.c.quest_id == quest_id)
)
return [dict(row._mapping) for row in result.fetchall()]
# MERCHANT OPERATIONS
# ========================================================================

View File

@@ -88,6 +88,26 @@ async def lifespan(app: FastAPI):
print(f"✅ Started {len(tasks)} background tasks in this worker")
else:
print("⏭️ Background tasks running in another worker")
# APPLY GLOBAL QUEST UNLOCKS
print("🔓 Applying global quest unlocks...")
try:
completed_quests = await db.get_completed_global_quests()
print(f" - Found {len(completed_quests)} completed global quests")
for quest_id in completed_quests:
# Unlock locations
for loc in LOCATIONS.values():
if loc.unlocked_by == quest_id:
loc.locked = False
print(f" - Unlocked location: {loc.id}")
# Unlock interactables
for inter in loc.interactables:
if inter.unlocked_by == quest_id:
inter.locked = False
print(f" - Unlocked interactable: {inter.id} in {loc.id}")
except Exception as e:
print(f"❌ Failed to apply global quest unlocks: {e}")
yield
@@ -150,6 +170,16 @@ try:
NPCS_DATA = n_data.get("static_npcs", {})
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
# Load Enemies / Other NPCs
enemies_path = Path("./gamedata/npcs.json")
if enemies_path.exists():
with open(enemies_path, "r") as f:
e_data = json.load(f)
enemies = e_data.get("npcs", {})
# Merge into NPCS_DATA
NPCS_DATA.update(enemies)
print(f"✅ Loaded {len(enemies)} enemies/NPCs")
except Exception as e:
print(f"❌ Error loading game data: {e}")
@@ -161,7 +191,7 @@ crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA)
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
npcs.init_router_dependencies()

View File

@@ -10,6 +10,8 @@ from ..services.helpers import get_game_message
from ..core.security import create_access_token, hash_password, verify_password, get_current_user
from ..services.models import UserRegister, UserLogin
from .. import database as db
from ..items import items_manager
from ..services.helpers import calculate_player_capacity, enrich_character_data
router = APIRouter(prefix="/api/auth", tags=["authentication"])
@@ -110,23 +112,7 @@ async def login(user: UserLogin):
"is_premium": account.get("premium_expires_at") is not None,
},
"characters": [
{
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"stamina": char["stamina"],
"max_stamina": char["max_stamina"],
"strength": char["strength"],
"agility": char["agility"],
"endurance": char["endurance"],
"intellect": char["intellect"],
"avatar_data": char.get("avatar_data"),
"last_played_at": char.get("last_played_at"),
"location_id": char["location_id"],
}
await enrich_character_data(char, items_manager)
for char in characters
],
"needs_character_creation": len(characters) == 0
@@ -188,17 +174,7 @@ async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
"last_login_at": account.get("last_login_at"),
},
"characters": [
{
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"location_id": char["location_id"],
"avatar_data": char.get("avatar_data"),
"last_played_at": char.get("last_played_at"),
}
await enrich_character_data(char, items_manager)
for char in characters
]
}
@@ -373,17 +349,7 @@ async def steam_login(steam_data: Dict[str, Any]):
"last_login_at": account.get("last_login_at")
},
"characters": [
{
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"location_id": char["location_id"],
"avatar_data": char.get("avatar_data"),
"last_played_at": char.get("last_played_at")
}
await enrich_character_data(char, items_manager)
for char in characters
],
"needs_character_creation": len(characters) == 0

View File

@@ -5,7 +5,8 @@ Handles character creation, selection, and deletion.
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from ..services.helpers import get_game_message
from ..items import items_manager
from ..services.helpers import enrich_character_data, get_game_message
from ..core.security import decode_token, create_access_token, security
from ..services.models import CharacterCreate, CharacterSelect
@@ -13,7 +14,6 @@ from .. import database as db
router = APIRouter(prefix="/api/characters", tags=["characters"])
@router.get("")
async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""List all characters for the logged-in account"""
@@ -31,20 +31,7 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se
return {
"characters": [
{
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"stamina": char["stamina"],
"max_stamina": char["max_stamina"],
"avatar_data": char.get("avatar_data"),
"location_id": char["location_id"],
"created_at": char["created_at"],
"last_played_at": char.get("last_played_at"),
}
await enrich_character_data(char, items_manager)
for char in characters
]
}

View File

@@ -464,9 +464,12 @@ async def combat_action(
new_progress[obj['target']] = current_count + 1
progres_changed = True
if progres_changed:
# Check completion
all_done = True
progress_str = ""
for obj in objectives:
target = obj['target']
req_count = obj['count']
@@ -476,8 +479,10 @@ async def combat_action(
if obj['type'] == 'kill_count':
if curr < req_count:
all_done = False
# Capture progress string for the notification (if this was the target updated)
if target == combat['npc_id']:
progress_str = f" ({curr}/{req_count})"
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')
@@ -486,7 +491,7 @@ async def combat_action(
messages.append(create_combat_message(
"quest_update",
origin="system",
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
message=f"{get_locale_string(q_def['title'], locale)}{progress_str}"
))
quest_updated = True
@@ -501,62 +506,6 @@ async def combat_action(
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}")
# -----------------------------

View File

@@ -502,6 +502,10 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
# Format interactables for response with cooldown info
interactables_data = []
for interactable in location.interactables:
# Check if locked
if getattr(interactable, 'locked', False):
continue
actions_data = []
for action in interactable.actions:
# Check cooldown status for this specific action
@@ -556,6 +560,10 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
destination_id = location.exits[direction]
destination_loc = LOCATIONS.get(destination_id)
# Check if destination is locked
if destination_loc and getattr(destination_loc, 'locked', False):
continue
if destination_loc:
# Calculate real distance using coordinates
distance = calculate_distance(

View File

@@ -3,10 +3,12 @@ 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",
@@ -14,19 +16,110 @@ router = APIRouter(
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):
global ITEMS_MANAGER, QUESTS_DATA, NPCS_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)):
@@ -34,18 +127,8 @@ async def get_active_quests(current_user: dict = Depends(get_current_user)):
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
@@ -63,6 +146,58 @@ async def get_active_quests(current_user: dict = Depends(get_current_user)):
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
@@ -194,7 +329,19 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
if inv_item:
available = inv_item['quantity']
needed = required_count - current_count
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:
@@ -207,13 +354,50 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
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
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
@@ -227,23 +411,38 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
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
# 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']
# 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")
@@ -262,7 +461,10 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
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
# 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'):
@@ -270,6 +472,44 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
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,
@@ -281,6 +521,7 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
"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
}
@@ -300,3 +541,78 @@ async def get_global_quest_progress(quest_id: str):
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
}
})

View File

@@ -391,3 +391,36 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it
await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost)
return True, "", consumed_tools
async def enrich_character_data(char: Dict[str, Any], items_manager: ItemsManager) -> Dict[str, Any]:
"""
Add calculated stats (weight, volume) to character data.
"""
# Calculate weight and volume
inventory = await db.get_inventory(char['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager)
return {
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"stamina": char["stamina"],
"max_stamina": char["max_stamina"],
"strength": char["strength"],
"agility": char["agility"],
"endurance": char["endurance"],
"intellect": char["intellect"],
"location_id": char["location_id"],
"avatar_data": char.get("avatar_data"),
"last_played_at": char.get("last_played_at"),
"created_at": char.get("created_at"),
# Add calculated capacity
"weight": round(current_weight, 1),
"max_weight": round(max_weight, 1),
"volume": round(current_volume, 1),
"max_volume": round(max_volume, 1),
}

View File

@@ -35,6 +35,8 @@ class Interactable:
name: Union[str, Dict[str, str]]
image_path: str = ""
actions: List[Action] = field(default_factory=list)
unlocked_by: str = ""
locked: bool = False
def add_action(self, action: Action):
self.actions.append(action)
@@ -63,6 +65,8 @@ class Location:
x: float = 0.0 # X coordinate for distance calculations
y: float = 0.0 # Y coordinate for distance calculations
danger_level: int = 0 # Danger level (0-5)
unlocked_by: str = ""
locked: bool = False
def add_exit(self, direction: str, destination: str, stamina_cost: int = 5):
self.exits[direction] = destination
@@ -114,9 +118,14 @@ class WorldLoader:
interactable = Interactable(
id=template_id,
name=template_data.get('name', 'Unknown'),
image_path=template_data.get('image_path', '')
image_path=template_data.get('image_path', ''),
unlocked_by=instance_data.get('unlocked_by', template_data.get('unlocked_by', '')),
)
# Set locked status if unlocked_by is present
if interactable.unlocked_by:
interactable.locked = True
# Get actions from template
template_actions = template_data.get('actions', {})
@@ -211,9 +220,14 @@ class WorldLoader:
y=float(loc_data.get('y', 0.0)),
danger_level=danger_level,
tags=loc_data.get('tags', []),
npcs=loc_data.get('npcs', [])
npcs=loc_data.get('npcs', []),
unlocked_by=loc_data.get('unlocked_by', '')
)
# Set locked status if unlocked_by is present
if location.unlocked_by:
location.locked = True
# Add exits
for direction, destination in loc_data.get('exits', {}).items():
location.add_exit(direction, destination)