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(null) const [location, setLocation] = useState(null) const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [message, setMessage] = useState('') const [selectedItem, setSelectedItem] = useState(null) const [combatState, setCombatState] = useState(null) const [combatLog, setCombatLog] = useState>([]) const [enemyName, setEnemyName] = useState('') const [enemyImage, setEnemyImage] = useState('') const [collapsedCategories, setCollapsedCategories] = useState>(new Set()) const [expandedCorpse, setExpandedCorpse] = useState(null) const [corpseDetails, setCorpseDetails] = useState(null) const [movementCooldown, setMovementCooldown] = useState(0) const [enemyTurnMessage, setEnemyTurnMessage] = useState('') const [equipment, setEquipment] = useState({}) const [showCraftingMenu, setShowCraftingMenu] = useState(false) const [showRepairMenu, setShowRepairMenu] = useState(false) const [craftableItems, setCraftableItems] = useState([]) const [repairableItems, setRepairableItems] = useState([]) const [workbenchTab, setWorkbenchTab] = useState<'craft' | 'repair' | 'uncraft'>('craft') const [craftFilter, setCraftFilter] = useState('') const [craftCategoryFilter, setCraftCategoryFilter] = useState('all') const [repairFilter, setRepairFilter] = useState('') const [uncraftFilter, setUncraftFilter] = useState('') const [uncraftableItems, setUncraftableItems] = useState([]) const [lastSeenPvPAction, setLastSeenPvPAction] = useState(null) // Use ref for synchronous duplicate checking (state updates are async) const lastSeenPvPActionRef = useRef(null) // Client-side PvP timer that counts down every second const [pvpTimeRemaining, setPvpTimeRemaining] = useState(null) const pvpTimerRef = useRef(null) // Mobile menu state const [mobileMenuOpen, setMobileMenuOpen] = useState<'none' | 'left' | 'right' | 'bottom'>('none') const [mobileHeaderOpen, setMobileHeaderOpen] = useState(false) // Location message log (cleared when changing locations) const [locationMessages, setLocationMessages] = useState>([]) // Interactable cooldown timers (instance_id -> expiry timestamp) const [interactableCooldowns, setInteractableCooldowns] = useState>({}) // 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 = {} 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
Loading game...
} if (!playerState || !location) { return
Failed to load game state
} // 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 ( ) } const renderExploreTab = () => (
{/* Left Sidebar: Movement & Surroundings */}
{/* Movement Controls */}

🧭 Travel

