1194 lines
38 KiB
TypeScript
1194 lines
38 KiB
TypeScript
// 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
|
||
updateStatusEffect: (effectName: string | any, remainingTicks: number) => 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: gameState.player.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: gameState.player.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())
|
||
}
|
||
|
||
const updateStatusEffect = useCallback((effectName: string | any, remainingTicks: number) => {
|
||
setPlayerState((prev: PlayerState | null) => {
|
||
if (!prev) return null
|
||
|
||
if (!prev) return null
|
||
const target = typeof effectName === 'object'
|
||
? (effectName.en || Object.values(effectName)[0])
|
||
: effectName
|
||
|
||
if (remainingTicks <= 0) {
|
||
return {
|
||
...prev,
|
||
status_effects: prev.status_effects.filter(e => {
|
||
const current = typeof e.effect_name === 'object'
|
||
? (e.effect_name.en || Object.values(e.effect_name)[0])
|
||
: e.effect_name
|
||
return current !== target
|
||
})
|
||
}
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
status_effects: prev.status_effects.map(e => {
|
||
const current = typeof e.effect_name === 'object'
|
||
? (e.effect_name.en || Object.values(e.effect_name)[0])
|
||
: e.effect_name
|
||
if (current === target) {
|
||
return { ...e, ticks_remaining: remainingTicks }
|
||
}
|
||
return e
|
||
})
|
||
}
|
||
})
|
||
}, [])
|
||
|
||
// 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 {
|
||
let payload: any = { action }
|
||
if (action.includes(':')) {
|
||
const [act, itemId] = action.split(':')
|
||
payload = { action: act, item_id: itemId }
|
||
}
|
||
|
||
const response = await api.post('/api/game/combat/action', payload)
|
||
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 {
|
||
let payload: any = { action }
|
||
if (action.includes(':')) {
|
||
const [act, itemId] = action.split(':')
|
||
payload = { action: act, item_id: itemId }
|
||
}
|
||
|
||
const response = await api.post('/api/game/pvp/action', payload)
|
||
setMessage(response.data.message || 'Action performed!')
|
||
await fetchGameData()
|
||
return response.data // Return data so caller can use it
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'PvP action failed')
|
||
throw error // Re-throw so caller knows it 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
|
||
})
|
||
},
|
||
updateStatusEffect
|
||
}
|
||
|
||
// 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]
|
||
}
|