Added trading and quests, checkpoint push

This commit is contained in:
Joan
2026-02-08 20:18:42 +01:00
parent 8820cd897e
commit 70dc35b4b2
36 changed files with 3583 additions and 279 deletions

View File

@@ -819,7 +819,83 @@ async def process_status_effects(manager=None):
logger.error(f"❌ Error in status effects task: {e}", exc_info=True)
await asyncio.sleep(10)
# ============================================================================
# BACKGROUND TASK: MERCHANT RESTOCK
# ============================================================================
async def restock_merchants(manager=None, npcs_data=None):
"""Periodically restocks merchant inventory."""
logger.info("💰 Merchant Restock task started")
# If no data provided, we can't restock effectively without doing I/O which we want to avoid.
if not npcs_data:
logger.warning("⚠️ No NPC data provided to restock task. Merchants will not restock.")
return
while True:
try:
# Use injected data
static_npcs = npcs_data
start_time = time.time()
restocked_count = 0
for npc_id, npc_def in static_npcs.items():
trade_cfg = npc_def.get('trade', {})
if not trade_cfg.get('enabled'):
continue
stock_config = trade_cfg.get('stock', [])
for item_cfg in stock_config:
if item_cfg.get('infinite'):
continue
item_id = item_cfg['item_id']
max_stock = item_cfg.get('max_stock', 10)
restock_rate = item_cfg.get('restock_rate', 1)
# Get current stock
current_item = await db.get_merchant_stock_item(npc_id, item_id)
now = time.time()
if not current_item:
# Initialize if missing
# If we assume 'restocked' means it should exist.
await db.update_merchant_stock(
npc_id=npc_id,
item_id=item_id,
quantity=restock_rate,
update_restock_time=True
)
restocked_count += 1
continue
# Check timer (1 hour default)
last_restock = current_item.get('last_restock_at', 0)
if now - last_restock > 3600: # 1 hour
current_qty = current_item['quantity']
if current_qty < max_stock:
new_qty = min(max_stock, current_qty + restock_rate)
await db.update_merchant_stock(
npc_id=npc_id,
item_id=item_id,
quantity=new_qty,
update_restock_time=True
)
restocked_count += 1
if restocked_count > 0:
elapsed = time.time() - start_time
logger.info(f"Restocked {restocked_count} items in {elapsed:.2f}s")
await asyncio.sleep(600) # Check every 10 minutes
except Exception as e:
logger.error(f"❌ Error in merchant restock task: {e}", exc_info=True)
await asyncio.sleep(60)
# ============================================================================
# TASK STARTUP FUNCTION
# ============================================================================
@@ -868,7 +944,7 @@ def release_background_tasks_lock():
_lock_file_handle = None
async def start_background_tasks(manager=None, world_locations=None):
async def start_background_tasks(manager=None, world_locations=None, npcs_data=None):
"""
Start all background tasks.
Called when the API starts up.
@@ -877,6 +953,7 @@ async def start_background_tasks(manager=None, world_locations=None):
Args:
manager: WebSocket ConnectionManager for broadcasting events
world_locations: Dict of Location objects for interactable mapping
npcs_data: Dict of static NPC definitions
"""
# Try to acquire lock - only one worker will succeed
if not acquire_background_tasks_lock():
@@ -894,6 +971,7 @@ async def start_background_tasks(manager=None, world_locations=None):
asyncio.create_task(check_pvp_combat_timers(manager)),
asyncio.create_task(decay_corpses(manager)),
asyncio.create_task(process_status_effects(manager)),
asyncio.create_task(restock_merchants(manager, npcs_data)),
# Note: Interactable cooldowns are handled client-side with server validation
]

View File

