Update
This commit is contained in:
223
api/database.py
223
api/database.py
@@ -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
|
||||
# ========================================================================
|
||||
|
||||
|
||||
32
api/main.py
32
api/main.py
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
# -----------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user