Files
echoes-of-the-ash/pwa/src/components/game/hooks/useGameEngine.ts

1143 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string>
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<string, number>
forceUpdate: number
}
export interface GameEngineActions {
// Data fetching
fetchGameData: (skipCombatLogInit?: boolean) => Promise<void>
fetchLocationData: () => Promise<void>
fetchPlayerState: () => Promise<void>
// Movement
handleMove: (direction: string) => Promise<void>
// Items
handlePickup: (itemId: number, quantity?: number) => Promise<void>
handleUseItem: (itemId: string) => Promise<void>
handleEquipItem: (inventoryId: number) => Promise<void>
handleUnequipItem: (slot: string) => Promise<void>
handleDropItem: (itemId: string, quantity?: number) => Promise<void>
// Crafting/Workbench
handleOpenCrafting: () => Promise<void>
handleCloseCrafting: () => void
handleCraft: (itemId: string) => Promise<void>
handleOpenRepair: () => Promise<void>
handleRepairFromMenu: (uniqueItemId: number, inventoryId?: number) => Promise<void>
handleUncraft: (uniqueItemId: number, inventoryId: number) => Promise<void>
handleSwitchWorkbenchTab: (tab: WorkbenchTab) => Promise<void>
// Combat
handleInitiateCombat: (enemyId: number) => Promise<void>
handleCombatAction: (action: string) => Promise<any>
handleExitCombat: () => void
handleExitPvPCombat: () => Promise<void>
handleInitiatePvP: (targetPlayerId: number) => Promise<void>
handlePvPAction: (action: string, targetId: number) => Promise<void>
handlePvPAcknowledge: () => Promise<void>
handleFlee: () => Promise<void>
addCombatLogEntry: (entry: CombatLogEntry) => void
// Interactions
handleInteract: (interactableId: string, actionId: string) => Promise<void>
handleViewCorpseDetails: (corpseId: string) => Promise<void>
handleCloseCorpseDetails: () => void
handleLootCorpse: (corpseId: string) => Promise<void>
handleLootCorpseItem: (corpseId: string, itemIndex: number | null) => Promise<void>
// Stats
handleSpendPoint: (stat: string) => Promise<void>
// 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<void>
refreshCombat: () => Promise<void>
updatePlayerState: (playerData: any) => void
updateCombatState: (combatData: any) => void
updateCooldowns: (cooldowns: Record<string, number>) => 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<void>
): [GameEngineState, GameEngineActions] {
// All state declarations
const [playerState, setPlayerState] = useState<PlayerState | null>(null)
const [location, setLocation] = useState<Location | null>(null)
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const [selectedItem, setSelectedItem] = useState<any>(null)
const [combatState, setCombatState] = useState<CombatState | null>(null)
const [combatLog, setCombatLog] = useState<CombatLogEntry[]>([])
const [enemyName, setEnemyName] = useState<string>('')
const [enemyImage, setEnemyImage] = useState<string>('')
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const [expandedCorpse, setExpandedCorpse] = useState<string | null>(null)
const [corpseDetails, setCorpseDetails] = useState<any>(null)
const [movementCooldown, setMovementCooldown] = useState<number>(0)
// const [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('') // Moved to Combat.tsx
const [equipment, setEquipment] = useState<Equipment>({})
const [showCraftingMenu, setShowCraftingMenu] = useState<boolean>(false)
const [showRepairMenu, setShowRepairMenu] = useState<boolean>(false)
const [craftableItems, setCraftableItems] = useState<any[]>([])
const [repairableItems, setRepairableItems] = useState<any[]>([])
const [workbenchTab, setWorkbenchTab] = useState<WorkbenchTab>('craft')
const [craftFilter, setCraftFilter] = useState<string>('')
const [craftCategoryFilter, setCraftCategoryFilter] = useState<string>('all')
const [repairFilter, setRepairFilter] = useState<string>('')
const [uncraftFilter, setUncraftFilter] = useState<string>('')
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
const [inventoryFilter, setInventoryFilter] = useState<string>('')
const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all')
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
const [mobileHeaderOpen, setMobileHeaderOpen] = useState<boolean>(false)
const [locationMessages, setLocationMessages] = useState<LocationMessage[]>([])
const [interactableCooldowns, setInteractableCooldowns] = useState<Record<string, number>>({})
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
const [_forceUpdate, _setForceUpdate] = useState(0)
// @ts-ignore - WebSocket state is set in useEffect
const [webSocket, setWebSocket] = useState<WebSocket | null>(null)
// Refs
const lastSeenPvPActionRef = useRef<string | null>(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<string, number> = {}
for (const interactable of locationRes.data.interactables) {
if (interactable.actions) {
for (const action of interactable.actions) {
if (action.on_cooldown && action.cooldown_remaining > 0) {
const cooldownKey = `${interactable.instance_id}:${action.id}`
cooldowns[cooldownKey] = Date.now() / 1000 + action.cooldown_remaining
}
}
}
}
setInteractableCooldowns((prev: Record<string, number>) => ({ ...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<string, number>) => {
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<string>) => {
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<typeof setInterval> | 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]
}