Files
echoes-of-the-ash/pwa/src/components/Game_OLD_BACKUP.tsx
2025-11-27 16:27:01 +01:00

3315 lines
144 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react'
import api from '../services/api'
import GameHeader from './GameHeader'
import { useGameWebSocket } from '../hooks/useGameWebSocket'
import './Game.css'
interface PlayerState {
location_id: string
location_name: string
health: number
max_health: number
stamina: number
max_stamina: number
inventory: any[]
status_effects: any[]
}
interface DirectionDetail {
direction: string
stamina_cost: number
distance: number
destination: string
destination_name?: string
}
interface Location {
id: string
name: string
description: string
directions: string[]
directions_detailed?: DirectionDetail[]
danger_level?: number
npcs: any[]
items: any[]
image_url?: string
interactables?: any[]
other_players?: any[]
corpses?: any[]
tags?: string[] // Tags for special location features like workbench
}
interface Profile {
name: string
level: number
xp: number
hp: number
max_hp: number
stamina: number
max_stamina: number
strength: number
agility: number
endurance: number
intellect: number
unspent_points: number
is_dead: boolean
max_weight?: number
current_weight?: number
max_volume?: number
current_volume?: number
}
function Game() {
const [playerState, setPlayerState] = useState<PlayerState | null>(null)
const [location, setLocation] = useState<Location | null>(null)
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const [selectedItem, setSelectedItem] = useState<any>(null)
const [combatState, setCombatState] = useState<any>(null)
const [combatLog, setCombatLog] = useState<Array<{time: string, message: string, isPlayer: boolean}>>([])
const [enemyName, setEnemyName] = useState<string>('')
const [enemyImage, setEnemyImage] = useState<string>('')
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const [expandedCorpse, setExpandedCorpse] = useState<string | null>(null)
const [corpseDetails, setCorpseDetails] = useState<any>(null)
const [movementCooldown, setMovementCooldown] = useState<number>(0)
const [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('')
const [equipment, setEquipment] = useState<any>({})
const [showCraftingMenu, setShowCraftingMenu] = useState<boolean>(false)
const [showRepairMenu, setShowRepairMenu] = useState<boolean>(false)
const [craftableItems, setCraftableItems] = useState<any[]>([])
const [repairableItems, setRepairableItems] = useState<any[]>([])
const [workbenchTab, setWorkbenchTab] = useState<'craft' | 'repair' | 'uncraft'>('craft')
const [craftFilter, setCraftFilter] = useState<string>('')
const [craftCategoryFilter, setCraftCategoryFilter] = useState<string>('all')
const [repairFilter, setRepairFilter] = useState<string>('')
const [uncraftFilter, setUncraftFilter] = useState<string>('')
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
// Use ref for synchronous duplicate checking (state updates are async)
const lastSeenPvPActionRef = useRef<string | null>(null)
// Client-side PvP timer that counts down every second
const [pvpTimeRemaining, setPvpTimeRemaining] = useState<number | null>(null)
const pvpTimerRef = useRef<number | null>(null)
// Mobile menu state
const [mobileMenuOpen, setMobileMenuOpen] = useState<'none' | 'left' | 'right' | 'bottom'>('none')
const [mobileHeaderOpen, setMobileHeaderOpen] = useState<boolean>(false)
// Location message log (cleared when changing locations)
const [locationMessages, setLocationMessages] = useState<Array<{time: string, message: string}>>([])
// Interactable cooldown timers (instance_id -> expiry timestamp)
const [interactableCooldowns, setInteractableCooldowns] = useState<Record<string, number>>({})
// Force re-render for countdown updates
const [forceUpdate, setForceUpdate] = useState(0)
// Get auth token from localStorage only once on mount
const [token] = useState(() => localStorage.getItem('token'))
// Handle WebSocket messages
const handleWebSocketMessage = async (message: any) => {
console.log('📨 WebSocket message:', message.type)
switch (message.type) {
case 'connected':
console.log('✅ WebSocket connected')
break
case 'state_update':
// Update player state from WebSocket
if (message.data?.player) {
const player = message.data.player
setPlayerState(prev => prev ? {
...prev,
health: player.hp ?? prev.health,
max_health: player.max_hp ?? prev.max_health,
stamina: player.stamina ?? prev.stamina,
max_stamina: player.max_stamina ?? prev.max_stamina,
location_id: player.location_id ?? prev.location_id,
location_name: message.data.location?.name ?? prev.location_name
} : null)
// Update profile if level/xp changed
if (player.level !== undefined || player.xp !== undefined) {
setProfile(prev => prev ? {
...prev,
level: player.level ?? prev.level,
xp: player.xp ?? prev.xp
} : null)
}
}
// Handle movement-triggered location change
if (message.data?.location) {
fetchGameData()
}
// Handle encounter
if (message.data?.encounter) {
setMessage(message.data.encounter.message || 'An enemy ambushes you!')
if (message.data.encounter.combat) {
setCombatState(message.data.encounter.combat)
}
// Fetch full game data to get complete combat state
fetchGameData()
}
break
case 'combat_started':
// New combat initiated (PvE or PvP)
if (message.data) {
if (message.data.message) {
setMessage(message.data.message)
}
if (message.data.combat) {
setCombatState(message.data.combat)
}
// Fetch full game data to ensure combat UI is shown (includes PvP)
fetchGameData()
}
break
case 'combat_update':
// Update combat state from WebSocket (both PvE and PvP)
// NOTE: Do NOT add log entries here for actions initiated by this player
// The HTTP response handler already adds them. WebSocket combat_update is
// for updating the UI state (HP, turn, combat_over) and for opponent actions
if (message.data) {
// Handle both PvE combat (combat) and PvP combat (pvp_combat)
if (message.data.combat) {
// PvE combat update
setCombatState(message.data.combat)
} else if (message.data.pvp_combat) {
// PvP combat update - need to format it like the API response
const pvpData = message.data.pvp_combat
// Check if we have complete attacker/defender data
// If not, we need to fetch the full PvP state
if (!pvpData.attacker || !pvpData.defender) {
// WebSocket sent incomplete data, fetch full state
console.log('⚠️ Incomplete PvP data in WebSocket, fetching full state...')
try {
const pvpRes = await api.get('/api/game/pvp/status')
if (pvpRes.data.in_pvp_combat) {
setCombatState(pvpRes.data)
}
} catch (err) {
console.error('Failed to fetch PvP status:', err)
}
} else {
// Update HP in the pvp_combat data
if (pvpData.attacker) {
pvpData.attacker.hp = message.data.attacker_hp
}
if (pvpData.defender) {
pvpData.defender.hp = message.data.defender_hp
}
const combatState = {
is_pvp: true,
in_pvp_combat: true,
pvp_combat: pvpData
}
setCombatState(combatState)
}
} else if (message.data.combat_over) {
setCombatState(null)
}
// Update player HP/XP/Level from WebSocket data (no API call needed)
if (message.data.player) {
const player = message.data.player
setProfile(prev => prev ? {
...prev,
hp: player.hp ?? prev.hp,
xp: player.xp ?? prev.xp,
level: player.level ?? prev.level
} : null)
// Also update playerState so HP bar reflects changes immediately
setPlayerState(prev => prev ? {
...prev,
health: player.hp ?? prev.health
} : null)
}
// Don't fetch game data on every combat update - WebSocket data is sufficient
// The combat state and player stats are already updated above
// Only fetch if combat ended to refresh location (corpses, etc.)
if (message.data.combat_over) {
await fetchLocationData() // Only fetch location, not full game data
}
}
break
case 'inventory_update':
// Refresh inventory data
fetchGameData()
break
case 'player_arrived':
// Handle player arriving at location (from PvP acknowledgment or regular movement)
if (message.data && message.data.message) {
addLocationMessage(message.data.message)
// Check for player data in either format (player_name or username)
const hasPlayerData = message.data.player_id &&
(message.data.player_name || message.data.username)
if (!hasPlayerData) {
await fetchLocationData()
} else {
// Update local state with the new player data
// This avoids the API call
const playerName = message.data.player_name || message.data.username
const playerId = message.data.player_id
const playerLevel = message.data.player_level || 1
const canPvp = message.data.can_pvp || false
console.log('Player arrived, adding to location:', playerName)
// Add player to location players list
setLocation(prev => {
if (!prev) return prev
// Check if player already in list
const playerExists = prev.other_players?.some((p: any) => p.id === playerId)
if (playerExists) {
return prev
}
return {
...prev,
other_players: [
...(prev.other_players || []),
{
id: playerId,
name: playerName,
level: playerLevel,
can_pvp: canPvp
}
]
}
})
}
}
break
case 'location_update':
// General location updates (items dropped, combat started/ended, corpses looted, etc.)
if (message.data && message.data.message) {
addLocationMessage(message.data.message)
// Only fetch location data when location state actually changes
// Skip for: player movements, item pickups, enemy spawns/despawns (data in message)
// Fetch for: item drops (added to ground), corpse loots (state changes), etc.
const action = message.data.action
if (action === 'player_arrived') {
// Player arrived - update local state
console.log('Location update: player arrived, updating state')
const hasPlayerData = message.data.player_id &&
(message.data.player_name || message.data.username)
if (hasPlayerData) {
const playerName = message.data.player_name || message.data.username
const playerId = message.data.player_id
const playerLevel = message.data.player_level || 1
const canPvp = message.data.can_pvp || false
setLocation(prev => {
if (!prev) return prev
// Check if player already in list
const playerExists = prev.other_players?.some((p: any) => p.id === playerId)
if (playerExists) {
return prev
}
return {
...prev,
other_players: [
...(prev.other_players || []),
{
id: playerId,
name: playerName,
level: playerLevel,
can_pvp: canPvp
}
]
}
})
}
} else if (action === 'player_left' && message.data.player_id) {
// Remove player from local state without fetching
console.log('Player left, removing from location:', message.data.player_name)
setLocation(prev => {
if (!prev) return prev
return {
...prev,
other_players: (prev.other_players || []).filter((p: any) => p.id !== message.data.player_id)
}
})
} else if (action === 'player_died' && message.data.player_id) {
// Player died - remove from other_players and add corpse directly
console.log('Player died, adding corpse to location:', message.data.corpse)
setLocation(prev => {
if (!prev) return prev
// Remove from other_players
const updatedOtherPlayers = (prev.other_players || []).filter((p: any) => p.id !== message.data.player_id)
// Add corpse if provided in message
let updatedCorpses = prev.corpses || []
if (message.data.corpse) {
updatedCorpses = [...updatedCorpses, message.data.corpse]
}
return {
...prev,
other_players: updatedOtherPlayers,
corpses: updatedCorpses
}
})
} else if (action === 'player_corpse_looted' && message.data.corpse_id) {
// Someone looted from a player corpse - update the corpse's items
console.log('Player corpse looted, updating items:', message.data)
setLocation(prev => {
if (!prev || !prev.corpses) return prev
return {
...prev,
corpses: prev.corpses.map((corpse: any) => {
if (corpse.id === message.data.corpse_id) {
return {
...corpse,
items: message.data.remaining_items,
loot_count: message.data.remaining_items.length
}
}
return corpse
})
}
})
} else if (action === 'player_corpse_emptied' && message.data.corpse_id) {
// Player corpse fully looted - but keep it visible (empty for 24h)
console.log('Player corpse emptied:', message.data.corpse_id)
setLocation(prev => {
if (!prev || !prev.corpses) return prev
return {
...prev,
corpses: prev.corpses.map((corpse: any) => {
if (corpse.id === message.data.corpse_id) {
return {
...corpse,
items: [],
loot_count: 0
}
}
return corpse
})
}
})
} else if (action === 'item_picked_up') {
// Don't fetch - no location state change
console.log('Location update (no fetch needed):', action)
} else if (action === 'enemy_spawned' && message.data.npc_data) {
// Add enemy to local state without fetching
console.log('Enemy spawned, updating local state:', message.data.npc_data)
setLocation(prev => {
if (!prev) return prev
return {
...prev,
npcs: [...(prev.npcs || []), message.data.npc_data]
}
})
} else if (action === 'enemy_despawned' && message.data.enemy_id) {
// Remove enemy from local state without fetching
console.log('Enemy despawned, updating local state:', message.data.enemy_id)
setLocation(prev => {
if (!prev) return prev
return {
...prev,
npcs: (prev.npcs || []).filter((npc: any) =>
!(npc.type === 'enemy' && npc.is_wandering && npc.id === message.data.enemy_id)
)
}
})
} else {
// Fetch for item_dropped, corpse_looted, and other state-changing actions
await fetchLocationData()
}
}
break
case 'interactable_cooldown':
// An interactable was used and is now on cooldown
if (message.data) {
const { instance_id, cooldown_remaining, message: msg, action_id } = message.data
if (instance_id && action_id && cooldown_remaining) {
const cooldownKey = `${instance_id}:${action_id}`
// Convert cooldown_remaining (seconds) to expiry timestamp
const cooldownExpiry = Date.now() / 1000 + cooldown_remaining
setInteractableCooldowns(prev => ({
...prev,
[cooldownKey]: cooldownExpiry
}))
}
if (msg) {
addLocationMessage(msg)
}
// No need to refresh - we already have the cooldown in state
}
break
case 'item_picked_up':
// Another player picked up an item
if (message.player_name) {
// Refresh location to update dropped items
fetchGameData()
}
break
case 'error':
console.error('❌ WebSocket error:', message.message)
break
default:
console.log('⚠️ Unhandled WebSocket message type:', message.type)
}
}
// Initialize WebSocket connection
const { isConnected } = useGameWebSocket({
token,
onMessage: handleWebSocketMessage,
enabled: !!token
})
// Note: sendMessage available from hook but not used yet
// Future: Use for sending chat messages, emotes, etc.
useEffect(() => {
fetchGameData()
// Set up fallback polling (less aggressive when WebSocket is active)
// WebSocket provides real-time updates, polling is just a backup
const pollInterval = setInterval(() => {
// Stop polling if combat is over (save server resources)
if (combatState?.pvp_combat?.combat_over) {
return
}
// If WebSocket is connected, skip polling entirely (WebSocket provides real-time updates)
if (isConnected) {
return
}
// Only poll if:
// 1. Not in PvP combat (need to detect incoming PvP), OR
// 2. In PvP combat but it's opponent's turn (need to see their actions)
const shouldPoll = !combatState?.in_pvp_combat || !combatState?.pvp_combat?.your_turn
if (!document.hidden && shouldPoll) {
fetchGameData(true)
}
}, 5000) // Poll every 5s when WebSocket is NOT connected
// Cleanup on unmount
return () => clearInterval(pollInterval)
// Only recreate interval when WebSocket connection status changes
}, [isConnected])
// Client-side countdown timer for PvP - runs every second
useEffect(() => {
// Clear any existing timer
if (pvpTimerRef.current) {
clearInterval(pvpTimerRef.current)
pvpTimerRef.current = null
}
// Only run timer if in PvP combat and combat is not over
if (combatState?.in_pvp_combat && !combatState?.pvp_combat?.combat_over) {
// Initialize timer from server value
setPvpTimeRemaining(combatState.pvp_combat.time_remaining)
// Start countdown that decreases every second
pvpTimerRef.current = setInterval(() => {
setPvpTimeRemaining(prev => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
} else {
setPvpTimeRemaining(null)
}
return () => {
if (pvpTimerRef.current) {
clearInterval(pvpTimerRef.current)
pvpTimerRef.current = null
}
}
}, [combatState?.in_pvp_combat, combatState?.pvp_combat?.combat_over])
// Sync client timer with server time on each poll
useEffect(() => {
if (combatState?.in_pvp_combat && combatState?.pvp_combat?.time_remaining !== undefined) {
setPvpTimeRemaining(combatState.pvp_combat.time_remaining)
}
}, [combatState?.pvp_combat?.time_remaining])
// Auto-dismiss messages after 4 seconds on mobile
useEffect(() => {
if (message && window.innerWidth <= 768) {
const timer = setTimeout(() => {
setMessage('')
}, 4000)
return () => clearTimeout(timer)
}
}, [message])
// Countdown effect for movement cooldown
useEffect(() => {
if (movementCooldown > 0) {
const timer = setTimeout(() => {
setMovementCooldown(prev => Math.max(0, prev - 1))
}, 1000)
return () => clearTimeout(timer)
}
}, [movementCooldown])
// Countdown effect for interactable cooldowns
useEffect(() => {
const hasActiveCooldowns = Object.keys(interactableCooldowns).length > 0
if (!hasActiveCooldowns) return
const timer = setInterval(() => {
const now = Date.now() / 1000 // Current time in seconds
setInteractableCooldowns(prev => {
const updated = { ...prev }
let changed = false
// Remove expired cooldowns
Object.keys(updated).forEach(instanceId => {
if (updated[instanceId] <= now) {
delete updated[instanceId]
changed = true
}
})
return changed ? updated : prev
})
// Force re-render every second to update countdown display
setForceUpdate(Date.now())
}, 1000)
return () => clearInterval(timer)
}, [Object.keys(interactableCooldowns).length]) // Only recreate when cooldowns are added/removed
// Targeted fetch functions for specific data
const fetchLocationData = async () => {
try {
console.log('🔄 Fetching location data...')
const locationRes = await api.get('/api/game/location')
console.log('✅ Location data received, setting location state')
setLocation(locationRes.data)
} catch (err) {
console.error('Failed to fetch location:', err)
}
}
const fetchPlayerState = async () => {
try {
const stateRes = await api.get('/api/game/state')
const gameState = stateRes.data
setPlayerState({
location_id: gameState.player.location_id,
location_name: gameState.location?.name || 'Unknown',
health: gameState.player.hp,
max_health: gameState.player.max_hp,
stamina: gameState.player.stamina,
max_stamina: gameState.player.max_stamina,
inventory: gameState.inventory || [],
status_effects: []
})
setEquipment(gameState.equipment || {})
// Set movement cooldown if available
if (gameState.player.movement_cooldown !== undefined) {
const cooldown = gameState.player.movement_cooldown
setMovementCooldown(cooldown > 0 ? Math.ceil(cooldown) + 1 : 0)
}
} catch (err) {
console.error('Failed to fetch player state:', err)
}
}
const fetchGameData = async (skipCombatLogInit: boolean = false) => {
// Note: fetchPlayerState and fetchLocationData are targeted helpers for WebSocket updates
// They're kept here for use by WebSocket handlers but not by fetchGameData
void fetchPlayerState // Silence unused warning
void fetchLocationData // Silence unused warning
void forceUpdate // Silence unused warning (used in interactable countdown)
try {
const [stateRes, locationRes, profileRes, combatRes, pvpRes] = 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')
])
// Map game state to player state format
const gameState = stateRes.data
setPlayerState({
location_id: gameState.player.location_id,
location_name: gameState.location?.name || 'Unknown',
health: gameState.player.hp,
max_health: gameState.player.max_hp,
stamina: gameState.player.stamina,
max_stamina: gameState.player.max_stamina,
inventory: gameState.inventory || [],
status_effects: []
})
setLocation(locationRes.data)
setProfile(profileRes.data.player || profileRes.data)
setEquipment(gameState.equipment || {})
// Initialize interactable cooldowns from location data
if (locationRes.data.interactables) {
const cooldowns: Record<string, number> = {}
for (const interactable of locationRes.data.interactables) {
if (interactable.actions) {
for (const action of interactable.actions) {
if (action.on_cooldown && action.cooldown_remaining > 0) {
const cooldownKey = `${interactable.instance_id}:${action.id}`
cooldowns[cooldownKey] = Date.now() / 1000 + action.cooldown_remaining
}
}
}
}
// Merge with existing cooldowns instead of replacing to avoid race conditions
setInteractableCooldowns(prev => ({ ...prev, ...cooldowns }))
}
// Set movement cooldown if available (add 1 second buffer only if there's actual cooldown)
if (gameState.player.movement_cooldown !== undefined) {
const cooldown = gameState.player.movement_cooldown
setMovementCooldown(cooldown > 0 ? Math.ceil(cooldown) + 1 : 0)
}
// Check for PvP combat first (takes priority)
if (pvpRes.data.in_pvp_combat) {
const newCombatState = {
...pvpRes.data,
is_pvp: true
}
setCombatState(newCombatState)
// Check if there's a new last_action to add to combat log (avoid duplicates)
// Use ref for synchronous check to prevent race conditions with state updates
if (pvpRes.data.pvp_combat.last_action &&
pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) {
// Update both state and ref
setLastSeenPvPAction(pvpRes.data.pvp_combat.last_action)
lastSeenPvPActionRef.current = pvpRes.data.pvp_combat.last_action
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse the action message (format: "message|timestamp")
const lastActionRaw = pvpRes.data.pvp_combat.last_action
const [lastAction, _actionTimestamp] = lastActionRaw.split('|')
const yourUsername = pvpRes.data.pvp_combat.is_attacker ?
pvpRes.data.pvp_combat.attacker.username :
pvpRes.data.pvp_combat.defender.username
// Check if the message starts with your username (e.g., "YourName attacks" or "YourName fled")
const isYourAction = lastAction.startsWith(yourUsername + ' ')
setCombatLog((prev: any) => [{
time: timeStr,
message: lastAction,
isPlayer: isYourAction
}, ...prev])
}
// Initialize combat log if empty
if (!skipCombatLogInit && combatLog.length === 0) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const opponent = pvpRes.data.pvp_combat.is_attacker ?
pvpRes.data.pvp_combat.defender :
pvpRes.data.pvp_combat.attacker
setCombatLog([{
time: timeStr,
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
isPlayer: true
}])
}
// Combat over state is handled in the UI with an acknowledgment button
// Don't auto-close anymore
}
// If not in PvP combat anymore, clear the tracking
else if (lastSeenPvPAction !== null) {
setLastSeenPvPAction(null)
lastSeenPvPActionRef.current = null
}
// Check for active PvE combat
else if (combatRes.data.in_combat) {
setCombatState(combatRes.data)
// Only initialize combat log if it's empty AND we're not skipping initialization
// Skip initialization after encounters since they already set the combat log
if (!skipCombatLogInit && combatLog.length === 0) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
time: timeStr,
message: 'Combat in progress...',
isPlayer: true
}])
}
}
} catch (error) {
console.error('Failed to fetch game data:', error)
setMessage('Failed to load game data')
} finally {
setLoading(false)
}
}
// Helper function to add messages to location log
const addLocationMessage = (msg: string) => {
console.log('📍 Adding location message:', msg)
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setLocationMessages(prev => {
console.log('📍 Current location messages:', prev.length, 'Adding:', msg)
return [...prev, { time: timeStr, message: msg }]
})
setMessage(msg)
}
const handleMove = async (direction: string) => {
// Prevent movement during combat
if (combatState) {
setMessage('Cannot move while in combat!')
return
}
// Close workbench menu when moving
if (showCraftingMenu || showRepairMenu) {
handleCloseCrafting()
}
// Close mobile menu after movement
setMobileMenuOpen('none')
try {
setMessage('Moving...')
const response = await api.post('/api/game/move', { direction })
setMessage(response.data.message)
// Clear location messages when changing locations
setLocationMessages([])
// Check if an encounter was triggered
if (response.data.encounter && response.data.encounter.triggered) {
const encounter = response.data.encounter
setMessage(encounter.message)
// Store enemy info
setEnemyName(encounter.combat.npc_name)
setEnemyImage(encounter.combat.npc_image)
// Set combat state
setCombatState({
in_combat: true,
combat_over: false,
player_won: false,
combat: encounter.combat
})
// Clear combat log for new encounter
setCombatLog([])
// Add initial message to combat log
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
time: timeStr,
message: `⚠️ ${encounter.combat.npc_name} ambushes you!`,
isPlayer: false
}])
// Refresh all game data after movement, but skip combat log init since we just set it
await fetchGameData(true)
} else {
// Normal movement, refresh game data normally
await fetchGameData()
}
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Move failed')
}
}
const handlePickup = async (itemId: number, quantity: number = 1) => {
try {
setMessage(`Picking up ${quantity > 1 ? quantity + ' items' : 'item'}...`)
const response = await api.post('/api/game/pickup', { item_id: itemId, quantity })
const msg = response.data.message || 'Item picked up!'
addLocationMessage(msg)
fetchGameData() // Refresh to update inventory and ground items
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to pick up item')
// Refresh to remove items that no longer exist
fetchGameData()
}
}
const handleOpenCrafting = async () => {
try {
const response = await api.get('/api/game/craftable')
setCraftableItems(response.data.craftable_items)
setShowCraftingMenu(true)
setShowRepairMenu(false)
setWorkbenchTab('craft')
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to load crafting menu')
}
}
const handleCloseCrafting = () => {
setShowCraftingMenu(false)
setShowRepairMenu(false)
setCraftableItems([])
setRepairableItems([])
setUncraftableItems([])
setCraftFilter('')
setRepairFilter('')
setUncraftFilter('')
}
const handleCraft = async (itemId: string) => {
try {
setMessage('Crafting...')
const response = await api.post('/api/game/craft_item', { item_id: itemId })
setMessage(response.data.message || 'Item crafted!')
await fetchGameData()
// Refresh craftable items list
const craftableRes = await api.get('/api/game/craftable')
setCraftableItems(craftableRes.data.craftable_items)
// Refresh salvageable items if on that tab
if (workbenchTab === 'uncraft') {
const salvageableRes = await api.get('/api/game/salvageable')
setUncraftableItems(salvageableRes.data.salvageable_items)
}
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to craft item')
}
}
const handleOpenRepair = async () => {
try {
const response = await api.get('/api/game/repairable')
setRepairableItems(response.data.repairable_items)
setShowRepairMenu(true)
setShowCraftingMenu(false)
setWorkbenchTab('repair')
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to load repair menu')
}
}
const handleRepairFromMenu = async (uniqueItemId: number, inventoryId?: number) => {
try {
setMessage('Repairing...')
const response = await api.post('/api/game/repair_item', {
unique_item_id: uniqueItemId,
inventory_id: inventoryId
})
setMessage(response.data.message || 'Item repaired!')
await fetchGameData()
// Refresh repairable items list
const repairableRes = await api.get('/api/game/repairable')
setRepairableItems(repairableRes.data.repairable_items)
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to repair item')
}
}
const handleUncraft = async (uniqueItemId: number, inventoryId: number) => {
try {
setMessage('Salvaging...')
const response = await api.post('/api/game/uncraft_item', {
unique_item_id: uniqueItemId,
inventory_id: inventoryId
})
const data = response.data
let msg = data.message || 'Item salvaged!'
if (data.materials_yielded && data.materials_yielded.length > 0) {
msg += '\n✅ Yielded: ' + data.materials_yielded.map((m: any) => `${m.emoji} ${m.name} x${m.quantity}`).join(', ')
}
if (data.materials_lost && data.materials_lost.length > 0) {
msg += '\n⚠ Lost: ' + data.materials_lost.map((m: any) => `${m.emoji} ${m.name} x${m.quantity}`).join(', ')
}
setMessage(msg)
await fetchGameData()
// Refresh salvageable items list
const salvageableRes = await api.get('/api/game/salvageable')
setUncraftableItems(salvageableRes.data.salvageable_items)
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to uncraft item')
}
}
const handleSwitchWorkbenchTab = async (tab: 'craft' | 'repair' | 'uncraft') => {
setWorkbenchTab(tab)
try {
if (tab === 'craft') {
const response = await api.get('/api/game/craftable')
setCraftableItems(response.data.craftable_items)
} else if (tab === 'repair') {
const response = await api.get('/api/game/repairable')
setRepairableItems(response.data.repairable_items)
} else if (tab === 'uncraft') {
const salvageableRes = await api.get('/api/game/salvageable')
setUncraftableItems(salvageableRes.data.salvageable_items)
}
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to load items')
}
}
const handleSpendPoint = async (stat: string) => {
try {
setMessage(`Increasing ${stat}...`)
const response = await api.post(`/api/game/spend_point?stat=${stat}`)
setMessage(response.data.message || 'Stat increased!')
fetchGameData() // Refresh to update stats
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to spend point')
}
}
const handleUseItem = async (itemId: string) => {
try {
setMessage('Using item...')
const response = await api.post('/api/game/use_item', { item_id: itemId })
const data = response.data
// If in combat, add to combat log
if (combatState && data.in_combat) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const messages = data.message.split('\n').filter((m: string) => m.trim())
const newEntries = messages.map((msg: string) => ({
time: timeStr,
message: msg,
isPlayer: !msg.includes('attacks')
}))
setCombatLog((prev: any) => [...newEntries, ...prev])
// Check if combat ended
if (data.combat_over) {
setCombatState({
...combatState,
combat_over: true,
player_won: data.player_won
})
}
} else {
const msg = data.message || 'Item used!'
addLocationMessage(msg)
}
fetchGameData()
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to use item')
}
}
const handleEquipItem = async (inventoryId: number) => {
try {
setMessage('Equipping item...')
const response = await api.post('/api/game/equip', { inventory_id: inventoryId })
const msg = response.data.message || 'Item equipped!'
addLocationMessage(msg)
fetchGameData()
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to equip item')
}
}
const handleUnequipItem = async (slot: string) => {
try {
setMessage('Unequipping item...')
const response = await api.post('/api/game/unequip', { slot })
const msg = response.data.message || 'Item unequipped!'
addLocationMessage(msg)
fetchGameData()
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to unequip item')
}
}
const handleDropItem = async (itemId: string, quantity: number = 1) => {
try {
setMessage(`Dropping ${quantity} item(s)...`)
const response = await api.post('/api/game/item/drop', { item_id: itemId, quantity })
const msg = response.data.message || 'Item dropped!'
addLocationMessage(msg)
fetchGameData()
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to drop item')
}
}
const handleInteract = async (interactableId: string, actionId: string) => {
if (combatState) {
setMessage('Cannot interact with objects while in combat!')
return
}
// Close mobile menu to show result
setMobileMenuOpen('none')
try {
const response = await api.post('/api/game/interact', {
interactable_id: interactableId,
action_id: actionId
})
const data = response.data
let msg = data.message
if (data.items_found && data.items_found.length > 0) {
// items_found is already an array of strings like "Item Name x2"
msg += '\n\n📦 Found: ' + data.items_found.join(', ')
}
if (data.hp_change) {
msg += `\n❤ HP: ${data.hp_change > 0 ? '+' : ''}${data.hp_change}`
}
setMessage(msg)
fetchGameData() // Refresh stats
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Interaction failed')
}
}
const handleViewCorpseDetails = async (corpseId: string) => {
try {
const response = await api.get(`/api/game/corpse/${corpseId}`)
setCorpseDetails(response.data)
setExpandedCorpse(corpseId)
// Don't show "examining" message - just open the details
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to examine corpse')
}
}
const handleLootCorpseItem = async (corpseId: string, itemIndex: number | null = null) => {
try {
setMessage('Looting...')
const response = await api.post('/api/game/loot_corpse', {
corpse_id: corpseId,
item_index: itemIndex
})
// Show message for longer
setMessage(response.data.message)
setTimeout(() => {
// Keep message visible for 5 seconds
}, 5000)
// If corpse is empty, close the details view
if (response.data.corpse_empty) {
setExpandedCorpse(null)
setCorpseDetails(null)
} else if (expandedCorpse === corpseId) {
// Refresh corpse details if still viewing (without clearing message)
try {
const detailsResponse = await api.get(`/api/game/corpse/${corpseId}`)
setCorpseDetails(detailsResponse.data)
} catch (err) {
// If corpse details fail, just close
setExpandedCorpse(null)
setCorpseDetails(null)
}
}
fetchGameData() // Refresh location and inventory
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to loot corpse')
}
}
const handleLootCorpse = async (corpseId: string) => {
// Show corpse details instead of looting all at once
handleViewCorpseDetails(corpseId)
}
const handleInitiateCombat = async (enemyId: number) => {
try {
// Close mobile menu to show combat
setMobileMenuOpen('none')
const response = await api.post('/api/game/combat/initiate', { enemy_id: enemyId })
setCombatState(response.data)
// Store enemy info to prevent it from disappearing
setEnemyName(response.data.combat.npc_name)
setEnemyImage(response.data.combat.npc_image)
// Initialize combat log with timestamp
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
time: timeStr,
message: `Combat started with ${response.data.combat.npc_name}!`,
isPlayer: true
}])
// Refresh location to remove enemy from list
const locationRes = await api.get('/api/game/location')
setLocation(locationRes.data)
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to initiate combat')
}
}
const handleCombatAction = async (action: string) => {
try {
const response = await api.post('/api/game/combat/action', { action })
const data = response.data
// Add message to combat log with timestamp
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Parse the message to separate player and enemy actions
const messages = data.message.split('\n').filter((m: string) => m.trim())
// Find player action and enemy action
// For PvE: Failed flee contains both, so check for "Failed to flee" first
// For PvP: Use standard logic
const isPvE = !combatState?.is_pvp
const playerMessages = messages.filter((msg: string) =>
msg.includes('You ') || msg.includes('Your ') || (isPvE && msg.includes('Failed to flee'))
)
const enemyMessages = messages.filter((msg: string) =>
!(isPvE && msg.includes('Failed to flee')) && // Exclude "Failed to flee" from enemy messages in PvE only
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
)
// Add player actions immediately
if (playerMessages.length > 0) {
const playerEntries = playerMessages.map((msg: string) => ({
time: timeStr,
message: msg,
isPlayer: true
}))
setCombatLog((prev: any) => [...playerEntries, ...prev])
// Update enemy HP immediately (but not player HP)
if (data.combat && !data.combat_over) {
setCombatState({
...combatState,
combat: {
...combatState.combat,
npc_hp: data.combat.npc_hp,
turn: data.combat.turn
}
})
}
}
// If there are enemy actions and combat is not over, show "Enemy's turn..." then delay
if (enemyMessages.length > 0 && !data.combat_over) {
// Show "Enemy's turn..." message
setEnemyTurnMessage("🗡️ Enemy's turn...")
// Wait 2 seconds before showing enemy attack
await new Promise(resolve => setTimeout(resolve, 2000))
// Clear the turn message and add enemy actions to log
setEnemyTurnMessage('')
const enemyEntries = enemyMessages.map((msg: string) => ({
time: timeStr,
message: msg,
isPlayer: false
}))
setCombatLog((prev: any) => [...enemyEntries, ...prev])
// NOW update player HP directly from response data instead of fetching
if (data.player) {
setProfile(prev => prev ? {
...prev,
hp: data.player.hp,
xp: data.player.xp ?? prev.xp,
level: data.player.level ?? prev.level
} : null)
}
} else if (enemyMessages.length > 0) {
// Combat is over, add enemy messages without delay
const enemyEntries = enemyMessages.map((msg: string) => ({
time: timeStr,
message: msg,
isPlayer: false
}))
setCombatLog((prev: any) => [...enemyEntries, ...prev])
}
if (data.combat_over) {
// Combat ended - keep combat view but show result with preserved enemy info
// Check if player fled successfully (message contains "fled")
const playerFled = data.message && data.message.toLowerCase().includes('fled')
setCombatState({
...combatState, // Keep existing state
combat_over: true,
player_won: data.player_won,
player_fled: playerFled, // Track if player fled
combat: {
...combatState.combat,
npc_name: enemyName, // Keep original enemy name
npc_image: enemyImage, // Keep original enemy image
npc_hp: data.player_won ? 0 : (combatState.combat?.npc_hp || 0) // Don't set HP to 0 on flee
}
})
// Update player stats from response (XP/level on victory, HP on defeat)
if (data.player) {
setProfile(prev => prev ? {
...prev,
hp: data.player.hp,
xp: data.player.xp ?? prev.xp,
level: data.player.level ?? prev.level
} : null)
}
} else {
// Update combat state for next turn, but preserve enemy info
// Keep the original stored enemy name/image (from state variables)
setCombatState({
...data,
combat: {
...data.combat,
npc_name: enemyName, // Use stored enemy name
npc_image: enemyImage // Use stored enemy image
}
})
}
} catch (error: any) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog((prev: any) => [{
time: timeStr,
message: error.response?.data?.detail || 'Combat action failed',
isPlayer: false
}, ...prev])
}
}
const handleExitCombat = () => {
setCombatState(null)
setCombatLog([])
setEnemyName('')
setEnemyImage('')
fetchGameData() // Refresh game state
}
const handleExitPvPCombat = async () => {
if (combatState?.pvp_combat?.id) {
try {
await api.post('/api/game/pvp/acknowledge', { combat_id: combatState.pvp_combat.id })
} catch (error) {
console.error('Failed to acknowledge PvP combat:', error)
}
}
setCombatState(null)
setCombatLog([])
setLastSeenPvPAction(null)
lastSeenPvPActionRef.current = null // Clear ref too
fetchGameData() // Refresh game state
}
const handleInitiatePvP = async (targetPlayerId: number) => {
try {
const response = await api.post('/api/game/pvp/initiate', { target_player_id: targetPlayerId })
setMessage(response.data.message || 'PvP combat initiated!')
await fetchGameData() // Refresh to show combat state
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Failed to initiate PvP')
}
}
const handlePvPAction = async (action: string) => {
try {
const response = await api.post('/api/game/pvp/action', { action })
const data = response.data
// Add message to combat log
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
if (data.message) {
const messages = data.message.split('\n').filter((m: string) => m.trim())
const logEntries = messages.map((msg: string) => ({
time: timeStr,
message: msg,
isPlayer: msg.includes('You ') || msg.includes('Your ')
}))
setCombatLog((prev: any) => [...logEntries, ...prev])
}
// Refresh combat state (skip combat log initialization to preserve our log entries)
await fetchGameData(true)
// If combat is over, show message
if (data.combat_over) {
setMessage(data.message || 'Combat ended!')
}
} catch (error: any) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog((prev: any) => [{
time: timeStr,
message: error.response?.data?.detail || 'PvP action failed',
isPlayer: false
}, ...prev])
}
}
const handleItemAction = async (action: string, itemId: number) => {
switch (action) {
case 'use':
await handleUseItem(itemId.toString())
break
case 'equip':
await handleEquipItem(itemId)
break
case 'unequip':
// Find the slot this item is equipped in
const equippedSlot = Object.keys(equipment).find(slot => equipment[slot]?.id === itemId)
if (equippedSlot) {
await handleUnequipItem(equippedSlot)
}
break
case 'drop':
await handleDropItem(itemId.toString(), 1)
break
}
setSelectedItem(null)
}
if (loading) {
return <div className="loading">Loading game...</div>
}
if (!playerState || !location) {
return <div className="error">Failed to load game state</div>
}
// Helper function to get direction details
const getDirectionDetail = (direction: string) => {
if (!location.directions_detailed) return null
return location.directions_detailed.find(d => d.direction === direction)
}
// Helper function to get stamina cost for a direction
const getStaminaCost = (direction: string): number => {
const detail = getDirectionDetail(direction)
return detail ? detail.stamina_cost : 5
}
// Helper function to get destination name for a direction
const getDestinationName = (direction: string): string => {
const detail = getDirectionDetail(direction)
return detail ? (detail.destination_name || detail.destination) : ''
}
// Helper function to get distance for a direction
const getDistance = (direction: string): number => {
const detail = getDirectionDetail(direction)
return detail ? detail.distance : 0
}
// Helper function to check if direction is available
const hasDirection = (direction: string): boolean => {
return location.directions.includes(direction)
}
// Helper function to render compass button
const renderCompassButton = (direction: string, arrow: string, className: string) => {
const available = hasDirection(direction)
const stamina = getStaminaCost(direction)
const destination = getDestinationName(direction)
const distance = getDistance(direction)
const insufficientStamina = profile ? profile.stamina < stamina : false
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
// Build detailed tooltip text
const tooltipText = profile?.is_dead ? 'You are dead' :
movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` :
combatState ? 'Cannot travel during combat' :
insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` :
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` :
`Cannot go ${direction}`
return (
<button
onClick={() => handleMove(direction)}
disabled={disabled}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
title={tooltipText}
>
<span className="compass-arrow">{arrow}</span>
{available && movementCooldown > 0 ? (
<span className="compass-cost">{movementCooldown}s</span>
) : available && (
<span className="compass-cost">{stamina}</span>
)}
</button>
)
}
const renderExploreTab = () => (
<div className="explore-tab-desktop">
{/* Left Sidebar: Movement & Surroundings */}
<div className={`left-sidebar mobile-menu-panel ${mobileMenuOpen === 'left' ? 'open' : ''}`}>
{/* Movement Controls */}
<div className="movement-controls">
<h3>🧭 Travel</h3>
<div className="compass-grid">
{/* Top row */}
{renderCompassButton('northwest', '↖', 'nw')}
{renderCompassButton('north', '↑', 'n')}
{renderCompassButton('northeast', '↗', 'ne')}
{/* Middle row */}
{renderCompassButton('west', '←', 'w')}
<div className="compass-center">
<div className="compass-icon">🧭</div>
</div>
{renderCompassButton('east', '→', 'e')}
{/* Bottom row */}
{renderCompassButton('southwest', '↙', 'sw')}
{renderCompassButton('south', '↓', 's')}
{renderCompassButton('southeast', '↘', 'se')}
</div>
{/* Cooldown indicator */}
{movementCooldown > 0 && (
<div className="cooldown-indicator">
Wait {movementCooldown}s before moving
</div>
)}
{/* Special movements */}
<div className="special-moves">
{location.directions.includes('up') && (
<button
onClick={() => handleMove('up')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`}
>
Up <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button>
)}
{location.directions.includes('down') && (
<button
onClick={() => handleMove('down')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`}
>
Down <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button>
)}
{location.directions.includes('enter') && (
<button
onClick={() => handleMove('enter')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`}
>
🚪 Enter <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span>
</button>
)}
{location.directions.includes('inside') && (
<button
onClick={() => handleMove('inside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`}
>
🚪 Inside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span>
</button>
)}
{location.directions.includes('exit') && (
<button
onClick={() => handleMove('exit')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'}
>
🚪 Exit
</button>
)}
{location.directions.includes('outside') && (
<button
onClick={() => handleMove('outside')}
className="special-btn"
disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`}
>
🚪 Outside <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span>
</button>
)}
</div>
</div>
{/* Surroundings */}
{(location.interactables && location.interactables.length > 0) && (
<div className="interactables-section">
<h3>🌿 Surroundings</h3>
{/* Interactables */}
{location.interactables && location.interactables.map((interactable: any) => {
return (
<div key={interactable.instance_id} className="interactable-card">
{interactable.image_path && (
<div className="interactable-image-container">
<img
src={`/${interactable.image_path}`}
alt={interactable.name}
className="interactable-image"
onError={(e: any) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
)}
<div className="interactable-content">
<div className="interactable-header">
<span className="interactable-name">
{interactable.name}
</span>
</div>
{interactable.actions && interactable.actions.length > 0 && (
<div className="interactable-actions">
{interactable.actions.map((action: any) => {
// Calculate live cooldown remaining per action
const cooldownKey = `${interactable.instance_id}:${action.id}`
const cooldownExpiry = interactableCooldowns[cooldownKey]
const now = Date.now() / 1000
const cooldownRemaining = cooldownExpiry ? Math.max(0, Math.ceil(cooldownExpiry - now)) : 0
const isOnCooldown = cooldownRemaining > 0
return (
<button
key={action.id}
onClick={() => handleInteract(interactable.instance_id, action.id)}
className="interact-btn"
disabled={isOnCooldown || !!combatState}
title={
combatState ? 'Cannot interact during combat' :
isOnCooldown ? `On cooldown (${cooldownRemaining}s)` :
action.description
}
>
{action.name} <span className="stamina-cost">{action.stamina_cost}</span>
{isOnCooldown && <span className="cooldown-emoji"> {cooldownRemaining}s</span>}
</button>
)
})}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div> {/* Close left-sidebar */}
{/* Center: Location/Combat Content */}
<div className="center-content">
{combatState ? (
/* Combat View */
<div className="combat-view">
<div className="combat-header-inline">
<h2>
{combatState.is_pvp ? '⚔️ PvP Combat' : `⚔️ Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`}
</h2>
</div>
{combatState.is_pvp ? (
/* PvP Combat UI */
<div className="pvp-combat-display">
<div className="pvp-players">
{/* Opponent Info */}
<div className="pvp-player-card">
{(() => {
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
return (
<>
<h3>🗡 {opponent.username}</h3>
<div className="pvp-level">Level {opponent.level}</div>
<div className="combat-hp-bar-container-inline">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
HP: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`
}}
/>
</div>
</div>
</>
)
})()}
</div>
{/* Your Info */}
<div className="pvp-player-card your-card">
{(() => {
const you = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.attacker :
combatState.pvp_combat.defender
return (
<>
<h3>🛡 You</h3>
<div className="pvp-level">Level {you.level}</div>
<div className="combat-hp-bar-container-inline">
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
HP: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
</>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => handlePvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn}
>
⚔️ Attack
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => handlePvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn}
>
🏃 Flee
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={handleExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? ' Continue' : '💀 Return'}
</button>
)}
</div>
</div>
) : (
/* PvE Combat UI */
<>
<div className="combat-enemy-display-inline">
<div className="combat-enemy-image-large">
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
/>
</div>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
Enemy HP: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
Your HP: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span>
) : combatState.combat?.turn === 'player' ? (
<span className="your-turn"> Your Turn</span>
) : (
<span className="enemy-turn"> Enemy Turn</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? "✅ Victory!" : combatState.player_fled ? "🏃 Escaped!" : "💀 Defeated"}
</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => handleCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage}
>
Attack
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => handleCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage}
>
🏃 Flee
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={handleExitCombat}
>
{combatState.player_won ? '✅ Victory! Continue' : '💀 Return'}
</button>
)}
</div>
</>
)}
{/* Combat Log */}
<div className="combat-log-container">
<h4>Combat Log:</h4>
<div className="combat-log-messages">
{combatLog.map((entry: any, i: number) => (
<div key={i} className={`combat-log-entry ${entry.isPlayer ? 'player-action' : 'enemy-action'}`}>
<span className="log-time">{entry.time}</span>
<span className="log-separator">{entry.isPlayer ? '→' : '←'}</span>
<span className="log-message">{entry.message}</span>
</div>
))}
</div>
</div>
</div>
) : (
/* Normal Location View */
<>
<div className="location-info">
<h2 className="centered-heading">
{location.name}
{location.danger_level !== undefined && location.danger_level === 0 && (
<span className="danger-badge danger-safe" title="Safe Zone">
Safe
</span>
)}
{location.danger_level !== undefined && location.danger_level > 0 && (
<span className={`danger-badge danger-${location.danger_level}`} title={`Danger Level: ${location.danger_level}`}>
{location.danger_level}
</span>
)}
</h2>
{location.tags && location.tags.length > 0 && (
<div className="location-tags">
{location.tags.map((tag: string, i: number) => {
const isClickable = tag === 'workbench' || tag === 'repair_station'
const handleClick = () => {
if (tag === 'workbench') handleOpenCrafting()
else if (tag === 'repair_station') handleOpenRepair()
}
return (
<span
key={i}
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
title={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}
onClick={isClickable ? handleClick : undefined}
style={isClickable ? { cursor: 'pointer' } : undefined}
>
{tag === 'workbench' && '🔧 Workbench'}
{tag === 'repair_station' && '🛠️ Repair Station'}
{tag === 'safe_zone' && '🛡️ Safe Zone'}
{tag === 'shop' && '🏪 Shop'}
{tag === 'shelter' && '🏠 Shelter'}
{tag === 'medical' && '⚕️ Medical'}
{tag === 'storage' && '📦 Storage'}
{tag === 'water_source' && '💧 Water'}
{tag === 'food_source' && '🍎 Food'}
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
</span>
)
})}
</div>
)}
{/* Workbench Menu (Crafting, Repair, Uncraft) */}
{(showCraftingMenu || showRepairMenu) && (
<div className="workbench-menu">
<div className="workbench-header">
<h3>🔧 Workbench</h3>
<button className="close-btn" onClick={handleCloseCrafting}></button>
</div>
{/* Tabs */}
<div className="workbench-tabs">
<button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => handleSwitchWorkbenchTab('craft')}
>
🔨 Craft
</button>
<button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => handleSwitchWorkbenchTab('repair')}
>
🛠 Repair
</button>
<button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => handleSwitchWorkbenchTab('uncraft')}
>
Salvage
</button>
</div>
{/* Craft Tab */}
{workbenchTab === 'craft' && (
<div className="workbench-content">
<div className="filter-box">
<input
type="text"
placeholder="🔍 Filter items..."
value={craftFilter}
onChange={(e) => setCraftFilter(e.target.value)}
className="filter-input"
/>
<select
value={craftCategoryFilter}
onChange={(e) => setCraftCategoryFilter(e.target.value)}
className="filter-select"
>
<option value="all">All Categories</option>
<option value="weapon"> Weapons</option>
<option value="armor">🛡 Armor</option>
<option value="consumable">🍎 Consumables</option>
<option value="material">🪵 Materials</option>
<option value="tool">🔨 Tools</option>
<option value="other">📦 Other</option>
</select>
</div>
<div className="craftable-items-list">
{craftableItems.filter(item =>
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
).length === 0 && <p className="no-items">No craftable items found</p>}
{craftableItems
.filter(item =>
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
)
.map((item: any) => (
<div key={item.item_id} className={`craftable-item ${!item.can_craft ? 'disabled' : ''}`}>
<div className="item-header">
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.emoji} {item.name}
</span>
{item.slot && <span className="item-slot">[{item.slot}]</span>}
</div>
{item.description && <p className="item-description">{item.description}</p>}
{/* Level requirement */}
{item.craft_level && item.craft_level > 1 && (
<div className={`level-requirement ${item.meets_level ? 'met' : 'not-met'}`}>
📊 Requires Level {item.craft_level} {item.meets_level ? '✅' : `❌ (You are level ${profile?.level || 1})`}
</div>
)}
{/* Tool requirements */}
{item.tools && item.tools.length > 0 && (
<div className="tools-list">
<p className="tools-label">🔧 Required Tools:</p>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`tool-requirement ${tool.has_tool ? 'has-tool' : 'missing-tool'}`}>
<span>{tool.emoji} {tool.name}</span>
<span className="tool-durability">
(-{tool.durability_cost} durability)
{tool.has_tool && ` [${tool.tool_durability} available]`}
{!tool.has_tool && ' ❌'}
</span>
</div>
))}
</div>
)}
{/* Materials */}
<div className="materials-list">
<p className="materials-label">📦 Materials:</p>
{item.materials.map((mat: any, i: number) => (
<div key={i} className={`material ${mat.has_enough ? 'has-enough' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span className="material-count">{mat.available}/{mat.required}</span>
</div>
))}
</div>
<button
className="craft-btn"
disabled={!item.can_craft}
onClick={() => handleCraft(item.item_id)}
>
{!item.meets_level ? `Need Level ${item.craft_level}` :
!item.can_craft ? 'Missing Requirements' : 'Craft'}
</button>
</div>
))}
</div>
</div>
)}
{/* Repair Tab */}
{workbenchTab === 'repair' && (
<div className="workbench-content">
<div className="filter-box">
<input
type="text"
placeholder="🔍 Filter items..."
value={repairFilter}
onChange={(e) => setRepairFilter(e.target.value)}
className="filter-input"
/>
</div>
<div className="repairable-items-list">
{repairableItems.filter(item =>
item.name.toLowerCase().includes(repairFilter.toLowerCase())
).length === 0 && <p className="no-items">No repairable items found</p>}
{repairableItems
.filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase()))
.map((item: any, idx: number) => (
<div key={idx} className={`repairable-item ${!item.can_repair ? 'disabled' : ''}`}>
<div className="item-header">
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.emoji} {item.name}
</span>
{item.location === 'equipped' && <span className="equipped-badge"> Equipped</span>}
{item.location === 'inventory' && <span className="inventory-badge">🎒 Inventory</span>}
</div>
<div className="durability-display">
<div className="durability-label">
🔧 Durability:
</div>
<div className="durability-bar">
<div
className={`durability-fill ${item.durability_percent === 100 ? 'full' : ''}`}
style={{ width: `${item.durability_percent}%` }}
></div>
<span className="durability-text">{item.current_durability}/{item.max_durability}</span>
</div>
</div>
{!item.needs_repair && (
<p className="repair-info full-durability"> At full durability</p>
)}
{item.needs_repair && (
<>
{/* Tool requirements */}
{item.tools && item.tools.length > 0 && (
<div className="tools-list">
<p className="tools-label">🔧 Required Tools:</p>
{item.tools.map((tool: any, i: number) => (
<div key={i} className={`tool-requirement ${tool.has_tool ? 'has-tool' : 'missing-tool'}`}>
<span>{tool.emoji} {tool.name}</span>
<span className="tool-durability">
(-{tool.durability_cost} durability)
{tool.has_tool && ` [${tool.tool_durability} available]`}
{!tool.has_tool && ' ❌'}
</span>
</div>
))}
</div>
)}
{/* Materials */}
<div className="materials-list">
<p className="repair-info">Restores {item.repair_percentage}% durability</p>
{item.materials.map((mat: any, i: number) => (
<div key={i} className={`material ${mat.has_enough ? 'has-enough' : 'missing'}`}>
<span>{mat.emoji} {mat.name}</span>
<span className="material-count">{mat.available}/{mat.quantity}</span>
</div>
))}
</div>
</>
)}
<button
className="repair-btn"
disabled={!item.can_repair}
onClick={() => handleRepairFromMenu(item.unique_item_id, item.inventory_id)}
>
{!item.needs_repair ? 'Already Full' :
!item.can_repair ? 'Missing Requirements' : 'Repair'}
</button>
</div>
))}
</div>
</div>
)}
{/* Uncraft Tab */}
{workbenchTab === 'uncraft' && (
<div className="workbench-content">
<div className="filter-box">
<input
type="text"
placeholder="🔍 Filter items..."
value={uncraftFilter}
onChange={(e) => setUncraftFilter(e.target.value)}
className="filter-input"
/>
</div>
<div className="uncraftable-items-list">
{uncraftableItems.filter(item =>
item.name.toLowerCase().includes(uncraftFilter.toLowerCase())
).length === 0 && <p className="no-items">No uncraftable items found</p>}
{uncraftableItems
.filter((item: any) => item.name.toLowerCase().includes(uncraftFilter.toLowerCase()))
.map((item: any, idx: number) => {
// Calculate adjusted yield based on durability
const durabilityRatio = item.unique_item_data
? item.unique_item_data.durability_percent / 100
: 1.0
const adjustedYield = item.base_yield.map((mat: any) => ({
...mat,
adjusted_quantity: Math.floor(mat.quantity * durabilityRatio)
}))
return (
<div key={idx} className={`uncraftable-item`}>
<div className="item-header">
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.emoji} {item.name}
</span>
</div>
{/* Unique item details */}
{item.unique_item_data && (
<div className="unique-item-details">
{/* Durability bar */}
<div className="durability-display">
<div className="durability-label">
🔧 Durability:
</div>
<div className="durability-bar">
<div
className={`durability-fill ${item.unique_item_data.durability_percent === 100 ? 'full' : ''}`}
style={{ width: `${item.unique_item_data.durability_percent}%` }}
></div>
<span className="durability-text">{item.unique_item_data.current_durability}/{item.unique_item_data.max_durability}</span>
</div>
</div>
{/* Format stats nicely */}
{item.unique_item_data.unique_stats && Object.keys(item.unique_item_data.unique_stats).length > 0 && (
<div className="unique-stats">
{Object.entries(item.unique_item_data.unique_stats).map(([stat, value]: [string, any]) => {
// Format stat names and values
let displayName = stat.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
let displayValue = value
// Combine min/max stats
if (stat === 'damage_min' && item.unique_item_data.unique_stats.damage_max) {
displayName = 'Damage'
displayValue = `${value}-${item.unique_item_data.unique_stats.damage_max}`
return (
<span key={stat} className="stat-badge">
{displayName}: {displayValue}
</span>
)
} else if (stat === 'damage_max') {
return null // Skip, already shown with damage_min
} else if (stat === 'armor') {
return (
<span key={stat} className="stat-badge">
🛡 {displayName}: {displayValue}
</span>
)
} else {
return (
<span key={stat} className="stat-badge">
{displayName}: {displayValue}
</span>
)
}
})}
</div>
)}
</div>
)}
{/* Durability impact warning */}
{durabilityRatio < 1.0 && (
<div className="uncraft-warning">
Item condition will reduce yield by {Math.round((1 - durabilityRatio) * 100)}%
</div>
)}
{durabilityRatio < 0.1 && (
<div className="uncraft-warning" style={{color: 'red'}}>
Item too damaged - will yield NO materials!
</div>
)}
{/* Loss chance warning */}
{item.loss_chance && (
<div className="uncraft-warning">
{Math.round(item.loss_chance * 100)}% chance to lose each material
</div>
)}
{/* Yield materials with durability adjustment */}
{adjustedYield && adjustedYield.length > 0 && (
<div className="materials-list">
<p className="materials-label"> Expected yield:</p>
{adjustedYield.map((mat: any, i: number) => (
<div key={i} className="material">
<span>{mat.emoji} {mat.name}</span>
<span className="material-count">
{durabilityRatio < 1.0 && durabilityRatio >= 0.1 ? (
<>
<span style={{textDecoration: 'line-through', opacity: 0.5}}>x{mat.quantity}</span>
{' → '}
<span style={{color: '#ffa500'}}>x{mat.adjusted_quantity}</span>
</>
) : durabilityRatio < 0.1 ? (
<span style={{color: 'red'}}>x0</span>
) : (
<>x{mat.quantity}</>
)}
</span>
</div>
))}
{durabilityRatio >= 0.1 && (
<p className="materials-note" style={{fontSize: '0.85em', opacity: 0.7, marginTop: '5px'}}>
* Subject to {Math.round((item.loss_chance || 0.3) * 100)}% random loss per material
</p>
)}
</div>
)}
<button
className="uncraft-btn"
onClick={() => {
const yieldPreview = adjustedYield.map((m: any) =>
durabilityRatio < 0.1 ? `${m.emoji} ${m.name} x0` :
`${m.emoji} ${m.name} x${m.adjusted_quantity}`
).join(', ')
if (window.confirm(
`⚠️ Salvage ${item.name}?\n\n` +
`Expected yield: ${yieldPreview}\n` +
(durabilityRatio < 1.0 ? `(Reduced due to ${item.unique_item_data.durability_percent}% condition)\n` : '') +
`Each material has ${Math.round((item.loss_chance || 0.3) * 100)}% chance to be lost!\n\n` +
`This will destroy the item permanently.`
)) {
handleUncraft(item.unique_item_id, item.inventory_id)
}
}}
>
Salvage
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{location.image_url && (
<div className="location-image-container">
<img src={location.image_url} alt={location.name} className="location-image" onError={(e: any) => (e.currentTarget.style.display = 'none')} />
</div>
)}
<div className="location-description-box">
<p className="location-description">{location.description}</p>
</div>
</div>
{message && (
<div className="message-box" onClick={() => setMessage('')}>
{message}
</div>
)}
{/* Location Messages Log */}
{locationMessages.length > 0 && (
<div className="location-messages-log">
<h4>📜 Recent Activity</h4>
<div className="messages-scroll">
{locationMessages.slice(-10).reverse().map((msg, idx) => (
<div key={idx} className="location-message-item">
<span className="message-time">{msg.time}</span>
<span className="message-text">{msg.message}</span>
</div>
))}
</div>
</div>
)}
{/* NPCs, Items, and Entities on ground - below the location image */}
<div className={`ground-entities mobile-menu-panel bottom ${mobileMenuOpen === 'bottom' ? 'open' : ''}`}>
{/* Enemies */}
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
<div className="entity-section enemies-section">
<h3> Enemies</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
<div key={i} className="entity-card enemy-card">
{enemy.id && (
<div className="entity-image">
<img
src={`/images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.png`}
alt={enemy.name}
onError={(e: any) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
)}
<div className="entity-info">
<div className="entity-name enemy-name">{enemy.name}</div>
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
</div>
<button
className="entity-action-btn combat-btn"
onClick={() => handleInitiateCombat(enemy.id)}
>
Fight
</button>
</div>
))}
</div>
</div>
)}
{/* Corpses */}
{location.corpses && location.corpses.length > 0 && (
<div className="entity-section corpses-section">
<h3>💀 Corpses</h3>
<div className="entity-list">
{location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card">
<div className="entity-info">
<div className="entity-name">{corpse.emoji} {corpse.name}</div>
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
</div>
<button
className="entity-action-btn loot-btn"
onClick={() => handleLootCorpse(corpse.id)}
disabled={corpse.loot_count === 0}
>
🔍 Examine
</button>
</div>
{/* Expanded corpse details */}
{expandedCorpse === corpse.id && corpseDetails && (
<div className="corpse-details">
<div className="corpse-details-header">
<h4>Lootable Items:</h4>
<button
className="close-btn"
onClick={() => {
setExpandedCorpse(null)
setCorpseDetails(null)
}}
>
</button>
</div>
<div className="corpse-items-list">
{corpseDetails.loot_items.map((item: any) => (
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
<div className="corpse-item-info">
<div className="corpse-item-name">
{item.emoji} {item.item_name}
</div>
<div className="corpse-item-qty">
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
</div>
{item.required_tool && (
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
🔧 {item.required_tool_name} {item.has_tool ? '✓' : '✗'}
</div>
)}
</div>
<button
className="corpse-item-loot-btn"
onClick={() => handleLootCorpseItem(corpse.id, item.index)}
disabled={!item.can_loot}
title={!item.can_loot ? `Requires ${item.required_tool_name}` : 'Loot this item'}
>
{item.can_loot ? '📦 Loot' : '🔒'}
</button>
</div>
))}
</div>
<button
className="loot-all-btn"
onClick={() => handleLootCorpseItem(corpse.id, null)}
>
📦 Loot All Available
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* NPCs */}
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
<div className="entity-section npcs-section">
<h3>👥 NPCs</h3>
<div className="entity-list">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card">
<span className="entity-icon">🧑</span>
<div className="entity-info">
<div className="entity-name">{npc.name}</div>
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
</div>
<button className="entity-action-btn">Talk</button>
</div>
))}
</div>
</div>
)}
{location.items.length > 0 && (
<div className="entity-section items-section">
<h3>📦 Items on Ground</h3>
<div className="entity-list">
{location.items.map((item: any, i: number) => (
<div key={i} className="entity-card item-card">
<span className="entity-icon">
{item.emoji || '📦'}
</span>
<div className="entity-info">
<div className={`entity-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name || 'Unknown Item'}</div>
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
</div>
<div className="item-info-btn-container">
<button className="entity-action-btn info" title="Item Info">Info</button>
<div className="item-info-tooltip">
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
{item.weight !== undefined && item.weight > 0 && (
<div className="item-tooltip-stat">
Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
</div>
)}
{item.volume !== undefined && item.volume > 0 && (
<div className="item-tooltip-stat">
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
</div>
)}
{item.hp_restore && item.hp_restore > 0 && (
<div className="item-tooltip-stat">
HP Restore: +{item.hp_restore}
</div>
)}
{item.stamina_restore && item.stamina_restore > 0 && (
<div className="item-tooltip-stat">
Stamina Restore: +{item.stamina_restore}
</div>
)}
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
<div className="item-tooltip-stat">
Damage: {item.damage_min}-{item.damage_max}
</div>
)}
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {item.tier}
</div>
)}
</div>
</div>
{item.quantity === 1 ? (
<button
className="entity-action-btn pickup"
onClick={() => handlePickup(item.id, 1)}
>
Pick Up
</button>
) : (
<div className="item-pickup-btn-container">
<button className="entity-action-btn pickup">Pick Up </button>
<div className="item-pickup-menu">
<button className="item-pickup-option" onClick={() => handlePickup(item.id, 1)}>Pick Up 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => handlePickup(item.id, 5)}>Pick Up 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => handlePickup(item.id, 10)}>Pick Up 10</button>
)}
<button className="item-pickup-option" onClick={() => handlePickup(item.id, item.quantity)}>Pick Up All ({item.quantity})</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Other Players */}
{location.other_players && location.other_players.length > 0 && (
<div className="entity-section players-section">
<h3>👥 Other Players</h3>
<div className="entity-list">
{location.other_players.map((player: any, i: number) => (
<div key={i} className="entity-card player-card">
<span className="entity-icon">🧍</span>
<div className="entity-info">
<div className="entity-name">{player.username}</div>
<div className="entity-level">Lv. {player.level}</div>
{player.level_diff !== undefined && (
<div className="level-diff">
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
</div>
)}
</div>
{player.can_pvp && (
<button
className="pvp-btn"
onClick={() => handleInitiatePvP(player.id)}
title={`Attack ${player.username}`}
>
Attack
</button>
)}
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
<div className="pvp-disabled-reason">
Level difference too high
</div>
)}
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
<div className="pvp-disabled-reason">
Area too safe for PvP
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Right Sidebar: Profile & Inventory */}
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>👤 Character</h3>
{/* Health & Stamina Bars */}
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> HP</span>
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill health"
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
</div>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Stamina</span>
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill stamina"
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
</div>
</div>
</div>
{/* Character Info */}
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">Level:</span>
<span className="sidebar-value">{profile.level}</span>
</div>
{/* XP Progress Bar */}
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> XP</span>
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill xp"
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
</div>
</div>
{profile.unspent_points > 0 && (
<div className="sidebar-stat-row highlight">
<span className="sidebar-label"> Unspent:</span>
<span className="sidebar-value">{profile.unspent_points}</span>
</div>
)}
<div className="sidebar-divider"></div>
<div className="sidebar-stat-row">
<span className="sidebar-label">💪 STR:</span>
<span className="sidebar-value">{profile.strength}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => handleSpendPoint('strength')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🏃 AGI:</span>
<span className="sidebar-value">{profile.agility}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => handleSpendPoint('agility')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🛡 END:</span>
<span className="sidebar-value">{profile.endurance}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => handleSpendPoint('endurance')}>+</button>
)}
</div>
<div className="sidebar-stat-row">
<span className="sidebar-label">🧠 INT:</span>
<span className="sidebar-value">{profile.intellect}</span>
{profile.unspent_points > 0 && (
<button className="stat-plus-btn" onClick={() => handleSpendPoint('intellect')}>+</button>
)}
</div>
</div>
)}
</div>
{/* Equipment Display */}
<div className="equipment-sidebar">
<h3> Equipment</h3>
<div className="equipment-grid">
{/* Row 1: Head */}
<div className="equipment-row">
<div className={`equipment-slot ${equipment.head ? 'filled' : 'empty'}`}>
{equipment.head ? (
<>
<button className="equipment-unequip-btn" onClick={() => handleUnequipItem('head')} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{equipment.head.emoji}</span>
<span className={`equipment-name ${equipment.head.tier ? `tier-${equipment.head.tier}` : ''}`}>{equipment.head.name}</span>
{equipment.head.durability && equipment.head.durability !== null && (
<span className="equipment-durability">{equipment.head.durability}/{equipment.head.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{equipment.head.description && <div className="item-tooltip-desc">{equipment.head.description}</div>}
{equipment.head.stats && Object.keys(equipment.head.stats).length > 0 && (
<>
{equipment.head.stats.armor && (
<div className="item-tooltip-stat">
🛡 Armor: +{equipment.head.stats.armor}
</div>
)}
{equipment.head.stats.hp_max && (
<div className="item-tooltip-stat">
Max HP: +{equipment.head.stats.hp_max}
</div>
)}
{equipment.head.stats.stamina_max && (
<div className="item-tooltip-stat">
Max Stamina: +{equipment.head.stats.stamina_max}
</div>
)}
</>
)}
{equipment.head.durability !== undefined && equipment.head.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {equipment.head.durability}/{equipment.head.max_durability}
</div>
)}
{equipment.head.tier !== undefined && equipment.head.tier !== null && equipment.head.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {equipment.head.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">🪖</span>
<span className="equipment-slot-label">Head</span>
</>
)}
</div>
</div>
{/* Row 2: Weapon, Torso, Backpack */}
<div className="equipment-row three-cols">
<div className={`equipment-slot ${equipment.weapon ? 'filled' : 'empty'}`}>
{equipment.weapon ? (
<>
<button className="equipment-unequip-btn" onClick={() => handleUnequipItem('weapon')} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{equipment.weapon.emoji}</span>
<span className={`equipment-name ${equipment.weapon.tier ? `tier-${equipment.weapon.tier}` : ''}`}>{equipment.weapon.name}</span>
{equipment.weapon.durability && equipment.weapon.durability !== null && (
<span className="equipment-durability">{equipment.weapon.durability}/{equipment.weapon.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{equipment.weapon.description && <div className="item-tooltip-desc">{equipment.weapon.description}</div>}
{equipment.weapon.stats && Object.keys(equipment.weapon.stats).length > 0 && (
<div className="item-tooltip-stat">
Damage: {equipment.weapon.stats.damage_min}-{equipment.weapon.stats.damage_max}
</div>
)}
{equipment.weapon.weapon_effects && Object.keys(equipment.weapon.weapon_effects).length > 0 && (
<div className="item-tooltip-stat">
Effects: {Object.entries(equipment.weapon.weapon_effects).map(([key, val]: [string, any]) => `${key} (${(val.chance * 100).toFixed(0)}%)`).join(', ')}
</div>
)}
{equipment.weapon.durability !== undefined && equipment.weapon.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {equipment.weapon.durability}/{equipment.weapon.max_durability}
</div>
)}
{equipment.weapon.tier !== undefined && equipment.weapon.tier !== null && equipment.weapon.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {equipment.weapon.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji"></span>
<span className="equipment-slot-label">Weapon</span>
</>
)}
</div>
<div className={`equipment-slot ${equipment.torso ? 'filled' : 'empty'}`}>
{equipment.torso ? (
<>
<button className="equipment-unequip-btn" onClick={() => handleUnequipItem('torso')} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{equipment.torso.emoji}</span>
<span className={`equipment-name ${equipment.torso.tier ? `tier-${equipment.torso.tier}` : ''}`}>{equipment.torso.name}</span>
{equipment.torso.durability && equipment.torso.durability !== null && (
<span className="equipment-durability">{equipment.torso.durability}/{equipment.torso.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{equipment.torso.description && <div className="item-tooltip-desc">{equipment.torso.description}</div>}
{equipment.torso.stats && Object.keys(equipment.torso.stats).length > 0 && (
<>
{equipment.torso.stats.armor && (
<div className="item-tooltip-stat">
🛡 Armor: +{equipment.torso.stats.armor}
</div>
)}
{equipment.torso.stats.hp_max && (
<div className="item-tooltip-stat">
Max HP: +{equipment.torso.stats.hp_max}
</div>
)}
{equipment.torso.stats.stamina_max && (
<div className="item-tooltip-stat">
Max Stamina: +{equipment.torso.stats.stamina_max}
</div>
)}
</>
)}
{equipment.torso.durability !== undefined && equipment.torso.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {equipment.torso.durability}/{equipment.torso.max_durability}
</div>
)}
{equipment.torso.tier !== undefined && equipment.torso.tier !== null && equipment.torso.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {equipment.torso.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">👕</span>
<span className="equipment-slot-label">Torso</span>
</>
)}
</div>
<div className={`equipment-slot ${equipment.backpack ? 'filled' : 'empty'}`}>
{equipment.backpack ? (
<>
<button className="equipment-unequip-btn" onClick={() => handleUnequipItem('backpack')} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{equipment.backpack.emoji}</span>
<span className={`equipment-name ${equipment.backpack.tier ? `tier-${equipment.backpack.tier}` : ''}`}>{equipment.backpack.name}</span>
{equipment.backpack.durability && equipment.backpack.durability !== null && (
<span className="equipment-durability">{equipment.backpack.durability}/{equipment.backpack.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{equipment.backpack.description && <div className="item-tooltip-desc">{equipment.backpack.description}</div>}
{equipment.backpack.stats && Object.keys(equipment.backpack.stats).length > 0 && (
<>
{equipment.backpack.stats.weight_capacity && (
<div className="item-tooltip-stat">
Weight: +{equipment.backpack.stats.weight_capacity}kg
</div>
)}
{equipment.backpack.stats.volume_capacity && (
<div className="item-tooltip-stat">
📦 Volume: +{equipment.backpack.stats.volume_capacity}L
</div>
)}
</>
)}
{equipment.backpack.durability !== undefined && equipment.backpack.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {equipment.backpack.durability}/{equipment.backpack.max_durability}
</div>
)}
{equipment.backpack.tier !== undefined && equipment.backpack.tier !== null && equipment.backpack.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {equipment.backpack.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">🎒</span>
<span className="equipment-slot-label">Backpack</span>
</>
)}
</div>
</div>
{/* Row 3: Legs */}
<div className="equipment-row">
<div className={`equipment-slot ${equipment.legs ? 'filled' : 'empty'}`}>
{equipment.legs ? (
<>
<button className="equipment-unequip-btn" onClick={() => handleUnequipItem('legs')} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{equipment.legs.emoji}</span>
<span className={`equipment-name ${equipment.legs.tier ? `tier-${equipment.legs.tier}` : ''}`}>{equipment.legs.name}</span>
{equipment.legs.durability && equipment.legs.durability !== null && (
<span className="equipment-durability">{equipment.legs.durability}/{equipment.legs.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{equipment.legs.description && <div className="item-tooltip-desc">{equipment.legs.description}</div>}
{equipment.legs.stats && Object.keys(equipment.legs.stats).length > 0 && (
<>
{equipment.legs.stats.armor && (
<div className="item-tooltip-stat">
🛡 Armor: +{equipment.legs.stats.armor}
</div>
)}
{equipment.legs.stats.hp_max && (
<div className="item-tooltip-stat">
Max HP: +{equipment.legs.stats.hp_max}
</div>
)}
{equipment.legs.stats.stamina_max && (
<div className="item-tooltip-stat">
Max Stamina: +{equipment.legs.stats.stamina_max}
</div>
)}
</>
)}
{equipment.legs.durability !== undefined && equipment.legs.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {equipment.legs.durability}/{equipment.legs.max_durability}
</div>
)}
{equipment.legs.tier !== undefined && equipment.legs.tier !== null && equipment.legs.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {equipment.legs.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">👖</span>
<span className="equipment-slot-label">Legs</span>
</>
)}
</div>
</div>
{/* Row 4: Feet */}
<div className="equipment-row">
<div className={`equipment-slot ${equipment.feet ? 'filled' : 'empty'}`}>
{equipment.feet ? (
<>
<button className="equipment-unequip-btn" onClick={() => handleUnequipItem('feet')} title="Unequip"></button>
<div className="equipment-item-content">
<span className="equipment-emoji">{equipment.feet.emoji}</span>
<span className={`equipment-name ${equipment.feet.tier ? `tier-${equipment.feet.tier}` : ''}`}>{equipment.feet.name}</span>
{equipment.feet.durability && equipment.feet.durability !== null && (
<span className="equipment-durability">{equipment.feet.durability}/{equipment.feet.max_durability}</span>
)}
</div>
<div className="equipment-tooltip">
{equipment.feet.description && <div className="item-tooltip-desc">{equipment.feet.description}</div>}
{equipment.feet.stats && Object.keys(equipment.feet.stats).length > 0 && (
<>
{equipment.feet.stats.armor && (
<div className="item-tooltip-stat">
🛡 Armor: +{equipment.feet.stats.armor}
</div>
)}
{equipment.feet.stats.hp_max && (
<div className="item-tooltip-stat">
Max HP: +{equipment.feet.stats.hp_max}
</div>
)}
{equipment.feet.stats.stamina_max && (
<div className="item-tooltip-stat">
Max Stamina: +{equipment.feet.stats.stamina_max}
</div>
)}
</>
)}
{equipment.feet.durability !== undefined && equipment.feet.durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {equipment.feet.durability}/{equipment.feet.max_durability}
</div>
)}
{equipment.feet.tier !== undefined && equipment.feet.tier !== null && equipment.feet.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {equipment.feet.tier}
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">👟</span>
<span className="equipment-slot-label">Feet</span>
</>
)}
</div>
</div>
</div>
</div>
{/* Enhanced Inventory */}
<div className="inventory-sidebar">
<h3>🎒 Inventory</h3>
{/* Weight and Volume Bars */}
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label"> Weight</span>
<span className="sidebar-stat-numbers">
{profile?.current_weight || 0}/{profile?.max_weight || 100}
</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill weight"
style={{
width: `${Math.min(((profile?.current_weight || 0) / (profile?.max_weight || 100)) * 100, 100)}%`
}}
></div>
<span className="progress-percentage">
{Math.round(Math.min(((profile?.current_weight || 0) / (profile?.max_weight || 100)) * 100, 100))}%
</span>
</div>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">📦 Volume</span>
<span className="sidebar-stat-numbers">
{profile?.current_volume || 0}/{profile?.max_volume || 100}
</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill volume"
style={{
width: `${Math.min(((profile?.current_volume || 0) / (profile?.max_volume || 100)) * 100, 100)}%`
}}
></div>
<span className="progress-percentage">
{Math.round(Math.min(((profile?.current_volume || 0) / (profile?.max_volume || 100)) * 100, 100))}%
</span>
</div>
</div>
</div>
{/* Inventory Items - Grouped by Category */}
<div className="inventory-items-scrollable">
{playerState.inventory.filter((item: any) => !item.is_equipped).length === 0 ? (
<p className="sidebar-empty">No items</p>
) : (
Object.entries(
playerState.inventory
.filter((item: any) => !item.is_equipped)
.reduce((acc: any, item: any) => {
const category = item.type || 'misc'
if (!acc[category]) acc[category] = []
acc[category].push(item)
return acc
}, {})
).sort(([catA], [catB]) => catA.localeCompare(catB))
.map(([category, items]: [string, any]) => {
const isCollapsed = collapsedCategories.has(category)
const sortedItems = (items as any[]).sort((a, b) => a.name.localeCompare(b.name))
return (
<div key={category} className="inventory-category-group">
<div
className="category-header clickable"
onClick={() => {
const newSet = new Set(collapsedCategories)
if (isCollapsed) {
newSet.delete(category)
} else {
newSet.add(category)
}
setCollapsedCategories(newSet)
}}
>
<span className="category-toggle">{isCollapsed ? '▶' : '▼'}</span>
{category === 'weapon' ? '⚔️ Weapons' :
category === 'armor' ? '🛡️ Armor' :
category === 'consumable' ? '🍖 Consumables' :
category === 'resource' ? '📦 Resources' :
category === 'quest' ? '📜 Quest Items' :
`📦 ${category.charAt(0).toUpperCase() + category.slice(1)}`}
<span className="category-count">({sortedItems.length})</span>
</div>
{!isCollapsed && sortedItems.map((item: any, i: number) => (
<div
key={i}
className="inventory-item-row-hover"
>
<div className="item-header-row">
<div className="item-icon-small">
<span>{item.emoji || '📦'}</span>
</div>
<div className="item-name-qty">
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
{item.name}
{item.quantity > 1 && <span className="item-qty"> ×{item.quantity}</span>}
{item.hp_restore > 0 && <span className="item-effect"> +{item.hp_restore}</span>}
{item.stamina_restore > 0 && <span className="item-effect"> +{item.stamina_restore}</span>}
</span>
</div>
</div>
{/* Hover tooltip */}
<div className="inventory-item-tooltip">
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
{item.weight !== undefined && item.weight > 0 && (
<div className="item-tooltip-stat">
Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
</div>
)}
{item.volume !== undefined && item.volume > 0 && (
<div className="item-tooltip-stat">
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
</div>
)}
{/* Equipment stats */}
{item.stats && item.stats.weight_capacity && (
<div className="item-tooltip-stat">
Weight Capacity: +{item.stats.weight_capacity}kg
</div>
)}
{item.stats && item.stats.volume_capacity && (
<div className="item-tooltip-stat">
📦 Volume Capacity: +{item.stats.volume_capacity}L
</div>
)}
{item.stats && item.stats.armor && (
<div className="item-tooltip-stat">
🛡 Armor: +{item.stats.armor}
</div>
)}
{item.stats && item.stats.hp_max && (
<div className="item-tooltip-stat">
Max HP: +{item.stats.hp_max}
</div>
)}
{item.stats && item.stats.stamina_max && (
<div className="item-tooltip-stat">
Max Stamina: +{item.stats.stamina_max}
</div>
)}
{item.hp_restore && item.hp_restore > 0 && (
<div className="item-tooltip-stat">
HP Restore: +{item.hp_restore}
</div>
)}
{item.stamina_restore && item.stamina_restore > 0 && (
<div className="item-tooltip-stat">
Stamina Restore: +{item.stamina_restore}
</div>
)}
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
<div className="item-tooltip-stat">
Damage: {item.damage_min}-{item.damage_max}
</div>
)}
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
<div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability}
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {item.tier}
</div>
)}
</div>
<div className="item-actions-hover">
{item.consumable && (
<button className="item-action-btn use" onClick={() => handleUseItem(item.item_id)}>Use</button>
)}
{item.equippable && !item.is_equipped && (
<button className="item-action-btn equip" onClick={() => handleEquipItem(item.id)}>Equip</button>
)}
{item.quantity === 1 ? (
<button className="item-action-btn drop" onClick={() => handleDropItem(item.item_id, 1)}>Drop</button>
) : (
<div className="item-drop-btn-container">
<button className="item-action-btn drop">Drop </button>
<div className="item-drop-menu">
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, 1)}>Drop 1</button>
{item.quantity >= 5 && (
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, 5)}>Drop 5</button>
)}
{item.quantity >= 10 && (
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, 10)}>Drop 10</button>
)}
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, item.quantity)}>Drop All ({item.quantity})</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
)
})
)}
</div>
{/* Item Actions Panel */}
{selectedItem && (
<div className="item-actions-panel">
<div className="item-details-header">
<strong>{selectedItem.name}</strong>
<button className="close-btn" onClick={() => setSelectedItem(null)}>×</button>
</div>
{selectedItem.description && (
<p className="item-description">{selectedItem.description}</p>
)}
<div className="item-action-buttons">
{selectedItem.usable && (
<button className="item-action-btn use-btn" onClick={() => handleItemAction('use', selectedItem.id)}>
🍷 Use
</button>
)}
{selectedItem.equippable && !selectedItem.is_equipped && (
<button className="item-action-btn equip-btn" onClick={() => handleItemAction('equip', selectedItem.id)}>
Equip
</button>
)}
{selectedItem.is_equipped && (
<button className="item-action-btn unequip-btn" onClick={() => handleItemAction('unequip', selectedItem.id)}>
🎒 Unequip
</button>
)}
<button className="item-action-btn drop-btn" onClick={() => handleItemAction('drop', selectedItem.id)}>
🗑 Drop
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
return (
<div className="game-container">
{/* Death Overlay */}
{profile?.is_dead && (
<div className="death-overlay">
<div className="death-modal">
<h1>💀 You Have Died</h1>
<p>Your character has been defeated in combat.</p>
<p>All your items have been placed in a corpse at your death location.</p>
<p>You can retrieve them with another character before they decay (24 hours).</p>
<button className="death-btn" onClick={() => window.location.href = '/characters'}>
Return to Character Selection
</button>
</div>
</div>
)}
<GameHeader
className={mobileHeaderOpen ? 'open' : ''}
/>
{/* Mobile Header Toggle - only show in main view */}
{mobileMenuOpen === 'none' && (
<button
className="mobile-header-toggle"
onClick={() => setMobileHeaderOpen(!mobileHeaderOpen)}
>
{mobileHeaderOpen ? '✕' : '☰'}
</button>
)}
<main className="game-main">
{renderExploreTab()}
{/* Mobile Tab Navigation */}
<div className="mobile-menu-buttons">
<button
className={`mobile-menu-btn left-btn ${mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(mobileMenuOpen === 'left' ? 'none' : 'left')}
>
<span>🧭</span>
</button>
<button
className={`mobile-menu-btn bottom-btn ${mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
disabled={!!combatState}
>
<span>📍</span>
</button>
<button
className={`mobile-menu-btn right-btn ${mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(mobileMenuOpen === 'right' ? 'none' : 'right')}
>
<span>🎒</span>
</button>
</div>
{/* Mobile Menu Overlays */}
{mobileMenuOpen !== 'none' && (
<div
className="mobile-menu-overlay"
onClick={() => setMobileMenuOpen('none')}
/>
)}
</main>
</div>
)
}
export default Game