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)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# ============================================================================
|
||||
# BACKGROUND TASK: MERCHANT RESTOCK
|
||||
# ============================================================================
|
||||
|
||||
async def restock_merchants(manager=None, npcs_data=None):
|
||||
"""Periodically restocks merchant inventory."""
|
||||
logger.info("💰 Merchant Restock task started")
|
||||
|
||||
# If no data provided, we can't restock effectively without doing I/O which we want to avoid.
|
||||
if not npcs_data:
|
||||
logger.warning("⚠️ No NPC data provided to restock task. Merchants will not restock.")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Use injected data
|
||||
static_npcs = npcs_data
|
||||
|
||||
start_time = time.time()
|
||||
restocked_count = 0
|
||||
|
||||
for npc_id, npc_def in static_npcs.items():
|
||||
trade_cfg = npc_def.get('trade', {})
|
||||
if not trade_cfg.get('enabled'):
|
||||
continue
|
||||
|
||||
stock_config = trade_cfg.get('stock', [])
|
||||
|
||||
for item_cfg in stock_config:
|
||||
if item_cfg.get('infinite'):
|
||||
continue
|
||||
|
||||
item_id = item_cfg['item_id']
|
||||
max_stock = item_cfg.get('max_stock', 10)
|
||||
restock_rate = item_cfg.get('restock_rate', 1)
|
||||
|
||||
# Get current stock
|
||||
current_item = await db.get_merchant_stock_item(npc_id, item_id)
|
||||
|
||||
now = time.time()
|
||||
|
||||
if not current_item:
|
||||
# Initialize if missing
|
||||
# If we assume 'restocked' means it should exist.
|
||||
await db.update_merchant_stock(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
quantity=restock_rate,
|
||||
update_restock_time=True
|
||||
)
|
||||
restocked_count += 1
|
||||
continue
|
||||
|
||||
# Check timer (1 hour default)
|
||||
last_restock = current_item.get('last_restock_at', 0)
|
||||
if now - last_restock > 3600: # 1 hour
|
||||
current_qty = current_item['quantity']
|
||||
if current_qty < max_stock:
|
||||
new_qty = min(max_stock, current_qty + restock_rate)
|
||||
|
||||
await db.update_merchant_stock(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
quantity=new_qty,
|
||||
update_restock_time=True
|
||||
)
|
||||
restocked_count += 1
|
||||
|
||||
if restocked_count > 0:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Restocked {restocked_count} items in {elapsed:.2f}s")
|
||||
|
||||
await asyncio.sleep(600) # Check every 10 minutes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in merchant restock task: {e}", exc_info=True)
|
||||
await asyncio.sleep(60)
|
||||
# ============================================================================
|
||||
# TASK STARTUP FUNCTION
|
||||
# ============================================================================
|
||||
@@ -868,7 +944,7 @@ def release_background_tasks_lock():
|
||||
_lock_file_handle = None
|
||||
|
||||
|
||||
async def start_background_tasks(manager=None, world_locations=None):
|
||||
async def start_background_tasks(manager=None, world_locations=None, npcs_data=None):
|
||||
"""
|
||||
Start all background tasks.
|
||||
Called when the API starts up.
|
||||
@@ -877,6 +953,7 @@ async def start_background_tasks(manager=None, world_locations=None):
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting events
|
||||
world_locations: Dict of Location objects for interactable mapping
|
||||
npcs_data: Dict of static NPC definitions
|
||||
"""
|
||||
# Try to acquire lock - only one worker will succeed
|
||||
if not acquire_background_tasks_lock():
|
||||
@@ -894,6 +971,7 @@ async def start_background_tasks(manager=None, world_locations=None):
|
||||
asyncio.create_task(check_pvp_combat_timers(manager)),
|
||||
asyncio.create_task(decay_corpses(manager)),
|
||||
asyncio.create_task(process_status_effects(manager)),
|
||||
asyncio.create_task(restock_merchants(manager, npcs_data)),
|
||||
# Note: Interactable cooldowns are handled client-side with server validation
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
class DatabaseSession:
|
||||
"""Context manager for database sessions"""
|
||||
@@ -357,6 +401,13 @@ async def init_db():
|
||||
|
||||
# Interactable cooldowns - checked on interact attempts
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_instance ON interactable_cooldowns(interactable_instance_id);",
|
||||
|
||||
# Quests
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);",
|
||||
|
||||
# Merchant Stock
|
||||
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
|
||||
]
|
||||
|
||||
for index_sql in indexes:
|
||||
@@ -692,6 +743,246 @@ async def can_create_character(account_id: int) -> tuple[bool, str]:
|
||||
# ========================================================================
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# QUEST OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_character_quests(character_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all quests for a character"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests).where(character_quests.c.character_id == character_id)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
async def get_character_quest(character_id: int, quest_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific quest for a character"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]:
|
||||
"""Accept a new quest or restart a repeatable one"""
|
||||
# Check if exists first to handle restarts
|
||||
existing = await get_character_quest(character_id, quest_id)
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
if existing:
|
||||
# Check if repeatable and cooldown passed
|
||||
# Validation should happen in logic layer, but good to be safe here
|
||||
stmt = update(character_quests).where(
|
||||
character_quests.c.id == existing['id']
|
||||
).values(
|
||||
status="active",
|
||||
progress={},
|
||||
started_at=time.time(),
|
||||
completed_at=None,
|
||||
# Preserve statistics
|
||||
).returning(character_quests)
|
||||
else:
|
||||
stmt = insert(character_quests).values(
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
status="active",
|
||||
progress={},
|
||||
started_at=time.time(),
|
||||
times_completed=0
|
||||
).returning(character_quests)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
await session.commit()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: Optional[str] = None) -> bool:
|
||||
"""Update quest progress"""
|
||||
values = {"progress": progress}
|
||||
if status:
|
||||
values["status"] = status
|
||||
if status == "completed":
|
||||
values["completed_at"] = time.time()
|
||||
values["last_completed_at"] = time.time()
|
||||
# Increment times_completed
|
||||
# We need to read first or use a raw update expression,
|
||||
# simplest is to just increment in python for now or assume caller logic handles it
|
||||
# But let's do it right:
|
||||
# We can't easily do col + 1 in a simple update call without pulling in Table object to the values
|
||||
# So we'll rely on a fetch-update pattern or standard SQL
|
||||
pass
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
# If completing, increment counter
|
||||
if status == "completed":
|
||||
# We can use the column expression for atomic increment
|
||||
values["times_completed"] = character_quests.c.times_completed + 1
|
||||
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(**values)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def set_quest_cooldown(character_id: int, quest_id: str, cooldown_expires_at: float) -> bool:
|
||||
"""Set cooldown for a quest"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(cooldown_expires_at=cooldown_expires_at)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# Global Quests
|
||||
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get global quest progress"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(global_quests).where(global_quests.c.quest_id == quest_id)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_global_quest(quest_id: str, progress: Dict, is_completed: bool = False) -> bool:
|
||||
"""Update or create global quest progress"""
|
||||
# Upsert logic
|
||||
existing = await get_global_quest(quest_id)
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
if existing:
|
||||
stmt = update(global_quests).where(
|
||||
global_quests.c.quest_id == quest_id
|
||||
).values(
|
||||
global_progress=progress,
|
||||
is_completed=is_completed,
|
||||
updated_at=time.time()
|
||||
)
|
||||
else:
|
||||
stmt = insert(global_quests).values(
|
||||
quest_id=quest_id,
|
||||
global_progress=progress,
|
||||
is_completed=is_completed,
|
||||
updated_at=time.time()
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# MERCHANT OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_merchant_stock(npc_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get stock for a merchant"""
|
||||
async with DatabaseSession() as session:
|
||||
# Join with unique_items to get stats if applicable
|
||||
# This is a bit complex, let's just get the stock and helper can resolve details
|
||||
result = await session.execute(
|
||||
select(merchant_stock).where(merchant_stock.c.npc_id == npc_id)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
async def update_merchant_stock(npc_id: str, item_id: str, quantity: int, unique_item_id: Optional[int] = None, update_restock_time: bool = False) -> bool:
|
||||
"""
|
||||
Update merchant stock quantity.
|
||||
If unique_item_id is provided, it targets that specific instance.
|
||||
If quantity <= 0, remove the row.
|
||||
If update_restock_time is True, updates last_restock_at to now.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Check if exists
|
||||
conditions = [
|
||||
merchant_stock.c.npc_id == npc_id,
|
||||
merchant_stock.c.item_id == item_id
|
||||
]
|
||||
if unique_item_id is not None:
|
||||
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
|
||||
else:
|
||||
conditions.append(merchant_stock.c.unique_item_id.is_(None))
|
||||
|
||||
stmt = select(merchant_stock).where(and_(*conditions))
|
||||
result = await session.execute(stmt)
|
||||
existing = result.first()
|
||||
|
||||
if quantity <= 0:
|
||||
if existing:
|
||||
await session.execute(delete(merchant_stock).where(merchant_stock.c.id == existing.id))
|
||||
else:
|
||||
if existing:
|
||||
values = {"quantity": quantity}
|
||||
if update_restock_time:
|
||||
values["last_restock_at"] = time.time()
|
||||
|
||||
await session.execute(
|
||||
update(merchant_stock)
|
||||
.where(merchant_stock.c.id == existing.id)
|
||||
.values(**values)
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
insert(merchant_stock).values(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
unique_item_id=unique_item_id,
|
||||
quantity=quantity,
|
||||
last_restock_at=time.time()
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_merchant_stock_item(npc_id: str, item_id: str, unique_item_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific item from merchant stock"""
|
||||
async with DatabaseSession() as session:
|
||||
conditions = [
|
||||
merchant_stock.c.npc_id == npc_id,
|
||||
merchant_stock.c.item_id == item_id
|
||||
]
|
||||
if unique_item_id is not None:
|
||||
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
|
||||
else:
|
||||
conditions.append(merchant_stock.c.unique_item_id.is_(None))
|
||||
|
||||
result = await session.execute(select(merchant_stock).where(and_(*conditions)))
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def get_all_merchants() -> List[str]:
|
||||
"""Get list of all NPC IDs that have stock"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(select(merchant_stock.c.npc_id).distinct())
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
|
||||
|
||||
# Inventory operations
|
||||
# NOTE: Functions below use 'player_id' parameter name for backward compatibility
|
||||
|
||||
@@ -24,6 +24,7 @@ class Item:
|
||||
volume: float = 0.0
|
||||
stats: Dict[str, int] = None
|
||||
effects: Dict[str, Any] = None
|
||||
value: int = 10 # Base value for trading
|
||||
# Equipment system
|
||||
slot: str = None # Equipment slot: head, torso, legs, feet, weapon, offhand, backpack
|
||||
durability: int = None # Max durability for equippable items
|
||||
@@ -109,6 +110,7 @@ class ItemsManager:
|
||||
name=item_data.get('name', 'Unknown Item'),
|
||||
description=item_data.get('description', ''),
|
||||
type=item_type,
|
||||
value=item_data.get('value', 10),
|
||||
image_path=item_data.get('image_path', ''),
|
||||
emoji=item_data.get('emoji', '📦'),
|
||||
stackable=item_data.get('stackable', True),
|
||||
|
||||
42
api/main.py
42
api/main.py
@@ -33,7 +33,11 @@ from .routers import (
|
||||
crafting,
|
||||
loot,
|
||||
statistics,
|
||||
admin
|
||||
statistics,
|
||||
admin,
|
||||
quests,
|
||||
trade,
|
||||
npcs
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
@@ -79,7 +83,7 @@ async def lifespan(app: FastAPI):
|
||||
print("✅ Redis listener started")
|
||||
|
||||
# Start background tasks (distributed via Redis locks)
|
||||
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS)
|
||||
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS, NPCS_DATA)
|
||||
if tasks:
|
||||
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
||||
else:
|
||||
@@ -123,14 +127,43 @@ if IMAGES_DIR.exists():
|
||||
else:
|
||||
print(f"⚠️ Images directory not found: {IMAGES_DIR}")
|
||||
|
||||
# Initialize routers with game data dependencies
|
||||
# Load Quests and NPCs Data at startup
|
||||
QUESTS_DATA = {}
|
||||
NPCS_DATA = {}
|
||||
|
||||
try:
|
||||
print("🔄 Loading quests and NPCs...")
|
||||
quests_path = Path("./gamedata/quests.json")
|
||||
npcs_path = Path("./gamedata/static_npcs.json")
|
||||
|
||||
import json
|
||||
if quests_path.exists():
|
||||
with open(quests_path, "r") as f:
|
||||
q_data = json.load(f)
|
||||
QUESTS_DATA = q_data.get("quests", {})
|
||||
print(f"✅ Loaded {len(QUESTS_DATA)} quests")
|
||||
|
||||
if npcs_path.exists():
|
||||
with open(npcs_path, "r") as f:
|
||||
n_data = json.load(f)
|
||||
NPCS_DATA = n_data.get("static_npcs", {})
|
||||
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading game data: {e}")
|
||||
|
||||
# Initialize routers with game data dependencies
|
||||
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
|
||||
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
||||
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA)
|
||||
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
|
||||
npcs.init_router_dependencies()
|
||||
|
||||
# Include all routers
|
||||
app.include_router(auth.router)
|
||||
@@ -142,6 +175,9 @@ app.include_router(crafting.router)
|
||||
app.include_router(loot.router)
|
||||
app.include_router(statistics.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(quests.router)
|
||||
app.include_router(trade.router)
|
||||
app.include_router(npcs.router)
|
||||
|
||||
print("✅ All routers registered")
|
||||
|
||||
|
||||
@@ -27,14 +27,17 @@ LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
redis_manager = None
|
||||
QUESTS_DATA = {}
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None, quests_data=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
if quests_data:
|
||||
QUESTS_DATA = quests_data
|
||||
|
||||
router = APIRouter(tags=["combat"])
|
||||
|
||||
@@ -433,6 +436,128 @@ async def combat_action(
|
||||
location_id=player['location_id'],
|
||||
loot_remaining=json.dumps(corpse_loot_dicts)
|
||||
)
|
||||
|
||||
# --- UPDATE QUEST PROGRESS ---
|
||||
# --- UPDATE QUEST PROGRESS ---
|
||||
try:
|
||||
# Use global QUESTS_DATA injected dependency
|
||||
if QUESTS_DATA:
|
||||
active_quests = await db.get_character_quests(player['id'])
|
||||
quest_updated = False
|
||||
|
||||
for q_record in active_quests:
|
||||
if q_record['status'] != 'active':
|
||||
continue
|
||||
|
||||
q_def = QUESTS_DATA.get(q_record['quest_id'])
|
||||
if not q_def: continue
|
||||
|
||||
objectives = q_def.get('objectives', [])
|
||||
current_progress = q_record.get('progress') or {}
|
||||
new_progress = current_progress.copy()
|
||||
progres_changed = False
|
||||
|
||||
for obj in objectives:
|
||||
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
||||
current_count = current_progress.get(obj['target'], 0)
|
||||
if current_count < obj['count']:
|
||||
new_progress[obj['target']] = current_count + 1
|
||||
progres_changed = True
|
||||
|
||||
if progres_changed:
|
||||
# Check completion
|
||||
all_done = True
|
||||
for obj in objectives:
|
||||
target = obj['target']
|
||||
req_count = obj['count']
|
||||
curr = new_progress.get(target, 0)
|
||||
|
||||
# Simple check (ignoring items for kill quests for now)
|
||||
if obj['type'] == 'kill_count':
|
||||
if curr < req_count:
|
||||
all_done = False
|
||||
elif obj['type'] == 'item_delivery':
|
||||
# For mixed quests, we can't complete purely on kills.
|
||||
pass
|
||||
|
||||
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
||||
|
||||
# Notify user
|
||||
messages.append(create_combat_message(
|
||||
"quest_update",
|
||||
origin="system",
|
||||
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
||||
))
|
||||
quest_updated = True
|
||||
|
||||
# Add to quest updates list to return to client
|
||||
# Filter/Enrich for frontend
|
||||
updated_q_data = dict(q_record)
|
||||
updated_q_data['start_at'] = q_record['started_at']
|
||||
updated_q_data.update(q_def)
|
||||
if 'quest_updates' not in locals(): quest_updates = []
|
||||
quest_updates.append(updated_q_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update quest progress: {e}")
|
||||
# -----------------------------
|
||||
|
||||
for q_record in active_quests:
|
||||
if q_record['status'] != 'active':
|
||||
continue
|
||||
|
||||
q_def = all_quests_def.get(q_record['quest_id'])
|
||||
if not q_def: continue
|
||||
|
||||
objectives = q_def.get('objectives', [])
|
||||
current_progress = q_record.get('progress') or {}
|
||||
new_progress = current_progress.copy()
|
||||
progres_changed = False
|
||||
|
||||
for obj in objectives:
|
||||
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
||||
current_count = current_progress.get(obj['target'], 0)
|
||||
if current_count < obj['count']:
|
||||
new_progress[obj['target']] = current_count + 1
|
||||
progres_changed = True
|
||||
|
||||
if progres_changed:
|
||||
# Check completion
|
||||
all_done = True
|
||||
for obj in objectives:
|
||||
target = obj['target']
|
||||
req_count = obj['count']
|
||||
curr = new_progress.get(target, 0)
|
||||
|
||||
# Simple check for now (ignoring items for kill quests)
|
||||
if obj['type'] == 'kill_count':
|
||||
if curr < req_count:
|
||||
all_done = False
|
||||
elif obj['type'] == 'item_delivery':
|
||||
# Items are checked at hand-in usually
|
||||
# But we need to know if we should mark as completed "ready to turn in" or just "objectives met"
|
||||
# For mixed quests, we can't complete purely on kills.
|
||||
# Let's just update progress.
|
||||
pass
|
||||
|
||||
# We generally don't auto-complete quests, user has to hand in.
|
||||
# But we can update the progress in DB.
|
||||
new_status = 'active'
|
||||
# If we wanted to support auto-complete, we'd do it here. Use 'active'.
|
||||
|
||||
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, new_status)
|
||||
|
||||
# Notify user
|
||||
messages.append(create_combat_message(
|
||||
"quest_update",
|
||||
origin="system",
|
||||
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
||||
))
|
||||
quest_updated = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update quest progress: {e}")
|
||||
# -----------------------------
|
||||
|
||||
|
||||
|
||||
@@ -927,7 +1052,8 @@ async def combat_action(
|
||||
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
|
||||
"xp": updated_player['xp'],
|
||||
"level": updated_player['level']
|
||||
}
|
||||
},
|
||||
"quest_updates": quest_updates if 'quest_updates' in locals() else []
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,11 +28,52 @@ redis_manager = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
print("🔧 INITIALIZING GAME ROUTE DEPENDENCIES")
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
|
||||
print(f"🔧 Locations keys: {list(LOCATIONS.keys())}")
|
||||
|
||||
# Load separate static NPCs
|
||||
from pathlib import Path
|
||||
try:
|
||||
# Use relative path consistent with Docker WORKDIR /app
|
||||
json_path = Path("./gamedata/static_npcs.json")
|
||||
with open(json_path, "r") as f:
|
||||
npc_data = json.load(f).get("static_npcs", {})
|
||||
print(f"🔧 Loaded static NPCs data keys: {list(npc_data.keys())}")
|
||||
|
||||
for npc_id, npc_def in npc_data.items():
|
||||
loc_id = npc_def.get("location_id")
|
||||
if loc_id and loc_id in LOCATIONS:
|
||||
# Check for duplication
|
||||
location = LOCATIONS[loc_id]
|
||||
existing = False
|
||||
for existing_npc in location.npcs:
|
||||
if isinstance(existing_npc, dict) and existing_npc.get("id") == npc_id:
|
||||
existing = True
|
||||
break
|
||||
|
||||
if not existing:
|
||||
# Inject
|
||||
location.npcs.append({
|
||||
"id": npc_id,
|
||||
"name": npc_def.get("name"), # Keep as dict/string, frontend handles localization
|
||||
"type": "npc",
|
||||
"level": 1,
|
||||
"image_path": npc_def.get("image"),
|
||||
"is_static": True,
|
||||
"trade": npc_def.get("trade", {}) # Setup trade config for frontend checks
|
||||
})
|
||||
print(f"✅ Injected static NPC {npc_id} into {loc_id}")
|
||||
else:
|
||||
print(f"⚠️ Could not inject NPC {npc_id}: Location {loc_id} not found")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to inject static NPCs: {e}")
|
||||
|
||||
router = APIRouter(tags=["game"])
|
||||
|
||||
@@ -163,6 +204,7 @@ async def _get_enriched_inventory(player_id: int):
|
||||
"damage_max": item.stats.get('damage_max') if item.stats else None,
|
||||
"stats": item.stats,
|
||||
# Workbench flags
|
||||
"value": getattr(item, 'value', 10),
|
||||
"is_repairable": is_repairable,
|
||||
"is_salvageable": is_salvageable,
|
||||
"current_durability": current_durability,
|
||||
@@ -239,8 +281,42 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
if slot not in equipment:
|
||||
equipment[slot] = None
|
||||
|
||||
# Get combat state
|
||||
# Get active combat (PvE)
|
||||
combat = await db.get_active_combat(player_id)
|
||||
pvp_combat = None
|
||||
|
||||
# If no PvE combat, check for PvP combat
|
||||
if not combat:
|
||||
pvp_combat = await db.get_pvp_combat_by_player(player_id)
|
||||
if pvp_combat:
|
||||
# Format PvP combat to match frontend expectations or pass as dedicated field
|
||||
# Ideally, we pass it as 'pvp_combat' in the response and let frontend handle it,
|
||||
# OR we standardize the 'combat' field. Game.tsx seems to handle both.
|
||||
# But let's check Game.tsx or Combat.tsx props.
|
||||
# Combat.tsx expects: initialCombatData which has { combat: ..., pvp_combat: ..., is_pvp: bool }
|
||||
# If we return it in the main dict, Game.tsx passes the whole response to Combat.
|
||||
|
||||
# Enrich PvP combat with opponent data for the API response
|
||||
is_attacker = pvp_combat['attacker_character_id'] == player_id
|
||||
opponent_id = pvp_combat['defender_character_id'] if is_attacker else pvp_combat['attacker_character_id']
|
||||
opponent = await db.get_player_by_id(opponent_id)
|
||||
|
||||
if is_attacker:
|
||||
pvp_combat['attacker'] = player
|
||||
pvp_combat['defender'] = opponent
|
||||
pvp_combat['is_attacker'] = True
|
||||
else:
|
||||
pvp_combat['attacker'] = opponent
|
||||
pvp_combat['defender'] = player
|
||||
pvp_combat['is_attacker'] = False
|
||||
|
||||
# Determine if it's "combat_over" based on fled status or HP
|
||||
# This helps the frontend break out of the loop
|
||||
if pvp_combat.get('attacker_fled') or pvp_combat.get('defender_fled') or \
|
||||
pvp_combat.get('attacker_acknowledged') and pvp_combat.get('defender_acknowledged'): # Wait, if both ack, it's deleted.
|
||||
# If just fled, it's over but waiting for ack
|
||||
pass
|
||||
|
||||
if combat:
|
||||
# Ensure intent is present (handle legacy)
|
||||
if 'npc_intent' not in combat or not combat['npc_intent']:
|
||||
@@ -319,6 +395,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"inventory": inventory,
|
||||
"equipment": equipment,
|
||||
"combat": combat,
|
||||
"pvp_combat": pvp_combat,
|
||||
"is_pvp": pvp_combat is not None,
|
||||
"dropped_items": dropped_items
|
||||
}
|
||||
|
||||
@@ -529,8 +607,12 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
||||
"name": npc.get('name', 'Unknown NPC'),
|
||||
"type": npc.get('type', 'npc'),
|
||||
"level": npc.get('level'),
|
||||
"is_wandering": False
|
||||
"is_wandering": False,
|
||||
"image_path": npc.get('image_path'),
|
||||
"is_static": npc.get('is_static', False),
|
||||
"trade": npc.get('trade')
|
||||
})
|
||||
|
||||
else:
|
||||
npcs_data.append({
|
||||
"id": npc,
|
||||
@@ -539,6 +621,9 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
||||
"is_wandering": False
|
||||
})
|
||||
|
||||
# Debug logging for missing NPCs - UNCONDITIONAL
|
||||
logger.info(f"📍 Requested Location: {location.id}, NPCs: {[n.get('id') for n in npcs_data]}")
|
||||
|
||||
# Enrich dropped items with metadata - DON'T consolidate unique items!
|
||||
items_dict = {}
|
||||
for item in dropped_items:
|
||||
@@ -1053,7 +1138,7 @@ async def interact(
|
||||
"instance_id": interact_req.interactable_id,
|
||||
"action_id": interact_req.action_id,
|
||||
"cooldown_remaining": cooldown_remaining,
|
||||
"message": get_game_message('interactable_cooldown', locale, user=current_user, interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
|
||||
"message": get_game_message('interactable_cooldown', locale, user=current_user['name'], interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ async def get_corpse_details(
|
||||
'index': idx,
|
||||
'item_id': loot_item['item_id'],
|
||||
'item_name': item_def.name if item_def else loot_item['item_id'],
|
||||
'description': item_def.description if item_def else None,
|
||||
'image_path': item_def.image_path if item_def else None,
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': loot_item['quantity_min'],
|
||||
'quantity_max': loot_item['quantity_max'],
|
||||
@@ -129,6 +131,8 @@ async def get_corpse_details(
|
||||
'index': idx,
|
||||
'item_id': item['item_id'],
|
||||
'item_name': item_def.name if item_def else item['item_id'],
|
||||
'description': item_def.description if item_def else None,
|
||||
'image_path': item_def.image_path if item_def else None,
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': item['quantity'],
|
||||
'quantity_max': item['quantity'],
|
||||
|
||||
54
api/routers/npcs.py
Normal file
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:
|
||||
- ./gamedata:/app/gamedata:ro
|
||||
- ./images:/app/images:ro
|
||||
- ./api:/app/api:rw
|
||||
- ./data:/app/data:rw
|
||||
depends_on:
|
||||
- echoes_of_the_ashes_db
|
||||
- echoes_of_the_ashes_redis
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"rusty_nails": {
|
||||
"name": {
|
||||
@@ -28,7 +29,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"wood_planks": {
|
||||
"name": {
|
||||
@@ -43,7 +45,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"cloth_scraps": {
|
||||
"name": {
|
||||
@@ -58,7 +61,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"cloth": {
|
||||
"name": {
|
||||
@@ -86,7 +90,8 @@
|
||||
"item_id": "knife",
|
||||
"durability_cost": 1
|
||||
}
|
||||
]
|
||||
],
|
||||
"value": 10
|
||||
},
|
||||
"plastic_bottles": {
|
||||
"name": {
|
||||
@@ -101,7 +106,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"bone": {
|
||||
"name": {
|
||||
@@ -116,7 +122,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"raw_meat": {
|
||||
"name": {
|
||||
@@ -131,7 +138,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"animal_hide": {
|
||||
"name": {
|
||||
@@ -146,7 +154,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"mutant_tissue": {
|
||||
"name": {
|
||||
@@ -161,7 +170,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"infected_tissue": {
|
||||
"name": {
|
||||
@@ -176,7 +186,8 @@
|
||||
"description": {
|
||||
"en": "A raw material used for crafting and upgrades.",
|
||||
"es": "Un material bruto utilizado para la fabricación y las mejoras."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"stale_chocolate_bar": {
|
||||
"name": {
|
||||
@@ -192,7 +203,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"canned_beans": {
|
||||
"name": {
|
||||
@@ -209,7 +221,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"canned_food": {
|
||||
"name": {
|
||||
@@ -226,7 +239,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"bottled_water": {
|
||||
"name": {
|
||||
@@ -242,7 +256,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"water_bottle": {
|
||||
"name": {
|
||||
@@ -258,7 +273,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"energy_bar": {
|
||||
"name": {
|
||||
@@ -274,7 +290,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"mystery_pills": {
|
||||
"name": {
|
||||
@@ -290,7 +307,8 @@
|
||||
"description": {
|
||||
"en": "Can be consumed to restore health or stamina.",
|
||||
"es": "Se puede consumir para restaurar salud o stamina."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"first_aid_kit": {
|
||||
"name": {
|
||||
@@ -306,7 +324,8 @@
|
||||
"type": "consumable",
|
||||
"hp_restore": 50,
|
||||
"emoji": "🩹",
|
||||
"image_path": "images/items/first_aid_kit.webp"
|
||||
"image_path": "images/items/first_aid_kit.webp",
|
||||
"value": 10
|
||||
},
|
||||
"bandage": {
|
||||
"name": {
|
||||
@@ -334,7 +353,8 @@
|
||||
]
|
||||
},
|
||||
"emoji": "🩹",
|
||||
"image_path": "images/items/bandage.webp"
|
||||
"image_path": "images/items/bandage.webp",
|
||||
"value": 10
|
||||
},
|
||||
"medical_supplies": {
|
||||
"name": {
|
||||
@@ -350,7 +370,8 @@
|
||||
"type": "consumable",
|
||||
"hp_restore": 40,
|
||||
"emoji": "⚕️",
|
||||
"image_path": "images/items/medical_supplies.webp"
|
||||
"image_path": "images/items/medical_supplies.webp",
|
||||
"value": 10
|
||||
},
|
||||
"antibiotics": {
|
||||
"name": {
|
||||
@@ -367,7 +388,8 @@
|
||||
"hp_restore": 20,
|
||||
"treats": "Infected",
|
||||
"emoji": "💊",
|
||||
"image_path": "images/items/antibiotics.webp"
|
||||
"image_path": "images/items/antibiotics.webp",
|
||||
"value": 10
|
||||
},
|
||||
"rad_pills": {
|
||||
"name": {
|
||||
@@ -384,7 +406,8 @@
|
||||
"hp_restore": 5,
|
||||
"treats": "Radiation",
|
||||
"emoji": "☢️",
|
||||
"image_path": "images/items/rad_pills.webp"
|
||||
"image_path": "images/items/rad_pills.webp",
|
||||
"value": 10
|
||||
},
|
||||
"tire_iron": {
|
||||
"name": {
|
||||
@@ -408,7 +431,8 @@
|
||||
"damage_max": 5
|
||||
},
|
||||
"emoji": "🔧",
|
||||
"image_path": "images/items/tire_iron.webp"
|
||||
"image_path": "images/items/tire_iron.webp",
|
||||
"value": 10
|
||||
},
|
||||
"baseball_bat": {
|
||||
"name": {
|
||||
@@ -428,7 +452,8 @@
|
||||
"stats": {
|
||||
"damage_min": 5,
|
||||
"damage_max": 8
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"rusty_knife": {
|
||||
"name": {
|
||||
@@ -464,7 +489,8 @@
|
||||
"damage_max": 5
|
||||
},
|
||||
"emoji": "🔪",
|
||||
"image_path": "images/items/rusty_knife.webp"
|
||||
"image_path": "images/items/rusty_knife.webp",
|
||||
"value": 10
|
||||
},
|
||||
"knife": {
|
||||
"name": {
|
||||
@@ -553,7 +579,8 @@
|
||||
}
|
||||
},
|
||||
"emoji": "🔪",
|
||||
"image_path": "images/items/knife.webp"
|
||||
"image_path": "images/items/knife.webp",
|
||||
"value": 10
|
||||
},
|
||||
"rusty_pipe": {
|
||||
"name": {
|
||||
@@ -573,7 +600,8 @@
|
||||
"stats": {
|
||||
"damage_min": 5,
|
||||
"damage_max": 8
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"tattered_rucksack": {
|
||||
"name": {
|
||||
@@ -620,7 +648,8 @@
|
||||
"volume_capacity": 10
|
||||
},
|
||||
"emoji": "🎒",
|
||||
"image_path": "images/items/tattered_rucksack.webp"
|
||||
"image_path": "images/items/tattered_rucksack.webp",
|
||||
"value": 10
|
||||
},
|
||||
"hiking_backpack": {
|
||||
"name": {
|
||||
@@ -656,7 +685,8 @@
|
||||
"volume_capacity": 20
|
||||
},
|
||||
"emoji": "🎒",
|
||||
"image_path": "images/items/hiking_backpack.webp"
|
||||
"image_path": "images/items/hiking_backpack.webp",
|
||||
"value": 10
|
||||
},
|
||||
"flashlight": {
|
||||
"name": {
|
||||
@@ -676,7 +706,8 @@
|
||||
"stats": {
|
||||
"damage_min": 5,
|
||||
"damage_max": 8
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"old_photograph": {
|
||||
"name": {
|
||||
@@ -691,7 +722,8 @@
|
||||
"description": {
|
||||
"en": "A useful old photograph.",
|
||||
"es": "Una fotografía vieja útil."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"key_ring": {
|
||||
"name": {
|
||||
@@ -706,7 +738,8 @@
|
||||
"description": {
|
||||
"en": "A useful key ring.",
|
||||
"es": "Un anillo de llaves útil."
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"makeshift_spear": {
|
||||
"name": {
|
||||
@@ -757,7 +790,8 @@
|
||||
"damage_max": 7
|
||||
},
|
||||
"emoji": "⚔️",
|
||||
"image_path": "images/items/makeshift_spear.webp"
|
||||
"image_path": "images/items/makeshift_spear.webp",
|
||||
"value": 10
|
||||
},
|
||||
"reinforced_bat": {
|
||||
"name": {
|
||||
@@ -814,7 +848,8 @@
|
||||
}
|
||||
},
|
||||
"emoji": "🏸",
|
||||
"image_path": "images/items/reinforced_bat.webp"
|
||||
"image_path": "images/items/reinforced_bat.webp",
|
||||
"value": 10
|
||||
},
|
||||
"leather_vest": {
|
||||
"name": {
|
||||
@@ -865,7 +900,8 @@
|
||||
"hp_bonus": 10
|
||||
},
|
||||
"emoji": "🦺",
|
||||
"image_path": "images/items/leather_vest.webp"
|
||||
"image_path": "images/items/leather_vest.webp",
|
||||
"value": 10
|
||||
},
|
||||
"cloth_bandana": {
|
||||
"name": {
|
||||
@@ -903,7 +939,8 @@
|
||||
"armor": 1
|
||||
},
|
||||
"emoji": "🧣",
|
||||
"image_path": "images/items/cloth_bandana.webp"
|
||||
"image_path": "images/items/cloth_bandana.webp",
|
||||
"value": 10
|
||||
},
|
||||
"sturdy_boots": {
|
||||
"name": {
|
||||
@@ -954,7 +991,8 @@
|
||||
"stamina_bonus": 5
|
||||
},
|
||||
"emoji": "🥾",
|
||||
"image_path": "images/items/sturdy_boots.webp"
|
||||
"image_path": "images/items/sturdy_boots.webp",
|
||||
"value": 10
|
||||
},
|
||||
"padded_pants": {
|
||||
"name": {
|
||||
@@ -1001,7 +1039,8 @@
|
||||
"hp_bonus": 5
|
||||
},
|
||||
"emoji": "👖",
|
||||
"image_path": "images/items/padded_pants.webp"
|
||||
"image_path": "images/items/padded_pants.webp",
|
||||
"value": 10
|
||||
},
|
||||
"reinforced_pack": {
|
||||
"name": {
|
||||
@@ -1091,7 +1130,8 @@
|
||||
"volume_capacity": 30
|
||||
},
|
||||
"emoji": "🎒",
|
||||
"image_path": "images/items/reinforced_pack.webp"
|
||||
"image_path": "images/items/reinforced_pack.webp",
|
||||
"value": 10
|
||||
},
|
||||
"hammer": {
|
||||
"name": {
|
||||
@@ -1130,7 +1170,8 @@
|
||||
],
|
||||
"repair_percentage": 30,
|
||||
"emoji": "🔨",
|
||||
"image_path": "images/items/hammer.webp"
|
||||
"image_path": "images/items/hammer.webp",
|
||||
"value": 10
|
||||
},
|
||||
"screwdriver": {
|
||||
"name": {
|
||||
@@ -1173,7 +1214,8 @@
|
||||
"stats": {
|
||||
"damage_min": 5,
|
||||
"damage_max": 8
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"pipe_bomb": {
|
||||
"name": {
|
||||
@@ -1194,7 +1236,8 @@
|
||||
"combat_effects": {
|
||||
"damage_min": 15,
|
||||
"damage_max": 25
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"molotov_cocktail": {
|
||||
"name": {
|
||||
@@ -1222,7 +1265,8 @@
|
||||
"ticks": 3,
|
||||
"persist_after_combat": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"smoke_bomb": {
|
||||
"name": {
|
||||
@@ -1249,7 +1293,8 @@
|
||||
"ticks": 1,
|
||||
"persist_after_combat": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
},
|
||||
"stim_pack": {
|
||||
"name": {
|
||||
@@ -1269,7 +1314,8 @@
|
||||
"consumable": true,
|
||||
"combat_usable": true,
|
||||
"combat_only": true,
|
||||
"hp_restore": 20
|
||||
"hp_restore": 20,
|
||||
"value": 10
|
||||
},
|
||||
"adrenaline_shot": {
|
||||
"name": {
|
||||
@@ -1297,7 +1343,8 @@
|
||||
"ticks": 2,
|
||||
"persist_after_combat": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"value": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
68
gamedata/quests.json
Normal file
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="."
|
||||
OUTPUT_DIR="../images"
|
||||
ITEM_SIZE="256x256"
|
||||
PORTRAIT_SIZE="512x512"
|
||||
|
||||
echo "🔄 Starting image conversion..."
|
||||
echo " Source: $SOURCE_DIR"
|
||||
echo " Output: $OUTPUT_DIR"
|
||||
echo ""
|
||||
|
||||
for category in items locations npcs interactables characters placeholder; do
|
||||
for category in items locations npcs interactables characters placeholder static_npcs; do
|
||||
src="$SOURCE_DIR/$category"
|
||||
out="$OUTPUT_DIR/$category"
|
||||
|
||||
@@ -38,11 +39,15 @@ for category in items locations npcs interactables characters placeholder; do
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$category" == "items" || "$category" == "placeholder" ]]; then
|
||||
if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" ]]; then
|
||||
# Special processing for items: remove white background and resize
|
||||
echo " ➜ Converting item: $filename"
|
||||
tmp="/tmp/${base}_clean.png"
|
||||
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
|
||||
if [[ "$category" == "static_npcs" ]]; then
|
||||
convert "$img" -fuzz 10% -transparent white -resize "$PORTRAIT_SIZE" "$tmp"
|
||||
else
|
||||
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
|
||||
fi
|
||||
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
|
||||
rm "$tmp"
|
||||
else
|
||||
|
||||
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 MovementControls from './game/MovementControls'
|
||||
import PlayerSidebar from './game/PlayerSidebar'
|
||||
|
||||
import { GameProvider } from '../contexts/GameContext'
|
||||
import { QuestJournal } from './game/QuestJournal'
|
||||
import './Game.css'
|
||||
|
||||
function Game() {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const [token] = useState(() => localStorage.getItem('token'))
|
||||
const [showQuestJournal, setShowQuestJournal] = useState(false)
|
||||
|
||||
// Handle WebSocket messages
|
||||
const handleWebSocketMessage = async (message: any) => {
|
||||
@@ -323,6 +328,17 @@ function Game() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Create context value
|
||||
const gameContextValue = {
|
||||
token,
|
||||
locale: i18n.language,
|
||||
inventory: state.playerState?.inventory || [],
|
||||
state,
|
||||
actions
|
||||
}
|
||||
|
||||
// No location loaded yet
|
||||
if (!state.location) {
|
||||
return (
|
||||
@@ -333,213 +349,233 @@ function Game() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-container">
|
||||
{/* Game Header is now in GameLayout */}
|
||||
<GameProvider value={gameContextValue}>
|
||||
<div className="game-container">
|
||||
{/* Game Header is now in GameLayout */}
|
||||
|
||||
{/* Mobile Header Toggle - only show in main view */}
|
||||
{state.mobileMenuOpen === 'none' && (
|
||||
<button
|
||||
className="mobile-header-toggle"
|
||||
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
|
||||
>
|
||||
{state.mobileHeaderOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
)}
|
||||
{/* Quest Journal Toggle Button - Add to header or float?
|
||||
Let's add it floating for now or in the top right.
|
||||
*/}
|
||||
|
||||
{/* Main game area */}
|
||||
<div className="game-main">
|
||||
<div className="explore-tab-desktop">
|
||||
{/* Left Sidebar: Movement & Surroundings */}
|
||||
<div className={`left-sidebar mobile-menu-panel ${state.mobileMenuOpen === 'left' ? 'open' : ''}`}>
|
||||
{state.location && state.profile && (
|
||||
<MovementControls
|
||||
location={state.location}
|
||||
profile={state.profile}
|
||||
combatState={state.combatState}
|
||||
movementCooldown={state.movementCooldown}
|
||||
interactableCooldowns={state.interactableCooldowns}
|
||||
onMove={actions.handleMove}
|
||||
onInteract={actions.handleInteract}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Location view or Combat */}
|
||||
<div className="center-area">
|
||||
{/* Combat view (when in combat) */}
|
||||
{state.combatState && state.playerState && (
|
||||
<Combat
|
||||
combatState={state.combatState}
|
||||
combatLog={state.combatLog}
|
||||
profile={state.profile}
|
||||
{/* Mobile Header Toggle - only show in main view */}
|
||||
{state.mobileMenuOpen === 'none' && (
|
||||
<button
|
||||
className="mobile-header-toggle"
|
||||
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
|
||||
>
|
||||
{state.mobileHeaderOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main game area */}
|
||||
<div className="game-main">
|
||||
<div className="explore-tab-desktop">
|
||||
{/* Left Sidebar: Movement & Surroundings */}
|
||||
<div className={`left-sidebar mobile-menu-panel ${state.mobileMenuOpen === 'left' ? 'open' : ''}`}>
|
||||
{state.location && state.profile && (
|
||||
<MovementControls
|
||||
location={state.location}
|
||||
profile={state.profile}
|
||||
combatState={state.combatState}
|
||||
movementCooldown={state.movementCooldown}
|
||||
interactableCooldowns={state.interactableCooldowns}
|
||||
onMove={actions.handleMove}
|
||||
onInteract={actions.handleInteract}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Location view or Combat */}
|
||||
<div className="center-area">
|
||||
{/* Combat view (when in combat) */}
|
||||
{state.combatState && state.playerState && (
|
||||
<Combat
|
||||
combatState={state.combatState}
|
||||
combatLog={state.combatLog}
|
||||
profile={state.profile}
|
||||
playerState={state.playerState}
|
||||
equipment={state.equipment}
|
||||
onCombatAction={actions.handleCombatAction}
|
||||
onPvPAction={async (action: string) => {
|
||||
try {
|
||||
const response = await api.post('/api/game/pvp/action', { action })
|
||||
actions.setMessage(response.data.message || 'Action performed!')
|
||||
// We don't need to fetchGameData here because the websocket update will handle it?
|
||||
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
|
||||
// So we should probably update state from response if possible, OR fetch.
|
||||
// Let's return the data so Combat.tsx can use it for animations.
|
||||
// And let's fetchGameData to be safe, but maybe skip if we trust the websocket?
|
||||
// Let's keep fetchGameData for now as a fallback.
|
||||
await actions.fetchGameData()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
actions.setMessage(error.response?.data?.detail || 'PvP action failed')
|
||||
return null
|
||||
}
|
||||
}}
|
||||
onExitCombat={() => {
|
||||
actions.handleExitCombat()
|
||||
}}
|
||||
onExitPvPCombat={actions.handleExitPvPCombat}
|
||||
addCombatLogEntry={actions.addCombatLogEntry}
|
||||
updatePlayerState={actions.updatePlayerState}
|
||||
updateCombatState={actions.updateCombatState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Location view (when not in combat) */}
|
||||
{!state.combatState && state.location && state.playerState && (
|
||||
<LocationView
|
||||
key={state.location.id}
|
||||
location={state.location}
|
||||
playerState={state.playerState}
|
||||
combatState={state.combatState || null}
|
||||
message={state.message}
|
||||
locationMessages={state.locationMessages}
|
||||
expandedCorpse={state.expandedCorpse}
|
||||
corpseDetails={state.corpseDetails}
|
||||
mobileMenuOpen={state.mobileMenuOpen}
|
||||
showCraftingMenu={state.showCraftingMenu}
|
||||
showRepairMenu={state.showRepairMenu}
|
||||
workbenchTab={state.workbenchTab}
|
||||
craftableItems={state.craftableItems}
|
||||
repairableItems={state.repairableItems}
|
||||
uncraftableItems={state.uncraftableItems}
|
||||
craftFilter={state.craftFilter}
|
||||
repairFilter={state.repairFilter}
|
||||
uncraftFilter={state.uncraftFilter}
|
||||
craftCategoryFilter={state.craftCategoryFilter}
|
||||
profile={state.profile}
|
||||
onSetMessage={actions.setMessage}
|
||||
onInitiateCombat={actions.handleInitiateCombat}
|
||||
onInitiatePvP={async (playerId: number) => {
|
||||
try {
|
||||
const response = await api.post('/api/game/pvp/initiate', { target_player_id: playerId })
|
||||
actions.setMessage(response.data.message || 'PvP combat initiated!')
|
||||
await actions.fetchGameData()
|
||||
} catch (error: any) {
|
||||
actions.setMessage(error.response?.data?.detail || 'Failed to initiate PvP')
|
||||
}
|
||||
}}
|
||||
onPickup={actions.handlePickup}
|
||||
onLootCorpse={actions.handleLootCorpse}
|
||||
onLootCorpseItem={actions.handleLootCorpseItem}
|
||||
onSetExpandedCorpse={(corpseId: string | null) => {
|
||||
if (corpseId === null) {
|
||||
actions.handleCloseCorpseDetails()
|
||||
} else {
|
||||
actions.handleViewCorpseDetails(corpseId)
|
||||
}
|
||||
}}
|
||||
onOpenCrafting={actions.handleOpenCrafting}
|
||||
onOpenRepair={actions.handleOpenRepair}
|
||||
onCloseCrafting={actions.handleCloseCrafting}
|
||||
onSwitchWorkbenchTab={actions.handleSwitchWorkbenchTab}
|
||||
onSetCraftFilter={actions.setCraftFilter}
|
||||
onSetRepairFilter={actions.setRepairFilter}
|
||||
onSetUncraftFilter={actions.setUncraftFilter}
|
||||
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
|
||||
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
|
||||
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
|
||||
onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)}
|
||||
failedActionItemId={state.failedActionItemId}
|
||||
quests={state.quests}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar: Stats + Inventory */}
|
||||
{state.playerState && state.profile && (
|
||||
<PlayerSidebar
|
||||
playerState={state.playerState}
|
||||
profile={state.profile}
|
||||
equipment={state.equipment}
|
||||
onCombatAction={actions.handleCombatAction}
|
||||
onPvPAction={async (action: string) => {
|
||||
try {
|
||||
const response = await api.post('/api/game/pvp/action', { action })
|
||||
actions.setMessage(response.data.message || 'Action performed!')
|
||||
// We don't need to fetchGameData here because the websocket update will handle it?
|
||||
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
|
||||
// So we should probably update state from response if possible, OR fetch.
|
||||
// Let's return the data so Combat.tsx can use it for animations.
|
||||
// And let's fetchGameData to be safe, but maybe skip if we trust the websocket?
|
||||
// Let's keep fetchGameData for now as a fallback.
|
||||
await actions.fetchGameData()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
actions.setMessage(error.response?.data?.detail || 'PvP action failed')
|
||||
return null
|
||||
}
|
||||
}}
|
||||
onExitCombat={() => {
|
||||
actions.handleExitCombat()
|
||||
}}
|
||||
onExitPvPCombat={actions.handleExitPvPCombat}
|
||||
addCombatLogEntry={actions.addCombatLogEntry}
|
||||
updatePlayerState={actions.updatePlayerState}
|
||||
updateCombatState={actions.updateCombatState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Location view (when not in combat) */}
|
||||
{!state.combatState && state.location && state.playerState && (
|
||||
<LocationView
|
||||
key={state.location.id}
|
||||
location={state.location}
|
||||
playerState={state.playerState}
|
||||
combatState={state.combatState || null}
|
||||
message={state.message}
|
||||
locationMessages={state.locationMessages}
|
||||
expandedCorpse={state.expandedCorpse}
|
||||
corpseDetails={state.corpseDetails}
|
||||
inventoryFilter={state.inventoryFilter}
|
||||
inventoryCategoryFilter={state.inventoryCategoryFilter}
|
||||
mobileMenuOpen={state.mobileMenuOpen}
|
||||
showCraftingMenu={state.showCraftingMenu}
|
||||
showRepairMenu={state.showRepairMenu}
|
||||
workbenchTab={state.workbenchTab}
|
||||
craftableItems={state.craftableItems}
|
||||
repairableItems={state.repairableItems}
|
||||
uncraftableItems={state.uncraftableItems}
|
||||
craftFilter={state.craftFilter}
|
||||
repairFilter={state.repairFilter}
|
||||
uncraftFilter={state.uncraftFilter}
|
||||
craftCategoryFilter={state.craftCategoryFilter}
|
||||
profile={state.profile}
|
||||
onSetMessage={actions.setMessage}
|
||||
onInitiateCombat={actions.handleInitiateCombat}
|
||||
onInitiatePvP={async (playerId: number) => {
|
||||
try {
|
||||
const response = await api.post('/api/game/pvp/initiate', { target_player_id: playerId })
|
||||
actions.setMessage(response.data.message || 'PvP combat initiated!')
|
||||
await actions.fetchGameData()
|
||||
} catch (error: any) {
|
||||
actions.setMessage(error.response?.data?.detail || 'Failed to initiate PvP')
|
||||
}
|
||||
onSetInventoryFilter={actions.setInventoryFilter}
|
||||
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
|
||||
onUseItem={async (itemId: number, _invId: number) => {
|
||||
await actions.handleUseItem(itemId.toString())
|
||||
}}
|
||||
onPickup={actions.handlePickup}
|
||||
onLootCorpse={actions.handleLootCorpse}
|
||||
onLootCorpseItem={actions.handleLootCorpseItem}
|
||||
onSetExpandedCorpse={(corpseId: string | null) => {
|
||||
if (corpseId === null) {
|
||||
actions.handleCloseCorpseDetails()
|
||||
} else {
|
||||
actions.handleViewCorpseDetails(corpseId)
|
||||
}
|
||||
onEquipItem={actions.handleEquipItem}
|
||||
onUnequipItem={actions.handleUnequipItem}
|
||||
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
|
||||
await actions.handleDropItem(itemId.toString(), quantity)
|
||||
}}
|
||||
onOpenCrafting={actions.handleOpenCrafting}
|
||||
onOpenRepair={actions.handleOpenRepair}
|
||||
onCloseCrafting={actions.handleCloseCrafting}
|
||||
onSwitchWorkbenchTab={actions.handleSwitchWorkbenchTab}
|
||||
onSetCraftFilter={actions.setCraftFilter}
|
||||
onSetRepairFilter={actions.setRepairFilter}
|
||||
onSetUncraftFilter={actions.setUncraftFilter}
|
||||
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
|
||||
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
|
||||
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
|
||||
onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)}
|
||||
failedActionItemId={state.failedActionItemId}
|
||||
onSpendPoint={actions.handleSpendPoint}
|
||||
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar: Stats + Inventory */}
|
||||
{state.playerState && state.profile && (
|
||||
<PlayerSidebar
|
||||
playerState={state.playerState}
|
||||
profile={state.profile}
|
||||
equipment={state.equipment}
|
||||
inventoryFilter={state.inventoryFilter}
|
||||
inventoryCategoryFilter={state.inventoryCategoryFilter}
|
||||
mobileMenuOpen={state.mobileMenuOpen}
|
||||
onSetInventoryFilter={actions.setInventoryFilter}
|
||||
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
|
||||
onUseItem={async (itemId: number, _invId: number) => {
|
||||
await actions.handleUseItem(itemId.toString())
|
||||
}}
|
||||
onEquipItem={actions.handleEquipItem}
|
||||
onUnequipItem={actions.handleUnequipItem}
|
||||
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
|
||||
await actions.handleDropItem(itemId.toString(), quantity)
|
||||
}}
|
||||
onSpendPoint={actions.handleSpendPoint}
|
||||
{/* Mobile Tab Navigation */}
|
||||
<div className="mobile-menu-buttons">
|
||||
<button
|
||||
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
|
||||
>
|
||||
<span>🧭</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
|
||||
disabled={!!state.combatState}
|
||||
>
|
||||
<span>📍</span>
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
|
||||
>
|
||||
<span>🎒</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlays */}
|
||||
{state.mobileMenuOpen !== 'none' && (
|
||||
<div
|
||||
className="mobile-menu-overlay"
|
||||
onClick={() => actions.setMobileMenuOpen('none')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Tab Navigation */}
|
||||
<div className="mobile-menu-buttons">
|
||||
{/* Mobile navigation */}
|
||||
<div className="mobile-nav">
|
||||
<button
|
||||
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
|
||||
className={`mobile-nav-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
|
||||
>
|
||||
<span>🧭</span>
|
||||
🗺️<br />Map
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
|
||||
className={`mobile-nav-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
|
||||
disabled={!!state.combatState}
|
||||
>
|
||||
<span>📍</span>
|
||||
📦<br />Items
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
|
||||
className={`mobile-nav-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
|
||||
>
|
||||
<span>🎒</span>
|
||||
🎒<br />Inventory
|
||||
</button>
|
||||
{/* Mobile Quest Button */}
|
||||
<button
|
||||
className={`mobile-nav-btn ${showQuestJournal ? 'active' : ''}`}
|
||||
onClick={() => setShowQuestJournal(!showQuestJournal)}
|
||||
>
|
||||
📜<br />Quests
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlays */}
|
||||
{state.mobileMenuOpen !== 'none' && (
|
||||
<div
|
||||
className="mobile-menu-overlay"
|
||||
onClick={() => actions.setMobileMenuOpen('none')}
|
||||
/>
|
||||
{showQuestJournal && (
|
||||
<QuestJournal onClose={() => setShowQuestJournal(false)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<div className="mobile-nav">
|
||||
<button
|
||||
className={`mobile-nav-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
|
||||
>
|
||||
🗺️<br />Map
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-nav-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
|
||||
>
|
||||
📦<br />Items
|
||||
</button>
|
||||
<button
|
||||
className={`mobile-nav-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
|
||||
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
|
||||
>
|
||||
🎒<br />Inventory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GameProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,14 +55,15 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
||||
// Use mousedown to catch clicks before they might trigger other things
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
// Disable scrolling while dropdown is open
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Handle scroll to close the dropdown (prevents detached menu and layout shifts)
|
||||
const handleScroll = () => {
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
// Restore scrolling
|
||||
document.body.style.overflow = originalOverflow;
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
|
||||
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 { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import { DialogModal } from './DialogModal'
|
||||
import { TradeModal } from './TradeModal'
|
||||
import './LocationView.css'
|
||||
|
||||
interface LocationViewProps {
|
||||
@@ -49,6 +51,7 @@ interface LocationViewProps {
|
||||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||||
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
||||
failedActionItemId: string | number | null
|
||||
quests: { active: any[], available: any[] }
|
||||
}
|
||||
|
||||
function LocationView({
|
||||
@@ -70,6 +73,7 @@ function LocationView({
|
||||
uncraftFilter,
|
||||
craftCategoryFilter,
|
||||
profile,
|
||||
quests,
|
||||
|
||||
onInitiateCombat,
|
||||
onInitiatePvP,
|
||||
@@ -91,17 +95,25 @@ function LocationView({
|
||||
}: LocationViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { playSfx } = useAudio()
|
||||
// const { token } = useGame() // No longer needed for fetching here
|
||||
|
||||
// Dropdown State
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
|
||||
|
||||
// NPC Interaction State
|
||||
const [activeDialogNpc, setActiveDialogNpc] = useState<string | null>(null)
|
||||
const [showTradeModal, setShowTradeModal] = useState<boolean>(false)
|
||||
const [activeNpcData, setActiveNpcData] = useState<any>(null)
|
||||
|
||||
// Quest State
|
||||
const [questIndicators, setQuestIndicators] = useState<{ [npcId: string]: string }>({})
|
||||
|
||||
// Handle dropdown toggle
|
||||
const handleDropdownClick = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
if (activeDropdown === id) {
|
||||
setActiveDropdown(null)
|
||||
} else {
|
||||
// GameDropdown now auto-detects mouse position if we don't pass it
|
||||
setActiveDropdown(id)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +125,85 @@ function LocationView({
|
||||
return () => window.removeEventListener('click', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Calculate Quest Indicators from props
|
||||
useEffect(() => {
|
||||
const indicators: { [id: string]: string } = {};
|
||||
if (!quests) return;
|
||||
|
||||
const { active, available } = quests;
|
||||
|
||||
// Check Available (New Quests)
|
||||
available.forEach((q: any) => {
|
||||
// Filter by location if needed? available/ endpoints already filters by location usually?
|
||||
// Actually /api/quests/available returns quests available *at the current location*.
|
||||
// But GameEngine fetches it globally?
|
||||
// Wait, /api/quests/available depends on current_user's location.
|
||||
// So useGameEngine.ts fetching valid ONLY for current location.
|
||||
// If player moves, fetchGameData is called, so available quests are refreshed. Correct.
|
||||
|
||||
if (q.giver_id) {
|
||||
if (q.type === 'global') indicators[q.giver_id] = 'blue_exclamation';
|
||||
else indicators[q.giver_id] = 'yellow_exclamation';
|
||||
}
|
||||
});
|
||||
|
||||
// Check Active (Ready to turn in or Cooldown)
|
||||
active.forEach((q: any) => {
|
||||
if (q.giver_id) {
|
||||
let allDone = true;
|
||||
|
||||
if (q.objectives) {
|
||||
q.objectives.forEach((obj: any) => {
|
||||
const current = q.progress?.[obj.target] || 0;
|
||||
if (current < obj.count) allDone = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (allDone && q.status === 'active') {
|
||||
indicators[q.giver_id] = 'yellow_question';
|
||||
}
|
||||
|
||||
if (q.on_cooldown) {
|
||||
indicators[q.giver_id] = 'gray_loop';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setQuestIndicators(indicators);
|
||||
}, [quests, location.id]);
|
||||
|
||||
const handleNpcClick = (npc: any) => {
|
||||
setActiveNpcData(npc);
|
||||
setActiveDialogNpc(npc.id);
|
||||
};
|
||||
|
||||
const renderIndicator = (npcId: string) => {
|
||||
const type = questIndicators[npcId];
|
||||
if (!type) return null;
|
||||
|
||||
let symbol = '';
|
||||
let color = '';
|
||||
|
||||
switch (type) {
|
||||
case 'yellow_exclamation': symbol = '!'; color = '#ffeb3b'; break;
|
||||
case 'blue_exclamation': symbol = '!'; color = '#4fc3f7'; break;
|
||||
case 'yellow_question': symbol = '?'; color = '#ffeb3b'; break;
|
||||
case 'gray_loop': symbol = '⟳'; color = '#9e9e9e'; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top: '-10px', right: '-5px',
|
||||
color: color, fontSize: '1.5rem', fontWeight: 'bold',
|
||||
textShadow: '0 0 5px black', zIndex: 10,
|
||||
animation: 'bounce 2s infinite'
|
||||
}}>
|
||||
{symbol}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="location-view">
|
||||
<div className="location-info">
|
||||
@@ -165,8 +256,6 @@ function LocationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{location.image_url && (
|
||||
<div className="location-image-container">
|
||||
<img
|
||||
@@ -183,12 +272,6 @@ function LocationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* {message && (
|
||||
<div className="message-box" onClick={() => onSetMessage('')}>
|
||||
{message}
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{locationMessages.length > 0 && (
|
||||
<div className="location-messages-log">
|
||||
<h4>{t('location.recentActivity')}</h4>
|
||||
@@ -321,30 +404,25 @@ function LocationView({
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||||
<div key={i} className="entity-card npc-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, `npc-${i}`)}
|
||||
onClick={() => handleNpcClick(npc)}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
|
||||
{npc.image_path ? (
|
||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
|
||||
)}
|
||||
|
||||
{renderIndicator(npc.id)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
|
||||
<div>{t('location.level')} {npc.level}</div>
|
||||
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === `npc-${i}` && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(npc.name)}</div>
|
||||
<GameButton variant="primary" size="sm" style={{ width: '100%', justifyContent: 'flex-start' }}>
|
||||
💬 {t('common.talk')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -514,8 +592,6 @@ function LocationView({
|
||||
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
// Fallback emoji next to it will show if image fails?
|
||||
// Current logic doesn't have fallback emoji element sibling, just keeping it simple.
|
||||
}}
|
||||
/>
|
||||
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
|
||||
@@ -583,6 +659,25 @@ function LocationView({
|
||||
onUncraft={onUncraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDialogNpc && activeNpcData && (
|
||||
<DialogModal
|
||||
npcId={activeDialogNpc}
|
||||
npcData={activeNpcData}
|
||||
onClose={() => setActiveDialogNpc(null)}
|
||||
onTrade={() => {
|
||||
setActiveDialogNpc(null);
|
||||
setShowTradeModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTradeModal && activeNpcData && (
|
||||
<TradeModal
|
||||
npcId={activeNpcData.id}
|
||||
onClose={() => setShowTradeModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface PlayerSidebarProps {
|
||||
onUnequipItem: (slot: string) => void
|
||||
onDropItem: (itemId: number, invId: number, quantity: number) => void
|
||||
onSpendPoint: (stat: string) => void
|
||||
onOpenQuestJournal: () => void
|
||||
}
|
||||
|
||||
function PlayerSidebar({
|
||||
@@ -38,7 +39,8 @@ function PlayerSidebar({
|
||||
onEquipItem,
|
||||
onUnequipItem,
|
||||
onDropItem,
|
||||
onSpendPoint
|
||||
onSpendPoint,
|
||||
onOpenQuestJournal
|
||||
}: PlayerSidebarProps) {
|
||||
const [showInventory, setShowInventory] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
@@ -290,15 +292,27 @@ function PlayerSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GameButton
|
||||
className="open-inventory-btn"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setShowInventory(true)}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{t('game.inventory')}
|
||||
</GameButton>
|
||||
<div className="sidebar-buttons" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<GameButton
|
||||
className="open-inventory-btn"
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => setShowInventory(true)}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{t('game.inventory')}
|
||||
</GameButton>
|
||||
|
||||
<GameButton
|
||||
className="quest-journal-btn"
|
||||
variant="secondary" // Different color as requested
|
||||
size="md"
|
||||
onClick={onOpenQuestJournal}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
📜 {t('common.quests')}
|
||||
</GameButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="equipment-sidebar">
|
||||
|
||||
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
|
||||
loading: boolean
|
||||
message: string
|
||||
quests: { active: any[], available: any[] }
|
||||
|
||||
// Combat state
|
||||
combatState: CombatState | null
|
||||
@@ -140,6 +141,10 @@ export interface GameEngineActions {
|
||||
addNPCToLocation: (npc: any) => void
|
||||
removeNPCFromLocation: (enemyId: string) => void
|
||||
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
|
||||
|
||||
// Quests
|
||||
updateQuests: (active: any[], available: any[]) => void
|
||||
handleQuestUpdate: (quest: any) => void
|
||||
}
|
||||
|
||||
export function useGameEngine(
|
||||
@@ -164,6 +169,7 @@ export function useGameEngine(
|
||||
const [corpseDetails, setCorpseDetails] = useState<any>(null)
|
||||
const [movementCooldown, setMovementCooldown] = useState<number>(0)
|
||||
const [failedActionItemId, setFailedActionItemId] = useState<string | number | null>(null)
|
||||
const [quests, setQuests] = useState<{ active: any[], available: any[] }>({ active: [], available: [] })
|
||||
// const [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('') // Moved to Combat.tsx
|
||||
|
||||
const [equipment, setEquipment] = useState<Equipment>({})
|
||||
@@ -265,15 +271,24 @@ export function useGameEngine(
|
||||
|
||||
const fetchGameData = useCallback(async (skipCombatLogInit: boolean = false) => {
|
||||
try {
|
||||
const [stateRes, locationRes, profileRes, combatRes, pvpRes] = await Promise.all([
|
||||
const [stateRes, locationRes, profileRes, combatRes, pvpRes, activeQuestsRes, availableQuestsRes] = await Promise.all([
|
||||
api.get('/api/game/state'),
|
||||
api.get('/api/game/location'),
|
||||
api.get('/api/game/profile'),
|
||||
api.get('/api/game/combat'),
|
||||
api.get('/api/game/pvp/status')
|
||||
api.get('/api/game/pvp/status'),
|
||||
api.get('/api/quests/active'),
|
||||
api.get('/api/quests/available')
|
||||
])
|
||||
|
||||
const gameState = stateRes.data
|
||||
|
||||
// Update quests
|
||||
setQuests({
|
||||
active: activeQuestsRes.data || [],
|
||||
available: availableQuestsRes.data || []
|
||||
})
|
||||
|
||||
setPlayerState({
|
||||
location_id: gameState.player.location_id,
|
||||
location_name: gameState.location?.name || 'Unknown',
|
||||
@@ -508,6 +523,46 @@ export function useGameEngine(
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateQuests = useCallback((active: any[], available: any[]) => {
|
||||
setQuests({ active, available })
|
||||
}, [])
|
||||
|
||||
const handleQuestUpdate = useCallback((quest: any) => {
|
||||
setQuests(prev => {
|
||||
// 1. Update active quests list
|
||||
let newActive = [...prev.active]
|
||||
const idx = newActive.findIndex(q => q.quest_id === quest.quest_id)
|
||||
|
||||
// If quest is active or completed, it should be in the active list
|
||||
if (quest.status === 'active' || quest.status === 'completed' || quest.status === 'can_turn_in') {
|
||||
if (idx >= 0) {
|
||||
// Update existing
|
||||
newActive[idx] = { ...newActive[idx], ...quest }
|
||||
} else {
|
||||
// Add new
|
||||
newActive.push(quest)
|
||||
}
|
||||
} else {
|
||||
// If failed or cancelled, maybe keep it or update status?
|
||||
if (idx >= 0) newActive[idx] = { ...newActive[idx], ...quest }
|
||||
}
|
||||
|
||||
// 2. Remove from available list if it was there (since it's now active/completed)
|
||||
// Only if status is active/completed. If it became available, we'd need logic for that.
|
||||
let newAvailable = prev.available
|
||||
if (quest.status === 'active' || quest.status === 'completed') {
|
||||
newAvailable = prev.available.filter(q => q.quest_id !== quest.quest_id)
|
||||
} else if (quest.status === 'available') {
|
||||
// It became available (e.g. repeatable cooldown finished?)
|
||||
if (!newAvailable.find(q => q.quest_id === quest.quest_id)) {
|
||||
newAvailable = [...newAvailable, quest]
|
||||
}
|
||||
}
|
||||
|
||||
return { active: newActive, available: newAvailable }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// State object
|
||||
const state: GameEngineState = {
|
||||
playerState,
|
||||
@@ -515,6 +570,7 @@ export function useGameEngine(
|
||||
profile,
|
||||
loading,
|
||||
message,
|
||||
quests,
|
||||
combatState,
|
||||
combatLog,
|
||||
enemyName,
|
||||
@@ -778,6 +834,11 @@ export function useGameEngine(
|
||||
}
|
||||
|
||||
const response = await api.post('/api/game/combat/action', payload)
|
||||
|
||||
if (response.data.quest_updates) {
|
||||
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
setMessage(error.response?.data?.detail || 'Combat action failed')
|
||||
@@ -1151,7 +1212,9 @@ export function useGameEngine(
|
||||
return newSet
|
||||
})
|
||||
},
|
||||
updateStatusEffect
|
||||
updateStatusEffect,
|
||||
updateQuests,
|
||||
handleQuestUpdate
|
||||
}
|
||||
|
||||
// Polling fallback for PvP Combat reliability
|
||||
|
||||
7
pwa/src/config.ts
Normal file
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",
|
||||
"enemy": "Enemy",
|
||||
"you": "You",
|
||||
"quests": "Quests",
|
||||
"all": "All"
|
||||
},
|
||||
"auth": {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"pickUp": "Recoger",
|
||||
"pickUpAll": "Recoger Todo",
|
||||
"qty": "Cant",
|
||||
"quests": "Misiones",
|
||||
"all": "Todo"
|
||||
},
|
||||
"auth": {
|
||||
|
||||
Reference in New Issue
Block a user