{/* Top row */} {renderCompassButton('northwest', '↖', 'nw')} {renderCompassButton('north', '↑', 'n')} {renderCompassButton('northeast', '↗', 'ne')} {/* Middle row */} {renderCompassButton('west', '←', 'w')}
🧭
{renderCompassButton('east', '→', 'e')} {/* Bottom row */} {renderCompassButton('southwest', '↙', 'sw')} {renderCompassButton('south', '↓', 's')} {renderCompassButton('southeast', 'ā†˜', 'se')}
{/* Cooldown indicator */} {movementCooldown > 0 && (
ā³ Wait {movementCooldown}s before moving
)} {/* Special movements */}
{location.directions.includes('up') && ( )} {location.directions.includes('down') && ( )} {location.directions.includes('enter') && ( )} {location.directions.includes('inside') && ( )} {location.directions.includes('exit') && ( )} {location.directions.includes('outside') && ( )}
{/* Surroundings */} {(location.interactables && location.interactables.length > 0) && (

🌿 Surroundings

{/* Interactables */} {location.interactables && location.interactables.map((interactable: any) => { return (
{interactable.image_path && (
{interactable.name} { e.currentTarget.style.display = 'none'; }} />
)}
{interactable.name}
{interactable.actions && interactable.actions.length > 0 && (
{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 ( ) })}
)}
) })}
)}
{/* Close left-sidebar */} {/* Center: Location/Combat Content */}
{combatState ? ( /* Combat View */

{combatState.is_pvp ? 'āš”ļø PvP Combat' : `āš”ļø Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`}

{combatState.is_pvp ? ( /* PvP Combat UI */
{/* Opponent Info */}
{(() => { const opponent = combatState.pvp_combat.is_attacker ? combatState.pvp_combat.defender : combatState.pvp_combat.attacker return ( <>

šŸ—”ļø {opponent.username}

Level {opponent.level}
HP: {opponent.hp} / {opponent.max_hp}
) })()}
{/* Your Info */}
{(() => { const you = combatState.pvp_combat.is_attacker ? combatState.pvp_combat.attacker : combatState.pvp_combat.defender return ( <>

šŸ›”ļø You

Level {you.level}
HP: {you.hp} / {you.max_hp}
) })()}
{combatState.pvp_combat.combat_over ? ( {combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "šŸƒ Combat Ended" : "šŸ’€ Combat Over"} ) : combatState.pvp_combat.your_turn ? ( āœ… Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) ) : ( ā³ Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) )}
{!combatState.pvp_combat.combat_over ? ( <> ) : ( )}
) : ( /* PvE Combat UI */ <>
{enemyName
Enemy HP: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
{playerState && (
Your HP: {playerState.health} / {playerState.max_health}
)}
{!combatState.combat_over ? ( enemyTurnMessage ? ( šŸ—”ļø Enemy's turn... ) : combatState.combat?.turn === 'player' ? ( āœ… Your Turn ) : ( āš ļø Enemy Turn ) ) : ( {combatState.player_won ? "āœ… Victory!" : combatState.player_fled ? "šŸƒ Escaped!" : "šŸ’€ Defeated"} )}
{!combatState.combat_over ? ( <> ) : ( )}
)} {/* Combat Log */}

Combat Log:

{combatLog.map((entry: any, i: number) => (
{entry.time} {entry.isPlayer ? '→' : '←'} {entry.message}
))}
) : ( /* Normal Location View */ <>

{location.name} {location.danger_level !== undefined && location.danger_level === 0 && ( āœ“ Safe )} {location.danger_level !== undefined && location.danger_level > 0 && ( āš ļø {location.danger_level} )}

{location.tags && location.tags.length > 0 && (
{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 ( {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}`} ) })}
)} {/* Workbench Menu (Crafting, Repair, Uncraft) */} {(showCraftingMenu || showRepairMenu) && (

šŸ”§ Workbench

{/* Tabs */}
{/* Craft Tab */} {workbenchTab === 'craft' && (
setCraftFilter(e.target.value)} className="filter-input" />
{craftableItems.filter(item => item.name.toLowerCase().includes(craftFilter.toLowerCase()) && (craftCategoryFilter === 'all' || item.category === craftCategoryFilter) ).length === 0 &&

No craftable items found

} {craftableItems .filter(item => item.name.toLowerCase().includes(craftFilter.toLowerCase()) && (craftCategoryFilter === 'all' || item.category === craftCategoryFilter) ) .map((item: any) => (
{item.emoji} {item.name} {item.slot && [{item.slot}]}
{item.description &&

{item.description}

} {/* Level requirement */} {item.craft_level && item.craft_level > 1 && (
šŸ“Š Requires Level {item.craft_level} {item.meets_level ? 'āœ…' : `āŒ (You are level ${profile?.level || 1})`}
)} {/* Tool requirements */} {item.tools && item.tools.length > 0 && (

šŸ”§ Required Tools:

{item.tools.map((tool: any, i: number) => (
{tool.emoji} {tool.name} (-{tool.durability_cost} durability) {tool.has_tool && ` [${tool.tool_durability} available]`} {!tool.has_tool && ' āŒ'}
))}
)} {/* Materials */}

šŸ“¦ Materials:

{item.materials.map((mat: any, i: number) => (
{mat.emoji} {mat.name} {mat.available}/{mat.required}
))}
))}
)} {/* Repair Tab */} {workbenchTab === 'repair' && (
setRepairFilter(e.target.value)} className="filter-input" />
{repairableItems.filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase()) ).length === 0 &&

No repairable items found

} {repairableItems .filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase())) .map((item: any, idx: number) => (
{item.emoji} {item.name} {item.location === 'equipped' && āš”ļø Equipped} {item.location === 'inventory' && šŸŽ’ Inventory}
šŸ”§ Durability:
{item.current_durability}/{item.max_durability}
{!item.needs_repair && (

āœ… At full durability

)} {item.needs_repair && ( <> {/* Tool requirements */} {item.tools && item.tools.length > 0 && (

šŸ”§ Required Tools:

{item.tools.map((tool: any, i: number) => (
{tool.emoji} {tool.name} (-{tool.durability_cost} durability) {tool.has_tool && ` [${tool.tool_durability} available]`} {!tool.has_tool && ' āŒ'}
))}
)} {/* Materials */}

Restores {item.repair_percentage}% durability

{item.materials.map((mat: any, i: number) => (
{mat.emoji} {mat.name} {mat.available}/{mat.quantity}
))}
)}
))}
)} {/* Uncraft Tab */} {workbenchTab === 'uncraft' && (
setUncraftFilter(e.target.value)} className="filter-input" />
{uncraftableItems.filter(item => item.name.toLowerCase().includes(uncraftFilter.toLowerCase()) ).length === 0 &&

No uncraftable items found

} {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 (
{item.emoji} {item.name}
{/* Unique item details */} {item.unique_item_data && (
{/* Durability bar */}
šŸ”§ Durability:
{item.unique_item_data.current_durability}/{item.unique_item_data.max_durability}
{/* Format stats nicely */} {item.unique_item_data.unique_stats && Object.keys(item.unique_item_data.unique_stats).length > 0 && (
{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 ( āš”ļø {displayName}: {displayValue} ) } else if (stat === 'damage_max') { return null // Skip, already shown with damage_min } else if (stat === 'armor') { return ( šŸ›”ļø {displayName}: {displayValue} ) } else { return ( {displayName}: {displayValue} ) } })}
)}
)} {/* Durability impact warning */} {durabilityRatio < 1.0 && (
āš ļø Item condition will reduce yield by {Math.round((1 - durabilityRatio) * 100)}%
)} {durabilityRatio < 0.1 && (
āŒ Item too damaged - will yield NO materials!
)} {/* Loss chance warning */} {item.loss_chance && (
āš ļø {Math.round(item.loss_chance * 100)}% chance to lose each material
)} {/* Yield materials with durability adjustment */} {adjustedYield && adjustedYield.length > 0 && (

ā™»ļø Expected yield:

{adjustedYield.map((mat: any, i: number) => (
{mat.emoji} {mat.name} {durabilityRatio < 1.0 && durabilityRatio >= 0.1 ? ( <> x{mat.quantity} {' → '} x{mat.adjusted_quantity} ) : durabilityRatio < 0.1 ? ( x0 ) : ( <>x{mat.quantity} )}
))} {durabilityRatio >= 0.1 && (

* Subject to {Math.round((item.loss_chance || 0.3) * 100)}% random loss per material

)}
)}
) })}
)}
)} {location.image_url && (
{location.name} (e.currentTarget.style.display = 'none')} />
)}

{location.description}

{message && (
setMessage('')}> {message}
)} {/* Location Messages Log */} {locationMessages.length > 0 && (

šŸ“œ Recent Activity

{locationMessages.slice(-10).reverse().map((msg, idx) => (
{msg.time} {msg.message}
))}
)} {/* NPCs, Items, and Entities on ground - below the location image */}
{/* Enemies */} {location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (

āš”ļø Enemies

{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
{enemy.id && (
{enemy.name} { e.currentTarget.style.display = 'none'; }} />
)}
{enemy.name}
{enemy.level &&
Lv. {enemy.level}
}
))}
)} {/* Corpses */} {location.corpses && location.corpses.length > 0 && (

šŸ’€ Corpses

{location.corpses.map((corpse: any) => (
{corpse.emoji} {corpse.name}
{corpse.loot_count} item(s)
{/* Expanded corpse details */} {expandedCorpse === corpse.id && corpseDetails && (

Lootable Items:

{corpseDetails.loot_items.map((item: any) => (
{item.emoji} {item.item_name}
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
{item.required_tool && (
šŸ”§ {item.required_tool_name} {item.has_tool ? 'āœ“' : 'āœ—'}
)}
))}
)}
))}
)} {/* NPCs */} {location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (

šŸ‘„ NPCs

{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
šŸ§‘
{npc.name}
{npc.level &&
Lv. {npc.level}
}
))}
)} {location.items.length > 0 && (

šŸ“¦ Items on Ground

{location.items.map((item: any, i: number) => (
{item.emoji || 'šŸ“¦'}
{item.name || 'Unknown Item'}
{item.quantity > 1 &&
Ɨ{item.quantity}
}
{item.description &&
{item.description}
} {item.weight !== undefined && item.weight > 0 && (
āš–ļø Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
)} {item.volume !== undefined && item.volume > 0 && (
šŸ“¦ Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
)} {item.hp_restore && item.hp_restore > 0 && (
ā¤ļø HP Restore: +{item.hp_restore}
)} {item.stamina_restore && item.stamina_restore > 0 && (
⚔ Stamina Restore: +{item.stamina_restore}
)} {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
āš”ļø Damage: {item.damage_min}-{item.damage_max}
)} {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
šŸ”§ Durability: {item.durability}/{item.max_durability}
)} {item.tier !== undefined && item.tier !== null && item.tier > 0 && (
⭐ Tier: {item.tier}
)}
{item.quantity === 1 ? ( ) : (
{item.quantity >= 5 && ( )} {item.quantity >= 10 && ( )}
)}
))}
)} {/* Other Players */} {location.other_players && location.other_players.length > 0 && (

šŸ‘„ Other Players

{location.other_players.map((player: any, i: number) => (
šŸ§
{player.username}
Lv. {player.level}
{player.level_diff !== undefined && (
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
)}
{player.can_pvp && ( )} {!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
Level difference too high
)} {!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
Area too safe for PvP
)}
))}
)}
)}
{/* Right Sidebar: Profile & Inventory */}
{/* Profile Stats */}

šŸ‘¤ Character

{/* Health & Stamina Bars */}
ā¤ļø HP {playerState.health}/{playerState.max_health}
{Math.round((playerState.health / playerState.max_health) * 100)}%
⚔ Stamina {playerState.stamina}/{playerState.max_stamina}
{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%
{/* Character Info */} {profile && (
Level: {profile.level}
{/* XP Progress Bar */}
⭐ XP {profile.xp} / {(profile.level * 100)}
{Math.round((profile.xp / (profile.level * 100)) * 100)}%
{profile.unspent_points > 0 && (
⭐ Unspent: {profile.unspent_points}
)}
šŸ’Ŗ STR: {profile.strength} {profile.unspent_points > 0 && ( )}
šŸƒ AGI: {profile.agility} {profile.unspent_points > 0 && ( )}
šŸ›”ļø END: {profile.endurance} {profile.unspent_points > 0 && ( )}
🧠 INT: {profile.intellect} {profile.unspent_points > 0 && ( )}
)}
{/* Equipment Display */}

āš”ļø Equipment

{/* Row 1: Head */}
{equipment.head ? ( <>
{equipment.head.emoji} {equipment.head.name} {equipment.head.durability && equipment.head.durability !== null && ( {equipment.head.durability}/{equipment.head.max_durability} )}
{equipment.head.description &&
{equipment.head.description}
} {equipment.head.stats && Object.keys(equipment.head.stats).length > 0 && ( <> {equipment.head.stats.armor && (
šŸ›”ļø Armor: +{equipment.head.stats.armor}
)} {equipment.head.stats.hp_max && (
ā¤ļø Max HP: +{equipment.head.stats.hp_max}
)} {equipment.head.stats.stamina_max && (
⚔ Max Stamina: +{equipment.head.stats.stamina_max}
)} )} {equipment.head.durability !== undefined && equipment.head.durability !== null && (
šŸ”§ Durability: {equipment.head.durability}/{equipment.head.max_durability}
)} {equipment.head.tier !== undefined && equipment.head.tier !== null && equipment.head.tier > 0 && (
⭐ Tier: {equipment.head.tier}
)}
) : ( <> šŸŖ– Head )}
{/* Row 2: Weapon, Torso, Backpack */}
{equipment.weapon ? ( <>
{equipment.weapon.emoji} {equipment.weapon.name} {equipment.weapon.durability && equipment.weapon.durability !== null && ( {equipment.weapon.durability}/{equipment.weapon.max_durability} )}
{equipment.weapon.description &&
{equipment.weapon.description}
} {equipment.weapon.stats && Object.keys(equipment.weapon.stats).length > 0 && (
āš”ļø Damage: {equipment.weapon.stats.damage_min}-{equipment.weapon.stats.damage_max}
)} {equipment.weapon.weapon_effects && Object.keys(equipment.weapon.weapon_effects).length > 0 && (
✨ Effects: {Object.entries(equipment.weapon.weapon_effects).map(([key, val]: [string, any]) => `${key} (${(val.chance * 100).toFixed(0)}%)`).join(', ')}
)} {equipment.weapon.durability !== undefined && equipment.weapon.durability !== null && (
šŸ”§ Durability: {equipment.weapon.durability}/{equipment.weapon.max_durability}
)} {equipment.weapon.tier !== undefined && equipment.weapon.tier !== null && equipment.weapon.tier > 0 && (
⭐ Tier: {equipment.weapon.tier}
)}
) : ( <> āš”ļø Weapon )}
{equipment.torso ? ( <>
{equipment.torso.emoji} {equipment.torso.name} {equipment.torso.durability && equipment.torso.durability !== null && ( {equipment.torso.durability}/{equipment.torso.max_durability} )}
{equipment.torso.description &&
{equipment.torso.description}
} {equipment.torso.stats && Object.keys(equipment.torso.stats).length > 0 && ( <> {equipment.torso.stats.armor && (
šŸ›”ļø Armor: +{equipment.torso.stats.armor}
)} {equipment.torso.stats.hp_max && (
ā¤ļø Max HP: +{equipment.torso.stats.hp_max}
)} {equipment.torso.stats.stamina_max && (
⚔ Max Stamina: +{equipment.torso.stats.stamina_max}
)} )} {equipment.torso.durability !== undefined && equipment.torso.durability !== null && (
šŸ”§ Durability: {equipment.torso.durability}/{equipment.torso.max_durability}
)} {equipment.torso.tier !== undefined && equipment.torso.tier !== null && equipment.torso.tier > 0 && (
⭐ Tier: {equipment.torso.tier}
)}
) : ( <> šŸ‘• Torso )}
{equipment.backpack ? ( <>
{equipment.backpack.emoji} {equipment.backpack.name} {equipment.backpack.durability && equipment.backpack.durability !== null && ( {equipment.backpack.durability}/{equipment.backpack.max_durability} )}
{equipment.backpack.description &&
{equipment.backpack.description}
} {equipment.backpack.stats && Object.keys(equipment.backpack.stats).length > 0 && ( <> {equipment.backpack.stats.weight_capacity && (
āš–ļø Weight: +{equipment.backpack.stats.weight_capacity}kg
)} {equipment.backpack.stats.volume_capacity && (
šŸ“¦ Volume: +{equipment.backpack.stats.volume_capacity}L
)} )} {equipment.backpack.durability !== undefined && equipment.backpack.durability !== null && (
šŸ”§ Durability: {equipment.backpack.durability}/{equipment.backpack.max_durability}
)} {equipment.backpack.tier !== undefined && equipment.backpack.tier !== null && equipment.backpack.tier > 0 && (
⭐ Tier: {equipment.backpack.tier}
)}
) : ( <> šŸŽ’ Backpack )}
{/* Row 3: Legs */}
{equipment.legs ? ( <>
{equipment.legs.emoji} {equipment.legs.name} {equipment.legs.durability && equipment.legs.durability !== null && ( {equipment.legs.durability}/{equipment.legs.max_durability} )}
{equipment.legs.description &&
{equipment.legs.description}
} {equipment.legs.stats && Object.keys(equipment.legs.stats).length > 0 && ( <> {equipment.legs.stats.armor && (
šŸ›”ļø Armor: +{equipment.legs.stats.armor}
)} {equipment.legs.stats.hp_max && (
ā¤ļø Max HP: +{equipment.legs.stats.hp_max}
)} {equipment.legs.stats.stamina_max && (
⚔ Max Stamina: +{equipment.legs.stats.stamina_max}
)} )} {equipment.legs.durability !== undefined && equipment.legs.durability !== null && (
šŸ”§ Durability: {equipment.legs.durability}/{equipment.legs.max_durability}
)} {equipment.legs.tier !== undefined && equipment.legs.tier !== null && equipment.legs.tier > 0 && (
⭐ Tier: {equipment.legs.tier}
)}
) : ( <> šŸ‘– Legs )}
{/* Row 4: Feet */}
{equipment.feet ? ( <>
{equipment.feet.emoji} {equipment.feet.name} {equipment.feet.durability && equipment.feet.durability !== null && ( {equipment.feet.durability}/{equipment.feet.max_durability} )}
{equipment.feet.description &&
{equipment.feet.description}
} {equipment.feet.stats && Object.keys(equipment.feet.stats).length > 0 && ( <> {equipment.feet.stats.armor && (
šŸ›”ļø Armor: +{equipment.feet.stats.armor}
)} {equipment.feet.stats.hp_max && (
ā¤ļø Max HP: +{equipment.feet.stats.hp_max}
)} {equipment.feet.stats.stamina_max && (
⚔ Max Stamina: +{equipment.feet.stats.stamina_max}
)} )} {equipment.feet.durability !== undefined && equipment.feet.durability !== null && (
šŸ”§ Durability: {equipment.feet.durability}/{equipment.feet.max_durability}
)} {equipment.feet.tier !== undefined && equipment.feet.tier !== null && equipment.feet.tier > 0 && (
⭐ Tier: {equipment.feet.tier}
)}
) : ( <> šŸ‘Ÿ Feet )}
{/* Enhanced Inventory */}

šŸŽ’ Inventory

{/* Weight and Volume Bars */}
āš–ļø Weight {profile?.current_weight || 0}/{profile?.max_weight || 100}
{Math.round(Math.min(((profile?.current_weight || 0) / (profile?.max_weight || 100)) * 100, 100))}%
šŸ“¦ Volume {profile?.current_volume || 0}/{profile?.max_volume || 100}
{Math.round(Math.min(((profile?.current_volume || 0) / (profile?.max_volume || 100)) * 100, 100))}%
{/* Inventory Items - Grouped by Category */}
{playerState.inventory.filter((item: any) => !item.is_equipped).length === 0 ? (

No items

) : ( 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 (
{ const newSet = new Set(collapsedCategories) if (isCollapsed) { newSet.delete(category) } else { newSet.add(category) } setCollapsedCategories(newSet) }} > {isCollapsed ? 'ā–¶' : 'ā–¼'} {category === 'weapon' ? 'āš”ļø Weapons' : category === 'armor' ? 'šŸ›”ļø Armor' : category === 'consumable' ? 'šŸ– Consumables' : category === 'resource' ? 'šŸ“¦ Resources' : category === 'quest' ? 'šŸ“œ Quest Items' : `šŸ“¦ ${category.charAt(0).toUpperCase() + category.slice(1)}`} ({sortedItems.length})
{!isCollapsed && sortedItems.map((item: any, i: number) => (
{item.emoji || 'šŸ“¦'}
{item.name} {item.quantity > 1 && Ɨ{item.quantity}} {item.hp_restore > 0 && +{item.hp_restore}ā¤ļø} {item.stamina_restore > 0 && +{item.stamina_restore}⚔}
{/* Hover tooltip */}
{item.description &&
{item.description}
} {item.weight !== undefined && item.weight > 0 && (
āš–ļø Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
)} {item.volume !== undefined && item.volume > 0 && (
šŸ“¦ Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
)} {/* Equipment stats */} {item.stats && item.stats.weight_capacity && (
āš–ļø Weight Capacity: +{item.stats.weight_capacity}kg
)} {item.stats && item.stats.volume_capacity && (
šŸ“¦ Volume Capacity: +{item.stats.volume_capacity}L
)} {item.stats && item.stats.armor && (
šŸ›”ļø Armor: +{item.stats.armor}
)} {item.stats && item.stats.hp_max && (
ā¤ļø Max HP: +{item.stats.hp_max}
)} {item.stats && item.stats.stamina_max && (
⚔ Max Stamina: +{item.stats.stamina_max}
)} {item.hp_restore && item.hp_restore > 0 && (
ā¤ļø HP Restore: +{item.hp_restore}
)} {item.stamina_restore && item.stamina_restore > 0 && (
⚔ Stamina Restore: +{item.stamina_restore}
)} {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
āš”ļø Damage: {item.damage_min}-{item.damage_max}
)} {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
šŸ”§ Durability: {item.durability}/{item.max_durability}
)} {item.tier !== undefined && item.tier !== null && item.tier > 0 && (
⭐ Tier: {item.tier}
)}
{item.consumable && ( )} {item.equippable && !item.is_equipped && ( )} {item.quantity === 1 ? ( ) : (
{item.quantity >= 5 && ( )} {item.quantity >= 10 && ( )}
)}
))}
) }) )}
{/* Item Actions Panel */} {selectedItem && (
{selectedItem.name}
{selectedItem.description && (

{selectedItem.description}

)}
{selectedItem.usable && ( )} {selectedItem.equippable && !selectedItem.is_equipped && ( )} {selectedItem.is_equipped && ( )}
)}
) return (
{/* Death Overlay */} {profile?.is_dead && (

šŸ’€ You Have Died

Your character has been defeated in combat.

All your items have been placed in a corpse at your death location.

You can retrieve them with another character before they decay (24 hours).

)} {/* Mobile Header Toggle - only show in main view */} {mobileMenuOpen === 'none' && ( )}
{renderExploreTab()} {/* Mobile Tab Navigation */}
{/* Mobile Menu Overlays */} {mobileMenuOpen !== 'none' && (
setMobileMenuOpen('none')} /> )}
) } export default Game