Added trading and quests, checkpoint push
This commit is contained in:
@@ -819,7 +819,83 @@ async def process_status_effects(manager=None):
|
|||||||
logger.error(f"❌ Error in status effects task: {e}", exc_info=True)
|
logger.error(f"❌ Error in status effects task: {e}", exc_info=True)
|
||||||
await asyncio.sleep(10)
|
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
|
# TASK STARTUP FUNCTION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -868,7 +944,7 @@ def release_background_tasks_lock():
|
|||||||
_lock_file_handle = None
|
_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.
|
Start all background tasks.
|
||||||
Called when the API starts up.
|
Called when the API starts up.
|
||||||
@@ -877,6 +953,7 @@ async def start_background_tasks(manager=None, world_locations=None):
|
|||||||
Args:
|
Args:
|
||||||
manager: WebSocket ConnectionManager for broadcasting events
|
manager: WebSocket ConnectionManager for broadcasting events
|
||||||
world_locations: Dict of Location objects for interactable mapping
|
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
|
# Try to acquire lock - only one worker will succeed
|
||||||
if not acquire_background_tasks_lock():
|
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(check_pvp_combat_timers(manager)),
|
||||||
asyncio.create_task(decay_corpses(manager)),
|
asyncio.create_task(decay_corpses(manager)),
|
||||||
asyncio.create_task(process_status_effects(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
|
# Note: Interactable cooldowns are handled client-side with server validation
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
291
api/database.py
291
api/database.py
@@ -308,6 +308,50 @@ player_statistics = Table(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# QUESTS AND TRADE TABLES
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# Quests: Character progress
|
||||||
|
character_quests = Table(
|
||||||
|
"character_quests",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
Column("quest_id", String, nullable=False),
|
||||||
|
Column("status", String(20), default="active"), # active, completed, failed
|
||||||
|
Column("progress", JSON, default={}), # {"rat_kills": 1, "wood_delivered": 50}
|
||||||
|
Column("started_at", Float, default=lambda: time.time()),
|
||||||
|
Column("completed_at", Float, nullable=True),
|
||||||
|
Column("last_completed_at", Float, nullable=True), # For repeatable quests
|
||||||
|
Column("cooldown_expires_at", Float, nullable=True), # For repeatable quests
|
||||||
|
Column("times_completed", Integer, default=0),
|
||||||
|
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quests: Global progress
|
||||||
|
global_quests = Table(
|
||||||
|
"global_quests",
|
||||||
|
metadata,
|
||||||
|
Column("quest_id", String, primary_key=True),
|
||||||
|
Column("global_progress", JSON, default={}),
|
||||||
|
Column("is_completed", Boolean, default=False),
|
||||||
|
Column("updated_at", Float, default=lambda: time.time()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trade: Merchant Stock
|
||||||
|
merchant_stock = Table(
|
||||||
|
"merchant_stock",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("npc_id", String, nullable=False),
|
||||||
|
Column("item_id", String, nullable=False),
|
||||||
|
Column("unique_item_id", Integer, ForeignKey("unique_items.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
Column("quantity", Integer, default=0),
|
||||||
|
Column("last_restock_at", Float, default=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Database session context manager
|
# Database session context manager
|
||||||
class DatabaseSession:
|
class DatabaseSession:
|
||||||
"""Context manager for database sessions"""
|
"""Context manager for database sessions"""
|
||||||
@@ -357,6 +401,13 @@ async def init_db():
|
|||||||
|
|
||||||
# Interactable cooldowns - checked on interact attempts
|
# Interactable cooldowns - checked on interact attempts
|
||||||
"CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_instance ON interactable_cooldowns(interactable_instance_id);",
|
"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:
|
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
|
# Inventory operations
|
||||||
# NOTE: Functions below use 'player_id' parameter name for backward compatibility
|
# NOTE: Functions below use 'player_id' parameter name for backward compatibility
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Item:
|
|||||||
volume: float = 0.0
|
volume: float = 0.0
|
||||||
stats: Dict[str, int] = None
|
stats: Dict[str, int] = None
|
||||||
effects: Dict[str, Any] = None
|
effects: Dict[str, Any] = None
|
||||||
|
value: int = 10 # Base value for trading
|
||||||
# Equipment system
|
# Equipment system
|
||||||
slot: str = None # Equipment slot: head, torso, legs, feet, weapon, offhand, backpack
|
slot: str = None # Equipment slot: head, torso, legs, feet, weapon, offhand, backpack
|
||||||
durability: int = None # Max durability for equippable items
|
durability: int = None # Max durability for equippable items
|
||||||
@@ -109,6 +110,7 @@ class ItemsManager:
|
|||||||
name=item_data.get('name', 'Unknown Item'),
|
name=item_data.get('name', 'Unknown Item'),
|
||||||
description=item_data.get('description', ''),
|
description=item_data.get('description', ''),
|
||||||
type=item_type,
|
type=item_type,
|
||||||
|
value=item_data.get('value', 10),
|
||||||
image_path=item_data.get('image_path', ''),
|
image_path=item_data.get('image_path', ''),
|
||||||
emoji=item_data.get('emoji', '📦'),
|
emoji=item_data.get('emoji', '📦'),
|
||||||
stackable=item_data.get('stackable', True),
|
stackable=item_data.get('stackable', True),
|
||||||
|
|||||||
42
api/main.py
42
api/main.py
@@ -33,7 +33,11 @@ from .routers import (
|
|||||||
crafting,
|
crafting,
|
||||||
loot,
|
loot,
|
||||||
statistics,
|
statistics,
|
||||||
admin
|
statistics,
|
||||||
|
admin,
|
||||||
|
quests,
|
||||||
|
trade,
|
||||||
|
npcs
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -79,7 +83,7 @@ async def lifespan(app: FastAPI):
|
|||||||
print("✅ Redis listener started")
|
print("✅ Redis listener started")
|
||||||
|
|
||||||
# Start background tasks (distributed via Redis locks)
|
# 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:
|
if tasks:
|
||||||
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
||||||
else:
|
else:
|
||||||
@@ -123,14 +127,43 @@ if IMAGES_DIR.exists():
|
|||||||
else:
|
else:
|
||||||
print(f"⚠️ Images directory not found: {IMAGES_DIR}")
|
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
|
# Initialize routers with game data dependencies
|
||||||
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
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)
|
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||||
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||||
statistics.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)
|
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
|
# Include all routers
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -142,6 +175,9 @@ app.include_router(crafting.router)
|
|||||||
app.include_router(loot.router)
|
app.include_router(loot.router)
|
||||||
app.include_router(statistics.router)
|
app.include_router(statistics.router)
|
||||||
app.include_router(admin.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")
|
print("✅ All routers registered")
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,17 @@ LOCATIONS = None
|
|||||||
ITEMS_MANAGER = None
|
ITEMS_MANAGER = None
|
||||||
WORLD = None
|
WORLD = None
|
||||||
redis_manager = 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"""
|
"""Initialize router with game data dependencies"""
|
||||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA
|
||||||
LOCATIONS = locations
|
LOCATIONS = locations
|
||||||
ITEMS_MANAGER = items_manager
|
ITEMS_MANAGER = items_manager
|
||||||
WORLD = world
|
WORLD = world
|
||||||
redis_manager = redis_mgr
|
redis_manager = redis_mgr
|
||||||
|
if quests_data:
|
||||||
|
QUESTS_DATA = quests_data
|
||||||
|
|
||||||
router = APIRouter(tags=["combat"])
|
router = APIRouter(tags=["combat"])
|
||||||
|
|
||||||
@@ -433,6 +436,128 @@ async def combat_action(
|
|||||||
location_id=player['location_id'],
|
location_id=player['location_id'],
|
||||||
loot_remaining=json.dumps(corpse_loot_dicts)
|
loot_remaining=json.dumps(corpse_loot_dicts)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- UPDATE QUEST PROGRESS ---
|
||||||
|
# --- UPDATE QUEST PROGRESS ---
|
||||||
|
try:
|
||||||
|
# Use global QUESTS_DATA injected dependency
|
||||||
|
if QUESTS_DATA:
|
||||||
|
active_quests = await db.get_character_quests(player['id'])
|
||||||
|
quest_updated = False
|
||||||
|
|
||||||
|
for q_record in active_quests:
|
||||||
|
if q_record['status'] != 'active':
|
||||||
|
continue
|
||||||
|
|
||||||
|
q_def = QUESTS_DATA.get(q_record['quest_id'])
|
||||||
|
if not q_def: continue
|
||||||
|
|
||||||
|
objectives = q_def.get('objectives', [])
|
||||||
|
current_progress = q_record.get('progress') or {}
|
||||||
|
new_progress = current_progress.copy()
|
||||||
|
progres_changed = False
|
||||||
|
|
||||||
|
for obj in objectives:
|
||||||
|
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
||||||
|
current_count = current_progress.get(obj['target'], 0)
|
||||||
|
if current_count < obj['count']:
|
||||||
|
new_progress[obj['target']] = current_count + 1
|
||||||
|
progres_changed = True
|
||||||
|
|
||||||
|
if progres_changed:
|
||||||
|
# Check completion
|
||||||
|
all_done = True
|
||||||
|
for obj in objectives:
|
||||||
|
target = obj['target']
|
||||||
|
req_count = obj['count']
|
||||||
|
curr = new_progress.get(target, 0)
|
||||||
|
|
||||||
|
# Simple check (ignoring items for kill quests for now)
|
||||||
|
if obj['type'] == 'kill_count':
|
||||||
|
if curr < req_count:
|
||||||
|
all_done = False
|
||||||
|
elif obj['type'] == 'item_delivery':
|
||||||
|
# For mixed quests, we can't complete purely on kills.
|
||||||
|
pass
|
||||||
|
|
||||||
|
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
||||||
|
|
||||||
|
# Notify user
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"quest_update",
|
||||||
|
origin="system",
|
||||||
|
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
||||||
|
))
|
||||||
|
quest_updated = True
|
||||||
|
|
||||||
|
# Add to quest updates list to return to client
|
||||||
|
# Filter/Enrich for frontend
|
||||||
|
updated_q_data = dict(q_record)
|
||||||
|
updated_q_data['start_at'] = q_record['started_at']
|
||||||
|
updated_q_data.update(q_def)
|
||||||
|
if 'quest_updates' not in locals(): quest_updates = []
|
||||||
|
quest_updates.append(updated_q_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update quest progress: {e}")
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
for q_record in active_quests:
|
||||||
|
if q_record['status'] != 'active':
|
||||||
|
continue
|
||||||
|
|
||||||
|
q_def = all_quests_def.get(q_record['quest_id'])
|
||||||
|
if not q_def: continue
|
||||||
|
|
||||||
|
objectives = q_def.get('objectives', [])
|
||||||
|
current_progress = q_record.get('progress') or {}
|
||||||
|
new_progress = current_progress.copy()
|
||||||
|
progres_changed = False
|
||||||
|
|
||||||
|
for obj in objectives:
|
||||||
|
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
||||||
|
current_count = current_progress.get(obj['target'], 0)
|
||||||
|
if current_count < obj['count']:
|
||||||
|
new_progress[obj['target']] = current_count + 1
|
||||||
|
progres_changed = True
|
||||||
|
|
||||||
|
if progres_changed:
|
||||||
|
# Check completion
|
||||||
|
all_done = True
|
||||||
|
for obj in objectives:
|
||||||
|
target = obj['target']
|
||||||
|
req_count = obj['count']
|
||||||
|
curr = new_progress.get(target, 0)
|
||||||
|
|
||||||
|
# Simple check for now (ignoring items for kill quests)
|
||||||
|
if obj['type'] == 'kill_count':
|
||||||
|
if curr < req_count:
|
||||||
|
all_done = False
|
||||||
|
elif obj['type'] == 'item_delivery':
|
||||||
|
# Items are checked at hand-in usually
|
||||||
|
# But we need to know if we should mark as completed "ready to turn in" or just "objectives met"
|
||||||
|
# For mixed quests, we can't complete purely on kills.
|
||||||
|
# Let's just update progress.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# We generally don't auto-complete quests, user has to hand in.
|
||||||
|
# But we can update the progress in DB.
|
||||||
|
new_status = 'active'
|
||||||
|
# If we wanted to support auto-complete, we'd do it here. Use 'active'.
|
||||||
|
|
||||||
|
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, new_status)
|
||||||
|
|
||||||
|
# Notify user
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"quest_update",
|
||||||
|
origin="system",
|
||||||
|
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
||||||
|
))
|
||||||
|
quest_updated = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update quest progress: {e}")
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -927,7 +1052,8 @@ async def combat_action(
|
|||||||
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
|
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
|
||||||
"xp": updated_player['xp'],
|
"xp": updated_player['xp'],
|
||||||
"level": updated_player['level']
|
"level": updated_player['level']
|
||||||
}
|
},
|
||||||
|
"quest_updates": quest_updates if 'quest_updates' in locals() else []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,52 @@ redis_manager = None
|
|||||||
|
|
||||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||||
"""Initialize router with game data dependencies"""
|
"""Initialize router with game data dependencies"""
|
||||||
|
print("🔧 INITIALIZING GAME ROUTE DEPENDENCIES")
|
||||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||||
LOCATIONS = locations
|
LOCATIONS = locations
|
||||||
ITEMS_MANAGER = items_manager
|
ITEMS_MANAGER = items_manager
|
||||||
WORLD = world
|
WORLD = world
|
||||||
redis_manager = redis_mgr
|
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"])
|
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,
|
"damage_max": item.stats.get('damage_max') if item.stats else None,
|
||||||
"stats": item.stats,
|
"stats": item.stats,
|
||||||
# Workbench flags
|
# Workbench flags
|
||||||
|
"value": getattr(item, 'value', 10),
|
||||||
"is_repairable": is_repairable,
|
"is_repairable": is_repairable,
|
||||||
"is_salvageable": is_salvageable,
|
"is_salvageable": is_salvageable,
|
||||||
"current_durability": current_durability,
|
"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:
|
if slot not in equipment:
|
||||||
equipment[slot] = None
|
equipment[slot] = None
|
||||||
|
|
||||||
# Get combat state
|
# Get active combat (PvE)
|
||||||
combat = await db.get_active_combat(player_id)
|
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:
|
if combat:
|
||||||
# Ensure intent is present (handle legacy)
|
# Ensure intent is present (handle legacy)
|
||||||
if 'npc_intent' not in combat or not combat['npc_intent']:
|
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,
|
"inventory": inventory,
|
||||||
"equipment": equipment,
|
"equipment": equipment,
|
||||||
"combat": combat,
|
"combat": combat,
|
||||||
|
"pvp_combat": pvp_combat,
|
||||||
|
"is_pvp": pvp_combat is not None,
|
||||||
"dropped_items": dropped_items
|
"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'),
|
"name": npc.get('name', 'Unknown NPC'),
|
||||||
"type": npc.get('type', 'npc'),
|
"type": npc.get('type', 'npc'),
|
||||||
"level": npc.get('level'),
|
"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:
|
else:
|
||||||
npcs_data.append({
|
npcs_data.append({
|
||||||
"id": npc,
|
"id": npc,
|
||||||
@@ -539,6 +621,9 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
|||||||
"is_wandering": False
|
"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!
|
# Enrich dropped items with metadata - DON'T consolidate unique items!
|
||||||
items_dict = {}
|
items_dict = {}
|
||||||
for item in dropped_items:
|
for item in dropped_items:
|
||||||
@@ -1053,7 +1138,7 @@ async def interact(
|
|||||||
"instance_id": interact_req.interactable_id,
|
"instance_id": interact_req.interactable_id,
|
||||||
"action_id": interact_req.action_id,
|
"action_id": interact_req.action_id,
|
||||||
"cooldown_remaining": cooldown_remaining,
|
"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()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ async def get_corpse_details(
|
|||||||
'index': idx,
|
'index': idx,
|
||||||
'item_id': loot_item['item_id'],
|
'item_id': loot_item['item_id'],
|
||||||
'item_name': item_def.name if item_def else 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 '📦',
|
'emoji': item_def.emoji if item_def else '📦',
|
||||||
'quantity_min': loot_item['quantity_min'],
|
'quantity_min': loot_item['quantity_min'],
|
||||||
'quantity_max': loot_item['quantity_max'],
|
'quantity_max': loot_item['quantity_max'],
|
||||||
@@ -129,6 +131,8 @@ async def get_corpse_details(
|
|||||||
'index': idx,
|
'index': idx,
|
||||||
'item_id': item['item_id'],
|
'item_id': item['item_id'],
|
||||||
'item_name': item_def.name if item_def else 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 '📦',
|
'emoji': item_def.emoji if item_def else '📦',
|
||||||
'quantity_min': item['quantity'],
|
'quantity_min': item['quantity'],
|
||||||
'quantity_max': item['quantity'],
|
'quantity_max': item['quantity'],
|
||||||
|
|||||||
54
api/routers/npcs.py
Normal file
54
api/routers/npcs.py
Normal 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
302
api/routers/quests.py
Normal 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
234
api/routers/trade.py
Normal 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"}
|
||||||
@@ -118,6 +118,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./gamedata:/app/gamedata:ro
|
- ./gamedata:/app/gamedata:ro
|
||||||
- ./images:/app/images:ro
|
- ./images:/app/images:ro
|
||||||
|
- ./api:/app/api:rw
|
||||||
|
- ./data:/app/data:rw
|
||||||
depends_on:
|
depends_on:
|
||||||
- echoes_of_the_ashes_db
|
- echoes_of_the_ashes_db
|
||||||
- echoes_of_the_ashes_redis
|
- echoes_of_the_ashes_redis
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"rusty_nails": {
|
"rusty_nails": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"wood_planks": {
|
"wood_planks": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -43,7 +45,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"cloth_scraps": {
|
"cloth_scraps": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -58,7 +61,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"cloth": {
|
"cloth": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -86,7 +90,8 @@
|
|||||||
"item_id": "knife",
|
"item_id": "knife",
|
||||||
"durability_cost": 1
|
"durability_cost": 1
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"plastic_bottles": {
|
"plastic_bottles": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -101,7 +106,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"bone": {
|
"bone": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -116,7 +122,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"raw_meat": {
|
"raw_meat": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -131,7 +138,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"animal_hide": {
|
"animal_hide": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -146,7 +154,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"mutant_tissue": {
|
"mutant_tissue": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -161,7 +170,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"infected_tissue": {
|
"infected_tissue": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -176,7 +186,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A raw material used for crafting and upgrades.",
|
"en": "A raw material used for crafting and upgrades.",
|
||||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"stale_chocolate_bar": {
|
"stale_chocolate_bar": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -192,7 +203,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"canned_beans": {
|
"canned_beans": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -209,7 +221,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"canned_food": {
|
"canned_food": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -226,7 +239,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"bottled_water": {
|
"bottled_water": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -242,7 +256,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"water_bottle": {
|
"water_bottle": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -258,7 +273,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"energy_bar": {
|
"energy_bar": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -274,7 +290,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"mystery_pills": {
|
"mystery_pills": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -290,7 +307,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "Can be consumed to restore health or stamina.",
|
"en": "Can be consumed to restore health or stamina.",
|
||||||
"es": "Se puede consumir para restaurar salud o stamina."
|
"es": "Se puede consumir para restaurar salud o stamina."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"first_aid_kit": {
|
"first_aid_kit": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -306,7 +324,8 @@
|
|||||||
"type": "consumable",
|
"type": "consumable",
|
||||||
"hp_restore": 50,
|
"hp_restore": 50,
|
||||||
"emoji": "🩹",
|
"emoji": "🩹",
|
||||||
"image_path": "images/items/first_aid_kit.webp"
|
"image_path": "images/items/first_aid_kit.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"bandage": {
|
"bandage": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -334,7 +353,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"emoji": "🩹",
|
"emoji": "🩹",
|
||||||
"image_path": "images/items/bandage.webp"
|
"image_path": "images/items/bandage.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"medical_supplies": {
|
"medical_supplies": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -350,7 +370,8 @@
|
|||||||
"type": "consumable",
|
"type": "consumable",
|
||||||
"hp_restore": 40,
|
"hp_restore": 40,
|
||||||
"emoji": "⚕️",
|
"emoji": "⚕️",
|
||||||
"image_path": "images/items/medical_supplies.webp"
|
"image_path": "images/items/medical_supplies.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"antibiotics": {
|
"antibiotics": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -367,7 +388,8 @@
|
|||||||
"hp_restore": 20,
|
"hp_restore": 20,
|
||||||
"treats": "Infected",
|
"treats": "Infected",
|
||||||
"emoji": "💊",
|
"emoji": "💊",
|
||||||
"image_path": "images/items/antibiotics.webp"
|
"image_path": "images/items/antibiotics.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"rad_pills": {
|
"rad_pills": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -384,7 +406,8 @@
|
|||||||
"hp_restore": 5,
|
"hp_restore": 5,
|
||||||
"treats": "Radiation",
|
"treats": "Radiation",
|
||||||
"emoji": "☢️",
|
"emoji": "☢️",
|
||||||
"image_path": "images/items/rad_pills.webp"
|
"image_path": "images/items/rad_pills.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"tire_iron": {
|
"tire_iron": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -408,7 +431,8 @@
|
|||||||
"damage_max": 5
|
"damage_max": 5
|
||||||
},
|
},
|
||||||
"emoji": "🔧",
|
"emoji": "🔧",
|
||||||
"image_path": "images/items/tire_iron.webp"
|
"image_path": "images/items/tire_iron.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"baseball_bat": {
|
"baseball_bat": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -428,7 +452,8 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"damage_min": 5,
|
"damage_min": 5,
|
||||||
"damage_max": 8
|
"damage_max": 8
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"rusty_knife": {
|
"rusty_knife": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -464,7 +489,8 @@
|
|||||||
"damage_max": 5
|
"damage_max": 5
|
||||||
},
|
},
|
||||||
"emoji": "🔪",
|
"emoji": "🔪",
|
||||||
"image_path": "images/items/rusty_knife.webp"
|
"image_path": "images/items/rusty_knife.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"knife": {
|
"knife": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -553,7 +579,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emoji": "🔪",
|
"emoji": "🔪",
|
||||||
"image_path": "images/items/knife.webp"
|
"image_path": "images/items/knife.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"rusty_pipe": {
|
"rusty_pipe": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -573,7 +600,8 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"damage_min": 5,
|
"damage_min": 5,
|
||||||
"damage_max": 8
|
"damage_max": 8
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"tattered_rucksack": {
|
"tattered_rucksack": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -620,7 +648,8 @@
|
|||||||
"volume_capacity": 10
|
"volume_capacity": 10
|
||||||
},
|
},
|
||||||
"emoji": "🎒",
|
"emoji": "🎒",
|
||||||
"image_path": "images/items/tattered_rucksack.webp"
|
"image_path": "images/items/tattered_rucksack.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"hiking_backpack": {
|
"hiking_backpack": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -656,7 +685,8 @@
|
|||||||
"volume_capacity": 20
|
"volume_capacity": 20
|
||||||
},
|
},
|
||||||
"emoji": "🎒",
|
"emoji": "🎒",
|
||||||
"image_path": "images/items/hiking_backpack.webp"
|
"image_path": "images/items/hiking_backpack.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"flashlight": {
|
"flashlight": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -676,7 +706,8 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"damage_min": 5,
|
"damage_min": 5,
|
||||||
"damage_max": 8
|
"damage_max": 8
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"old_photograph": {
|
"old_photograph": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -691,7 +722,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A useful old photograph.",
|
"en": "A useful old photograph.",
|
||||||
"es": "Una fotografía vieja útil."
|
"es": "Una fotografía vieja útil."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"key_ring": {
|
"key_ring": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -706,7 +738,8 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"en": "A useful key ring.",
|
"en": "A useful key ring.",
|
||||||
"es": "Un anillo de llaves útil."
|
"es": "Un anillo de llaves útil."
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"makeshift_spear": {
|
"makeshift_spear": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -757,7 +790,8 @@
|
|||||||
"damage_max": 7
|
"damage_max": 7
|
||||||
},
|
},
|
||||||
"emoji": "⚔️",
|
"emoji": "⚔️",
|
||||||
"image_path": "images/items/makeshift_spear.webp"
|
"image_path": "images/items/makeshift_spear.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"reinforced_bat": {
|
"reinforced_bat": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -814,7 +848,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emoji": "🏸",
|
"emoji": "🏸",
|
||||||
"image_path": "images/items/reinforced_bat.webp"
|
"image_path": "images/items/reinforced_bat.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"leather_vest": {
|
"leather_vest": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -865,7 +900,8 @@
|
|||||||
"hp_bonus": 10
|
"hp_bonus": 10
|
||||||
},
|
},
|
||||||
"emoji": "🦺",
|
"emoji": "🦺",
|
||||||
"image_path": "images/items/leather_vest.webp"
|
"image_path": "images/items/leather_vest.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"cloth_bandana": {
|
"cloth_bandana": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -903,7 +939,8 @@
|
|||||||
"armor": 1
|
"armor": 1
|
||||||
},
|
},
|
||||||
"emoji": "🧣",
|
"emoji": "🧣",
|
||||||
"image_path": "images/items/cloth_bandana.webp"
|
"image_path": "images/items/cloth_bandana.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"sturdy_boots": {
|
"sturdy_boots": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -954,7 +991,8 @@
|
|||||||
"stamina_bonus": 5
|
"stamina_bonus": 5
|
||||||
},
|
},
|
||||||
"emoji": "🥾",
|
"emoji": "🥾",
|
||||||
"image_path": "images/items/sturdy_boots.webp"
|
"image_path": "images/items/sturdy_boots.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"padded_pants": {
|
"padded_pants": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1001,7 +1039,8 @@
|
|||||||
"hp_bonus": 5
|
"hp_bonus": 5
|
||||||
},
|
},
|
||||||
"emoji": "👖",
|
"emoji": "👖",
|
||||||
"image_path": "images/items/padded_pants.webp"
|
"image_path": "images/items/padded_pants.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"reinforced_pack": {
|
"reinforced_pack": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1091,7 +1130,8 @@
|
|||||||
"volume_capacity": 30
|
"volume_capacity": 30
|
||||||
},
|
},
|
||||||
"emoji": "🎒",
|
"emoji": "🎒",
|
||||||
"image_path": "images/items/reinforced_pack.webp"
|
"image_path": "images/items/reinforced_pack.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"hammer": {
|
"hammer": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1130,7 +1170,8 @@
|
|||||||
],
|
],
|
||||||
"repair_percentage": 30,
|
"repair_percentage": 30,
|
||||||
"emoji": "🔨",
|
"emoji": "🔨",
|
||||||
"image_path": "images/items/hammer.webp"
|
"image_path": "images/items/hammer.webp",
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"screwdriver": {
|
"screwdriver": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1173,7 +1214,8 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"damage_min": 5,
|
"damage_min": 5,
|
||||||
"damage_max": 8
|
"damage_max": 8
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"pipe_bomb": {
|
"pipe_bomb": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1194,7 +1236,8 @@
|
|||||||
"combat_effects": {
|
"combat_effects": {
|
||||||
"damage_min": 15,
|
"damage_min": 15,
|
||||||
"damage_max": 25
|
"damage_max": 25
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"molotov_cocktail": {
|
"molotov_cocktail": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1222,7 +1265,8 @@
|
|||||||
"ticks": 3,
|
"ticks": 3,
|
||||||
"persist_after_combat": true
|
"persist_after_combat": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"smoke_bomb": {
|
"smoke_bomb": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1249,7 +1293,8 @@
|
|||||||
"ticks": 1,
|
"ticks": 1,
|
||||||
"persist_after_combat": false
|
"persist_after_combat": false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"stim_pack": {
|
"stim_pack": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1269,7 +1314,8 @@
|
|||||||
"consumable": true,
|
"consumable": true,
|
||||||
"combat_usable": true,
|
"combat_usable": true,
|
||||||
"combat_only": true,
|
"combat_only": true,
|
||||||
"hp_restore": 20
|
"hp_restore": 20,
|
||||||
|
"value": 10
|
||||||
},
|
},
|
||||||
"adrenaline_shot": {
|
"adrenaline_shot": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -1297,7 +1343,8 @@
|
|||||||
"ticks": 2,
|
"ticks": 2,
|
||||||
"persist_after_combat": false
|
"persist_after_combat": false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"value": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
68
gamedata/quests.json
Normal file
68
gamedata/quests.json
Normal 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
93
gamedata/static_npcs.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
images-source/items/water_bottle.png
Normal file
BIN
images-source/items/water_bottle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 KiB |
@@ -10,13 +10,14 @@ set -e
|
|||||||
SOURCE_DIR="."
|
SOURCE_DIR="."
|
||||||
OUTPUT_DIR="../images"
|
OUTPUT_DIR="../images"
|
||||||
ITEM_SIZE="256x256"
|
ITEM_SIZE="256x256"
|
||||||
|
PORTRAIT_SIZE="512x512"
|
||||||
|
|
||||||
echo "🔄 Starting image conversion..."
|
echo "🔄 Starting image conversion..."
|
||||||
echo " Source: $SOURCE_DIR"
|
echo " Source: $SOURCE_DIR"
|
||||||
echo " Output: $OUTPUT_DIR"
|
echo " Output: $OUTPUT_DIR"
|
||||||
echo ""
|
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"
|
src="$SOURCE_DIR/$category"
|
||||||
out="$OUTPUT_DIR/$category"
|
out="$OUTPUT_DIR/$category"
|
||||||
|
|
||||||
@@ -38,11 +39,15 @@ for category in items locations npcs interactables characters placeholder; do
|
|||||||
continue
|
continue
|
||||||
fi
|
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
|
# Special processing for items: remove white background and resize
|
||||||
echo " ➜ Converting item: $filename"
|
echo " ➜ Converting item: $filename"
|
||||||
tmp="/tmp/${base}_clean.png"
|
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
|
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
|
||||||
rm "$tmp"
|
rm "$tmp"
|
||||||
else
|
else
|
||||||
|
|||||||
BIN
images-source/static_npcs/trader_joe.png
Normal file
BIN
images-source/static_npcs/trader_joe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
images/items/water_bottle.webp
Normal file
BIN
images/items/water_bottle.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
images/static_npcs/trader_joe.webp
Normal file
BIN
images/static_npcs/trader_joe.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -7,11 +7,16 @@ import { Combat } from './game/Combat'
|
|||||||
import LocationView from './game/LocationView'
|
import LocationView from './game/LocationView'
|
||||||
import MovementControls from './game/MovementControls'
|
import MovementControls from './game/MovementControls'
|
||||||
import PlayerSidebar from './game/PlayerSidebar'
|
import PlayerSidebar from './game/PlayerSidebar'
|
||||||
|
|
||||||
|
import { GameProvider } from '../contexts/GameContext'
|
||||||
|
import { QuestJournal } from './game/QuestJournal'
|
||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
function Game() {
|
function Game() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
const [token] = useState(() => localStorage.getItem('token'))
|
const [token] = useState(() => localStorage.getItem('token'))
|
||||||
|
const [showQuestJournal, setShowQuestJournal] = useState(false)
|
||||||
|
|
||||||
// Handle WebSocket messages
|
// Handle WebSocket messages
|
||||||
const handleWebSocketMessage = async (message: any) => {
|
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
|
// No location loaded yet
|
||||||
if (!state.location) {
|
if (!state.location) {
|
||||||
return (
|
return (
|
||||||
@@ -333,213 +349,233 @@ function Game() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-container">
|
<GameProvider value={gameContextValue}>
|
||||||
{/* Game Header is now in GameLayout */}
|
<div className="game-container">
|
||||||
|
{/* Game Header is now in GameLayout */}
|
||||||
|
|
||||||
{/* Mobile Header Toggle - only show in main view */}
|
{/* Quest Journal Toggle Button - Add to header or float?
|
||||||
{state.mobileMenuOpen === 'none' && (
|
Let's add it floating for now or in the top right.
|
||||||
<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 */}
|
{/* Mobile Header Toggle - only show in main view */}
|
||||||
<div className="center-area">
|
{state.mobileMenuOpen === 'none' && (
|
||||||
{/* Combat view (when in combat) */}
|
<button
|
||||||
{state.combatState && state.playerState && (
|
className="mobile-header-toggle"
|
||||||
<Combat
|
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
|
||||||
combatState={state.combatState}
|
>
|
||||||
combatLog={state.combatLog}
|
{state.mobileHeaderOpen ? '✕' : '☰'}
|
||||||
profile={state.profile}
|
</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}
|
playerState={state.playerState}
|
||||||
|
profile={state.profile}
|
||||||
equipment={state.equipment}
|
equipment={state.equipment}
|
||||||
onCombatAction={actions.handleCombatAction}
|
inventoryFilter={state.inventoryFilter}
|
||||||
onPvPAction={async (action: string) => {
|
inventoryCategoryFilter={state.inventoryCategoryFilter}
|
||||||
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}
|
mobileMenuOpen={state.mobileMenuOpen}
|
||||||
showCraftingMenu={state.showCraftingMenu}
|
onSetInventoryFilter={actions.setInventoryFilter}
|
||||||
showRepairMenu={state.showRepairMenu}
|
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
|
||||||
workbenchTab={state.workbenchTab}
|
onUseItem={async (itemId: number, _invId: number) => {
|
||||||
craftableItems={state.craftableItems}
|
await actions.handleUseItem(itemId.toString())
|
||||||
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}
|
onEquipItem={actions.handleEquipItem}
|
||||||
onLootCorpse={actions.handleLootCorpse}
|
onUnequipItem={actions.handleUnequipItem}
|
||||||
onLootCorpseItem={actions.handleLootCorpseItem}
|
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
|
||||||
onSetExpandedCorpse={(corpseId: string | null) => {
|
await actions.handleDropItem(itemId.toString(), quantity)
|
||||||
if (corpseId === null) {
|
|
||||||
actions.handleCloseCorpseDetails()
|
|
||||||
} else {
|
|
||||||
actions.handleViewCorpseDetails(corpseId)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onOpenCrafting={actions.handleOpenCrafting}
|
onSpendPoint={actions.handleSpendPoint}
|
||||||
onOpenRepair={actions.handleOpenRepair}
|
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right sidebar: Stats + Inventory */}
|
{/* Mobile Tab Navigation */}
|
||||||
{state.playerState && state.profile && (
|
<div className="mobile-menu-buttons">
|
||||||
<PlayerSidebar
|
<button
|
||||||
playerState={state.playerState}
|
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
|
||||||
profile={state.profile}
|
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
|
||||||
equipment={state.equipment}
|
>
|
||||||
inventoryFilter={state.inventoryFilter}
|
<span>🧭</span>
|
||||||
inventoryCategoryFilter={state.inventoryCategoryFilter}
|
</button>
|
||||||
mobileMenuOpen={state.mobileMenuOpen}
|
<button
|
||||||
onSetInventoryFilter={actions.setInventoryFilter}
|
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
|
||||||
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
|
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
|
||||||
onUseItem={async (itemId: number, _invId: number) => {
|
disabled={!!state.combatState}
|
||||||
await actions.handleUseItem(itemId.toString())
|
>
|
||||||
}}
|
<span>📍</span>
|
||||||
onEquipItem={actions.handleEquipItem}
|
</button>
|
||||||
onUnequipItem={actions.handleUnequipItem}
|
<button
|
||||||
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
|
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
|
||||||
await actions.handleDropItem(itemId.toString(), quantity)
|
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
|
||||||
}}
|
>
|
||||||
onSpendPoint={actions.handleSpendPoint}
|
<span>🎒</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlays */}
|
||||||
|
{state.mobileMenuOpen !== 'none' && (
|
||||||
|
<div
|
||||||
|
className="mobile-menu-overlay"
|
||||||
|
onClick={() => actions.setMobileMenuOpen('none')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Tab Navigation */}
|
{/* Mobile navigation */}
|
||||||
<div className="mobile-menu-buttons">
|
<div className="mobile-nav">
|
||||||
<button
|
<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')}
|
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
|
||||||
>
|
>
|
||||||
<span>🧭</span>
|
🗺️<br />Map
|
||||||
</button>
|
</button>
|
||||||
<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')}
|
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
|
||||||
disabled={!!state.combatState}
|
|
||||||
>
|
>
|
||||||
<span>📍</span>
|
📦<br />Items
|
||||||
</button>
|
</button>
|
||||||
<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')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Overlays */}
|
{showQuestJournal && (
|
||||||
{state.mobileMenuOpen !== 'none' && (
|
<QuestJournal onClose={() => setShowQuestJournal(false)} />
|
||||||
<div
|
|
||||||
className="mobile-menu-overlay"
|
|
||||||
onClick={() => actions.setMobileMenuOpen('none')}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</GameProvider>
|
||||||
{/* 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,14 +55,15 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
|||||||
// Use mousedown to catch clicks before they might trigger other things
|
// Use mousedown to catch clicks before they might trigger other things
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
// Disable scrolling while dropdown is open
|
// Handle scroll to close the dropdown (prevents detached menu and layout shifts)
|
||||||
const originalOverflow = document.body.style.overflow;
|
const handleScroll = () => {
|
||||||
document.body.style.overflow = 'hidden';
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
// Restore scrolling
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
document.body.style.overflow = originalOverflow;
|
|
||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
|||||||
112
pwa/src/components/game/DialogModal.css
Normal file
112
pwa/src/components/game/DialogModal.css
Normal 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;
|
||||||
|
}
|
||||||
297
pwa/src/components/game/DialogModal.tsx
Normal file
297
pwa/src/components/game/DialogModal.tsx
Normal 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}>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
pwa/src/components/game/GameModal.css
Normal file
102
pwa/src/components/game/GameModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
pwa/src/components/game/GameModal.tsx
Normal file
35
pwa/src/components/game/GameModal.tsx
Normal 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}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="game-modal-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="game-modal-footer">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,8 @@ import { GameButton } from '../common/GameButton'
|
|||||||
import { GameDropdown } from '../common/GameDropdown'
|
import { GameDropdown } from '../common/GameDropdown'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
|
import { DialogModal } from './DialogModal'
|
||||||
|
import { TradeModal } from './TradeModal'
|
||||||
import './LocationView.css'
|
import './LocationView.css'
|
||||||
|
|
||||||
interface LocationViewProps {
|
interface LocationViewProps {
|
||||||
@@ -49,6 +51,7 @@ interface LocationViewProps {
|
|||||||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||||||
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
||||||
failedActionItemId: string | number | null
|
failedActionItemId: string | number | null
|
||||||
|
quests: { active: any[], available: any[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocationView({
|
function LocationView({
|
||||||
@@ -70,6 +73,7 @@ function LocationView({
|
|||||||
uncraftFilter,
|
uncraftFilter,
|
||||||
craftCategoryFilter,
|
craftCategoryFilter,
|
||||||
profile,
|
profile,
|
||||||
|
quests,
|
||||||
|
|
||||||
onInitiateCombat,
|
onInitiateCombat,
|
||||||
onInitiatePvP,
|
onInitiatePvP,
|
||||||
@@ -91,17 +95,25 @@ function LocationView({
|
|||||||
}: LocationViewProps) {
|
}: LocationViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { playSfx } = useAudio()
|
const { playSfx } = useAudio()
|
||||||
|
// const { token } = useGame() // No longer needed for fetching here
|
||||||
|
|
||||||
// Dropdown State
|
// Dropdown State
|
||||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
|
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
|
// Handle dropdown toggle
|
||||||
const handleDropdownClick = (e: React.MouseEvent, id: string) => {
|
const handleDropdownClick = (e: React.MouseEvent, id: string) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (activeDropdown === id) {
|
if (activeDropdown === id) {
|
||||||
setActiveDropdown(null)
|
setActiveDropdown(null)
|
||||||
} else {
|
} else {
|
||||||
// GameDropdown now auto-detects mouse position if we don't pass it
|
|
||||||
setActiveDropdown(id)
|
setActiveDropdown(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +125,85 @@ function LocationView({
|
|||||||
return () => window.removeEventListener('click', handleClickOutside)
|
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 (
|
return (
|
||||||
<div className="location-view">
|
<div className="location-view">
|
||||||
<div className="location-info">
|
<div className="location-info">
|
||||||
@@ -165,8 +256,6 @@ function LocationView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{location.image_url && (
|
{location.image_url && (
|
||||||
<div className="location-image-container">
|
<div className="location-image-container">
|
||||||
<img
|
<img
|
||||||
@@ -183,12 +272,6 @@ function LocationView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* {message && (
|
|
||||||
<div className="message-box" onClick={() => onSetMessage('')}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{locationMessages.length > 0 && (
|
{locationMessages.length > 0 && (
|
||||||
<div className="location-messages-log">
|
<div className="location-messages-log">
|
||||||
<h4>{t('location.recentActivity')}</h4>
|
<h4>{t('location.recentActivity')}</h4>
|
||||||
@@ -321,30 +404,25 @@ function LocationView({
|
|||||||
<div className="entity-list grid-view">
|
<div className="entity-list grid-view">
|
||||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||||||
<div key={i} className="entity-card npc-card grid-card"
|
<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={
|
<GameTooltip content={
|
||||||
<div>
|
<div>
|
||||||
<div className="tooltip-title">{getTranslatedText(npc.name)}</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>
|
||||||
}>
|
}>
|
||||||
<div className="grid-overlay"></div>
|
<div className="grid-overlay"></div>
|
||||||
</GameTooltip>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -514,8 +592,6 @@ function LocationView({
|
|||||||
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(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>}
|
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
|
||||||
@@ -583,6 +659,25 @@ function LocationView({
|
|||||||
onUncraft={onUncraft}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface PlayerSidebarProps {
|
|||||||
onUnequipItem: (slot: string) => void
|
onUnequipItem: (slot: string) => void
|
||||||
onDropItem: (itemId: number, invId: number, quantity: number) => void
|
onDropItem: (itemId: number, invId: number, quantity: number) => void
|
||||||
onSpendPoint: (stat: string) => void
|
onSpendPoint: (stat: string) => void
|
||||||
|
onOpenQuestJournal: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayerSidebar({
|
function PlayerSidebar({
|
||||||
@@ -38,7 +39,8 @@ function PlayerSidebar({
|
|||||||
onEquipItem,
|
onEquipItem,
|
||||||
onUnequipItem,
|
onUnequipItem,
|
||||||
onDropItem,
|
onDropItem,
|
||||||
onSpendPoint
|
onSpendPoint,
|
||||||
|
onOpenQuestJournal
|
||||||
}: PlayerSidebarProps) {
|
}: PlayerSidebarProps) {
|
||||||
const [showInventory, setShowInventory] = useState(false)
|
const [showInventory, setShowInventory] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -290,15 +292,27 @@ function PlayerSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<GameButton
|
<div className="sidebar-buttons" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
className="open-inventory-btn"
|
<GameButton
|
||||||
variant="primary"
|
className="open-inventory-btn"
|
||||||
size="lg"
|
variant="primary"
|
||||||
onClick={() => setShowInventory(true)}
|
size="md"
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
onClick={() => setShowInventory(true)}
|
||||||
>
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
{t('game.inventory')}
|
>
|
||||||
</GameButton>
|
{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>
|
||||||
|
|
||||||
<div className="equipment-sidebar">
|
<div className="equipment-sidebar">
|
||||||
|
|||||||
146
pwa/src/components/game/QuestJournal.css
Normal file
146
pwa/src/components/game/QuestJournal.css
Normal 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;
|
||||||
|
}
|
||||||
120
pwa/src/components/game/QuestJournal.tsx
Normal file
120
pwa/src/components/game/QuestJournal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
316
pwa/src/components/game/TradeModal.css
Normal file
316
pwa/src/components/game/TradeModal.css
Normal 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;
|
||||||
|
}
|
||||||
505
pwa/src/components/game/TradeModal.tsx
Normal file
505
pwa/src/components/game/TradeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ export interface GameEngineState {
|
|||||||
profile: Profile | null
|
profile: Profile | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
message: string
|
message: string
|
||||||
|
quests: { active: any[], available: any[] }
|
||||||
|
|
||||||
// Combat state
|
// Combat state
|
||||||
combatState: CombatState | null
|
combatState: CombatState | null
|
||||||
@@ -140,6 +141,10 @@ export interface GameEngineActions {
|
|||||||
addNPCToLocation: (npc: any) => void
|
addNPCToLocation: (npc: any) => void
|
||||||
removeNPCFromLocation: (enemyId: string) => void
|
removeNPCFromLocation: (enemyId: string) => void
|
||||||
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
|
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
|
||||||
|
|
||||||
|
// Quests
|
||||||
|
updateQuests: (active: any[], available: any[]) => void
|
||||||
|
handleQuestUpdate: (quest: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGameEngine(
|
export function useGameEngine(
|
||||||
@@ -164,6 +169,7 @@ export function useGameEngine(
|
|||||||
const [corpseDetails, setCorpseDetails] = useState<any>(null)
|
const [corpseDetails, setCorpseDetails] = useState<any>(null)
|
||||||
const [movementCooldown, setMovementCooldown] = useState<number>(0)
|
const [movementCooldown, setMovementCooldown] = useState<number>(0)
|
||||||
const [failedActionItemId, setFailedActionItemId] = useState<string | number | null>(null)
|
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 [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('') // Moved to Combat.tsx
|
||||||
|
|
||||||
const [equipment, setEquipment] = useState<Equipment>({})
|
const [equipment, setEquipment] = useState<Equipment>({})
|
||||||
@@ -265,15 +271,24 @@ export function useGameEngine(
|
|||||||
|
|
||||||
const fetchGameData = useCallback(async (skipCombatLogInit: boolean = false) => {
|
const fetchGameData = useCallback(async (skipCombatLogInit: boolean = false) => {
|
||||||
try {
|
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/state'),
|
||||||
api.get('/api/game/location'),
|
api.get('/api/game/location'),
|
||||||
api.get('/api/game/profile'),
|
api.get('/api/game/profile'),
|
||||||
api.get('/api/game/combat'),
|
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
|
const gameState = stateRes.data
|
||||||
|
|
||||||
|
// Update quests
|
||||||
|
setQuests({
|
||||||
|
active: activeQuestsRes.data || [],
|
||||||
|
available: availableQuestsRes.data || []
|
||||||
|
})
|
||||||
|
|
||||||
setPlayerState({
|
setPlayerState({
|
||||||
location_id: gameState.player.location_id,
|
location_id: gameState.player.location_id,
|
||||||
location_name: gameState.location?.name || 'Unknown',
|
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
|
// State object
|
||||||
const state: GameEngineState = {
|
const state: GameEngineState = {
|
||||||
playerState,
|
playerState,
|
||||||
@@ -515,6 +570,7 @@ export function useGameEngine(
|
|||||||
profile,
|
profile,
|
||||||
loading,
|
loading,
|
||||||
message,
|
message,
|
||||||
|
quests,
|
||||||
combatState,
|
combatState,
|
||||||
combatLog,
|
combatLog,
|
||||||
enemyName,
|
enemyName,
|
||||||
@@ -778,6 +834,11 @@ export function useGameEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post('/api/game/combat/action', payload)
|
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
|
return response.data
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMessage(error.response?.data?.detail || 'Combat action failed')
|
setMessage(error.response?.data?.detail || 'Combat action failed')
|
||||||
@@ -1151,7 +1212,9 @@ export function useGameEngine(
|
|||||||
return newSet
|
return newSet
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateStatusEffect
|
updateStatusEffect,
|
||||||
|
updateQuests,
|
||||||
|
handleQuestUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polling fallback for PvP Combat reliability
|
// Polling fallback for PvP Combat reliability
|
||||||
|
|||||||
7
pwa/src/config.ts
Normal file
7
pwa/src/config.ts
Normal 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`;
|
||||||
26
pwa/src/contexts/GameContext.tsx
Normal file
26
pwa/src/contexts/GameContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"qty": "Qty",
|
"qty": "Qty",
|
||||||
"enemy": "Enemy",
|
"enemy": "Enemy",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
|
"quests": "Quests",
|
||||||
"all": "All"
|
"all": "All"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"pickUp": "Recoger",
|
"pickUp": "Recoger",
|
||||||
"pickUpAll": "Recoger Todo",
|
"pickUpAll": "Recoger Todo",
|
||||||
"qty": "Cant",
|
"qty": "Cant",
|
||||||
|
"quests": "Misiones",
|
||||||
"all": "Todo"
|
"all": "Todo"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|||||||
Reference in New Issue
Block a user