// useGameEngine - Core game state and logic hook import { useState, useEffect, useRef, useCallback } from 'react' import api from '../../../services/api' import type { PlayerState, Location, Profile, CombatState, CombatLogEntry, LocationMessage, Equipment, WorkbenchTab, MobileMenuState } from '../types' export interface GameEngineState { // Core state playerState: PlayerState | null location: Location | null profile: Profile | null loading: boolean message: string // Combat state combatState: CombatState | null combatLog: CombatLogEntry[] enemyName: string enemyImage: string enemyTurnMessage: string // UI state selectedItem: any collapsedCategories: Set expandedCorpse: string | null corpseDetails: any movementCooldown: number equipment: Equipment // Workbench state showCraftingMenu: boolean showRepairMenu: boolean craftableItems: any[] repairableItems: any[] uncraftableItems: any[] workbenchTab: WorkbenchTab craftFilter: string craftCategoryFilter: string repairFilter: string uncraftFilter: string inventoryFilter: string inventoryCategoryFilter: string // PvP state lastSeenPvPAction: string | null pvpTimeRemaining: number | null // Mobile UI state mobileMenuOpen: MobileMenuState mobileHeaderOpen: boolean // Location messages locationMessages: LocationMessage[] // Interactable cooldowns interactableCooldowns: Record forceUpdate: number } export interface GameEngineActions { // Data fetching fetchGameData: (skipCombatLogInit?: boolean) => Promise fetchLocationData: () => Promise fetchPlayerState: () => Promise // Movement handleMove: (direction: string) => Promise // Items handlePickup: (itemId: number, quantity?: number) => Promise handleUseItem: (itemId: string) => Promise handleEquipItem: (inventoryId: number) => Promise handleUnequipItem: (slot: string) => Promise handleDropItem: (itemId: string, quantity?: number) => Promise // Crafting/Workbench handleOpenCrafting: () => Promise handleCloseCrafting: () => void handleCraft: (itemId: string) => Promise handleOpenRepair: () => Promise handleRepairFromMenu: (uniqueItemId: number, inventoryId?: number) => Promise handleUncraft: (uniqueItemId: number, inventoryId: number) => Promise handleSwitchWorkbenchTab: (tab: WorkbenchTab) => Promise // Combat handleInitiateCombat: (enemyId: number) => Promise handleCombatAction: (action: string) => Promise handleExitCombat: () => void handleExitPvPCombat: () => Promise handleInitiatePvP: (targetPlayerId: number) => Promise handlePvPAction: (action: string, targetId: number) => Promise handlePvPAcknowledge: () => Promise handleFlee: () => Promise addCombatLogEntry: (entry: CombatLogEntry) => void // Interactions handleInteract: (interactableId: string, actionId: string) => Promise handleViewCorpseDetails: (corpseId: string) => Promise handleCloseCorpseDetails: () => void handleLootCorpse: (corpseId: string) => Promise handleLootCorpseItem: (corpseId: string, itemIndex: number | null) => Promise // Stats handleSpendPoint: (stat: string) => Promise // UI helpers addLocationMessage: (msg: string) => void setMessage: (msg: string) => void setSelectedItem: (item: any) => void setMobileMenuOpen: (state: MobileMenuState) => void setMobileHeaderOpen: (open: boolean) => void setCraftFilter: (filter: string) => void setCraftCategoryFilter: (filter: string) => void setRepairFilter: (filter: string) => void setUncraftFilter: (filter: string) => void setInventoryFilter: (filter: string) => void setInventoryCategoryFilter: (filter: string) => void toggleCategoryCollapse: (category: string) => void // WebSocket helpers refreshLocation: () => Promise refreshCombat: () => Promise updatePlayerState: (playerData: any) => void updateCombatState: (combatData: any) => void updateCooldowns: (cooldowns: Record) => void addPlayerToLocation: (player: any) => void removePlayerFromLocation: (playerId: number) => void addNPCToLocation: (npc: any) => void removeNPCFromLocation: (enemyId: string) => void } export function useGameEngine( token: string | null, _handleWebSocketMessage: (message: any) => Promise ): [GameEngineState, GameEngineActions] { // All state declarations 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('') // Moved to Combat.tsx 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') const [craftFilter, setCraftFilter] = useState('') const [craftCategoryFilter, setCraftCategoryFilter] = useState('all') const [repairFilter, setRepairFilter] = useState('') const [uncraftFilter, setUncraftFilter] = useState('') const [uncraftableItems, setUncraftableItems] = useState([]) const [inventoryFilter, setInventoryFilter] = useState('') const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState('all') const [lastSeenPvPAction, setLastSeenPvPAction] = useState(null) const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState(null) const [mobileMenuOpen, setMobileMenuOpen] = useState('none') const [mobileHeaderOpen, setMobileHeaderOpen] = useState(false) const [locationMessages, setLocationMessages] = useState([]) const [interactableCooldowns, setInteractableCooldowns] = useState>({}) const [loadedTabs, setLoadedTabs] = useState>(new Set()) const [_forceUpdate, _setForceUpdate] = useState(0) // @ts-ignore - WebSocket state is set in useEffect const [webSocket, setWebSocket] = useState(null) // Refs const lastSeenPvPActionRef = useRef(null) // Movement cooldown countdown effect useEffect(() => { if (movementCooldown > 0) { const timer = setInterval(() => { setMovementCooldown((prev: number) => { const newVal = prev - 1 return newVal > 0 ? newVal : 0 }) }, 1000) return () => clearInterval(timer) } }, [movementCooldown]) // Interactable cooldown live countdown re-render useEffect(() => { if (Object.keys(interactableCooldowns).length > 0) { const timer = setInterval(() => { _setForceUpdate(Date.now()) }, 1000) return () => clearInterval(timer) } }, [Object.keys(interactableCooldowns).length]) // Helper function to add messages to location log const addLocationMessage = useCallback((msg: string) => { const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setLocationMessages((prev: LocationMessage[]) => [...prev, { time: timeStr, message: msg }]) setMessage(msg) }, []) const addCombatLogEntry = useCallback((entry: CombatLogEntry) => { setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev]) }, []) // Fetch functions const fetchLocationData = useCallback(async () => { try { const locationRes = await api.get('/api/game/location') setLocation(locationRes.data) } catch (err) { console.error('Failed to fetch location:', err) } }, []) const fetchPlayerState = useCallback(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 || {}) 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 = useCallback(async (skipCombatLogInit: boolean = false) => { 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') ]) 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 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 } } } } setInteractableCooldowns((prev: Record) => ({ ...prev, ...cooldowns })) } if (gameState.player.movement_cooldown !== undefined) { const cooldown = gameState.player.movement_cooldown setMovementCooldown(cooldown > 0 ? Math.ceil(cooldown) + 1 : 0) } // Handle PvP combat if (pvpRes.data.in_pvp_combat) { const newCombatState = { ...pvpRes.data, is_pvp: true } setCombatState(newCombatState) if (pvpRes.data.pvp_combat.last_action && pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) { 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' }) const [lastAction] = pvpRes.data.pvp_combat.last_action.split('|') const yourUsername = pvpRes.data.pvp_combat.is_attacker ? pvpRes.data.pvp_combat.attacker.username : pvpRes.data.pvp_combat.defender.username const isYourAction = lastAction.startsWith(yourUsername + ' ') setCombatLog((prev: any) => [{ time: timeStr, message: lastAction, isPlayer: isYourAction }, ...prev]) } 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([{ id: 'pvp-combat-init', time: timeStr, message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`, isPlayer: true }]) } } else if (lastSeenPvPAction !== null) { setLastSeenPvPAction(null) lastSeenPvPActionRef.current = null } else if (combatRes.data.in_combat) { setCombatState(combatRes.data) if (!skipCombatLogInit && combatLog.length === 0) { const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setCombatLog([{ id: 'combat-in-progress', 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) } }, [combatLog.length, lastSeenPvPAction]) // Movement handler - placeholder, needs full implementation const handleMove = useCallback(async (direction: string) => { if (combatState) { setMessage('Cannot move while in combat!') return } if (showCraftingMenu || showRepairMenu) { setShowCraftingMenu(false) setShowRepairMenu(false) } setMobileMenuOpen('none') try { setMessage('Moving...') const response = await api.post('/api/game/move', { direction }) setMessage(response.data.message) setLocationMessages([]) if (response.data.encounter && response.data.encounter.triggered) { const encounter = response.data.encounter setMessage(encounter.message) setEnemyName(encounter.combat.npc_name) setEnemyImage(encounter.combat.npc_image) setCombatState({ in_combat: true, combat_over: false, player_won: false, combat: encounter.combat }) setCombatLog([]) const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setCombatLog([{ id: Date.now() + Math.random(), time: timeStr, message: { type: 'combat_start', data: { npc_name: encounter.combat.npc_name } }, isPlayer: false }]) await fetchGameData(true) } else { await fetchGameData() } } catch (error: any) { setMessage(error.response?.data?.detail || 'Move failed') } }, [combatState, showCraftingMenu, showRepairMenu, fetchGameData]) // Simplified placeholder handlers // (Full implementations would be moved from Game.tsx) const handlePickup = async (itemId: number, quantity: number = 1) => { try { const response = await api.post('/api/game/pickup', { item_id: itemId, quantity }) addLocationMessage(response.data.message || 'Item picked up!') fetchGameData() } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to pick up item') fetchGameData() } } const handleOpenCrafting = async () => { try { const response = await api.get('/api/game/craftable') setCraftableItems(response.data.craftable_items) setShowCraftingMenu(true) setShowRepairMenu(false) setWorkbenchTab('craft') setLoadedTabs(new Set(['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('') setLoadedTabs(new Set()) } // State object const state: GameEngineState = { playerState, location, profile, loading, message, combatState, combatLog, enemyName, enemyImage, enemyTurnMessage: '', // Placeholder as it's now handled locally in Combat.tsx selectedItem, collapsedCategories, expandedCorpse, corpseDetails, movementCooldown, equipment, showCraftingMenu, showRepairMenu, craftableItems, repairableItems, uncraftableItems, workbenchTab, craftFilter, craftCategoryFilter, repairFilter, uncraftFilter, inventoryFilter, inventoryCategoryFilter, lastSeenPvPAction, pvpTimeRemaining: _pvpTimeRemaining, mobileMenuOpen, mobileHeaderOpen, locationMessages, interactableCooldowns, forceUpdate: _forceUpdate } 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 (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 parsedMessages = messages.map((msg: string) => { try { if (msg.trim().startsWith('{')) { const parsed = JSON.parse(msg) if (parsed.type && parsed.data) return parsed } } catch (e) { } return msg }) const newEntries = parsedMessages.map((msg: any) => ({ id: `item-use-${Date.now()}-${Math.random()}`, time: timeStr, message: msg, isPlayer: typeof msg === 'object' ? msg.type !== 'enemy_attack' && msg.type !== 'flee_fail' : !msg.includes('attacks') && !msg.includes('hits') })) setCombatLog((prev: any) => [...newEntries, ...prev]) 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 refreshWorkbenchData = async () => { // Always fetch game data (inventory, stats) await fetchGameData() // Refresh the current tab's data if (workbenchTab === 'craft') { const res = await api.get('/api/game/craftable') setCraftableItems(res.data.craftable_items) } else if (workbenchTab === 'repair') { const res = await api.get('/api/game/repairable') setRepairableItems(res.data.repairable_items) } else if (workbenchTab === 'uncraft') { const res = await api.get('/api/game/salvageable') setUncraftableItems(res.data.salvageable_items) } // Invalidate other tabs so they refresh when visited setLoadedTabs(new Set([workbenchTab])) } 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 refreshWorkbenchData() } 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') setLoadedTabs(new Set(['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 refreshWorkbenchData() } 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 refreshWorkbenchData() } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to uncraft item') } } const handleSwitchWorkbenchTab = async (tab: 'craft' | 'repair' | 'uncraft') => { setWorkbenchTab(tab) if (loadedTabs.has(tab)) { return } 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) } setLoadedTabs(prev => new Set(prev).add(tab)) } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to load items') } } const handleInitiateCombat = async (enemyId: number) => { try { setMobileMenuOpen('none') const response = await api.post('/api/game/combat/initiate', { enemy_id: enemyId }) // Properly structure combat state with in_combat flag and nested combat object setCombatState({ in_combat: true, combat_over: false, player_won: false, combat: response.data.combat }) setEnemyName(response.data.combat.npc_name) setEnemyImage(response.data.combat.npc_image) const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setCombatLog([{ id: Date.now() + Math.random(), time: timeStr, message: { type: 'combat_start', data: { npc_name: response.data.combat.npc_name } }, isPlayer: true }]) 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 { // setEnemyTurnMessage('Processing...') // Handled by Combat.tsx now const response = await api.post('/api/game/combat/action', { action }) return response.data } catch (error: any) { setMessage(error.response?.data?.detail || 'Combat action failed') throw error } } const handleExitCombat = () => { setCombatState(null) setCombatLog([]) setEnemyName('') setEnemyImage('') fetchGameData() } 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 fetchGameData() } const handlePvPAction = async (action: string, _targetId: number) => { try { const response = await api.post('/api/game/pvp/action', { action }) setMessage(response.data.message || 'Action performed!') await fetchGameData() } catch (error: any) { setMessage(error.response?.data?.detail || 'PvP action failed') } } const handlePvPAcknowledge = async () => { if (combatState?.pvp_combat?.id) { try { await api.post('/api/game/pvp/acknowledge', { combat_id: combatState.pvp_combat.id }) await handleExitPvPCombat() } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to acknowledge') } } } const handleFlee = async () => { await handleCombatAction('flee') } const handleInteract = async (interactableId: string, actionId: string) => { if (combatState) { setMessage('Cannot interact with objects while in combat!') return } 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) { 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() } 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) } 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 }) setMessage(response.data.message) setTimeout(() => { }, 5000) if (response.data.corpse_empty) { setExpandedCorpse(null) setCorpseDetails(null) } else if (expandedCorpse === corpseId) { try { const detailsResponse = await api.get(`/api/game/corpse/${corpseId}`) setCorpseDetails(detailsResponse.data) } catch (err) { setExpandedCorpse(null) setCorpseDetails(null) } } fetchGameData() } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to loot corpse') } } const handleLootCorpse = async (corpseId: string) => { handleViewCorpseDetails(corpseId) } 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() } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to spend point') } } 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() } catch (error: any) { setMessage(error.response?.data?.detail || 'Failed to initiate PvP') } } // WebSocket helper functions - for updating state directly from WebSocket messages const refreshLocation = async () => { try { await fetchLocationData() } catch (error) { console.error('Failed to refresh location:', error) } } const refreshCombat = async () => { try { const combatRes = await api.get('/api/game/combat') if (combatRes.data.in_combat) { // Set combat state with proper structure setCombatState({ in_combat: true, combat_over: false, combat: combatRes.data.combat }) // Update enemy name/image state if (combatRes.data.combat?.npc_name) { setEnemyName(combatRes.data.combat.npc_name) } if (combatRes.data.combat?.npc_image) { setEnemyImage(combatRes.data.combat.npc_image) } } else { setCombatState(null) setEnemyName('') setEnemyImage('') } } catch (error) { console.error('Failed to refresh combat:', error) } } const updatePlayerState = (playerData: any) => { // Map API field names to playerState field names const mappedData: any = {} // HP updates are now controlled by Combat.tsx - it calls updatePlayerState at the right time if (playerData.hp !== undefined) { mappedData.health = playerData.hp } if (playerData.max_hp !== undefined) { mappedData.max_health = playerData.max_hp } if (playerData.stamina !== undefined) { mappedData.stamina = playerData.stamina } if (playerData.max_stamina !== undefined) { mappedData.max_stamina = playerData.max_stamina } // Update playerState with mapped fields if (Object.keys(mappedData).length > 0) { setPlayerState((prev: any) => prev ? { ...prev, ...mappedData } : null) } // Also update profile for consistency if (playerData.hp !== undefined && profile) { setProfile((prev: any) => prev ? { ...prev, hp: playerData.hp } : null) } if (playerData.xp !== undefined && profile) { setProfile((prev: any) => prev ? { ...prev, xp: playerData.xp } : null) } if (playerData.level !== undefined && profile) { setProfile((prev: any) => prev ? { ...prev, level: playerData.level } : null) } } const updateCombatState = (combatData: any) => { setCombatState((prev: any) => { // If we have no previous state, but we're receiving combat data, initialize it if (!prev) { if (combatData.in_combat || combatData.pvp_combat) { return { in_combat: true, combat_over: false, ...combatData, combat: combatData.combat || (combatData.pvp_combat ? null : {}) } } return null } // Preserve enemy name/image when updating combat state return { ...prev, ...combatData, combat: combatData.combat ? { ...combatData.combat, npc_name: enemyName || combatData.combat.npc_name, npc_image: enemyImage || combatData.combat.npc_image } : prev.combat } }) } const updateCooldowns = (cooldowns: Record) => { setInteractableCooldowns((prev: any) => ({ ...prev, ...cooldowns })) } const addPlayerToLocation = (player: any) => { setLocation((prev: any) => { if (!prev) return prev // Check if player already exists const playerExists = prev.other_players?.some((p: any) => p.id === player.id) if (playerExists) return prev return { ...prev, other_players: [...(prev.other_players || []), player] } }) } const removePlayerFromLocation = (playerId: number) => { setLocation((prev: any) => { if (!prev) return prev return { ...prev, other_players: (prev.other_players || []).filter((p: any) => p.id !== playerId) } }) } const addNPCToLocation = (npc: any) => { setLocation((prev: any) => { if (!prev) return prev return { ...prev, npcs: [...(prev.npcs || []), npc] } }) } const removeNPCFromLocation = (enemyId: string) => { setLocation((prev: any) => { if (!prev) return prev return { ...prev, npcs: (prev.npcs || []).filter((npc: any) => !(npc.type === 'enemy' && npc.is_wandering && npc.id === enemyId) ) } }) } // Actions object const actions: GameEngineActions = { fetchGameData, fetchLocationData, fetchPlayerState, handleMove, handlePickup, handleUseItem, handleEquipItem, handleUnequipItem, handleDropItem, handleOpenCrafting, handleCloseCrafting, handleCraft, handleOpenRepair, handleRepairFromMenu, handleUncraft, handleSwitchWorkbenchTab, handleInitiateCombat, handleCombatAction, handleExitCombat, handleExitPvPCombat, handleInitiatePvP, handlePvPAction, handlePvPAcknowledge, handleFlee, handleInteract, handleViewCorpseDetails, handleCloseCorpseDetails: () => { setExpandedCorpse(null) setCorpseDetails(null) }, handleLootCorpse, handleLootCorpseItem, handleSpendPoint, addLocationMessage, setMessage, setSelectedItem, setMobileMenuOpen, setMobileHeaderOpen, setCraftFilter, setCraftCategoryFilter, setRepairFilter, setUncraftFilter, setInventoryFilter, setInventoryCategoryFilter, // WebSocket helper functions refreshLocation, refreshCombat, updatePlayerState, updateCombatState, updateCooldowns, addPlayerToLocation, removePlayerFromLocation, addNPCToLocation, removeNPCFromLocation, addCombatLogEntry, toggleCategoryCollapse: (category: string) => { setCollapsedCategories((prev: Set) => { const newSet = new Set(prev) if (newSet.has(category)) { newSet.delete(category) } else { newSet.add(category) } return newSet }) } } // Polling fallback for PvP Combat reliability // Polling fallback for PvP Combat reliability // optimized: poll less frequently (15s) and rely on WS reconnect event useEffect(() => { // 1. Listen for WebSocket reconnection to fetch immediately const handleReconnect = () => { console.log("[PvP] WebSocket reconnected, fetching fresh state..."); fetchGameData(true); }; window.addEventListener('game-ws-connected', handleReconnect); // 2. Slow polling as safety net let interval: ReturnType | null = null; if (combatState?.is_pvp && !combatState?.combat_over) { interval = setInterval(() => { fetchGameData(true); }, 15000); // Poll every 15s instead of 3s } return () => { window.removeEventListener('game-ws-connected', handleReconnect); if (interval) clearInterval(interval); }; }, [combatState?.is_pvp, combatState?.combat_over, fetchGameData]); // Initial data load useEffect(() => { if (token) { fetchGameData() } }, [token]) // WebSocket Event Bus Listener // Instead of maintaining a second connection, we listen to the global connection managed by GameHeader useEffect(() => { const handleGameMessage = (event: Event) => { const customEvent = event as CustomEvent; if (customEvent.detail) { _handleWebSocketMessage(customEvent.detail); } }; window.addEventListener('game-ws-message', handleGameMessage); return () => { window.removeEventListener('game-ws-message', handleGameMessage); }; }, [_handleWebSocketMessage]); return [state, actions] }