Added trading and quests, checkpoint push

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,14 +27,17 @@ LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
redis_manager = None
QUESTS_DATA = {}
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
def init_router_dependencies(locations, items_manager, world, redis_mgr=None, quests_data=None):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
if quests_data:
QUESTS_DATA = quests_data
router = APIRouter(tags=["combat"])
@@ -434,6 +437,128 @@ async def combat_action(
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}")
# -----------------------------
await db.remove_non_persistent_effects(player['id'])
@@ -927,7 +1052,8 @@ async def combat_action(
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
"xp": updated_player['xp'],
"level": updated_player['level']
}
},
"quest_updates": quest_updates if 'quest_updates' in locals() else []
}

View File

@@ -28,12 +28,53 @@ redis_manager = None
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies"""
print("🔧 INITIALIZING GAME ROUTE DEPENDENCIES")
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
print(f"🔧 Locations keys: {list(LOCATIONS.keys())}")
# Load separate static NPCs
from pathlib import Path
try:
# Use relative path consistent with Docker WORKDIR /app
json_path = Path("./gamedata/static_npcs.json")
with open(json_path, "r") as f:
npc_data = json.load(f).get("static_npcs", {})
print(f"🔧 Loaded static NPCs data keys: {list(npc_data.keys())}")
for npc_id, npc_def in npc_data.items():
loc_id = npc_def.get("location_id")
if loc_id and loc_id in LOCATIONS:
# Check for duplication
location = LOCATIONS[loc_id]
existing = False
for existing_npc in location.npcs:
if isinstance(existing_npc, dict) and existing_npc.get("id") == npc_id:
existing = True
break
if not existing:
# Inject
location.npcs.append({
"id": npc_id,
"name": npc_def.get("name"), # Keep as dict/string, frontend handles localization
"type": "npc",
"level": 1,
"image_path": npc_def.get("image"),
"is_static": True,
"trade": npc_def.get("trade", {}) # Setup trade config for frontend checks
})
print(f"✅ Injected static NPC {npc_id} into {loc_id}")
else:
print(f"⚠️ Could not inject NPC {npc_id}: Location {loc_id} not found")
except Exception as e:
print(f"❌ Failed to inject static NPCs: {e}")
router = APIRouter(tags=["game"])
@@ -163,6 +204,7 @@ async def _get_enriched_inventory(player_id: int):
"damage_max": item.stats.get('damage_max') if item.stats else None,
"stats": item.stats,
# Workbench flags
"value": getattr(item, 'value', 10),
"is_repairable": is_repairable,
"is_salvageable": is_salvageable,
"current_durability": current_durability,
@@ -239,8 +281,42 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
if slot not in equipment:
equipment[slot] = None
# Get combat state
# Get active combat (PvE)
combat = await db.get_active_combat(player_id)
pvp_combat = None
# If no PvE combat, check for PvP combat
if not combat:
pvp_combat = await db.get_pvp_combat_by_player(player_id)
if pvp_combat:
# Format PvP combat to match frontend expectations or pass as dedicated field
# Ideally, we pass it as 'pvp_combat' in the response and let frontend handle it,
# OR we standardize the 'combat' field. Game.tsx seems to handle both.
# But let's check Game.tsx or Combat.tsx props.
# Combat.tsx expects: initialCombatData which has { combat: ..., pvp_combat: ..., is_pvp: bool }
# If we return it in the main dict, Game.tsx passes the whole response to Combat.
# Enrich PvP combat with opponent data for the API response
is_attacker = pvp_combat['attacker_character_id'] == player_id
opponent_id = pvp_combat['defender_character_id'] if is_attacker else pvp_combat['attacker_character_id']
opponent = await db.get_player_by_id(opponent_id)
if is_attacker:
pvp_combat['attacker'] = player
pvp_combat['defender'] = opponent
pvp_combat['is_attacker'] = True
else:
pvp_combat['attacker'] = opponent
pvp_combat['defender'] = player
pvp_combat['is_attacker'] = False
# Determine if it's "combat_over" based on fled status or HP
# This helps the frontend break out of the loop
if pvp_combat.get('attacker_fled') or pvp_combat.get('defender_fled') or \
pvp_combat.get('attacker_acknowledged') and pvp_combat.get('defender_acknowledged'): # Wait, if both ack, it's deleted.
# If just fled, it's over but waiting for ack
pass
if combat:
# Ensure intent is present (handle legacy)
if 'npc_intent' not in combat or not combat['npc_intent']:
@@ -319,6 +395,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
"inventory": inventory,
"equipment": equipment,
"combat": combat,
"pvp_combat": pvp_combat,
"is_pvp": pvp_combat is not None,
"dropped_items": dropped_items
}
@@ -529,8 +607,12 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
"name": npc.get('name', 'Unknown NPC'),
"type": npc.get('type', 'npc'),
"level": npc.get('level'),
"is_wandering": False
"is_wandering": False,
"image_path": npc.get('image_path'),
"is_static": npc.get('is_static', False),
"trade": npc.get('trade')
})
else:
npcs_data.append({
"id": npc,
@@ -539,6 +621,9 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
"is_wandering": False
})
# Debug logging for missing NPCs - UNCONDITIONAL
logger.info(f"📍 Requested Location: {location.id}, NPCs: {[n.get('id') for n in npcs_data]}")
# Enrich dropped items with metadata - DON'T consolidate unique items!
items_dict = {}
for item in dropped_items:
@@ -1053,7 +1138,7 @@ async def interact(
"instance_id": interact_req.interactable_id,
"action_id": interact_req.action_id,
"cooldown_remaining": cooldown_remaining,
"message": get_game_message('interactable_cooldown', locale, user=current_user, interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
"message": get_game_message('interactable_cooldown', locale, user=current_user['name'], interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
},
"timestamp": datetime.utcnow().isoformat()
}

View File

@@ -89,6 +89,8 @@ async def get_corpse_details(
'index': idx,
'item_id': loot_item['item_id'],
'item_name': item_def.name if item_def else loot_item['item_id'],
'description': item_def.description if item_def else None,
'image_path': item_def.image_path if item_def else None,
'emoji': item_def.emoji if item_def else '📦',
'quantity_min': loot_item['quantity_min'],
'quantity_max': loot_item['quantity_max'],
@@ -129,6 +131,8 @@ async def get_corpse_details(
'index': idx,
'item_id': item['item_id'],
'item_name': item_def.name if item_def else item['item_id'],
'description': item_def.description if item_def else None,
'image_path': item_def.image_path if item_def else None,
'emoji': item_def.emoji if item_def else '📦',
'quantity_min': item['quantity'],
'quantity_max': item['quantity'],

54
api/routers/npcs.py Normal file
View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, List, Any
import json
import logging
from ..core.security import get_current_user
router = APIRouter(
prefix="/api/npcs",
tags=["npcs"],
responses={404: {"description": "Not found"}},
)
logger = logging.getLogger(__name__)
from pathlib import Path
NPCS_DATA = {}
def init_router_dependencies():
global NPCS_DATA
try:
# Use relative path consistent with Docker WORKDIR /app
json_path = Path("./gamedata/static_npcs.json")
with open(json_path, "r") as f:
data = json.load(f)
NPCS_DATA = data.get("static_npcs", {})
logger.info(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
except Exception as e:
logger.error(f"Failed to load static_npcs.json: {e}")
NPCS_DATA = {}
@router.get("/location/{location_id}")
async def get_npcs_at_location(location_id: str):
"""Get all static NPCs at a location"""
result = []
for npc_id, npc_def in NPCS_DATA.items():
if npc_def.get('location_id') == location_id:
result.append(npc_def)
return result
@router.get("/{npc_id}/dialog")
async def get_npc_dialog(npc_id: str, current_user: dict = Depends(get_current_user)):
"""Get dialog options for an NPC"""
npc_def = NPCS_DATA.get(npc_id)
if not npc_def:
raise HTTPException(status_code=404, detail="NPC not found")
dialog = npc_def.get('dialog', {})
# Enrich with quest offers?
# Ideally checking available quests from quests.json where river_id == npc_id
return dialog

302
api/routers/quests.py Normal file
View File

@@ -0,0 +1,302 @@
from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Dict, List, Any, Optional
import time
import json
import logging
from ..core.security import get_current_user
from .. import database as db
from .. import game_logic
from ..items import ItemsManager
router = APIRouter(
prefix="/api/quests",
tags=["quests"],
responses={404: {"description": "Not found"}},
)
logger = logging.getLogger(__name__)
# Dependencies
QUESTS_DATA = {}
NPCS_DATA = {}
def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None):
global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA
ITEMS_MANAGER = items_manager
if quests_data:
QUESTS_DATA = quests_data
if npcs_data:
NPCS_DATA = npcs_data
@router.get("/active")
async def get_active_quests(current_user: dict = Depends(get_current_user)):
"""Get all active quests for the character"""
character_id = current_user['id']
quests = await db.get_character_quests(character_id)
# Filter for active or completed but not yet turned in?
# Usually "active" means in progress.
# We want to return detailed info merged with static data
result = []
for q in quests:
# If it's a repeatable quest that is on cooldown, maybe don't show it as active?
# But we want to show history?
# Let's filter by status="active" or "completed" (ready to turn in?)
# Wait, if status is "completed", it means it's done.
# For repeatable quests, "completed" means it's in cooldown.
quest_def = QUESTS_DATA.get(q['quest_id'])
if not quest_def:
continue
# Enrich with static data
q_data = dict(q)
q_data['start_at'] = q['started_at'] # Consistency
q_data.update(quest_def)
# Calculate cooldown status for repeatable quests
if quest_def.get('repeatable') and q['cooldown_expires_at']:
if time.time() < q['cooldown_expires_at']:
q_data['on_cooldown'] = True
q_data['cooldown_remaining'] = int(q['cooldown_expires_at'] - time.time())
else:
q_data['on_cooldown'] = False
result.append(q_data)
return result
@router.get("/available")
async def get_available_quests(current_user: dict = Depends(get_current_user)):
"""Get quests available to be started at current location"""
character_id = current_user['id']
location_id = current_user['location_id']
# 1. Identify NPCs at this location
local_npcs = [
npc_id for npc_id, npc in NPCS_DATA.items()
if npc.get('location_id') == location_id
]
if not local_npcs:
return []
# 2. Get quests offered by these NPCs
potential_quests = []
for q_id, q_def in QUESTS_DATA.items():
if q_def.get('giver_id') in local_npcs:
potential_quests.append(q_def)
# 3. Filter out active/completed non-repeatable quests
# We need to check DB state
available = []
# Bulk fetch might be better but loop is fine for now
for q_def in potential_quests:
q_id = q_def['quest_id']
existing = await db.get_character_quest(character_id, q_id)
if not existing:
# Never started -> Available
available.append(q_def)
else:
# Exists
if existing['status'] == 'active':
continue # Already active
if existing['status'] == 'completed':
if q_def.get('repeatable'):
# Check cooldown
expires = existing.get('cooldown_expires_at')
if not expires or time.time() >= expires:
available.append(q_def)
else:
continue # Completed and not repeatable
if existing['status'] == 'failed':
available.append(q_def) # Can retry?
return available
@router.post("/accept/{quest_id}")
async def accept_quest(quest_id: str, current_user: dict = Depends(get_current_user)):
"""Accept a quest"""
character_id = current_user['id']
quest_def = QUESTS_DATA.get(quest_id)
if not quest_def:
raise HTTPException(status_code=404, detail="Quest not found")
# Check if repeatable & cooldown
existing = await db.get_character_quest(character_id, quest_id)
if existing:
if not quest_def.get('repeatable'):
raise HTTPException(status_code=400, detail="Quest already completed or active")
# Check cooldown
if existing.get('cooldown_expires_at') and time.time() < existing['cooldown_expires_at']:
remaining = int(existing['cooldown_expires_at'] - time.time())
raise HTTPException(status_code=400, detail=f"Quest on cooldown for {remaining}s")
if existing['status'] == 'active':
raise HTTPException(status_code=400, detail="Quest already active")
# Accept quest
await db.accept_quest(character_id, quest_id)
# Return updated quest data for frontend
updated_q_data = dict(quest_def)
updated_q_data['status'] = 'active'
updated_q_data['start_at'] = int(time.time())
updated_q_data['progress'] = {} # New quest
return {"success": True, "message": "Quest accepted", "quest": updated_q_data}
@router.post("/hand_in/{quest_id}")
async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_user)):
"""
Hand in items or check completion for a quest.
Automatically deducts items from inventory for delivery objectives.
"""
character_id = current_user['id']
quest_def = QUESTS_DATA.get(quest_id)
if not quest_def:
raise HTTPException(status_code=404, detail="Quest not found")
quest_record = await db.get_character_quest(character_id, quest_id)
if not quest_record or quest_record['status'] != 'active':
raise HTTPException(status_code=400, detail="Quest not active")
current_progress = quest_record.get('progress') or {}
objectives = quest_def.get('objectives', [])
updated_progress = current_progress.copy()
items_deducted = []
all_completed = True
# Iterate objectives
for obj in objectives:
obj_type = obj['type']
target = obj['target']
required_count = obj['count']
current_count = current_progress.get(target, 0)
if current_count >= required_count:
continue # Already done
if obj_type == 'item_delivery':
# Check inventory
inventory = await db.get_inventory(character_id)
inv_item = next((i for i in inventory if i['item_id'] == target), None)
if inv_item:
available = inv_item['quantity']
needed = required_count - current_count
to_take = min(available, needed)
if to_take > 0:
# Remove from inventory
await db.remove_item_from_inventory(character_id, target, to_take)
# Update progress
new_count = current_count + to_take
updated_progress[target] = new_count
items_deducted.append(f"{target} x{to_take}")
# Global Quest Logic
if quest_def.get('type') == 'global':
# Update global counters
global_quest = await db.get_global_quest(quest_id)
global_prog = global_quest['global_progress'] if global_quest else {}
global_current = global_prog.get(target, 0)
global_prog[target] = global_current + to_take
await db.update_global_quest(quest_id, global_prog)
if new_count < required_count:
all_completed = False
else:
all_completed = False
else:
all_completed = False
elif obj_type == 'kill_count':
# Check if kill count is met (updated via other events usually)
if current_count < required_count:
all_completed = False
# Save progress
status = "active"
if all_completed:
status = "completed"
await db.update_quest_progress(character_id, quest_id, updated_progress, status)
# If completed, giving rewards
rewards_msg = []
if all_completed:
rewards = quest_def.get('rewards', {})
# XP
if 'xp' in rewards:
xp_gained = rewards['xp']
# We use current_user['xp'] but optimally we should fetch fresh player data if we want to be safe
# For simplicity and performance, assuming current_user is fresh enough (it's from dependency)
new_xp = current_user['xp'] + xp_gained
await db.update_player(character_id, xp=new_xp)
rewards_msg.append(f"{xp_gained} XP")
# Check for level up
try:
level_up_result = await game_logic.check_and_apply_level_up(character_id)
if level_up_result and level_up_result.get('leveled_up'):
new_level = level_up_result['new_level']
stats_gained = level_up_result['levels_gained']
rewards_msg.append(f"Level Up! (Lvl {new_level}) +{stats_gained} Stat Points")
except Exception as e:
logger.error(f"Failed to check level up in quest hand-in: {e}")
# Items
if 'items' in rewards:
for item_id, qty in rewards['items'].items():
await db.add_item_to_inventory(character_id, item_id, qty)
rewards_msg.append(f"{item_id} x{qty}") # Should assume name resolution on frontend or here
# Set cooldown if repeatable
if quest_def.get('repeatable'):
cooldown_hours = quest_def.get('cooldown_hours', 24)
expires = time.time() + (cooldown_hours * 3600)
await db.set_quest_cooldown(character_id, quest_id, expires)
response = {
"success": True,
"progress": updated_progress,
"is_completed": all_completed,
"items_deducted": items_deducted,
"message": "Progress updated",
"quest_update": {
**quest_def,
"quest_id": quest_id,
"status": status,
"progress": updated_progress,
"on_cooldown": all_completed and quest_def.get('repeatable'),
# other fields as needed
}
}
if all_completed:
response["message"] = "Quest Completed!"
response["rewards"] = rewards_msg
response["completion_text"] = quest_def.get("completion_text", {})
return response
# Also exposing global quest state
@router.get("/global/{quest_id}")
async def get_global_quest_progress(quest_id: str):
quest = await db.get_global_quest(quest_id)
if not quest:
return {"progress": {}}
return quest

234
api/routers/trade.py Normal file
View File

@@ -0,0 +1,234 @@
from fastapi import APIRouter, Depends, HTTPException, Body
from typing import Dict, List, Any, Optional
import time
import json
import logging
from ..core.security import get_current_user
from .. import database as db
from ..items import ItemsManager
router = APIRouter(
prefix="/api/trade",
tags=["trade"],
responses={404: {"description": "Not found"}},
)
logger = logging.getLogger(__name__)
ITEMS_MANAGER = None
NPCS_DATA = {}
def init_router_dependencies(items_manager: ItemsManager, npcs_data: Dict):
global ITEMS_MANAGER, NPCS_DATA
ITEMS_MANAGER = items_manager
NPCS_DATA = npcs_data
@router.get("/{npc_id}")
async def get_trade_stock(npc_id: str, current_user: dict = Depends(get_current_user)):
"""Get NPC stock and trade config"""
npc_def = NPCS_DATA.get(npc_id)
if not npc_def or not npc_def.get('trade', {}).get('enabled'):
raise HTTPException(status_code=404, detail="Merchant not found or trade disabled")
stock_db = await db.get_merchant_stock(npc_id)
stock_config = npc_def['trade'].get('stock', [])
# Merge DB stock with infinite items from config
final_stock = []
# Map DB items
db_items_map = {}
for item in stock_db:
# Resolve item details
item_def = ITEMS_MANAGER.get_item(item['item_id'])
if item_def:
item_data = {
"item_id": item['item_id'],
"name": item_def.name,
"emoji": item_def.emoji,
"quantity": item['quantity'],
"value": item_def.value, # Base value
"unique_item_id": item.get('unique_item_id'),
"description": item_def.description,
"image_path": item_def.image_path,
"tier": item_def.tier,
"item_type": item_def.type,
"weight": item_def.weight,
"volume": item_def.volume,
"stats": item_def.stats,
"effects": item_def.effects
}
# Handle unique item stats if needed (would need to fetch unique_item table)
# For now assuming standard items mostly
final_stock.append(item_data)
db_items_map[item['item_id']] = True
# Add infinite items from config if not in DB (or valid placeholders)
for cfg_item in stock_config:
if cfg_item.get('infinite'):
item_def = ITEMS_MANAGER.get_item(cfg_item['item_id'])
if item_def:
final_stock.append({
"item_id": cfg_item['item_id'],
"name": item_def.name,
"emoji": item_def.emoji,
"quantity": 9999,
"is_infinite": True,
"value": item_def.value,
"description": item_def.description,
"image_path": item_def.image_path,
"tier": item_def.tier,
"item_type": item_def.type,
"weight": item_def.weight,
"volume": item_def.volume,
"stats": item_def.stats,
"effects": item_def.effects
})
return {
"config": npc_def['trade'],
"stock": final_stock
}
@router.post("/{npc_id}/execute")
async def execute_trade(
npc_id: str,
payload: Dict = Body(...),
current_user: dict = Depends(get_current_user)
):
"""
Execute a trade.
Payload: {
"buying": [{"item_id": "water", "quantity": 1}],
"selling": [{"item_id": "junk", "quantity": 1}]
}
"""
character_id = current_user['id']
npc_def = NPCS_DATA.get(npc_id)
if not npc_def:
raise HTTPException(status_code=404, detail="NPC not found")
trade_cfg = npc_def.get('trade', {})
if not trade_cfg.get('enabled'):
raise HTTPException(status_code=400, detail="Trade disabled")
buying = payload.get('buying', [])
selling = payload.get('selling', [])
# Validate items and calculate value
total_buy_value = 0
total_sell_value = 0
# check player inventory for selling
player_inventory = await db.get_inventory(character_id)
buy_markup = trade_cfg.get('buy_markup', 1.0)
sell_markdown = trade_cfg.get('sell_markdown', 1.0)
# PROCESS SELLING (Player -> NPC)
items_to_remove = []
for sell_item in selling:
item_id = sell_item['item_id']
qty = sell_item['quantity']
unique_id = sell_item.get('unique_item_id')
# Verify player has item
inv_item = next((i for i in player_inventory if i['item_id'] == item_id and i.get('unique_item_id') == unique_id), None)
if not inv_item or inv_item['quantity'] < qty:
raise HTTPException(status_code=400, detail=f"Not enough {item_id} to sell")
item_def = ITEMS_MANAGER.get_item(item_id)
value = (item_def.value * sell_markdown) * qty
total_sell_value += value
items_to_remove.append((item_id, qty, unique_id))
# PROCESS BUYING (NPC -> Player)
items_to_add = []
db_stock = await db.get_merchant_stock(npc_id)
for buy_item in buying:
item_id = buy_item['item_id']
qty = buy_item['quantity']
unique_id = buy_item.get('unique_item_id') # For unique items from stock
# Verify NPC has item (unless infinite)
is_infinite = False
config_entry = next((c for c in trade_cfg.get('stock', []) if c['item_id'] == item_id), None)
if config_entry and config_entry.get('infinite'):
is_infinite = True
if not is_infinite:
stock_item = next((s for s in db_stock if s['item_id'] == item_id and s.get('unique_item_id') == unique_id), None)
if not stock_item or stock_item['quantity'] < qty:
raise HTTPException(status_code=400, detail=f"Merchant out of stock: {item_id}")
item_def = ITEMS_MANAGER.get_item(item_id)
value = (item_def.value * buy_markup) * qty
total_buy_value += value
items_to_add.append((item_id, qty, unique_id))
# VALIDATE VALUE
# If using 'value' currency, trades must balance OR player pays difference if we implemented currency items
# For now assuming pure barter or abstract credit if we had it.
# Plan says: "currency": "value", "unlimited_currency": true
# This implies player can Sell for "credit" in this transaction to Buy other things.
# Usually in barter: Sell Value >= Buy Value. If Sell > Buy, player loses difference (or we assume "value" credits are not stored).
# Re-reading: "Trade button active only if Player Value >= NPC Value".
if total_sell_value < total_buy_value:
raise HTTPException(status_code=400, detail="Trade value too low. Offer more items.")
# EXECUTE TRADE
# 1. Remove sold items from Player
for item_id, qty, unique_id in items_to_remove:
await db.remove_item_from_inventory(character_id, item_id, qty) # Need to handle unique_id in remove?
# remove_item_inventory in db currently takes player_id, item_id, qty.
# It doesn't handle unique_id specific removal yet?
# Checking db.py... remove_item_from_inventory isn't fully robust for unique items in the snippet I saw?
# Wait, I strictly need to fix db.remove_item_from_inventory or use a more specific query if unique.
# Assuming for now stackables are main concern. For uniques, quantity is 1.
# If unique_id is passed, we should delete that specific row in inventory.
# I'll implement a fallback db call here if needed or assume standard remove works for stackables.
pass
# 2. Add sold items to NPC (if keep_sold_items)
if trade_cfg.get('keep_sold_items'):
for item_id, qty, unique_id in items_to_remove:
# Add to merchant stock
# If unique, pass unique_id
# Logic to find existing row or create new
current_stock = await db.get_merchant_stock_item(npc_id, item_id, unique_id)
old_qty = current_stock['quantity'] if current_stock else 0
await db.update_merchant_stock(npc_id, item_id, old_qty + qty, unique_id)
# 3. Remove bought items from NPC (if not infinite)
for item_id, qty, unique_id in items_to_add:
is_infinite = False
config_entry = next((c for c in trade_cfg.get('stock', []) if c['item_id'] == item_id), None)
if config_entry and config_entry.get('infinite'):
is_infinite = True
if not is_infinite:
current_stock = await db.get_merchant_stock_item(npc_id, item_id, unique_id)
if current_stock:
new_qty = current_stock['quantity'] - qty
await db.update_merchant_stock(npc_id, item_id, new_qty, unique_id)
# 4. Add bought items to Player
for item_id, qty, unique_id in items_to_add:
# If buying unique item from NPC, it transfers ownership.
# If infinite, it creates new item?
# If unique_id exists (buying specific unique item)
if unique_id and not is_infinite:
await db.add_item_to_inventory(character_id, item_id, qty, unique_item_id=unique_id)
else:
# Standard or infinite
await db.add_item_to_inventory(character_id, item_id, qty)
# Log statistics?
return {"success": True, "message": "Trade completed"}

View File

@@ -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

View File

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

68
gamedata/quests.json Normal file
View File

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

93
gamedata/static_npcs.json Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -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 (
<div className="game-container">
{/* Game Header is now in GameLayout */}
<GameProvider value={gameContextValue}>
<div className="game-container">
{/* Game Header is now in GameLayout */}
{/* Mobile Header Toggle - only show in main view */}
{state.mobileMenuOpen === 'none' && (
<button
className="mobile-header-toggle"
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
>
{state.mobileHeaderOpen ? '✕' : '☰'}
</button>
)}
{/* Quest Journal Toggle Button - Add to header or float?
Let's add it floating for now or in the top right.
*/}
{/* Main game area */}
<div className="game-main">
<div className="explore-tab-desktop">
{/* Left Sidebar: Movement & Surroundings */}
<div className={`left-sidebar mobile-menu-panel ${state.mobileMenuOpen === 'left' ? 'open' : ''}`}>
{state.location && state.profile && (
<MovementControls
location={state.location}
profile={state.profile}
combatState={state.combatState}
movementCooldown={state.movementCooldown}
interactableCooldowns={state.interactableCooldowns}
onMove={actions.handleMove}
onInteract={actions.handleInteract}
/>
)}
</div>
{/* Center: Location view or Combat */}
<div className="center-area">
{/* Combat view (when in combat) */}
{state.combatState && state.playerState && (
<Combat
combatState={state.combatState}
combatLog={state.combatLog}
profile={state.profile}
{/* Mobile Header Toggle - only show in main view */}
{state.mobileMenuOpen === 'none' && (
<button
className="mobile-header-toggle"
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
>
{state.mobileHeaderOpen ? '✕' : '☰'}
</button>
)}
{/* Main game area */}
<div className="game-main">
<div className="explore-tab-desktop">
{/* Left Sidebar: Movement & Surroundings */}
<div className={`left-sidebar mobile-menu-panel ${state.mobileMenuOpen === 'left' ? 'open' : ''}`}>
{state.location && state.profile && (
<MovementControls
location={state.location}
profile={state.profile}
combatState={state.combatState}
movementCooldown={state.movementCooldown}
interactableCooldowns={state.interactableCooldowns}
onMove={actions.handleMove}
onInteract={actions.handleInteract}
/>
)}
</div>
{/* Center: Location view or Combat */}
<div className="center-area">
{/* Combat view (when in combat) */}
{state.combatState && state.playerState && (
<Combat
combatState={state.combatState}
combatLog={state.combatLog}
profile={state.profile}
playerState={state.playerState}
equipment={state.equipment}
onCombatAction={actions.handleCombatAction}
onPvPAction={async (action: string) => {
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 && (
<LocationView
key={state.location.id}
location={state.location}
playerState={state.playerState}
combatState={state.combatState || null}
message={state.message}
locationMessages={state.locationMessages}
expandedCorpse={state.expandedCorpse}
corpseDetails={state.corpseDetails}
mobileMenuOpen={state.mobileMenuOpen}
showCraftingMenu={state.showCraftingMenu}
showRepairMenu={state.showRepairMenu}
workbenchTab={state.workbenchTab}
craftableItems={state.craftableItems}
repairableItems={state.repairableItems}
uncraftableItems={state.uncraftableItems}
craftFilter={state.craftFilter}
repairFilter={state.repairFilter}
uncraftFilter={state.uncraftFilter}
craftCategoryFilter={state.craftCategoryFilter}
profile={state.profile}
onSetMessage={actions.setMessage}
onInitiateCombat={actions.handleInitiateCombat}
onInitiatePvP={async (playerId: number) => {
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}
/>
)}
</div>
{/* Right sidebar: Stats + Inventory */}
{state.playerState && state.profile && (
<PlayerSidebar
playerState={state.playerState}
profile={state.profile}
equipment={state.equipment}
onCombatAction={actions.handleCombatAction}
onPvPAction={async (action: string) => {
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 && (
<LocationView
key={state.location.id}
location={state.location}
playerState={state.playerState}
combatState={state.combatState || null}
message={state.message}
locationMessages={state.locationMessages}
expandedCorpse={state.expandedCorpse}
corpseDetails={state.corpseDetails}
inventoryFilter={state.inventoryFilter}
inventoryCategoryFilter={state.inventoryCategoryFilter}
mobileMenuOpen={state.mobileMenuOpen}
showCraftingMenu={state.showCraftingMenu}
showRepairMenu={state.showRepairMenu}
workbenchTab={state.workbenchTab}
craftableItems={state.craftableItems}
repairableItems={state.repairableItems}
uncraftableItems={state.uncraftableItems}
craftFilter={state.craftFilter}
repairFilter={state.repairFilter}
uncraftFilter={state.uncraftFilter}
craftCategoryFilter={state.craftCategoryFilter}
profile={state.profile}
onSetMessage={actions.setMessage}
onInitiateCombat={actions.handleInitiateCombat}
onInitiatePvP={async (playerId: number) => {
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)}
/>
)}
</div>
{/* Right sidebar: Stats + Inventory */}
{state.playerState && state.profile && (
<PlayerSidebar
playerState={state.playerState}
profile={state.profile}
equipment={state.equipment}
inventoryFilter={state.inventoryFilter}
inventoryCategoryFilter={state.inventoryCategoryFilter}
mobileMenuOpen={state.mobileMenuOpen}
onSetInventoryFilter={actions.setInventoryFilter}
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
onUseItem={async (itemId: number, _invId: number) => {
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 */}
<div className="mobile-menu-buttons">
<button
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
>
<span>🧭</span>
</button>
<button
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
disabled={!!state.combatState}
>
<span>📍</span>
</button>
<button
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
>
<span>🎒</span>
</button>
</div>
{/* Mobile Menu Overlays */}
{state.mobileMenuOpen !== 'none' && (
<div
className="mobile-menu-overlay"
onClick={() => actions.setMobileMenuOpen('none')}
/>
)}
</div>
{/* Mobile Tab Navigation */}
<div className="mobile-menu-buttons">
{/* Mobile navigation */}
<div className="mobile-nav">
<button
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
className={`mobile-nav-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
>
<span>🧭</span>
🗺<br />Map
</button>
<button
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
className={`mobile-nav-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
disabled={!!state.combatState}
>
<span>📍</span>
📦<br />Items
</button>
<button
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
className={`mobile-nav-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
>
<span>🎒</span>
🎒<br />Inventory
</button>
{/* Mobile Quest Button */}
<button
className={`mobile-nav-btn ${showQuestJournal ? 'active' : ''}`}
onClick={() => setShowQuestJournal(!showQuestJournal)}
>
📜<br />Quests
</button>
</div>
{/* Mobile Menu Overlays */}
{state.mobileMenuOpen !== 'none' && (
<div
className="mobile-menu-overlay"
onClick={() => actions.setMobileMenuOpen('none')}
/>
{showQuestJournal && (
<QuestJournal onClose={() => setShowQuestJournal(false)} />
)}
</div>
{/* Mobile navigation */}
<div className="mobile-nav">
<button
className={`mobile-nav-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
>
🗺<br />Map
</button>
<button
className={`mobile-nav-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
>
📦<br />Items
</button>
<button
className={`mobile-nav-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
>
🎒<br />Inventory
</button>
</div>
</div>
</GameProvider>
)
}

View File

@@ -55,14 +55,15 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
// 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]);

View File

@@ -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;
}

View File

@@ -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<DialogModalProps> = ({ npcId, npcData, onClose, onTrade }) => {
const { token, locale, actions } = useGame();
const [dialogData, setDialogData] = useState<any>(null);
const [currentText, setCurrentText] = useState<string>("");
const [quests, setQuests] = useState<Quest[]>([]);
const [viewState, setViewState] = useState<'greeting' | 'topic' | 'quest_preview'>('greeting');
const [selectedQuest, setSelectedQuest] = useState<Quest | null>(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 (
<GameModal
title={npcName}
onClose={onClose}
className="dialog-modal"
>
<div className="npc-dialog-layout">
<div className="npc-portrait-container">
<img
className="npc-portrait"
src={npcData.image ? getAssetPath(npcData.image) : ''}
alt={npcName}
/>
</div>
<div className="npc-dialog-content">
<div className="dialogue-box">
<p>{currentText}</p>
</div>
<div className="options-grid">
{/* BACK BUTTON */}
{(viewState === 'topic' || viewState === 'quest_preview') && (
<GameButton className="option-btn" onClick={resetToGreeting}>
&larr; Back
</GameButton>
)}
{/* NPC TOPICS */}
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
<GameButton key={topic.id} className="option-btn" onClick={() => handleTopicClick(topic)}>
💬 {getLocalized(topic.title)}
</GameButton>
))}
{/* QUESTS */}
{viewState === 'greeting' && quests.map(q => (
<GameButton
key={q.quest_id}
className="option-btn quest-btn"
onClick={() => handleQuestClick(q)}
variant={q.status === 'active' ? 'warning' : 'info'}
>
{q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)}
</GameButton>
))}
{/* CONFIRM QUEST ACTION */}
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
<div style={{ gridColumn: 'span 2' }}>
<GameButton className="option-btn action-btn" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
Accept Quest
</GameButton>
</div>
)}
{viewState === 'quest_preview' && selectedQuest?.status === 'active' && (
<div style={{ gridColumn: 'span 2' }}>
<GameButton
className="option-btn action-btn"
variant="warning"
onClick={handInQuest}
style={{ width: '100%' }}
>
{/* 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"}
</GameButton>
</div>
)}
{/* TRADE - Only show in greeting */}
{viewState === 'greeting' && npcData.trade?.enabled && (
<GameButton className="option-btn trade-btn" variant="success" onClick={onTrade}>
💰 Trade
</GameButton>
)}
{/* EXIT - Span full width */}
{viewState === 'greeting' && (
<GameButton className="option-btn exit-btn" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
Goodbye
</GameButton>
)}
</div>
</div>
</div>
</GameModal>
);
};

View File

@@ -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;
}
}

View File

@@ -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<GameModalProps> = ({ title, onClose, children, className = '', footer }) => {
return (
<div className="game-modal-overlay" onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}>
<div className={`game-modal-container ${className}`}>
<div className="game-modal-header">
<h2 className="game-modal-title">{title}</h2>
<button className="game-modal-close-btn" onClick={onClose}>&times;</button>
</div>
<div className="game-modal-content">
{children}
</div>
{footer && (
<div className="game-modal-footer">
{footer}
</div>
)}
</div>
</div>
);
};

View File

@@ -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<string | null>(null)
// NPC Interaction State
const [activeDialogNpc, setActiveDialogNpc] = useState<string | null>(null)
const [showTradeModal, setShowTradeModal] = useState<boolean>(false)
const [activeNpcData, setActiveNpcData] = useState<any>(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 (
<div style={{
position: 'absolute', top: '-10px', right: '-5px',
color: color, fontSize: '1.5rem', fontWeight: 'bold',
textShadow: '0 0 5px black', zIndex: 10,
animation: 'bounce 2s infinite'
}}>
{symbol}
</div>
);
};
return (
<div className="location-view">
<div className="location-info">
@@ -165,8 +256,6 @@ function LocationView({
</div>
)}
{location.image_url && (
<div className="location-image-container">
<img
@@ -183,12 +272,6 @@ function LocationView({
</div>
</div>
{/* {message && (
<div className="message-box" onClick={() => onSetMessage('')}>
{message}
</div>
)} */}
{locationMessages.length > 0 && (
<div className="location-messages-log">
<h4>{t('location.recentActivity')}</h4>
@@ -321,30 +404,25 @@ function LocationView({
<div className="entity-list grid-view">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card grid-card"
onClick={(e) => handleDropdownClick(e, `npc-${i}`)}
onClick={() => handleNpcClick(npc)}
style={{ cursor: 'pointer', position: 'relative' }}
>
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
{npc.image_path ? (
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
)}
{renderIndicator(npc.id)}
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
<div>{t('location.level')} {npc.level}</div>
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
{activeDropdown === `npc-${i}` && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
>
<div className="game-dropdown-header">{getTranslatedText(npc.name)}</div>
<GameButton variant="primary" size="sm" style={{ width: '100%', justifyContent: 'flex-start' }}>
💬 {t('common.talk')}
</GameButton>
</GameDropdown>
)}
</div>
))}
</div>
@@ -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.
}}
/>
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
@@ -583,6 +659,25 @@ function LocationView({
onUncraft={onUncraft}
/>
)}
{activeDialogNpc && activeNpcData && (
<DialogModal
npcId={activeDialogNpc}
npcData={activeNpcData}
onClose={() => setActiveDialogNpc(null)}
onTrade={() => {
setActiveDialogNpc(null);
setShowTradeModal(true);
}}
/>
)}
{showTradeModal && activeNpcData && (
<TradeModal
npcId={activeNpcData.id}
onClose={() => setShowTradeModal(false)}
/>
)}
</div>
)
}

View File

@@ -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({
</div>
)}
<GameButton
className="open-inventory-btn"
variant="primary"
size="lg"
onClick={() => setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }}
>
{t('game.inventory')}
</GameButton>
<div className="sidebar-buttons" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<GameButton
className="open-inventory-btn"
variant="primary"
size="md"
onClick={() => setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }}
>
{t('game.inventory')}
</GameButton>
<GameButton
className="quest-journal-btn"
variant="secondary" // Different color as requested
size="md"
onClick={onOpenQuestJournal}
style={{ width: '100%', justifyContent: 'center' }}
>
📜 {t('common.quests')}
</GameButton>
</div>
</div>
<div className="equipment-sidebar">

View File

@@ -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;
}

View File

@@ -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<QuestJournalProps> = ({ 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 (
<li key={idx} className={`objective-item ${met ? 'met' : ''}`}>
{label}: {current}/{required}
</li>
);
});
};
return (
<GameModal
title="Quest Journal"
onClose={onClose}
className="quest-journal-modal"
footer={
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="tab-container">
<button
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
onClick={() => setActiveTab('active')}
>
Active
</button>
<button
className={`journal-tab ${activeTab === 'completed' ? 'active' : ''}`}
onClick={() => setActiveTab('completed')}
>
Completed
</button>
</div>
</div>
}
>
<div className="journal-content">
<div className="quest-list">
{filteredQuests.length === 0 ? (
<div className="empty-message">No quests found in this category.</div>
) : (
filteredQuests.map((quest: Quest) => (
<div key={quest.quest_id} className={`quest-card ${quest.status === 'completed' ? 'completed' : ''}`}>
<h3>
{getLocalizedText(quest.title)}
{quest.type === 'global' && <span style={{ fontSize: '0.8rem', color: '#64b5f6', marginLeft: '10px' }}>GLOBAL</span>}
</h3>
<div className="quest-desc">{getLocalizedText(quest.description)}</div>
{quest.status === 'active' && (
<ul className="objective-list">
{renderObjectives(quest)}
</ul>
)}
{quest.status === 'completed' && quest.completion_text && (
<div className="completion-text">
"{getLocalizedText(quest.completion_text)}"
</div>
)}
</div>
))
)}
</div>
</div>
</GameModal>
);
};

View File

@@ -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;
}

View File

@@ -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<TradeModalProps> = ({ npcId, onClose }) => {
const { token, inventory: playerInv } = useGame();
const [npcStock, setNpcStock] = useState<TradeItem[]>([]);
const [playerItems, setPlayerItems] = useState<TradeItem[]>([]);
const [buying, setBuying] = useState<Selection[]>([]); // Items selected from NPC
const [selling, setSelling] = useState<Selection[]>([]); // Items selected from Player
const [tradeConfig, setTradeConfig] = useState<any>({});
// Filters
const [npcSearch, setNpcSearch] = useState('');
const [playerSearch, setPlayerSearch] = useState('');
// Selection logic
const [selectedItem, setSelectedItem] = useState<TradeItem | null>(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 <div className="loading-text">Loading trade data...</div>;
return (
<GameModal
title="Trading"
onClose={onClose}
className="trade-modal"
>
<div className="trade-container">
<div className="trade-content">
{/* LEFT: NPC STOCK */}
<div className="trade-column">
<h3 className="column-header">Merchant Stock {tradeConfig.buy_markup && <small>(x{tradeConfig.buy_markup})</small>}</h3>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={npcSearch}
onChange={(e) => setNpcSearch(e.target.value)}
/>
<div className="inventory-grid">
{availableNpcStock.map((item, idx) => {
// Prepare tooltip content matching InventoryModal
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div style={{ color: '#ffd700' }}>💰 {Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
{item.weight !== undefined && <div> {item.weight}kg</div>}
{item.volume !== undefined && <div>📦 {item.volume}L</div>}
</div>
<div className="stat-badges-container">
{/* Capacity */}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<span className="stat-badge capacity">
+{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</span>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</span>
)}
{/* Combat Stats */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{(item.unique_stats?.armor || item.stats?.armor) && (
<span className="stat-badge armor">
🛡 +{item.unique_stats?.armor || item.stats?.armor}
</span>
)}
{/* Consumables */}
{item.hp_restore && (
<span className="stat-badge health">
+{item.hp_restore} HP
</span>
)}
{item.stamina_restore && (
<span className="stat-badge stamina">
+{item.stamina_restore} Stm
</span>
)}
</div>
</div>
);
return (
<GameTooltip key={idx} content={tooltipContent}>
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'npc')}>
<div className="trade-item-image">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
) : (
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
)}
</div>
{(item.is_infinite || (item as any)._displayQuantity > 1) && (
<div className="trade-item-qty">{item.is_infinite ? '∞' : `x${(item as any)._displayQuantity}`}</div>
)}
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
</div>
</GameTooltip>
);
})}
</div>
</div>
{/* CENTER: CART */}
<div className="trade-center-column">
<div className="trade-cart-section">
<div className="trade-list-header">
<span>Buying</span>
<span style={{ color: '#ff9800' }}>{Math.round(buyTotal)}</span>
</div>
<div className="cart-grid">
{buying.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
{buying.map((b, i) => (
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>}>
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
const n = [...buying]; n.splice(i, 1); setBuying(n);
}}>
{b.image_path ? (
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
) : (
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
)}
<div className="trade-item-qty">x{b.quantity}</div>
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>
</div>
</GameTooltip>
))}
</div>
</div>
<div className="trade-cart-section">
<div className="trade-list-header">
<span>Selling</span>
<span style={{ color: '#4caf50' }}>{Math.round(sellTotal)}</span>
</div>
<div className="cart-grid">
{selling.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
{selling.map((b, i) => (
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>}>
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
const n = [...selling]; n.splice(i, 1); setSelling(n);
}}>
{b.image_path ? (
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
) : (
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
)}
<div className="trade-item-qty">x{b.quantity}</div>
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>
</div>
</GameTooltip>
))}
</div>
</div>
</div>
{/* RIGHT: PLAYER INVENTORY */}
<div className="trade-column">
<h3 className="column-header">Inventory {tradeConfig.sell_markdown && <small>(x{tradeConfig.sell_markdown})</small>}</h3>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
/>
<div className="inventory-grid">
{availablePlayerInv.map((item, idx) => {
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div style={{ color: '#4caf50' }}>💰 {Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
</div>
<div className="stat-badges-container">
{/* Same badges logic could be extracted but duplicating for speed/safety */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{item.hp_restore && <span className="stat-badge health"> +{item.hp_restore} HP</span>}
</div>
</div>
);
return (
<GameTooltip key={idx} content={tooltipContent}>
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'player')}>
<div className="trade-item-image">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
) : (
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
)}
</div>
{(item as any)._displayQuantity > 1 && <div className="trade-item-qty">x{(item as any)._displayQuantity}</div>}
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
</div>
</GameTooltip>
);
})}
</div>
</div>
</div>
<div className="trade-footer">
<div className="trade-summary">
<span>Balance</span>
<span className={`trade-total ${sellTotal >= buyTotal ? 'text-green' : 'text-red'}`}>
{Math.round(sellTotal - buyTotal)}
</span>
</div>
<button className="trade-action-btn" onClick={executeTrade} disabled={!isValid}>
{isValid ? "CONFIRM TRADE" : "INVALID OFFER"}
</button>
<div style={{ width: '60px' }}></div> {/* Spacer */}
</div>
{showQtyModal && selectedItem && (
<div className="dialog-modal-overlay" style={{ zIndex: 1101 }}>
<div className="quantity-modal">
<h4>How many {getTranslatedText(selectedItem.name)}?</h4>
<div className="qty-controls">
<GameButton size="sm" onClick={() => setQtyInput(Math.max(1, qtyInput - 1))}>-</GameButton>
<input
className="qty-input"
type="number"
value={qtyInput}
onChange={e => setQtyInput(parseInt(e.target.value) || 1)}
min="1"
/>
<GameButton size="sm" onClick={() => setQtyInput(qtyInput + 1)}>+</GameButton>
<GameButton size="sm" onClick={() => {
const max = (selectedItem as any)._displayQuantity || 1;
setQtyInput(max);
}}>Max</GameButton>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<GameButton variant="primary" onClick={confirmSelection}>Confirm</GameButton>
<GameButton variant="secondary" onClick={() => setShowQtyModal(false)}>Cancel</GameButton>
</div>
</div>
</div>
)}
</div>
</GameModal>
);
};

View File

@@ -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<any>(null)
const [movementCooldown, setMovementCooldown] = useState<number>(0)
const [failedActionItemId, setFailedActionItemId] = useState<string | number | null>(null)
const [quests, setQuests] = useState<{ active: any[], available: any[] }>({ active: [], available: [] })
// const [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('') // Moved to Combat.tsx
const [equipment, setEquipment] = useState<Equipment>({})
@@ -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

7
pwa/src/config.ts Normal file
View File

@@ -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`;

View File

@@ -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<GameContextType | undefined>(undefined);
export const GameProvider: React.FC<{
children: React.ReactNode;
value: GameContextType;
}> = ({ children, value }) => {
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
};
export const useGame = () => {
const context = useContext(GameContext);
if (context === undefined) {
throw new Error('useGame must be used within a GameProvider');
}
return context;
};

View File

@@ -22,6 +22,7 @@
"qty": "Qty",
"enemy": "Enemy",
"you": "You",
"quests": "Quests",
"all": "All"
},
"auth": {

View File

@@ -20,6 +20,7 @@
"pickUp": "Recoger",
"pickUpAll": "Recoger Todo",
"qty": "Cant",
"quests": "Misiones",
"all": "Todo"
},
"auth": {