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 ( -
{currentText}
+