@@ -308,6 +308,50 @@ player_statistics = Table(
)
# ========================================================================
# QUESTS AND TRADE TABLES
# ========================================================================
# Quests: Character progress
character_quests = Table(
"character_quests",
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("status", String(20), default="active"), # active, completed, failed
Column("progress", JSON, default={}), # {"rat_kills": 1, "wood_delivered": 50}
Column("started_at", Float, default=lambda: time.time()),
Column("completed_at", Float, nullable=True),
Column("last_completed_at", Float, nullable=True), # For repeatable quests
Column("cooldown_expires_at", Float, nullable=True), # For repeatable quests
Column("times_completed", Integer, default=0),
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
)
# Quests: Global progress
global_quests = Table(
"global_quests",
metadata,
Column("quest_id", String, primary_key=True),
Column("global_progress", JSON, default={}),
Column("is_completed", Boolean, default=False),
Column("updated_at", Float, default=lambda: time.time()),
)
# Trade: Merchant Stock
merchant_stock = Table(
"merchant_stock",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("npc_id", String, nullable=False),
Column("item_id", String, nullable=False),
Column("unique_item_id", Integer, ForeignKey("unique_items.id", ondelete="SET NULL"), nullable=True),
Column("quantity", Integer, default=0),
Column("last_restock_at", Float, default=0),
)
# Database session context manager
class DatabaseSession:
"""Context manager for database sessions"""
@@ -357,6 +401,13 @@ async def init_db():
# Interactable cooldowns - checked on interact attempts
"CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_instance ON interactable_cooldowns(interactable_instance_id);",
# 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);",
# Merchant Stock
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
]
for index_sql in indexes:
@@ -692,6 +743,246 @@ async def can_create_character(account_id: int) -> tuple[bool, str]:
# ========================================================================
# ========================================================================
# QUEST OPERATIONS
# ========================================================================
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)
)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
async def get_character_quest(character_id: int, quest_id: str) -> Optional[Dict[str, Any]]:
"""Get a specific quest for a character"""
async with DatabaseSession() as session:
result = await session.execute(
select(character_quests).where(
and_(
character_quests.c.character_id == character_id,
character_quests.c.quest_id == quest_id
)
)
)
row = result.first()
return dict(row._mapping) if row else None
async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]:
"""Accept a new quest or restart a repeatable one"""
# Check if exists first to handle restarts
existing = await get_character_quest(character_id, quest_id)
async with DatabaseSession() as session:
if existing:
# Check if repeatable and cooldown passed
# Validation should happen in logic layer, but good to be safe here
stmt = update(character_quests).where(
character_quests.c.id == existing['id']
).values(
status="active",
progress={},
started_at=time.time(),
completed_at=None,
# Preserve statistics
).returning(character_quests)
else:
stmt = insert(character_quests).values(
character_id=character_id,
quest_id=quest_id,
status="active",
progress={},
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:
"""Update quest progress"""
values = {"progress": progress}
if status:
values["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
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 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)
await session.execute(stmt)
await session.commit()
return True
# Global Quests
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
"""Get global quest progress"""
async with DatabaseSession() as session:
result = await session.execute(
select(global_quests).where(global_quests.c.quest_id == quest_id)
)
row = result.first()
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 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()
)
else:
stmt = insert(global_quests).values(
quest_id=quest_id,
global_progress=progress,
is_completed=is_completed,
updated_at=time.time()
)
await session.execute(stmt)
await session.commit()
return True
# ========================================================================
# MERCHANT OPERATIONS
# ========================================================================
async def get_merchant_stock(npc_id: str) -> List[Dict[str, Any]]:
"""Get stock for a merchant"""
async with DatabaseSession() as session:
# Join with unique_items to get stats if applicable
# This is a bit complex, let's just get the stock and helper can resolve details
result = await session.execute(
select(merchant_stock).where(merchant_stock.c.npc_id == npc_id)
)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
async def update_merchant_stock(npc_id: str, item_id: str, quantity: int, unique_item_id: Optional[int] = None, update_restock_time: bool = False) -> bool:
"""
Update merchant stock quantity.
If unique_item_id is provided, it targets that specific instance.
If quantity <= 0, remove the row.
If update_restock_time is True, updates last_restock_at to now.
"""
async with DatabaseSession() as session:
# Check if exists
conditions = [
merchant_stock.c.npc_id == npc_id,
merchant_stock.c.item_id == item_id
]
if unique_item_id is not None:
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
else:
conditions.append(merchant_stock.c.unique_item_id.is_(None))
stmt = select(merchant_stock).where(and_(*conditions))
result = await session.execute(stmt)
existing = result.first()
if quantity <= 0:
if existing:
await session.execute(delete(merchant_stock).where(merchant_stock.c.id == existing.id))
else:
if existing:
values = {"quantity": quantity}
if update_restock_time:
values["last_restock_at"] = time.time()
await session.execute(
update(merchant_stock)
.where(merchant_stock.c.id == existing.id)
.values(**values)
)
else:
await session.execute(
insert(merchant_stock).values(
npc_id=npc_id,
item_id=item_id,
unique_item_id=unique_item_id,
quantity=quantity,
last_restock_at=time.time()
)
)
await session.commit()
return True
async def get_merchant_stock_item(npc_id: str, item_id: str, unique_item_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Get specific item from merchant stock"""
async with DatabaseSession() as session:
conditions = [
merchant_stock.c.npc_id == npc_id,
merchant_stock.c.item_id == item_id
]
if unique_item_id is not None:
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
else:
conditions.append(merchant_stock.c.unique_item_id.is_(None))
result = await session.execute(select(merchant_stock).where(and_(*conditions)))
row = result.first()
return dict(row._mapping) if row else None
async def get_all_merchants() -> List[str]:
"""Get list of all NPC IDs that have stock"""
async with DatabaseSession() as session:
result = await session.execute(select(merchant_stock.c.npc_id).distinct())
return [row[0] for row in result.fetchall()]
# Inventory operations
# NOTE: Functions below use 'player_id' parameter name for backward compatibility

View File

@@ -24,6 +24,7 @@ class Item:
volume: float = 0.0
stats: Dict[str, int] = None
effects: Dict[str, Any] = None
value: int = 10 # Base value for trading
# Equipment system
slot: str = None # Equipment slot: head, torso, legs, feet, weapon, offhand, backpack
durability: int = None # Max durability for equippable items
@@ -109,6 +110,7 @@ class ItemsManager:
name=item_data.get('name', 'Unknown Item'),
description=item_data.get('description', ''),
type=item_type,
value=item_data.get('value', 10),
image_path=item_data.get('image_path', ''),
emoji=item_data.get('emoji', '📦'),
stackable=item_data.get('stackable', True),

View File

@@ -33,7 +33,11 @@ from .routers import (
crafting,
loot,
statistics,
admin
statistics,
admin,
quests,
trade,
npcs
)
# Configure logging
@@ -79,7 +83,7 @@ async def lifespan(app: FastAPI):
print("✅ Redis listener started")
# Start background tasks (distributed via Redis locks)
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS)
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS, NPCS_DATA)
if tasks:
print(f"✅ Started {len(tasks)} background tasks in this worker")
else:
@@ -123,14 +127,43 @@ if IMAGES_DIR.exists():
else:
print(f"⚠️ Images directory not found: {IMAGES_DIR}")
# Initialize routers with game data dependencies
# Load Quests and NPCs Data at startup
QUESTS_DATA = {}
NPCS_DATA = {}
try:
print("🔄 Loading quests and NPCs...")
quests_path = Path("./gamedata/quests.json")
npcs_path = Path("./gamedata/static_npcs.json")
import json
if quests_path.exists():
with open(quests_path, "r") as f:
q_data = json.load(f)
QUESTS_DATA = q_data.get("quests", {})
print(f"✅ Loaded {len(QUESTS_DATA)} quests")
if npcs_path.exists():
with open(npcs_path, "r") as f:
n_data = json.load(f)
NPCS_DATA = n_data.get("static_npcs", {})
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
except Exception as e:
print(f"❌ Error loading game data: {e}")
# Initialize routers with game data dependencies
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
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)
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
npcs.init_router_dependencies()
# Include all routers
app.include_router(auth.router)
@@ -142,6 +175,9 @@ app.include_router(crafting.router)
app.include_router(loot.router)
app.include_router(statistics.router)
app.include_router(admin.router)
app.include_router(quests.router)
app.include_router(trade.router)
app.include_router(npcs.router)
print("✅ All routers registered")

View File

@@ -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 []
}

View File

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

View File

@@ -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
View 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
View 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
View 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"}