diff --git a/api/background_tasks.py b/api/background_tasks.py index 8baf0a0..3081789 100644 --- a/api/background_tasks.py +++ b/api/background_tasks.py @@ -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 ] diff --git a/api/database.py b/api/database.py index 9223d89..2c3f0ba 100644 --- a/api/database.py +++ b/api/database.py @@ -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 diff --git a/api/items.py b/api/items.py index 6975bf6..5ea99dd 100644 --- a/api/items.py +++ b/api/items.py @@ -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), diff --git a/api/main.py b/api/main.py index 6c9d2a3..0086871 100644 --- a/api/main.py +++ b/api/main.py @@ -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") diff --git a/api/routers/combat.py b/api/routers/combat.py index 7d744ef..debd58c 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -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 [] } diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 24fa53c..7764ce5 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -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() } diff --git a/api/routers/loot.py b/api/routers/loot.py index 8960ae7..4fd048a 100644 --- a/api/routers/loot.py +++ b/api/routers/loot.py @@ -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'], diff --git a/api/routers/npcs.py b/api/routers/npcs.py new file mode 100644 index 0000000..f77f000 --- /dev/null +++ b/api/routers/npcs.py @@ -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 diff --git a/api/routers/quests.py b/api/routers/quests.py new file mode 100644 index 0000000..1dad9e2 --- /dev/null +++ b/api/routers/quests.py @@ -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 diff --git a/api/routers/trade.py b/api/routers/trade.py new file mode 100644 index 0000000..dd265d5 --- /dev/null +++ b/api/routers/trade.py @@ -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"} diff --git a/docker-compose.yml b/docker-compose.yml index 53650a0..4ca0a6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,6 +118,8 @@ services: volumes: - ./gamedata:/app/gamedata:ro - ./images:/app/images:ro + - ./api:/app/api:rw + - ./data:/app/data:rw depends_on: - echoes_of_the_ashes_db - echoes_of_the_ashes_redis diff --git a/gamedata/items.json b/gamedata/items.json index df3d833..4dd62de 100644 --- a/gamedata/items.json +++ b/gamedata/items.json @@ -13,7 +13,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "rusty_nails": { "name": { @@ -28,7 +29,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "wood_planks": { "name": { @@ -43,7 +45,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "cloth_scraps": { "name": { @@ -58,7 +61,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "cloth": { "name": { @@ -86,7 +90,8 @@ "item_id": "knife", "durability_cost": 1 } - ] + ], + "value": 10 }, "plastic_bottles": { "name": { @@ -101,7 +106,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "bone": { "name": { @@ -116,7 +122,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "raw_meat": { "name": { @@ -131,7 +138,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "animal_hide": { "name": { @@ -146,7 +154,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "mutant_tissue": { "name": { @@ -161,7 +170,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "infected_tissue": { "name": { @@ -176,7 +186,8 @@ "description": { "en": "A raw material used for crafting and upgrades.", "es": "Un material bruto utilizado para la fabricación y las mejoras." - } + }, + "value": 10 }, "stale_chocolate_bar": { "name": { @@ -192,7 +203,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "canned_beans": { "name": { @@ -209,7 +221,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "canned_food": { "name": { @@ -226,7 +239,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "bottled_water": { "name": { @@ -242,7 +256,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "water_bottle": { "name": { @@ -258,7 +273,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "energy_bar": { "name": { @@ -274,7 +290,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "mystery_pills": { "name": { @@ -290,7 +307,8 @@ "description": { "en": "Can be consumed to restore health or stamina.", "es": "Se puede consumir para restaurar salud o stamina." - } + }, + "value": 10 }, "first_aid_kit": { "name": { @@ -306,7 +324,8 @@ "type": "consumable", "hp_restore": 50, "emoji": "🩹", - "image_path": "images/items/first_aid_kit.webp" + "image_path": "images/items/first_aid_kit.webp", + "value": 10 }, "bandage": { "name": { @@ -334,7 +353,8 @@ ] }, "emoji": "🩹", - "image_path": "images/items/bandage.webp" + "image_path": "images/items/bandage.webp", + "value": 10 }, "medical_supplies": { "name": { @@ -350,7 +370,8 @@ "type": "consumable", "hp_restore": 40, "emoji": "⚕️", - "image_path": "images/items/medical_supplies.webp" + "image_path": "images/items/medical_supplies.webp", + "value": 10 }, "antibiotics": { "name": { @@ -367,7 +388,8 @@ "hp_restore": 20, "treats": "Infected", "emoji": "💊", - "image_path": "images/items/antibiotics.webp" + "image_path": "images/items/antibiotics.webp", + "value": 10 }, "rad_pills": { "name": { @@ -384,7 +406,8 @@ "hp_restore": 5, "treats": "Radiation", "emoji": "☢️", - "image_path": "images/items/rad_pills.webp" + "image_path": "images/items/rad_pills.webp", + "value": 10 }, "tire_iron": { "name": { @@ -408,7 +431,8 @@ "damage_max": 5 }, "emoji": "🔧", - "image_path": "images/items/tire_iron.webp" + "image_path": "images/items/tire_iron.webp", + "value": 10 }, "baseball_bat": { "name": { @@ -428,7 +452,8 @@ "stats": { "damage_min": 5, "damage_max": 8 - } + }, + "value": 10 }, "rusty_knife": { "name": { @@ -464,7 +489,8 @@ "damage_max": 5 }, "emoji": "🔪", - "image_path": "images/items/rusty_knife.webp" + "image_path": "images/items/rusty_knife.webp", + "value": 10 }, "knife": { "name": { @@ -553,7 +579,8 @@ } }, "emoji": "🔪", - "image_path": "images/items/knife.webp" + "image_path": "images/items/knife.webp", + "value": 10 }, "rusty_pipe": { "name": { @@ -573,7 +600,8 @@ "stats": { "damage_min": 5, "damage_max": 8 - } + }, + "value": 10 }, "tattered_rucksack": { "name": { @@ -620,7 +648,8 @@ "volume_capacity": 10 }, "emoji": "🎒", - "image_path": "images/items/tattered_rucksack.webp" + "image_path": "images/items/tattered_rucksack.webp", + "value": 10 }, "hiking_backpack": { "name": { @@ -656,7 +685,8 @@ "volume_capacity": 20 }, "emoji": "🎒", - "image_path": "images/items/hiking_backpack.webp" + "image_path": "images/items/hiking_backpack.webp", + "value": 10 }, "flashlight": { "name": { @@ -676,7 +706,8 @@ "stats": { "damage_min": 5, "damage_max": 8 - } + }, + "value": 10 }, "old_photograph": { "name": { @@ -691,7 +722,8 @@ "description": { "en": "A useful old photograph.", "es": "Una fotografía vieja útil." - } + }, + "value": 10 }, "key_ring": { "name": { @@ -706,7 +738,8 @@ "description": { "en": "A useful key ring.", "es": "Un anillo de llaves útil." - } + }, + "value": 10 }, "makeshift_spear": { "name": { @@ -757,7 +790,8 @@ "damage_max": 7 }, "emoji": "⚔️", - "image_path": "images/items/makeshift_spear.webp" + "image_path": "images/items/makeshift_spear.webp", + "value": 10 }, "reinforced_bat": { "name": { @@ -814,7 +848,8 @@ } }, "emoji": "🏸", - "image_path": "images/items/reinforced_bat.webp" + "image_path": "images/items/reinforced_bat.webp", + "value": 10 }, "leather_vest": { "name": { @@ -865,7 +900,8 @@ "hp_bonus": 10 }, "emoji": "🦺", - "image_path": "images/items/leather_vest.webp" + "image_path": "images/items/leather_vest.webp", + "value": 10 }, "cloth_bandana": { "name": { @@ -903,7 +939,8 @@ "armor": 1 }, "emoji": "🧣", - "image_path": "images/items/cloth_bandana.webp" + "image_path": "images/items/cloth_bandana.webp", + "value": 10 }, "sturdy_boots": { "name": { @@ -954,7 +991,8 @@ "stamina_bonus": 5 }, "emoji": "🥾", - "image_path": "images/items/sturdy_boots.webp" + "image_path": "images/items/sturdy_boots.webp", + "value": 10 }, "padded_pants": { "name": { @@ -1001,7 +1039,8 @@ "hp_bonus": 5 }, "emoji": "👖", - "image_path": "images/items/padded_pants.webp" + "image_path": "images/items/padded_pants.webp", + "value": 10 }, "reinforced_pack": { "name": { @@ -1091,7 +1130,8 @@ "volume_capacity": 30 }, "emoji": "🎒", - "image_path": "images/items/reinforced_pack.webp" + "image_path": "images/items/reinforced_pack.webp", + "value": 10 }, "hammer": { "name": { @@ -1130,7 +1170,8 @@ ], "repair_percentage": 30, "emoji": "🔨", - "image_path": "images/items/hammer.webp" + "image_path": "images/items/hammer.webp", + "value": 10 }, "screwdriver": { "name": { @@ -1173,7 +1214,8 @@ "stats": { "damage_min": 5, "damage_max": 8 - } + }, + "value": 10 }, "pipe_bomb": { "name": { @@ -1194,7 +1236,8 @@ "combat_effects": { "damage_min": 15, "damage_max": 25 - } + }, + "value": 10 }, "molotov_cocktail": { "name": { @@ -1222,7 +1265,8 @@ "ticks": 3, "persist_after_combat": true } - } + }, + "value": 10 }, "smoke_bomb": { "name": { @@ -1249,7 +1293,8 @@ "ticks": 1, "persist_after_combat": false } - } + }, + "value": 10 }, "stim_pack": { "name": { @@ -1269,7 +1314,8 @@ "consumable": true, "combat_usable": true, "combat_only": true, - "hp_restore": 20 + "hp_restore": 20, + "value": 10 }, "adrenaline_shot": { "name": { @@ -1297,7 +1343,8 @@ "ticks": 2, "persist_after_combat": false } - } + }, + "value": 10 } } } \ No newline at end of file diff --git a/gamedata/quests.json b/gamedata/quests.json new file mode 100644 index 0000000..e2448f9 --- /dev/null +++ b/gamedata/quests.json @@ -0,0 +1,68 @@ +{ + "quests": { + "quest_collect_wood": { + "quest_id": "quest_collect_wood", + "title": { + "en": "Rebuilding the Bridge", + "es": "Reconstruyendo el Puente" + }, + "description": { + "en": "We need wood to repair the bridge to the north. Bring what you can.", + "es": "Necesitamos madera para reparar el puente del norte. Trae lo que puedas." + }, + "giver_id": "mechanic_mike", + "type": "global", + "repeatable": true, + "cooldown_hours": 0, + "objectives": [ + { + "type": "item_delivery", + "target": "wood_plank", + "count": 1000 + } + ], + "rewards": { + "xp": 10, + "items": { + "credits": 5 + } + }, + "completion_text": { + "en": "Thanks, every plank helps.", + "es": "Gracias, cada tabla ayuda." + } + }, + "quest_rat_problem": { + "quest_id": "quest_rat_problem", + "title": { + "en": "Rat Problem", + "es": "Problema de Ratas" + }, + "description": { + "en": "Mutant rats are infesting the basement. Kill 3 of them.", + "es": "Ratas mutantes infestan el sótano. Mata a 3 de ellas." + }, + "giver_id": "trader_joe", + "type": "individual", + "repeatable": true, + "cooldown_hours": 24, + "objectives": [ + { + "type": "kill_count", + "target": "mutant_rat", + "count": 3 + } + ], + "rewards": { + "xp": 50, + "items": { + "canned_food": 1 + } + }, + "completion_text": { + "en": "Thanks for clearing them out. Here's some food.", + "es": "Gracias por limpiarlos. Aquí tienes algo de comida." + } + } + } +} \ No newline at end of file diff --git a/gamedata/static_npcs.json b/gamedata/static_npcs.json new file mode 100644 index 0000000..4eb2ecc --- /dev/null +++ b/gamedata/static_npcs.json @@ -0,0 +1,93 @@ +{ + "static_npcs": { + "trader_joe": { + "npc_id": "trader_joe", + "name": { + "en": "Trader Joe", + "es": "Comerciante José" + }, + "location_id": "residential", + "image": "images/static_npcs/trader_joe.webp", + "dialog": { + "greeting": { + "en": "Got some rare goods for sale, stranger.", + "es": "Tengo mercancía rara a la venta, forastero." + }, + "topics": [ + { + "id": "lore_markets", + "title": { + "en": "About the markets", + "es": "Sobre los mercados" + }, + "text": { + "en": "Before the fall, this place was bustling. Now, we scrape by with what we can found.", + "es": "Antes de la caída, este lugar estaba lleno de vida. Ahora, sobrevivimos con lo que podemos encontrar." + } + } + ], + "quest_offer": { + "en": "I could use a hand with something.", + "es": "Podría necesitar una mano con algo." + } + }, + "trade": { + "enabled": true, + "currency": "value", + "unlimited_currency": true, + "keep_sold_items": true, + "buy_markup": 1.5, + "sell_markdown": 0.5, + "stock": [ + { + "item_id": "water_bottle", + "max_stock": 10, + "restock_rate": 2, + "infinite": false + }, + { + "item_id": "canned_food", + "max_stock": 50, + "infinite": true + } + ] + } + }, + "mechanic_mike": { + "npc_id": "mechanic_mike", + "name": { + "en": "Mechanic Mike", + "es": "Mecánico Mike" + }, + "location_id": "gas_station", + "image": "images/static_npcs/mechanic_mike.webp", + "dialog": { + "greeting": { + "en": "If it's broken, I might be able to fix it. Might.", + "es": "Si está roto, tal vez pueda arreglarlo. Tal vez." + }, + "topics": [], + "quest_offer": { + "en": "Need parts. Always need parts.", + "es": "Necesito piezas. Siempre necesito piezas." + } + }, + "trade": { + "enabled": true, + "currency": "value", + "unlimited_currency": true, + "keep_sold_items": false, + "buy_markup": 1.2, + "sell_markdown": 0.6, + "stock": [ + { + "item_id": "scrap_metal", + "max_stock": 20, + "refresh_rate": 5, + "infinite": false + } + ] + } + } + } +} \ No newline at end of file diff --git a/images-source/items/water_bottle.png b/images-source/items/water_bottle.png new file mode 100644 index 0000000..5afec41 Binary files /dev/null and b/images-source/items/water_bottle.png differ diff --git a/images-source/make_webp.sh b/images-source/make_webp.sh index 8408044..335cd33 100755 --- a/images-source/make_webp.sh +++ b/images-source/make_webp.sh @@ -10,13 +10,14 @@ set -e SOURCE_DIR="." OUTPUT_DIR="../images" ITEM_SIZE="256x256" +PORTRAIT_SIZE="512x512" echo "🔄 Starting image conversion..." echo " Source: $SOURCE_DIR" echo " Output: $OUTPUT_DIR" echo "" -for category in items locations npcs interactables characters placeholder; do +for category in items locations npcs interactables characters placeholder static_npcs; do src="$SOURCE_DIR/$category" out="$OUTPUT_DIR/$category" @@ -38,11 +39,15 @@ for category in items locations npcs interactables characters placeholder; do continue fi - if [[ "$category" == "items" || "$category" == "placeholder" ]]; then + if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" ]]; then # Special processing for items: remove white background and resize echo " ➜ Converting item: $filename" tmp="/tmp/${base}_clean.png" - convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp" + if [[ "$category" == "static_npcs" ]]; then + convert "$img" -fuzz 10% -transparent white -resize "$PORTRAIT_SIZE" "$tmp" + else + convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp" + fi cwebp "$tmp" -q 85 -o "$out_file" >/dev/null rm "$tmp" else diff --git a/images-source/static_npcs/trader_joe.png b/images-source/static_npcs/trader_joe.png new file mode 100644 index 0000000..ea00db1 Binary files /dev/null and b/images-source/static_npcs/trader_joe.png differ diff --git a/images/items/water_bottle.webp b/images/items/water_bottle.webp new file mode 100644 index 0000000..059726f Binary files /dev/null and b/images/items/water_bottle.webp differ diff --git a/images/static_npcs/trader_joe.webp b/images/static_npcs/trader_joe.webp new file mode 100644 index 0000000..edbd343 Binary files /dev/null and b/images/static_npcs/trader_joe.webp differ diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index 2fa03ff..6c440e8 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -7,11 +7,16 @@ import { Combat } from './game/Combat' import LocationView from './game/LocationView' import MovementControls from './game/MovementControls' import PlayerSidebar from './game/PlayerSidebar' + +import { GameProvider } from '../contexts/GameContext' +import { QuestJournal } from './game/QuestJournal' import './Game.css' function Game() { const { t, i18n } = useTranslation() + const [token] = useState(() => localStorage.getItem('token')) + const [showQuestJournal, setShowQuestJournal] = useState(false) // Handle WebSocket messages const handleWebSocketMessage = async (message: any) => { @@ -323,6 +328,17 @@ function Game() { ) } + + + // Create context value + const gameContextValue = { + token, + locale: i18n.language, + inventory: state.playerState?.inventory || [], + state, + actions + } + // No location loaded yet if (!state.location) { return ( @@ -333,213 +349,233 @@ function Game() { } return ( -
- {/* Game Header is now in GameLayout */} + +
+ {/* Game Header is now in GameLayout */} - {/* Mobile Header Toggle - only show in main view */} - {state.mobileMenuOpen === 'none' && ( - - )} + {/* Quest Journal Toggle Button - Add to header or float? + Let's add it floating for now or in the top right. + */} - {/* Main game area */} -
-
- {/* Left Sidebar: Movement & Surroundings */} -
- {state.location && state.profile && ( - - )} -
- {/* Center: Location view or Combat */} -
- {/* Combat view (when in combat) */} - {state.combatState && state.playerState && ( - actions.setMobileHeaderOpen(!state.mobileHeaderOpen)} + > + {state.mobileHeaderOpen ? '✕' : '☰'} + + )} + + {/* Main game area */} +
+
+ {/* Left Sidebar: Movement & Surroundings */} +
+ {state.location && state.profile && ( + + )} +
+ + {/* Center: Location view or Combat */} +
+ {/* Combat view (when in combat) */} + {state.combatState && state.playerState && ( + { + try { + const response = await api.post('/api/game/pvp/action', { action }) + actions.setMessage(response.data.message || 'Action performed!') + // We don't need to fetchGameData here because the websocket update will handle it? + // The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call." + // So we should probably update state from response if possible, OR fetch. + // Let's return the data so Combat.tsx can use it for animations. + // And let's fetchGameData to be safe, but maybe skip if we trust the websocket? + // Let's keep fetchGameData for now as a fallback. + await actions.fetchGameData() + return response.data + } catch (error: any) { + actions.setMessage(error.response?.data?.detail || 'PvP action failed') + return null + } + }} + onExitCombat={() => { + actions.handleExitCombat() + }} + onExitPvPCombat={actions.handleExitPvPCombat} + addCombatLogEntry={actions.addCombatLogEntry} + updatePlayerState={actions.updatePlayerState} + updateCombatState={actions.updateCombatState} + /> + )} + + {/* Location view (when not in combat) */} + {!state.combatState && state.location && state.playerState && ( + { + try { + const response = await api.post('/api/game/pvp/initiate', { target_player_id: playerId }) + actions.setMessage(response.data.message || 'PvP combat initiated!') + await actions.fetchGameData() + } catch (error: any) { + actions.setMessage(error.response?.data?.detail || 'Failed to initiate PvP') + } + }} + onPickup={actions.handlePickup} + onLootCorpse={actions.handleLootCorpse} + onLootCorpseItem={actions.handleLootCorpseItem} + onSetExpandedCorpse={(corpseId: string | null) => { + if (corpseId === null) { + actions.handleCloseCorpseDetails() + } else { + actions.handleViewCorpseDetails(corpseId) + } + }} + onOpenCrafting={actions.handleOpenCrafting} + onOpenRepair={actions.handleOpenRepair} + onCloseCrafting={actions.handleCloseCrafting} + onSwitchWorkbenchTab={actions.handleSwitchWorkbenchTab} + onSetCraftFilter={actions.setCraftFilter} + onSetRepairFilter={actions.setRepairFilter} + onSetUncraftFilter={actions.setUncraftFilter} + onSetCraftCategoryFilter={actions.setCraftCategoryFilter} + onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())} + onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)} + onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)} + failedActionItemId={state.failedActionItemId} + quests={state.quests} + /> + )} +
+ + {/* Right sidebar: Stats + Inventory */} + {state.playerState && state.profile && ( + { - try { - const response = await api.post('/api/game/pvp/action', { action }) - actions.setMessage(response.data.message || 'Action performed!') - // We don't need to fetchGameData here because the websocket update will handle it? - // The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call." - // So we should probably update state from response if possible, OR fetch. - // Let's return the data so Combat.tsx can use it for animations. - // And let's fetchGameData to be safe, but maybe skip if we trust the websocket? - // Let's keep fetchGameData for now as a fallback. - await actions.fetchGameData() - return response.data - } catch (error: any) { - actions.setMessage(error.response?.data?.detail || 'PvP action failed') - return null - } - }} - onExitCombat={() => { - actions.handleExitCombat() - }} - onExitPvPCombat={actions.handleExitPvPCombat} - addCombatLogEntry={actions.addCombatLogEntry} - updatePlayerState={actions.updatePlayerState} - updateCombatState={actions.updateCombatState} - /> - )} - - {/* Location view (when not in combat) */} - {!state.combatState && state.location && state.playerState && ( - { - try { - const response = await api.post('/api/game/pvp/initiate', { target_player_id: playerId }) - actions.setMessage(response.data.message || 'PvP combat initiated!') - await actions.fetchGameData() - } catch (error: any) { - actions.setMessage(error.response?.data?.detail || 'Failed to initiate PvP') - } + onSetInventoryFilter={actions.setInventoryFilter} + onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter} + onUseItem={async (itemId: number, _invId: number) => { + await actions.handleUseItem(itemId.toString()) }} - onPickup={actions.handlePickup} - onLootCorpse={actions.handleLootCorpse} - onLootCorpseItem={actions.handleLootCorpseItem} - onSetExpandedCorpse={(corpseId: string | null) => { - if (corpseId === null) { - actions.handleCloseCorpseDetails() - } else { - actions.handleViewCorpseDetails(corpseId) - } + onEquipItem={actions.handleEquipItem} + onUnequipItem={actions.handleUnequipItem} + onDropItem={async (itemId: number, _invId: number, quantity: number) => { + await actions.handleDropItem(itemId.toString(), quantity) }} - onOpenCrafting={actions.handleOpenCrafting} - onOpenRepair={actions.handleOpenRepair} - onCloseCrafting={actions.handleCloseCrafting} - onSwitchWorkbenchTab={actions.handleSwitchWorkbenchTab} - onSetCraftFilter={actions.setCraftFilter} - onSetRepairFilter={actions.setRepairFilter} - onSetUncraftFilter={actions.setUncraftFilter} - onSetCraftCategoryFilter={actions.setCraftCategoryFilter} - onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())} - onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)} - onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)} - failedActionItemId={state.failedActionItemId} + onSpendPoint={actions.handleSpendPoint} + onOpenQuestJournal={() => setShowQuestJournal(true)} /> )}
- {/* Right sidebar: Stats + Inventory */} - {state.playerState && state.profile && ( - { - await actions.handleUseItem(itemId.toString()) - }} - onEquipItem={actions.handleEquipItem} - onUnequipItem={actions.handleUnequipItem} - onDropItem={async (itemId: number, _invId: number, quantity: number) => { - await actions.handleDropItem(itemId.toString(), quantity) - }} - onSpendPoint={actions.handleSpendPoint} + {/* Mobile Tab Navigation */} +
+ + + +
+ + {/* Mobile Menu Overlays */} + {state.mobileMenuOpen !== 'none' && ( +
actions.setMobileMenuOpen('none')} /> )}
- {/* Mobile Tab Navigation */} -
+ {/* Mobile navigation */} +
+ {/* Mobile Quest Button */} +
- {/* Mobile Menu Overlays */} - {state.mobileMenuOpen !== 'none' && ( -
actions.setMobileMenuOpen('none')} - /> + {showQuestJournal && ( + setShowQuestJournal(false)} /> )}
- - {/* Mobile navigation */} -
- - - -
-
+ ) } diff --git a/pwa/src/components/common/GameDropdown.tsx b/pwa/src/components/common/GameDropdown.tsx index ad7ba5e..8a64835 100644 --- a/pwa/src/components/common/GameDropdown.tsx +++ b/pwa/src/components/common/GameDropdown.tsx @@ -55,14 +55,15 @@ export const GameDropdown: React.FC = ({ // Use mousedown to catch clicks before they might trigger other things document.addEventListener('mousedown', handleClickOutside); - // Disable scrolling while dropdown is open - const originalOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; + // Handle scroll to close the dropdown (prevents detached menu and layout shifts) + const handleScroll = () => { + onClose(); + }; + window.addEventListener('scroll', handleScroll, true); return () => { document.removeEventListener('mousedown', handleClickOutside); - // Restore scrolling - document.body.style.overflow = originalOverflow; + window.removeEventListener('scroll', handleScroll, true); }; }, [isOpen, onClose]); diff --git a/pwa/src/components/game/DialogModal.css b/pwa/src/components/game/DialogModal.css new file mode 100644 index 0000000..5898b80 --- /dev/null +++ b/pwa/src/components/game/DialogModal.css @@ -0,0 +1,112 @@ +.dialog-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.dialog-container { + background: rgba(20, 20, 20, 0.95); + border: 1px solid #444; + border-radius: 8px; + padding: 20px; + max-width: 600px; + width: 90%; + color: #e0e0e0; + position: relative; + display: flex; + flex-direction: column; + gap: 15px; + min-height: 300px; +} + +.npc-image { + width: 100px; + height: 100px; + border-radius: 50%; + border: 2px solid #555; + object-fit: cover; + align-self: center; +} + +.npc-name { + text-align: center; + margin: 5px 0 15px 0; + color: #ff9800; +} + +.dialogue-text { + background: rgba(0, 0, 0, 0.3); + padding: 15px; + border-radius: 5px; + font-size: 1.1rem; + line-height: 1.4; + min-height: 80px; + border: 1px solid #333; +} + +/* Renamed from .options-container to match JSX */ +.options-grid { + display: grid; + grid-template-columns: 1fr 1fr; + /* grid-auto-rows: 1fr; Removed to prevent forced height expansion */ + gap: 10px; + margin-top: auto; +} + +/* Make back button and exit button span full width if needed, or keep grid */ +/* Let's make the 'Back' button span full width for better UX */ +.options-grid>.option-btn:first-child:nth-last-child(1) { + grid-column: span 2; +} + +.option-btn { + /* Base styles handled by GameButton, but we can override */ + width: 100%; + /* height: 100%; Removed to prevent stretching */ + /* Fill the grid cell */ + margin: 0; +} + +.option-button { + /* Legacy style - keeping just in case */ + background: rgba(255, 255, 255, 0.05); + border: 1px solid #555; + color: #ccc; + padding: 10px; + text-align: left; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; +} + +.option-button:hover { + background: rgba(255, 152, 0, 0.1); + border-color: #ff9800; + color: #fff; +} + +.action-button { + border-left: 3px solid #ff9800; +} + +.dialog-close-btn { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + color: #666; + font-size: 1.2rem; + cursor: pointer; +} + +.dialog-close-btn:hover { + color: #fff; +} \ No newline at end of file diff --git a/pwa/src/components/game/DialogModal.tsx b/pwa/src/components/game/DialogModal.tsx new file mode 100644 index 0000000..f1141d5 --- /dev/null +++ b/pwa/src/components/game/DialogModal.tsx @@ -0,0 +1,297 @@ +import React, { useState, useEffect } from 'react'; +import { useGame } from '../../contexts/GameContext'; +import { GAME_API_URL } from '../../config'; +import { GameModal } from './GameModal'; +import { GameButton } from '../common/GameButton'; +import { getAssetPath } from '../../utils/assetPath'; +import './DialogModal.css'; + +interface DialogModalProps { + npcId: string; + npcData: any; + onClose: () => void; + onTrade?: () => void; +} + +interface Topic { + id: string; + title: { [key: string]: string } | string; + text: { [key: string]: string } | string; +} + +interface Quest { + quest_id: string; + title: { [key: string]: string } | string; + description: { [key: string]: string } | string; + giver_id: string; + objectives: any[]; + repeatable?: boolean; + type?: 'individual' | 'global'; + // Logic for frontend state + status?: 'available' | 'active' | 'completed' | 'can_turn_in'; +} + +export const DialogModal: React.FC = ({ npcId, npcData, onClose, onTrade }) => { + const { token, locale, actions } = useGame(); + const [dialogData, setDialogData] = useState(null); + const [currentText, setCurrentText] = useState(""); + const [quests, setQuests] = useState([]); + const [viewState, setViewState] = useState<'greeting' | 'topic' | 'quest_preview'>('greeting'); + const [selectedQuest, setSelectedQuest] = useState(null); + + // Fetch dialog and quests + useEffect(() => { + const fetchData = async () => { + if (!token || !npcId) return; + + try { + // 1. Fetch Dialog + const dialogRes = await fetch(`${GAME_API_URL}/npcs/${npcId}/dialog`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const dialog = await dialogRes.json(); + setDialogData(dialog); + + // Initial greeting + const greeting = getLocalized(dialog.greeting) || "Hello."; + setCurrentText(greeting); + + // 2. Fetch Available Quests (Starts) + const availRes = await fetch(`${GAME_API_URL}/quests/available`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const availableQuests = await availRes.json(); + + // 3. Fetch Active Quests (Turn-ins) + const activeRes = await fetch(`${GAME_API_URL}/quests/active`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const activeQuests = await activeRes.json(); + + // Filter and Merge for this NPC + const npcQuests: Quest[] = []; + + // Add available quests from this NPC + if (Array.isArray(availableQuests)) { + availableQuests.forEach((q: any) => { + if (q.giver_id === npcId) { + npcQuests.push({ ...q, status: 'available' }); + } + }); + } + + // Add active quests from this NPC + if (Array.isArray(activeQuests)) { + activeQuests.forEach((q: any) => { + if (q.giver_id === npcId && q.status === 'active') { + npcQuests.push({ ...q, status: 'active' }); + } + }); + } + + setQuests(npcQuests); + + } catch (e) { + console.error("Error fetching NPC data", e); + } + }; + + fetchData(); + }, [npcId, token, locale]); + + const getLocalized = (obj: any) => { + if (typeof obj === 'string') return obj; + return obj?.[locale] || obj?.['en'] || ""; + }; + + const handleTopicClick = (topic: Topic) => { + const text = getLocalized(topic.text) || "..."; + setCurrentText(text); + setViewState('topic'); + }; + + const handleQuestClick = (quest: Quest) => { + setSelectedQuest(quest); + const desc = getLocalized(quest.description); + + if (quest.status === 'active') { + setCurrentText(desc + "\n\n(Quest in progress...)"); + } else { + setCurrentText(desc); + } + setViewState('quest_preview'); + }; + + const acceptQuest = async () => { + if (!selectedQuest) return; + try { + const res = await fetch(`${GAME_API_URL}/quests/accept/${selectedQuest.quest_id}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + // Refresh or update state + setCurrentText("Quest accepted! Good luck."); + + if (data.quest) { + actions.handleQuestUpdate(data.quest); + } + + setTimeout(() => { + setViewState('greeting'); + // Remove from available, add to active locally (simplification) + setQuests(prev => prev.map(q => q.quest_id === selectedQuest.quest_id ? { ...q, status: 'active' } : q)); + setSelectedQuest(null); + resetToGreeting(); + }, 1500); + } else { + const err = await res.json(); + alert(err.detail); + } + } catch (e) { + console.error(e); + } + }; + + const handInQuest = async () => { + if (!selectedQuest) return; + try { + const res = await fetch(`${GAME_API_URL}/quests/hand_in/${selectedQuest.quest_id}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + const result = await res.json(); + + if (res.ok) { + if (result.quest_update) { + actions.handleQuestUpdate(result.quest_update); + } + // Refresh game data to update inventory/stats + actions.fetchGameData(); + + if (result.is_completed) { + let msg = getLocalized(result.completion_text) || "Thank you!"; + if (result.rewards && result.rewards.length > 0) { + msg += "\n\nRewards:\n" + result.rewards.join('\n'); + } + setCurrentText(msg); + // Remove from list + setQuests(prev => prev.filter(q => q.quest_id !== selectedQuest.quest_id)); + } else { + setCurrentText(`Progress updated.\n${result.items_deducted?.join('\n')}`); + } + setTimeout(() => { + resetToGreeting(); + }, 2000); + } else { + alert(result.detail); + } + } catch (e) { + console.error(e); + } + }; + + const resetToGreeting = () => { + if (!dialogData) return; + const greeting = getLocalized(dialogData.greeting) || "Hello."; + setCurrentText(greeting); + setViewState('greeting'); + setSelectedQuest(null); + }; + + if (!dialogData) return null; + + const npcName = getLocalized(npcData?.name) || "Unknown"; + + return ( + +
+
+ {npcName} +
+ +
+
+

{currentText}

+
+ +
+ {/* BACK BUTTON */} + {(viewState === 'topic' || viewState === 'quest_preview') && ( + + ← Back + + )} + + {/* NPC TOPICS */} + {viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => ( + handleTopicClick(topic)}> + 💬 {getLocalized(topic.title)} + + ))} + + {/* QUESTS */} + {viewState === 'greeting' && quests.map(q => ( + handleQuestClick(q)} + variant={q.status === 'active' ? 'warning' : 'info'} + > + {q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)} + + ))} + + {/* CONFIRM QUEST ACTION */} + {viewState === 'quest_preview' && selectedQuest?.status === 'available' && ( +
+ + Accept Quest + +
+ )} + + {viewState === 'quest_preview' && selectedQuest?.status === 'active' && ( +
+ + {/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */} + {selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery') + ? "Complete Quest" + : "Hand In Items"} + +
+ )} + + {/* TRADE - Only show in greeting */} + {viewState === 'greeting' && npcData.trade?.enabled && ( + + 💰 Trade + + )} + + {/* EXIT - Span full width */} + {viewState === 'greeting' && ( + + Goodbye + + )} +
+
+
+
+ ); +}; diff --git a/pwa/src/components/game/GameModal.css b/pwa/src/components/game/GameModal.css new file mode 100644 index 0000000..b0c498c --- /dev/null +++ b/pwa/src/components/game/GameModal.css @@ -0,0 +1,102 @@ +.game-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(2px); + animation: fadeIn 0.2s ease-out; +} + +.game-modal-container { + background: #1a1a1a; + border: 1px solid #333; + /* border-radius: 8px; REMOVED */ + clip-path: var(--game-clip-path); + width: 90%; + /* Default width */ + max-width: 600px; + max-height: 90vh; + min-height: 400px; + display: flex; + flex-direction: column; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); + animation: slideUp 0.3s ease-out; + color: #e0e0e0; +} + +.game-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #333; + background: linear-gradient(to bottom, #252525, #1a1a1a); + /* border-radius: 8px 8px 0 0; REMOVED */ +} + +.game-modal-title { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + color: #fff; + letter-spacing: 0.5px; +} + +.game-modal-close-btn { + background: none; + border: none; + color: #888; + font-size: 24px; + cursor: pointer; + line-height: 1; + padding: 0; + transition: color 0.2s; +} + +.game-modal-close-btn:hover { + color: #fff; +} + +.game-modal-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.game-modal-footer { + padding: 15px 20px; + border-top: 1px solid #333; + background: #151515; + /* border-radius: 0 0 8px 8px; REMOVED */ + display: flex; + justify-content: flex-end; + gap: 10px; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/pwa/src/components/game/GameModal.tsx b/pwa/src/components/game/GameModal.tsx new file mode 100644 index 0000000..4843a3c --- /dev/null +++ b/pwa/src/components/game/GameModal.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; +import './GameModal.css'; + +interface GameModalProps { + title?: string; + onClose: () => void; + children: ReactNode; + className?: string; // For specific styling overrides + footer?: ReactNode; +} + +export const GameModal: React.FC = ({ title, onClose, children, className = '', footer }) => { + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }}> +
+
+

{title}

+ +
+ +
+ {children} +
+ + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +}; diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx index 3d8430c..94109a4 100644 --- a/pwa/src/components/game/LocationView.tsx +++ b/pwa/src/components/game/LocationView.tsx @@ -8,6 +8,8 @@ import { GameButton } from '../common/GameButton' import { GameDropdown } from '../common/GameDropdown' import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' +import { DialogModal } from './DialogModal' +import { TradeModal } from './TradeModal' import './LocationView.css' interface LocationViewProps { @@ -49,6 +51,7 @@ interface LocationViewProps { onRepair: (uniqueItemId: string, inventoryId: number) => void onUncraft: (uniqueItemId: string, inventoryId: number) => void failedActionItemId: string | number | null + quests: { active: any[], available: any[] } } function LocationView({ @@ -70,6 +73,7 @@ function LocationView({ uncraftFilter, craftCategoryFilter, profile, + quests, onInitiateCombat, onInitiatePvP, @@ -91,17 +95,25 @@ function LocationView({ }: LocationViewProps) { const { t } = useTranslation() const { playSfx } = useAudio() + // const { token } = useGame() // No longer needed for fetching here // Dropdown State const [activeDropdown, setActiveDropdown] = useState(null) + // NPC Interaction State + const [activeDialogNpc, setActiveDialogNpc] = useState(null) + const [showTradeModal, setShowTradeModal] = useState(false) + const [activeNpcData, setActiveNpcData] = useState(null) + + // Quest State + const [questIndicators, setQuestIndicators] = useState<{ [npcId: string]: string }>({}) + // Handle dropdown toggle const handleDropdownClick = (e: React.MouseEvent, id: string) => { e.stopPropagation() if (activeDropdown === id) { setActiveDropdown(null) } else { - // GameDropdown now auto-detects mouse position if we don't pass it setActiveDropdown(id) } } @@ -113,6 +125,85 @@ function LocationView({ return () => window.removeEventListener('click', handleClickOutside) }, []) + // Calculate Quest Indicators from props + useEffect(() => { + const indicators: { [id: string]: string } = {}; + if (!quests) return; + + const { active, available } = quests; + + // Check Available (New Quests) + available.forEach((q: any) => { + // Filter by location if needed? available/ endpoints already filters by location usually? + // Actually /api/quests/available returns quests available *at the current location*. + // But GameEngine fetches it globally? + // Wait, /api/quests/available depends on current_user's location. + // So useGameEngine.ts fetching valid ONLY for current location. + // If player moves, fetchGameData is called, so available quests are refreshed. Correct. + + if (q.giver_id) { + if (q.type === 'global') indicators[q.giver_id] = 'blue_exclamation'; + else indicators[q.giver_id] = 'yellow_exclamation'; + } + }); + + // Check Active (Ready to turn in or Cooldown) + active.forEach((q: any) => { + if (q.giver_id) { + let allDone = true; + + if (q.objectives) { + q.objectives.forEach((obj: any) => { + const current = q.progress?.[obj.target] || 0; + if (current < obj.count) allDone = false; + }); + } + + if (allDone && q.status === 'active') { + indicators[q.giver_id] = 'yellow_question'; + } + + if (q.on_cooldown) { + indicators[q.giver_id] = 'gray_loop'; + } + } + }); + + setQuestIndicators(indicators); + }, [quests, location.id]); + + const handleNpcClick = (npc: any) => { + setActiveNpcData(npc); + setActiveDialogNpc(npc.id); + }; + + const renderIndicator = (npcId: string) => { + const type = questIndicators[npcId]; + if (!type) return null; + + let symbol = ''; + let color = ''; + + switch (type) { + case 'yellow_exclamation': symbol = '!'; color = '#ffeb3b'; break; + case 'blue_exclamation': symbol = '!'; color = '#4fc3f7'; break; + case 'yellow_question': symbol = '?'; color = '#ffeb3b'; break; + case 'gray_loop': symbol = '⟳'; color = '#9e9e9e'; break; + default: return null; + } + + return ( +
+ {symbol} +
+ ); + }; + return (
@@ -165,8 +256,6 @@ function LocationView({
)} - - {location.image_url && (
- {/* {message && ( -
onSetMessage('')}> - {message} -
- )} */} - {locationMessages.length > 0 && (

{t('location.recentActivity')}

@@ -321,30 +404,25 @@ function LocationView({
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
handleDropdownClick(e, `npc-${i}`)} + onClick={() => handleNpcClick(npc)} + style={{ cursor: 'pointer', position: 'relative' }} > - 🧑 + {npc.image_path ? ( + {getTranslatedText(npc.name)} + ) : ( + 🧑 + )} + + {renderIndicator(npc.id)}
{getTranslatedText(npc.name)}
-
{t('location.level')} {npc.level}
+
Click to Interact
}>
- - {activeDropdown === `npc-${i}` && ( - setActiveDropdown(null)} - > -
{getTranslatedText(npc.name)}
- - 💬 {t('common.talk')} - -
- )}
))}
@@ -514,8 +592,6 @@ function LocationView({ style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; - // Fallback emoji next to it will show if image fails? - // Current logic doesn't have fallback emoji element sibling, just keeping it simple. }} /> ) : {item.emoji || '📦'}} @@ -583,6 +659,25 @@ function LocationView({ onUncraft={onUncraft} /> )} + + {activeDialogNpc && activeNpcData && ( + setActiveDialogNpc(null)} + onTrade={() => { + setActiveDialogNpc(null); + setShowTradeModal(true); + }} + /> + )} + + {showTradeModal && activeNpcData && ( + setShowTradeModal(false)} + /> + )}
) } diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx index 6cae566..fb7d436 100644 --- a/pwa/src/components/game/PlayerSidebar.tsx +++ b/pwa/src/components/game/PlayerSidebar.tsx @@ -23,6 +23,7 @@ interface PlayerSidebarProps { onUnequipItem: (slot: string) => void onDropItem: (itemId: number, invId: number, quantity: number) => void onSpendPoint: (stat: string) => void + onOpenQuestJournal: () => void } function PlayerSidebar({ @@ -38,7 +39,8 @@ function PlayerSidebar({ onEquipItem, onUnequipItem, onDropItem, - onSpendPoint + onSpendPoint, + onOpenQuestJournal }: PlayerSidebarProps) { const [showInventory, setShowInventory] = useState(false) const { t } = useTranslation() @@ -290,15 +292,27 @@ function PlayerSidebar({
)} - setShowInventory(true)} - style={{ width: '100%', justifyContent: 'center' }} - > - {t('game.inventory')} - +
+ setShowInventory(true)} + style={{ width: '100%', justifyContent: 'center' }} + > + {t('game.inventory')} + + + + 📜 {t('common.quests')} + +
diff --git a/pwa/src/components/game/QuestJournal.css b/pwa/src/components/game/QuestJournal.css new file mode 100644 index 0000000..d9b9c40 --- /dev/null +++ b/pwa/src/components/game/QuestJournal.css @@ -0,0 +1,146 @@ +.quest-journal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.journal-container { + background: rgba(20, 20, 20, 0.95); + border: 1px solid #444; + border-radius: 8px; + padding: 20px; + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + color: #e0e0e0; + position: relative; +} + +.journal-title { + color: #ff9800; + border-bottom: 2px solid #555; + padding-bottom: 10px; + margin-top: 0; +} + +.journal-close-btn { + position: absolute; + top: 15px; + right: 15px; + background: none; + border: none; + color: #888; + font-size: 1.5rem; + cursor: pointer; +} + +.journal-close-btn:hover { + color: #fff; +} + +.tab-container { + display: flex; + margin-bottom: 20px; + border-bottom: 1px solid #444; +} + +.journal-tab { + background: transparent; + border: none; + border-bottom: 3px solid transparent; + color: #aaa; + padding: 10px 20px; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; +} + +.journal-tab:hover { + color: #fff; + background: rgba(255, 255, 255, 0.05); +} + +.journal-tab.active { + background: rgba(255, 152, 0, 0.2); + border-bottom: 3px solid #ff9800; + color: #ff9800; +} + +.quest-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.quest-card { + background: rgba(0, 0, 0, 0.3); + border: 1px solid #555; + border-radius: 5px; + padding: 15px; +} + +.quest-card.completed { + border-color: #4caf50; +} + +.quest-card h3 { + margin: 0 0 5px 0; + color: #ddd; + display: flex; + justify-content: space-between; +} + +.quest-card.completed h3 { + color: #4caf50; +} + +.quest-desc { + font-size: 0.9rem; + color: #ccc; + margin-bottom: 10px; + font-style: italic; +} + +.objective-list { + list-style: none; + padding: 0; + margin: 10px 0; +} + +.objective-item { + color: #aaa; + margin-bottom: 4px; + display: flex; + align-items: center; +} + +.objective-item.met { + color: #8bc34a; +} + +.objective-item:before { + content: '○'; + margin-right: 8px; + font-weight: bold; + color: #777; +} + +.objective-item.met:before { + content: '✓'; + color: #8bc34a; +} + +.empty-message { + text-align: center; + padding: 40px; + color: #777; + font-style: italic; +} \ No newline at end of file diff --git a/pwa/src/components/game/QuestJournal.tsx b/pwa/src/components/game/QuestJournal.tsx new file mode 100644 index 0000000..570da1d --- /dev/null +++ b/pwa/src/components/game/QuestJournal.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import { useGame } from '../../contexts/GameContext'; +import { GameModal } from './GameModal'; +import './QuestJournal.css'; + +interface Quest { + quest_id: string; + title: { [key: string]: string } | string; + description: { [key: string]: string } | string; + status: string; + progress: { [key: string]: number }; + objectives: any[]; + rewards: any; + type: string; + completion_text?: { [key: string]: string } | string; + completed_at?: number; +} + +interface QuestJournalProps { + onClose: () => void; +} + +export const QuestJournal: React.FC = ({ onClose }) => { + const { locale, state } = useGame(); // Use global state + const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active'); + + // Derived from global state + const quests = (state.quests.active || []) as Quest[]; + + const getLocalizedText = (textObj: any) => { + if (typeof textObj === 'string') return textObj; + if (!textObj) return ''; + return textObj[locale] || textObj['en'] || Object.values(textObj)[0] || ''; + }; + + const filteredQuests = quests.filter((q: Quest) => { + if (activeTab === 'active') { + return q.status === 'active'; + } else { + return q.status === 'completed'; + } + }); + + const renderObjectives = (quest: Quest) => { + return quest.objectives.map((obj, idx) => { + const current = quest.progress[obj.target] || 0; + const required = obj.count; + const met = current >= required; + let label = obj.target; + + if (obj.type === 'kill_count') { + label = `Kill ${obj.target}`; + } else if (obj.type === 'item_delivery') { + label = `Deliver ${obj.target}`; + } + + return ( +
  • + {label}: {current}/{required} +
  • + ); + }); + }; + + return ( + +
    + + +
    +
    + } + > +
    +
    + {filteredQuests.length === 0 ? ( +
    No quests found in this category.
    + ) : ( + filteredQuests.map((quest: Quest) => ( +
    +

    + {getLocalizedText(quest.title)} + {quest.type === 'global' && GLOBAL} +

    +
    {getLocalizedText(quest.description)}
    + + {quest.status === 'active' && ( +
      + {renderObjectives(quest)} +
    + )} + + {quest.status === 'completed' && quest.completion_text && ( +
    + "{getLocalizedText(quest.completion_text)}" +
    + )} +
    + )) + )} +
    +
    + + ); +}; diff --git a/pwa/src/components/game/TradeModal.css b/pwa/src/components/game/TradeModal.css new file mode 100644 index 0000000..38a9328 --- /dev/null +++ b/pwa/src/components/game/TradeModal.css @@ -0,0 +1,316 @@ +/* Trade container layout */ +/* Trade container overrides */ +.game-modal-container.trade-modal { + max-width: 1400px; + width: 95vw; + height: 90vh; +} + +.trade-modal .game-modal-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.trade-container { + display: flex; + flex-direction: column; + height: 100%; + gap: 15px; + overflow: hidden; +} + +.trade-content { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 15px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.trade-column { + background: rgba(0, 0, 0, 0.4); + border: 1px solid #444; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + +.column-header { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + color: #ffd700; + text-align: center; + border-bottom: 1px solid #444; + padding-bottom: 0.5rem; + flex-shrink: 0; +} + +.search-bar { + margin-bottom: 0.5rem; + padding: 0.75rem 1rem; + background: rgba(0, 0, 0, 0.5); + border: 1px solid #555; + color: white; + width: 100%; + box-sizing: border-box; + /* Fixes cut-off issue */ + clip-path: var(--game-clip-path-sm, polygon(0 0, + 100% 0, + 100% calc(100% - 5px), + calc(100% - 5px) 100%, + 0 100%)); +} + +.inventory-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); + grid-auto-rows: max-content; + /* Ensure rows don't stretch */ + gap: 0.5rem; + overflow-y: auto; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.75rem; + padding: 0.5rem; + overflow-y: auto; +} + +.trade-item-card { + position: relative; + aspect-ratio: 1; + background: var(--game-bg-card); + border: 1px solid var(--game-border-color); + clip-path: var(--game-clip-path); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + box-shadow: var(--game-shadow-sm); +} + +.trade-item-card:hover { + border-color: #63b3ed; + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); + z-index: 10; +} + +.trade-item-card.text-tier-0 { + border-color: #a0aec0; +} + +.trade-item-card.text-tier-1 { + border-color: #ffffff; +} + +.trade-item-card.text-tier-2 { + border-color: #68d391; +} + +.trade-item-card.text-tier-3 { + border-color: #63b3ed; +} + +.trade-item-card.text-tier-4 { + border-color: #9f7aea; +} + +.trade-item-card.text-tier-5 { + border-color: #ed8936; +} + +.trade-item-image { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + /* Margins inside container */ +} + +.trade-item-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); +} + +/* Exact match for quantity badge from InventoryModal.css */ +.trade-item-qty { + position: absolute; + bottom: 2px; + right: 2px; + background: var(--game-bg-panel); + /* Match source */ + border: 1px solid var(--game-border-color); + /* Match source */ + color: var(--game-text-primary); + /* Match source */ + font-size: 0.7rem; + /* Match source grid adjustment */ + padding: 1px 4px; + /* Match source grid adjustment */ + clip-path: var(--game-clip-path-sm); + /* Match source */ + font-weight: bold; + box-shadow: var(--game-shadow-sm); + /* Match source */ + pointer-events: none; +} + +.trade-item-value { + position: absolute; + bottom: 2px; + left: 2px; + background: rgba(0, 0, 0, 0.7); + /* Keep visible background for value */ + color: #ffd700; + font-size: 0.7rem; + padding: 1px 4px; + clip-path: var(--game-clip-path-sm); + font-weight: bold; + pointer-events: none; +} + +/* Cart Grid - Slightly different or same? User checked inventory grid, so same is safe for source lists. + Cart lists might need to remain distinct or use same style. + Currently they use same .trade-item-card class, so they will inherit this. */ +.cart-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + /* Smaller for cart? */ + gap: 0.5rem; + padding: 0.5rem; + overflow-y: auto; + max-height: 200px; +} + +.trade-center-column { + display: flex; + flex-direction: column; + gap: 15px; + overflow: hidden; +} + +.trade-cart-section { + flex: 1; + background: rgba(0, 0, 0, 0.4); + border: 1px solid #444; + padding: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cart-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + grid-auto-rows: max-content; + gap: 8px; + overflow-y: auto; + padding-right: 5px; + margin-top: 10px; + align-content: start; +} + +.trade-list-header { + font-size: 1rem; + font-weight: bold; + margin-bottom: 0.5rem; + color: #ddd; + display: flex; + justify-content: space-between; +} + +.cart-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-right: 0.25rem; +} + +.cart-item { + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(0, 0, 0, 0.4); + padding: 0.25rem 0.5rem; + font-size: 0.9rem; + cursor: pointer; +} + +.cart-item:hover { + background: rgba(255, 0, 0, 0.2); +} + +.trade-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid #444; +} + +.trade-summary { + display: flex; + flex-direction: column; + align-items: center; + font-size: 1.1rem; +} + +.trade-total { + font-weight: bold; + color: #ffd700; +} + +.trade-action-btn { + padding: 0.75rem 2rem; + font-size: 1.2rem; + font-weight: bold; + text-transform: uppercase; + background: linear-gradient(to bottom, #4caf50, #2e7d32); + border: none; + color: white; + cursor: pointer; + clip-path: var(--game-clip-path); +} + +.trade-action-btn:disabled { + background: #555; + cursor: not-allowed; + opacity: 0.7; +} + +.quantity-modal { + background: #2d3748; + padding: 1rem; + border: 1px solid #4a5568; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + clip-path: var(--game-clip-path); +} + +.qty-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.qty-input { + width: 60px; + text-align: center; + padding: 0.25rem; + background: #1a202c; + border: 1px solid #4a5568; + color: white; +} \ No newline at end of file diff --git a/pwa/src/components/game/TradeModal.tsx b/pwa/src/components/game/TradeModal.tsx new file mode 100644 index 0000000..dec53e9 --- /dev/null +++ b/pwa/src/components/game/TradeModal.tsx @@ -0,0 +1,505 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useGame } from '../../contexts/GameContext'; +import { GAME_API_URL } from '../../config'; +import { GameModal } from './GameModal'; +import { GameButton } from '../common/GameButton'; +import { GameTooltip } from '../common/GameTooltip'; +import { getAssetPath } from '../../utils/assetPath'; +import { getTranslatedText } from '../../utils/i18nUtils'; +import './TradeModal.css'; + +interface TradeItem { + item_id: string; + name: string; // This might be a translatable object or string + emoji?: string; + quantity: number; + value: number; + unique_item_id?: number; + is_infinite?: boolean; + description?: string; + item_type?: string; + stats?: any; + unique_stats?: any; + image_path?: string; + tier?: number; + effects?: any; + weight?: number; + volume?: number; + hp_restore?: number; + stamina_restore?: number; + equippable?: boolean; + consumable?: boolean; + slot?: string; + is_equipped?: boolean; +} + +interface Selection { + item_id: string; + quantity: number; + value: number; + unique_item_id?: number; + name: string; + emoji?: string; + image_path?: string; + tier?: number; +} + +interface TradeModalProps { + npcId: string; + onClose: () => void; +} + +export const TradeModal: React.FC = ({ npcId, onClose }) => { + const { token, inventory: playerInv } = useGame(); + + const [npcStock, setNpcStock] = useState([]); + const [playerItems, setPlayerItems] = useState([]); + + const [buying, setBuying] = useState([]); // Items selected from NPC + const [selling, setSelling] = useState([]); // Items selected from Player + + const [tradeConfig, setTradeConfig] = useState({}); + + // Filters + const [npcSearch, setNpcSearch] = useState(''); + const [playerSearch, setPlayerSearch] = useState(''); + + // Selection logic + const [selectedItem, setSelectedItem] = useState(null); + const [showQtyModal, setShowQtyModal] = useState(false); + const [qtyInput, setQtyInput] = useState(1); + const [selectionSource, setSelectionSource] = useState<'npc' | 'player'>('npc'); + + useEffect(() => { + // Determine player items from context inventory + if (!playerInv) return; + const mappedPlayerItems = playerInv.map((i: any) => ({ + ...i, + // Backend now sends flat structure, but we keep falbacks just in case + value: i.value || i.item_def?.value || 10, + name: i.name || (i.item_def ? i.item_def.name : i.item_id), + emoji: i.emoji || i.item_def?.emoji || '📦', + description: i.description || (i.item_def ? i.item_def.description : ''), + item_type: i.type || i.item_type || i.item_def?.item_type, // 'type' from backend, 'item_type' variable + stats: i.stats || i.item_def?.stats, + unique_stats: i.unique_stats, + image_path: i.image_path || i.item_def?.image_path, + tier: i.tier || i.item_def?.tier, + effects: i.effects || i.item_def?.effects, + weight: i.weight || i.item_def?.weight || 0, + volume: i.volume || i.item_def?.volume || 0, + hp_restore: i.hp_restore || i.item_def?.hp_restore, + stamina_restore: i.stamina_restore || i.item_def?.stamina_restore, + equippable: i.equippable || i.item_def?.equippable, + consumable: i.consumable || i.item_def?.consumable + })); + setPlayerItems(mappedPlayerItems); + }, [playerInv]); + + useEffect(() => { + const fetchStock = async () => { + try { + const res = await fetch(`${GAME_API_URL}/trade/${npcId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await res.json(); + + // Map NPC stock similarly + // Note: The backend returns item details mixed in usually, but let's verify if we need to map via item_def logic on frontend or if backend sends it all. + // Looking at trade.py, it sends 'name', 'emoji', 'quantity', 'value', 'unique_item_id'. + // If we want stats, we might need more data from backend or map it if we have a global items list. + // Currently only name/emoji/value are strictly returned. + // Ideally backend should send full item_def or we need to access ItemsManager context if available. + // For now, we work with what we have, or assume backend trade endpoint includes simplified data. + // Wait, trade.py returns: "name": item_def.name, "emoji": item_def.emoji... it doesn't return generic stats. + // We'll stick to what we have but if backend is updated we'd use it. + // *Crucial*: To show stats, we'd need them in the API response. I will assume for now we might miss detailed stats for NPC items unless I update backend. + // BUT, for Player items we have full access via context. + + setNpcStock(data.stock); + setTradeConfig(data.config); + } catch (e) { + console.error(e); + } + }; + if (token) fetchStock(); + }, [npcId, token]); + + // Computed Lists (Virtual Inventory with Subtraction) + const availableNpcStock = useMemo(() => { + return npcStock.map(item => { + // Find how many are currently in 'buying' list + const inCart = buying.find(b => b.item_id === item.item_id && b.unique_item_id === item.unique_item_id); + const qtyInCart = inCart ? inCart.quantity : 0; + const remaining = item.is_infinite ? 999 : Math.max(0, item.quantity - qtyInCart); + + return { ...item, _displayQuantity: remaining }; + }).filter(item => { + // Filter by search + const n = getTranslatedText(item.name).toLowerCase(); + return n.includes(npcSearch.toLowerCase()); + }); + }, [npcStock, npcSearch, buying]); + + const availablePlayerInv = useMemo(() => { + return playerItems.map(item => { + // Find how many are currently in 'selling' list + const inCart = selling.find(s => s.item_id === item.item_id && s.unique_item_id === item.unique_item_id); + const qtyInCart = inCart ? inCart.quantity : 0; + const remaining = Math.max(0, item.quantity - qtyInCart); + + return { ...item, _displayQuantity: remaining }; + }).filter(item => { + const n = getTranslatedText(item.name).toLowerCase(); + if (!n.includes(playerSearch.toLowerCase())) return false; + // Hide items with 0 quantity remaining? + if (item._displayQuantity <= 0) return false; + if (item.is_equipped) return false; // Usually can't sell equipped items directly + return true; + }); + }, [playerItems, playerSearch, selling]); + + // Calculations + const buyTotal = buying.reduce((sum, item) => sum + (item.value * (tradeConfig.buy_markup || 1) * item.quantity), 0); + const sellTotal = selling.reduce((sum, item) => sum + (item.value * (tradeConfig.sell_markdown || 1) * item.quantity), 0); + + // Validity checking + const isValid = sellTotal >= buyTotal && (buying.length > 0 || selling.length > 0); + + const handleItemClick = (item: TradeItem, source: 'npc' | 'player') => { + // Use the displayed quantity which already accounts for cart + // But we need the *original* item to check is_infinite etc. + // Actually, we can just use the mapped item's _displayQuantity as the max available to add *more*. + + const maxAvailable = (item as any)._displayQuantity; + + if (maxAvailable <= 0) return; + + setSelectedItem(item); + setSelectionSource(source); + setQtyInput(1); + setShowQtyModal(true); + }; + + const confirmSelection = () => { + if (!selectedItem) return; + + const list = selectionSource === 'npc' ? buying : selling; + const setList = selectionSource === 'npc' ? setBuying : setSelling; + + // Max available to add is displayed quantity + const maxAvailable = (selectedItem as any)._displayQuantity; + + let finalQty = qtyInput; + if (finalQty > maxAvailable) finalQty = maxAvailable; + + if (finalQty <= 0) { + setShowQtyModal(false); + setSelectedItem(null); + return; + } + + const existingIdx = list.findIndex(i => i.item_id === selectedItem.item_id && i.unique_item_id === selectedItem.unique_item_id); + + if (existingIdx >= 0) { + // Update quantity + const newList = [...list]; + newList[existingIdx].quantity += finalQty; + setList(newList); + } else { + // Add new + setList([...list, { + item_id: selectedItem.item_id, + quantity: finalQty, + value: selectedItem.value, + unique_item_id: selectedItem.unique_item_id, + name: selectedItem.name, + emoji: selectedItem.emoji, + image_path: selectedItem.image_path, + tier: selectedItem.tier + }]); + } + + setShowQtyModal(false); + setSelectedItem(null); + }; + + const executeTrade = async () => { + try { + const res = await fetch(`${GAME_API_URL}/trade/${npcId}/execute`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + buying: buying, + selling: selling + }) + }); + + const result = await res.json(); + if (res.ok) { + alert("Trade Successful!"); + onClose(); + // Should trigger inventory refresh + window.location.reload(); + } else { + alert("Trade Failed: " + result.detail); + } + } catch (e) { + console.error(e); + alert("Trade Error"); + } + }; + + // Tooltip Renderer (Reusable) - REMOVED as we use inline now to match InventoryModal structure better + + + if (!npcStock || !tradeConfig) return
    Loading trade data...
    ; + + return ( + +
    +
    + {/* LEFT: NPC STOCK */} +
    +

    Merchant Stock {tradeConfig.buy_markup && (x{tradeConfig.buy_markup})}

    + setNpcSearch(e.target.value)} + /> +
    + {availableNpcStock.map((item, idx) => { + // Prepare tooltip content matching InventoryModal + const tooltipContent = ( +
    +
    + {item.emoji} {getTranslatedText(item.name)} +
    + {item.description &&
    {getTranslatedText(item.description)}
    } + +
    +
    💰 {Math.round(item.value * (tradeConfig.buy_markup || 1))}
    + {item.weight !== undefined &&
    ⚖️ {item.weight}kg
    } + {item.volume !== undefined &&
    📦 {item.volume}L
    } +
    + +
    + {/* Capacity */} + {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && ( + + ⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg + + )} + {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( + + 📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L + + )} + {/* Combat Stats */} + {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( + + ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} + + )} + {(item.unique_stats?.armor || item.stats?.armor) && ( + + 🛡️ +{item.unique_stats?.armor || item.stats?.armor} + + )} + {/* Consumables */} + {item.hp_restore && ( + + ❤️ +{item.hp_restore} HP + + )} + {item.stamina_restore && ( + + ⚡ +{item.stamina_restore} Stm + + )} +
    +
    + ); + + return ( + +
    handleItemClick(item, 'npc')}> +
    + {item.image_path ? ( + {getTranslatedText(item.name)} + ) : ( +
    {item.emoji || '📦'}
    + )} +
    + + {(item.is_infinite || (item as any)._displayQuantity > 1) && ( +
    {item.is_infinite ? '∞' : `x${(item as any)._displayQuantity}`}
    + )} +
    {Math.round(item.value * (tradeConfig.buy_markup || 1))}
    +
    +
    + ); + })} +
    +
    + + {/* CENTER: CART */} +
    +
    +
    + Buying + {Math.round(buyTotal)} +
    +
    + {buying.length === 0 &&
    Empty
    } + {buying.map((b, i) => ( + {getTranslatedText(b.name)}
    x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}
    }> +
    { + const n = [...buying]; n.splice(i, 1); setBuying(n); + }}> + {b.image_path ? ( + {getTranslatedText(b.name)} + ) : ( +
    {b.emoji || '📦'}
    + )} +
    x{b.quantity}
    +
    {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}
    +
    + + ))} +
    +
    + +
    +
    + Selling + {Math.round(sellTotal)} +
    +
    + {selling.length === 0 &&
    Empty
    } + {selling.map((b, i) => ( + {getTranslatedText(b.name)}
    x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}
    }> +
    { + const n = [...selling]; n.splice(i, 1); setSelling(n); + }}> + {b.image_path ? ( + {getTranslatedText(b.name)} + ) : ( +
    {b.emoji || '📦'}
    + )} +
    x{b.quantity}
    +
    {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}
    +
    + + ))} +
    +
    +
    + + {/* RIGHT: PLAYER INVENTORY */} +
    +

    Inventory {tradeConfig.sell_markdown && (x{tradeConfig.sell_markdown})}

    + setPlayerSearch(e.target.value)} + /> +
    + {availablePlayerInv.map((item, idx) => { + const tooltipContent = ( +
    +
    + {item.emoji} {getTranslatedText(item.name)} +
    + {item.description &&
    {getTranslatedText(item.description)}
    } + +
    +
    💰 {Math.round(item.value * (tradeConfig.sell_markdown || 1))}
    +
    +
    + {/* Same badges logic could be extracted but duplicating for speed/safety */} + {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( + + ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} + + )} + {item.hp_restore && ❤️ +{item.hp_restore} HP} +
    +
    + ); + return ( + +
    handleItemClick(item, 'player')}> +
    + {item.image_path ? ( + {getTranslatedText(item.name)} + ) : ( +
    {item.emoji || '📦'}
    + )} +
    + {(item as any)._displayQuantity > 1 &&
    x{(item as any)._displayQuantity}
    } +
    {Math.round(item.value * (tradeConfig.sell_markdown || 1))}
    +
    +
    + ); + })} +
    +
    +
    + +
    +
    + Balance + = buyTotal ? 'text-green' : 'text-red'}`}> + {Math.round(sellTotal - buyTotal)} + +
    + + + +
    {/* Spacer */} +
    + + {showQtyModal && selectedItem && ( +
    +
    +

    How many {getTranslatedText(selectedItem.name)}?

    +
    + setQtyInput(Math.max(1, qtyInput - 1))}>- + setQtyInput(parseInt(e.target.value) || 1)} + min="1" + /> + setQtyInput(qtyInput + 1)}>+ + { + const max = (selectedItem as any)._displayQuantity || 1; + setQtyInput(max); + }}>Max +
    +
    + Confirm + setShowQtyModal(false)}>Cancel +
    +
    +
    + )} +
    + + ); +}; diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts index 12aca9d..dd726c0 100644 --- a/pwa/src/components/game/hooks/useGameEngine.ts +++ b/pwa/src/components/game/hooks/useGameEngine.ts @@ -22,6 +22,7 @@ export interface GameEngineState { profile: Profile | null loading: boolean message: string + quests: { active: any[], available: any[] } // Combat state combatState: CombatState | null @@ -140,6 +141,10 @@ export interface GameEngineActions { addNPCToLocation: (npc: any) => void removeNPCFromLocation: (enemyId: string) => void updateStatusEffect: (effectName: string | any, remainingTicks: number) => void + + // Quests + updateQuests: (active: any[], available: any[]) => void + handleQuestUpdate: (quest: any) => void } export function useGameEngine( @@ -164,6 +169,7 @@ export function useGameEngine( const [corpseDetails, setCorpseDetails] = useState(null) const [movementCooldown, setMovementCooldown] = useState(0) const [failedActionItemId, setFailedActionItemId] = useState(null) + const [quests, setQuests] = useState<{ active: any[], available: any[] }>({ active: [], available: [] }) // const [enemyTurnMessage, setEnemyTurnMessage] = useState('') // Moved to Combat.tsx const [equipment, setEquipment] = useState({}) @@ -265,15 +271,24 @@ export function useGameEngine( const fetchGameData = useCallback(async (skipCombatLogInit: boolean = false) => { try { - const [stateRes, locationRes, profileRes, combatRes, pvpRes] = await Promise.all([ + const [stateRes, locationRes, profileRes, combatRes, pvpRes, activeQuestsRes, availableQuestsRes] = await Promise.all([ api.get('/api/game/state'), api.get('/api/game/location'), api.get('/api/game/profile'), api.get('/api/game/combat'), - api.get('/api/game/pvp/status') + api.get('/api/game/pvp/status'), + api.get('/api/quests/active'), + api.get('/api/quests/available') ]) const gameState = stateRes.data + + // Update quests + setQuests({ + active: activeQuestsRes.data || [], + available: availableQuestsRes.data || [] + }) + setPlayerState({ location_id: gameState.player.location_id, location_name: gameState.location?.name || 'Unknown', @@ -508,6 +523,46 @@ export function useGameEngine( }) }, []) + const updateQuests = useCallback((active: any[], available: any[]) => { + setQuests({ active, available }) + }, []) + + const handleQuestUpdate = useCallback((quest: any) => { + setQuests(prev => { + // 1. Update active quests list + let newActive = [...prev.active] + const idx = newActive.findIndex(q => q.quest_id === quest.quest_id) + + // If quest is active or completed, it should be in the active list + if (quest.status === 'active' || quest.status === 'completed' || quest.status === 'can_turn_in') { + if (idx >= 0) { + // Update existing + newActive[idx] = { ...newActive[idx], ...quest } + } else { + // Add new + newActive.push(quest) + } + } else { + // If failed or cancelled, maybe keep it or update status? + if (idx >= 0) newActive[idx] = { ...newActive[idx], ...quest } + } + + // 2. Remove from available list if it was there (since it's now active/completed) + // Only if status is active/completed. If it became available, we'd need logic for that. + let newAvailable = prev.available + if (quest.status === 'active' || quest.status === 'completed') { + newAvailable = prev.available.filter(q => q.quest_id !== quest.quest_id) + } else if (quest.status === 'available') { + // It became available (e.g. repeatable cooldown finished?) + if (!newAvailable.find(q => q.quest_id === quest.quest_id)) { + newAvailable = [...newAvailable, quest] + } + } + + return { active: newActive, available: newAvailable } + }) + }, []) + // State object const state: GameEngineState = { playerState, @@ -515,6 +570,7 @@ export function useGameEngine( profile, loading, message, + quests, combatState, combatLog, enemyName, @@ -778,6 +834,11 @@ export function useGameEngine( } const response = await api.post('/api/game/combat/action', payload) + + if (response.data.quest_updates) { + response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q)) + } + return response.data } catch (error: any) { setMessage(error.response?.data?.detail || 'Combat action failed') @@ -1151,7 +1212,9 @@ export function useGameEngine( return newSet }) }, - updateStatusEffect + updateStatusEffect, + updateQuests, + handleQuestUpdate } // Polling fallback for PvP Combat reliability diff --git a/pwa/src/config.ts b/pwa/src/config.ts new file mode 100644 index 0000000..d8d142d --- /dev/null +++ b/pwa/src/config.ts @@ -0,0 +1,7 @@ +export const API_URL = import.meta.env.VITE_API_URL || ( + import.meta.env.PROD + ? 'https://api-staging.echoesoftheash.com' + : 'http://localhost:8000' +); + +export const GAME_API_URL = `${API_URL}/api`; diff --git a/pwa/src/contexts/GameContext.tsx b/pwa/src/contexts/GameContext.tsx new file mode 100644 index 0000000..76ff8d9 --- /dev/null +++ b/pwa/src/contexts/GameContext.tsx @@ -0,0 +1,26 @@ +import React, { createContext, useContext } from 'react'; + +interface GameContextType { + token: string | null; + locale: string; + inventory: any[]; + state: any; + actions: any; +} + +const GameContext = createContext(undefined); + +export const GameProvider: React.FC<{ + children: React.ReactNode; + value: GameContextType; +}> = ({ children, value }) => { + return {children}; +}; + +export const useGame = () => { + const context = useContext(GameContext); + if (context === undefined) { + throw new Error('useGame must be used within a GameProvider'); + } + return context; +}; diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index 991d8cc..070f168 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -22,6 +22,7 @@ "qty": "Qty", "enemy": "Enemy", "you": "You", + "quests": "Quests", "all": "All" }, "auth": { diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index 0a7a34b..57e91db 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -20,6 +20,7 @@ "pickUp": "Recoger", "pickUpAll": "Recoger Todo", "qty": "Cant", + "quests": "Misiones", "all": "Todo" }, "auth": {